diff --git a/.github/workflows/cargo-publish.yaml b/.github/workflows/cargo-publish.yaml index e8173d8..74831b1 100644 --- a/.github/workflows/cargo-publish.yaml +++ b/.github/workflows/cargo-publish.yaml @@ -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" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index db9a415..b133880 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7c84147..9e22129 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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" \ @@ -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" \ @@ -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" diff --git a/docs/cargo-releasing.md b/docs/cargo-releasing.md index 2333d39..a84c670 100644 --- a/docs/cargo-releasing.md +++ b/docs/cargo-releasing.md @@ -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. diff --git a/docs/distribution.md b/docs/distribution.md index 1b64b19..961f554 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -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 ``` diff --git a/scripts/publication-completion.mjs b/scripts/publication-completion.mjs new file mode 100644 index 0000000..b7c7984 --- /dev/null +++ b/scripts/publication-completion.mjs @@ -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)); +} diff --git a/scripts/publication-completion.test.mjs b/scripts/publication-completion.test.mjs new file mode 100644 index 0000000..386bf5f --- /dev/null +++ b/scripts/publication-completion.test.mjs @@ -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[.[]/); +}); diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index 96ec4c5..a3fd638 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -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", () => { diff --git a/scripts/validate-release-workflow.mjs b/scripts/validate-release-workflow.mjs index d1e5ea9..d8303ac 100644 --- a/scripts/validate-release-workflow.mjs +++ b/scripts/validate-release-workflow.mjs @@ -21,6 +21,8 @@ const expectedPermissions = new Map([ ["attest", ["attestations: write", "contents: read", "id-token: write"]], ["assemble", ["attestations: write", "contents: read", "id-token: write"]], ["publish", ["attestations: read", "contents: write"]], + ["native-install", ["attestations: read", "contents: read"]], + ["completion", ["contents: read"]], ]); function invariant(condition, message) { @@ -73,7 +75,7 @@ export function validateReleaseWorkflow(source) { const jobs = extractJobs(workflow); invariant( JSON.stringify([...jobs.keys()].sort()) === JSON.stringify([...expectedPermissions.keys()].sort()), - "release workflow must contain only the six reviewed jobs", + "release workflow must contain only the eight reviewed jobs", ); let permissionCount = 0; for (const [name, expected] of expectedPermissions) { @@ -103,6 +105,7 @@ export function validateReleaseWorkflow(source) { ["attest", "20"], ["assemble", "15"], ["publish", "15"], + ["completion", "5"], ]); for (const [name, minutes] of expectedTimeouts) { invariant(jobs.get(name).includes(`timeout-minutes: ${minutes}`), `${name} timeout is missing or changed`); @@ -111,7 +114,7 @@ export function validateReleaseWorkflow(source) { const runsOn = [...workflow.matchAll(/^ runs-on: (.+)$/gm)].map((match) => match[1]); invariant( JSON.stringify(runsOn.sort()) === - JSON.stringify(["${{ matrix.runner }}", "${{ matrix.runner }}", "ubuntu-24.04", "ubuntu-24.04", "ubuntu-24.04", "ubuntu-24.04"].sort()), + JSON.stringify(["${{ matrix.runner }}", "${{ matrix.runner }}", "ubuntu-24.04", "ubuntu-24.04", "ubuntu-24.04", "ubuntu-24.04", "ubuntu-24.04"].sort()), "release jobs must use only reviewed GitHub-hosted runners", ); const targetRunners = [ @@ -145,6 +148,7 @@ export function validateReleaseWorkflow(source) { invariant(occurrences(job, "python3 scripts/package_release.py create") === 2, `${name} must package twice`); invariant(job.includes('cmp "$first_archive" "$second_archive"'), `${name} must compare rebuilt archives`); invariant(job.includes("python3 scripts/verify_release_binary.py"), `${name} must run native binary smoke tests`); + invariant(job.includes('python3 -m scripts.smoke_installed_cli --binary "$first_binary" --target "$target" --version "$VERSION" --source-root "$GITHUB_WORKSPACE"'), `${name} must import and render a real provider before publication`); invariant(job.includes("--remap-path-prefix=$GITHUB_WORKSPACE=/workspace"), `${name} must remap source paths`); } for (const requirement of [ @@ -160,8 +164,11 @@ export function validateReleaseWorkflow(source) { invariant(!macos.includes("-no_uuid"), "macOS binaries must retain a valid UUID"); invariant(linux.includes('test "$(rustc --version)" = "rustc 1.85.0 (4d91de4e4 2025-02-17)"'), "GNU/Linux Rust version must be exact"); - const uses = [...workflow.matchAll(/^\s+uses: ([^\s#]+)(?:\s+#.*)?$/gm)].map((match) => match[1]); - invariant(uses.length === 19, "release workflow action count changed and requires review"); + const allUses = [...workflow.matchAll(/^\s+(?:- )?uses: ([^\s#]+)(?:\s+#.*)?$/gm)].map((match) => match[1]); + const localUses = allUses.filter(action => action.startsWith('./')); + invariant(JSON.stringify(localUses) === JSON.stringify(['./.github/workflows/distribution-smoke.yaml']), "only the reviewed local installation workflow may be reused"); + const uses = allUses.filter(action => !action.startsWith('./')); + invariant(uses.length === 20, "release workflow action count changed and requires review"); for (const action of uses) { const match = action.match(/^([^@]+)@([0-9a-f]{40})$/); invariant(match, `action must be pinned to a full commit: ${action}`); @@ -223,6 +230,17 @@ export function validateReleaseWorkflow(source) { invariant(publish.includes("python3 scripts/verify_release_binary.py"), "a downloaded native binary must pass install smoke tests"); invariant(publish.includes('gh release edit "$TAG" --draft=false'), "only a fully verified draft may be published"); + const install = jobs.get("native-install"); + for (const requirement of [ + "if: needs.context.outputs.publish == 'true' && !contains(needs.context.outputs.version, '-rc.')", + "needs: [context, publish]", "scope: native", "version: ${{ needs.context.outputs.version }}", "source_commit: ${{ github.sha }}", + ]) invariant(install.includes(requirement), `post-publication installation requirement is missing: ${requirement}`); + const completion = jobs.get("completion"); + for (const requirement of [ + "if: always()", "needs: [context, build-macos, build-linux, attest, assemble, publish, native-install]", + "PUBLICATION_NEEDS: ${{ toJSON(needs) }}", "node scripts/publication-completion.mjs release", + ]) invariant(completion.includes(requirement), `release completion requirement is missing: ${requirement}`); + return { actions: uses.length, jobs: jobs.size, permissions: permissionCount, targets: targetRunners.length }; }