diff --git a/.github/NPM_RELEASE_RECOVERY.md b/.github/NPM_RELEASE_RECOVERY.md new file mode 100644 index 0000000..bddd215 --- /dev/null +++ b/.github/NPM_RELEASE_RECOVERY.md @@ -0,0 +1,45 @@ +# Temporary npm release credential + +Use this recovery only while npm's immutable-subject OIDC token exchange is +unavailable. OIDC remains configured. The presence of the **Production environment** +secret `NPM_RELEASE_TOKEN` enables the temporary fallback in the SDK and CLI release +jobs. Removing that secret restores OIDC. Never store this credential in repository +files, Actions variables, chat, or a repository-wide secret. + +A maintainer must create and submit the credential themselves: + +1. In npm account settings, create a granular token named `lingva-release-recovery`. + Set expiration to one day (or the shortest supported period). Grant package + **Read and write (publish and stage)** access only to `@lingva/core`, `@lingva/js`, + `@lingva/react`, `@lingva/next`, `@lingva/angular`, `@lingva/vue`, `@lingva/svelte`, + `@lingva/docusaurus`, `@lingva/electron`, `@lingva/node`, and `@lingva/cli`. + Grant no organization-management permission. Enable publish-time 2FA bypass for + unattended CI; keep account and package 2FA settings unchanged. +2. Paste the token directly into GitHub → usausoft/lingva → Settings → Environments + → Production → Environment secrets as `NPM_RELEASE_TOKEN`. Do not send it to an + assistant or commit it. Credentials appear only in authentication/publication + step environments, after quality and native gates. +3. Merge the reviewed recovery workflow. Create a new immutable + `sdk-recovery-v0.2.0` tag on that main revision, then dispatch `release.yml` from + that tag with `release_tag=v0.2.0`. The flat recovery tag fits the existing `*` + Production tag policy. The original SDK tag must never move. +4. Confirm all ten SDK 0.2.0 versions are anonymously readable with provenance. + A staged version is not a completed release; approve any npm staged entries in + npm's authenticated UI, then retry the guarded workflow. +5. Tag the reviewed CLI revision as `cli/v0.1.6` only after the SDKs are public. + All five native engine tests, fresh npm/wheel installation proofs, checksums, + provenance, and anonymous asset checks remain mandatory. +6. After registry verification, remove the GitHub environment secret and revoke + the npm token in account settings. Check both are gone. Do not delete OIDC trust + entries or relax Production deployment rules. + +Validation for workflow changes: + +```bash +node --test scripts/release-auth.test.mjs +pnpm run test:cli-release +``` + +Public package availability, not a successful publish command alone, determines +completion. An expired or rejected token must fail the release; do not fall back +to another credential or remove provenance requirements. diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index a185b42..7dab0b6 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -128,6 +128,7 @@ jobs: id-token: write actions: read env: + NPM_AUTH_MODE: ${{ secrets.NPM_RELEASE_TOKEN != '' && 'token' || 'oidc' }} CLI_RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} CLI_PREPARED_RUN_ID: ${{ inputs.prepared_run_id }} CLI_ORIGINAL_RUN_ID: ${{ inputs.original_run_id }} @@ -213,8 +214,18 @@ jobs: pnpm --filter @lingva/cli run build node scripts/verify-public-boundary.mjs packages/cli/dist pnpm --filter @lingva/cli pack --pack-destination release + - name: Verify temporary npm credential + if: env.NPM_AUTH_MODE == 'token' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }} + run: | + test -n "$NODE_AUTH_TOKEN" + pnpm whoami --registry https://registry.npmjs.org > /dev/null - name: Publish the checksummed public binary channel - # Public source supports provenance with the approved OIDC trusted publisher. + # The temporary Production secret supplies a fallback for registry OIDC outages. + # Removing it restores OIDC; provenance and all five native gates stay required. + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }} run: pnpm --filter @lingva/cli publish --access public --provenance --no-git-checks - name: Verify anonymous public assets run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05fcc7d..c4a8494 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,10 @@ jobs: run: | if [ "$RETRY" = true ]; then [[ "$SDK_RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] - test "$WORKFLOW_REF" = "refs/tags/sdk-recovery/$SDK_RELEASE_TAG" + case "$WORKFLOW_REF" in + "refs/tags/sdk-recovery/$SDK_RELEASE_TAG"|"refs/tags/sdk-recovery-$SDK_RELEASE_TAG") ;; + *) exit 1 ;; + esac git merge-base --is-ancestor "$GITHUB_WORKFLOW_SHA" origin/main test "$(git rev-parse "refs/tags/$SDK_RELEASE_TAG^{commit}")" = "$(git rev-parse HEAD)" git merge-base --is-ancestor HEAD origin/main @@ -138,6 +141,8 @@ jobs: needs: verify runs-on: ubuntu-latest environment: Production + env: + NPM_AUTH_MODE: ${{ secrets.NPM_RELEASE_TOKEN != '' && 'token' || 'oidc' }} permissions: contents: write id-token: write @@ -163,7 +168,16 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify temporary npm credential + if: env.NPM_AUTH_MODE == 'token' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }} + run: | + test -n "$NODE_AUTH_TOKEN" + pnpm whoami --registry https://registry.npmjs.org > /dev/null + - name: Check npm OIDC authentication without exposing credentials + if: env.NPM_AUTH_MODE == 'oidc' env: FORCE_COLOR: 0 run: | @@ -193,8 +207,27 @@ jobs: console.log('npm accepted this workflow identity for @lingva/core.'); JS - - name: Publish with npm trusted publishing + - name: Publish with provenance and selected authentication + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }} uses: changesets/action/publish@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: pnpm release + + - name: Verify anonymous registry availability and provenance + run: | + node --input-type=module <<'JS' + import fs from 'node:fs'; + const groups = JSON.parse(fs.readFileSync('.changeset/config.json')).fixed; + for (const name of groups.flat()) { + const version = JSON.parse(fs.readFileSync(`packages/${name.slice(8)}/package.json`)).version; + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/${version}`); + if (!response.ok) throw new Error(`${name}@${version} is not public (HTTP ${response.status}); check npm staged approvals before retrying.`); + const metadata = await response.json(); + if (metadata.name !== name || metadata.version !== version || !metadata.dist?.attestations?.url) { + throw new Error(`Missing public identity or provenance for ${name}@${version}`); + } + console.log(`Verified public ${name}@${version} with provenance.`); + } + JS diff --git a/package.json b/package.json index 95cc294..0fc6b47 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "build:cli": "cargo build --release --locked --package lingva", "build:packages": "node scripts/build-public-packages.mjs", "test:cli": "cargo test --workspace --locked", - "test:cli-release": "node --test scripts/render-homebrew-formula.test.mjs scripts/update-homebrew-tap.test.mjs scripts/build-package.test.mjs scripts/verify-cli-recovery.test.mjs scripts/verify-public-boundary.test.mjs scripts/verify-cli-prepared-release.test.mjs", + "test:cli-release": "node --test scripts/render-homebrew-formula.test.mjs scripts/update-homebrew-tap.test.mjs scripts/build-package.test.mjs scripts/verify-cli-recovery.test.mjs scripts/verify-public-boundary.test.mjs scripts/verify-cli-prepared-release.test.mjs scripts/release-auth.test.mjs", "verify:cli-adapters": "node scripts/verify-cli-engine-version.mjs", "dev:consumer": "pnpm --filter @lingva/example-next-starter dev", "changeset": "changeset", diff --git a/scripts/release-auth.test.mjs b/scripts/release-auth.test.mjs new file mode 100644 index 0000000..a77e3f4 --- /dev/null +++ b/scripts/release-auth.test.mjs @@ -0,0 +1,70 @@ +/** Release credentials stay confined to gated publication, never native proof or SDK verification. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { parse } from 'yaml'; + +for (const file of ['release.yml', 'release-cli.yml']) { + const workflow = parse(fs.readFileSync(`.github/workflows/${file}`, 'utf8')); + test(`${file}: temporary credential cannot reach verification or native build jobs`, () => { + for (const [name, job] of Object.entries(workflow.jobs)) { + if (!['publish', 'release'].includes(name)) { + assert.doesNotMatch(JSON.stringify(job), /secrets\.NPM_RELEASE_TOKEN/); + continue; + } + assert.equal(job.environment, 'Production'); + assert.equal(job.permissions['id-token'], 'write', 'Provenance still needs GitHub OIDC.'); + assert.equal(job.env.NPM_AUTH_MODE, "${{ secrets.NPM_RELEASE_TOKEN != '' && 'token' || 'oidc' }}"); + const credentialSteps = job.steps.filter((step) => JSON.stringify(step).includes('secrets.NPM_RELEASE_TOKEN')); + assert.equal(credentialSteps.length, 2); + for (const step of credentialSteps) { + assert.match(step.name, /^(Verify temporary npm credential|Publish )/); + assert.equal(step.env.NODE_AUTH_TOKEN, '${{ secrets.NPM_RELEASE_TOKEN }}'); + } + const publication = job.steps.find((step) => step.name?.startsWith('Publish ')); + if (file === 'release.yml') { + assert.equal(workflow.env.NPM_CONFIG_PROVENANCE, true); + assert.equal(publication.with.script, 'pnpm release'); + assert.equal(job.steps[0].with.ref, '${{ needs.verify.outputs.revision }}'); + } else { + assert.match(publication.run, /--provenance/); + assert.match(job.if, /needs\.build\.result == 'success'/); + assert.equal(workflow.jobs.build.strategy['fail-fast'], false); + } + } + }); + test(`${file}: embedded shell steps parse`, () => { + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps ?? []) { + if (!step.run) continue; + if (step.shell === 'pwsh') continue; + const result = spawnSync('bash', ['-n'], { input: step.run, encoding: 'utf8' }); + assert.equal(result.status, 0, `${step.name ?? 'step'}: ${result.stderr}`); + } + } + }); +} + +test('SDK recovery accepts only matching recovery tags and revisions already reviewed on main', () => { + const workflow = parse(fs.readFileSync('.github/workflows/release.yml', 'utf8')); + const guard = workflow.jobs.verify.steps.find((step) => step.id === 'identity').run; + assert.match(guard, /git merge-base --is-ancestor "\$GITHUB_WORKFLOW_SHA" origin\/main/); + assert.match(guard, /git merge-base --is-ancestor HEAD origin\/main/); + assert.ok(guard.includes('git rev-parse "refs/tags/$SDK_RELEASE_TAG^{commit}"')); + // Isolate ref selection from Git operations so hostile or unrelated refs fail before execution. + const selection = guard.slice(guard.indexOf('case '), guard.indexOf('esac') + 4); + for (const [ref, expected] of [ + ['refs/tags/sdk-recovery/v0.2.0', 0], + ['refs/tags/sdk-recovery-v0.2.0', 0], + ['refs/heads/main', 1], + ['refs/tags/sdk-recovery-v0.2.1', 1], + ['refs/tags/unrelated', 1], + ]) { + const result = spawnSync('bash', ['-e', '-c', selection], { + env: { ...process.env, WORKFLOW_REF: ref, SDK_RELEASE_TAG: 'v0.2.0' }, + }); + assert.equal(result.status, expected, ref); + } +});