From b7e9fdf3f00f9e9dbbc844b94d0dbca31b1f4e64 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 30 Jul 2026 14:52:44 +1000 Subject: [PATCH] =?UTF-8?q?docs(skills):=20trusted=20publishing=20is=20liv?= =?UTF-8?q?e,=20not=20deferred=20=E2=80=94=20plus=20a=20first-publish=20ru?= =?UTF-8?q?nbook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stash-supply-chain-security skill still listed provenance attestations and OIDC trusted publishing under "What's Deferred (Follow-Up PR)" and told readers release.yml needs NPM_TOKEN dropped. Both landed a while ago: release.yml publishes with no token at all, and rc.4 tarballs carry SLSA provenance. Replace that section with what is actually true, including the constraints that keep it working and are easy to undo by accident: id-token: write, a GitHub-hosted runner (npm rejects provenance from self-hosted with E422), never setting NPM_TOKEN (changesets/action writes a token .npmrc that shadows OIDC and fails with E404 — npm/cli#8976), npm >= 11.5.1, and no Actions cache in a credential-bearing job. Add a runbook for publishing a package name that has never been published. A trusted publisher is configured per package on npmjs.com, so it can only be attached to a package that already exists — a brand-new name cannot be released by release.yml on its own and needs a manual placeholder publish first. This is why stack-drizzle and stack-supabase carry 0.0.0 placeholders (those versions have no attestations; their rc builds do). @cipherstash/stack-prisma is 404 on the registry today and hits this at GA — see #661. Skills ship inside the stash tarball, so this carries a stash patch changeset. --- .changeset/tall-moons-yawn.md | 5 ++ skills/stash-supply-chain-security/SKILL.md | 71 +++++++++++++++++++-- 2 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 .changeset/tall-moons-yawn.md diff --git a/.changeset/tall-moons-yawn.md b/.changeset/tall-moons-yawn.md new file mode 100644 index 000000000..884cd51bf --- /dev/null +++ b/.changeset/tall-moons-yawn.md @@ -0,0 +1,5 @@ +--- +'stash': patch +--- + +Update the `stash-supply-chain-security` skill: npm OIDC trusted publishing and provenance are live in `release.yml`, not deferred. Documents the constraints that keep them working (`id-token: write`, GitHub-hosted runner, no `NPM_TOKEN`, npm >= 11.5.1, no Actions cache) and adds a runbook for claiming a package name on npm for the first time — a trusted publisher can only be attached to a package that already exists, so a new name needs a manual placeholder publish before the release workflow can publish it. diff --git a/skills/stash-supply-chain-security/SKILL.md b/skills/stash-supply-chain-security/SKILL.md index 10f9c96f0..ec8491320 100644 --- a/skills/stash-supply-chain-security/SKILL.md +++ b/skills/stash-supply-chain-security/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-supply-chain-security -description: Supply-chain security controls for the @cipherstash/stack monorepo. Covers post-install script policy (onlyBuiltDependencies), install cooldown (minimumReleaseAge), lockfile integrity (blockExoticSubdeps + lockfile registry check), frozen-lockfile CI, registry pinning (.npmrc), Dependabot cooldown, and CODEOWNERS. Use when modifying CI workflows, pnpm config, dependency updates, .github/dependabot.yml, or anything that touches how packages enter the build. +description: Supply-chain security controls for the @cipherstash/stack monorepo. Covers post-install script policy (onlyBuiltDependencies), install cooldown (minimumReleaseAge), lockfile integrity (blockExoticSubdeps + lockfile registry check), frozen-lockfile CI, registry pinning (.npmrc), Dependabot cooldown, CODEOWNERS, and npm OIDC trusted publishing / provenance (including claiming a new package name). Use when modifying CI workflows, pnpm config, dependency updates, .github/dependabot.yml, release.yml, publishing a package to npm for the first time, or anything that touches how packages enter the build. --- # Supply Chain Security @@ -14,6 +14,7 @@ Controls applied in this repo to limit blast radius from compromised npm package - Updating `.github/dependabot.yml` or `.github/CODEOWNERS` - Adding a dependency that needs a build script (i.e. `node-gyp`, `node-pty`, prebuilt binaries) - Bypassing the install cooldown for a security fix +- Publishing a package to npm under a name that has never been published before - Reviewing a PR that touches any of the above ## What's Enforced (Config + Test Gate) @@ -91,7 +92,10 @@ Every maintainer with publish access to `@cipherstash/*` should have: npm profile enable-2fa auth-and-writes ``` -(This becomes mostly moot once the deferred OIDC-trusted-publisher migration lands — the workflow won't need long-lived tokens at all. See "Deferred" below.) +Releases no longer depend on this — `release.yml` publishes via OIDC and holds no +long-lived token (see "Publishing" below). 2FA still matters for the manual +publishes that OIDC can't cover: claiming a new package name, `npm deprecate`, +and `npm dist-tag` changes. ### Reduce dependency tree — practice #13 @@ -107,14 +111,67 @@ Before adding a new direct dep, ask: Do **not** commit any `.env` file to the repo. -## What's Deferred (Follow-Up PR) +## Publishing — OIDC trusted publishing + provenance (practices #11, #12) -These need npmjs.com-side configuration and are tracked separately: +`.github/workflows/release.yml` publishes to npm with **no `NPM_TOKEN`**. It +authenticates via npm OIDC trusted publishing, and provenance attestations are +generated automatically as a side effect. Verify any published version with: -- **Provenance attestations** — practice #11 -- **OIDC trusted publishing** — practice #12 +```bash +npm view @ --json | grep -A3 attestations +``` + +Constraints baked into that workflow — don't undo them: + +- **`permissions: id-token: write`** is what mints the OIDC token. Without it every publish fails. +- **`runs-on: ubuntu-latest`, not a self-hosted/Blacksmith runner.** npm rejects provenance from non-GitHub-hosted runners with E422. +- **Never set `NPM_TOKEN`.** `changesets/action` writes a token `.npmrc` when it sees one, which shadows OIDC and fails every publish with E404 (npm/cli#8976). +- **npm ≥ 11.5.1 and Node ≥ 22.14.** Node 22 ships npm 10.x, so the workflow installs `npm@^11.5.1` explicitly before publishing. +- **No Actions cache in this workflow** (no `cache:`, `package-manager-cache: false`, `pnpm/action-setup` with `cache: false`). A poisoned cache entry would execute in a credential-bearing job. Enforced by `scripts/lint-no-workflow-caching.mjs`. + +Trusted publishing is configured **per package** on npmjs.com (package settings → +Trusted publisher → GitHub Actions): owner/repo `cipherstash/stack`, workflow +filename `release.yml` (filename only, with extension — not a path), environment +blank. npm does not validate this on save, so a typo only surfaces as a failed +publish. + +### Publishing a package name for the first time + +A trusted publisher can only be attached to a package that already exists on the +registry, so a brand-new name can't be released by `release.yml` on its own — +the first publish has to be manual. This is why `@cipherstash/stack-drizzle` and +`@cipherstash/stack-supabase` each carry a `0.0.0` placeholder version. + +Do this **before** the release that would first publish the name: + +1. `npm login` as a maintainer with publish rights on the `@cipherstash` scope. +2. Publish a placeholder to claim the name. Use `pnpm publish`, not `npm publish` — workspace packages depend on each other via `workspace:*` and only pnpm rewrites that protocol on pack: + + ```bash + pnpm --filter build + cd packages/ + npm version 0.0.0 --no-git-tag-version + pnpm publish --tag bootstrap --access public --no-git-checks + git checkout package.json # restore the real version + ``` + + This publish has no provenance — it predates the trusted-publisher config by + definition. That's expected and is the only unattested version. +3. Register the trusted publisher on npmjs.com as described above. +4. `npm deprecate @0.0.0 "Placeholder package"` so nothing installs it silently. +5. After the real release lands, clean up the placeholder tags — `changeset publish` never removes a tag it didn't create: + + ```bash + npm dist-tag rm bootstrap + ``` + +The first publish also sets `latest` to `0.0.0` regardless of `--tag`, so keep the +gap between the placeholder and the real release short, and confirm +`npm view dist-tags` afterwards. -Both require the npm org admin to register each `@cipherstash/*` package as a Trusted Publisher (cipherstash/stack repo + release.yml). Once that's done, `release.yml` can drop `NPM_TOKEN` entirely, run `npm publish` with `id-token: write`, and provenance is auto-generated. +Also confirm the package's `package.json` has `"publishConfig": {"access": "public"}` — +`.changeset/config.json` sets `access: "restricted"` repo-wide, and the per-package +field is what overrides it. ## Common Operations