Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/NPM_RELEASE_RECOVERY.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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: |
Expand Down
37 changes: 35 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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: |
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions scripts/release-auth.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading