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.yaml
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: Check whitespace
run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD

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/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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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-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.
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-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.');
}
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-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));
}
});