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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ jobs:
- name: Check out repository
uses: actions/checkout@v7

- name: Test initial publication guards
run: node --test scripts/initial-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
68 changes: 68 additions & 0 deletions .github/workflows/initial-publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Initial crates.io publish

on:
workflow_dispatch:
inputs:
expected_sha:
description: Exact main commit whose CI has succeeded
required: true
type: string

permissions:
contents: read
actions: read

concurrency:
group: initial-crates-io-publish
cancel-in-progress: false

defaults:
run:
shell: bash

jobs:
publish:
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
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"

- name: Check out the exact dispatch commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Install the minimum supported Rust toolchain
run: rustup toolchain install 1.85.0 --profile minimal

- name: Verify package identity and successful main CI
env:
GH_TOKEN: ${{ github.token }}
run: |
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
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/initial-publish-context.mjs "$RUNNER_TEMP/package.json" "$RUNNER_TEMP/ci.json"

- name: Require an unpublished crate name
run: |
code=$(curl --silent --show-error --max-time 30 --user-agent 'stack-sh/compiler initial publication (https://github.com/stack-sh/compiler)' --output "$RUNNER_TEMP/crate-state.json" --write-out '%{http_code}' https://crates.io/api/v1/crates/stack-compiler)
test "$code" = 404

- name: Verify the exact source package without credentials
run: cargo +1.85.0 publish --package stack-compiler --registry crates-io --locked --dry-run

- name: Publish the initial crate
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_INITIAL_PUBLISH_TOKEN }}
run: |
test -n "$CARGO_REGISTRY_TOKEN"
cargo +1.85.0 publish --package stack-compiler --registry crates-io --locked
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ cargo add stack-compiler@0.1.0

The published package is built and documented on Rust 1.85 or newer. Repository CI performs a full crates.io packaging dry run so the released source archive remains independent of Git checkouts.

Maintainers follow the [initial publication procedure](./docs/releasing.md) before the first registry release.

## Pipeline

```text
Expand Down
11 changes: 11 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Initial crates.io publication

The initial publication creates `stack-compiler` version `0.1.0`. The workflow is deliberately limited to this bootstrap operation; it is not the recurring release mechanism.

1. Merge the release preparation through a reviewed pull request and wait for both main CI jobs to succeed.
2. Create a short-lived crates.io token limited to `publish-new` and the exact crate name `stack-compiler`. Store it only as the repository Actions secret `CARGO_INITIAL_PUBLISH_TOKEN`; never paste it into an issue, pull request, workflow input, or source file.
3. Dispatch `initial-publish.yaml` on `main`, with `expected_sha` equal to the full successful main commit. The workflow rejects a different ref, commit, package identity, initial version, or CI state. It requires the crate name to be absent and performs a credential-free packaging dry run before publishing.
4. Verify the registry version, checksum, downloaded `.cargo_vcs_info.json`, and a clean registry-only consumer. If the upload times out, inspect the registry before retrying: a Cargo polling timeout does not undo an upload.
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.
26 changes: 26 additions & 0 deletions scripts/initial-publish-context.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';

export function validateInitialPublish(metadata, runs, expectedSha) {
assert.match(expectedSha, /^[a-f0-9]{40}$/);
assert.equal(metadata.packages.length, 1, 'Expected one source package');
const crate = metadata.packages[0];
assert.equal(crate.name, 'stack-compiler');
assert.equal(crate.version, '0.1.0', 'Only the initial version may use this workflow');
assert.deepEqual(crate.publish, ['crates-io']);
assert.equal(crate.license, 'Apache-2.0');
assert.equal(crate.rust_version, '1.85');
assert.equal(runs.length, 1, 'The exact main commit needs a CI run');
assert.equal(runs[0].headSha, expectedSha);
assert.equal(runs[0].status, 'completed');
assert.equal(runs[0].conclusion, 'success');
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const metadata = JSON.parse(await readFile(process.argv[2], 'utf8'));
const runs = JSON.parse(await readFile(process.argv[3], 'utf8'));
validateInitialPublish(metadata, runs, process.env.EXPECTED_SHA);
console.log('Initial package identity and exact-commit CI verified.');
}
31 changes: 31 additions & 0 deletions scripts/initial-publish-context.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { validateInitialPublish } from './initial-publish-context.mjs';

const sha = 'a'.repeat(40);
const metadata = { packages: [{ name: 'stack-compiler', version: '0.1.0', publish: ['crates-io'], license: 'Apache-2.0', rust_version: '1.85' }] };
const runs = [{ headSha: sha, status: 'completed', conclusion: 'success' }];

test('accepts only the initial package and successful exact-commit CI', () => {
validateInitialPublish(metadata, runs, sha);
});

test('rejects missing, stale, running, failed, and skipped CI', () => {
for (const invalid of [[], [...runs, ...runs], [{ ...runs[0], headSha: 'b'.repeat(40) }], [{ ...runs[0], status: 'in_progress' }], [{ ...runs[0], conclusion: 'failure' }], [{ ...runs[0], conclusion: 'skipped' }]]) {
assert.throws(() => validateInitialPublish(metadata, invalid, sha));
}
});

test('rejects changed package identity, registry, version, license, and MSRV', () => {
for (const change of [{ name: 'other' }, { version: '0.1.1' }, { publish: null }, { publish: ['other-registry'] }, { license: 'MIT' }, { rust_version: '1.86' }]) {
assert.throws(() => validateInitialPublish({ packages: [{ ...metadata.packages[0], ...change }] }, runs, sha));
}
assert.throws(() => validateInitialPublish({ packages: [] }, runs, sha));
assert.throws(() => validateInitialPublish({ packages: [...metadata.packages, ...metadata.packages] }, runs, sha));
});

test('rejects mutable, malformed, and shell-like commit inputs', () => {
for (const invalid of ['main', 'a'.repeat(39), 'A'.repeat(40), `${sha}\n`, '$(echo unsafe)', undefined]) {
assert.throws(() => validateInitialPublish(metadata, runs, invalid));
}
});