diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b4148ae..56e7e52 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,10 @@ jobs: run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD - name: Validate provider catalogs run: node scripts/validate-provider-catalogs.mjs + - name: Validate distribution contract + run: | + node scripts/validate-distribution-contract.mjs + node --test scripts/distribution-contract.test.mjs - name: Validate pinned example templates env: STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification @@ -99,6 +103,9 @@ jobs: test -s SECURITY.md test -s docs/publication-audit.md test -s docs/provider-icon-import.md + test -s docs/distribution.md + test -s distribution/distribution-contract.json + test -s distribution/distribution-contract.schema.json test -s Cargo.toml test -s Cargo.lock test -s src/config.rs @@ -113,6 +120,8 @@ jobs: test -s scripts/generate-provider-catalogs.mjs test -s scripts/sync-example-templates.mjs test -s scripts/validate-provider-catalogs.mjs + test -s scripts/validate-distribution-contract.mjs + test -s scripts/distribution-contract.test.mjs test -s templates/catalog.json test -s templates/sources/01-minimal.stack test -s tests/specification-revision diff --git a/README.md b/README.md index 6d29ceb..2f1d7eb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `stack-sh/cli` is the open-source native Rust `stack` command for Stack architecture diagrams. -The repository contains native validation, formatting, and rendering commands. The interface remains pre-release and no supported binary distribution is published yet. +The repository contains native validation, formatting, and rendering commands. The interface remains pre-release and no supported binary distribution is published yet. The planned target matrix, artifact names, verification material, channel ownership, and rollback rules are defined by the [distribution contract](./docs/distribution.md). ## Commands diff --git a/distribution/distribution-contract.json b/distribution/distribution-contract.json new file mode 100644 index 0000000..adb0a42 --- /dev/null +++ b/distribution/distribution-contract.json @@ -0,0 +1,182 @@ +{ + "$schema": "./distribution-contract.schema.json", + "schemaVersion": 1, + "product": { + "binary": "stack", + "sourceCargoPackage": "stack-cli", + "publishedCargoPackage": null, + "sourceVersionFile": "Cargo.toml", + "currentSourceVersion": "0.3.0", + "minimumRustVersion": "1.85" + }, + "availability": { + "state": "planned", + "message": "No supported binary or package-manager release is currently published. A channel becomes available only after its required verification passes for a stable release." + }, + "versioning": { + "scheme": "Semantic Versioning", + "tagTemplate": "v{version}", + "stableVersionRequirement": "MAJOR.MINOR.PATCH without a prerelease suffix", + "prereleaseVersionRequirement": "MAJOR.MINOR.PATCH-rc.N", + "prereleasePolicy": "GitHub prerelease only; never selected by default by package managers or self-update", + "minimumSupportedVersionSource": "The minimumSupportedCliVersion field in the stable release manifest", + "preOneSupportWindow": "latest stable release only", + "stableSupportWindow": "latest two minor lines after 1.0.0" + }, + "artifacts": { + "archiveNameTemplate": "stack-v{version}-{target}.tar.gz", + "archiveRootTemplate": "stack-v{version}-{target}", + "requiredEntries": [ + "stack", + "LICENSE", + "NOTICE", + "THIRD_PARTY_LICENSES.md" + ], + "releaseManifestNameTemplate": "stack-v{version}-release-manifest.json", + "checksumNameTemplate": "stack-v{version}-checksums.txt", + "signatureBundleNameTemplate": "stack-v{version}-checksums.txt.sigstore.json", + "sbomNameTemplate": "stack-v{version}-{target}.spdx.json", + "provenanceNameTemplate": "stack-v{version}-{target}.intoto.jsonl", + "checksumAlgorithm": "sha256", + "reproducibility": { + "archiveOrder": "bytewise path order", + "uid": 0, + "gid": 0, + "mtime": "SOURCE_DATE_EPOCH", + "gzipHeader": "no original filename or wall-clock timestamp" + } + }, + "targets": [ + { + "target": "aarch64-apple-darwin", + "os": "macos", + "architecture": "arm64", + "libc": "system", + "minimumRuntime": "macOS 13", + "supportTier": "tier-1", + "state": "planned" + }, + { + "target": "x86_64-apple-darwin", + "os": "macos", + "architecture": "x86_64", + "libc": "system", + "minimumRuntime": "macOS 13", + "supportTier": "tier-1", + "state": "planned" + }, + { + "target": "aarch64-unknown-linux-gnu", + "os": "linux", + "architecture": "arm64", + "libc": "glibc", + "minimumRuntime": "glibc 2.31", + "supportTier": "tier-1", + "state": "planned" + }, + { + "target": "x86_64-unknown-linux-gnu", + "os": "linux", + "architecture": "x86_64", + "libc": "glibc", + "minimumRuntime": "glibc 2.31", + "supportTier": "tier-1", + "state": "planned" + } + ], + "unsupported": [ + { + "platform": "Windows", + "reason": "No release build or smoke-test lane is defined." + }, + { + "platform": "Linux with musl libc, including Alpine Linux", + "reason": "No musl artifact or runtime smoke-test lane is defined." + }, + { + "platform": "BSD", + "reason": "No release build or smoke-test lane is defined." + }, + { + "platform": "32-bit architectures", + "reason": "Only arm64 and x86_64 artifacts are in the supported matrix." + } + ], + "channels": [ + { + "id": "github-release", + "state": "planned", + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu" + ], + "owns": "canonical immutable binary archives, release manifest, checksums, signature bundle, SBOMs, and provenance", + "source": "tagged stack-sh/cli source", + "updatePolicy": "stable releases only unless the user requests an exact prerelease" + }, + { + "id": "homebrew", + "state": "planned", + "targets": [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu" + ], + "owns": "formula metadata, GitHub archive URL and SHA-256 mapping, install, upgrade, and uninstall lifecycle on current Homebrew tier-1 hosts", + "source": "github-release", + "updatePolicy": "Homebrew owns upgrades; stack self-update must refuse replacement" + }, + { + "id": "cargo", + "state": "planned", + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu" + ], + "owns": "a future unambiguous crates.io source package and dependency graph; installs the stack binary with Rust 1.85 or newer", + "source": "crates.io", + "updatePolicy": "Cargo owns upgrades; stack self-update must refuse replacement" + }, + { + "id": "aqua", + "state": "planned", + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu" + ], + "owns": "registry metadata and version pinning mapped to GitHub archives and SHA-256 values", + "source": "github-release", + "updatePolicy": "Aqua owns upgrades; stack self-update must refuse replacement" + }, + { + "id": "self-update", + "state": "planned", + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu" + ], + "owns": "verified atomic replacement for direct GitHub installations carrying a Stack installation receipt", + "source": "github-release", + "updatePolicy": "refuse without a direct-install receipt and print the owning package manager command" + } + ], + "verification": { + "releaseActivation": [ + "tag equals v plus the Cargo package version", + "all tier-1 archives build from the tagged commit", + "all archive contents and metadata names match this contract", + "all checksums, Sigstore bundles, SBOMs, and provenance verify", + "stack --version, help, init, check, and render pass on each target", + "the release manifest records minimumSupportedCliVersion and each verified channel" + ], + "rollback": "Never replace a tag or asset. Mark a broken release as withdrawn, remove it from default update resolution, restore package-manager metadata to the last verified release, and publish a new patch version." + } +} diff --git a/distribution/distribution-contract.schema.json b/distribution/distribution-contract.schema.json new file mode 100644 index 0000000..cc9868a --- /dev/null +++ b/distribution/distribution-contract.schema.json @@ -0,0 +1,189 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/cli/main/distribution/distribution-contract.schema.json", + "title": "Stack CLI distribution contract", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "product", + "availability", + "versioning", + "artifacts", + "targets", + "unsupported", + "channels", + "verification" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": 1 }, + "product": { + "type": "object", + "additionalProperties": false, + "required": ["binary", "sourceCargoPackage", "publishedCargoPackage", "sourceVersionFile", "currentSourceVersion", "minimumRustVersion"], + "properties": { + "binary": { "const": "stack" }, + "sourceCargoPackage": { "const": "stack-cli" }, + "publishedCargoPackage": { "type": "null" }, + "sourceVersionFile": { "const": "Cargo.toml" }, + "currentSourceVersion": { "$ref": "#/$defs/version" }, + "minimumRustVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" } + } + }, + "availability": { + "type": "object", + "additionalProperties": false, + "required": ["state", "message"], + "properties": { + "state": { "enum": ["planned", "available"] }, + "message": { "type": "string", "minLength": 1 } + } + }, + "versioning": { + "type": "object", + "additionalProperties": false, + "required": [ + "scheme", + "tagTemplate", + "stableVersionRequirement", + "prereleaseVersionRequirement", + "prereleasePolicy", + "minimumSupportedVersionSource", + "preOneSupportWindow", + "stableSupportWindow" + ], + "properties": { + "scheme": { "const": "Semantic Versioning" }, + "tagTemplate": { "const": "v{version}" }, + "stableVersionRequirement": { "type": "string", "minLength": 1 }, + "prereleaseVersionRequirement": { "type": "string", "minLength": 1 }, + "prereleasePolicy": { "type": "string", "minLength": 1 }, + "minimumSupportedVersionSource": { "type": "string", "minLength": 1 }, + "preOneSupportWindow": { "type": "string", "minLength": 1 }, + "stableSupportWindow": { "type": "string", "minLength": 1 } + } + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": [ + "archiveNameTemplate", + "archiveRootTemplate", + "requiredEntries", + "releaseManifestNameTemplate", + "checksumNameTemplate", + "signatureBundleNameTemplate", + "sbomNameTemplate", + "provenanceNameTemplate", + "checksumAlgorithm", + "reproducibility" + ], + "properties": { + "archiveNameTemplate": { "$ref": "#/$defs/template" }, + "archiveRootTemplate": { "$ref": "#/$defs/template" }, + "requiredEntries": { + "type": "array", + "minItems": 4, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "releaseManifestNameTemplate": { "$ref": "#/$defs/template" }, + "checksumNameTemplate": { "$ref": "#/$defs/template" }, + "signatureBundleNameTemplate": { "$ref": "#/$defs/template" }, + "sbomNameTemplate": { "$ref": "#/$defs/template" }, + "provenanceNameTemplate": { "$ref": "#/$defs/template" }, + "checksumAlgorithm": { "const": "sha256" }, + "reproducibility": { + "type": "object", + "additionalProperties": false, + "required": ["archiveOrder", "uid", "gid", "mtime", "gzipHeader"], + "properties": { + "archiveOrder": { "type": "string", "minLength": 1 }, + "uid": { "const": 0 }, + "gid": { "const": 0 }, + "mtime": { "const": "SOURCE_DATE_EPOCH" }, + "gzipHeader": { "type": "string", "minLength": 1 } + } + } + } + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/target" } + }, + "unsupported": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["platform", "reason"], + "properties": { + "platform": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + } + }, + "channels": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/channel" } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": ["releaseActivation", "rollback"], + "properties": { + "releaseActivation": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "rollback": { "type": "string", "minLength": 1 } + } + } + }, + "$defs": { + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-rc\\.[1-9][0-9]*)?$" + }, + "template": { + "type": "string", + "pattern": "\\{version\\}" + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": ["target", "os", "architecture", "libc", "minimumRuntime", "supportTier", "state"], + "properties": { + "target": { "type": "string", "minLength": 1 }, + "os": { "enum": ["macos", "linux"] }, + "architecture": { "enum": ["arm64", "x86_64"] }, + "libc": { "enum": ["system", "glibc"] }, + "minimumRuntime": { "type": "string", "minLength": 1 }, + "supportTier": { "const": "tier-1" }, + "state": { "enum": ["planned", "available"] } + } + }, + "channel": { + "type": "object", + "additionalProperties": false, + "required": ["id", "state", "targets", "owns", "source", "updatePolicy"], + "properties": { + "id": { "enum": ["github-release", "homebrew", "cargo", "aqua", "self-update"] }, + "state": { "enum": ["planned", "available"] }, + "targets": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + }, + "owns": { "type": "string", "minLength": 1 }, + "source": { "type": "string", "minLength": 1 }, + "updatePolicy": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/docs/distribution.md b/docs/distribution.md new file mode 100644 index 0000000..95afca8 --- /dev/null +++ b/docs/distribution.md @@ -0,0 +1,83 @@ +# Distribution contract + +This document defines the shared release contract for the Stack CLI. It is normative for GitHub Releases, Homebrew, Cargo, Aqua, and `stack` self-update implementations. The machine-readable source is [`distribution/distribution-contract.json`](../distribution/distribution-contract.json). + +No supported binary or package-manager release is published yet. Every target and channel below is **planned**, not currently available. A stable release changes availability only after its complete matrix passes the activation checks in this document. + +## Supported platform matrix + +The first supported binary matrix is intentionally narrow: + +| Rust target | OS | Architecture | Runtime floor | Direct | Homebrew | Cargo | Aqua | Self-update | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `aarch64-apple-darwin` | macOS | arm64 | macOS 13 | planned | planned | planned | planned | planned | +| `x86_64-apple-darwin` | macOS | x86_64 | macOS 13 | planned | — | planned | planned | planned | +| `aarch64-unknown-linux-gnu` | Linux | arm64 | glibc 2.31 | planned | planned | planned | planned | planned | +| `x86_64-unknown-linux-gnu` | Linux | x86_64 | glibc 2.31 | planned | planned | planned | planned | planned | + +Windows, musl-based Linux distributions such as Alpine, BSD, and 32-bit architectures are not supported release targets. A source build may happen to work elsewhere, but it is best-effort and does not block a release. Cargo installs on supported targets require Rust 1.85 or newer. Homebrew availability additionally follows [Homebrew's current tier-1 host requirements](https://docs.brew.sh/Support-Tiers); Stack does not label a host as supported when the package manager itself classifies it below tier 1. + +`tier-1` means the release must build, verify, and smoke-test that target. Missing or failing evidence blocks the entire stable release; it is not acceptable to publish a partial stable matrix. + +## Version and support policy + +- Cargo `package.version`, CLI output, the Git tag `v{version}`, release title, archive names, and release manifest version must agree exactly. +- Stable versions use `MAJOR.MINOR.PATCH`. Release candidates use `MAJOR.MINOR.PATCH-rc.N`, are GitHub prereleases, and are never selected by default by package managers or self-update. +- Before 1.0, only the latest stable release is supported. Starting at 1.0, the latest two minor lines are supported. +- Each stable release manifest records `minimumSupportedCliVersion`. This is the only input used by update clients and documentation to describe the minimum supported version. +- Existing 0.3.0 source is not a supported distribution. The first published version is selected by its release change; this contract does not reserve or silently publish one. + +## Release artifacts + +For every supported Rust target, publish: + +```text +stack-v{version}-{target}.tar.gz +stack-v{version}-{target}.spdx.json +stack-v{version}-{target}.intoto.jsonl +``` + +Each archive contains one directory named `stack-v{version}-{target}` with `stack`, `LICENSE`, `NOTICE`, and `THIRD_PARTY_LICENSES.md`. Archives use bytewise path order, numeric uid/gid 0, `SOURCE_DATE_EPOCH` for entry times, and a gzip header without a source filename or wall-clock timestamp. + +Every release also publishes: + +```text +stack-v{version}-release-manifest.json +stack-v{version}-checksums.txt +stack-v{version}-checksums.txt.sigstore.json +``` + +The sorted checksum file uses SHA-256 and covers the release manifest, all archives, all SPDX SBOMs, and all SLSA-compatible provenance files. A keyless Sigstore bundle signs the checksum file; GitHub's [artifact attestation model](https://docs.github.com/actions/concepts/security/artifact-attestations) is the trust baseline. The immutable GitHub Release asset is the canonical binary byte sequence; Homebrew and Aqua must reference its URL and digest instead of rebuilding or repacking it. Aqua uses its [`github_release` package mapping](https://aquaproj.github.io/docs/reference/registry-config/github-release-package) rather than a separate binary build. + +The release manifest records the tag, commit, source version, `minimumSupportedCliVersion`, each target's artifact names and SHA-256 values, the build identity, and each channel whose own install smoke test passed. The release implementation must version and validate that manifest schema before publishing. + +## Channel ownership + +| Channel | Owns | Must not do | +| --- | --- | --- | +| GitHub Releases | Canonical immutable archives, manifest, checksums, signature bundle, SBOMs, and provenance | Replace a tag or asset after publication | +| Homebrew | Formula metadata, archive URL/digest mapping, install, upgrade, and uninstall | Rebuild a different binary or delegate upgrades to `stack` | +| Cargo | A future unambiguous crates.io source package, its registry dependency graph, and installation of the `stack` binary | Claim binary-archive identity, promise the local `stack-cli` package name on crates.io, or publish while dependencies remain Git-only | +| Aqua | Registry metadata and version pinning mapped to canonical archives and digests | Repack an archive or select prereleases by default | +| `stack` self-update | Verified atomic replacement for direct installs with a Stack installation receipt | Replace a binary owned by Homebrew, Cargo, Aqua, or an unknown installer | + +The direct installer must create an installation receipt that identifies the GitHub Release channel, installed version, target, and artifact digest. Self-update refuses to write when that receipt is absent or names another owner and prints the appropriate package-manager upgrade command. This keeps ownership deterministic instead of guessing from an executable path. + +The workspace currently uses `stack-cli` as its local Cargo package name, but that name is already occupied by an unrelated crates.io package. No public Cargo install command is supported yet. The Cargo channel must select and verify an unambiguous registry package name, while keeping the installed binary name `stack`, before changing its state to available. + +## Release activation and rollback + +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, 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. + +## Contract validation + +Run: + +```sh +node scripts/validate-distribution-contract.mjs +node --test scripts/distribution-contract.test.mjs +``` + +The validator checks source-version and MSRV drift, the exact target and channel sets, target-to-channel references, artifact naming placeholders, archive requirements, package-manager ownership, unsupported-platform declarations, and release activation requirements. diff --git a/scripts/distribution-contract.test.mjs b/scripts/distribution-contract.test.mjs new file mode 100644 index 0000000..bf16959 --- /dev/null +++ b/scripts/distribution-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { validateDistributionContract } from "./validate-distribution-contract.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const contract = JSON.parse( + fs.readFileSync(path.join(root, "distribution", "distribution-contract.json"), "utf8"), +); +const cargoToml = fs.readFileSync(path.join(root, "Cargo.toml"), "utf8"); + +function changed(change) { + const copy = structuredClone(contract); + change(copy); + return copy; +} + +test("the checked-in distribution contract is valid", () => { + assert.deepEqual(validateDistributionContract(contract, cargoToml), { + targets: 4, + channels: 5, + }); +}); + +test("an unknown channel target is rejected", () => { + const candidate = changed((value) => value.channels[0].targets.push("x86_64-pc-windows-msvc")); + assert.throws( + () => validateDistributionContract(candidate, cargoToml), + /references unknown target x86_64-pc-windows-msvc/, + ); +}); + +test("source version drift is rejected", () => { + const candidate = changed((value) => { + value.product.currentSourceVersion = "0.2.0"; + }); + assert.throws(() => validateDistributionContract(candidate, cargoToml), /must match Cargo.toml/); +}); + +test("an unverified crates.io package name is rejected", () => { + const candidate = changed((value) => { + value.product.publishedCargoPackage = "stack-cli"; + }); + assert.throws(() => validateDistributionContract(candidate, cargoToml), /must remain unset/); +}); + +test("an incomplete archive contract is rejected", () => { + const candidate = changed((value) => value.artifacts.requiredEntries.pop()); + assert.throws(() => validateDistributionContract(candidate, cargoToml), /archive entries must be exactly/); +}); + +test("package-manager ownership cannot be delegated to self-update", () => { + const candidate = changed((value) => { + value.channels.find(({ id }) => id === "homebrew").updatePolicy = "stack replaces the binary"; + }); + assert.throws(() => validateDistributionContract(candidate, cargoToml), /must own upgrades/); +}); + +test("a distribution cannot be marked available before release verification", () => { + const candidate = changed((value) => { + value.availability.state = "available"; + }); + assert.throws(() => validateDistributionContract(candidate, cargoToml), /must remain planned/); +}); diff --git a/scripts/validate-distribution-contract.mjs b/scripts/validate-distribution-contract.mjs new file mode 100644 index 0000000..26d82e3 --- /dev/null +++ b/scripts/validate-distribution-contract.mjs @@ -0,0 +1,179 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const contractPath = path.join(root, "distribution", "distribution-contract.json"); + +const expectedTargets = [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", +]; +const expectedChannels = ["aqua", "cargo", "github-release", "homebrew", "self-update"]; +const requiredArchiveEntries = ["LICENSE", "NOTICE", "THIRD_PARTY_LICENSES.md", "stack"]; +const requiredUnsupportedTerms = ["32-bit", "BSD", "Windows", "musl"]; +const requiredActivationTerms = ["Cargo package version", "SBOMs", "provenance", "stack --version"]; +const targetDefinitions = new Map([ + ["aarch64-apple-darwin", ["macos", "arm64", "system"]], + ["x86_64-apple-darwin", ["macos", "x86_64", "system"]], + ["aarch64-unknown-linux-gnu", ["linux", "arm64", "glibc"]], + ["x86_64-unknown-linux-gnu", ["linux", "x86_64", "glibc"]], +]); + +function invariant(condition, message) { + if (!condition) throw new Error(message); +} + +function uniqueSorted(values, label) { + invariant(new Set(values).size === values.length, `${label} contains duplicates`); + return [...values].sort(); +} + +function sameValues(actual, expected, label) { + invariant( + JSON.stringify(uniqueSorted(actual, label)) === JSON.stringify([...expected].sort()), + `${label} must be exactly: ${expected.join(", ")}`, + ); +} + +function cargoValue(cargoToml, field) { + const packageSection = cargoToml.match(/\[package\]([\s\S]*?)(?:\n\[|$)/)?.[1] ?? ""; + return packageSection.match(new RegExp(`^${field}\\s*=\\s*"([^"]+)"`, "m"))?.[1]; +} + +export function validateDistributionContract(contract, cargoToml) { + invariant(contract.schemaVersion === 1, "schemaVersion must be 1"); + invariant(contract.product?.binary === "stack", "binary must be stack"); + invariant(contract.product?.sourceCargoPackage === "stack-cli", "source Cargo package must be stack-cli"); + invariant( + contract.product.sourceCargoPackage === cargoValue(cargoToml, "name"), + "sourceCargoPackage must match Cargo.toml", + ); + invariant(contract.product?.publishedCargoPackage === null, "published Cargo package must remain unset before registry ownership is verified"); + invariant(contract.availability?.state === "planned", "distribution must remain planned until a verified stable release exists"); + invariant( + contract.product.currentSourceVersion === cargoValue(cargoToml, "version"), + "currentSourceVersion must match Cargo.toml", + ); + invariant( + contract.product.minimumRustVersion === cargoValue(cargoToml, "rust-version"), + "minimumRustVersion must match Cargo.toml", + ); + + invariant(Array.isArray(contract.targets), "targets must be an array"); + sameValues( + contract.targets.map(({ target }) => target), + expectedTargets, + "target matrix", + ); + for (const target of contract.targets) { + const expected = targetDefinitions.get(target.target); + invariant( + JSON.stringify([target.os, target.architecture, target.libc]) === JSON.stringify(expected), + `${target.target} has inconsistent OS, architecture, or libc metadata`, + ); + invariant(target.supportTier === "tier-1", `${target.target} must be tier-1`); + invariant(target.state === "planned", `${target.target} must remain planned before release`); + invariant(target.minimumRuntime, `${target.target} must declare a runtime floor`); + if (target.os === "linux") invariant(target.libc === "glibc", `${target.target} must use glibc`); + if (target.os === "macos") invariant(target.libc === "system", `${target.target} must use the system libc`); + } + + invariant(Array.isArray(contract.channels), "channels must be an array"); + sameValues( + contract.channels.map(({ id }) => id), + expectedChannels, + "channel set", + ); + const targetIds = new Set(expectedTargets); + for (const channel of contract.channels) { + invariant(channel.state === "planned", `${channel.id} must remain planned before release`); + uniqueSorted(channel.targets, `${channel.id} targets`); + for (const target of channel.targets) { + invariant(targetIds.has(target), `${channel.id} references unknown target ${target}`); + } + invariant(channel.owns && channel.source && channel.updatePolicy, `${channel.id} must define ownership, source, and updates`); + } + const githubTargets = contract.channels.find(({ id }) => id === "github-release")?.targets ?? []; + const cargoTargets = contract.channels.find(({ id }) => id === "cargo")?.targets ?? []; + const aquaTargets = contract.channels.find(({ id }) => id === "aqua")?.targets ?? []; + const updateTargets = contract.channels.find(({ id }) => id === "self-update")?.targets ?? []; + const homebrewTargets = contract.channels.find(({ id }) => id === "homebrew")?.targets ?? []; + const channels = new Map(contract.channels.map((channel) => [channel.id, channel])); + sameValues(githubTargets, expectedTargets, "github-release targets"); + sameValues(cargoTargets, expectedTargets, "cargo targets"); + sameValues(aquaTargets, expectedTargets, "aqua targets"); + sameValues(updateTargets, expectedTargets, "self-update targets"); + sameValues( + homebrewTargets, + ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"], + "homebrew targets", + ); + invariant(channels.get("github-release")?.source === "tagged stack-sh/cli source", "GitHub releases must build tagged source"); + invariant(channels.get("cargo")?.source === "crates.io", "Cargo must install from crates.io"); + for (const id of ["homebrew", "aqua", "self-update"]) { + invariant(channels.get(id)?.source === "github-release", `${id} must consume GitHub releases`); + } + for (const id of ["homebrew", "cargo", "aqua"]) { + invariant( + channels.get(id)?.updatePolicy.includes("self-update must refuse replacement"), + `${id} must own upgrades instead of self-update`, + ); + } + invariant( + channels.get("self-update")?.updatePolicy.includes("refuse without a direct-install receipt"), + "self-update must require a direct-install receipt", + ); + + for (const [name, template] of Object.entries(contract.artifacts ?? {})) { + if (!name.endsWith("Template")) continue; + invariant(template.includes("{version}"), `${name} must contain {version}`); + } + for (const name of ["archiveNameTemplate", "archiveRootTemplate", "sbomNameTemplate", "provenanceNameTemplate"]) { + invariant(contract.artifacts?.[name]?.includes("{target}"), `${name} must contain {target}`); + } + sameValues(contract.artifacts?.requiredEntries ?? [], requiredArchiveEntries, "archive entries"); + invariant(contract.artifacts?.checksumAlgorithm === "sha256", "checksum algorithm must be sha256"); + invariant(contract.artifacts?.signatureBundleNameTemplate?.endsWith(".sigstore.json"), "signature bundle must use .sigstore.json"); + invariant(contract.artifacts?.sbomNameTemplate?.endsWith(".spdx.json"), "SBOM must use .spdx.json"); + invariant(contract.artifacts?.provenanceNameTemplate?.endsWith(".intoto.jsonl"), "provenance must use .intoto.jsonl"); + invariant(contract.artifacts?.reproducibility?.uid === 0, "archive uid must be zero"); + invariant(contract.artifacts?.reproducibility?.gid === 0, "archive gid must be zero"); + invariant(contract.artifacts?.reproducibility?.mtime === "SOURCE_DATE_EPOCH", "archive mtime must use SOURCE_DATE_EPOCH"); + + invariant(contract.versioning?.tagTemplate === "v{version}", "tag template must be v{version}"); + invariant(contract.versioning?.minimumSupportedVersionSource?.includes("minimumSupportedCliVersion"), "minimum version source must be explicit"); + + const unsupported = (contract.unsupported ?? []).map(({ platform }) => platform).join(" "); + for (const term of requiredUnsupportedTerms) { + invariant(unsupported.includes(term), `unsupported platforms must mention ${term}`); + } + const activation = (contract.verification?.releaseActivation ?? []).join(" "); + for (const term of requiredActivationTerms) { + invariant(activation.includes(term), `release activation must mention ${term}`); + } + invariant(contract.verification?.rollback?.includes("Never replace"), "rollback must preserve immutable releases"); + + return { + targets: contract.targets.length, + channels: contract.channels.length, + }; +} + +export function loadAndValidateDistributionContract(file = contractPath) { + const contract = JSON.parse(fs.readFileSync(file, "utf8")); + const cargoToml = fs.readFileSync(path.join(root, "Cargo.toml"), "utf8"); + return validateDistributionContract(contract, cargoToml); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = loadAndValidateDistributionContract(process.argv[2]); + console.log(`Validated ${result.targets} distribution targets and ${result.channels} channels.`); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +}