Files
zooqueen 1c56d091d6 ci: pack the release tarballs one way, with bun
The verifier packed with pnpm while the publish job packed with bun, so the
tarball that was inspected was not the tarball that shipped -- and the runner
carries no pnpm, which is where the publish run stopped.

The two packers do not agree. pnpm applies the publishConfig entrypoints; bun
does not, and eleven packages name their built files there. Packed by bun
alone they would publish an `exports` map pointing at TypeScript sources that
`files` does not carry.

scripts/pack.mjs is now the one packer: bun rewrites the workspace specifiers
to the concrete version, the publishConfig entrypoints are applied on top, and
the manifest on disk is put back byte for byte. The verifier and the publish
job both call it, so what is verified is what is uploaded.
2026-08-13 21:01:24 -07:00

159 lines
6.7 KiB
YAML

name: publish
# Publishes the workspace packages whose version npmjs does not serve yet.
#
# There is ONE question here — "which packages does the registry not have at
# this tree's version?" — and the plan step asks the registry itself. A paths
# filter would guess the same answer from the shape of a diff and guess wrong
# in both directions, so there is none: the job is idempotent and a push that
# changes nothing publishes nothing.
#
# The tarball is packed by scripts/pack.mjs and uploaded by npm. That split is
# deliberate. Every package depends on its siblings through `workspace:`
# specifiers, which npm cannot resolve; the packer builds on bun, which rewrites
# them to the concrete version, and applies the publishConfig entrypoints on top.
# npm then does the half it is good at — registry auth and upload.
#
# verify:packed-manifests calls that same packer, so the tarball inspected in
# the build step is the tarball this job uploads.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
publish:
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
with:
# validate-release-channel resolves the channel from the branches
# that contain HEAD, so it needs the remote refs.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org/
# The runner image carries no bun, and this repo is bun-native.
- name: Install bun
run: |
command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
- name: Plan
id: plan
run: |
node --input-type=module -e '
import { appendFileSync, readdirSync, readFileSync } from "node:fs";
import { expectedDistTag } from "./scripts/validate-release-channel.mjs";
const pending = [];
let version = "";
for (const dir of readdirSync("packages")) {
let pkg;
try {
pkg = JSON.parse(readFileSync(`packages/${dir}/package.json`, "utf8"));
} catch {
continue;
}
if (pkg.private || !pkg.name || !pkg.version) continue;
version = pkg.version;
const url = `https://registry.npmjs.org/${pkg.name.replace("/", "%2f")}/${pkg.version}`;
const res = await fetch(url);
if (res.status === 404) {
pending.push(dir);
console.log(`publish ${pkg.name}@${pkg.version}`);
} else if (res.ok) {
console.log(`skip ${pkg.name}@${pkg.version} (already on the registry)`);
} else {
console.error(`::error::registry lookup for ${pkg.name}@${pkg.version} answered ${res.status}`);
process.exit(1);
}
}
const out = process.env.GITHUB_OUTPUT;
appendFileSync(out, `pending=${pending.join(" ")}\n`);
appendFileSync(out, `version=${version}\n`);
appendFileSync(out, `dist_tag=${expectedDistTag(version)}\n`);
'
# A prerelease version must not land on `latest`, and a stable one must
# come from a branch that is allowed to cut it.
- name: Validate release channel
if: steps.plan.outputs.pending != ''
env:
VERSION: ${{ steps.plan.outputs.version }}
DIST_TAG: ${{ steps.plan.outputs.dist_tag }}
EVENT_NAME: ${{ github.event_name }}
run: node scripts/validate-release-channel.mjs
# dist/ is gitignored, so publishing without a build ships empty packages.
# verify:packed-manifests then checks each tarball carries the entrypoints
# its manifest promises.
- name: Build
if: steps.plan.outputs.pending != ''
run: |
bun install --frozen-lockfile
bun run build
bun run verify:packed-manifests
- name: Fetch the npm token from KMS
id: npmtok
if: steps.plan.outputs.pending != ''
# KMS is where a secret lives. The token is read at run time with the
# same machine identity the rest of the fleet uses, so there is nothing
# new to rotate and no npm credential sits on the forge.
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
# NPM_TOKEN sits at the org root. Empty means root; set KMS_NPM_PATH
# to move it without editing this file.
KMS_NPM_PATH: ${{ vars.KMS_NPM_PATH || '' }}
run: |
token=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
| jq -r '.accessToken // empty')
if [ -z "${token}" ]; then
echo "::error::KMS login failed at ${KMS_ENDPOINT}/v1/kms/auth/login (check KMS_CLIENT_ID/KMS_CLIENT_SECRET)."
exit 1
fi
# The read is org-scoped by the token's owner claim, not by a path
# segment: the flat form answers {name, env, value}.
secret_url="${KMS_ENDPOINT}/v1/kms/secrets/${KMS_NPM_PATH:+${KMS_NPM_PATH}/}NPM_TOKEN?env=${KMS_SECRET_ENV}"
# No -f: a 404 must report WHICH path was empty, not just exit 22.
npm_token=$(curl -sS "${secret_url}" -H "Authorization: Bearer ${token}" | jq -r '.value // empty')
if [ -z "${npm_token}" ]; then
echo "::error::No NPM_TOKEN at ${secret_url}."
echo "::error::Put it there, or set the KMS_NPM_PATH variable to the path that has it."
exit 1
fi
# Masked before it is ever an output, so it cannot surface in a log.
echo "::add-mask::${npm_token}"
echo "token=${npm_token}" >> "$GITHUB_OUTPUT"
# --no-provenance: a token publish 422s without it.
- name: Publish
if: steps.plan.outputs.pending != ''
env:
NODE_AUTH_TOKEN: ${{ steps.npmtok.outputs.token }}
DIST_TAG: ${{ steps.plan.outputs.dist_tag }}
run: |
set -e
mkdir -p "$RUNNER_TEMP/tarballs"
for dir in ${{ steps.plan.outputs.pending }}; do
node scripts/pack.mjs "packages/$dir" "$RUNNER_TEMP/tarballs"
done
for tarball in "$RUNNER_TEMP"/tarballs/*.tgz; do
echo "publishing $(basename "$tarball")"
npm publish "$tarball" --access public --no-provenance --tag "$DIST_TAG"
done