From 9330f7c248a74ea33fa89302d1c7876bde29ed40 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sun, 6 Sep 2026 05:18:36 +0900 Subject: [PATCH] Add guarded Cargo trusted publishing with a non-publishing verification mode --- .github/workflows/cargo-publish.yaml | 100 +++++++++++++++++++++++++ .github/workflows/ci.yaml | 2 +- docs/cargo-releasing.md | 8 ++ scripts/cargo-publish-context.mjs | 44 +++++++++++ scripts/cargo-publish-context.test.mjs | 40 ++++++++++ 5 files changed, 193 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..b83f424 --- /dev/null +++ b/.github/workflows/cargo-publish.yaml @@ -0,0 +1,100 @@ +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 + package: + description: Publish formatter before an engine version that depends on it + required: true + type: choice + options: [stack-formatter, stack-engine] + +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: ${{ inputs.package }} + PUBLISH: ${{ inputs.publish }} + steps: + - name: Reject unexpected dispatch context + run: | + test "$GITHUB_REPOSITORY" = stack-sh/engine + 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-formatter|stack-engine) ;; *) 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/engine --workflow ci.yaml --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.yaml b/.github/workflows/ci.yaml index 189db1e..d7eb2cf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,7 +18,7 @@ jobs: - name: Validate initial publication guards and Cargo license assets run: | - node --test scripts/initial-publish-context.test.mjs + node --test scripts/initial-publish-context.test.mjs scripts/cargo-publish-context.test.mjs node scripts/cargo-package-assets.mjs --check - name: Read supported specification revision diff --git a/docs/cargo-releasing.md b/docs/cargo-releasing.md index 1c38f2b..f3a9b66 100644 --- a/docs/cargo-releasing.md +++ b/docs/cargo-releasing.md @@ -12,3 +12,11 @@ The native `stack-formatter` 0.1.0 and `stack-engine` 0.7.0 crates use exact reg 6. Remove the bootstrap GitHub secret and revoke the token after initial publications. Configure crate-specific trusted publishers before later releases. The bootstrap workflow cannot publish a second version and never persists a Cargo credential file. If Cargo times out after upload, inspect registry state before retrying. Never overwrite a version or tag: investigate failures and publish an explicitly versioned correction. No layout snapshot or language behavior is changed for registry packaging. + +## Ongoing trusted publishing + +After initial publication, configure each crate's Settings → Trusted Publishing on crates.io with repository owner `stack-sh`, repository name `engine`, workflow filename `cargo-publish.yaml`, and no environment. Add a separate configuration for both `stack-formatter` and `stack-engine`. 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..fe97750 --- /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-formatter","stack-engine"]; + +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/engine publication (https://github.com/stack-sh/engine)' }, 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..7942138 --- /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-formatter', 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-formatter","stack-engine"]) 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}/); +});