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
30 changes: 30 additions & 0 deletions .github/workflows/cargo-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,33 @@ jobs:
run: |
test -n "$CARGO_REGISTRY_TOKEN"
cargo +1.85.0 publish --package "$PACKAGE_NAME" --registry crates-io --locked

cargo-install:
if: inputs.publish == true
needs: publish
permissions:
contents: read
attestations: read
uses: ./.github/workflows/distribution-smoke.yaml
with:
scope: cargo
version: ${{ inputs.version }}
source_commit: ${{ inputs.expected_sha }}

completion:
name: cargo publication completion
if: always()
needs: [publish, cargo-install]
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Require packaging, publication intent, and all applicable Cargo installations
env:
PUBLICATION_NEEDS: ${{ toJSON(needs) }}
PUBLISH: ${{ inputs.publish }}
run: node scripts/publication-completion.mjs cargo >> "$GITHUB_STEP_SUMMARY"
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
run: node --test scripts/initial-publish-context.test.mjs scripts/cargo-publish-context.test.mjs
- name: Validate continuous distribution smoke guards
run: |
node --test scripts/distribution-smoke-context.test.mjs scripts/distribution-smoke-workflow.test.mjs
node --test scripts/distribution-smoke-context.test.mjs scripts/distribution-smoke-workflow.test.mjs scripts/publication-completion.test.mjs
python3 -m unittest scripts/test_smoke_installed_cli.py

- name: Read supported specification revision
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ jobs:
python3 scripts/normalize_macos_binary.py --binary "$second_binary" --target "$target"
cmp "$first_binary" "$second_binary"
python3 scripts/verify_release_binary.py --binary "$first_binary" --target "$target" --version "$VERSION"
python3 -m scripts.smoke_installed_cli --binary "$first_binary" --target "$target" --version "$VERSION" --source-root "$GITHUB_WORKSPACE"
first_archive=$(python3 scripts/package_release.py create \
--binary "$first_binary" \
--target "$target" \
Expand Down Expand Up @@ -192,6 +193,7 @@ jobs:
second_binary="$second_target/$target/release/stack"
cmp "$first_binary" "$second_binary"
python3 scripts/verify_release_binary.py --binary "$first_binary" --target "$target" --version "$VERSION"
python3 -m scripts.smoke_installed_cli --binary "$first_binary" --target "$target" --version "$VERSION" --source-root "$GITHUB_WORKSPACE"
first_archive=$(python3 scripts/package_release.py create \
--binary "$first_binary" \
--target "$target" \
Expand Down Expand Up @@ -623,3 +625,32 @@ jobs:
gh release edit "$TAG" --draft=false --latest
fi
test "$(gh release view "$TAG" --json isDraft --jq '.isDraft')" = "false"

native-install:
if: needs.context.outputs.publish == 'true' && !contains(needs.context.outputs.version, '-rc.')
needs: [context, publish]
permissions:
contents: read
attestations: read
uses: ./.github/workflows/distribution-smoke.yaml
with:
scope: native
version: ${{ needs.context.outputs.version }}
source_commit: ${{ github.sha }}

completion:
name: release completion
if: always()
needs: [context, build-macos, build-linux, attest, assemble, publish, native-install]
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Require all applicable build, publication, and installation results
env:
PUBLICATION_NEEDS: ${{ toJSON(needs) }}
run: node scripts/publication-completion.mjs release >> "$GITHUB_STEP_SUMMARY"
17 changes: 17 additions & 0 deletions docs/cargo-releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,20 @@ After initial publication, configure each crate's Settings → Trusted Publishin
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. Keep the native release version/source identical and verify each package-manager channel separately.

After an actual upload, `cargo-install` invokes the shared distribution smoke in
Cargo-only mode for the exact version and `expected_sha`: all four supported
native targets, each with Rust 1.85.0 and stable. Each fresh registry install
checks its packaged source SHA, commands, generated shell assets, and a real
audited provider import/render. `cargo publication completion` always runs and
rejects failed, cancelled, skipped, or missing installation results. The default
`publish: false` intentionally skips new-version installation: it proves
packaging/OIDC only and cannot claim that an unpublished crate was installed.

A failed post-upload smoke makes the workflow fail but cannot undo a crates.io
upload. Use GitHub Actions failure notifications and the named matrix cell to
diagnose it. Re-run only failed verification jobs once the cause is resolved;
do not rerun a successful publication job. Run the complete 19-cell Distribution
smoke for the same stable version after the GitHub Release and Homebrew tap are
available before declaring all-channel activation. Never edit immutable release
assets to record later channel evidence.
8 changes: 6 additions & 2 deletions docs/distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,12 +200,16 @@ Direct, Aqua, and Homebrew binaries must byte-match the canonical archive after

The `distribution smoke completion` job always evaluates the context and the whole requested matrix. A failure, cancellation, skip, missing artifact, wrong version, or mismatched digest prevents success. The job summary identifies the version and scope; GitHub Actions reports failure through its normal workflow notifications. Maintainers should watch **Actions** notifications for this repository and inspect the failed matrix cell before retrying; a retry is not a substitute for resolving a reproducible failure.

The reusable workflow also exposes `native` (Direct + Aqua, eight cells) and `cargo` (eight cells) scopes for publication integration. Cargo-only checks require the exact published source commit and do not assume the GitHub Release already exists. A scoped success is **not** all-channel activation. Full activation requires a successful `all` run for the same stable version after the tap and registry are available. These later results supplement the immutable publication-time manifest; they never rewrite a tag, release asset, or its `verifiedChannels` field.
The reusable workflow also exposes `native` (Direct + Aqua, eight cells) and `cargo` (eight cells) scopes for publication integration. The Release workflow calls the native scope after stable publication; Cargo trusted publishing calls the Cargo scope after an actual upload. Cargo-only checks require the exact published source commit and do not assume the GitHub Release already exists. A scoped success is **not** all-channel activation. Full activation requires a successful `all` run for the same stable version after the tap and registry are available. These later results supplement the immutable publication-time manifest; they never rewrite a tag, release asset, or its `verifiedChannels` field.

Both publication workflows have an always-running completion job that rejects missing, failed, cancelled, or unexpectedly skipped required work. Native release builds exercise a real audited provider import/render before publication, including manual dry runs. A native dry run verifies newly built artifacts without requiring an unpublished version to exist in a package manager. Release candidates retain those build checks but skip stable package-manager installation; they do not activate stable channels. Cargo's no-upload OIDC verification similarly does not claim installation of a new version.

After an upload, a failing installation makes the workflow fail but does not undo publication. Inspect the named failed cell and registry/release state first, fix the cause, then re-run failed verification jobs only. Never rerun a successful upload job or overwrite release assets to make the run green. If the published package itself is broken, follow the withdrawal and new-patch procedure below.

Run the negative guards locally with:

```sh
node --test scripts/distribution-smoke-context.test.mjs scripts/distribution-smoke-workflow.test.mjs
node --test scripts/distribution-smoke-context.test.mjs scripts/distribution-smoke-workflow.test.mjs scripts/publication-completion.test.mjs
python3 -m unittest scripts/test_smoke_installed_cli.py
```

Expand Down
34 changes: 34 additions & 0 deletions scripts/publication-completion.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';

function requireResult(needs, job, expected = 'success') {
assert.equal(needs[job]?.result, expected, `${job} must finish with ${expected}`);
}

export function requireReleaseCompletion(needs) {
for (const job of ['context', 'build-macos', 'build-linux', 'attest', 'assemble']) requireResult(needs, job);
const { publish, version } = needs.context.outputs;
assert.ok(['true', 'false'].includes(publish), 'Publication intent is missing');
assert.match(version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-rc\.[1-9]\d*)?$/);
requireResult(needs, 'publish', publish === 'true' ? 'success' : 'skipped');
const stablePublished = publish === 'true' && !version.includes('-rc.');
requireResult(needs, 'native-install', stablePublished ? 'success' : 'skipped');
return stablePublished ? 'Native publication verified; all-channel activation still requires a full distribution smoke run.'
: publish === 'true' ? 'Release candidate verified; stable package-manager activation is not claimed.'
: 'New artifacts passed dry-run verification, including real provider import; nothing was published.';
}

export function requireCargoCompletion(needs, publish) {
assert.ok(['true', 'false'].includes(publish), 'Publication intent is missing');
requireResult(needs, 'publish');
requireResult(needs, 'cargo-install', publish === 'true' ? 'success' : 'skipped');
return publish === 'true' ? 'Registry publication and all eight Cargo installations verified; other channels are independent.'
: 'Packaging and OIDC verified without publication; new-version installation is not claimed.';
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const needs = JSON.parse(process.env.PUBLICATION_NEEDS);
const mode = process.argv[2];
assert.ok(['release', 'cargo'].includes(mode));
console.log(mode === 'release' ? requireReleaseCompletion(needs) : requireCargoCompletion(needs, process.env.PUBLISH));
}
71 changes: 71 additions & 0 deletions scripts/publication-completion.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { requireCargoCompletion, requireReleaseCompletion } from './publication-completion.mjs';
import { validateReleaseWorkflow } from './validate-release-workflow.mjs';

function releaseNeeds(publish, version = '0.5.1') {
const needs = Object.fromEntries(['context', 'build-macos', 'build-linux', 'attest', 'assemble'].map(job => [job, { result: 'success' }]));
needs.context.outputs = { publish, version };
needs.publish = { result: publish === 'true' ? 'success' : 'skipped' };
needs['native-install'] = { result: publish === 'true' && !version.includes('-rc.') ? 'success' : 'skipped' };
return needs;
}

test('stable release, prerelease, and dry-run have distinct completion requirements', () => {
assert.match(requireReleaseCompletion(releaseNeeds('true')), /all-channel activation still requires/);
assert.match(requireReleaseCompletion(releaseNeeds('true', '0.6.0-rc.1')), /stable package-manager activation is not claimed/);
assert.match(requireReleaseCompletion(releaseNeeds('false', '0.6.0')), /nothing was published/);
assert.throws(() => requireReleaseCompletion(releaseNeeds('')), /intent is missing/);
});

test('publication cannot succeed with a failed, cancelled, skipped, or missing required job', () => {
for (const publish of ['true', 'false']) {
const original = releaseNeeds(publish);
for (const job of Object.keys(original)) {
for (const result of ['failure', 'cancelled', 'skipped', 'success', undefined]) {
if (result === original[job].result) continue;
const needs = structuredClone(original);
needs[job].result = result;
assert.throws(() => requireReleaseCompletion(needs), `${publish}/${job}/${result}`);
}
}
}
});

test('Cargo upload and no-upload verification cannot be confused', () => {
const needs = { publish: { result: 'success' }, 'cargo-install': { result: 'success' } };
assert.match(requireCargoCompletion(needs, 'true'), /all eight Cargo installations verified/);
assert.throws(() => requireCargoCompletion(needs, 'false'));
needs['cargo-install'].result = 'skipped';
assert.match(requireCargoCompletion(needs, 'false'), /without publication/);
assert.throws(() => requireCargoCompletion(needs, 'true'));
for (const result of ['failure', 'cancelled', undefined]) {
assert.throws(() => requireCargoCompletion({ ...needs, publish: { result } }, 'false'));
assert.throws(() => requireCargoCompletion({ ...needs, 'cargo-install': { result } }, 'true'));
}
});

test('release integration rejects missing import, mutable reuse, and detached completion', () => {
const workflow = fs.readFileSync('.github/workflows/release.yaml', 'utf8');
for (const [before, after] of [
['python3 -m scripts.smoke_installed_cli', 'echo skipped'],
['./.github/workflows/distribution-smoke.yaml', 'stack-sh/cli/.github/workflows/distribution-smoke.yaml@main'],
['needs: [context, publish]', 'needs: context'],
['scope: native', 'scope: all'],
['source_commit: ${{ github.sha }}', 'source_commit: main'],
['if: always()', 'if: success()'],
['node scripts/publication-completion.mjs release', 'echo passed'],
]) assert.throws(() => validateReleaseWorkflow(workflow.replace(before, after)), before);
});

test('Cargo integration starts only after upload and always reports completion', () => {
const workflow = fs.readFileSync('.github/workflows/cargo-publish.yaml', 'utf8');
for (const requirement of [
"cargo-install:\n if: inputs.publish == true\n needs: publish",
"uses: ./.github/workflows/distribution-smoke.yaml", "scope: cargo",
"version: ${{ inputs.version }}", "source_commit: ${{ inputs.expected_sha }}",
"if: always()\n needs: [publish, cargo-install]", "node scripts/publication-completion.mjs cargo",
]) assert.ok(workflow.includes(requirement), requirement);
assert.doesNotMatch(workflow, /continue-on-error:|secrets[.[]/);
});
2 changes: 1 addition & 1 deletion scripts/release-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const workflow = fs.readFileSync(path.join(root, ".github", "workflows", "release.yaml"), "utf8");

test("the checked-in release workflow has the reviewed target and trust boundaries", () => {
assert.deepEqual(validateReleaseWorkflow(workflow), { actions: 19, jobs: 6, permissions: 11, targets: 4 });
assert.deepEqual(validateReleaseWorkflow(workflow), { actions: 20, jobs: 8, permissions: 14, targets: 4 });
});

test("an additional automatic trigger is rejected", () => {
Expand Down
Loading
Loading