diff --git a/.github/workflows/self-hosted-release.yml b/.github/workflows/self-hosted-release.yml index 109bbde..e7ae508 100644 --- a/.github/workflows/self-hosted-release.yml +++ b/.github/workflows/self-hosted-release.yml @@ -326,7 +326,7 @@ jobs: export SKILLWIRE_PUBLISHED_AT SKILLWIRE_PUBLISHED_AT="$(git show -s --format=%cI "${GITHUB_SHA}")" export SKILLWIRE_TRUST_SEQUENCE=1 - pnpm exec tsx scripts/build-self-hosted-release.ts \ + pnpm build:self-hosted \ "${RUNNER_TEMP}/payload-${ARCH}" \ "${RUNNER_TEMP}/release-${ARCH}" \ "${ARCH}" @@ -448,6 +448,14 @@ jobs: run: | set -euo pipefail base="${RUNNER_TEMP}/signed-assets/skillwire-${VERSION}-linux-${MATRIX_ARCH}" + pnpm verify:self-hosted \ + --manifest "${base}.release.json" \ + --bundle "${base}.release.sigstore.json" \ + --archive "${base}.tar.zst" \ + --policy "${RUNNER_TEMP}/signed-assets/skillwire-trust-policy-v1.json" \ + --trusted-root "${PWD}/distribution/self-hosted/trusted-root.v1.json" \ + --cosign "${RUNNER_TEMP}/cosign-independent" \ + --architecture "${MATRIX_ARCH}" result="$(pnpm exec tsx scripts/validate-self-hosted-quickstart.ts \ --manifest "${base}.release.json" \ --bundle "${base}.release.sigstore.json" \ diff --git a/distribution/self-hosted/README.md b/distribution/self-hosted/README.md index d1a3783..0c39014 100644 --- a/distribution/self-hosted/README.md +++ b/distribution/self-hosted/README.md @@ -82,7 +82,7 @@ Run the release verifier from a trusted source checkout, still with outbound networking blocked: ```sh -pnpm exec tsx scripts/verify-self-hosted-release.ts \ +pnpm verify:self-hosted \ --manifest skillwire-VERSION-linux-ARCH.release.json \ --bundle skillwire-VERSION-linux-ARCH.release.sigstore.json \ --archive skillwire-VERSION-linux-ARCH.tar.zst \ diff --git a/package.json b/package.json index 5f1c733..66b8d3e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "packageManager": "pnpm@11.21.0", "scripts": { "build": "tsc -p tsconfig.json", + "build:self-hosted": "tsx scripts/build-self-hosted-release.ts", + "verify:self-hosted": "tsx scripts/verify-self-hosted-release.ts", "auth:admin": "tsx src/authentication/admin-cli.ts", "advisory:verify": "tsx src/catalog/advisory-verify-cli.ts", "catalog:publish": "tsx src/catalog/publish-cli.ts", diff --git a/scripts/build-self-hosted-release.ts b/scripts/build-self-hosted-release.ts index 58ef0ca..e8ec280 100644 --- a/scripts/build-self-hosted-release.ts +++ b/scripts/build-self-hosted-release.ts @@ -42,6 +42,9 @@ export interface BuiltRelease { readonly manifest: ReleaseManifest; } +const BUILD_USAGE = + "Usage: build-self-hosted-release "; + function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } @@ -296,6 +299,10 @@ export async function buildSelfHostedRelease( } async function main(): Promise { + if (process.argv.length === 3 && process.argv[2] === "--help") { + process.stdout.write(`${BUILD_USAGE}\n`); + return; + } const payloadRoot = process.argv[2]; const outputDirectory = process.argv[3]; const architecture = process.argv[4]; @@ -304,9 +311,7 @@ async function main(): Promise { outputDirectory === undefined || (architecture !== "amd64" && architecture !== "arm64") ) { - throw new Error( - "Usage: build-self-hosted-release ", - ); + throw new Error(BUILD_USAGE); } const imagesJson = process.env["SKILLWIRE_RELEASE_IMAGES_JSON"]; if (imagesJson === undefined) { diff --git a/scripts/verify-self-hosted-release.ts b/scripts/verify-self-hosted-release.ts index 83548b5..4b9d219 100644 --- a/scripts/verify-self-hosted-release.ts +++ b/scripts/verify-self-hosted-release.ts @@ -396,6 +396,12 @@ export async function verifySelfHostedReleasePolicy( } export async function verifyCandidateFromCommandLine(): Promise { + const usage = + "Usage: verify-self-hosted-release --manifest PATH --bundle PATH --archive PATH --policy PATH --trusted-root PATH --cosign PATH --architecture amd64|arm64"; + if (process.argv.length === 3 && process.argv[2] === "--help") { + process.stdout.write(`${usage}\n`); + return; + } const manifestPath = argument("--manifest"); const bundlePaths = argumentsFor("--bundle"); const bundlePath = bundlePaths[0]; @@ -413,9 +419,7 @@ export async function verifyCandidateFromCommandLine(): Promise { cosign === undefined || (architecture !== "amd64" && architecture !== "arm64") ) { - throw new Error( - "Usage: verify-self-hosted-release --manifest PATH --bundle PATH --archive PATH --policy PATH --trusted-root PATH --cosign PATH --architecture amd64|arm64", - ); + throw new Error(usage); } const verified = await verifySignedReleaseEnvelope({ manifestPath: resolve(manifestPath), diff --git a/specs/004-self-hosted-onboarding/contracts/release-and-recovery.md b/specs/004-self-hosted-onboarding/contracts/release-and-recovery.md index 5bf2f81..bbf0368 100644 --- a/specs/004-self-hosted-onboarding/contracts/release-and-recovery.md +++ b/specs/004-self-hosted-onboarding/contracts/release-and-recovery.md @@ -15,6 +15,26 @@ The bootstrap instructions in `distribution/self-hosted/README.md` require an in Bootstrap runs `cosign verify-blob` with outbound network blocked, the local trusted root, external bundle, canonical release manifest, exact certificate identity/issuer, and the policy-required repository/workflow/tag/SHA claims. It then validates canonical encoding, policy/manifest sequences, archive size/digest, and deny lists before extraction. Once started, the CLI independently repeats the same release-pinned verification before any installation path, image, container, service secret, credential, client profile, or database mutation. Missing or stale trusted material is a blocking integrity result with bounded instructions for an explicit TUF refresh; verification never silently refreshes or performs an unbounded transparency lookup. +## Stable release entrypoints + +The protected release workflow and trusted source checkout use these stable package commands: + +```text +pnpm build:self-hosted +pnpm verify:self-hosted \ + --manifest \ + --bundle \ + --archive \ + --policy \ + --trusted-root \ + --cosign \ + --architecture +``` + +`build:self-hosted` consumes only explicit, immutable workflow inputs and deterministically emits the unsigned canonical archive and release manifest. It does not sign, publish, install, or mutate user or service state. Signing remains the separate protected-tag workflow step described below. + +`verify:self-hosted` validates the complete published or extracted release contract: canonical manifest and policy bytes, archive identity and safe extraction, Bundle v0.3 evidence, signer claims, the local TrustedRoot, payload inventory, and release policy. It fails closed when any required asset or trust input is absent and performs offline verification without consulting mutable GitHub state. A locally built unsigned payload cannot satisfy this command or enter the production installation path. + ## Signing and trust-policy contract `.github/workflows/self-hosted-release.yml` is the only release signer. The protected-tag workflow accepts only `self-hosted-v`, requires an annotated tag object, recursively peels it to the exact workflow SHA, proves the target is reachable from protected `main`, and checks manifest version/source identity before signing. It uses only command-scoped `safe.directory`, builds and completes all acceptance gates before signing, and grants only `contents: read` and `id-token: write`. diff --git a/specs/004-self-hosted-onboarding/quickstart.md b/specs/004-self-hosted-onboarding/quickstart.md index 7757bdc..38cb4ed 100644 --- a/specs/004-self-hosted-onboarding/quickstart.md +++ b/specs/004-self-hosted-onboarding/quickstart.md @@ -96,15 +96,73 @@ pnpm exec vitest run --project integration \ Expected: the compiled `skillwire` executable reaches every administrative command and bridge mode, JSON and MCP stdout stay pure, `SIGINT`/`SIGTERM` propagate, every exit maps to the contract, and the entire process-start-to-STDIO-ready/failure path remains within 10.0 seconds by a monotonic clock. The Secret Service job uses real `/usr/bin/secret-tool` inside an isolated D-Bus/keyring session, destroys all runtime/session processes and state, then proves a fresh session can use retained persistent state. A supported-host physical reboot smoke remains manual and consent-gated. -## 3. Build and verify the local release candidate +## 3. Reproduce the unsigned build and verify signed workflow assets -Build only the platform matching the current fixture: +These are separate gates. Run the first block only inside a pre-populated +release-workflow environment: `SW004_PAYLOAD_ROOT` must already contain the +exact application, bundled runtime, verified Cosign binary, catalogs, +migrations, distributions, and integrations assembled by +`.github/workflows/self-hosted-release.yml`. The remaining inputs must be the +same immutable values selected by that workflow. This quickstart deliberately +does not synthesize a substitute payload, image identity, sequence, timestamp, +or source commit. + +The guards make that precondition explicit before the public build entrypoint +deterministically creates the unsigned archive and canonical external +manifest. The command does not sign or publish them: + +```bash +: "${SW004_PAYLOAD_ROOT:?use the pre-populated release-workflow payload root}" +: "${SW004_RELEASE_IMAGES_JSON:?use the workflow's digest-pinned image JSON}" +: "${SW004_RELEASE_SEQUENCE:?use the immutable workflow release sequence}" +: "${SW004_PUBLISHED_AT:?use the immutable workflow publication time}" +: "${SW004_SOURCE_COMMIT:?use the exact protected-tag source commit}" + +export SW004_ARCH="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" +export SW004_RELEASE_OUTPUT="$SW004_ROOT/release-$SW004_ARCH" + +env \ + SKILLWIRE_RELEASE_IMAGES_JSON="$SW004_RELEASE_IMAGES_JSON" \ + SKILLWIRE_RELEASE_VERSION="$(node -p "require('./package.json').version")" \ + SKILLWIRE_RELEASE_SEQUENCE="$SW004_RELEASE_SEQUENCE" \ + SKILLWIRE_PUBLISHED_AT="$SW004_PUBLISHED_AT" \ + GITHUB_SHA="$SW004_SOURCE_COMMIT" \ + SKILLWIRE_TRUST_SEQUENCE=1 \ + pnpm build:self-hosted \ + "$SW004_PAYLOAD_ROOT" "$SW004_RELEASE_OUTPUT" "$SW004_ARCH" +``` + +The unsigned output is never an input to production verification. For the +acceptance flow below, obtain the four sibling assets from the protected-tag +workflow, place them in one private directory, and separately provide the +independently authenticated TrustedRoot and Cosign 3.1.3 paths. A normal +source/test checkout can begin here without running the reproducibility block: ```bash -pnpm build:self-hosted -- --platform linux-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') -pnpm verify:self-hosted +: "${SW004_SIGNED_ASSET_ROOT:?directory containing protected-workflow assets}" +: "${SW004_TRUSTED_ROOT:?independently authenticated local TrustedRoot}" +: "${SW004_COSIGN:?independently verified absolute Cosign 3.1.3 path}" + +export SW004_ARCH="$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" +export SW004_VERSION="$(node -p "require('./package.json').version")" + +pnpm verify:self-hosted \ + --manifest "$SW004_SIGNED_ASSET_ROOT/skillwire-$SW004_VERSION-linux-$SW004_ARCH.release.json" \ + --bundle "$SW004_SIGNED_ASSET_ROOT/skillwire-$SW004_VERSION-linux-$SW004_ARCH.release.sigstore.json" \ + --archive "$SW004_SIGNED_ASSET_ROOT/skillwire-$SW004_VERSION-linux-$SW004_ARCH.tar.zst" \ + --policy "$SW004_SIGNED_ASSET_ROOT/skillwire-trust-policy-v1.json" \ + --trusted-root "$SW004_TRUSTED_ROOT" \ + --cosign "$SW004_COSIGN" \ + --architecture "$SW004_ARCH" ``` +`SW004_RELEASE_IMAGES_JSON`, `SW004_RELEASE_SEQUENCE`, +`SW004_PUBLISHED_AT`, and `SW004_SOURCE_COMMIT` are explicit immutable inputs +from the protected release workflow. The signed-asset verifier requires the +external Bundle v0.3 and policy-pinned TrustedRoot; the unsigned reproducibility +output therefore cannot pass production verification or become an +installation input. + The release job emits exactly four sibling assets for that platform: ```text diff --git a/tests/contract/release/release-entrypoints.test.ts b/tests/contract/release/release-entrypoints.test.ts new file mode 100644 index 0000000..69edbeb --- /dev/null +++ b/tests/contract/release/release-entrypoints.test.ts @@ -0,0 +1,311 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { chmod, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + createOnboardingEnvironment, + type OnboardingEnvironment, +} from "../../helpers/onboarding-environment.js"; +import { + RELEASE_PAYLOAD_FILES, + releasePayloadMode, +} from "../../helpers/self-hosted-release-fixtures.js"; + +interface PackageFile { + readonly scripts?: Readonly>; + readonly version?: string; +} + +const implementations = { + "build:self-hosted": "scripts/build-self-hosted-release.ts", + "verify:self-hosted": "scripts/verify-self-hosted-release.ts", +} as const; + +function packageFile(): PackageFile { + return JSON.parse( + readFileSync(resolve("package.json"), "utf8"), + ) as PackageFile; +} + +function runPackageCommand( + command: keyof typeof implementations, + args: readonly string[], + environment: NodeJS.ProcessEnv = {}, +) { + return spawnSync( + process.execPath, + [ + resolve("node_modules/tsx/dist/cli.mjs"), + resolve(implementations[command]), + ...args, + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + HOME: "/nonexistent", + PATH: process.env["PATH"], + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8", + CI: "1", + ...environment, + }, + timeout: 60_000, + }, + ); +} + +function runSourceCli(args: readonly string[], environment: NodeJS.ProcessEnv) { + return spawnSync( + process.execPath, + [ + resolve("node_modules/tsx/dist/cli.mjs"), + resolve("src/onboarding/cli/main.ts"), + ...args, + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { ...environment, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" }, + timeout: 60_000, + }, + ); +} + +describe("public self-hosted release entrypoints", () => { + let fixture: OnboardingEnvironment | undefined; + + afterEach(async () => fixture?.close()); + + it("publishes one tracked package command for each normative release entrypoint", () => { + const packageJson = packageFile(); + expect(packageJson.scripts?.["build:self-hosted"]).toBe( + "tsx scripts/build-self-hosted-release.ts", + ); + expect(packageJson.scripts?.["verify:self-hosted"]).toBe( + "tsx scripts/verify-self-hosted-release.ts", + ); + + for (const implementation of Object.values(implementations)) { + expect( + readFileSync(resolve(implementation), "utf8").length, + ).toBeGreaterThan(0); + if (existsSync(resolve(".git"))) { + expect( + execFileSync( + "/usr/bin/git", + [ + "-c", + `safe.directory=${process.cwd()}`, + "ls-files", + "--error-unmatch", + implementation, + ], + { + encoding: "utf8", + }, + ).trim(), + ).toBe(implementation); + } + } + + const quickstart = readFileSync( + resolve("specs/004-self-hosted-onboarding/quickstart.md"), + "utf8", + ); + const readme = readFileSync( + resolve("distribution/self-hosted/README.md"), + "utf8", + ); + const releaseContract = readFileSync( + resolve( + "specs/004-self-hosted-onboarding/contracts/release-and-recovery.md", + ), + "utf8", + ); + const workflow = readFileSync( + resolve(".github/workflows/self-hosted-release.yml"), + "utf8", + ); + for (const source of [quickstart, readme, releaseContract, workflow]) { + expect(source).not.toContain("pnpm build:self-hosted --"); + expect(source).not.toContain("pnpm verify:self-hosted --"); + } + expect(quickstart).toContain("pnpm build:self-hosted \\"); + expect(quickstart).toContain("pnpm verify:self-hosted \\"); + expect(readme).toContain("pnpm verify:self-hosted \\"); + expect(releaseContract).toContain("pnpm build:self-hosted "); + expect(releaseContract).toContain("pnpm verify:self-hosted \\"); + expect(workflow).toContain("pnpm build:self-hosted \\"); + expect(workflow).toContain("pnpm verify:self-hosted \\"); + }); + + it.each([ + ["build:self-hosted", "Usage: build-self-hosted-release"], + ["verify:self-hosted", "Usage: verify-self-hosted-release"], + ] as const)( + "starts %s help without production credentials", + (command, usage) => { + const result = runPackageCommand(command, ["--help"]); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toContain(usage); + expect(result.stderr).toBe(""); + }, + ); + + it("keeps unsigned reproducibility separate from signed-asset verification", () => { + const quickstart = readFileSync( + resolve("specs/004-self-hosted-onboarding/quickstart.md"), + "utf8", + ); + const buildIndex = quickstart.indexOf("pnpm build:self-hosted \\"); + const verifyIndex = quickstart.indexOf("pnpm verify:self-hosted \\"); + expect(buildIndex).toBeGreaterThan(0); + expect(verifyIndex).toBeGreaterThan(buildIndex); + const buildPrelude = quickstart.slice(0, buildIndex); + for (const input of [ + "SW004_PAYLOAD_ROOT", + "SW004_RELEASE_IMAGES_JSON", + "SW004_RELEASE_SEQUENCE", + "SW004_PUBLISHED_AT", + "SW004_SOURCE_COMMIT", + ]) { + expect(buildPrelude).toContain(`\${${input}:?`); + } + const verification = quickstart.slice( + verifyIndex, + quickstart.indexOf("```", verifyIndex), + ); + expect(verification).toContain("$SW004_SIGNED_ASSET_ROOT/"); + expect(verification).not.toContain("$SW004_RELEASE_OUTPUT/"); + expect(quickstart).toContain( + "The unsigned output is never an input to production verification", + ); + }); + + it("builds the same unsigned canonical outputs without signing or publishing", async () => { + fixture = await createOnboardingEnvironment(); + const payload = resolve(fixture.root, "payload"); + for (const [path, contents] of Object.entries(RELEASE_PAYLOAD_FILES)) { + const target = resolve(payload, path); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + await writeFile(target, contents, { mode: releasePayloadMode(path) }); + await chmod(target, releasePayloadMode(path)); + } + const environment = { + HOME: fixture.home, + SKILLWIRE_RELEASE_IMAGES_JSON: JSON.stringify([ + { + role: "skillwire", + repository: "ghcr.io/lucenx9/skillwire", + digest: `sha256:${"1".repeat(64)}`, + platform: "linux/amd64", + }, + { + role: "postgres", + repository: "docker.io/library/postgres", + digest: `sha256:${"2".repeat(64)}`, + platform: "linux/amd64", + }, + ]), + SKILLWIRE_RELEASE_VERSION: "0.2.0", + SKILLWIRE_RELEASE_SEQUENCE: "17", + SKILLWIRE_PUBLISHED_AT: "2026-08-15T00:00:00.000Z", + GITHUB_SHA: "1".repeat(40), + SKILLWIRE_TRUST_SEQUENCE: "1", + }; + const outputs = [ + resolve(fixture.root, "release-a"), + resolve(fixture.root, "release-b"), + ]; + for (const output of outputs) { + const result = runPackageCommand( + "build:self-hosted", + [payload, output, "amd64"], + environment, + ); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + expect((await readdir(output)).toSorted()).toEqual([ + "skillwire-0.2.0-linux-amd64.release.json", + "skillwire-0.2.0-linux-amd64.tar.zst", + ]); + } + const firstOutput = outputs[0]; + const secondOutput = outputs[1]; + if (firstOutput === undefined || secondOutput === undefined) { + throw new Error("two deterministic release outputs are required"); + } + for (const filename of await readdir(firstOutput)) { + expect(await readFile(resolve(firstOutput, filename))).toEqual( + await readFile(resolve(secondOutput, filename)), + ); + } + }); + + it("fails closed when a release asset, signature, or trust input is absent", async () => { + fixture = await createOnboardingEnvironment(); + const inputs = [ + ["--manifest", resolve(fixture.root, "release.json")], + ["--bundle", resolve(fixture.root, "release.sigstore.json")], + ["--archive", resolve(fixture.root, "release.tar.zst")], + ["--policy", resolve(fixture.root, "trust-policy.json")], + ["--trusted-root", resolve(fixture.root, "trusted-root.json")], + ["--cosign", resolve(fixture.root, "cosign")], + ["--architecture", "amd64"], + ] as const; + for (const missing of [ + "--manifest", + "--bundle", + "--policy", + "--trusted-root", + ]) { + const args = inputs + .filter(([flag]) => flag !== missing) + .flatMap(([flag, value]) => [flag, value]); + const result = runPackageCommand("verify:self-hosted", args, { + HOME: fixture.home, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(12); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Usage: verify-self-hosted-release"); + expect(result.stderr).not.toContain('verified":true'); + } + + const missingFiles = runPackageCommand( + "verify:self-hosted", + inputs.flatMap(([flag, value]) => [flag, value]), + { HOME: fixture.home }, + ); + expect(missingFiles.error).toBeUndefined(); + expect(missingFiles.status).toBe(12); + expect(missingFiles.stdout).toBe(""); + expect(missingFiles.stderr).not.toContain('verified":true'); + }); + + it("does not make unsigned source-checkout setup available", async () => { + fixture = await createOnboardingEnvironment(); + const before = await readdir(fixture.xdgDataHome); + const result = runSourceCli( + ["setup", "--clients", "codex", "--preview-only", "--output", "json"], + fixture.environment, + ); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(12); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout) as unknown).toMatchObject({ + command: "setup", + status: "failure", + exitClass: "release-integrity-failure", + changed: false, + }); + expect(await readdir(fixture.xdgDataHome)).toEqual(before); + }); +}); diff --git a/tests/contract/release/signing-workflow.test.ts b/tests/contract/release/signing-workflow.test.ts index 69cc41f..a27ad8b 100644 --- a/tests/contract/release/signing-workflow.test.ts +++ b/tests/contract/release/signing-workflow.test.ts @@ -74,7 +74,8 @@ describe("protected self-hosted signing", () => { expect(workflow).toContain( "github.ref == format('refs/tags/self-hosted-v{0}'", ); - expect(workflow).toContain("scripts/build-self-hosted-release.ts"); + expect(workflow).toContain("pnpm build:self-hosted \\"); + expect(workflow).toContain("pnpm verify:self-hosted \\"); expect(workflow).toContain("NODE_VERSION: 24.18.0"); expect(workflow).toContain("node-v${NODE_VERSION}-linux-${node_arch}"); expect(workflow).toContain("cosign-linux-arm64");