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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ jobs:
uses: actions/checkout@v7
- name: Validate initial Cargo publication guards
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
python3 -m unittest scripts/test_smoke_installed_cli.py

- name: Read supported specification revision
id: specification
Expand Down
196 changes: 196 additions & 0 deletions .github/workflows/distribution-smoke.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
name: Distribution smoke

on:
pull_request:
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: Exact stable release (empty uses the distribution contract)
type: string
default: ''
workflow_call:
inputs:
version:
type: string
default: ''
scope:
type: string
default: all
source_commit:
type: string
default: ''

permissions:
contents: read
attestations: read

concurrency:
group: distribution-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ inputs.scope || 'all' }}-${{ inputs.version || 'current' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

defaults:
run:
shell: bash

jobs:
context:
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
version: ${{ steps.context.outputs.version }}
scope: ${{ steps.context.outputs.scope }}
source: ${{ steps.context.outputs.source }}
tap: ${{ steps.context.outputs.tap }}
matrix: ${{ steps.context.outputs.matrix }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Resolve exact published version, source, and supported matrix
id: context
env:
GH_TOKEN: ${{ github.token }}
SMOKE_VERSION: ${{ inputs.version }}
SMOKE_SCOPE: ${{ inputs.scope }}
SMOKE_SOURCE_COMMIT: ${{ inputs.source_commit }}
run: node scripts/distribution-smoke-context.mjs >> "$GITHUB_OUTPUT"

install:
name: install (${{ matrix.channel }}, ${{ matrix.target }}, Rust ${{ matrix.rust }})
needs: context
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.context.outputs.matrix) }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.context.outputs.version }}
SOURCE_COMMIT: ${{ needs.context.outputs.source }}
TAP_COMMIT: ${{ needs.context.outputs.tap }}
CHANNEL: ${{ matrix.channel }}
TARGET: ${{ matrix.target }}
RUST_VERSION: ${{ matrix.rust }}
steps:
- name: Check out current verification code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Check out exact published source for command and catalog comparisons
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
repository: stack-sh/cli
ref: ${{ needs.context.outputs.source }}
path: .release-source
persist-credentials: false
- name: Verify canonical archive checksum, provenance, and package before execution
if: matrix.channel != 'cargo'
run: |
python3 -m scripts.download_smoke_archive --version "$VERSION" --target "$TARGET" \
--source-commit "$SOURCE_COMMIT" --source-root "$GITHUB_WORKSPACE/.release-source" \
--destination "$RUNNER_TEMP/canonical-release"
echo "CANONICAL_BINARY=$RUNNER_TEMP/canonical-release/stack-v${VERSION}-${TARGET}/stack" >> "$GITHUB_ENV"

- name: Install directly into an empty prefix
if: matrix.channel == 'direct'
run: |
mkdir "$RUNNER_TEMP/direct-install"
install -m 0755 "$CANONICAL_BINARY" "$RUNNER_TEMP/direct-install/stack"
echo "STACK_BINARY=$RUNNER_TEMP/direct-install/stack" >> "$GITHUB_ENV"

- name: Install pinned Aqua
if: matrix.channel == 'aqua'
uses: aquaproj/aqua-installer@96a9bc20066c5bf5e275b41019cfc165b25f4e2e # v4.0.5
with:
aqua_version: v2.62.3
enable_aqua_install: false
- name: Install through Aqua in a fresh project and store
if: matrix.channel == 'aqua'
env:
AQUA_ROOT_DIR: ${{ runner.temp }}/aqua-root
XDG_CONFIG_HOME: ${{ runner.temp }}/aqua-config
run: |
project="$RUNNER_TEMP/aqua-project"
node scripts/prepare-aqua-smoke.mjs "$VERSION" "$project"
git init --quiet "$project"
cd "$project"
export AQUA_CONFIG="$project/aqua.yaml"
export AQUA_POLICY_CONFIG="$project/aqua-policy.yaml"
aqua policy allow
aqua update-checksum
aqua install
echo "STACK_BINARY=$(aqua which stack)" >> "$GITHUB_ENV"

- name: Set up supported Homebrew host
if: matrix.channel == 'homebrew'
uses: Homebrew/actions/setup-homebrew@3cdb78d0f62ad29dd32de765782654f4eedea607
- name: Install the exact official tap revision into a clean formula prefix
if: matrix.channel == 'homebrew'
env:
HOMEBREW_NO_AUTO_UPDATE: 1
HOMEBREW_NO_ANALYTICS: 1
HOMEBREW_CACHE: ${{ runner.temp }}/brew-cache
run: |
brew tap stack-sh/tap
tap_path=$(brew --repository stack-sh/tap)
git -C "$tap_path" fetch origin "$TAP_COMMIT"
git -C "$tap_path" checkout --detach "$TAP_COMMIT"
test "$(git -C "$tap_path" rev-parse HEAD)" = "$TAP_COMMIT"
brew info --json=v2 stack-sh/tap/stack > "$RUNNER_TEMP/formula.json"
node --input-type=module -e 'import fs from "node:fs"; import assert from "node:assert/strict"; const [formula] = JSON.parse(fs.readFileSync(process.env.RUNNER_TEMP + "/formula.json")).formulae; assert.equal(formula.versions.stable, process.env.VERSION); assert.equal(formula.installed.length, 0);'
brew install stack-sh/tap/stack
brew test stack-sh/tap/stack
prefix=$(brew --prefix stack-sh/tap/stack)
cmp "$prefix/etc/bash_completion.d/stack" .release-source/distribution/generated/share/bash-completion/completions/stack
for asset in zsh/site-functions/_stack fish/vendor_completions.d/stack.fish man/man1/stack.1; do
cmp "$prefix/share/$asset" ".release-source/distribution/generated/share/$asset"
done
echo "STACK_BINARY=$prefix/bin/stack" >> "$GITHUB_ENV"

- name: Install exact registry package with a fresh Cargo cache and build directory
if: matrix.channel == 'cargo'
env:
CARGO_HOME: ${{ runner.temp }}/cargo-registry-home
CARGO_TARGET_DIR: ${{ runner.temp }}/cargo-registry-target
run: |
rustup toolchain install "$RUST_VERSION" --profile minimal
cargo "+$RUST_VERSION" install stack-diagram-cli --version "=$VERSION" --locked --registry crates-io --root "$RUNNER_TEMP/cargo-install"
python3 -m scripts.verify_smoke_cargo_source --cargo-home "$CARGO_HOME" --version "$VERSION" --source-commit "$SOURCE_COMMIT"
echo "STACK_BINARY=$RUNNER_TEMP/cargo-install/bin/stack" >> "$GITHUB_ENV"

- name: Exercise the installed CLI and a real audited provider import
run: |
comparison=()
if [ "$CHANNEL" != cargo ]; then comparison=(--canonical-binary "$CANONICAL_BINARY"); fi
python3 -m scripts.smoke_installed_cli --binary "$STACK_BINARY" --target "$TARGET" --version "$VERSION" \
--source-root "$GITHUB_WORKSPACE/.release-source" "${comparison[@]}" > "$RUNNER_TEMP/smoke.json"
cat "$RUNNER_TEMP/smoke.json" >> "$GITHUB_STEP_SUMMARY"
- name: Preserve only verification metadata, never imported artwork
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: install-${{ matrix.channel }}-${{ matrix.target }}-${{ matrix.rust }}
path: ${{ runner.temp }}/smoke.json
if-no-files-found: error
retention-days: 14

completion:
name: distribution smoke completion
if: always()
needs: [context, install]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Fail closed unless every requested installation passed
env:
CONTEXT_RESULT: ${{ needs.context.result }}
INSTALL_RESULT: ${{ needs.install.result }}
SMOKE_SCOPE: ${{ needs.context.outputs.scope }}
SMOKE_VERSION: ${{ needs.context.outputs.version }}
run: |
printf 'Scope: %s; version: %s; context: %s; installs: %s\n' "$SMOKE_SCOPE" "$SMOKE_VERSION" "$CONTEXT_RESULT" "$INSTALL_RESULT" >> "$GITHUB_STEP_SUMMARY"
node --input-type=module -e 'import { requireSuccessfulSmoke } from "./scripts/distribution-smoke-context.mjs"; requireSuccessfulSmoke(process.env.CONTEXT_RESULT, process.env.INSTALL_RESULT);'
26 changes: 26 additions & 0 deletions docs/distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,32 @@ The source and published Cargo package names are both `stack-diagram-cli`; the i

## Release activation and rollback

### Continuous clean-install verification

[`Distribution smoke`](../.github/workflows/distribution-smoke.yaml) runs on pull requests and pushes to `main`, and accepts a manual exact stable version. Without an override it tests `currentReleaseVersion` from the distribution contract, not the possibly unpublished source version. It resolves the immutable release source and the official tap revision once before starting:

| Channel | Native installation cells |
| --- | --- |
| Direct archive | Four supported targets |
| Aqua 2.62.3 | Four supported targets, fresh Git project and Aqua store |
| Cargo | Four supported targets, each with Rust 1.85.0 and stable; fresh registry cache and build directory |
| Homebrew | Apple Silicon macOS, GNU/Linux arm64 and x86_64; fresh Stack formula prefix and download cache |

All 19 cells execute the installed binary on the matching native architecture. They check the exact version, help, configuration, doctor, templates, validation, formatting, SVG/JSON output, and completion/manual generation against the **published source**. Each also explicitly imports the audited Simple Icons catalog into a disposable store and renders an imported icon with its attribution. Only result metadata is uploaded; imported artwork and rendered provider examples are not redistributed as CI artifacts.

Direct, Aqua, and Homebrew binaries must byte-match the canonical archive after checksum, source-bound GitHub provenance, and archive-layout verification. Cargo must install the exact registry package with `--locked` and match its packaged source commit; it is not expected to reproduce prebuilt binary bytes. Homebrew additionally checks installed completion/manual files. Fresh installations do not use the repository's Cargo build output or an existing Stack configuration/icon store.

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.

Run the negative guards locally with:

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

A channel becomes available only after all of its target builds and clean-install smoke tests pass. A stable GitHub release additionally requires matching tag/version metadata, complete archive contents, valid checksums and Sigstore bundle, inspectable SPDX SBOMs and provenance, exact generated completion/manual bytes, and successful `stack --version`, `help`, `init`, `check`, and `render` smoke tests on every tier-1 target.

Tags and assets are immutable. For a broken release, mark it as withdrawn, exclude it from default update resolution, restore package-manager metadata to the last verified release, and publish a new patch version. Do not overwrite the broken tag or assets. Cargo may yank a broken package version, but yanking is not deletion and the replacement still uses a new version.
Expand Down
82 changes: 82 additions & 0 deletions scripts/distribution-smoke-context.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";

const targets = [
["aarch64-apple-darwin", "macos-15"],
["x86_64-apple-darwin", "macos-15-intel"],
["aarch64-unknown-linux-gnu", "ubuntu-24.04-arm"],
["x86_64-unknown-linux-gnu", "ubuntu-24.04"],
];

export function smokeMatrix(scope) {
assert.ok(["all", "native", "cargo"].includes(scope), "Unsupported smoke scope");
const include = [];
for (const [target, runner] of targets) {
if (scope !== "cargo") {
for (const channel of ["direct", "aqua"]) include.push({ channel, target, runner, rust: "none" });
}
if (scope !== "native") {
for (const rust of ["1.85.0", "stable"]) include.push({ channel: "cargo", target, runner, rust });
}
if (scope === "all" && target !== "x86_64-apple-darwin") {
include.push({ channel: "homebrew", target, runner: target.endsWith("apple-darwin") ? "macos-26" : runner, rust: "none" });
}
}
return { include };
}

export function validateVersion(version) {
assert.match(version, /^\d+\.\d+\.\d+$/, "Only exact stable versions are supported");
return version;
}

export function validateRelease(release, version) {
validateVersion(version);
assert.equal(release.tagName, `v${version}`, "Release version mismatch");
assert.equal(release.isDraft, false, "Draft release is not installable");
assert.equal(release.isPrerelease, false, "Prerelease is not a stable channel release");
const names = release.assets.map(asset => asset.name);
assert.equal(new Set(names).size, names.length, "Duplicate release asset");
for (const name of [
`stack-v${version}-checksums.txt`,
`stack-v${version}-checksums.txt.sigstore.json`,
`stack-v${version}-release-manifest.json`,
...targets.flatMap(([target]) => ["tar.gz", "spdx.json", "provenance.sigstore.json", "sbom.sigstore.json"].map(suffix => `stack-v${version}-${target}.${suffix}`)),
]) assert.ok(names.includes(name), `Missing release asset: ${name}`);
}

export function requireSuccessfulSmoke(context, install) {
assert.equal(context, "success", "Smoke context failed or was skipped");
assert.equal(install, "success", "At least one install failed, was cancelled, or was skipped");
}

function gh(...args) {
return JSON.parse(execFileSync("gh", args, { encoding: "utf8", timeout: 60_000 }));
}

export function resolveContext(env = process.env) {
const scope = env.SMOKE_SCOPE || "all";
const contract = JSON.parse(readFileSync("distribution/distribution-contract.json", "utf8"));
const version = validateVersion(env.SMOKE_VERSION || contract.product.currentReleaseVersion);
const matrix = smokeMatrix(scope);
let source = env.SMOKE_SOURCE_COMMIT;
if (scope === "cargo") {
assert.match(source || "", /^[0-9a-f]{40}$/, "Cargo-only smoke requires its published source commit");
} else {
const release = gh("release", "view", `v${version}`, "--repo", "stack-sh/cli", "--json", "tagName,isDraft,isPrerelease,assets");
validateRelease(release, version);
const tagged = gh("api", `repos/stack-sh/cli/commits/v${version}`).sha;
if (source) assert.equal(source, tagged, "Published source does not match the release tag");
source = tagged;
}
assert.match(source, /^[0-9a-f]{40}$/);
const tap = scope === "all" ? gh("api", "repos/stack-sh/homebrew-tap/git/ref/heads/main").object.sha : "";
if (tap) assert.match(tap, /^[0-9a-f]{40}$/);
return { version, scope, source, tap, matrix: JSON.stringify(matrix) };
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
for (const [key, value] of Object.entries(resolveContext())) console.log(`${key}=${value}`);
}
Loading
Loading