diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3305484..5012ea8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,10 @@ jobs: with: node-version: 24.18.0 package-manager-cache: false + - name: Verify release toolchain + run: | + test "$(node --version)" = "v24.18.0" + test "$(npm --version)" = "11.16.0" - run: npm ci --ignore-scripts - run: npm audit - run: npm run check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f48b69a..b278158 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,6 +30,8 @@ jobs: run: | # release-source-checks:begin set -euo pipefail + test "$(node --version)" = "v24.18.0" + test "$(npm --version)" = "11.16.0" test "$GITHUB_REPOSITORY" = "firstdraft/cli" test "$GITHUB_EVENT_NAME" = "push" test "$GITHUB_REF_TYPE" = "tag" @@ -75,12 +77,14 @@ jobs: - name: Verify approved release env: NPM_RELEASE_ENABLED: ${{ vars.NPM_RELEASE_ENABLED }} - # Inline by design: no tagged repository script runs while the bootstrap secret is in scope. + # Inline by design: the approved job reruns the reviewed checks before OIDC publication. # test/package.test.js enforces synchronization with verify. run: | # release-source-checks:begin set -euo pipefail test "$NPM_RELEASE_ENABLED" = "true" + test "$(node --version)" = "v24.18.0" + test "$(npm --version)" = "11.16.0" test "$GITHUB_REPOSITORY" = "firstdraft/cli" test "$GITHUB_EVENT_NAME" = "push" test "$GITHUB_REF_TYPE" = "tag" @@ -99,8 +103,5 @@ jobs: git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/main-first-parent" grep -Fqx "$release_sha" "$RUNNER_TEMP/main-first-parent" # release-source-checks:end - - name: Publish verified source + - name: Publish verified source with OIDC run: npm publish --access public --tag next --provenance --ignore-scripts - # Remove after v0.1.0 and before any later release tag. - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 2a298ec..4846abc 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ identifies `0.1.0-alpha.2`. After `0.1.0` is published under `next`, an untagged that historical alpha rather than `0.1.0` until the separate promotion occurs. Remote Plan and Compilation commands require a compatible First Draft service and are currently intended for coordinated trials. +Before the first ordinary `v0.1.0` release tag is created, an operator must inspect npm's exact listed +[GitHub Actions trusted-publisher](https://docs.npmjs.com/trusted-publishers/) relationship for +`@firstdraft.com/cli`, type `github`, the `firstdraft/cli` repository, `publish.yml`, the protected `npm` environment, +and permission `createPackage` (npm's trust-list vocabulary for the allowed publish operation). The release workflow +authenticates only with a short-lived GitHub OIDC credential. A persistent npm token, `NODE_AUTH_TOKEN`, or GitHub +Actions secret is not permitted as a publication fallback. + ## Authenticate API commands Create an API token in First Draft and provide it only through the environment when running a network command: diff --git a/RELEASING.md b/RELEASING.md index 835872c..8f15ffd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,6 +3,10 @@ Publishing is a separate, explicit action after a release-preparation pull request has merged. npm registry bytes and package versions cannot be replaced, so do not create or push a release tag as a dry run. +Only a version that npm reports as published or a protected release tag that exists is immutable. Before either +exists, an unpublished candidate may be revised without changing its proposed version; its exact source SHA and +reviewed digests identify it. Reconcile the registry and remote tags before deciding that a version was consumed. + ## Pre-1.0 version and channel policy Before `1.0.0`, First Draft CLI uses ordinary `0.MINOR.PATCH` versions. Increase `MINOR` for a breaking @@ -66,11 +70,9 @@ The published scoped package already exists. Before another release, a repositor npm view '@firstdraft.com/cli' name repository.url versions dist-tags --json ``` -5. If trusted publishing has not yet been verified, `v0.1.0` is the final release permitted to use the bootstrap - credential. Create a one-day granular npm token with read/write access limited to the existing - `@firstdraft.com/cli` package, no organization-management access, and bypass 2FA enabled. Add it directly as the - `npm` environment secret `NPM_TOKEN`; never put it in an Issue, chat, workflow file, repository file, or command - history. +5. Before creating the first ordinary `v0.1.0` tag, verify npm trusted publishing for the exact package, repository, + workflow, protected environment, and allowed publish operation. No persistent npm credential is permitted for + this or a later release workflow. Use the repository-pinned Node.js 24.18.0 toolchain with npm 11.16.0 to verify the organization's durable read/write access. Grant it only if the package did not inherit access for the `developers` team: @@ -82,7 +84,7 @@ npm access grant read-write firstdraft.com:developers '@firstdraft.com/cli' ``` Using an interactive npm login backed by the account's 2FA, configure trusted publishing for the exact package, -repository, workflow, and protected environment. Do not use the bypass-2FA bootstrap token for trust setup: +repository, workflow, protected environment, and allowed publish action: ```sh npm trust github '@firstdraft.com/cli' \ @@ -93,18 +95,18 @@ npm trust github '@firstdraft.com/cli' \ npm trust list '@firstdraft.com/cli' ``` -Confirm the listed relationship identifies `firstdraft/cli`, `publish.yml`, the `npm` environment, and publish -permission. After publishing `v0.1.0` and before creating any later release tag, merge a follow-up pull request that -removes the `NODE_AUTH_TOKEN` environment from the publish step. Then remove the GitHub secret, revoke the bootstrap -token, and configure the package to disallow token publication: +Confirm `npm trust list` reports type `github`, repository `firstdraft/cli`, file `publish.yml`, environment `npm`, +and permission `createPackage`, which is npm's trust-list vocabulary for the allowed publish operation, before +creating `v0.1.0`. npm does not validate the saved relationship by attempting an exchange, so each case-sensitive +value must be inspected. The publish job must remain on a GitHub-hosted runner with `id-token: write` and must not +read `NODE_AUTH_TOKEN`, an npm token, or any GitHub Actions secret. Confirm the repository and `npm` environment +secret lists contain no npm automation secret. Trusted publishing's short-lived OIDC exchange is the sole workflow +publication credential; an authentication failure stops the release and must never fall back to a persistent token. -```sh -npm access set mfa=publish '@firstdraft.com/cli' -``` - -Confirm that the package's npm Publishing access now requires 2FA and disallows tokens. The workflow continues -through GitHub OIDC without a persistent npm credential. Apply this restriction only after the trusted publisher -has been verified. +As optional defense-in-depth after trusted publication is operationally proven, an npm administrator may complete +the separate security-key ceremony and set package **Publishing access** to **Require two-factor authentication and +disallow tokens**. This package-level setting is not a `v0.1.0` release prerequisite and must not be reported as +enabled until it is directly observed. ## Historical alpha publications @@ -126,9 +128,13 @@ approved promotion. 3. Apply the pre-1.0 policy: use a minor increment for a breaking compatibility line and a patch increment for a change that is otherwise backward-compatible. Keep the initial distribution under `next` independently of that version choice. Do not move `latest` during release publication. -4. Confirm neither the exact package version nor its `v` tag already exists. -5. Update user-facing documentation and release notes for behavior changes. -6. Run: +4. Confirm neither the exact package version nor its `v` tag already exists. If both remain absent, + the unpublished candidate may retain its proposed version while its exact SHA and digests are revised. +5. Re-run `npm trust list '@firstdraft.com/cli'`, verify the exact `github`/repository/file/environment/`createPackage` + relationship described above, and confirm the workflow contains no persistent npm credential or GitHub Actions + secret. +6. Update user-facing documentation and release notes for behavior changes. +7. Run: ```sh npm ci --ignore-scripts @@ -136,7 +142,7 @@ approved promotion. npm run check ``` -7. Merge the reviewed pull request only after local and hosted checks pass. +8. Merge the reviewed pull request only after local and hosted checks pass. ## Publish @@ -150,7 +156,8 @@ equals `v` plus the version in `package.json`, the remote tag still identifies t appears in the first-parent history of `origin/main`. First-parent membership allows an older reviewed `main` state after another change lands while rejecting intermediate commits from a merged side branch. The workflow reruns the complete check, waits for approval in the `npm` environment, reverifies the remote refs, and publishes to the public -registry with provenance under `next`. +registry with provenance under `next`. It authenticates only through the exact npm trusted-publisher relationship and +the job's short-lived GitHub OIDC token; it reads no persistent npm credential or GitHub Actions secret. The tag ruleset and `npm` environment approval are the external trust boundary because a tag-push run loads its workflow from the tagged commit. Before approving the `npm` deployment, the reviewer must confirm: @@ -159,6 +166,8 @@ workflow from the tagged commit. Before approving the `npm` deployment, the revi - The commit is a known reviewed state in protected `main` history and its required checks passed. - `.github/workflows/publish.yml` at that commit is the reviewed workflow, still selects the `npm` environment, and publishes the public `@firstdraft.com/cli` package only under `next` with provenance. +- npm lists the exact `github`/`firstdraft/cli`/`publish.yml`/`npm`/`createPackage` trusted-publisher relationship, + and the GitHub repository and `npm` environment contain no npm automation secret. - The unprivileged verification job passed for that exact commit. Do not move or reuse a release tag. If the tagged commit is not a first-parent state of `main`, merge the intended @@ -178,6 +187,11 @@ npm dist-tag ls '@firstdraft.com/cli' Install `@firstdraft.com/cli@0.1.0` into a fresh temporary prefix, confirm `firstdraft --version`, compare the packed file list with the release workflow, and run `npm audit signatures` after an exact installation. +If OIDC authentication fails, reconcile both the registry version and protected remote tag first. If only npm's +listed relationship is wrong, correct it on npm and rerun the existing workflow's failed jobs without changing the +tagged source. If the workflow filename, environment, permissions, or other identity at the tagged commit is wrong, +the protected tag is immutable: prepare the next version rather than moving the tag. Never add a token fallback. + A published version cannot be overwritten or reused. For a bad release, move `next` only to a known-good compatible version if one exists; otherwise deprecate the bad version and publish a corrected higher version. Treat unpublishing as an exceptional incident response, not a routine rollback. diff --git a/test/package.test.js b/test/package.test.js index 41a7ba0..c005312 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -82,15 +82,15 @@ test("ordinary pre-1.0 versions use the approval-gated distribution channel", () }); }); -test("privileged publication repeats every release source check", () => { +test("OIDC publication repeats every release source check", () => { const verifyJobStart = publishWorkflow.indexOf("\n verify:\n"); const publishJobStart = publishWorkflow.indexOf("\n publish:\n"); assert.ok(verifyJobStart >= 0, "verify job must exist"); assert.ok(publishJobStart > verifyJobStart, "publish job must follow verify"); - const verifyJob = publishWorkflow.slice(verifyJobStart, publishJobStart); - const publishJob = publishWorkflow.slice(publishJobStart); + const verifyJob = workflowJob(publishWorkflow, "verify"); + const publishJob = workflowJob(publishWorkflow, "publish"); const npmApprovalGate = 'test "$NPM_RELEASE_ENABLED" = "true"'; const verifyChecks = releaseSourceChecks(verifyJob); const publishChecks = releaseSourceChecks(publishJob); @@ -101,6 +101,16 @@ test("privileged publication repeats every release source check", () => { "npm publish --access public --tag next --provenance --ignore-scripts"; const publishCommandIndex = publishJob.indexOf(publishCommand); const publishInvocation = "npm publish"; + const oidcPermission = "\n id-token: write\n"; + const oidcPermissionIndex = publishJob.indexOf(oidcPermission); + const approvalEnvironmentKey = "\n environment:"; + const approvalEnvironment = "\n environment: npm\n"; + const approvalEnvironmentIndex = publishJob.indexOf(approvalEnvironment); + const runnerKey = "\n runs-on:"; + const approvedRunner = "\n runs-on: ubuntu-latest\n"; + const approvedRunnerIndex = publishJob.indexOf(approvedRunner); + const nodeVersion = "\n node-version: 24.18.0\n"; + const npmVersionCheck = 'test "$(npm --version)" = "11.16.0"'; const publishChecksEndIndex = publishJob.indexOf( "# release-source-checks:end", ); @@ -140,13 +150,86 @@ test("privileged publication repeats every release source check", () => { false, "publication must not mutate a dist-tag separately", ); - if (metadata.version !== "0.1.0") { - assert.equal( - publishWorkflow.includes("NODE_AUTH_TOKEN"), - false, - "only v0.1.0 may use the bootstrap publication credential", - ); - } + assert.ok(oidcPermissionIndex >= 0, "publication must permit OIDC tokens"); + assert.equal( + publishJob.indexOf( + oidcPermission, + oidcPermissionIndex + oidcPermission.length, + ), + -1, + "the OIDC permission must be unique", + ); + assert.equal( + verifyJob.includes(oidcPermission), + false, + "only publication may request an OIDC token", + ); + assert.ok( + approvalEnvironmentIndex >= 0, + "publication must select the approval-gated npm environment", + ); + assert.equal( + publishJob.indexOf(approvalEnvironmentKey), + approvalEnvironmentIndex, + "publication must use the exact approval-gated environment", + ); + assert.equal( + publishJob.indexOf( + approvalEnvironmentKey, + approvalEnvironmentIndex + approvalEnvironmentKey.length, + ), + -1, + "the approval-gated environment must be unique", + ); + assert.equal( + verifyJob.includes(approvalEnvironmentKey), + false, + "verification must not enter the npm environment", + ); + assert.ok( + approvedRunnerIndex >= 0, + "trusted publication must use the approved GitHub-hosted runner", + ); + assert.equal( + publishJob.indexOf(runnerKey), + approvedRunnerIndex, + "the publish job must use only the approved runner syntax", + ); + assert.equal( + publishJob.indexOf(runnerKey, approvedRunnerIndex + runnerKey.length), + -1, + "the publish job must declare one runner", + ); + assert.equal( + publishJob.includes(nodeVersion), + true, + "trusted publication must use the pinned Node version", + ); + assert.equal( + publishChecks.includes(npmVersionCheck), + true, + "trusted publication must verify the pinned npm version", + ); + assert.equal( + publishWorkflow.includes("NODE_AUTH_TOKEN"), + false, + "trusted publication must not use a persistent npm credential", + ); + assert.equal( + publishWorkflow.includes("NPM_TOKEN"), + false, + "trusted publication must not name a persistent npm token", + ); + assert.doesNotMatch( + publishWorkflow, + /\bsecrets\b/i, + "trusted publication must not read a GitHub Actions secret", + ); + assert.doesNotMatch( + publishWorkflow, + /auth[_-]?token/i, + "trusted publication must not configure an authentication token", + ); assert.ok( publishChecksEndIndex < publishCommandIndex, "release checks must precede publication", @@ -157,6 +240,33 @@ test("privileged publication repeats every release source check", () => { ); }); +/** + * @param {string} workflowSource + * @param {string} jobName + * @returns {string} + */ +function workflowJob(workflowSource, jobName) { + const marker = `\n ${jobName}:\n`; + const startIndex = workflowSource.indexOf(marker); + + assert.ok(startIndex >= 0, `${jobName} job must exist`); + assert.equal( + workflowSource.indexOf(marker, startIndex + marker.length), + -1, + `${jobName} job must be unique`, + ); + + const contentStart = startIndex + marker.length; + const followingJob = /\n {2}[a-zA-Z0-9_-]+:\n/.exec( + workflowSource.slice(contentStart), + ); + const endIndex = followingJob + ? contentStart + followingJob.index + : workflowSource.length; + + return workflowSource.slice(startIndex, endIndex); +} + test("package metadata defines no installation lifecycle", () => { for (const script of [ "preinstall",