# Publishing `@citisen/dsh-font` is published in two halves: 1. **CI stages** a tarball — `.github/workflows/stage.yml`, over npm [trusted publishing](https://docs.npmjs.com/trusted-publishers). No secret is involved. 2. **A human approves** it, via [staged publishing](https://docs.npmjs.com/staged-publishing), which requires that human's 2FA. There is no `NPM_TOKEN` in this repository and there must never be one. ## Why this is safer than a token A publish token is a bearer secret: anything that can read it can publish to the package from anywhere, forever, until someone notices and rotates it. It usually has to bypass 2FA to be usable by a machine, which removes the one control that would otherwise stop an attacker. Trusted publishing replaces the secret with a **short-lived OIDC identity** that GitHub mints per run and npm exchanges for a staging grant. Nothing to steal, nothing to leak into a log, nothing to rotate. The grant is scoped to this repository, this workflow file, and the `npm-publish` environment, so it cannot be replayed from another repo or a laptop. npm attaches a signed provenance attestation automatically. ## Why staged rather than direct npm's trusted-publisher form offers an **Allowed actions** checkbox for `npm publish`, and warns that leaving it unchecked is stronger. That warning is correct, and this repository leaves it **unchecked**. Checked, the workflow's OIDC grant can publish unattended — so anything that can run the workflow puts a version on the registry and makes it installable, immediately, with no further check. Unchecked, the same grant can only *stage*. `npm stage publish` uploads the tarball and stops; nothing is installable, and `npm view` still reports the previous version. Publishing then needs `npm stage approve`, which npm gates on the maintainer's 2FA. A compromised runner, a malicious commit, or a bad dependency in CI gets as far as a staged tarball that a human still has to look at and approve. That human step is also what currently holds the security boundary, because **`main` has no branch protection**: ``` $ gh api repos/citisen/dsh-font/branches/main/protection gh: Branch not protected (HTTP 404) ``` Trusted publishing moves the trust boundary from *whoever holds the token* to *whoever can push*. With `main` unprotected those are close to the same set of people, so without staging this would not have been a real improvement. Staging adds back the missing gate. **The strongest configuration is all three: staging, a protected `main`, and a protected `npm-publish` environment.** Enabling branch protection on `main` (require a PR and a review, no force pushes) is the remaining step, and it is worth doing even for a single-maintainer repo. ## One-time setup ### 1. Configure the trusted publisher on npm → **Trusted Publishers** → *Add a trusted publisher* → **GitHub Actions**, and enter exactly: | Field | Value | | --- | --- | | Organization or user | `citisen` | | Repository | `dsh-font` | | Workflow filename | `stage.yml` | | Environment | `npm-publish` | | Allowed actions | leave **`npm publish` UNCHECKED** (staged publishing only) | The filename is matched literally and must live at `.github/workflows/stage.yml`. **Renaming or moving that file stops CI staging** until you update this setting. The environment must match the `environment:` key in the workflow; to skip the reviewer gate, remove that key from both. ### 2. Set publishing access, then revoke every token On the same page set **Publishing access** to *Require two-factor authentication and disallow tokens*. Then delete every credential that could still publish: → revoke any **Automation** or **Granular** token with publish rights, especially any with *bypass 2FA* enabled. Remove the `//registry.npmjs.org/:_authToken=...` line from `~/.npmrc` on every machine once you stop releasing by hand. Skipping this leaves the old door open — the OIDC path being locked down says nothing about a token that is still live. Verify the reduction is real: ```sh npm publish --dry-run --access public # a plain publish must now fail ``` ### 3. Optionally gate staging on a reviewer Create an environment named `npm-publish` in *Settings → Environments* and add required reviewers. The workflow already references it; no edit is needed. ## Cutting a release ```sh # on main npm version patch --no-git-tag-version # or minor / major git commit -am "Release v0.1.1" git tag v0.1.1 git push --follow-tags ``` Then **Actions → Stage release → Run workflow**, pick the dist-tag, and type the version to confirm. The workflow refuses to run when: - the typed confirmation does not match `package.json` - `npm run check` fails — `lib/client.js` does not match `src/client.js`, or the envelope, stylesheet, settings row, or host half is broken - the version is already published **or already staged** - you asked for `latest` without a `v` tag pointing at exactly the commit being released It also deletes any `.npmrc` and unsets `NODE_AUTH_TOKEN`/`NPM_TOKEN` first, so a stray credential fails closed instead of quietly overriding the OIDC exchange. ### Approving ```sh npm run release -- list # find the stage-id npm run release -- view # inspect before trusting it npm run release -- approve # publishes; requires your 2FA npm run release -- reject # discard ``` The helper delegates to `npx npm@12` because `npm stage` needs npm ≥ 11.6 (12 for the current subcommands) and Node 22.22 / 24.15 / 26+, which the local Node may not satisfy. `approve` is the only step that publishes, and npm demands your 2FA there. Inspect the staged tarball before approving a release you did not build yourself — `npm run release -- download ` fetches it. That is the whole value of the gate: the approval is only meaningful if the artifact is actually looked at. ## Verifying a published release ```sh npm view @citisen/dsh-font --json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);const v=j['dist-tags'].latest;console.log(v, j.versions[v].dist.integrity);console.log(j.versions[v].dist.attestations)})" ``` For an end-to-end check, install into a scratch profile and verify — composition first, then the shipped code itself: ```sh dsh plugin --profile scratch add @citisen/dsh-font node scripts/verify-profile.mjs scratch # both halves of the PUBLISHED copy, not the working tree node scripts/verify-host.mjs "$DSH_HOME/profiles/scratch/node_modules/@citisen/dsh-font/lib/index.js" node scripts/verify-client.mjs "$DSH_HOME/profiles/scratch/node_modules/@citisen/dsh-font/lib/client.js" ``` A `files` entry missing from `package.json` is invisible locally and fatal remotely, so a published artifact has to be exercised from where it was installed — and note that importing through a profile's directory junction resolves to the junction's real path, which is why the explicit paths matter. ## Where the signing key lives The workflow is deliberately two jobs: - **`build`** holds `contents: read` and nothing else. It runs `npm ci --ignore-scripts`, this repository's own checks, and `verify-release.mjs`, which packs the tarball once and prints its sha512 into the run summary. It has no `id-token`, and the gate fails outright if OIDC ever shows up there. - **`stage`** is the only job with `id-token: write`, and it runs no repository code at all: no checkout, no `npm ci`, no cache, no build step. It re-hashes the tarball the build job produced, refuses anything that does not match, and uploads exactly those bytes. That split is the point of the May 2026 TanStack / "Mini Shai-Hulud" compromise: the attacker never stole an npm token. They got code to execute inside the job that could mint one, and published under the project's own trusted-publisher identity, attestations and all. Keeping the signing capability in a job that executes no third-party code removes that path, and `npm-publish` requires a human approval before the staging job starts — so no OIDC token exists until a person clicks. Approving is the last mile, and a stage-id alone does not say *what* is in the queue. Copy the integrity out of the run summary and pass it, so that the approval is checked rather than assumed: ```sh npm run release -- approve --expect sha512-… ``` Without `--expect` the command still works, but it warns that it could not compare the staged bytes against the ones CI built.