From 8868cd1c6046c7d176012c5027bf99ac6214f3af Mon Sep 17 00:00:00 2001 From: konojunya Date: Sun, 6 Sep 2026 05:19:19 +0900 Subject: [PATCH] Add guarded Cargo trusted publishing with a non-publishing verification mode --- .github/workflows/cargo-publish.yaml | 95 ++++++++++++++++++++++++++ .github/workflows/ci.yml | 2 +- docs/releasing.md | 8 +++ scripts/cargo-publish-context.mjs | 44 ++++++++++++ scripts/cargo-publish-context.test.mjs | 40 +++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cargo-publish.yaml create mode 100644 scripts/cargo-publish-context.mjs create mode 100644 scripts/cargo-publish-context.test.mjs diff --git a/.github/workflows/cargo-publish.yaml b/.github/workflows/cargo-publish.yaml new file mode 100644 index 0000000..0529484 --- /dev/null +++ b/.github/workflows/cargo-publish.yaml @@ -0,0 +1,95 @@ +name: Cargo trusted publishing + +on: + workflow_dispatch: + inputs: + expected_sha: + description: Exact main commit whose CI has succeeded + required: true + type: string + version: + description: Exact package version in the selected source + required: true + type: string + publish: + description: Publish a new version (false only verifies packaging and OIDC) + required: true + default: false + type: boolean + +permissions: + contents: read + actions: read + +concurrency: + group: cargo-trusted-publishing + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + actions: read + id-token: write + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + EXPECTED_VERSION: ${{ inputs.version }} + PACKAGE_NAME: stack-compiler + PUBLISH: ${{ inputs.publish }} + steps: + - name: Reject unexpected dispatch context + run: | + test "$GITHUB_REPOSITORY" = stack-sh/compiler + test "$GITHUB_REF" = refs/heads/main + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$GITHUB_SHA" = "$EXPECTED_SHA" + [[ "$EXPECTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + case "$PACKAGE_NAME" in stack-compiler) ;; *) exit 1 ;; esac + case "$PUBLISH" in true|false) ;; *) exit 1 ;; esac + + - name: Check out exact main source without persisted credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Install minimum supported Rust + run: rustup toolchain install 1.85.0 --profile minimal + + - name: Verify package, successful CI, and immutable registry state + env: + GH_TOKEN: ${{ github.token }} + run: | + cargo +1.85.0 metadata --no-deps --locked --format-version 1 > "$RUNNER_TEMP/package.json" + gh run list --repo stack-sh/compiler --workflow ci.yml --event push --branch main --commit "$EXPECTED_SHA" --limit 1 --json status,conclusion,headSha > "$RUNNER_TEMP/ci.json" + node scripts/cargo-publish-context.mjs "$RUNNER_TEMP/package.json" "$RUNNER_TEMP/ci.json" + + - name: Verify package without registry credentials + run: cargo +1.85.0 publish --package "$PACKAGE_NAME" --registry crates-io --locked --dry-run + + - name: Exchange GitHub OIDC identity for a short-lived registry token + id: auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + + - name: Verify OIDC exchange without publishing + if: inputs.publish == false + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: | + test -n "$CARGO_REGISTRY_TOKEN" + echo "OIDC exchange verified; no crate was published." + + - name: Publish the previously verified new version + if: inputs.publish == true + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: | + test -n "$CARGO_REGISTRY_TOKEN" + cargo +1.85.0 publish --package "$PACKAGE_NAME" --registry crates-io --locked diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 740bb9b..ef635d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v7 - name: Test initial publication guards - run: node --test scripts/initial-publish-context.test.mjs + run: node --test scripts/initial-publish-context.test.mjs scripts/cargo-publish-context.test.mjs - name: Install latest stable Rust toolchain run: rustup toolchain install stable --profile minimal --component clippy,rustfmt,llvm-tools-preview diff --git a/docs/releasing.md b/docs/releasing.md index ae6bcf0..9d301cf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -9,3 +9,11 @@ The initial publication creates `stack-compiler` version `0.1.0`. The workflow i 5. Remove the GitHub bootstrap secret and revoke the crates.io token. Configure a crates.io trusted publisher for the ongoing release workflow before any later publication. Do not reuse this initial workflow for updates or broaden the bootstrap token. The token is supplied only to the publication step through `CARGO_REGISTRY_TOKEN`; the workflow never runs `cargo login` or writes a credentials file. It cannot configure trusted publishing on behalf of a crate owner. See the [Cargo publication reference](https://doc.rust-lang.org/cargo/commands/cargo-publish.html) for upload and timeout behavior. + +## Ongoing trusted publishing + +After initial publication, configure each crate's Settings → Trusted Publishing on crates.io with repository owner `stack-sh`, repository name `compiler`, workflow filename `cargo-publish.yaml`, and no environment. The crate owner must save these settings; committing this workflow does not configure or prove registry trust. Follow the [crates.io instructions](https://crates.io/docs/trusted-publishing). + +Dispatch `cargo-publish.yaml` from `main` with the full successful main CI commit and the exact package version. The default `publish: false` validates identity, registry state, and packaging, then checks the OIDC exchange **without uploading a crate**. This proves workflow authentication, not a new version's publication or every crate's owner configuration. The pinned authentication action revokes its short-lived token when the job ends; no long-lived repository secret or credentials file is used. + +For an actual new release, merge the version change and all checks first, publish dependencies before consumers, then dispatch with `publish: true`. Existing versions, missing crates, non-main refs, version/SHA drift, and unsuccessful CI fail closed. Verify the downloaded archive checksum and source SHA after publication; a failed post-upload check does not undo an upload. Never rerun an upload without checking registry state. diff --git a/scripts/cargo-publish-context.mjs b/scripts/cargo-publish-context.mjs new file mode 100644 index 0000000..96fd688 --- /dev/null +++ b/scripts/cargo-publish-context.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const packages = ["stack-compiler"]; + +export function validatePublish(metadata, runs, context) { + assert.match(context.expectedSha, /^[a-f0-9]{40}$/); + assert.match(context.version, /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/); + assert.ok(packages.includes(context.packageName), 'Unexpected crate'); + assert.ok(['true', 'false'].includes(context.publish), 'Explicit publish mode required'); + const matches = metadata.packages.filter(crate => crate.name === context.packageName); + assert.equal(matches.length, 1); + const crate = matches[0]; + assert.equal(crate.version, context.version); + assert.equal(crate.license, 'Apache-2.0'); + assert.equal(crate.rust_version, '1.85'); + assert.deepEqual(crate.publish, ['crates-io']); + for (const dependency of crate.dependencies) { + assert.ok(dependency.source === 'registry+https://github.com/rust-lang/crates.io-index' || (dependency.source === null && dependency.path && /^=[0-9]+\.[0-9]+\.[0-9]+$/.test(dependency.req)), 'Dependencies must resolve from crates.io when packaged'); + } + assert.equal(runs.length, 1, 'Exact main source needs successful CI'); + assert.equal(runs[0].headSha, context.expectedSha); + assert.equal(runs[0].status, 'completed'); + assert.equal(runs[0].conclusion, 'success'); +} + +export function validateRegistry(crateStatus, versionStatus, publish) { + assert.equal(crateStatus, 200, 'Only existing crates may use trusted publishing'); + assert.ok([200, 404].includes(versionStatus), 'Registry version lookup failed'); + if (publish === 'true') assert.equal(versionStatus, 404, 'Published versions are immutable'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const context = { packageName: process.env.PACKAGE_NAME, version: process.env.EXPECTED_VERSION, expectedSha: process.env.EXPECTED_SHA, publish: process.env.PUBLISH }; + const metadata = JSON.parse(await readFile(process.argv[2], 'utf8')); + const runs = JSON.parse(await readFile(process.argv[3], 'utf8')); + validatePublish(metadata, runs, context); + const base = 'https://crates.io/api/v1/crates/' + context.packageName; + const status = async url => (await fetch(url, { headers: { 'User-Agent': 'stack-sh/compiler publication (https://github.com/stack-sh/compiler)' }, signal: AbortSignal.timeout(30000) })).status; + validateRegistry(await status(base), await status(base + '/' + context.version), context.publish); + console.log('Exact source, package, main CI, and registry state verified.'); +} diff --git a/scripts/cargo-publish-context.test.mjs b/scripts/cargo-publish-context.test.mjs new file mode 100644 index 0000000..e0b5753 --- /dev/null +++ b/scripts/cargo-publish-context.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import { validatePublish, validateRegistry } from './cargo-publish-context.mjs'; + +const sha = 'a'.repeat(40); +const context = { packageName: 'stack-compiler', version: '0.1.0', expectedSha: sha, publish: 'false' }; +const crate = { name: context.packageName, version: context.version, publish: ['crates-io'], license: 'Apache-2.0', rust_version: '1.85', dependencies: [] }; +const metadata = { packages: [crate] }; +const runs = [{ headSha: sha, status: 'completed', conclusion: 'success' }]; + +test('accepts exact successful source for verification or publication', () => { + for (const publish of ['true', 'false']) validatePublish(metadata, runs, { ...context, publish }); + for (const name of ["stack-compiler"]) validatePublish({ packages: [{ ...crate, name }] }, runs, { ...context, packageName: name }); +}); +test('rejects malformed identity, versions, or implicit publishing', () => { + for (const change of [{ expectedSha: 'main' }, { expectedSha: sha + '\n' }, { version: '0.1.0-rc.1' }, { version: '0x1x0' }, { version: '01.0.0' }, { packageName: 'other' }, { publish: '' }]) assert.throws(() => validatePublish(metadata, runs, { ...context, ...change })); + for (const change of [{ version: '0.2.0' }, { license: 'MIT' }, { rust_version: '1.86' }, { publish: null }, { dependencies: [{ source: 'git+https://example.com/source' }] }, { dependencies: [{ source: null, path: '../library', req: '*' }] }]) assert.throws(() => validatePublish({ packages: [{ ...crate, ...change }] }, runs, context)); +}); +test('rejects missing, stale, incomplete, and unsuccessful CI', () => { + for (const invalid of [[], [...runs, ...runs], [{ ...runs[0], headSha: 'b'.repeat(40) }], [{ ...runs[0], status: 'in_progress' }], [{ ...runs[0], conclusion: 'failure' }]]) assert.throws(() => validatePublish(metadata, invalid, context)); +}); +test('never republishes an existing version or ignores registry failures', () => { + validateRegistry(200, 200, 'false'); + validateRegistry(200, 404, 'false'); + validateRegistry(200, 404, 'true'); + assert.throws(() => validateRegistry(200, 200, 'true')); + for (const code of [401, 403, 429, 500]) assert.throws(() => validateRegistry(200, code, 'false')); + assert.throws(() => validateRegistry(404, 404, 'true')); +}); +test('workflow keeps manual main-only publishing and ephemeral credentials', () => { + const workflow = fs.readFileSync(new URL('../.github/workflows/cargo-publish.yaml', import.meta.url), 'utf8'); + assert.match(workflow, /workflow_dispatch:/); + assert.doesNotMatch(workflow, /\n (push|pull_request|schedule):|secrets\.|cargo login|self-hosted/); + assert.ok(workflow.includes("if: github.ref == 'refs/heads/main'")); + assert.ok(workflow.includes('if: inputs.publish == true')); + assert.ok(workflow.includes('default: false')); + assert.match(workflow, /id-token: write/); + assert.match(workflow, /crates-io-auth-action@[a-f0-9]{40}/); +});