diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml index 3eac0bf..b34afbe 100644 --- a/.github/workflows/candidate.yml +++ b/.github/workflows/candidate.yml @@ -33,6 +33,7 @@ jobs: outputs: artifact-name: ${{ steps.identity.outputs.artifact_name }} commit: ${{ steps.identity.outputs.commit }} + ci-run-id: ${{ steps.identity.outputs.ci_run_id }} steps: - name: Check out the exact successful CI commit uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -94,6 +95,7 @@ jobs: id: identity env: CI_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HELM_CANDIDATE_CI_RUN_ID: ${{ github.event.workflow_run.id }} run: | set -euo pipefail version="$(node -p 'require("./package.json").version')" @@ -107,6 +109,7 @@ jobs: sha256sum "$evidence/candidate.json" > "$evidence/manifest.sha256" printf 'artifact_name=1helm-candidate-%s\n' "$CI_HEAD_SHA" >> "$GITHUB_OUTPUT" printf 'commit=%s\n' "$CI_HEAD_SHA" >> "$GITHUB_OUTPUT" + printf 'ci_run_id=%s\n' "$HELM_CANDIDATE_CI_RUN_ID" >> "$GITHUB_OUTPUT" - name: Attest archive provenance on the hosted builder id: attest @@ -168,8 +171,67 @@ jobs: sudo -n /usr/local/sbin/1helm-candidate-install - name: Publish private installation evidence in the job log - if: always() run: | test -r /var/lib/1helm-candidate/evidence/status.json python3 /usr/local/lib/1helm-candidate/candidate-boundary.py summary \ /var/lib/1helm-candidate/evidence/status.json + + - name: Retain exact private dress-rehearsal evidence + run: | + set -euo pipefail + install -d -m 0700 candidate-result + install -m 0600 /var/lib/1helm-candidate/evidence/status.json candidate-result/dress-rehearsal.json + + - name: Upload private evidence for hosted promotion assembly + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: 1helm-dress-rehearsal-evidence-${{ needs.build.outputs.commit }} + path: candidate-result/dress-rehearsal.json + if-no-files-found: error + retention-days: 30 + + assemble-promotion: + name: Assemble honest Phase 3 promotion candidate + needs: [build, deploy] + if: needs.deploy.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + steps: + - name: Check out the exact candidate commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.build.outputs.commit }} + fetch-depth: 1 + persist-credentials: false + + - name: Download exact built Linux candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.build.outputs.artifact-name }} + path: candidate-download + + - name: Download exact dress-rehearsal result + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: 1helm-dress-rehearsal-evidence-${{ needs.build.outputs.commit }} + path: rehearsal-download + + - name: Assemble retained bytes without rebuilding + env: + HELM_CANDIDATE_DOWNLOAD: candidate-download + HELM_REHEARSAL_EVIDENCE: rehearsal-download/dress-rehearsal.json + HELM_PROMOTION_OUTPUT: promotion-candidate + HELM_PROJECT_ROOT: . + HELM_CANDIDATE_CI_RUN_ID: ${{ needs.build.outputs.ci-run-id }} + run: node scripts/candidate-promotion-skeleton.mjs + + - name: Upload the exact Phase 3 promotion candidate + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: 1helm-promotion-candidate-${{ needs.build.outputs.commit }} + path: promotion-candidate/ + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml new file mode 100644 index 0000000..42f92b4 --- /dev/null +++ b/.github/workflows/promote-stable.yml @@ -0,0 +1,211 @@ +name: Promote exact candidate to Stable + +on: + workflow_dispatch: + inputs: + candidate_workflow_run_id: + description: Exact successful Candidate dress rehearsal workflow run ID + required: true + type: string + candidate_artifact_id: + description: Exact immutable candidate artifact ID from that run + required: true + type: string + version: + description: Intended three-part semantic version (without v) + required: true + type: string + mode: + description: Dry-run validates without publishing; publish enters the protected gate + required: true + default: dry-run + type: choice + options: [dry-run, publish] + confirmation: + description: Publish only - PROMOTE EXACT CANDIDATE vX.Y.Z RUN N ARTIFACT N + required: false + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: 1helm-stable-promotion + cancel-in-progress: false + +jobs: + verify: + name: Verify exact candidate bytes (never publishes) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + actions: read + outputs: + confirmation: ${{ steps.identity.outputs.confirmation }} + steps: + - name: Validate allowlisted dispatch inputs + id: identity + env: + CANDIDATE_RUN_ID: ${{ inputs.candidate_workflow_run_id }} + CANDIDATE_ARTIFACT_ID: ${{ inputs.candidate_artifact_id }} + VERSION: ${{ inputs.version }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + [[ "$CANDIDATE_RUN_ID" =~ ^[0-9]+$ ]] + [[ "$CANDIDATE_ARTIFACT_ID" =~ ^[0-9]+$ ]] + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + [[ "$MODE" == dry-run || "$MODE" == publish ]] + printf 'confirmation=PROMOTE EXACT CANDIDATE v%s RUN %s ARTIFACT %s\n' "$VERSION" "$CANDIDATE_RUN_ID" "$CANDIDATE_ARTIFACT_ID" >> "$GITHUB_OUTPUT" + + - name: Refuse publish mode without the exact owner confirmation + if: inputs.mode == 'publish' + env: + OWNER_CONFIRMATION: ${{ inputs.confirmation }} + EXPECTED_CONFIRMATION: ${{ steps.identity.outputs.confirmation }} + run: test "$OWNER_CONFIRMATION" = "$EXPECTED_CONFIRMATION" + + - name: Check out current main verification code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: refs/heads/main + fetch-depth: 0 + persist-credentials: false + + - name: Fetch exact trusted GitHub identities + env: + GH_TOKEN: ${{ github.token }} + CANDIDATE_RUN_ID: ${{ inputs.candidate_workflow_run_id }} + CANDIDATE_ARTIFACT_ID: ${{ inputs.candidate_artifact_id }} + run: | + set -euo pipefail + mkdir -m 0700 promotion-api promotion-bundle + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$CANDIDATE_RUN_ID" > promotion-api/run.json + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$CANDIDATE_ARTIFACT_ID" > promotion-api/artifact.json + + - name: Download only the exact candidate artifact ID + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + artifact-ids: ${{ inputs.candidate_artifact_id }} + path: promotion-bundle + merge-multiple: true + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.candidate_workflow_run_id }} + + - name: Bind trusted candidate API records and derive the exact CI run ID + id: candidate_api + env: + HELM_PROMOTION_BUNDLE: promotion-bundle + HELM_PROMOTION_RUN_JSON: promotion-api/run.json + HELM_PROMOTION_ARTIFACT_JSON: promotion-api/artifact.json + run: node scripts/prepare-promotion-bundle.mjs + + - name: Fetch and bind the exact trusted CI run + env: + GH_TOKEN: ${{ github.token }} + CI_RUN_ID: ${{ steps.candidate_api.outputs.ci_run_id }} + HELM_PROMOTION_BUNDLE: promotion-bundle + HELM_PROMOTION_RUN_JSON: promotion-api/run.json + HELM_PROMOTION_ARTIFACT_JSON: promotion-api/artifact.json + HELM_PROMOTION_CI_JSON: promotion-api/ci.json + run: | + set -euo pipefail + [[ "$CI_RUN_ID" =~ ^[0-9]+$ ]] + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$CI_RUN_ID" > promotion-api/ci.json + node scripts/prepare-promotion-bundle.mjs + + - name: Cryptographically verify Linux provenance on the hosted verifier + env: + GH_TOKEN: ${{ github.token }} + HELM_PROMOTION_BUNDLE: promotion-bundle + run: node scripts/verify-promotion-attestation.mjs + + - name: Verify current main, absent tag, and absent release + id: repository + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + main_commit="$(git rev-parse refs/remotes/origin/main)" + candidate_commit="$(node -p 'require("./promotion-bundle/promotion.json").commit')" + git merge-base --is-ancestor "$candidate_commit" refs/remotes/origin/main + node scripts/github-promotion-gates.mjs version-absent "$VERSION" + printf 'main_commit=%s\n' "$main_commit" >> "$GITHUB_OUTPUT" + + - name: Verify complete evidence, exact bytes, and no-rebuild promotion outputs + env: + VERSION: ${{ inputs.version }} + CANDIDATE_RUN_ID: ${{ inputs.candidate_workflow_run_id }} + CANDIDATE_ARTIFACT_ID: ${{ inputs.candidate_artifact_id }} + HELM_PROMOTION_MAIN_COMMIT: ${{ steps.repository.outputs.main_commit }} + HELM_PROMOTION_MAIN_CONTAINS_CANDIDATE: "1" + HELM_PROMOTION_TAG_ABSENT: "1" + HELM_PROMOTION_RELEASE_ABSENT: "1" + HELM_PROMOTION_LINUX_ATTESTATION_VERIFIED: "1" + run: | + set -euo pipefail + node scripts/promotion-status.mjs \ + --bundle promotion-bundle \ + --version "$VERSION" \ + --candidate-run "$CANDIDATE_RUN_ID" \ + --candidate-artifact "$CANDIDATE_ARTIFACT_ID" \ + --write-verified promotion-bundle + + - name: Retain verified exact bytes only for an explicitly confirmed publish request + if: >- + inputs.mode == 'publish' && + inputs.confirmation == steps.identity.outputs.confirmation + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: verified-stable-promotion-${{ inputs.candidate_artifact_id }} + path: | + promotion-bundle/1Helm-${{ inputs.version }}-arm64.dmg + promotion-bundle/1Helm-${{ inputs.version }}-mac-arm64.zip + promotion-bundle/1Helm-${{ inputs.version }}-linux-node.tgz + promotion-bundle/1Helm-${{ inputs.version }}-stable.json + promotion-bundle/1Helm-${{ inputs.version }}-release-notes.md + promotion-bundle/verified-promotion.json + if-no-files-found: error + retention-days: 1 + + publish: + name: Protected owner approval - publish Stable + needs: verify + if: >- + inputs.mode == 'publish' && + inputs.confirmation == needs.verify.outputs.confirmation + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: Stable publication + permissions: + contents: write + actions: read + steps: + - name: Check out current main for the guarded tag push + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: refs/heads/main + fetch-depth: 0 + persist-credentials: true + + - name: Download the complete verified promotion output + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: verified-stable-promotion-${{ inputs.candidate_artifact_id }} + path: verified-promotion + + - name: Publish annotated immutable tag and one complete GitHub Release + env: + GH_TOKEN: ${{ github.token }} + STABLE_PUBLICATION_ENABLED: ${{ secrets.STABLE_PUBLICATION_ENABLED }} + HELM_PROMOTION_BUNDLE: verified-promotion + HELM_PROMOTION_MODE: ${{ inputs.mode }} + HELM_PROMOTION_VERSION: ${{ inputs.version }} + HELM_PROMOTION_RUN_ID: ${{ inputs.candidate_workflow_run_id }} + HELM_PROMOTION_ARTIFACT_ID: ${{ inputs.candidate_artifact_id }} + HELM_PROMOTION_CONFIRMATION: ${{ inputs.confirmation }} + run: node scripts/publish-promotion.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e9af9..d573d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Stable desktop releases now use a fail-closed manual promotion gate that + verifies and republishes exact retained candidate bytes, keeps publication + behind explicit owner approval, and serves digest-validated last-known-good + website metadata without a follow-up digest commit. + ## [0.0.41] - 2026-08-03 ### Fixed diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 80972b2..5b4bbda 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -86,6 +86,15 @@ contract as the slice hardens. every user-visible fix and feature accepted for that release, using the same numbered ledger as the originating request when one exists. A short summary can introduce that ledger but cannot replace it. +- Desktop Stable publication uses only the manual promotion workflow. It + verifies and republishes exact retained candidate bytes without rebuilding, + requires an explicit identity-bound owner confirmation and approval in the + protected **Stable publication** Environment, and refuses any existing tag or + Release. Repository automation does not create or configure that Environment. +- Every promoted Release includes a digest-qualified machine-readable Stable + manifest. The site retains the last manifest it validated and must fail closed + instead of inventing metadata. Tags and Release assets are never rewritten; + rollback uses a new version or a supported installed-updater rollback policy. - macOS verification must use the exact publicly downloaded artifact, preserve Application Support, and prove signature/ticket/Gatekeeper, launch, version, loopback behavior, and retained state on the retained release host. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index a3bcc57..6377d6c 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -112,18 +112,14 @@ executable code of its own, there is no Windows signing identity and no Windows signature status to record or disclose. Never sign anything with a self-signed identity. -Only after Sections 6–8 pass for all three desktop platforms: +Only after Sections 6–8 pass for all three desktop platforms, use the manual +GitHub workflow. Direct tag or release commands are not a supported path: ```bash -git tag -a "v${VERSION}" "$MERGED_COMMIT" -m "1Helm ${VERSION}" -git push origin "refs/tags/v${VERSION}" -gh release create "v${VERSION}" \ - "$DMG" "$UPDATE_ZIP" "$HEADLESS" \ - --title "1Helm ${VERSION}" --notes-file "$RELEASE_NOTES" --draft -# Upload mobile artifacts through their applicable distribution lane; their -# timing never permits a partial desktop release. -# review notes, then: -gh release edit "v${VERSION}" --draft=false +gh workflow run "Promote exact candidate to Stable" --ref main \ + -f candidate_workflow_run_id="$CANDIDATE_RUN_ID" \ + -f candidate_artifact_id="$CANDIDATE_ARTIFACT_ID" \ + -f version="$VERSION" -f mode=dry-run ``` `--generate-notes` is not an acceptable replacement for the authored notes. @@ -132,6 +128,29 @@ body must lead with the complete user-visible acceptance ledger. Before publication, compare the notes item-by-item with the originating request and the versioned `CHANGELOG.md` entry. +Review the dry-run's single plain-English report. It must name the candidate +identity, exact dress-rehearsal result, required Mac/Linux/Windows evidence, +eligibility, every publish blocker, and `Stable touched: NO`. Phase 3 honestly +blocks on missing retained Mac bytes and all retained platform acceptance +records until Phase 4 supplies them. + +The workflow runs this same owner-facing reporter; for an already downloaded +promotion bundle it can also be invoked locally without any network mutation: + +```bash +npm run stable:status -- --bundle --version "$VERSION" \ + --candidate-run "$CANDIDATE_RUN_ID" --candidate-artifact "$CANDIDATE_ARTIFACT_ID" +``` + +After the owner separately creates and protects the GitHub Environment +**Stable publication**, sets its required reviewer, and adds the environment +secret `STABLE_PUBLICATION_ENABLED=PROTECTED STABLE ENVIRONMENT ENABLED`, an +eligible candidate may be dispatched again with `mode=publish` and the exact +confirmation string printed by the dry run. Those settings are not created by +this repository or by the workflow. The publish job refuses an existing tag or +release and uploads only the verified DMG, updater ZIP, Linux TGZ, and Stable +manifest. It never rebuilds. + ### Mobile release gates - Build Android only with the retained external production key and properties @@ -238,3 +257,14 @@ Android: iOS: CI: Actions green on main ``` + +## 10. Publication rollback + +Never rewrite, move, delete for reuse, or silently replace a published tag or +asset. Roll back by promoting a previously verified immutable artifact set +through a new semantic version after all current gates pass, or through a +documented supported updater rollback that restores an already installed prior +release. If the annotated tag push succeeds but Release creation fails, that +version is permanently unavailable for reuse; fix the issue and choose a new +version. The website retains the last digest-validated Stable manifest until a +complete later promotion succeeds. diff --git a/docs/release-lifecycle.md b/docs/release-lifecycle.md index 1aa9ec7..2d36a0d 100644 --- a/docs/release-lifecycle.md +++ b/docs/release-lifecycle.md @@ -139,12 +139,42 @@ Draft PRs are allowed for long slices; mark ready only when the quality bar is m signing status to record; complete its behavioural acceptance instead (`docs/release-checklist.md` Section 7). 7. Publish those desktop artifacts and complete release notes together through - one GitHub Release. Never publish a subset or attach a platform later to a + the manual `Promote exact candidate to Stable` workflow. Supply the exact + retained candidate workflow run ID, immutable artifact ID, and intended + version; run its default dry-run first. Never rebuild in promotion, publish + a subset, or attach a platform later to a version already described as complete. Include a directly distributed signed Android APK when applicable. Submit iOS through App Store Connect rather than publishing an installable IPA as a generic download. Do not use GitHub's generated notes as the sole or primary body. +### Stable promotion gate + +The manual workflow is the only supported desktop publication path. Its +read-only verification job checks that the exact candidate commit remains on +current `main`; successful +CI and candidate workflow identities; Linux attestation, archive digest, and +embedded commit; private dress-rehearsal health for that digest; all three +retained artifact records; and retained macOS, Linux, and Windows acceptance. +Phase 3 does not manufacture platform records: until Phase 4 supplies them, the +dry run reports them as blockers and publication remains paused. + +Publication additionally requires `mode=publish`, the exact identity-bound +confirmation printed by the dry run, and owner approval in the protected +GitHub Environment named **Stable publication**. That environment must also +contain `STABLE_PUBLICATION_ENABLED` with the documented enablement value. It is +intentionally absent until the owner separately creates and protects the +environment. The publish job rechecks that the tag and release do not exist and +that the candidate remains on `origin/main`. It creates an annotated tag and one GitHub +Release from the already verified bytes; there is no package/build command. + +The Release includes `1Helm--stable.json`. The site accepts GitHub +metadata only when that manifest asset's digest and the complete Release matrix +match. It retains the last validated manifest in website state, so GitHub +unavailability does not move Stable backward or cause invented metadata. The +bootstrap manifest in `site/stable-manifest.json` represents the last release +before this mechanism and is not edited per release. + ## 7. Deploy ### Local service @@ -176,3 +206,15 @@ workspace state. If any platform artifact or acceptance run is skipped, the release is paused, not partially shipped. Say exactly what is missing and do not call it “done.” + +## 9. Rollback after publication + +Tags and assets are immutable. Never delete, move, reuse, or force-update a tag, +and never silently replace an asset. To restore older behavior, select a +previously verified immutable artifact set and promote it under a **new semantic +version** after the same complete verification, or use an explicitly supported +host-updater rollback policy that preserves the installed prior release. If +publication fails after its annotated tag is pushed but before the complete +Release exists, that version is stranded: do not reuse it; correct the cause and +promote a new version. The website continues serving its last validated Stable +manifest until a complete new promotion validates. diff --git a/package.json b/package.json index a2ff316..e941525 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,10 @@ "test:delivery": "node --test test/delivery-status.mjs test/cleanup-report.mjs test/delivery-governance.mjs", "test:phase1": "node --test test/phase1-tools.mjs test/delivery-status.mjs test/cleanup-report.mjs", "test:phase2": "node --test test/phase2-candidate.mjs test/delivery-status.mjs", + "test:phase3": "node --test test/phase3-promotion.mjs test/site-stable-manifest.mjs", "test:fast": "node scripts/run-fast-tests.mjs", "delivery:status": "node scripts/delivery-status.mjs", + "stable:status": "node scripts/promotion-status.mjs", "cleanup:report": "node scripts/cleanup-report.mjs", "benchmark:autonomy": "node scripts/autonomy-benchmark.mjs", "helm": "node scripts/1helm-cli.mjs", diff --git a/scripts/candidate-promotion-skeleton.mjs b/scripts/candidate-promotion-skeleton.mjs new file mode 100644 index 0000000..8b41136 --- /dev/null +++ b/scripts/candidate-promotion-skeleton.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { copyFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { sha256File, STABLE_REPOSITORY } from "./stable-manifest-lib.mjs"; + +const source = resolve(process.env.HELM_CANDIDATE_DOWNLOAD || ""); +const rehearsalPath = resolve(process.env.HELM_REHEARSAL_EVIDENCE || ""); +const output = resolve(process.env.HELM_PROMOTION_OUTPUT || ""); +const project = resolve(process.env.HELM_PROJECT_ROOT || "."); +const workflowRunId = String(process.env.GITHUB_RUN_ID || "unbound"); +const ciRunId = String(process.env.HELM_CANDIDATE_CI_RUN_ID || ""); +if (!source || !rehearsalPath || !output) throw new Error("Candidate, rehearsal, and promotion output paths are required"); +mkdirSync(output, { recursive: true, mode: 0o700 }); +const digest = sha256File; +const record = (sourcePath, name) => { + const destination = join(output, name); + copyFileSync(sourcePath, destination); + return { path: name, sha256: digest(destination) }; +}; +const candidateSource = join(source, "candidate-evidence", "candidate.json"); +const candidate = JSON.parse(readFileSync(candidateSource, "utf8")); +const version = String(candidate?.version || ""); +const commit = String(candidate?.source?.commit || ""); +if (!/^\d+\.\d+\.\d+$/.test(version) || !/^[a-f0-9]{40}$/.test(commit)) throw new Error("Candidate manifest version or commit is invalid"); +if (!/^\d+$/.test(ciRunId) || candidate?.ci?.run_id !== ciRunId) throw new Error("Candidate CI run identity changed before promotion assembly"); +const archiveSource = join(source, candidate.artifact.name); +if (digest(archiveSource) !== candidate.artifact.sha256 || statSync(archiveSource).size !== candidate.artifact.bytes) { + throw new Error("Candidate Linux archive no longer matches its manifest"); +} +const archive = record(archiveSource, candidate.artifact.name); +const candidateRecord = record(candidateSource, "candidate.json"); +const rehearsal = record(rehearsalPath, "dress-rehearsal.json"); +const packageRecord = record(join(project, "package.json"), "package.json"); +const changelog = record(join(project, "CHANGELOG.md"), "authored-changelog.md"); +const provenanceBundle = record(join(source, "candidate-evidence", "provenance.bundle.json"), "provenance.bundle.json"); +const provenanceValue = { + schema: 1, kind: "1helm-artifact-provenance", repository: STABLE_REPOSITORY, + ref: "refs/heads/main", commit, builder: "github-hosted", attestation_created: true, + signer_workflow: "gitcommit90/1Helm/.github/workflows/candidate.yml", + artifact: { role: "linux_tgz", name: candidate.artifact.name, sha256: candidate.artifact.sha256 }, + bundle: provenanceBundle, +}; +const provenanceBytes = Buffer.from(`${JSON.stringify(provenanceValue, null, 2)}\n`); +writeFileSync(join(output, "linux-provenance.json"), provenanceBytes, { mode: 0o600 }); +const provenance = { path: "linux-provenance.json", sha256: createHash("sha256").update(provenanceBytes).digest("hex") }; +const artifactName = `1helm-promotion-candidate-${commit}`; +const promotion = { + schema: 1, kind: "1helm-stable-promotion-candidate", repository: STABLE_REPOSITORY, ref: "refs/heads/main", + commit, version, + candidate: { workflow_run_id: workflowRunId, artifact_id: "unbound", artifact_name: artifactName }, + records: { + candidate_manifest: candidateRecord, + dress_rehearsal: rehearsal, + package: packageRecord, + changelog, + }, + acceptance_ledger_required: true, + artifacts: [{ + role: "linux_tgz", name: basename(archive.path), path: archive.path, + sha256: candidate.artifact.sha256, bytes: candidate.artifact.bytes, provenance, + }], +}; +writeFileSync(join(output, "promotion.json"), `${JSON.stringify(promotion, null, 2)}\n`, { mode: 0o600 }); diff --git a/scripts/github-promotion-gates.mjs b/scripts/github-promotion-gates.mjs new file mode 100644 index 0000000..f9ae5e5 --- /dev/null +++ b/scripts/github-promotion-gates.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +const REPOSITORY = "gitcommit90/1Helm"; + +async function github(path, token, fetchImpl = globalThis.fetch) { + const response = await fetchImpl(`https://api.github.com/repos/${REPOSITORY}${path}`, { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "1helm-stable-promotion-gate", + "x-github-api-version": "2022-11-28", + }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + return response; +} + +export async function assertRemoteVersionAbsent(version, token, fetchImpl) { + if (!/^\d+\.\d+\.\d+$/.test(String(version || "")) || !token) throw new Error("Remote version absence check received invalid inputs"); + const tag = `v${version}`; + for (const [kind, path] of [ + ["tag", `/git/ref/tags/${encodeURIComponent(tag)}`], + ["release", `/releases/tags/${encodeURIComponent(tag)}`], + ]) { + const response = await github(path, token, fetchImpl); + if (response.status === 404) continue; + if (response.ok) throw new Error(`Refusing because ${kind} ${tag} already exists`); + throw new Error(`Could not prove ${kind} ${tag} absent: GitHub API ${response.status}`); + } +} + +if (process.argv[1] && new URL(`file://${process.argv[1]}`).href === import.meta.url) { + const command = process.argv[2]; + try { + if (command === "version-absent") await assertRemoteVersionAbsent(process.argv[3], process.env.GH_TOKEN); + else throw new Error("Usage: github-promotion-gates.mjs version-absent "); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); + } +} diff --git a/scripts/prepare-promotion-bundle.mjs b/scripts/prepare-promotion-bundle.mjs new file mode 100644 index 0000000..c3610bc --- /dev/null +++ b/scripts/prepare-promotion-bundle.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, writeFileSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; + +const exactRegular = (path, root) => { + const rel = relative(root, path); + if (!rel || rel === ".." || rel.startsWith(`..${sep}`)) throw new Error("Promotion path escapes its bundle"); + let current = root; + for (const segment of rel.split(sep)) { + current = join(current, segment); + const info = lstatSync(current); + if (info.isSymbolicLink()) throw new Error("Promotion path contains a symbolic link"); + } + if (!lstatSync(path).isFile()) throw new Error("Promotion path is not a regular file"); +}; + +const bundle = resolve(process.env.HELM_PROMOTION_BUNDLE || ""); +const runPath = resolve(process.env.HELM_PROMOTION_RUN_JSON || ""); +const artifactPath = resolve(process.env.HELM_PROMOTION_ARTIFACT_JSON || ""); +const ciPath = process.env.HELM_PROMOTION_CI_JSON ? resolve(process.env.HELM_PROMOTION_CI_JSON) : ""; +if (!bundle || !runPath || !artifactPath) throw new Error("Promotion bundle and trusted API record paths are required"); +const manifestPath = join(bundle, "promotion.json"); +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); +const run = JSON.parse(readFileSync(runPath, "utf8")); +const artifact = JSON.parse(readFileSync(artifactPath, "utf8")); +const candidateRecord = manifest?.records?.candidate_manifest; +const candidatePath = resolve(bundle, String(candidateRecord?.path || "")); +const candidateRelative = relative(bundle, candidatePath); +if (!candidateRelative || candidateRelative === ".." || candidateRelative.startsWith(`..${sep}`) + || !/^[a-f0-9]{64}$/.test(String(candidateRecord?.sha256 || ""))) { + throw new Error("Candidate manifest record path or digest is invalid"); +} +exactRegular(candidatePath, bundle); +const candidateBytes = readFileSync(candidatePath); +if (createHash("sha256").update(candidateBytes).digest("hex") !== candidateRecord.sha256) { + throw new Error("Candidate manifest record digest does not match its bytes"); +} +const candidate = JSON.parse(candidateBytes); +const ciRunId = String(candidate?.ci?.run_id || ""); +if (!/^\d+$/.test(ciRunId) || candidate?.ci?.workflow !== "CI" || candidate?.ci?.conclusion !== "success") { + throw new Error("Candidate manifest has no successful CI run identity"); +} +if ((!['', 'unbound'].includes(String(manifest?.candidate?.workflow_run_id || '')) && String(manifest?.candidate?.workflow_run_id) !== String(run?.id)) + || (!['', 'unbound'].includes(String(manifest?.candidate?.artifact_id || '')) && String(manifest?.candidate?.artifact_id) !== String(artifact?.id)) + || String(artifact?.workflow_run?.id) !== String(run?.id) + || run?.head_sha !== candidate?.source?.commit + || artifact?.name !== manifest?.candidate?.artifact_name || artifact?.expired !== false) { + throw new Error("Trusted GitHub API records do not match the candidate promotion identity"); +} +const writeRecord = (name, value) => { + const path = join(bundle, name); + const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + writeFileSync(path, bytes, { mode: 0o600 }); + return { path: name, sha256: createHash("sha256").update(bytes).digest("hex") }; +}; +manifest.records ||= {}; +manifest.candidate.workflow_run_id = String(run.id); +manifest.candidate.artifact_id = String(artifact.id); +manifest.records.candidate_workflow = writeRecord("trusted-candidate-workflow.json", run); +manifest.records.candidate_artifact = writeRecord("trusted-candidate-artifact.json", artifact); +if (ciPath) { + const ci = JSON.parse(readFileSync(ciPath, "utf8")); + if (String(ci?.id) !== ciRunId || ci?.head_sha !== candidate?.source?.commit) throw new Error("Trusted CI record does not match the candidate manifest"); + manifest.records.candidate_ci = writeRecord("trusted-candidate-ci.json", ci); +} +writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); +if (process.env.GITHUB_OUTPUT) writeFileSync(process.env.GITHUB_OUTPUT, `ci_run_id=${ciRunId}\n`, { flag: "a" }); diff --git a/scripts/promotion-lib.mjs b/scripts/promotion-lib.mjs new file mode 100644 index 0000000..1968145 --- /dev/null +++ b/scripts/promotion-lib.mjs @@ -0,0 +1,284 @@ +import { lstatSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { basename, join, relative, resolve, sep } from "node:path"; +import { candidateIdentityFromArchive } from "./candidate-manifest.mjs"; +import { STABLE_ARTIFACT_ROLES, STABLE_MANIFEST_KIND, STABLE_REPOSITORY, sha256, sha256File, stableArtifactNames, validateStableManifest } from "./stable-manifest-lib.mjs"; + +export const PROMOTION_KIND = "1helm-stable-promotion-candidate"; +export const CONFIRMATION_PREFIX = "PROMOTE EXACT CANDIDATE"; + +const VERSION = /^\d+\.\d+\.\d+$/; +const HEX40 = /^[a-f0-9]{40}$/; +const HEX64 = /^[a-f0-9]{64}$/; +const ID = /^\d+$/; +const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +const PLATFORMS = Object.freeze({ + macos: ["signature", "notarization", "staple", "gatekeeper", "clean_install", "prior_version_update", "retained_state", "loopback", "version"], + linux: ["digest", "clean_install", "prior_version_update", "health_failure_rollback", "retained_state", "systemd_health"], + windows: ["non_elevated_install", "single_uac", "restart_resume", "keepalive_reboot", "onboarding", "prior_version_update", "retained_state", "uninstall_safety"], +}); + +const digestFile = sha256File; +const add = (blockers, condition, message) => { if (!condition) blockers.push(message); }; +const json = (path) => JSON.parse(readFileSync(path, "utf8")); + +function isChangelogDate(value) { + if (value.length !== 10 || value[4] !== "-" || value[7] !== "-") return false; + for (const index of [0, 1, 2, 3, 5, 6, 8, 9]) { + if (value[index] < "0" || value[index] > "9") return false; + } + return true; +} + +function hasVersionedChangelogHeading(changelog, version) { + const prefix = `## [${version}] - `; + return String(changelog).split("\n").some((rawLine) => { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + return line.startsWith(prefix) + && line.length === prefix.length + 10 + && isChangelogDate(line.slice(prefix.length)); + }); +} + +function confinedFile(bundle, relativePath, blockers, label) { + if (typeof relativePath !== "string" || !relativePath || relativePath.startsWith("/") || relativePath.includes("\\")) { + blockers.push(`${label}: path is invalid`); + return null; + } + const path = resolve(bundle, relativePath); + const rel = relative(bundle, path); + if (!rel || rel === ".." || rel.startsWith(`..${sep}`)) { + blockers.push(`${label}: path escapes the promotion bundle`); + return null; + } + try { + let current = bundle; + for (const segment of rel.split(sep)) { + current = join(current, segment); + if (lstatSync(current).isSymbolicLink()) throw new Error(); + } + const info = lstatSync(path); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(); + return path; + } catch { + blockers.push(`${label}: file is missing or is not a regular file`); + return null; + } +} + +function checkedRecord(bundle, record, blockers, label) { + if (!record || typeof record !== "object") { + blockers.push(`${label}: evidence record is missing`); + return null; + } + const path = confinedFile(bundle, record?.path, blockers, label); + if (!path) return null; + add(blockers, HEX64.test(String(record?.sha256 || "")), `${label}: recorded SHA-256 is invalid`); + if (HEX64.test(String(record?.sha256 || ""))) add(blockers, digestFile(path) === record.sha256, `${label}: file SHA-256 mismatch`); + try { return { path, value: json(path) }; } catch { blockers.push(`${label}: JSON is invalid`); return null; } +} + +function validateCandidateManifest(value, expected, blockers) { + add(blockers, value?.schema === 1 && value?.kind === "1helm-dress-rehearsal-candidate", "candidate manifest: schema or kind mismatch"); + add(blockers, value?.source?.repository === STABLE_REPOSITORY && value?.source?.ref === "refs/heads/main" && value?.source?.state === "trusted-main", "candidate manifest: source is not trusted repository main"); + add(blockers, value?.source?.commit === expected.commit, "candidate manifest: commit does not match promotion identity"); + add(blockers, value?.version === expected.version, "candidate manifest: version does not match intended version"); + add(blockers, value?.ci?.workflow === "CI" && ID.test(String(value?.ci?.run_id || "")) && value?.ci?.conclusion === "success", "candidate manifest: CI did not succeed"); + add(blockers, value?.artifact?.name === stableArtifactNames(expected.version).linux_tgz && HEX64.test(String(value?.artifact?.sha256 || "")), "candidate manifest: Linux artifact identity is invalid"); + add(blockers, HEX64.test(String(value?.source?.source_archive_sha256 || "")) && HEX64.test(String(value?.sealed_oci?.sha256 || "")), "candidate manifest: source or sealed OCI digest is invalid"); +} + +function validateRun(value, expected, blockers) { + add(blockers, String(value?.id) === expected.runId, "candidate workflow: run ID mismatch"); + add(blockers, value?.name === "Candidate dress rehearsal" && value?.path === ".github/workflows/candidate.yml", "candidate workflow: workflow name or path mismatch"); + add(blockers, value?.event === "workflow_run" && value?.status === "completed" && value?.conclusion === "success", "candidate workflow: run did not complete successfully"); + add(blockers, value?.head_branch === "main" && value?.head_sha === expected.commit && value?.head_repository?.full_name === STABLE_REPOSITORY, "candidate workflow: source is not the exact repository main commit"); +} + +function validateCi(value, expected, blockers) { + add(blockers, String(value?.id) === String(expected.ciRunId), "candidate CI: run ID mismatch"); + add(blockers, value?.name === "CI" && value?.path === ".github/workflows/ci.yml", "candidate CI: workflow name or path mismatch"); + add(blockers, value?.event === "push" && value?.status === "completed" && value?.conclusion === "success", "candidate CI: run did not complete successfully from push"); + add(blockers, value?.head_branch === "main" && value?.head_sha === expected.commit && value?.head_repository?.full_name === STABLE_REPOSITORY, "candidate CI: source is not the exact repository main commit"); +} + +function validateArtifactRecord(value, expected, blockers) { + add(blockers, String(value?.id) === expected.artifactId, "candidate artifact: artifact ID mismatch"); + add(blockers, value?.name === expected.artifactName && value?.expired === false, "candidate artifact: name mismatch or artifact expired"); + add(blockers, String(value?.workflow_run?.id) === expected.runId, "candidate artifact: workflow run mismatch"); +} + +function validatePlatformEvidence(platform, value, expected, artifacts, blockers) { + const label = `${platform} acceptance`; + add(blockers, value?.schema === 1 && value?.kind === "1helm-platform-acceptance" && value?.platform === platform, `${label}: schema, kind, or platform mismatch`); + add(blockers, value?.repository === STABLE_REPOSITORY && value?.ref === "refs/heads/main" && value?.commit === expected.commit && value?.version === expected.version, `${label}: source identity mismatch`); + add(blockers, value?.result === "passed" && ISO_TIME.test(String(value?.checked_at || "")), `${label}: retained result is not a timestamped pass`); + const checkMap = new Map((Array.isArray(value?.checks) ? value.checks : []).map((item) => [item?.id, item?.result])); + for (const check of PLATFORMS[platform]) add(blockers, checkMap.get(check) === "passed", `${label}: ${check} evidence is missing or did not pass`); + const expectedRoles = platform === "macos" ? ["mac_dmg", "mac_updater_zip"] : ["linux_tgz"]; + const records = Array.isArray(value?.artifacts) ? value.artifacts : []; + for (const role of expectedRoles) { + const matching = records.filter((item) => item?.role === role); + add(blockers, matching.length === 1 && matching[0].name === artifacts[role]?.name && matching[0].sha256 === artifacts[role]?.sha256, `${label}: ${role} does not match candidate bytes`); + } +} + +function releaseNotes(version, commit, promotion, artifacts, changelog, acceptance) { + const digestLines = STABLE_ARTIFACT_ROLES.map((role) => `- \`${artifacts[role].name}\` — \`${artifacts[role].sha256}\``).join("\n"); + return `# 1Helm ${version}\n\n${acceptance.trim()}\n\n## Authored changelog\n\n${changelog.trim()}\n\n## Promoted candidate evidence\n\n- Source: \`${STABLE_REPOSITORY}@${commit}\` on \`main\`\n- Candidate workflow run: \`${promotion.candidate.workflow_run_id}\`\n- Candidate artifact: \`${promotion.candidate.artifact_id}\` (\`${promotion.candidate.artifact_name}\`)\n- Private dress rehearsal: exact Linux commit and digest healthy\n- Platform acceptance: retained macOS, Linux, and Windows records all passed\n\n## Exact release artifacts\n\n${digestLines}\n`; +} + +export function confirmationText(version, runId, artifactId) { + return `${CONFIRMATION_PREFIX} v${version} RUN ${runId} ARTIFACT ${artifactId}`; +} + +export function validatePromotionBundle(options) { + const blockers = []; + const bundle = resolve(options.bundleDir); + let promotion; + try { promotion = json(join(bundle, "promotion.json")); } catch { blockers.push("promotion manifest: promotion.json is missing or invalid"); } + const expected = { + version: String(options.version || ""), runId: String(options.runId || ""), + artifactId: String(options.artifactId || ""), commit: String(promotion?.commit || ""), + artifactName: String(promotion?.candidate?.artifact_name || ""), + }; + add(blockers, VERSION.test(expected.version), "intended version is not three-part semantic versioning"); + add(blockers, promotion?.schema === 1 && promotion?.kind === PROMOTION_KIND, "promotion manifest: schema or kind mismatch"); + add(blockers, promotion?.repository === STABLE_REPOSITORY && promotion?.ref === "refs/heads/main", "promotion manifest: repository or ref mismatch"); + add(blockers, promotion?.version === expected.version && HEX40.test(expected.commit), "promotion manifest: version or commit mismatch"); + add(blockers, promotion?.acceptance_ledger_required === true, "promotion manifest: authored acceptance ledger was not declared required"); + add(blockers, String(promotion?.candidate?.workflow_run_id) === expected.runId && ID.test(expected.runId), "promotion manifest: candidate workflow run ID mismatch"); + add(blockers, String(promotion?.candidate?.artifact_id) === expected.artifactId && ID.test(expected.artifactId), "promotion manifest: candidate artifact ID mismatch"); + add(blockers, /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(expected.artifactName), "promotion manifest: candidate artifact name is invalid"); + add(blockers, options.mainContainsCandidate === true, "candidate commit is not contained on current main"); + add(blockers, options.tagAbsent === true, `tag v${expected.version} already exists or absence was not proven`); + add(blockers, options.releaseAbsent === true, `release v${expected.version} already exists or absence was not proven`); + + const records = promotion?.records || {}; + const candidateManifest = checkedRecord(bundle, records.candidate_manifest, blockers, "candidate manifest"); + const runRecord = checkedRecord(bundle, records.candidate_workflow, blockers, "candidate workflow"); + const ciRecord = checkedRecord(bundle, records.candidate_ci, blockers, "candidate CI"); + const artifactRecord = checkedRecord(bundle, records.candidate_artifact, blockers, "candidate artifact"); + const rehearsal = checkedRecord(bundle, records.dress_rehearsal, blockers, "private dress rehearsal"); + if (candidateManifest) { + expected.ciRunId = String(candidateManifest.value?.ci?.run_id || ""); + validateCandidateManifest(candidateManifest.value, expected, blockers); + } + if (runRecord) validateRun(runRecord.value, expected, blockers); + if (ciRecord) validateCi(ciRecord.value, expected, blockers); + if (artifactRecord) validateArtifactRecord(artifactRecord.value, expected, blockers); + + const artifacts = {}; + const expectedNames = stableArtifactNames(expected.version); + for (const role of STABLE_ARTIFACT_ROLES) { + const spec = (Array.isArray(promotion?.artifacts) ? promotion.artifacts : []).find((item) => item?.role === role); + const path = confinedFile(bundle, spec?.path, blockers, `${role} artifact`); + add(blockers, spec?.name === expectedNames[role] && basename(spec?.path || "") === expectedNames[role], `${role} artifact: name/version mismatch`); + add(blockers, HEX64.test(String(spec?.sha256 || "")), `${role} artifact: recorded SHA-256 is invalid`); + if (path && HEX64.test(String(spec?.sha256 || ""))) { + add(blockers, digestFile(path) === spec.sha256, `${role} artifact: exact-byte SHA-256 mismatch`); + add(blockers, Number(spec.bytes) === statSync(path).size && statSync(path).size > 0, `${role} artifact: byte count mismatch`); + } + artifacts[role] = { ...spec, path }; + const provenance = checkedRecord(bundle, spec?.provenance, blockers, `${role} provenance`); + if (provenance) { + add(blockers, provenance.value?.schema === 1 && provenance.value?.kind === "1helm-artifact-provenance", `${role} provenance: schema or kind mismatch`); + add(blockers, provenance.value?.repository === STABLE_REPOSITORY && provenance.value?.ref === "refs/heads/main" && provenance.value?.commit === expected.commit, `${role} provenance: source identity mismatch`); + add(blockers, provenance.value?.artifact?.role === role && provenance.value?.artifact?.name === spec?.name && provenance.value?.artifact?.sha256 === spec?.sha256, `${role} provenance: artifact digest mismatch`); + if (role === "linux_tgz") { + add(blockers, provenance.value?.builder === "github-hosted" && provenance.value?.attestation_created === true && provenance.value?.signer_workflow === "gitcommit90/1Helm/.github/workflows/candidate.yml", "linux_tgz provenance: trusted hosted builder attestation record is missing"); + add(blockers, options.linuxAttestationVerified === true, "linux_tgz provenance: GitHub attestation was not cryptographically verified in this promotion run"); + } + if (role !== "linux_tgz") add(blockers, provenance.value?.signing === "developer-id" && provenance.value?.notarization === "accepted", `${role} provenance: signing or notarization evidence is missing`); + } + } + add(blockers, (Array.isArray(promotion?.artifacts) ? promotion.artifacts : []).length === 3, "desktop artifact matrix must contain exactly three artifacts"); + + if (candidateManifest && artifacts.linux_tgz?.path) { + add(blockers, candidateManifest.value?.artifact?.sha256 === artifacts.linux_tgz.sha256 && candidateManifest.value?.artifact?.bytes === artifacts.linux_tgz.bytes, "Linux candidate manifest does not match promoted archive bytes"); + try { + const identity = candidateIdentityFromArchive(artifacts.linux_tgz.path); + add(blockers, identity.commit === expected.commit && identity.version === expected.version, "Linux embedded commit/version does not match promotion identity"); + add(blockers, identity.source_archive_sha256 === candidateManifest.value?.source?.source_archive_sha256 && identity.sealed_oci_sha256 === candidateManifest.value?.sealed_oci?.sha256, "Linux embedded source/OCI digest does not match candidate manifest"); + } catch (error) { blockers.push(`Linux embedded candidate identity refused: ${error.message}`); } + } + if (rehearsal) { + const running = rehearsal.value?.running_candidate; + add(blockers, rehearsal.value?.schema === 1 && rehearsal.value?.kind === "1helm-dress-rehearsal-status", "private dress rehearsal: schema or kind mismatch"); + add(blockers, running?.commit === expected.commit && running?.digest === artifacts.linux_tgz?.sha256 && running?.version === expected.version, "private dress rehearsal: running commit/digest/version mismatch"); + add(blockers, rehearsal.value?.install?.result === "healthy" && rehearsal.value?.install?.health === "healthy", "private dress rehearsal: exact candidate is not healthy"); + } + + for (const platform of Object.keys(PLATFORMS)) { + const record = checkedRecord(bundle, records?.acceptance?.[platform], blockers, `${platform} acceptance`); + if (record) validatePlatformEvidence(platform, record.value, expected, artifacts, blockers); + } + + const packageRecord = checkedRecord(bundle, records.package, blockers, "package version record"); + if (packageRecord) add(blockers, packageRecord.value?.version === expected.version, "package version does not match intended version"); + const changelogPath = confinedFile(bundle, records?.changelog?.path, blockers, "authored changelog"); + const acceptancePath = confinedFile(bundle, records?.acceptance_content?.path, blockers, "authored acceptance content"); + let changelog = ""; let acceptance = ""; + if (changelogPath) { + changelog = readFileSync(changelogPath, "utf8"); + add(blockers, digestFile(changelogPath) === records.changelog.sha256, "authored changelog: SHA-256 mismatch"); + add(blockers, hasVersionedChangelogHeading(changelog, expected.version), "authored changelog: named version section is missing"); + } + if (acceptancePath) { + acceptance = readFileSync(acceptancePath, "utf8"); + add(blockers, digestFile(acceptancePath) === records.acceptance_content.sha256, "authored acceptance content: SHA-256 mismatch"); + add(blockers, /^1\.\s+\S/m.test(acceptance), "authored acceptance content: numbered owner ledger is missing"); + } + + const digest = promotion ? digestFile(join(bundle, "promotion.json")) : ""; + const stableManifest = blockers.length ? null : validateStableManifest({ + schema: 1, kind: STABLE_MANIFEST_KIND, repository: STABLE_REPOSITORY, ref: "refs/heads/main", + version: expected.version, tag: `v${expected.version}`, commit: expected.commit, + promoted_at: options.promotedAt || new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + promotion: { candidate_workflow_run_id: expected.runId, candidate_artifact_id: expected.artifactId, manifest_sha256: digest }, + artifacts: STABLE_ARTIFACT_ROLES.map((role) => ({ + role, name: artifacts[role].name, sha256: artifacts[role].sha256, bytes: artifacts[role].bytes, + url: `https://github.com/${STABLE_REPOSITORY}/releases/download/v${expected.version}/${artifacts[role].name}`, + })), + }); + return { + schema: 1, kind: "1helm-stable-promotion-report", mode: "dry-run", repository: STABLE_REPOSITORY, + candidate: { workflow_run_id: expected.runId, artifact_id: expected.artifactId, artifact_name: expected.artifactName, commit: expected.commit, version: expected.version }, + dress_rehearsal: rehearsal ? { result: rehearsal.value?.install?.result || "unknown", health: rehearsal.value?.install?.health || "unknown" } : { result: "missing", health: "unknown" }, + evidence: Object.fromEntries(Object.keys(PLATFORMS).map((platform) => [platform, blockers.some((item) => item.startsWith(`${platform} acceptance`)) ? "blocked" : "passed"])), + artifacts: STABLE_ARTIFACT_ROLES.map((role) => ({ role, name: artifacts[role]?.name || expectedNames[role], sha256: artifacts[role]?.sha256 || null })), + eligible: blockers.length === 0, blockers, stable_touched: false, stable_manifest: stableManifest, + release_notes: blockers.length ? null : releaseNotes(expected.version, expected.commit, promotion, artifacts, changelog, acceptance), + }; +} + +export function formatPromotionReport(report) { + const lines = [ + "1Helm Stable promotion dry run", + ` Candidate: workflow run ${report.candidate.workflow_run_id}, artifact ${report.candidate.artifact_id} (${report.candidate.artifact_name || "unknown"})`, + ` Identity: v${report.candidate.version} @ ${report.candidate.commit || "unknown"}`, + ` Dress rehearsal: ${report.dress_rehearsal.result} / ${report.dress_rehearsal.health}`, + ` Required evidence: macOS ${report.evidence.macos}; Linux ${report.evidence.linux}; Windows ${report.evidence.windows}`, + ` Dry-run eligibility: ${report.eligible ? "ELIGIBLE" : "BLOCKED"}`, + ]; + lines.push(" Publish blockers:", ...report.blockers.map((item) => ` - ${item}`)); + lines.push( + " - this report is dry-run mode, not publish mode", + " - protected Stable publication approval and the intentionally absent enablement secret are required", + " - the identity-bound owner confirmation is not active in a dry run", + ` Owner confirmation after every blocker is cleared: ${confirmationText(report.candidate.version, report.candidate.workflow_run_id, report.candidate.artifact_id)}`, + ); + lines.push(" Stable touched: NO", " This command is read-only and cannot tag, release, upload, or change the website."); + return `${lines.join("\n")}\n`; +} + +export function writeVerifiedPromotion(report, directory) { + if (!report.eligible || !report.stable_manifest || !report.release_notes) throw new Error("Refusing to write publish inputs for an ineligible promotion"); + writeFileSync(join(directory, `1Helm-${report.candidate.version}-stable.json`), `${JSON.stringify(report.stable_manifest, null, 2)}\n`); + writeFileSync(join(directory, `1Helm-${report.candidate.version}-release-notes.md`), report.release_notes); + writeFileSync(join(directory, "verified-promotion.json"), `${JSON.stringify({ + ...report, + release_notes: undefined, + release_notes_sha256: sha256(report.release_notes), + stable_manifest_sha256: sha256(`${JSON.stringify(report.stable_manifest, null, 2)}\n`), + }, null, 2)}\n`); +} diff --git a/scripts/promotion-status.mjs b/scripts/promotion-status.mjs new file mode 100644 index 0000000..1c7c35c --- /dev/null +++ b/scripts/promotion-status.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { formatPromotionReport, validatePromotionBundle, writeVerifiedPromotion } from "./promotion-lib.mjs"; + +const args = process.argv.slice(2); +const option = (name) => { const index = args.indexOf(name); return index < 0 ? "" : String(args[index + 1] || ""); }; +const has = (name) => args.includes(name); +const HELP = `Usage: npm run stable:status -- --bundle --version --candidate-run --candidate-artifact [--json] [--write-verified ] + +Performs a read-only, fail-closed Stable promotion dry run. It never publishes. +In GitHub Actions, fetched main/tag/release state is passed through environment +variables; local fixture proofs may use the HELM_PROMOTION_* overrides. +`; + +if (has("--help") || has("-h")) { process.stdout.write(HELP); process.exit(0); } +const known = new Set(["--bundle", "--version", "--candidate-run", "--candidate-artifact", "--json", "--write-verified"]); +for (let index = 0; index < args.length; index += 1) { + if (!known.has(args[index])) { process.stderr.write(`${HELP}\nUnknown option: ${args[index]}\n`); process.exit(2); } + if (args[index] !== "--json") index += 1; +} +const bundleDir = option("--bundle"); +const version = option("--version"); +const runId = option("--candidate-run"); +const artifactId = option("--candidate-artifact"); +if (!bundleDir || !version || !runId || !artifactId) { process.stderr.write(HELP); process.exit(2); } + +const capture = (file, commandArgs) => execFileSync(file, commandArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +const env = process.env; +let mainCommit = env.HELM_PROMOTION_MAIN_COMMIT || ""; +let mainContainsCandidate = env.HELM_PROMOTION_MAIN_CONTAINS_CANDIDATE === "1"; +let tagAbsent = env.HELM_PROMOTION_TAG_ABSENT === "1"; +let releaseAbsent = env.HELM_PROMOTION_RELEASE_ABSENT === "1"; +if (!mainCommit) { + try { mainCommit = capture("git", ["rev-parse", "refs/remotes/origin/main"]); } catch {} + try { + const promotion = JSON.parse(capture(process.execPath, ["-e", `process.stdout.write(require('fs').readFileSync(${JSON.stringify(resolve(bundleDir, "promotion.json"))},'utf8'))`])); + mainContainsCandidate = capture("git", ["merge-base", "--is-ancestor", promotion.commit, "refs/remotes/origin/main"]) === ""; + tagAbsent = !capture("git", ["tag", "--list", `v${version}`]); + } catch {} +} +const report = validatePromotionBundle({ + bundleDir, version, runId, artifactId, mainCommit, mainContainsCandidate, tagAbsent, releaseAbsent, + linuxAttestationVerified: env.HELM_PROMOTION_LINUX_ATTESTATION_VERIFIED === "1", + promotedAt: env.HELM_PROMOTION_TIME || undefined, +}); +const output = option("--write-verified"); +if (output) writeVerifiedPromotion(report, resolve(output)); +process.stdout.write(has("--json") ? `${JSON.stringify(report, null, 2)}\n` : formatPromotionReport(report)); +if (!report.eligible) process.exitCode = 1; diff --git a/scripts/publish-promotion.mjs b/scripts/publish-promotion.mjs new file mode 100644 index 0000000..bb69630 --- /dev/null +++ b/scripts/publish-promotion.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { confirmationText } from "./promotion-lib.mjs"; +import { sha256File, STABLE_ARTIFACT_ROLES, validateStableManifest } from "./stable-manifest-lib.mjs"; +import { assertRemoteVersionAbsent } from "./github-promotion-gates.mjs"; + +const bundle = resolve(process.env.HELM_PROMOTION_BUNDLE || ""); +const version = String(process.env.HELM_PROMOTION_VERSION || ""); +const runId = String(process.env.HELM_PROMOTION_RUN_ID || ""); +const artifactId = String(process.env.HELM_PROMOTION_ARTIFACT_ID || ""); +const confirmation = String(process.env.HELM_PROMOTION_CONFIRMATION || ""); +const mode = String(process.env.HELM_PROMOTION_MODE || ""); +const environmentEnabled = String(process.env.STABLE_PUBLICATION_ENABLED || ""); +const githubToken = String(process.env.GH_TOKEN || ""); +if (!/^\d+\.\d+\.\d+$/.test(version) || !/^\d+$/.test(runId) || !/^\d+$/.test(artifactId)) throw new Error("Refusing invalid publish identity"); +if (mode !== "publish") throw new Error("Refusing without explicit publish mode"); +if (confirmation !== confirmationText(version, runId, artifactId)) throw new Error("Refusing without exact owner confirmation text"); +// This secret must exist only inside the owner-protected environment. Its +// absence keeps the path unusable even if GitHub auto-creates an unprotected +// environment for the workflow name. +if (environmentEnabled !== "PROTECTED STABLE ENVIRONMENT ENABLED") throw new Error("Refusing until the owner protects and enables the Stable publication environment"); + +const verified = JSON.parse(readFileSync(join(bundle, "verified-promotion.json"), "utf8")); +const stablePath = join(bundle, `1Helm-${version}-stable.json`); +const stable = validateStableManifest(JSON.parse(readFileSync(stablePath, "utf8"))); +if (!verified.eligible || verified.stable_touched !== false + || verified.candidate?.version !== version || String(verified.candidate?.workflow_run_id) !== runId + || String(verified.candidate?.artifact_id) !== artifactId || stable.version !== version + || stable.commit !== verified.candidate.commit) { + throw new Error("Refusing publish inputs that are not the exact complete verification result"); +} +const hash = sha256File; +if (JSON.stringify(stable) !== JSON.stringify(verified.stable_manifest) + || hash(stablePath) !== verified.stable_manifest_sha256) { + throw new Error("Refusing changed stable manifest after verification"); +} + +const run = (file, args, options = {}) => execFileSync(file, args, { encoding: "utf8", stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit" }); +const captured = (file, args) => run(file, args, { capture: true }).trim(); +const tag = `v${version}`; +await assertRemoteVersionAbsent(version, githubToken); +run("git", ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]); +run("git", ["merge-base", "--is-ancestor", stable.commit, "refs/remotes/origin/main"]); + +const artifactPaths = STABLE_ARTIFACT_ROLES.map((role) => { + const artifact = stable.artifacts.find((item) => item.role === role); + const path = join(bundle, artifact.name); + const digest = hash(path); + if (digest !== artifact.sha256) throw new Error(`Refusing changed ${role} bytes after verification`); + return path; +}); +const notes = join(bundle, `1Helm-${version}-release-notes.md`); +if (hash(notes) !== verified.release_notes_sha256) throw new Error("Refusing changed authored release notes after verification"); + +// GitHub cannot atomically create an annotated tag and Release. Push the one +// immutable tag only after every check, then create the complete Release in one +// command. A failure after the push strands this version; the tag is never +// deleted, moved, or reused. The recovery is a new version. +run("git", ["tag", "-a", tag, stable.commit, "-m", `1Helm ${version}`]); +run("git", ["push", "origin", `refs/tags/${tag}:refs/tags/${tag}`]); +// Upload behind a draft boundary so Stable never exposes a partial matrix. The +// same guarded owner-approved job verifies GitHub's stored digests, then makes +// the complete Release public without a second approval or follow-up change. +run("gh", ["release", "create", tag, ...artifactPaths, stablePath, "--repo", stable.repository, + "--verify-tag", "--draft", "--title", `1Helm ${version}`, "--notes-file", notes]); +const response = await fetch(`https://api.github.com/repos/${stable.repository}/releases/tags/${encodeURIComponent(tag)}`, { + headers: { accept: "application/vnd.github+json", authorization: `Bearer ${githubToken}`, "user-agent": "1helm-stable-promotion", "x-github-api-version": "2022-11-28" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), +}); +if (!response.ok) throw new Error(`Could not verify draft Release assets: GitHub API ${response.status}`); +const release = await response.json(); +if (release.draft !== true || release.prerelease === true || release.tag_name !== tag) throw new Error("Draft Release identity changed before publication"); +const expectedAssets = [...stable.artifacts, { name: `1Helm-${version}-stable.json`, sha256: hash(stablePath) }]; +if (!Array.isArray(release.assets) || release.assets.length !== expectedAssets.length) throw new Error("Draft Release asset matrix is incomplete or contains unexpected assets"); +for (const expected of expectedAssets) { + const matches = release.assets.filter((asset) => asset?.name === expected.name && asset?.digest === `sha256:${expected.sha256}`); + if (matches.length !== 1) throw new Error(`Draft Release bytes do not match ${expected.name}`); +} +run("gh", ["release", "edit", tag, "--repo", stable.repository, "--draft=false", "--latest"]); diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 6d6f69f..6e88a48 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -18,7 +18,7 @@ const suites = [ "test/feedback.mjs", "test/feedback-browser.mjs", "test/cowork-browser.mjs", "test/files-latency.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", "test/release-license.mjs", "test/release-governance.mjs", "test/channel-surfaces.mjs", "test/workspace-interactions.mjs", "test/sweep-fleet-telemetry.mjs", "test/sweep-server-integration.mjs", "test/thread-followup-chat.mjs", "test/notifications.mjs", "test/mobile-push.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/mobile.mjs", "test/web-research.mjs", "test/workflows.mjs", - "test/delivery-status.mjs", "test/cleanup-report.mjs", "test/delivery-governance.mjs", "test/phase1-tools.mjs", "test/phase2-candidate.mjs"], + "test/delivery-status.mjs", "test/cleanup-report.mjs", "test/delivery-governance.mjs", "test/phase1-tools.mjs", "test/phase2-candidate.mjs", "test/phase3-promotion.mjs", "test/site-stable-manifest.mjs"], ]; let status = 0; diff --git a/scripts/stable-manifest-lib.mjs b/scripts/stable-manifest-lib.mjs new file mode 100644 index 0000000..d002ea6 --- /dev/null +++ b/scripts/stable-manifest-lib.mjs @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import { closeSync, openSync, readFileSync, readSync } from "node:fs"; + +export const STABLE_MANIFEST_KIND = "1helm-promoted-stable"; +export const STABLE_REPOSITORY = "gitcommit90/1Helm"; +export const STABLE_ARTIFACT_ROLES = Object.freeze(["mac_dmg", "mac_updater_zip", "linux_tgz"]); + +const VERSION = /^\d+\.\d+\.\d+$/; +const HEX40 = /^[a-f0-9]{40}$/; +const HEX64 = /^[a-f0-9]{64}$/; +const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; + +export const sha256 = (value) => createHash("sha256").update(value).digest("hex"); +export function sha256File(path) { + const hash = createHash("sha256"); + const descriptor = openSync(path, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + let length; + while ((length = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, length)); + } finally { closeSync(descriptor); } + return hash.digest("hex"); +} + +export function stableArtifactNames(version) { + return { + mac_dmg: `1Helm-${version}-arm64.dmg`, + mac_updater_zip: `1Helm-${version}-mac-arm64.zip`, + linux_tgz: `1Helm-${version}-linux-node.tgz`, + }; +} + +function refuse(message) { + throw new Error(`Stable manifest refused: ${message}`); +} + +export function validateStableManifest(value) { + if (!value || Array.isArray(value) || value.schema !== 1 || value.kind !== STABLE_MANIFEST_KIND) { + refuse("schema or kind mismatch"); + } + if (value.repository !== STABLE_REPOSITORY || value.ref !== "refs/heads/main") { + refuse("repository or ref mismatch"); + } + const version = String(value.version || ""); + const commit = String(value.commit || ""); + if (!VERSION.test(version) || value.tag !== `v${version}` || !HEX40.test(commit)) refuse("version, tag, or commit is invalid"); + if (!ISO_TIME.test(String(value.promoted_at || ""))) refuse("promotion time is invalid"); + const promotion = value.promotion || {}; + if (!/^\d+$/.test(String(promotion.candidate_workflow_run_id || "")) + || !/^\d+$/.test(String(promotion.candidate_artifact_id || "")) + || !HEX64.test(String(promotion.manifest_sha256 || ""))) { + refuse("promotion identity is incomplete"); + } + if (!Array.isArray(value.artifacts) || value.artifacts.length !== STABLE_ARTIFACT_ROLES.length) { + refuse("desktop artifact matrix is incomplete"); + } + const names = stableArtifactNames(version); + const byRole = new Map(); + for (const artifact of value.artifacts) { + const role = String(artifact?.role || ""); + if (!STABLE_ARTIFACT_ROLES.includes(role) || byRole.has(role)) refuse("desktop artifact roles are invalid or duplicated"); + const digest = String(artifact.sha256 || ""); + const expectedUrl = `https://github.com/${STABLE_REPOSITORY}/releases/download/v${version}/${names[role]}`; + if (artifact.name !== names[role] || !HEX64.test(digest) || artifact.url !== expectedUrl) { + refuse(`${role} name, digest, or URL mismatch`); + } + if (artifact.bytes != null && (!Number.isSafeInteger(artifact.bytes) || artifact.bytes < 1)) { + refuse(`${role} byte count is invalid`); + } + byRole.set(role, { ...artifact, sha256: digest }); + } + return { + ...value, + version, + commit, + promotion: { + candidate_workflow_run_id: String(promotion.candidate_workflow_run_id), + candidate_artifact_id: String(promotion.candidate_artifact_id), + manifest_sha256: String(promotion.manifest_sha256), + }, + artifacts: STABLE_ARTIFACT_ROLES.map((role) => byRole.get(role)), + }; +} + +export function parseStableManifest(text) { + let value; + try { value = JSON.parse(String(text)); } catch { refuse("JSON is invalid"); } + return validateStableManifest(value); +} + +export function readStableManifest(path) { + return parseStableManifest(readFileSync(path, "utf8")); +} + +export function validateManifestRelease(manifestValue, release) { + const manifest = validateStableManifest(manifestValue); + if (!release || release.draft || release.prerelease || release.tag_name !== manifest.tag) { + refuse("GitHub Release identity is not the promoted stable tag"); + } + const releaseAssets = Array.isArray(release.assets) ? release.assets : []; + for (const artifact of manifest.artifacts) { + const matches = releaseAssets.filter((asset) => asset?.name === artifact.name); + if (matches.length !== 1 || matches[0].digest !== `sha256:${artifact.sha256}` + || matches[0].browser_download_url !== artifact.url) { + refuse(`GitHub Release does not match ${artifact.role}`); + } + } + return manifest; +} + +export function manifestAssetForRelease(release) { + const version = String(release?.tag_name || "").replace(/^v/, ""); + if (!VERSION.test(version)) refuse("GitHub Release tag is invalid"); + const expected = `1Helm-${version}-stable.json`; + const matches = (Array.isArray(release.assets) ? release.assets : []).filter((asset) => asset?.name === expected); + if (matches.length !== 1 || !/^sha256:[a-f0-9]{64}$/.test(String(matches[0].digest || ""))) { + refuse("GitHub Release has no unique digest-qualified stable manifest asset"); + } + return matches[0]; +} + +export function validateDownloadedManifest(body, release) { + const asset = manifestAssetForRelease(release); + const bytes = Buffer.isBuffer(body) ? body : Buffer.from(String(body)); + if (sha256(bytes) !== asset.digest.slice(7)) refuse("downloaded manifest digest does not match GitHub"); + return validateManifestRelease(parseStableManifest(bytes.toString("utf8")), release); +} diff --git a/scripts/verify-promotion-attestation.mjs b/scripts/verify-promotion-attestation.mjs new file mode 100644 index 0000000..2145118 --- /dev/null +++ b/scripts/verify-promotion-attestation.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { lstatSync, readFileSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; +import { sha256File } from "./stable-manifest-lib.mjs"; + +const bundle = resolve(process.env.HELM_PROMOTION_BUNDLE || ""); +const promotion = JSON.parse(readFileSync(resolve(bundle, "promotion.json"), "utf8")); +const linux = (Array.isArray(promotion?.artifacts) ? promotion.artifacts : []).find((item) => item?.role === "linux_tgz"); +const path = resolve(bundle, String(linux?.path || "")); +const rel = relative(bundle, path); +if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || lstatSync(path).isSymbolicLink() + || !/^[a-f0-9]{64}$/.test(String(linux?.sha256 || "")) + || sha256File(path) !== linux.sha256 + || !/^[a-f0-9]{40}$/.test(String(promotion?.commit || ""))) { + throw new Error("Refusing invalid Linux artifact identity before attestation verification"); +} +execFileSync("gh", ["attestation", "verify", path, + "--repo", "gitcommit90/1Helm", + "--signer-workflow", "gitcommit90/1Helm/.github/workflows/candidate.yml", + "--source-ref", "refs/heads/main", + "--source-digest", promotion.commit, + "--deny-self-hosted-runners"], { stdio: "inherit" }); diff --git a/site/server.mjs b/site/server.mjs index e719b3f..9b1cecc 100644 --- a/site/server.mjs +++ b/site/server.mjs @@ -1,10 +1,16 @@ import { createHash } from "node:crypto"; import { createServer } from "node:http"; -import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; import { extname, join, normalize, resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { pages, redirects, sitemapPaths } from "./content.mjs"; import { renderPage } from "./template.mjs"; +import { + manifestAssetForRelease, + readStableManifest, + validateDownloadedManifest, + validateManifestRelease, +} from "../scripts/stable-manifest-lib.mjs"; const ROOT = resolve(import.meta.dirname, ".."); const SITE_PUBLIC = join(import.meta.dirname, "public"); @@ -30,31 +36,12 @@ const ORIGIN = "https://1helm.com"; const REPO = "gitcommit90/1Helm"; const RELEASE_PAGE = `https://github.com/${REPO}/releases/latest`; const RELEASE_CACHE_MS = 10 * 60_000; -// Served only when GitHub's release API is unreachable or rate limited, and it -// must name the current release: a stale fallback silently hands visitors an -// older build from the download links. -// -// The digests are the digests OF the release commit's own artifacts, so they -// are filled in here once those artifacts exist and the site is redeployed. -// Between the release commit and this one the placeholder is deliberately not -// 64 hex characters, so latestLinuxRelease() rejects it and -// /api/releases/linux/latest answers 503 - failing closed rather than handing -// an installer a digest that cannot match what it downloads. -const RELEASE_FALLBACK_TAG = "v0.0.41"; -const RELEASE_FALLBACK = { - tag_name: RELEASE_FALLBACK_TAG, - draft: false, - prerelease: false, - assets: [ - ["1Helm-0.0.41-arm64.dmg", "dd95d6012db5519581871a9c0b0379988ee4e9a38ac388d2762f71a29ea4548a"], - ["1Helm-0.0.41-mac-arm64.zip", "59da24ff56d736fd953060be4990add8dd373b5caac07a6ed6906206378f748a"], - ["1Helm-0.0.41-linux-node.tgz", "001ea971c2a1e8079802554a893deacb99eb9220a47735b40513d657098e7992"], - ].map(([name, digest]) => ({ - name, - digest: `sha256:${digest}`, - browser_download_url: `https://github.com/${REPO}/releases/download/${RELEASE_FALLBACK_TAG}/${name}`, - })), -}; +// Promotion stores a digest-qualified manifest both as a GitHub Release asset +// and in the site's writable state directory. GitHub metadata is used only +// after its manifest asset and complete artifact matrix validate. If GitHub is +// unavailable or inconsistent, the last locally validated manifest remains the +// source of stable download metadata. No tag or digest is invented in source. +const BOOTSTRAP_STABLE_MANIFEST = join(import.meta.dirname, "stable-manifest.json"); const FEEDBACK_DATA_DIR = resolve(process.env.SITE_DATA_DIR || join(ROOT, ".site-data")); const FEEDBACK_ADMIN_TOKEN = String(process.env.SITE_FEEDBACK_ADMIN_TOKEN || ""); const FEEDBACK_BODY_LIMIT = 15 * 1024 * 1024; @@ -62,16 +49,63 @@ const FEEDBACK_RATE_LIMIT = 30; const FEEDBACK_RATE_WINDOW_MS = 60_000; let releaseCache = { at: 0, release: null }; +let stableManifestCache = null; let feedbackDatabase; const feedbackRate = new Map(); -async function latestRelease() { +function manifestAsRelease(manifest) { + return { + tag_name: manifest.tag, + draft: false, + prerelease: false, + assets: manifest.artifacts.map((artifact) => ({ + name: artifact.name, + digest: `sha256:${artifact.sha256}`, + browser_download_url: artifact.url, + })), + }; +} +function lastKnownStableManifest() { + if (stableManifestCache) return stableManifestCache; + const statePath = join(FEEDBACK_DATA_DIR, "stable-manifest.json"); + for (const path of [statePath, BOOTSTRAP_STABLE_MANIFEST]) { + try { return (stableManifestCache = readStableManifest(path)); } catch {} + } + throw new Error("no validated last-known-good stable manifest"); +} +function retainStableManifest(manifest) { + const path = join(FEEDBACK_DATA_DIR, "stable-manifest.json"); + const temporary = `${path}.candidate`; + mkdirSync(FEEDBACK_DATA_DIR, { recursive: true, mode: 0o700 }); + writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, path); + stableManifestCache = manifest; +} +function stableOrder(version) { + return String(version).split(".").map(Number); +} +function isNewerStable(candidate, current) { + const left = stableOrder(candidate.version); + const right = stableOrder(current.version); + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] > right[index]; + } + return JSON.stringify(candidate) === JSON.stringify(current); +} +async function latestReleaseAndManifest() { if (Date.now() - releaseCache.at < RELEASE_CACHE_MS && releaseCache.release) return releaseCache.release; const releaseOverride = String(process.env.SITE_RELEASE_METADATA_JSON || ""); - let release; - if (releaseOverride) { - release = JSON.parse(releaseOverride); - } else { - try { + try { + let release; + if (releaseOverride) { + release = JSON.parse(releaseOverride); + const manifestOverride = String(process.env.SITE_STABLE_MANIFEST_JSON || ""); + const manifest = manifestOverride + ? validateManifestRelease(JSON.parse(manifestOverride), release) + : validateManifestRelease(lastKnownStableManifest(), release); + stableManifestCache = manifest; + releaseCache = { at: Date.now(), release: { release, manifest } }; + return releaseCache.release; + } else { if (process.env.SITE_RELEASE_FETCH_DISABLED === "1") throw new Error("release fetch disabled"); const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers: { "user-agent": "1helm-site", accept: "application/vnd.github+json" }, @@ -79,17 +113,28 @@ async function latestRelease() { }); if (!response.ok) throw new Error(`GitHub API ${response.status}`); release = await response.json(); - } catch { - release = RELEASE_FALLBACK; + const manifestAsset = manifestAssetForRelease(release); + const manifestResponse = await fetch(manifestAsset.browser_download_url, { + headers: { "user-agent": "1helm-site", accept: "application/json" }, + signal: AbortSignal.timeout(8000), + }); + if (!manifestResponse.ok) throw new Error(`stable manifest download ${manifestResponse.status}`); + const manifest = validateDownloadedManifest(Buffer.from(await manifestResponse.arrayBuffer()), release); + const current = lastKnownStableManifest(); + if (!isNewerStable(manifest, current)) throw new Error("GitHub Stable manifest would move or rewrite Stable"); + retainStableManifest(manifest); + releaseCache = { at: Date.now(), release: { release, manifest } }; + return releaseCache.release; } + } catch { + const manifest = lastKnownStableManifest(); + const release = manifestAsRelease(manifest); + releaseCache = { at: Date.now(), release: { release, manifest } }; + return releaseCache.release; } - const version = String(release.tag_name || "").replace(/^v/, ""); - if (!/^\d+\.\d+\.\d+$/.test(version) || release.draft || release.prerelease) throw new Error("latest release is not stable"); - releaseCache = { at: Date.now(), release }; - return release; } async function latestReleaseAssets() { - return (await latestRelease()).assets || []; + return (await latestReleaseAndManifest()).release.assets || []; } async function latestAssetUrl(pattern) { const asset = (await latestReleaseAssets()).find((entry) => pattern.test(String(entry.name || ""))); @@ -97,8 +142,8 @@ async function latestAssetUrl(pattern) { return asset.browser_download_url; } async function latestLinuxRelease() { - const release = await latestRelease(); - const version = String(release.tag_name).replace(/^v/, ""); + const { manifest } = await latestReleaseAndManifest(); + const version = manifest.version; // Windows ships no release artifacts: it installs the Linux archive inside WSL // via https://1helm.com/install.ps1. Requiring a Setup executable, .nupkg and // RELEASES here would make this throw for every release after 0.0.38 - and @@ -109,15 +154,14 @@ async function latestLinuxRelease() { `1Helm-${version}-mac-arm64.zip`, `1Helm-${version}-linux-node.tgz`, ]; - const assets = Array.isArray(release.assets) ? release.assets : []; - const matrix = expectedNames.map((name) => assets.find((asset) => asset.name === name)); - if (matrix.some((asset) => !asset || !/^sha256:[a-f0-9]{64}$/.test(String(asset.digest || "")))) { + const matrix = expectedNames.map((name) => manifest.artifacts.find((asset) => asset.name === name)); + if (matrix.some((asset) => !asset || !/^[a-f0-9]{64}$/.test(String(asset.sha256 || "")))) { throw new Error("latest release does not contain the complete digest-qualified desktop matrix"); } const linux = matrix[2]; const expectedUrl = `https://github.com/${REPO}/releases/download/v${version}/${linux.name}`; - if (linux.browser_download_url !== expectedUrl) throw new Error("latest Linux release URL does not match its version"); - return { version, url: expectedUrl, sha256: linux.digest.slice(7) }; + if (linux.url !== expectedUrl) throw new Error("latest Linux release URL does not match its version"); + return { version, url: expectedUrl, sha256: linux.sha256 }; } const mime = { @@ -392,6 +436,20 @@ const server = createServer(async (req, res) => { } return; } + if (path === "/api/releases/stable/manifest") { + try { + answer(res, 200, JSON.stringify((await latestReleaseAndManifest()).manifest), { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + } catch { + answer(res, 503, JSON.stringify({ error: "No validated Stable manifest is available." }), { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + } + return; + } if (STATIC_PAGES[path]) { answer(res, 200, STATIC_PAGES[path], { "content-type": "text/html; charset=utf-8", diff --git a/site/stable-manifest.json b/site/stable-manifest.json new file mode 100644 index 0000000..dd64114 --- /dev/null +++ b/site/stable-manifest.json @@ -0,0 +1,35 @@ +{ + "schema": 1, + "kind": "1helm-promoted-stable", + "repository": "gitcommit90/1Helm", + "ref": "refs/heads/main", + "version": "0.0.41", + "tag": "v0.0.41", + "commit": "c8415119ed97aee6ef9d68aca15c2209ea8cb29a", + "promoted_at": "2026-08-03T18:44:30Z", + "promotion": { + "candidate_workflow_run_id": "0", + "candidate_artifact_id": "0", + "manifest_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "artifacts": [ + { + "role": "mac_dmg", + "name": "1Helm-0.0.41-arm64.dmg", + "sha256": "dd95d6012db5519581871a9c0b0379988ee4e9a38ac388d2762f71a29ea4548a", + "url": "https://github.com/gitcommit90/1Helm/releases/download/v0.0.41/1Helm-0.0.41-arm64.dmg" + }, + { + "role": "mac_updater_zip", + "name": "1Helm-0.0.41-mac-arm64.zip", + "sha256": "59da24ff56d736fd953060be4990add8dd373b5caac07a6ed6906206378f748a", + "url": "https://github.com/gitcommit90/1Helm/releases/download/v0.0.41/1Helm-0.0.41-mac-arm64.zip" + }, + { + "role": "linux_tgz", + "name": "1Helm-0.0.41-linux-node.tgz", + "sha256": "001ea971c2a1e8079802554a893deacb99eb9220a47735b40513d657098e7992", + "url": "https://github.com/gitcommit90/1Helm/releases/download/v0.0.41/1Helm-0.0.41-linux-node.tgz" + } + ] +} diff --git a/test/fixtures/phase3-blocked/promotion.json b/test/fixtures/phase3-blocked/promotion.json new file mode 100644 index 0000000..ab5306a --- /dev/null +++ b/test/fixtures/phase3-blocked/promotion.json @@ -0,0 +1,16 @@ +{ + "schema": 1, + "kind": "1helm-stable-promotion-candidate", + "repository": "gitcommit90/1Helm", + "ref": "refs/heads/main", + "commit": "6b0a35ff6c3781d4263e3223b54f0e1f7e769ad1", + "version": "0.0.41", + "acceptance_ledger_required": true, + "candidate": { + "workflow_run_id": "1001", + "artifact_id": "2002", + "artifact_name": "1helm-promotion-candidate-6b0a35ff6c3781d4263e3223b54f0e1f7e769ad1" + }, + "records": {}, + "artifacts": [] +} diff --git a/test/phase3-promotion.mjs b/test/phase3-promotion.mjs new file mode 100644 index 0000000..05bd927 --- /dev/null +++ b/test/phase3-promotion.mjs @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { confirmationText, validatePromotionBundle } from "../scripts/promotion-lib.mjs"; +import { assertRemoteVersionAbsent } from "../scripts/github-promotion-gates.mjs"; + +const root = join(import.meta.dirname, ".."); +const sha = (value) => createHash("sha256").update(value).digest("hex"); +const commit = "a".repeat(40); +const version = "9.8.7"; +const runId = "12345"; +const artifactId = "67890"; + +function createBundle() { + const bundle = mkdtempSync(join(tmpdir(), "1helm-phase3-")); + const write = (name, value) => { + const content = typeof value === "string" || Buffer.isBuffer(value) ? value : `${JSON.stringify(value, null, 2)}\n`; + writeFileSync(join(bundle, name), content); + return { path: name, sha256: sha(content) }; + }; + const oci = Buffer.from("sealed-oci-phase3"); + const identity = { + schema: 1, kind: "1helm-dress-rehearsal-candidate", repository: "gitcommit90/1Helm", ref: "refs/heads/main", + commit, source_state: "trusted-main", build_identity: "candidate-111-12345.1", created_at: "2026-08-04T12:00:00Z", + ci: { workflow: "CI", run_id: "111", conclusion: "success" }, version, + source_archive_sha256: "b".repeat(64), sealed_oci_sha256: sha(oci), + }; + const stage = join(bundle, "stage", `1Helm-${version}`); + mkdirSync(join(stage, "resources"), { recursive: true }); + mkdirSync(join(stage, "container"), { recursive: true }); + writeFileSync(join(stage, "resources", "candidate-build.json"), JSON.stringify(identity)); + writeFileSync(join(stage, "package.json"), JSON.stringify({ version })); + writeFileSync(join(stage, "container", "channel-machine.oci.tar"), oci); + const linuxName = `1Helm-${version}-linux-node.tgz`; + execFileSync("tar", ["-czf", join(bundle, linuxName), "-C", join(bundle, "stage"), `1Helm-${version}`]); + const linuxBytes = readFileSync(join(bundle, linuxName)); + const linux = { role: "linux_tgz", name: linuxName, path: linuxName, sha256: sha(linuxBytes), bytes: linuxBytes.length }; + const macDmgBytes = Buffer.from("exact retained signed notarized DMG bytes"); + const macZipBytes = Buffer.from("exact retained signed notarized updater bytes"); + const artifact = (role, name, bytes) => { + writeFileSync(join(bundle, name), bytes); + return { role, name, path: name, sha256: sha(bytes), bytes: bytes.length }; + }; + const macDmg = artifact("mac_dmg", `1Helm-${version}-arm64.dmg`, macDmgBytes); + const macZip = artifact("mac_updater_zip", `1Helm-${version}-mac-arm64.zip`, macZipBytes); + const provenance = (item, extra) => write(`${item.role}-provenance.json`, { + schema: 1, kind: "1helm-artifact-provenance", repository: "gitcommit90/1Helm", ref: "refs/heads/main", commit, + artifact: { role: item.role, name: item.name, sha256: item.sha256 }, ...extra, + }); + macDmg.provenance = provenance(macDmg, { signing: "developer-id", notarization: "accepted" }); + macZip.provenance = provenance(macZip, { signing: "developer-id", notarization: "accepted" }); + linux.provenance = provenance(linux, { builder: "github-hosted", attestation_created: true, signer_workflow: "gitcommit90/1Helm/.github/workflows/candidate.yml" }); + const candidateManifest = { + schema: 1, kind: "1helm-dress-rehearsal-candidate", + source: { repository: "gitcommit90/1Helm", ref: "refs/heads/main", commit, state: "trusted-main", source_archive_sha256: identity.source_archive_sha256 }, + version, build: { identity: identity.build_identity, created_at: identity.created_at }, ci: identity.ci, + artifact: { name: linux.name, sha256: linux.sha256, bytes: linux.bytes }, sealed_oci: { sha256: identity.sealed_oci_sha256 }, + }; + const candidateRecord = write("candidate.json", candidateManifest); + const workflowRecord = write("trusted-candidate-workflow.json", { + id: Number(runId), name: "Candidate dress rehearsal", path: ".github/workflows/candidate.yml", event: "workflow_run", + status: "completed", conclusion: "success", head_branch: "main", head_sha: commit, head_repository: { full_name: "gitcommit90/1Helm" }, + }); + const ciRecord = write("trusted-candidate-ci.json", { + id: 111, name: "CI", path: ".github/workflows/ci.yml", event: "push", status: "completed", conclusion: "success", + head_branch: "main", head_sha: commit, head_repository: { full_name: "gitcommit90/1Helm" }, + }); + const artifactRecord = write("trusted-candidate-artifact.json", { + id: Number(artifactId), name: `1helm-promotion-candidate-${commit}`, expired: false, workflow_run: { id: Number(runId) }, + }); + const rehearsal = write("dress-rehearsal.json", { + schema: 1, kind: "1helm-dress-rehearsal-status", + running_candidate: { commit, digest: linux.sha256, version, build_identity: identity.build_identity, ci: identity.ci }, + last_attempt: { commit, digest: linux.sha256, version, build_identity: identity.build_identity, ci: identity.ci }, + install: { result: "healthy", health: "healthy", checked_at: "2026-08-04T13:00:00Z" }, + }); + const checkIds = { + macos: ["signature", "notarization", "staple", "gatekeeper", "clean_install", "prior_version_update", "retained_state", "loopback", "version"], + linux: ["digest", "clean_install", "prior_version_update", "health_failure_rollback", "retained_state", "systemd_health"], + windows: ["non_elevated_install", "single_uac", "restart_resume", "keepalive_reboot", "onboarding", "prior_version_update", "retained_state", "uninstall_safety"], + }; + const acceptanceArtifacts = { macos: [macDmg, macZip], linux: [linux], windows: [linux] }; + const acceptance = {}; + for (const platform of Object.keys(checkIds)) { + acceptance[platform] = write(`${platform}-acceptance.json`, { + schema: 1, kind: "1helm-platform-acceptance", platform, repository: "gitcommit90/1Helm", ref: "refs/heads/main", commit, version, + result: "passed", checked_at: "2026-08-04T14:00:00Z", checks: checkIds[platform].map((id) => ({ id, result: "passed" })), + artifacts: acceptanceArtifacts[platform].map(({ role, name, sha256 }) => ({ role, name, sha256 })), + }); + } + const packageRecord = write("package.json", { version }); + const changelog = write("authored-changelog.md", `## [${version}] - 2026-08-04\n\n### Added\n\n- Promotion fixture.\n`); + const acceptanceContent = write("acceptance.md", "1. Exact candidate bytes passed all retained platform gates.\n"); + const promotion = { + schema: 1, kind: "1helm-stable-promotion-candidate", repository: "gitcommit90/1Helm", ref: "refs/heads/main", commit, version, + acceptance_ledger_required: true, + candidate: { workflow_run_id: runId, artifact_id: artifactId, artifact_name: `1helm-promotion-candidate-${commit}` }, + records: { candidate_manifest: candidateRecord, candidate_workflow: workflowRecord, candidate_ci: ciRecord, candidate_artifact: artifactRecord, dress_rehearsal: rehearsal, acceptance, package: packageRecord, changelog, acceptance_content: acceptanceContent }, + artifacts: [macDmg, macZip, linux], + }; + writeFileSync(join(bundle, "promotion.json"), `${JSON.stringify(promotion, null, 2)}\n`); + rmSync(join(bundle, "stage"), { recursive: true, force: true }); + return bundle; +} + +const options = (bundle, overrides = {}) => ({ + bundleDir: bundle, version, runId, artifactId, mainCommit: commit, mainContainsCandidate: true, + tagAbsent: true, releaseAbsent: true, linuxAttestationVerified: true, promotedAt: "2026-08-04T15:00:00Z", ...overrides, +}); + +test("complete retained evidence is eligible and generated output names only exact candidate bytes", () => { + const bundle = createBundle(); + try { + const report = validatePromotionBundle(options(bundle)); + assert.equal(report.eligible, true, report.blockers.join("\n")); + assert.equal(report.stable_touched, false); + assert.equal(report.stable_manifest.artifacts.length, 3); + assert.deepEqual(report.stable_manifest.artifacts.map(({ sha256 }) => sha256), report.artifacts.map(({ sha256 }) => sha256)); + assert.match(report.release_notes, /Authored changelog/); + assert.doesNotMatch(report.release_notes, /generated notes/i); + } finally { rmSync(bundle, { recursive: true, force: true }); } +}); + +test("missing and mismatched evidence fail closed", () => { + const bundle = createBundle(); + try { + const promotionPath = join(bundle, "promotion.json"); + const promotion = JSON.parse(readFileSync(promotionPath, "utf8")); + delete promotion.records.acceptance.windows; + writeFileSync(promotionPath, JSON.stringify(promotion)); + let report = validatePromotionBundle(options(bundle)); + assert.equal(report.eligible, false); + assert.ok(report.blockers.some((item) => /windows acceptance.*missing/.test(item))); + writeFileSync(join(bundle, `1Helm-${version}-arm64.dmg`), "changed bytes"); + report = validatePromotionBundle(options(bundle)); + assert.ok(report.blockers.some((item) => /mac_dmg artifact: exact-byte SHA-256 mismatch/.test(item))); + } finally { rmSync(bundle, { recursive: true, force: true }); } +}); + +test("metacharacters in the version input are compared literally without regex construction", () => { + const bundle = createBundle(); + try { + let report; + assert.doesNotThrow(() => { report = validatePromotionBundle(options(bundle, { version: "9.8.7(" })); }); + assert.equal(report.eligible, false); + assert.ok(report.blockers.includes("intended version is not three-part semantic versioning")); + assert.ok(report.blockers.includes("authored changelog: named version section is missing")); + } finally { rmSync(bundle, { recursive: true, force: true }); } +}); + +test("existing or unproven tag and release absence are blockers", () => { + const bundle = createBundle(); + try { + const report = validatePromotionBundle(options(bundle, { tagAbsent: false, releaseAbsent: false })); + assert.equal(report.eligible, false); + assert.ok(report.blockers.some((item) => /tag v9\.8\.7 already exists/.test(item))); + assert.ok(report.blockers.some((item) => /release v9\.8\.7 already exists/.test(item))); + } finally { rmSync(bundle, { recursive: true, force: true }); } +}); + +test("remote tag/release gates distinguish absence from API failure", async () => { + const responses = (statuses) => async () => { + const status = statuses.shift(); + return { ok: status >= 200 && status < 300, status, json: async () => ({ protection_rules: [{ type: "required_reviewers", reviewers: [{ reviewer: { login: "owner" } }] }] }) }; + }; + await assertRemoteVersionAbsent(version, "fixture-token", responses([404, 404])); + await assert.rejects(assertRemoteVersionAbsent(version, "fixture-token", responses([500])), /Could not prove tag.*absent/); + await assert.rejects(assertRemoteVersionAbsent(version, "fixture-token", responses([200])), /tag v9\.8\.7 already exists/); +}); + +test("the owner command reports dry-run eligibility, platform evidence, blockers, and Stable state", () => { + const bundle = createBundle(); + try { + const result = spawnSync(process.execPath, ["scripts/promotion-status.mjs", "--bundle", bundle, "--version", version, "--candidate-run", runId, "--candidate-artifact", artifactId], { + cwd: root, encoding: "utf8", env: { ...process.env, HELM_PROMOTION_MAIN_COMMIT: commit, HELM_PROMOTION_MAIN_CONTAINS_CANDIDATE: "1", HELM_PROMOTION_TAG_ABSENT: "1", HELM_PROMOTION_RELEASE_ABSENT: "1", HELM_PROMOTION_LINUX_ATTESTATION_VERIFIED: "1", HELM_PROMOTION_TIME: "2026-08-04T15:00:00Z" }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Candidate: workflow run 12345, artifact 67890/); + assert.match(result.stdout, /Dress rehearsal: healthy \/ healthy/); + assert.match(result.stdout, /macOS passed; Linux passed; Windows passed/); + assert.match(result.stdout, /Dry-run eligibility: ELIGIBLE/); + assert.match(result.stdout, /Owner confirmation.*PROMOTE EXACT CANDIDATE v9\.8\.7 RUN 12345 ARTIFACT 67890/); + assert.match(result.stdout, /Stable touched: NO/); + } finally { rmSync(bundle, { recursive: true, force: true }); } +}); + +test("publication helper refuses before running git or gh without every explicit gate", () => { + const scratch = mkdtempSync(join(tmpdir(), "1helm-publish-refusal-")); + try { + const bin = join(scratch, "bin"); mkdirSync(bin); + for (const name of ["git", "gh"]) { + const path = join(bin, name); writeFileSync(path, `#!/bin/sh\nprintf called >> ${join(scratch, "called")}\n\nexit 99\n`); chmodSync(path, 0o755); + } + const result = spawnSync(process.execPath, ["scripts/publish-promotion.mjs"], { + cwd: root, encoding: "utf8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, HELM_PROMOTION_BUNDLE: scratch, HELM_PROMOTION_MODE: "publish", HELM_PROMOTION_VERSION: version, HELM_PROMOTION_RUN_ID: runId, HELM_PROMOTION_ARTIFACT_ID: artifactId, HELM_PROMOTION_CONFIRMATION: "wrong" }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /exact owner confirmation/); + assert.throws(() => readFileSync(join(scratch, "called"))); + assert.equal(confirmationText(version, runId, artifactId), "PROMOTE EXACT CANDIDATE v9.8.7 RUN 12345 ARTIFACT 67890"); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}); + +test("workflow is manual-only, permission-separated, environment-gated, and contains no build step", () => { + const workflow = readFileSync(join(root, ".github/workflows/promote-stable.yml"), "utf8"); + assert.match(workflow, /on:\n workflow_dispatch:/); + assert.doesNotMatch(workflow, /\n (push|pull_request|workflow_run):/); + assert.match(workflow, /verify:[\s\S]*permissions:\n contents: read\n actions: read/); + assert.match(workflow, /publish:[\s\S]*environment: Stable publication[\s\S]*contents: write/); + assert.match(workflow, /inputs\.mode == 'publish'/); + assert.match(workflow, /inputs\.confirmation == needs\.verify\.outputs\.confirmation/); + assert.match(workflow, /STABLE_PUBLICATION_ENABLED/); + assert.match(workflow, /github-promotion-gates\.mjs version-absent/); + assert.match(readFileSync(join(root, "scripts", "publish-promotion.mjs"), "utf8"), /PROTECTED STABLE ENVIRONMENT ENABLED/); + assert.doesNotMatch(workflow, /npm (ci|install|run build|run package)|package:(mac|linux|dmg)/); + assert.doesNotMatch(readFileSync(join(root, "scripts/publish-promotion.mjs"), "utf8"), /npm|package-linux|package-mac/); + assert.match(readFileSync(join(root, "scripts/publish-promotion.mjs"), "utf8"), /"--draft"[\s\S]*Draft Release bytes[\s\S]*"--draft=false"/); +}); diff --git a/test/release-governance.mjs b/test/release-governance.mjs index e15d8c7..230b904 100644 --- a/test/release-governance.mjs +++ b/test/release-governance.mjs @@ -16,7 +16,7 @@ test("multi-item releases retain the complete numbered acceptance ledger", () => for (const source of [checklist, lifecycle, governance, pullRequest, notesTemplate]) { assert.match(source, /numbered acceptance\s+ledger/i); } - assert.match(checklist, /--notes-file "\$RELEASE_NOTES"/); + assert.match(checklist + read(".github/workflows/promote-stable.yml") + read("scripts/publish-promotion.mjs"), /--notes-file/); assert.doesNotMatch(checklist, /gh release create[^\n]+--generate-notes/); assert.match(notesTemplate, /^1\. \*\*Feature or fix name\*\*/m); assert.match(notesTemplate, /artifact/i); @@ -75,8 +75,13 @@ test("every release document names the same three published artifacts", () => { const checklist = RELEASE_DOCS["docs/release-checklist.md"]; assert.match(checklist, /for artifact in "\$DMG" "\$UPDATE_ZIP" "\$HEADLESS"; do/, "the checklist verifies exactly the three built artifacts"); - assert.match(checklist, /gh release create "v\$\{VERSION\}" \\\n "\$DMG" "\$UPDATE_ZIP" "\$HEADLESS" \\\n --title/, - "the publish command attaches exactly the three artifacts"); + const promotion = read("scripts/publish-promotion.mjs"); + assert.match(promotion, /STABLE_ARTIFACT_ROLES\.map/, + "the publish command derives exactly the three validated artifact roles"); + assert.match(promotion, /"release", "create", tag, \.\.\.artifactPaths, stablePath/, + "the publish command attaches the three artifacts plus their Stable manifest"); + assert.match(promotion, /"--draft"[\s\S]*expectedAssets[\s\S]*"--draft=false"/, + "publication exposes Stable only after the complete draft matrix is digest-verified"); }); test("release documents never reintroduce a Windows artifact, installer or signing lane", () => { diff --git a/test/site-stable-manifest.mjs b/test/site-stable-manifest.mjs new file mode 100644 index 0000000..a968a95 --- /dev/null +++ b/test/site-stable-manifest.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { parseStableManifest, readStableManifest, validateDownloadedManifest, validateManifestRelease } from "../scripts/stable-manifest-lib.mjs"; + +const root = join(import.meta.dirname, ".."); +const stablePath = join(root, "site", "stable-manifest.json"); + +function releaseFor(manifest) { + const text = `${JSON.stringify(manifest, null, 2)}\n`; + return { + tag_name: manifest.tag, draft: false, prerelease: false, + assets: [ + ...manifest.artifacts.map((artifact) => ({ name: artifact.name, digest: `sha256:${artifact.sha256}`, browser_download_url: artifact.url })), + { name: `1Helm-${manifest.version}-stable.json`, digest: `sha256:${createHash("sha256").update(text).digest("hex")}`, browser_download_url: `https://github.com/gitcommit90/1Helm/releases/download/${manifest.tag}/1Helm-${manifest.version}-stable.json` }, + ], + }; +} + +test("source-controlled stable metadata is machine-readable and contains the last known complete release", () => { + const manifest = readStableManifest(stablePath); + assert.equal(manifest.version, "0.0.41"); + assert.equal(manifest.artifacts.length, 3); + assert.ok(manifest.artifacts.every(({ sha256 }) => /^[a-f0-9]{64}$/.test(sha256))); + const server = readFileSync(join(root, "site", "server.mjs"), "utf8"); + assert.doesNotMatch(server, /RELEASE_FALLBACK_TAG|const RELEASE_FALLBACK/); + assert.match(server, /lastKnownStableManifest/); + assert.match(server, /retainStableManifest/); + assert.match(server, /GitHub Stable manifest would move or rewrite Stable/); +}); + +test("digest-qualified remote stable manifests must match every GitHub Release asset", () => { + const manifest = readStableManifest(stablePath); + const body = `${JSON.stringify(manifest, null, 2)}\n`; + const release = releaseFor(manifest); + assert.equal(validateDownloadedManifest(body, release).commit, manifest.commit); + const mismatched = structuredClone(release); + mismatched.assets[0].digest = `sha256:${"f".repeat(64)}`; + assert.throws(() => validateManifestRelease(manifest, mismatched), /does not match mac_dmg/); + assert.throws(() => validateDownloadedManifest(`${body} `, release), /downloaded manifest digest/); +}); + +test("invalid last-known-good manifests fail closed instead of inventing metadata", () => { + const manifest = JSON.parse(readFileSync(stablePath, "utf8")); + manifest.artifacts.pop(); + assert.throws(() => parseStableManifest(JSON.stringify(manifest)), /artifact matrix is incomplete/); + manifest.artifacts = readStableManifest(stablePath).artifacts; + manifest.artifacts[2].url = "https://example.invalid/fake.tgz"; + assert.throws(() => parseStableManifest(JSON.stringify(manifest)), /name, digest, or URL mismatch/); +}); diff --git a/test/site.mjs b/test/site.mjs index 9ac7afd..4211837 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -30,7 +30,16 @@ const freePort = () => new Promise((resolve, reject) => { const server = createS const waitFor = async (url) => { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { try { const result = await fetch(url); if (result.ok) return result; } catch {} await new Promise((resolve) => setTimeout(resolve, 80)); } throw new Error(`Timed out: ${url}`); }; test("standalone 1helm.com website serves independent product and documentation surface", async () => { const port = await freePort(); - const child = spawn(process.execPath, ["site/server.mjs"], { cwd: root, env: { ...process.env, SITE_PORT: String(port), SITE_RELEASE_METADATA_JSON: JSON.stringify(releaseFixture) }, stdio: ["ignore", "pipe", "pipe"] }); + const releaseManifestFixture = { + schema: 1, kind: "1helm-promoted-stable", repository: "gitcommit90/1Helm", ref: "refs/heads/main", + version: "0.0.31", tag: "v0.0.31", commit: "a".repeat(40), promoted_at: "2026-08-04T12:00:00Z", + promotion: { candidate_workflow_run_id: "1", candidate_artifact_id: "2", manifest_sha256: "b".repeat(64) }, + artifacts: releaseFixture.assets.slice(0, 3).map((asset, index) => ({ + role: ["mac_dmg", "mac_updater_zip", "linux_tgz"][index], name: asset.name, + sha256: asset.digest.slice(7), url: asset.browser_download_url, + })), + }; + const child = spawn(process.execPath, ["site/server.mjs"], { cwd: root, env: { ...process.env, SITE_PORT: String(port), SITE_RELEASE_METADATA_JSON: JSON.stringify(releaseFixture), SITE_STABLE_MANIFEST_JSON: JSON.stringify(releaseManifestFixture) }, stdio: ["ignore", "pipe", "pipe"] }); try { const base = `http://127.0.0.1:${port}`; const health = await (await waitFor(`${base}/health`)).json(); @@ -128,6 +137,9 @@ test("standalone 1helm.com website serves independent product and documentation url: "https://github.com/gitcommit90/1Helm/releases/download/v0.0.31/1Helm-0.0.31-linux-node.tgz", sha256: "c".repeat(64), }); + const stableManifest = await (await fetch(`${base}/api/releases/stable/manifest`)).json(); + assert.equal(stableManifest.version, "0.0.31"); + assert.equal(stableManifest.artifacts.length, 3); assert.equal((await fetch(`${base}/../../package.json`)).status, 404); const sitemap = await (await fetch(`${base}/sitemap.xml`)).text(); assert.match(sitemap, /https:\/\/1helm\.com\/manual\/connections/); @@ -160,25 +172,12 @@ test("release metadata stays available when GitHub's unauthenticated API is exha const base = `http://127.0.0.1:${port}`; await waitFor(`${base}/health`); const response = await fetch(`${base}/api/releases/linux/latest`); - // Derived from package.json rather than pinned: the point of this contract - // is that the offline fallback serves the SHIPPING release, so hardcoding a - // version here would keep passing while the fallback silently went stale. - const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; - const server = readFileSync(join(root, "site", "server.mjs"), "utf8"); - const pending = server.includes('const PENDING_DIGEST = "pending-release-digest"') - && new RegExp(`\\["1Helm-${version.replaceAll(".", "\\.")}-linux-node\\.tgz", PENDING_DIGEST\\]`).test(server); - if (pending) { - // Digests are the digests OF the release commit's artifacts, so they are - // filled in at publish. Until then this must fail closed rather than hand - // an installer a digest that cannot match what it downloads. - assert.equal(response.status, 503, "a fallback with pending digests must refuse, not serve a wrong digest"); - } else { - assert.equal(response.status, 200); - const offline = await response.json(); - assert.equal(offline.version, version, "the offline fallback serves the shipping version"); - assert.equal(offline.url, `https://github.com/gitcommit90/1Helm/releases/download/v${version}/1Helm-${version}-linux-node.tgz`); - assert.match(offline.sha256, /^[a-f0-9]{64}$/, "the offline fallback carries a real digest"); - } + const stable = JSON.parse(readFileSync(join(root, "site", "stable-manifest.json"), "utf8")); + assert.equal(response.status, 200); + const offline = await response.json(); + assert.equal(offline.version, stable.version, "the validated manifest retains the last promoted stable version"); + assert.equal(offline.url, stable.artifacts.find(({ role }) => role === "linux_tgz").url); + assert.equal(offline.sha256, stable.artifacts.find(({ role }) => role === "linux_tgz").sha256); } finally { child.kill("SIGTERM"); await new Promise((resolve) => child.once("exit", resolve)); @@ -409,38 +408,27 @@ test("autonomy report names its deterministic scope and live-system limits", () assert.match(report.scope.does_not_validate.join(" "), /live model or provider/); }); -test("the website's offline release fallback names the shipping version", () => { - // This fallback is served when GitHub's release API is unreachable or rate - // limited. A stale entry does not fail loudly — it quietly hands every - // visitor an older build from the download links, which is exactly how a - // release goes out with the previous version behind the buttons. - const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; +test("the website's last-known-good release metadata is a validated manifest, not server source", () => { + const stable = JSON.parse(readFileSync(join(root, "site", "stable-manifest.json"), "utf8")); const server = readFileSync(join(root, "site", "server.mjs"), "utf8"); - const block = server.match(/const RELEASE_FALLBACK = \{[\s\S]*?\n\};/)?.[0]; - assert.ok(block, "site/server.mjs still exposes a release fallback block"); - assert.match(server, new RegExp(`RELEASE_FALLBACK_TAG = "v${version.replaceAll(".", "\\.")}"`), "the fallback tag matches package.json"); + assert.doesNotMatch(server, /RELEASE_FALLBACK_TAG|const RELEASE_FALLBACK/); + assert.equal(stable.schema, 1); + assert.equal(stable.kind, "1helm-promoted-stable"); + assert.equal(stable.repository, "gitcommit90/1Helm"); // Three artifacts, not six: Windows publishes nothing, it installs the Linux // archive inside WSL. A fallback still naming a Setup executable or .nupkg // would advertise files the release does not contain. for (const asset of [ - `1Helm-${version}-arm64.dmg`, - `1Helm-${version}-mac-arm64.zip`, - `1Helm-${version}-linux-node.tgz`, + `1Helm-${stable.version}-arm64.dmg`, + `1Helm-${stable.version}-mac-arm64.zip`, + `1Helm-${stable.version}-linux-node.tgz`, ]) { - assert.ok(block.includes(asset), `the release fallback names ${asset}`); + assert.ok(stable.artifacts.some(({ name }) => name === asset), `the stable manifest names ${asset}`); } for (const gone of ["windows-x64-setup.exe", "full.nupkg", '"RELEASES"']) { - assert.ok(!block.includes(gone), `the release fallback must not advertise ${gone}`); - } - const digests = [...block.matchAll(/"([a-f0-9]{64})"/g)].map((m) => m[1]); - const pendingCount = [...block.matchAll(/PENDING_DIGEST/g)].length; - if (pendingCount) { - // Pre-publish: every digest must be pending, never a mix. A half-filled - // fallback would serve one real and two wrong digests. - assert.equal(pendingCount, 3, "either all three fallback digests are pending or none are"); - assert.equal(digests.length, 0, "a pending fallback must not also carry a stale real digest"); - } else { - assert.equal(digests.length, 3, "all three desktop artifacts carry a fallback digest"); - assert.equal(new Set(digests).size, 3, "no two fallback digests are duplicated"); + assert.ok(!JSON.stringify(stable).includes(gone), `the stable manifest must not advertise ${gone}`); } + const digests = stable.artifacts.map(({ sha256 }) => sha256); + assert.ok(digests.every((digest) => /^[a-f0-9]{64}$/.test(digest)), "all three desktop artifacts carry a real digest"); + assert.equal(new Set(digests).size, 3, "no two stable artifact digests are duplicated"); });