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
95 changes: 95 additions & 0 deletions .github/workflows/cargo-publish.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
44 changes: 44 additions & 0 deletions scripts/cargo-publish-context.mjs
Original file line number Diff line number Diff line change
@@ -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.');
}
40 changes: 40 additions & 0 deletions scripts/cargo-publish-context.test.mjs
Original file line number Diff line number Diff line change
@@ -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}/);
});