From 0ac3222fbd868cd7fb7140dffa0865c3af69750a Mon Sep 17 00:00:00 2001 From: konojunya Date: Sun, 6 Sep 2026 04:40:22 +0900 Subject: [PATCH] Add guarded initial theme publication --- .github/workflows/ci.yaml | 3 ++ .github/workflows/initial-publish.yaml | 68 ++++++++++++++++++++++++ README.md | 2 + docs/releasing.md | 11 ++++ scripts/initial-publish-context.mjs | 26 +++++++++ scripts/initial-publish-context.test.mjs | 31 +++++++++++ 6 files changed, 141 insertions(+) create mode 100644 .github/workflows/initial-publish.yaml create mode 100644 docs/releasing.md create mode 100644 scripts/initial-publish-context.mjs create mode 100644 scripts/initial-publish-context.test.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c448660..d01af62 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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: Check whitespace run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD diff --git a/.github/workflows/initial-publish.yaml b/.github/workflows/initial-publish.yaml new file mode 100644 index 0000000..20c709d --- /dev/null +++ b/.github/workflows/initial-publish.yaml @@ -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/theme + 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/theme --workflow ci.yaml --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/theme initial publication (https://github.com/stack-sh/theme)' --output "$RUNNER_TEMP/crate-state.json" --write-out '%{http_code}' https://crates.io/api/v1/crates/stack-theme) + test "$code" = 404 + + - name: Verify the exact source package without credentials + run: cargo +1.85.0 publish --package stack-theme --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-theme --registry crates-io --locked diff --git a/README.md b/README.md index 53b882a..6720196 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,5 @@ The local [core theme review](./review/index.html) renders every fallback withou Repository-authored source code, catalog data, and assets are licensed under the [Apache License 2.0](./LICENSE). Third-party fonts, icons, and other assets keep their own licenses and are not relicensed under Apache-2.0. Their provenance and redistribution terms must be recorded in [THIRD_PARTY_LICENSES.md](./THIRD_PARTY_LICENSES.md) before they are committed. Current provider icons are user-imported and are never committed or copied into the Cargo or npm packages. + +Maintainers use the [initial publication procedure](./docs/releasing.md) for the first crates.io release. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..f66ff24 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,11 @@ +# Initial crates.io publication + +The initial publication creates `stack-theme` version `0.5.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-theme`. 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. diff --git a/scripts/initial-publish-context.mjs b/scripts/initial-publish-context.mjs new file mode 100644 index 0000000..309767f --- /dev/null +++ b/scripts/initial-publish-context.mjs @@ -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-theme'); + assert.equal(crate.version, '0.5.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.'); +} diff --git a/scripts/initial-publish-context.test.mjs b/scripts/initial-publish-context.test.mjs new file mode 100644 index 0000000..3e16c57 --- /dev/null +++ b/scripts/initial-publish-context.test.mjs @@ -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-theme', version: '0.5.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.5.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)); + } +});