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
10 changes: 9 additions & 1 deletion .github/workflows/self-hosted-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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" \
Expand Down
2 changes: 1 addition & 1 deletion distribution/self-hosted/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 8 additions & 3 deletions scripts/build-self-hosted-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export interface BuiltRelease {
readonly manifest: ReleaseManifest;
}

const BUILD_USAGE =
"Usage: build-self-hosted-release <payload-root> <output-directory> <amd64|arm64>";

function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
Expand Down Expand Up @@ -296,6 +299,10 @@ export async function buildSelfHostedRelease(
}

async function main(): Promise<void> {
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];
Expand All @@ -304,9 +311,7 @@ async function main(): Promise<void> {
outputDirectory === undefined ||
(architecture !== "amd64" && architecture !== "arm64")
) {
throw new Error(
"Usage: build-self-hosted-release <payload-root> <output-directory> <amd64|arm64>",
);
throw new Error(BUILD_USAGE);
}
const imagesJson = process.env["SKILLWIRE_RELEASE_IMAGES_JSON"];
if (imagesJson === undefined) {
Expand Down
10 changes: 7 additions & 3 deletions scripts/verify-self-hosted-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,12 @@ export async function verifySelfHostedReleasePolicy(
}

export async function verifyCandidateFromCommandLine(): Promise<void> {
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];
Expand All @@ -413,9 +419,7 @@ export async function verifyCandidateFromCommandLine(): Promise<void> {
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),
Expand Down
20 changes: 20 additions & 0 deletions specs/004-self-hosted-onboarding/contracts/release-and-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <payload-root> <output-directory> <amd64|arm64>
pnpm verify:self-hosted \
--manifest <release-manifest> \
--bundle <sigstore-bundle> \
--archive <release-archive> \
--policy <trust-policy> \
--trusted-root <trusted-root> \
--cosign <policy-pinned-cosign> \
--architecture <amd64|arm64>
```

`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<package.version>`, 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`.
Expand Down
66 changes: 62 additions & 4 deletions specs/004-self-hosted-onboarding/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Comment thread
Lucenx9 marked this conversation as resolved.
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
Expand Down
Loading