| … | … | @@ -66,6 +66,9 @@ canary promotion remain required before deployment.** |
| 66 | 66 | - The exact-target updater repair command defaults to a read-only probe and |
| 67 | 67 | requires repeated relay confirmation before atomically applying a pin; it |
| 68 | 68 | refuses application-version changes and restores updater state on failure. |
| 69 | - The release cutter now distinguishes a locked vault from an absent signing | |
| 70 | key, refuses setup while key presence is unknowable, and rejects a signer | |
| 71 | principal not trusted by the fleet before it can create a tag. | |
| 69 | 72 | - StartOS and Umbrel metadata, release-evidence tests, and operator docs are |
| 70 | 73 | synchronized to the current prerelease baseline and distinguish configured |
| 71 | 74 | defaults from signed runtime proof. |
| … | … | @@ -55,7 +55,7 @@ Marketing one-liner when both are live: **“Fleet v0.25.0 + Blind public-test o |
| 55 | 55 | | Item | State | |
| 56 | 56 | |------|--------| |
| 57 | 57 | | Fleet channel | **canary `v0.25.0-rc.7`** · **stable `v0.24.3`** · held `v0.24.3` | |
| 58 | | Repair candidate | `v0.25.0-rc.8` package identity prepared: full Node unit **3564/3564**, integration **107/107**, lint, public-artifact audit, release mailbox benchmark, and npm package dry-runs green; signed tag and CI image evidence remain pending | | |
| 58 | | Repair candidate | `v0.25.0-rc.8` package identity prepared: full Node unit **3570/3570** with **17954/17954** assertions, integration **107/107**, lint, public-artifact audit, release mailbox benchmark, and npm package dry-runs green; signed tag and CI image evidence remain pending | | |
| 59 | 59 | | Scoped live health (2026-07-29) | Latest operator scan excludes Sydney/Dallas and covers 12 inventory entries: service catalogues reached **10/12**, hosts **11/12**, and active relay applications **10/12**. Bern remains unreachable; Dubai-2GB is reachable but blank/inactive with no relay repo, service, updater, config, or health endpoint. `utah-8gb` is healthy on signed RC4 with `CHANNEL=stable`, exact `PINNED_TAG=v0.25.0-rc.4`, and updater timer enabled/active | |
| 60 | 60 | | Wake runtime | `notify` loaded on **10/10** service-reachable relays, but signed live egress **0/10** and exact-lane wake **0/10**; eight stable relays still carry the old false `notify-v1` feature claim, while Utah and Utah-8GB correctly report loaded-not-live | |
| 61 | 61 | | Agent mailbox | `outboxlog` is signed-advertised on **8/10** service-reachable relays; Utah and Utah-8GB have mailbox off | |
| … | … | @@ -442,7 +442,8 @@ You can call the **product ladder complete for honest marketing** when: |
| 442 | 442 | ## 9. Immediate next three moves (in order) |
| 443 | 443 | |
| 444 | 444 | 1. **Sign and ship the prepared RC8 candidate:** unlock the existing release |
| 445 | signer, cut and push the signed `v0.25.0-rc.8` tag, and let CI build and prove | |
| 445 | signer, select the principal already shipped in `fleet/allowed-signers`, cut | |
| 446 | and push the signed `v0.25.0-rc.8` tag, and let CI build and prove | |
| 446 | 447 | the digest-pinned image. Promote only the permitted canary in a separate |
| 447 | 448 | health-gated change. Utah-8GB's exact RC4 updater pin and timer repair is |
| 448 | 449 | complete; do not fold a downgrade into the canary promotion. |
| … | … | @@ -60,8 +60,8 @@ Two prerequisites, both currently unmet: |
| 60 | 60 | |
| 61 | 61 | **The vault is locked.** `status` reports |
| 62 | 62 | `locked: no agent running for ~/.keyvault/secrets.vault`, and consequently cannot |
| 63 | see the key (`signing key: NOT in vault` is what a locked vault looks like — it is | |
| 64 | not proof the key is absent). Unlock first: | |
| 63 | see the key. Current tooling reports this as `unavailable while vault is locked`; | |
| 64 | it never treats a locked vault as proof the key is absent. Unlock first: | |
| 65 | 65 | |
| 66 | 66 | ```bash |
| 67 | 67 | keyvault agent --daemonize |
| … | … | @@ -55,8 +55,11 @@ agent is down): |
| 55 | 55 | keyvault exec --scope hiverelay-release -- scripts/release.sh cut v0.24.0 |
| 56 | 56 | ``` |
| 57 | 57 | |
| 58 | `scripts/release.sh status` shows what's configured (vault key present, signer | |
| 59 | email, allowed-signers count, package.json version). | |
| 58 | `scripts/release.sh status` shows what's configured (vault state, key | |
| 59 | availability, whether the signer principal is trusted by `allowed-signers`, | |
| 60 | allowed-signers count, and package.json version). A locked vault is reported as | |
| 61 | unknown/unavailable, never as proof that the signing key is absent; unlock it | |
| 62 | before considering the one-time `setup` command. | |
| 60 | 63 | |
| 61 | 64 | ## Guards the script enforces |
| 62 | 65 | |
| … | … | @@ -70,6 +73,10 @@ email, allowed-signers count, package.json version). |
| 70 | 73 | - **Self-verify before push.** The signed tag is verified against |
| 71 | 74 | `fleet/allowed-signers` locally; if your key isn't trusted there, the tag is |
| 72 | 75 | deleted and nothing is pushed — you never publish a tag the fleet would reject. |
| 76 | - **Trusted principal before signing.** If `git config user.email` is not an | |
| 77 | allowed principal, `cut` fails before creating a tag. Set | |
| 78 | `HIVERELAY_RELEASE_SIGNER_EMAIL` to the principal shipped in | |
| 79 | `fleet/allowed-signers`; do not rotate a key merely to repair an email mismatch. | |
| 73 | 80 | - **No key on disk.** The signing key is materialized only to a `0600` temp file |
| 74 | 81 | that is removed on every exit path (success, error, or interrupt). |
| 75 | 82 |
| … | … | @@ -1,4 +1,18 @@ |
| 1 | 1 | export const AUDIT_OWNED_DIFF_SLICES = Object.freeze([ |
| 2 | { | |
| 3 | id: 'release-signer-authority-preflight', | |
| 4 | title: 'Release signer vault and trusted-principal preflight', | |
| 5 | files: [ | |
| 6 | 'CHANGELOG.md', | |
| 7 | 'docs/LADDER-SHIP-MAP.md', | |
| 8 | 'docs/RELEASE.md', | |
| 9 | 'docs/RELEASE-RUNBOOK-0.25.0-rc.5.md', | |
| 10 | 'scripts/lib/audit-owned-diff.mjs', | |
| 11 | 'scripts/release.sh', | |
| 12 | 'test/unit/audit-owned-diff.test.js', | |
| 13 | 'test/unit/release-cutter-status.test.js' | |
| 14 | ] | |
| 15 | }, | |
| 2 | 16 | { |
| 3 | 17 | id: 'release-blocker-closure', |
| 4 | 18 | title: 'Release blocker closure board', |
| … | … | @@ -43,6 +43,23 @@ die() { printf '\033[31mrelease: ERROR:\033[0m %s\n' "$*" >&2; exit 1; } |
| 43 | 43 | |
| 44 | 44 | need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; } |
| 45 | 45 | |
| 46 | allowed_signer_count() { | |
| 47 | [ -r "$ALLOWED_SIGNERS" ] || { printf '0\n'; return 0; } | |
| 48 | grep -cvE '^[[:space:]]*(#|$)' "$ALLOWED_SIGNERS" 2>/dev/null || true | |
| 49 | } | |
| 50 | ||
| 51 | signer_principal_is_trusted() { | |
| 52 | [ -n "${SIGNER_EMAIL:-}" ] && [ -r "$ALLOWED_SIGNERS" ] || return 1 | |
| 53 | awk -v principal="$SIGNER_EMAIL" ' | |
| 54 | /^[[:space:]]*(#|$)/ { next } | |
| 55 | { | |
| 56 | count = split($1, principals, ",") | |
| 57 | for (i = 1; i <= count; i++) if (principals[i] == principal) found = 1 | |
| 58 | } | |
| 59 | END { exit(found ? 0 : 1) } | |
| 60 | ' "$ALLOWED_SIGNERS" | |
| 61 | } | |
| 62 | ||
| 46 | 63 | # Any secret material written to disk is tracked here and removed on EVERY exit |
| 47 | 64 | # path — success, error under `set -e`, or interrupt. A function-local trap would |
| 48 | 65 | # miss the errexit path and strand a private key in the temp dir. |
| … | … | @@ -61,7 +78,12 @@ get_signing_key() { |
| 61 | 78 | if [ -n "${SIGNING_KEY_FILE:-}" ]; then cat "$SIGNING_KEY_FILE"; return 0; fi |
| 62 | 79 | if command -v keyvault >/dev/null 2>&1; then |
| 63 | 80 | keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" 2>/dev/null && return 0 |
| 64 | die "keyvault has no '$VAULT_SCOPE/$VAULT_KEY_NAME' — run: $0 setup (and unlock the agent)" | |
| 81 | local vault_state | |
| 82 | vault_state="$(keyvault status 2>/dev/null | head -1 || true)" | |
| 83 | if [[ "$vault_state" == locked:* ]]; then | |
| 84 | die "keyvault is locked, so signing-key presence is unknown — run: keyvault agent --daemonize" | |
| 85 | fi | |
| 86 | die "unlocked keyvault has no '$VAULT_SCOPE/$VAULT_KEY_NAME' — run: $0 setup" | |
| 65 | 87 | fi |
| 66 | 88 | die "no signing key available: set $KEY_ENV_VAR (via 'keyvault exec --scope $VAULT_SCOPE'), install keyvault, or set SIGNING_KEY_FILE" |
| 67 | 89 | } |
| … | … | @@ -70,6 +92,11 @@ get_signing_key() { |
| 70 | 92 | cmd_setup() { |
| 71 | 93 | need ssh-keygen; need keyvault; need awk |
| 72 | 94 | [ -n "$SIGNER_EMAIL" ] || die "no signer email — set HIVERELAY_RELEASE_SIGNER_EMAIL or 'git config user.email'" |
| 95 | local vault_state | |
| 96 | vault_state="$(keyvault status 2>/dev/null | head -1 || true)" | |
| 97 | if [[ "$vault_state" == locked:* ]]; then | |
| 98 | die "keyvault is locked, so setup cannot prove the signing key is absent — unlock it before setup" | |
| 99 | fi | |
| 73 | 100 | if keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" >/dev/null 2>&1; then |
| 74 | 101 | die "'$VAULT_SCOPE/$VAULT_KEY_NAME' already exists in the vault — refusing to overwrite (rotate deliberately with 'keyvault rm' first)" |
| 75 | 102 | fi |
| … | … | @@ -117,6 +144,11 @@ cmd_cut() { |
| 117 | 144 | [ -n "$version" ] || die "usage: $0 cut <vX.Y.Z> [<ref>] [--stable] [--promote-canary] [-y]" |
| 118 | 145 | [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "version must look like v0.24.0 (got '$version')" |
| 119 | 146 | [ -n "$SIGNER_EMAIL" ] || die "no signer email — set HIVERELAY_RELEASE_SIGNER_EMAIL or 'git config user.email'" |
| 147 | local signer_lines | |
| 148 | signer_lines="$(allowed_signer_count)" | |
| 149 | if [ "${signer_lines:-0}" -gt 0 ] && ! signer_principal_is_trusted; then | |
| 150 | die "signer principal '$SIGNER_EMAIL' is not trusted by fleet/allowed-signers — set HIVERELAY_RELEASE_SIGNER_EMAIL to an allowed principal" | |
| 151 | fi | |
| 120 | 152 | |
| 121 | 153 | # FOOTGUN GUARD: the fleet updater health-gate compares the tag (minus 'v') to |
| 122 | 154 | # the running /health version, which is package.json's version. A mismatch |
| … | … | @@ -171,10 +203,7 @@ cmd_cut() { |
| 171 | 203 | # Self-verify against the shipped allowed-signers BEFORE pushing, so we never |
| 172 | 204 | # publish a tag the fleet's verify_tag would reject — but only when the file |
| 173 | 205 | # has at least one real (non-comment) signer line. |
| 174 | local signer_lines=0 | |
| 175 | if [ -r "$ALLOWED_SIGNERS" ]; then | |
| 176 | signer_lines="$(grep -cvE '^[[:space:]]*(#|$)' "$ALLOWED_SIGNERS" 2>/dev/null || true)" | |
| 177 | fi | |
| 206 | signer_lines="$(allowed_signer_count)" | |
| 178 | 207 | if [ "${signer_lines:-0}" -gt 0 ]; then |
| 179 | 208 | if git -C "$REPO_ROOT" -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$ALLOWED_SIGNERS" \ |
| 180 | 209 | verify-tag "$version" >/dev/null 2>&1; then |
| … | … | @@ -226,14 +255,35 @@ cmd_status() { |
| 226 | 255 | printf 'git: %s\n' "$(git --version 2>/dev/null || echo MISSING)" |
| 227 | 256 | printf 'gpg: %s\n' "$(command -v gpg >/dev/null 2>&1 && gpg --version | head -1 || echo 'absent (fine — SSH signing used)')" |
| 228 | 257 | printf 'gh: %s\n' "$(command -v gh >/dev/null 2>&1 && echo present || echo 'absent (release step skipped)')" |
| 229 | printf 'keyvault: %s\n' "$(command -v keyvault >/dev/null 2>&1 && (keyvault status 2>/dev/null | head -1 || echo installed) || echo absent)" | |
| 230 | printf 'signer email: %s\n' "${SIGNER_EMAIL:-<unset>}" | |
| 231 | if command -v keyvault >/dev/null 2>&1 && keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" >/dev/null 2>&1; then | |
| 258 | local vault_state="absent" | |
| 259 | if command -v keyvault >/dev/null 2>&1; then | |
| 260 | vault_state="$(keyvault status 2>/dev/null | head -1 || true)" | |
| 261 | [ -n "$vault_state" ] || vault_state="installed; status unavailable" | |
| 262 | fi | |
| 263 | printf 'keyvault: %s\n' "$vault_state" | |
| 264 | local signer_trust="unverifiable (no allowed signers)" | |
| 265 | if [ "$(allowed_signer_count)" -gt 0 ]; then | |
| 266 | if signer_principal_is_trusted; then | |
| 267 | signer_trust="trusted by fleet/allowed-signers" | |
| 268 | else | |
| 269 | signer_trust="NOT trusted by fleet/allowed-signers" | |
| 270 | fi | |
| 271 | fi | |
| 272 | printf 'signer email: %s (%s)\n' "${SIGNER_EMAIL:-<unset>}" "$signer_trust" | |
| 273 | if [ -n "${!KEY_ENV_VAR:-}" ]; then | |
| 274 | printf 'signing key: available via %s\n' "$KEY_ENV_VAR" | |
| 275 | elif [ -n "${SIGNING_KEY_FILE:-}" ] && [ -r "$SIGNING_KEY_FILE" ]; then | |
| 276 | printf 'signing key: available via SIGNING_KEY_FILE\n' | |
| 277 | elif command -v keyvault >/dev/null 2>&1 && [[ "$vault_state" == locked:* ]]; then | |
| 278 | printf 'signing key: unavailable while vault is locked — run: keyvault agent --daemonize\n' | |
| 279 | elif command -v keyvault >/dev/null 2>&1 && keyvault get "$VAULT_SCOPE/$VAULT_KEY_NAME" >/dev/null 2>&1; then | |
| 232 | 280 | printf 'signing key: present in vault (%s/%s)\n' "$VAULT_SCOPE" "$VAULT_KEY_NAME" |
| 281 | elif command -v keyvault >/dev/null 2>&1; then | |
| 282 | printf 'signing key: NOT found in unlocked vault — run: %s setup\n' "$0" | |
| 233 | 283 | else |
| 234 | printf 'signing key: NOT in vault — run: %s setup\n' "$0" | |
| 284 | printf 'signing key: unavailable — keyvault is not installed\n' | |
| 235 | 285 | fi |
| 236 | local as_n=0; [ -r "$ALLOWED_SIGNERS" ] && as_n="$(grep -cvE '^[[:space:]]*(#|$)' "$ALLOWED_SIGNERS" 2>/dev/null || true)" | |
| 286 | local as_n; as_n="$(allowed_signer_count)" | |
| 237 | 287 | printf 'allowed-signers:%s\n' "$([ "${as_n:-0}" -gt 0 ] && echo " ${as_n} signer(s)" || echo ' none — run: setup')" |
| 238 | 288 | printf 'package.json: v%s (a fleet tag MUST be exactly this)\n' "$(node -p "require('$REPO_ROOT/package.json').version" 2>/dev/null || echo '?')" |
| 239 | 289 | } |
| … | … | @@ -259,6 +259,24 @@ test('audit-owned diff assigns fleet runtime evidence surfaces', (t) => { |
| 259 | 259 | } |
| 260 | 260 | }) |
| 261 | 261 | |
| 262 | test('audit-owned diff assigns release signer authority surfaces', (t) => { | |
| 263 | const paths = [ | |
| 264 | 'CHANGELOG.md', | |
| 265 | 'docs/LADDER-SHIP-MAP.md', | |
| 266 | 'docs/RELEASE.md', | |
| 267 | 'docs/RELEASE-RUNBOOK-0.25.0-rc.5.md', | |
| 268 | 'scripts/release.sh', | |
| 269 | 'test/unit/release-cutter-status.test.js' | |
| 270 | ] | |
| 271 | const report = buildOwnedDiffReport(paths.map(file => ` M ${file}`).join('\n')) | |
| 272 | ||
| 273 | t.is(report.status, 'pass') | |
| 274 | t.is(report.totals.unknown, 0) | |
| 275 | for (const entry of report.entries) { | |
| 276 | t.ok(entry.owners.some(owner => owner.id === 'release-signer-authority-preflight'), entry.path) | |
| 277 | } | |
| 278 | }) | |
| 279 | ||
| 262 | 280 | test('audit-owned diff blocks unknown development paths', async (t) => { |
| 263 | 281 | const fixture = await tempFile(t, '?? stray-release-note.txt\n') |
| 264 | 282 | const res = await runAudit(['--json', '--status-file', fixture]) |
| … | … | @@ -0,0 +1,86 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { spawn } from 'node:child_process' | |
| 3 | import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 4 | import { delimiter, join } from 'node:path' | |
| 5 | import { tmpdir } from 'node:os' | |
| 6 | ||
| 7 | function runRelease (args, env = {}) { | |
| 8 | return new Promise((resolve, reject) => { | |
| 9 | const child = spawn('bash', ['scripts/release.sh', ...args], { | |
| 10 | env: { ...process.env, ...env }, | |
| 11 | stdio: ['ignore', 'pipe', 'pipe'] | |
| 12 | }) | |
| 13 | let stdout = '' | |
| 14 | let stderr = '' | |
| 15 | child.stdout.on('data', chunk => { stdout += chunk }) | |
| 16 | child.stderr.on('data', chunk => { stderr += chunk }) | |
| 17 | child.once('error', reject) | |
| 18 | child.once('close', code => resolve({ code, stdout, stderr })) | |
| 19 | }) | |
| 20 | } | |
| 21 | ||
| 22 | async function fakeKeyvault (t, state, hasKey) { | |
| 23 | const dir = await mkdtemp(join(tmpdir(), 'hiverelay-release-status-')) | |
| 24 | t.teardown(() => rm(dir, { recursive: true, force: true })) | |
| 25 | const keyvault = join(dir, 'keyvault') | |
| 26 | await writeFile(keyvault, `#!/bin/sh | |
| 27 | case "$1" in | |
| 28 | status) printf '%s\n' '${state}'; exit 0 ;; | |
| 29 | get) ${hasKey ? "printf '%s\\n' 'FAKE-KEY'; exit 0" : 'exit 1'} ;; | |
| 30 | esac | |
| 31 | exit 1 | |
| 32 | `) | |
| 33 | await chmod(keyvault, 0o755) | |
| 34 | return `${dir}${delimiter}${process.env.PATH || ''}` | |
| 35 | } | |
| 36 | ||
| 37 | test('release status distinguishes a locked vault from an absent signing key', async (t) => { | |
| 38 | const path = await fakeKeyvault(t, 'locked: no agent running for test vault', false) | |
| 39 | const result = await runRelease(['status'], { | |
| 40 | PATH: path, | |
| 41 | HIVERELAY_RELEASE_SIGNER_EMAIL: 'bigdestiny2@users.noreply.github.com' | |
| 42 | }) | |
| 43 | t.is(result.code, 0) | |
| 44 | t.ok(result.stdout.includes('signing key: unavailable while vault is locked')) | |
| 45 | t.absent(result.stdout.includes('NOT found in unlocked vault')) | |
| 46 | t.ok(result.stdout.includes('(trusted by fleet/allowed-signers)')) | |
| 47 | }) | |
| 48 | ||
| 49 | test('release status reports a missing key only when the vault is unlocked', async (t) => { | |
| 50 | const path = await fakeKeyvault(t, 'unlocked: agent ready', false) | |
| 51 | const result = await runRelease(['status'], { | |
| 52 | PATH: path, | |
| 53 | HIVERELAY_RELEASE_SIGNER_EMAIL: 'not-trusted@example.com' | |
| 54 | }) | |
| 55 | t.is(result.code, 0) | |
| 56 | t.ok(result.stdout.includes('signing key: NOT found in unlocked vault')) | |
| 57 | t.ok(result.stdout.includes('(NOT trusted by fleet/allowed-signers)')) | |
| 58 | }) | |
| 59 | ||
| 60 | test('release status recognizes a key in an unlocked vault', async (t) => { | |
| 61 | const path = await fakeKeyvault(t, 'unlocked: agent ready', true) | |
| 62 | const result = await runRelease(['status'], { | |
| 63 | PATH: path, | |
| 64 | HIVERELAY_RELEASE_SIGNER_EMAIL: 'bigdestiny2@users.noreply.github.com' | |
| 65 | }) | |
| 66 | t.is(result.code, 0) | |
| 67 | t.ok(result.stdout.includes('signing key: present in vault (hiverelay-release/tag-signing-key)')) | |
| 68 | }) | |
| 69 | ||
| 70 | test('release setup refuses to infer key absence from a locked vault', async (t) => { | |
| 71 | const path = await fakeKeyvault(t, 'locked: no agent running for test vault', false) | |
| 72 | const result = await runRelease(['setup'], { | |
| 73 | PATH: path, | |
| 74 | HIVERELAY_RELEASE_SIGNER_EMAIL: 'bigdestiny2@users.noreply.github.com' | |
| 75 | }) | |
| 76 | t.is(result.code, 1) | |
| 77 | t.ok(result.stderr.includes('setup cannot prove the signing key is absent')) | |
| 78 | }) | |
| 79 | ||
| 80 | test('release cut rejects an untrusted signer principal before tagging', async (t) => { | |
| 81 | const result = await runRelease(['cut', 'v0.25.0-rc.8', '-y'], { | |
| 82 | HIVERELAY_RELEASE_SIGNER_EMAIL: 'not-trusted@example.com' | |
| 83 | }) | |
| 84 | t.is(result.code, 1) | |
| 85 | t.ok(result.stderr.includes("signer principal 'not-trusted@example.com' is not trusted")) | |
| 86 | }) |