From 8c010b051ca997e48c13d167dc3627973df7c0cc Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 4 Aug 2026 11:43:36 +0000 Subject: [PATCH] build: automate cross-platform acceptance Retain exact Mac and Linux candidate artifacts, run fail-closed platform acceptance, and assemble promotion evidence only after all supported lanes pass. Co-Authored-By: Claude Signed-off-by: Joseph Yaksich --- .github/workflows/candidate.yml | 343 ++++++++++++++++-- docs/dress-rehearsal.md | 6 + docs/phase4-platform-acceptance.md | 138 +++++++ docs/release-lifecycle.md | 10 +- ops/platform-acceptance/linux.sh | 113 ++++++ ops/platform-acceptance/macos.sh | 154 ++++++++ .../runner-job-started.ps1 | 29 ++ ops/platform-acceptance/runner-job-started.sh | 32 ++ ops/platform-acceptance/windows.ps1 | 201 ++++++++++ package.json | 1 + scripts/candidate-matrix-status.mjs | 35 ++ scripts/candidate-promotion-skeleton.mjs | 141 +++++-- scripts/linux-acceptance-evidence.mjs | 53 +++ scripts/mac-candidate-manifest.mjs | 80 ++++ scripts/macos-acceptance-evidence.mjs | 37 ++ scripts/pending-acceptance-evidence.mjs | 64 ++++ scripts/platform-acceptance-evidence.mjs | 15 + scripts/platform-acceptance-lib.mjs | 250 +++++++++++++ scripts/promotion-lib.mjs | 73 ++-- scripts/run-test-suite.mjs | 2 +- scripts/windows-acceptance-evidence.mjs | 40 ++ site/public/install.ps1 | 19 +- test/phase3-promotion.mjs | 40 +- test/phase4-platform-acceptance.mjs | 199 ++++++++++ 24 files changed, 1985 insertions(+), 90 deletions(-) create mode 100644 docs/phase4-platform-acceptance.md create mode 100755 ops/platform-acceptance/linux.sh create mode 100755 ops/platform-acceptance/macos.sh create mode 100644 ops/platform-acceptance/runner-job-started.ps1 create mode 100755 ops/platform-acceptance/runner-job-started.sh create mode 100644 ops/platform-acceptance/windows.ps1 create mode 100644 scripts/candidate-matrix-status.mjs create mode 100644 scripts/linux-acceptance-evidence.mjs create mode 100644 scripts/mac-candidate-manifest.mjs create mode 100644 scripts/macos-acceptance-evidence.mjs create mode 100644 scripts/pending-acceptance-evidence.mjs create mode 100644 scripts/platform-acceptance-evidence.mjs create mode 100644 scripts/platform-acceptance-lib.mjs create mode 100644 scripts/windows-acceptance-evidence.mjs create mode 100644 test/phase4-platform-acceptance.mjs diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml index b34afbe..c53d2cb 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 }} + version: ${{ steps.identity.outputs.version }} ci-run-id: ${{ steps.identity.outputs.ci_run_id }} steps: - name: Check out the exact successful CI commit @@ -102,13 +103,13 @@ jobs: archive="dist/1Helm-${version}-linux-node.tgz" evidence="dist/candidate-evidence" mkdir -p "$evidence" - HELM_CANDIDATE_ARCHIVE="$archive" \ - HELM_CANDIDATE_MANIFEST="$evidence/candidate.json" \ + HELM_CANDIDATE_ARCHIVE="$archive" HELM_CANDIDATE_MANIFEST="$evidence/candidate.json" \ node scripts/candidate-manifest.mjs cp "$archive.sha256" "$evidence/archive.sha256" 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 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" printf 'ci_run_id=%s\n' "$HELM_CANDIDATE_CI_RUN_ID" >> "$GITHUB_OUTPUT" - name: Attest archive provenance on the hosted builder @@ -138,14 +139,104 @@ jobs: if-no-files-found: error retention-days: 30 + build-macos: + name: Build signed notarized exact Mac candidate + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.repository.full_name == github.repository && + github.sha == github.event.workflow_run.head_sha && + github.ref == 'refs/heads/main' && + vars.HELM_PHASE4_MACOS_ENABLED == '1' + runs-on: [1helm-macos-phase4] + timeout-minutes: 90 + permissions: + contents: read + env: + HELM_PHASE4_RUNNER_LABEL: 1helm-macos-phase4 + outputs: + artifact-name: ${{ steps.identity.outputs.artifact_name }} + steps: + - name: Re-verify trusted event before repository code runs + shell: bash + env: + EXPECTED_SHA: ${{ github.event.workflow_run.head_sha }} + EXPECTED_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + EXPECTED_EVENT: ${{ github.event.workflow_run.event }} + EXPECTED_BRANCH: ${{ github.event.workflow_run.head_branch }} + EXPECTED_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = gitcommit90/1Helm + test "$EXPECTED_REPOSITORY" = "$GITHUB_REPOSITORY" + test "$EXPECTED_EVENT" = push + test "$EXPECTED_BRANCH" = main + test "$EXPECTED_CONCLUSION" = success + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_SHA" = "$EXPECTED_SHA" + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Verify checkout and install exact dependencies without privilege + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "${{ github.event.workflow_run.head_sha }}" + test -z "$(git status --porcelain)" + test "$(uname -s)-$(uname -m)" = Darwin-arm64 + PUPPETEER_SKIP_DOWNLOAD=1 npm ci + + - name: Build, sign, notarize, staple, and Gatekeeper-check both Mac artifacts + shell: bash + env: + HELM_REQUIRE_NOTARIZATION: "1" + run: npm run package:dmg:release + + - name: Bind exact Mac bytes to the candidate and CI identities + id: identity + shell: bash + env: + HELM_CANDIDATE_COMMIT: ${{ 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')" + mkdir -p dist/candidate-evidence + HELM_CANDIDATE_VERSION="$version" \ + HELM_MAC_DMG="dist/1Helm-$version-arm64.dmg" \ + HELM_MAC_ZIP="dist/1Helm-$version-mac-arm64.zip" \ + HELM_MAC_MANIFEST="dist/candidate-evidence/mac-candidate.json" \ + node scripts/mac-candidate-manifest.mjs + printf 'artifact_name=1helm-macos-candidate-%s\n' "$HELM_CANDIDATE_COMMIT" >> "$GITHUB_OUTPUT" + + - name: Upload exact retained signed Mac candidate + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.identity.outputs.artifact_name }} + path: | + dist/1Helm-*-arm64.dmg + dist/1Helm-*-mac-arm64.zip + dist/candidate-evidence/mac-candidate.json + dist/candidate-evidence/mac_dmg-provenance.json + dist/candidate-evidence/mac_updater_zip-provenance.json + if-no-files-found: error + retention-days: 30 + deploy: - name: Install only on private Phase 2 dress rehearsal + name: Retain private Phase 2 Linux dress rehearsal needs: build runs-on: [1helm-dress-rehearsal-phase2] timeout-minutes: 20 permissions: contents: read actions: read + attestations: read steps: - name: Download this workflow's exact candidate uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -159,10 +250,12 @@ jobs: run: | set -euo pipefail test "$GITHUB_REPOSITORY" = "gitcommit90/1Helm" + test "$GITHUB_EVENT_NAME" = workflow_run test "$EXPECTED_COMMIT" = "${{ github.event.workflow_run.head_sha }}" - test "${{ github.event.workflow_run.head_branch }}" = "main" + test "${{ github.event.workflow_run.event }}" = push + test "${{ github.event.workflow_run.head_branch }}" = main test "${{ github.event.workflow_run.head_repository.full_name }}" = "$GITHUB_REPOSITORY" - test "${{ github.event.workflow_run.conclusion }}" = "success" + test "${{ github.event.workflow_run.conclusion }}" = success archive="$(find candidate-download -maxdepth 1 -type f -name '1Helm-*-linux-node.tgz' -print -quit)" test -n "$archive" install -m 0600 "$archive" /var/lib/1helm-candidate/inbox/candidate.tgz @@ -170,15 +263,10 @@ jobs: install -m 0600 candidate-download/candidate-evidence/provenance.bundle.json /var/lib/1helm-candidate/inbox/provenance.bundle.json sudo -n /usr/local/sbin/1helm-candidate-install - - name: Publish private installation evidence in the job log - 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 + python3 /usr/local/lib/1helm-candidate/candidate-boundary.py summary /var/lib/1helm-candidate/evidence/status.json install -d -m 0700 candidate-result install -m 0600 /var/lib/1helm-candidate/evidence/status.json candidate-result/dress-rehearsal.json @@ -190,48 +278,253 @@ jobs: 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' + accept-linux: + name: Linux clean, update, state, health, and rollback acceptance + needs: build runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 60 permissions: contents: read actions: read + attestations: read steps: - - name: Check out the exact candidate commit + - name: Check out the exact candidate verification code uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ needs.build.outputs.commit }} fetch-depth: 1 persist-credentials: false - - name: Download exact built Linux candidate + - name: Download exact 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 + - name: Execute real ephemeral systemd acceptance + env: + GH_TOKEN: ${{ github.token }} + HELM_EXPECTED_COMMIT: ${{ needs.build.outputs.commit }} + HELM_EXPECTED_CI_RUN_ID: ${{ needs.build.outputs.ci-run-id }} + HELM_CANDIDATE_MANIFEST: candidate-download/candidate-evidence/candidate.json + HELM_CANDIDATE_PROVENANCE: candidate-download/candidate-evidence/provenance.bundle.json + HELM_ACCEPTANCE_OUTPUT: linux-acceptance.json + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = gitcommit90/1Helm + test "$GITHUB_EVENT_NAME" = workflow_run + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_SHA" = "$HELM_EXPECTED_COMMIT" + test "${{ github.event.workflow_run.event }}" = push + test "${{ github.event.workflow_run.head_repository.full_name }}" = "$GITHUB_REPOSITORY" + export HELM_CANDIDATE_ARCHIVE="$(find candidate-download -maxdepth 1 -type f -name '1Helm-*-linux-node.tgz' -print -quit)" + test -n "$HELM_CANDIDATE_ARCHIVE" + bash ops/platform-acceptance/linux.sh + + - name: Upload exact Linux acceptance evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: 1helm-dress-rehearsal-evidence-${{ needs.build.outputs.commit }} - path: rehearsal-download + name: 1helm-linux-acceptance-${{ needs.build.outputs.commit }} + path: linux-acceptance.json + if-no-files-found: error + retention-days: 30 - - name: Assemble retained bytes without rebuilding + accept-macos: + name: macOS signature, clean install, updater, state, and health acceptance + needs: [build, build-macos] + if: needs.build-macos.result == 'success' && vars.HELM_PHASE4_MACOS_ENABLED == '1' + runs-on: [1helm-macos-phase4] + timeout-minutes: 60 + permissions: + contents: read + actions: read + env: + HELM_PHASE4_RUNNER_LABEL: 1helm-macos-phase4 + steps: + - name: Re-verify trusted event before repository code runs + shell: bash + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = gitcommit90/1Helm + test "$GITHUB_EVENT_NAME" = workflow_run + test "$GITHUB_REF" = refs/heads/main + test "$GITHUB_SHA" = "${{ needs.build.outputs.commit }}" + test "${{ github.event.workflow_run.event }}" = push + test "${{ github.event.workflow_run.head_branch }}" = main + test "${{ github.event.workflow_run.head_repository.full_name }}" = "$GITHUB_REPOSITORY" + test "${{ github.event.workflow_run.conclusion }}" = success + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.build.outputs.commit }} + fetch-depth: 1 + persist-credentials: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.build-macos.outputs.artifact-name }} + path: mac-candidate-download + + - name: Execute exact Apple Silicon acceptance + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HELM_EXPECTED_COMMIT: ${{ needs.build.outputs.commit }} + HELM_EXPECTED_CI_RUN_ID: ${{ needs.build.outputs.ci-run-id }} + HELM_MAC_CANDIDATE_DOWNLOAD: mac-candidate-download + HELM_ACCEPTANCE_OUTPUT: macos-acceptance.json + run: bash ops/platform-acceptance/macos.sh + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: 1helm-macos-acceptance-${{ needs.build.outputs.commit }} + path: macos-acceptance.json + if-no-files-found: error + retention-days: 30 + + accept-windows: + name: Windows 11 WSL behavior, reboot, update, keepalive, and uninstall acceptance + needs: build + if: needs.build.result == 'success' && vars.HELM_PHASE4_WINDOWS_ENABLED == '1' + runs-on: [1helm-windows-phase4] + timeout-minutes: 90 + permissions: + contents: read + actions: read + attestations: read + env: + HELM_PHASE4_RUNNER_LABEL: 1helm-windows-phase4 + steps: + - name: Re-verify trusted event before repository code runs + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + if ($env:GITHUB_REPOSITORY -ne 'gitcommit90/1Helm' -or $env:GITHUB_EVENT_NAME -ne 'workflow_run' -or + $env:GITHUB_REF -ne 'refs/heads/main' -or $env:GITHUB_SHA -ne '${{ needs.build.outputs.commit }}' -or + '${{ github.event.workflow_run.event }}' -ne 'push' -or '${{ github.event.workflow_run.head_branch }}' -ne 'main' -or + '${{ github.event.workflow_run.head_repository.full_name }}' -ne $env:GITHUB_REPOSITORY -or + '${{ github.event.workflow_run.conclusion }}' -ne 'success') { throw 'Windows runner refused untrusted candidate identity.' } + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.build.outputs.commit }} + fetch-depth: 1 + persist-credentials: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ needs.build.outputs.artifact-name }} + path: candidate-download + + - name: Execute exact Windows 11 WSL behavior acceptance + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + HELM_EXPECTED_COMMIT: ${{ needs.build.outputs.commit }} + HELM_EXPECTED_CI_RUN_ID: ${{ needs.build.outputs.ci-run-id }} + HELM_CANDIDATE_MANIFEST: candidate-download/candidate-evidence/candidate.json + HELM_CANDIDATE_PROVENANCE: candidate-download/candidate-evidence/provenance.bundle.json + HELM_ACCEPTANCE_OUTPUT: windows-acceptance.json + run: | + $archive = Get-ChildItem candidate-download -Filter '1Helm-*-linux-node.tgz' | Select-Object -First 1 -ExpandProperty FullName + if (-not $archive) { throw 'Exact Linux candidate archive is missing.' } + $env:HELM_CANDIDATE_ARCHIVE = $archive + & .\ops\platform-acceptance\windows.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: 1helm-windows-acceptance-${{ needs.build.outputs.commit }} + path: windows-acceptance.json + if-no-files-found: error + retention-days: 30 + + assemble-promotion: + name: Assemble complete Phase 3 promotion bundle without rebuilding + needs: [build, build-macos, deploy, accept-linux, accept-macos, accept-windows] + if: >- + needs.build.result == 'success' && needs.build-macos.result == 'success' && + needs.deploy.result == 'success' && needs.accept-linux.result == 'success' && + needs.accept-macos.result == 'success' && needs.accept-windows.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.build.outputs.commit }} + fetch-depth: 1 + persist-credentials: false + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: { name: "${{ needs.build.outputs.artifact-name }}", path: candidate-download } + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: { name: "${{ needs.build-macos.outputs.artifact-name }}", path: mac-candidate-download } + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: { name: "1helm-dress-rehearsal-evidence-${{ needs.build.outputs.commit }}", path: rehearsal-download } + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: { name: "1helm-linux-acceptance-${{ needs.build.outputs.commit }}", path: linux-acceptance-download } + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: { name: "1helm-macos-acceptance-${{ needs.build.outputs.commit }}", path: macos-acceptance-download } + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: { name: "1helm-windows-acceptance-${{ needs.build.outputs.commit }}", path: windows-acceptance-download } + + - name: Assemble only the retained complete matrix and evidence env: HELM_CANDIDATE_DOWNLOAD: candidate-download + HELM_MAC_CANDIDATE_DOWNLOAD: mac-candidate-download HELM_REHEARSAL_EVIDENCE: rehearsal-download/dress-rehearsal.json + HELM_LINUX_ACCEPTANCE_EVIDENCE: linux-acceptance-download/linux-acceptance.json + HELM_MAC_ACCEPTANCE_EVIDENCE: macos-acceptance-download/macos-acceptance.json + HELM_WINDOWS_ACCEPTANCE_EVIDENCE: windows-acceptance-download/windows-acceptance.json + HELM_ACCEPTANCE_CONTENT: docs/phase4-platform-acceptance.md 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 + - 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 + + candidate-status: + name: Report per-platform Phase 3 readiness + needs: [build, build-macos, deploy, accept-linux, accept-macos, accept-windows, assemble-promotion] + if: always() && needs.build.result != 'skipped' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 1 + persist-credentials: false + - name: Retain honest pass, failure, and missing-runner blockers + env: + HELM_CANDIDATE_COMMIT: ${{ github.event.workflow_run.head_sha }} + HELM_CANDIDATE_VERSION: ${{ needs.build.outputs.version }} + HELM_LINUX_BUILD_RESULT: ${{ needs.build.result }} + HELM_LINUX_REHEARSAL_RESULT: ${{ needs.deploy.result }} + HELM_MAC_BUILD_RESULT: ${{ needs.build-macos.result }} + HELM_LINUX_ACCEPTANCE_RESULT: ${{ needs.accept-linux.result }} + HELM_MAC_ACCEPTANCE_RESULT: ${{ needs.accept-macos.result }} + HELM_WINDOWS_ACCEPTANCE_RESULT: ${{ needs.accept-windows.result }} + HELM_PROMOTION_BUNDLE_RESULT: ${{ needs.assemble-promotion.result }} + HELM_CANDIDATE_STATUS_OUTPUT: candidate-matrix-status.json + run: node scripts/candidate-matrix-status.mjs + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: 1helm-candidate-matrix-status-${{ github.event.workflow_run.head_sha }} + path: candidate-matrix-status.json + if-no-files-found: error + retention-days: 30 diff --git a/docs/dress-rehearsal.md b/docs/dress-rehearsal.md index 9710ace..e6ca778 100644 --- a/docs/dress-rehearsal.md +++ b/docs/dress-rehearsal.md @@ -67,6 +67,12 @@ previous candidate, and rollback result/time. Historical attempts are retained root-only beside it; detailed install logs are root-only under `/var/log/1helm-candidate`. +Phase 4 retains this private status as the Linux dress-rehearsal proof and also +runs an ephemeral hosted Linux acceptance lane for clean install, distinct +prior Stable to candidate update, state identity, systemd health, and a forced +failure rollback. The Phase 3 bundle requires both records. Phase 4 does not +change or repurpose this guest or its unique runner. + Integrate this read-only evidence with Phase 0 without tracking private coordinates: diff --git a/docs/phase4-platform-acceptance.md b/docs/phase4-platform-acceptance.md new file mode 100644 index 0000000..6d0690a --- /dev/null +++ b/docs/phase4-platform-acceptance.md @@ -0,0 +1,138 @@ +# Phase 4 cross-platform candidate acceptance + +1. Build the ready-to-run Linux TGZ on the hosted builder and retain its + GitHub-hosted provenance attestation. +2. Build the Apple Silicon DMG and updater ZIP on the dedicated Mac runner; + Developer ID-sign, notarize, staple, and Gatekeeper-check both exact + payloads. Missing Apple credentials or runner capacity blocks the matrix. +3. Run Linux, macOS, and Windows 11 acceptance concurrently after their exact + bytes exist. Each job rechecks repository, workflow, push event, main ref, + candidate SHA, CI run, and candidate run before repository code executes. +4. Retain normalized JSON with exact artifact SHA-256 and byte counts, candidate + and CI run identities, machine/runner identity, check timestamps, state + digests, and rollback or scoped-uninstall outcome. Windows binds behavior to + the Linux TGZ and publishes no artifact or signing claim. +5. Assemble Phase 3's promotion bundle only after all builds, the private Linux + dress rehearsal, and all three acceptance records pass. The assembler copies + retained bytes and cannot build. Missing, failed, skipped, or unavailable + lanes remain blockers in the retained candidate matrix status. + +## One-time dedicated runners + +Register repository-scoped runners with `--no-default-labels` and exactly one +label: `1helm-macos-phase4` or `1helm-windows-phase4`. Do not put Proxmox, +Stable, website, production, or broad repository-write credentials on either +machine. The runner account is a dedicated ordinary user with no production +data, no broad `sudo`/administrator membership, and no other workloads. + +Install the root-owned `ops/platform-acceptance/runner-job-started.sh` on macOS +or the administrator-owned native PowerShell +`ops/platform-acceptance/runner-job-started.ps1` on Windows as +`ACTIONS_RUNNER_HOOK_JOB_STARTED`. The hook accepts only `gitcommit90/1Helm`, the Candidate dress +rehearsal workflow, its allowlisted job, a successful CI `push` to trusted +`main`, and the exact SHA. Do not set the enabling variables until the label, +hook, account isolation, and prerequisites below have been inspected: + +- repository variable `HELM_PHASE4_MACOS_ENABLED=1` enables the Mac build and + acceptance jobs; +- repository variable `HELM_PHASE4_WINDOWS_ENABLED=1` enables Windows + acceptance. + +Leaving either variable absent is the safe default: the lane is skipped by +GitHub but recorded as **blocked**, and no complete promotion artifact exists. +Registration tokens are one-use and transient; never save one in an image, +shell profile, workflow, or repository file. + +### macOS runner and credentials + +Use an Apple Silicon Mac retained solely for release acceptance. Install the +repository runner, Node 22, Xcode command-line tools, and the pinned npm +dependencies. In the dedicated account's login keychain install: + +- one Developer ID Application certificate/private key; +- `APPLE_TEAM_ID` and optionally `APPLE_SIGN_IDENTITY` as runner service + environment values; +- an `APPLE_NOTARY_PROFILE` created with `xcrun notarytool store-credentials`. + +The workflow intentionally does not use GitHub repository secrets for signing: +the secure credential and private key remain on the retained Mac. Configure the +runner service to unlock only that dedicated user's keychain before a job and +lock it in a job-completed hook; do not store the keychain password in the +repository, runner labels, workflow environment, or shell history. The account's +`~/Library/Application Support/1Helm-OCI-v1` must be absent before every job; +residue is a hard failure. The job validates signature, notarization ticket, +staple, Gatekeeper, exact versions/digests, clean launch/loopback health, updater +ZIP replacement from the latest digest-qualified prior Stable DMG, and +Application Support identity. Signing requirements are never downgraded to +ad-hoc signing. + +### Windows 11 runner and reboot resume + +Use a dedicated Windows 11 x64 VM/account with no production data. The runner +is non-elevated. The one-time operator proof starts from both WSL optional +features disabled, invokes the tracked installer from an ordinary PowerShell +window, records the single UAC approval, expected restart, and same-user resume, +then stores an administrator-owned, user-readable +`C:\ProgramData\1Helm-Phase4\provisioning-evidence.json`: + +```json +{ + "schema": 1, + "kind": "1helm-windows-runner-provisioning", + "features_initially_disabled": true, + "single_uac": true, + "restart_required": true, + "same_user_resume": true, + "keepalive_after_sign_in": true, + "service_active": true, + "localhost_health": true, + "snapshot_baseline": true, + "dedicated": true, + "production_data": false +} +``` + +After that real provisioning exercise, use the scoped uninstaller, confirm the +named product distribution and install root are absent, and capture the clean, +WSL-enabled dedicated-VM snapshot used before every acceptance job. The runner +registration, hook, pinned rootfs, and the administrator-owned provisioning +record remain; 1Helm product state does not. + +Also stage the pinned Ubuntu rootfs at +`C:\ProgramData\1Helm-Phase4\ubuntu-noble-wsl-amd64.rootfs.tar.gz`. + +The workflow does not claim an in-job Windows reboot: a self-hosted job cannot +honestly survive one. Instead, it records the allowed snapshot-assisted +equivalent. The provisioner restores the accepted clean snapshot before the +job routes; the exact candidate job then stops the same-user limited logon +keepalive, cold-stops only its named WSL distribution, restarts that keepalive, +and requires systemd, localhost health, and retained state to recover. The JSON +record names this mode `snapshot-assisted-equivalent`; it never labels the +exercise as a real reboot. The administrator-owned record separately preserves +the one-time real disabled-features/UAC/restart/same-user-resume proof. + +Windows uses the repository's tracked `install.ps1`, `install.sh`, keepalive, +and `uninstall.ps1` with documented local candidate inputs, so it is a private +site-equivalent path without touching the public website. It creates an +unrelated WSL control distribution and proves uninstall retains it. It never +claims Windows artifact creation or signing. + +## Recovery and teardown + +First disable the applicable repository variable so new jobs cannot route. +Wait for any active job to finish, archive only the retained JSON and exact +candidate artifacts needed by the owner, then remove the repo runner +registration and stop its service. Remove only the dedicated account/VM after +confirming its identity; never use a wildcard or a shared host. + +Mac cleanup quits the test app and removes transient app copies and Application +Support after success; the dedicated Application Support directory is retained +on failure for diagnosis and must be removed before re-enabling the runner. Windows cleanup +uses the scoped product uninstaller for the exact `1helm-phase4` distribution +and separately unregisters only its named unrelated test control. If teardown +is interrupted, leave the runner disabled and inspect both exact names before +retrying. Linux hosted runners are ephemeral; the Phase 2 private runner and +its retained evidence are not teardown targets for Phase 4. + +No Phase 4 automation publishes Stable, creates a tag/Release, deploys the +website, or changes production data/services. diff --git a/docs/release-lifecycle.md b/docs/release-lifecycle.md index 2d36a0d..a5d31e8 100644 --- a/docs/release-lifecycle.md +++ b/docs/release-lifecycle.md @@ -156,8 +156,14 @@ 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. +The trusted-main candidate workflow now retains the complete Phase 4 matrix: +hosted Linux candidate and acceptance, signed/notarized Mac bytes and Apple +Silicon acceptance, and Windows 11 behavior acceptance bound to the Linux TGZ. +It assembles the Phase 3 promotion bundle only when every exact byte and record +passes. Missing credentials, disabled/offline runners, failed checks, or absent +evidence appear in the retained per-platform status as blockers; they never +become skipped success. Provisioning and recovery are documented in +[phase4-platform-acceptance.md](./phase4-platform-acceptance.md). Publication additionally requires `mode=publish`, the exact identity-bound confirmation printed by the dry run, and owner approval in the protected diff --git a/ops/platform-acceptance/linux.sh b/ops/platform-acceptance/linux.sh new file mode 100755 index 0000000..4ffee81 --- /dev/null +++ b/ops/platform-acceptance/linux.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ARCHIVE="${HELM_CANDIDATE_ARCHIVE:?exact Linux candidate archive is required}" +MANIFEST="${HELM_CANDIDATE_MANIFEST:?exact candidate manifest is required}" +PROVENANCE="${HELM_CANDIDATE_PROVENANCE:?exact hosted provenance bundle is required}" +OUTPUT="${HELM_ACCEPTANCE_OUTPUT:?acceptance output is required}" +STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +VERSION="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).version' "$MANIFEST")" +DIGEST="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).artifact.sha256' "$MANIFEST")" +COMMIT="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).source.commit' "$MANIFEST")" +CI_RUN="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).ci.run_id' "$MANIFEST")" + +[[ "$GITHUB_REPOSITORY" == "gitcommit90/1Helm" && "$GITHUB_EVENT_NAME" == "workflow_run" \ + && "$GITHUB_REF" == "refs/heads/main" && "$GITHUB_SHA" == "$COMMIT" \ + && "${HELM_EXPECTED_COMMIT:?}" == "$COMMIT" && "${HELM_EXPECTED_CI_RUN_ID:?}" == "$CI_RUN" ]] \ + || { echo "Linux acceptance refused an untrusted candidate identity." >&2; exit 1; } +export HELM_ACCEPTANCE_PLATFORM=linux +export HELM_ACCEPTANCE_STARTED_AT="$STARTED_AT" +export HELM_PHASE4_RUNNER_LABEL=ubuntu-latest +node "$ROOT/scripts/pending-acceptance-evidence.mjs" +[[ "$(id -u)" -ne 0 ]] || { echo "Linux acceptance must begin as the hosted ordinary runner user." >&2; exit 1; } +[[ "$(sha256sum "$ARCHIVE" | awk '{print $1}')" == "$DIGEST" ]] \ + || { echo "Linux candidate digest mismatch." >&2; exit 1; } +node -e 'import("./scripts/candidate-manifest.mjs").then(({candidateIdentityFromArchive})=>{const x=candidateIdentityFromArchive(process.argv[1]); if(x.commit!==process.argv[2]) process.exit(2)})' "$ARCHIVE" "$COMMIT" +gh attestation verify "$ARCHIVE" --bundle "$PROVENANCE" \ + --repo gitcommit90/1Helm --signer-workflow gitcommit90/1Helm/.github/workflows/candidate.yml \ + --source-ref refs/heads/main --source-digest "$COMMIT" --deny-self-hosted-runners + +work="$(mktemp -d)" +trap 'rm -rf -- "$work"' EXIT +prefix="$(tar -tzf "$ARCHIVE" | awk -F/ '/^[^/]+\/site\/public\/install\.sh$/ && !found { print $1; found=1 }')" +[[ -n "$prefix" ]] || { echo "Candidate installer is missing." >&2; exit 1; } +tar -xzf "$ARCHIVE" -C "$work" "$prefix/site/public/install.sh" + +# The hosted VM is disposable and contains no user or production data. Its +# first installation is therefore an actual clean systemd installation. +sudo env HELM_RELEASE_SHA256="$DIGEST" bash "$work/$prefix/site/public/install.sh" "$ARCHIVE" +sudo systemctl is-active --quiet 1helm.service +curl -fsS http://127.0.0.1:8123/api/setup/status >"$work/clean-health.json" +[[ "$(readlink -f /opt/1helm/current)" == "/opt/1helm/releases/$VERSION-$DIGEST" ]] + +# Resolve the newest immutable public Stable release distinct from this +# candidate version. Candidate versions normally remain unchanged during +# preview phases, so `releases/latest` can legitimately be the same version. +gh api "repos/$GITHUB_REPOSITORY/releases?per_page=20" >"$work/releases.json" +node - "$work/releases.json" "$VERSION" >"$work/previous-release.json" <<'NODE' +const fs = require("fs"); +const releases = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const current = process.argv[3]; +const release = releases.find((item) => !item.draft && !item.prerelease + && /^v\d+\.\d+\.\d+$/.test(String(item.tag_name || "")) + && String(item.tag_name).slice(1) !== current); +if (!release) process.exit(2); +process.stdout.write(JSON.stringify(release)); +NODE +PREVIOUS_VERSION="$(node -p 'String(JSON.parse(require("fs").readFileSync(process.argv[1])).tag_name||"").replace(/^v/,"")' "$work/previous-release.json")" +[[ "$PREVIOUS_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$PREVIOUS_VERSION" != "$VERSION" ]] \ + || { echo "A distinct previous Stable version is required for Linux updater acceptance." >&2; exit 1; } +PREVIOUS_NAME="1Helm-$PREVIOUS_VERSION-linux-node.tgz" +readarray -t previous_asset < <(node - "$work/previous-release.json" "$PREVIOUS_NAME" <<'NODE' +const fs = require("fs"); +const release = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const asset = (release.assets || []).find((item) => item.name === process.argv[3]); +if (!asset || !/^sha256:[a-f0-9]{64}$/.test(String(asset.digest || ""))) process.exit(2); +console.log(asset.browser_download_url); +console.log(asset.digest.slice(7)); +NODE +) +[[ "${#previous_asset[@]}" -eq 2 ]] || { echo "Prior Stable Linux asset lacks digest-qualified metadata." >&2; exit 1; } +PREVIOUS_URL="${previous_asset[0]}" +curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$work/$PREVIOUS_NAME" "$PREVIOUS_URL" +PREVIOUS_DIGEST="$(sha256sum "$work/$PREVIOUS_NAME" | awk '{print $1}')" +[[ "$PREVIOUS_DIGEST" == "${previous_asset[1]}" ]] || { echo "Prior Stable Linux digest mismatch." >&2; exit 1; } +previous_prefix="$(tar -tzf "$work/$PREVIOUS_NAME" | awk -F/ '/^[^/]+\/site\/public\/install\.sh$/ && !found { print $1; found=1 }')" +tar -xzf "$work/$PREVIOUS_NAME" -C "$work" "$previous_prefix/site/public/install.sh" +sudo env HELM_RELEASE_SHA256="$PREVIOUS_DIGEST" bash "$work/$previous_prefix/site/public/install.sh" "$work/$PREVIOUS_NAME" +[[ "$(node -p 'require("/opt/1helm/current/package.json").version')" == "$PREVIOUS_VERSION" ]] + +MARKER=/var/lib/1helm-oci-v1/phase4-acceptance-state +openssl rand -hex 32 | sudo tee "$MARKER" >/dev/null +sudo chown 1helm:1helm "$MARKER" +STATE_BEFORE="$(sudo sha256sum "$MARKER" | awk '{print $1}')" +CANDIDATE_RELEASE="/opt/1helm/releases/$VERSION-$DIGEST" +[[ -d "$CANDIDATE_RELEASE" ]] || { echo "Clean candidate release was not retained for updater acceptance." >&2; exit 1; } +sudo "$CANDIDATE_RELEASE/site/public/apply-linux-release.sh" "$CANDIDATE_RELEASE" "$VERSION" +[[ "$(node -p 'require("/opt/1helm/current/package.json").version')" == "$VERSION" ]] +sudo systemctl is-active --quiet 1helm.service +curl -fsS http://127.0.0.1:8123/api/setup/status >"$work/update-health.json" + +# Exercise the candidate's real atomic host transaction with a derived local +# startup-failure fixture. The fixture is never uploaded as candidate bytes. +[[ "$(readlink -f /opt/1helm/current)" == "$CANDIDATE_RELEASE" ]] +FAILURE_RELEASE="/opt/1helm/releases/$VERSION-$DIGEST-phase4-failure" +sudo cp -a "$CANDIDATE_RELEASE" "$FAILURE_RELEASE" +printf '%s\n' 'throw new Error("Phase 4 controlled startup failure");' | sudo tee "$FAILURE_RELEASE/src/server/index.ts" >/dev/null +if sudo "$CANDIDATE_RELEASE/site/public/apply-linux-release.sh" "$FAILURE_RELEASE" "$VERSION"; then + echo "Controlled failure unexpectedly passed." >&2 + exit 1 +fi +[[ "$(readlink -f /opt/1helm/current)" == "$CANDIDATE_RELEASE" ]] +sudo systemctl is-active --quiet 1helm.service +curl -fsS http://127.0.0.1:8123/api/setup/status >"$work/rollback-health.json" +STATE_AFTER="$(sudo sha256sum "$MARKER" | awk '{print $1}')" +[[ "$STATE_BEFORE" == "$STATE_AFTER" ]] +sudo rm -rf -- "$FAILURE_RELEASE" + +export HELM_PREVIOUS_VERSION="$PREVIOUS_VERSION" +export HELM_STATE_BEFORE_SHA256="$STATE_BEFORE" +export HELM_STATE_AFTER_SHA256="$STATE_AFTER" +export HELM_MACHINE_OS_VERSION="$(. /etc/os-release; printf '%s' "$PRETTY_NAME")" +node "$ROOT/scripts/linux-acceptance-evidence.mjs" diff --git a/ops/platform-acceptance/macos.sh b/ops/platform-acceptance/macos.sh new file mode 100755 index 0000000..d18dee8 --- /dev/null +++ b/ops/platform-acceptance/macos.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DOWNLOAD="${HELM_MAC_CANDIDATE_DOWNLOAD:?exact Mac candidate directory is required}" +OUTPUT="${HELM_ACCEPTANCE_OUTPUT:?acceptance output is required}" +MANIFEST="$DOWNLOAD/candidate-evidence/mac-candidate.json" +STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +[[ ! -e "$HOME/Library/Application Support/1Helm-OCI-v1" ]] \ + || { echo "Dedicated Mac account has acceptance residue before this job." >&2; exit 1; } +VERSION="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).version' "$MANIFEST")" +COMMIT="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).commit' "$MANIFEST")" +CI_RUN="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).source_ci.run_id' "$MANIFEST")" +DMG="$DOWNLOAD/1Helm-$VERSION-arm64.dmg" +ZIP="$DOWNLOAD/1Helm-$VERSION-mac-arm64.zip" + +[[ "$GITHUB_REPOSITORY" == "gitcommit90/1Helm" && "$GITHUB_EVENT_NAME" == workflow_run \ + && "$GITHUB_REF" == refs/heads/main && "$GITHUB_SHA" == "$COMMIT" \ + && "$COMMIT" == "${HELM_EXPECTED_COMMIT:?}" && "$CI_RUN" == "${HELM_EXPECTED_CI_RUN_ID:?}" ]] \ + || { echo "macOS acceptance refused an untrusted candidate identity." >&2; exit 1; } +[[ "$(uname -s)" == Darwin && "$(uname -m)" == arm64 ]] +export HELM_ACCEPTANCE_PLATFORM=macos +export HELM_ACCEPTANCE_STARTED_AT="$STARTED_AT" +node "$ROOT/scripts/pending-acceptance-evidence.mjs" +[[ "$(id -u)" -ne 0 ]] || { echo "macOS acceptance must run as the dedicated ordinary user." >&2; exit 1; } + +artifact_field() { + node -p 'const m=JSON.parse(require("fs").readFileSync(process.argv[1])); const a=m.artifacts.find(x=>x.role===process.argv[2]); if(!a)process.exit(2); a[process.argv[3]]' "$MANIFEST" "$1" "$2" +} +DMG_SHA="$(artifact_field mac_dmg sha256)"; ZIP_SHA="$(artifact_field mac_updater_zip sha256)" +[[ "$(shasum -a 256 "$DMG" | awk '{print $1}')" == "$DMG_SHA" ]] +[[ "$(shasum -a 256 "$ZIP" | awk '{print $1}')" == "$ZIP_SHA" ]] +xcrun stapler validate "$DMG" +spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG" + +work="$(mktemp -d)" +apps="$HOME/Applications" +installed="$apps/1Helm.app" +DATA_ROOT="$HOME/Library/Application Support/1Helm-OCI-v1" +completed=0 +mount="" +cleanup() { + [[ -z "$mount" ]] || hdiutil detach "$mount" >/dev/null 2>&1 || true + [[ ! -d "$installed" ]] || rm -rf -- "$installed" + [[ "$completed" -ne 1 || ! -d "$DATA_ROOT" ]] || rm -rf -- "$DATA_ROOT" + rm -rf -- "$work" +} +trap cleanup EXIT +mkdir -p "$apps" "$work/update" "$work/previous" +[[ ! -e "$installed" ]] || { echo "Dedicated Mac account already has a 1Helm app installed." >&2; exit 1; } +attach="$(hdiutil attach "$DMG" -nobrowse)" +mount="$(printf '%s\n' "$attach" | awk 'index($0,"/Volumes/"){print substr($0,index($0,"/Volumes/")); exit}')" +[[ -d "$mount/1Helm.app" ]] +codesign --verify --deep --strict --verbose=2 "$mount/1Helm.app" +xcrun stapler validate "$mount/1Helm.app" +spctl --assess --type execute --verbose=4 "$mount/1Helm.app" +ditto "$mount/1Helm.app" "$installed" +hdiutil detach "$mount" >/dev/null; mount="" +ditto -x -k "$ZIP" "$work/update" +codesign --verify --deep --strict --verbose=2 "$work/update/1Helm.app" +xcrun stapler validate "$work/update/1Helm.app" +spctl --assess --type execute --verbose=4 "$work/update/1Helm.app" +[[ "$(defaults read "$installed/Contents/Info" CFBundleShortVersionString)" == "$VERSION" ]] + +# Real clean launch on the dedicated account. The app uses the isolated account's +# normal Application Support path, so this exercises shipped path semantics. +[[ ! -e "$DATA_ROOT" ]] || { echo "Dedicated Mac account is not clean; Application Support already exists." >&2; exit 1; } +mkdir -p "$DATA_ROOT" +printf '%s\n' server >"$DATA_ROOT/desktop-mode" +open -n "$installed" --args --1helm-background +for _ in {1..180}; do + PORT="$(lsof -nP -a -c 1Helm -iTCP -sTCP:LISTEN 2>/dev/null | awk '/127\.0\.0\.1:/ {split($9,a,":"); print a[length(a)]; exit}')" + [[ "$PORT" =~ ^[0-9]+$ ]] && curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >"$work/clean-health.json" && break + sleep 1 +done +[[ -s "$work/clean-health.json" ]] +osascript -e 'tell application id "com.gitcommit90.1helm" to quit' || true +for _ in {1..30}; do pgrep -x 1Helm >/dev/null || break; sleep 1; done +! pgrep -x 1Helm >/dev/null + +# Reset the dedicated account, install the latest immutable prior Stable DMG, +# then apply the exact candidate updater ZIP while Application Support remains. +rm -rf -- "$installed" "$DATA_ROOT" +gh api "repos/$GITHUB_REPOSITORY/releases?per_page=20" >"$work/releases.json" +node - "$work/releases.json" "$VERSION" >"$work/previous-release.json" <<'NODE' +const fs = require("fs"); +const releases = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const current = process.argv[3]; +const release = releases.find((item) => !item.draft && !item.prerelease + && /^v\d+\.\d+\.\d+$/.test(String(item.tag_name || "")) + && String(item.tag_name).slice(1) !== current); +if (!release) process.exit(2); +process.stdout.write(JSON.stringify(release)); +NODE +PREVIOUS_VERSION="$(node -p 'String(JSON.parse(require("fs").readFileSync(process.argv[1])).tag_name||"").replace(/^v/,"")' "$work/previous-release.json")" +[[ "$PREVIOUS_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$PREVIOUS_VERSION" != "$VERSION" ]] +PREVIOUS_NAME="1Helm-$PREVIOUS_VERSION-arm64.dmg" +previous_metadata="$(node - "$work/previous-release.json" "$PREVIOUS_NAME" <<'NODE' +const fs = require("fs"); +const release = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const asset = (release.assets || []).find((item) => item.name === process.argv[3]); +if (!asset || !/^sha256:[a-f0-9]{64}$/.test(String(asset.digest || ""))) process.exit(2); +console.log(asset.browser_download_url); +console.log(asset.digest.slice(7)); +NODE +)" +PREVIOUS_URL="$(printf '%s\n' "$previous_metadata" | sed -n '1p')" +PREVIOUS_SHA="$(printf '%s\n' "$previous_metadata" | sed -n '2p')" +[[ "$PREVIOUS_URL" == https://github.com/gitcommit90/1Helm/releases/download/v* && "$PREVIOUS_SHA" =~ ^[a-f0-9]{64}$ ]] \ + || { echo "Prior Stable Mac asset lacks digest-qualified metadata." >&2; exit 1; } +curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$work/previous/$PREVIOUS_NAME" "$PREVIOUS_URL" +[[ "$(shasum -a 256 "$work/previous/$PREVIOUS_NAME" | awk '{print $1}')" == "$PREVIOUS_SHA" ]] +previous_attach="$(hdiutil attach "$work/previous/$PREVIOUS_NAME" -nobrowse)" +mount="$(printf '%s\n' "$previous_attach" | awk 'index($0,"/Volumes/"){print substr($0,index($0,"/Volumes/")); exit}')" +codesign --verify --deep --strict --verbose=2 "$mount/1Helm.app" +xcrun stapler validate "$mount/1Helm.app" +spctl --assess --type execute --verbose=4 "$mount/1Helm.app" +ditto "$mount/1Helm.app" "$installed" +hdiutil detach "$mount" >/dev/null; mount="" +mkdir -p "$DATA_ROOT" +printf '%s\n' server >"$DATA_ROOT/desktop-mode" +printf '%s\n' "phase4-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >"$DATA_ROOT/phase4-acceptance-state" +STATE_BEFORE="$(shasum -a 256 "$DATA_ROOT/phase4-acceptance-state" | awk '{print $1}')" +open -n "$installed" --args --1helm-background +for _ in {1..180}; do + PORT="$(lsof -nP -a -c 1Helm -iTCP -sTCP:LISTEN 2>/dev/null | awk '/127\.0\.0\.1:/ {split($9,a,":"); print a[length(a)]; exit}')" + [[ "$PORT" =~ ^[0-9]+$ ]] && curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >"$work/prior-health.json" && break + sleep 1 +done +[[ -s "$work/prior-health.json" ]] +osascript -e 'tell application id "com.gitcommit90.1helm" to quit' || true +for _ in {1..30}; do pgrep -x 1Helm >/dev/null || break; sleep 1; done +! pgrep -x 1Helm >/dev/null +rm -rf -- "$installed" +ditto "$work/update/1Helm.app" "$installed" +[[ "$(defaults read "$installed/Contents/Info" CFBundleShortVersionString)" == "$VERSION" ]] +open -n "$installed" --args --1helm-background +for _ in {1..180}; do + PORT="$(lsof -nP -a -c 1Helm -iTCP -sTCP:LISTEN 2>/dev/null | awk '/127\.0\.0\.1:/ {split($9,a,":"); print a[length(a)]; exit}')" + [[ "$PORT" =~ ^[0-9]+$ ]] && curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >"$work/update-health.json" && break + sleep 1 +done +[[ -s "$work/update-health.json" ]] +STATE_AFTER="$(shasum -a 256 "$DATA_ROOT/phase4-acceptance-state" | awk '{print $1}')" +[[ "$STATE_BEFORE" == "$STATE_AFTER" ]] +osascript -e 'tell application id "com.gitcommit90.1helm" to quit' || true +for _ in {1..30}; do pgrep -x 1Helm >/dev/null || break; sleep 1; done +! pgrep -x 1Helm >/dev/null + +export HELM_STATE_BEFORE_SHA256="$STATE_BEFORE" HELM_STATE_AFTER_SHA256="$STATE_AFTER" +export HELM_PREVIOUS_VERSION="$PREVIOUS_VERSION" +export HELM_MACHINE_OS_VERSION="$(sw_vers -productVersion)" +node "$ROOT/scripts/macos-acceptance-evidence.mjs" +completed=1 diff --git a/ops/platform-acceptance/runner-job-started.ps1 b/ops/platform-acceptance/runner-job-started.ps1 new file mode 100644 index 0000000..eb29377 --- /dev/null +++ b/ops/platform-acceptance/runner-job-started.ps1 @@ -0,0 +1,29 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +function Refuse([string] $Message) { + throw "Phase 4 runner refused: $Message" +} + +if ($env:GITHUB_REPOSITORY -ne 'gitcommit90/1Helm') { Refuse 'another repository' } +if ($env:GITHUB_WORKFLOW -ne 'Candidate dress rehearsal') { Refuse 'another workflow' } +if ($env:GITHUB_WORKFLOW_REF -ne 'gitcommit90/1Helm/.github/workflows/candidate.yml@refs/heads/main') { + Refuse 'another workflow path or ref' +} +if ($env:GITHUB_EVENT_NAME -ne 'workflow_run') { Refuse 'PR, fork, dispatch, and direct-push events' } +if ($env:GITHUB_JOB -ne 'accept-windows') { Refuse 'a non-allowlisted job' } +if ($env:GITHUB_REF -ne 'refs/heads/main' -or $env:GITHUB_SHA -notmatch '^[a-f0-9]{40}$') { + Refuse 'a non-main or invalid workflow identity' +} +if (-not (Test-Path -LiteralPath $env:GITHUB_EVENT_PATH -PathType Leaf)) { Refuse 'a missing event payload' } + +$event = Get-Content -LiteralPath $env:GITHUB_EVENT_PATH -Raw | ConvertFrom-Json +$run = $event.workflow_run +if ($event.repository.full_name -ne $env:GITHUB_REPOSITORY -or + $run.head_repository.full_name -ne $env:GITHUB_REPOSITORY -or + $run.name -ne 'CI' -or $run.event -ne 'push' -or $run.head_branch -ne 'main' -or + $run.head_sha -ne $env:GITHUB_SHA -or $run.status -ne 'completed' -or $run.conclusion -ne 'success') { + Refuse 'an untrusted repository/ref/SHA/CI event' +} diff --git a/ops/platform-acceptance/runner-job-started.sh b/ops/platform-acceptance/runner-job-started.sh new file mode 100755 index 0000000..a51c0dc --- /dev/null +++ b/ops/platform-acceptance/runner-job-started.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ "${GITHUB_REPOSITORY:-}" == "gitcommit90/1Helm" ]] || { echo "Phase 4 runner refused another repository." >&2; exit 1; } +[[ "${GITHUB_WORKFLOW:-}" == "Candidate dress rehearsal" ]] || { echo "Phase 4 runner refused another workflow." >&2; exit 1; } +[[ "${GITHUB_WORKFLOW_REF:-}" == "gitcommit90/1Helm/.github/workflows/candidate.yml@refs/heads/main" ]] || { echo "Phase 4 runner refused another workflow path or ref." >&2; exit 1; } +[[ "${GITHUB_EVENT_NAME:-}" == "workflow_run" ]] || { echo "Phase 4 runner refused PR, fork, dispatch, and direct-push events." >&2; exit 1; } +case "${GITHUB_JOB:-}" in + build-macos) expected_label=1helm-macos-phase4 ;; + accept-macos) expected_label=1helm-macos-phase4 ;; + *) echo "Phase 4 runner refused job ${GITHUB_JOB:-missing}." >&2; exit 1 ;; +esac +[[ "${GITHUB_REF:-}" == "refs/heads/main" && "${GITHUB_SHA:-}" =~ ^[a-f0-9]{40}$ ]] \ + || { echo "Phase 4 runner refused a non-main or invalid workflow identity." >&2; exit 1; } + +event="${GITHUB_EVENT_PATH:?GitHub event payload is required}" +python3 - "$event" "$GITHUB_REPOSITORY" "$GITHUB_SHA" <<'PY' +import json, sys +event = json.load(open(sys.argv[1], encoding="utf-8")) +run = event.get("workflow_run") or {} +repo = ((run.get("head_repository") or {}).get("full_name")) +if not ( + repo == sys.argv[2] == ((event.get("repository") or {}).get("full_name")) + and run.get("name") == "CI" + and run.get("event") == "push" + and run.get("head_branch") == "main" + and run.get("head_sha") == sys.argv[3] + and run.get("status") == "completed" + and run.get("conclusion") == "success" +): + raise SystemExit("Phase 4 runner refused an untrusted repository/ref/SHA/CI event.") +PY diff --git a/ops/platform-acceptance/windows.ps1 b/ops/platform-acceptance/windows.ps1 new file mode 100644 index 0000000..06b3bbc --- /dev/null +++ b/ops/platform-acceptance/windows.ps1 @@ -0,0 +1,201 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$Archive = (Resolve-Path $env:HELM_CANDIDATE_ARCHIVE).Path +$ManifestPath = (Resolve-Path $env:HELM_CANDIDATE_MANIFEST).Path +$ProvenancePath = (Resolve-Path $env:HELM_CANDIDATE_PROVENANCE).Path +$Output = $env:HELM_ACCEPTANCE_OUTPUT +$StartedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') +$Manifest = Get-Content -Raw $ManifestPath | ConvertFrom-Json +$Commit = [string]$Manifest.source.commit +$Version = [string]$Manifest.version +$Digest = [string]$Manifest.artifact.sha256 +$CiRun = [string]$Manifest.ci.run_id +$Wsl = Join-Path $env:SystemRoot 'System32\wsl.exe' +$Distro = '1helm-phase4' +$Unrelated = '1helm-phase4-unrelated' +$InstallRoot = 'C:\1helm-phase4' +$InstallScript = Join-Path $Root 'site\public\install.ps1' +$UninstallScript = Join-Path $Root 'site\public\uninstall.ps1' +$KeepaliveSource = Join-Path $Root 'site\public\keepalive' + +function Refuse([string] $Message) { throw "Windows acceptance refused: $Message" } +function Get-Distros { + $raw = (& $Wsl --list --quiet 2>$null | Out-String) -replace "`0", '' + return @($raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} +function Invoke-Distro([string] $Command) { + & $Wsl -d $Distro -u root --exec /bin/bash -lc $Command | Out-Host + if ($LASTEXITCODE -ne 0) { Refuse "in-distribution command failed: $Command" } +} + +if ($env:GITHUB_REPOSITORY -ne 'gitcommit90/1Helm' -or $env:GITHUB_EVENT_NAME -ne 'workflow_run' -or + $env:GITHUB_REF -ne 'refs/heads/main' -or $env:GITHUB_SHA -ne $Commit -or + $env:HELM_EXPECTED_COMMIT -ne $Commit -or $env:HELM_EXPECTED_CI_RUN_ID -ne $CiRun -or + $Manifest.source.state -ne 'trusted-main' -or $Manifest.ci.workflow -ne 'CI' -or $Manifest.ci.conclusion -ne 'success') { + Refuse 'repository, event, ref, commit, or successful CI identity changed' +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +if ($identity.User.Value -eq 'S-1-5-18' -or $identity.Name -match '^NT AUTHORITY') { Refuse 'runner is not the dedicated signed-in user account' } +if (([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Refuse 'runner account must not be elevated' } +if (Test-Path $InstallRoot) { Refuse "dedicated install root is not clean: $InstallRoot" } +foreach ($name in @($Distro, $Unrelated)) { if ((Get-Distros) -contains $name) { Refuse "dedicated distribution already exists: $name" } } +$env:HELM_ACCEPTANCE_PLATFORM = 'windows' +$env:HELM_ACCEPTANCE_STARTED_AT = $StartedAt +node (Join-Path $Root 'scripts\pending-acceptance-evidence.mjs') +if ($LASTEXITCODE -ne 0) { Refuse 'could not retain pending Windows evidence' } + +# This runner is provisioned after its one-time WSL feature/reboot proof. The +# provisioning record is root-owned outside the work directory and is consumed +# read-only here; automation never invents UAC/reboot success. +$Provisioning = 'C:\ProgramData\1Helm-Phase4\provisioning-evidence.json' +if (-not (Test-Path $Provisioning)) { Refuse 'one-time clean-feature, single-UAC, restart/resume provisioning evidence is missing' } +$Provision = Get-Content -Raw $Provisioning | ConvertFrom-Json +if ($Provision.schema -ne 1 -or $Provision.kind -ne '1helm-windows-runner-provisioning' -or + $Provision.features_initially_disabled -ne $true -or $Provision.single_uac -ne $true -or + $Provision.restart_required -ne $true -or $Provision.same_user_resume -ne $true -or + $Provision.keepalive_after_sign_in -ne $true -or $Provision.service_active -ne $true -or + $Provision.localhost_health -ne $true -or $Provision.snapshot_baseline -ne $true -or + $Provision.dedicated -ne $true -or $Provision.production_data -ne $false) { + Refuse 'one-time real-restart and accepted-snapshot provisioning proof is incomplete' +} +if ((Get-FileHash $Archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Digest) { Refuse 'Linux TGZ digest changed' } +gh attestation verify $Archive --bundle $ProvenancePath ` + --repo gitcommit90/1Helm --signer-workflow gitcommit90/1Helm/.github/workflows/candidate.yml ` + --source-ref refs/heads/main --source-digest $Commit --deny-self-hosted-runners +if ($LASTEXITCODE -ne 0) { Refuse 'hosted Linux candidate attestation verification failed' } + +$Rootfs = 'C:\ProgramData\1Helm-Phase4\ubuntu-noble-wsl-amd64.rootfs.tar.gz' +if (-not (Test-Path $Rootfs)) { Refuse 'pinned offline WSL rootfs is missing from the dedicated runner' } +$RootfsSha = '8251e27ffff381a4af5f41dcb94d867de3e0d9774a9241908ab34555d99315ea' +if ((Get-FileHash $Rootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne $RootfsSha) { Refuse 'pinned runner rootfs digest mismatch' } +$unrelatedRoot = Join-Path $env:TEMP '1helm-phase4-unrelated' +$previousArchive = $null +try { + New-Item -ItemType Directory -Path $unrelatedRoot -Force | Out-Null + & $Wsl --import $Unrelated $unrelatedRoot $Rootfs + if ($LASTEXITCODE -ne 0) { Refuse 'could not create unrelated WSL safety control' } + +# Clean install the exact candidate first through the tracked site-equivalent +# Windows entry point, prove onboarding and health, then remove only that target +# so the same VM can exercise the distinct prior-to-candidate path. +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $InstallScript ` + -Distro $Distro -InstallRoot $InstallRoot -LocalArchive $Archive ` + -LocalInstaller (Join-Path $Root 'site\public\install.sh') -LocalArchiveSha256 $Digest ` + -LocalRootfs $Rootfs -LocalRootfsSha256 $RootfsSha ` + -KeepaliveSource $KeepaliveSource +if ($LASTEXITCODE -ne 0) { Refuse "exact candidate clean install failed with exit $LASTEXITCODE" } +if (-not ((Get-Distros) -ccontains $Distro) -or -not ((Get-Distros) -ccontains $Unrelated)) { Refuse 'clean install did not retain both target and unrelated control distributions' } +$cleanHealth = Invoke-WebRequest -Uri 'http://localhost:8123/api/setup/status' -UseBasicParsing -TimeoutSec 10 +if ($cleanHealth.StatusCode -ne 200 -or -not (($cleanHealth.Content | ConvertFrom-Json).needs_setup)) { Refuse 'candidate clean install did not expose localhost onboarding health' } +Invoke-Distro "test \"`$(systemctl is-active 1helm.service)\" = active; test \"`$(node -p 'require(\"/opt/1helm/current/package.json\").version')\" = '$Version'" +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $UninstallScript -Distro $Distro -InstallRoot $InstallRoot -Force +if ($LASTEXITCODE -ne 0 -or (Get-Distros) -ccontains $Distro -or -not ((Get-Distros) -ccontains $Unrelated) -or (Test-Path $InstallRoot)) { + Refuse 'clean-install teardown was not scoped to the exact target' +} + +# Resolve the newest public Stable Linux archive distinct from the candidate. +# Install that through the tracked site path, seed state, then use the exact +# retained candidate's atomic Linux updater transaction. +$headers = @{ Accept = 'application/vnd.github+json'; Authorization = "Bearer $env:GH_TOKEN"; 'X-GitHub-Api-Version' = '2022-11-28' } +$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/releases?per_page=20" -Headers $headers +$previous = @($releases | Where-Object { -not $_.draft -and -not $_.prerelease -and $_.tag_name -match '^v\d+\.\d+\.\d+$' -and $_.tag_name.Substring(1) -ne $Version })[0] +if ($null -eq $previous) { Refuse 'no distinct prior Stable release exists for updater acceptance' } +$PreviousVersion = [string]$previous.tag_name.Substring(1) +$PreviousName = "1Helm-$PreviousVersion-linux-node.tgz" +$PreviousAsset = @($previous.assets | Where-Object name -eq $PreviousName)[0] +if ($null -eq $PreviousAsset -or [string]$PreviousAsset.digest -notmatch '^sha256:[a-f0-9]{64}$') { Refuse 'prior Stable Linux asset lacks digest-qualified metadata' } +$PreviousArchive = Join-Path $env:TEMP $PreviousName +$previousArchive = $PreviousArchive +Invoke-WebRequest -Uri $PreviousAsset.browser_download_url -OutFile $PreviousArchive -UseBasicParsing +$PreviousDigest = ([string]$PreviousAsset.digest).Substring(7) +if ((Get-FileHash $PreviousArchive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $PreviousDigest) { Refuse 'prior Stable Linux digest mismatch' } + +# Site-equivalent prior Stable install. This is the tracked site installer with +# only its documented local archive inputs, running as the signed-in user. +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $InstallScript ` + -Distro $Distro -InstallRoot $InstallRoot -LocalArchive $PreviousArchive ` + -LocalInstaller (Join-Path $Root 'site\public\install.sh') -LocalArchiveSha256 $PreviousDigest ` + -LocalRootfs $Rootfs -LocalRootfsSha256 $RootfsSha ` + -KeepaliveSource $KeepaliveSource +if ($LASTEXITCODE -ne 0) { Refuse "site-equivalent install failed with exit $LASTEXITCODE" } +if (-not ((Get-Distros) -ccontains $Distro)) { Refuse 'target WSL distribution was not imported' } +if (-not ((Get-Distros) -ccontains $Unrelated)) { Refuse 'unrelated WSL control disappeared during install' } + +$health = Invoke-WebRequest -Uri 'http://localhost:8123/api/setup/status' -UseBasicParsing -TimeoutSec 10 +if ($health.StatusCode -ne 200 -or -not (($health.Content | ConvertFrom-Json).needs_setup)) { Refuse 'prior Stable localhost health did not report a clean install' } +Invoke-Distro "test \"`$(systemctl is-active 1helm.service)\" = active; test \"`$(node -p 'require(\"/opt/1helm/current/package.json\").version')\" = '$PreviousVersion'" + +# Stage the exact candidate as a verified retained release, then apply its real +# atomic Linux update transaction from prior Stable to candidate. +Invoke-Distro "install -o 1helm -g 1helm -m 0600 /dev/stdin /var/lib/1helm-oci-v1/phase4-acceptance-state <<< phase4-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" +$StateBefore = (& $Wsl -d $Distro -u root --exec sha256sum /var/lib/1helm-oci-v1/phase4-acceptance-state | Out-String).Split()[0] +$Stage = Join-Path $InstallRoot 'candidate-stage' +New-Item -ItemType Directory -Path $Stage -Force | Out-Null +Copy-Item $Archive (Join-Path $Stage 'candidate.tgz') -Force +$StageInDistro = '/mnt/' + $Stage.Substring(0,1).ToLowerInvariant() + ($Stage.Substring(2) -replace '\\','/') +$CandidateRelease = "/opt/1helm/releases/$Version-$Digest" +Invoke-Distro "set -e; rm -rf '$CandidateRelease.tmp'; mkdir -p '$CandidateRelease.tmp'; tar -xzf '$StageInDistro/candidate.tgz' -C '$CandidateRelease.tmp' --strip-components=1; chown -R 1helm:1helm '$CandidateRelease.tmp'; mv '$CandidateRelease.tmp' '$CandidateRelease'; '$CandidateRelease/site/public/apply-linux-release.sh' '$CandidateRelease' '$Version'" +Invoke-Distro "test \"`$(systemctl is-active 1helm.service)\" = active; test \"`$(node -p 'require(\"/opt/1helm/current/package.json\").version')\" = '$Version'" +$StateAfterUpdate = (& $Wsl -d $Distro -u root --exec sha256sum /var/lib/1helm-oci-v1/phase4-acceptance-state | Out-String).Split()[0] +if ($StateBefore -ne $StateAfterUpdate) { Refuse 'WSL data marker changed across update' } + +# A self-hosted job cannot honestly survive an in-job Windows reboot. The +# dedicated VM is restored to the administrator-accepted clean snapshot before +# routing, and this exact run exercises the reboot-sensitive product boundary: +# stop the limited-user logon keepalive, cold-stop only the target WSL VM, then +# start that same task and require service, localhost, and state recovery. The +# normalized record calls this a snapshot-assisted equivalent, never a reboot. +$KeepaliveTaskPath = '\1Helm\' +$KeepaliveTaskName = '1Helm-WSL-Keepalive' +$KeepaliveTask = Get-ScheduledTask -TaskPath $KeepaliveTaskPath -TaskName $KeepaliveTaskName -ErrorAction SilentlyContinue +if ($null -eq $KeepaliveTask) { Refuse 'limited same-user logon keepalive task is missing' } +$CurrentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$HasLogonTrigger = @($KeepaliveTask.Triggers | Where-Object { $_.CimClass.CimClassName -eq 'MSFT_TaskLogonTrigger' }).Count -ge 1 +if ($KeepaliveTask.Principal.UserId -ne $CurrentUser -or + [string]$KeepaliveTask.Principal.RunLevel -ne 'Limited' -or -not $HasLogonTrigger) { + Refuse 'limited same-user logon keepalive task is missing or changed' +} +Stop-ScheduledTask -TaskPath $KeepaliveTaskPath -TaskName $KeepaliveTaskName -ErrorAction Stop +& $Wsl --terminate $Distro | Out-Null +if ($LASTEXITCODE -ne 0) { Refuse 'targeted WSL cold stop failed' } +Start-ScheduledTask -TaskPath $KeepaliveTaskPath -TaskName $KeepaliveTaskName -ErrorAction Stop +$Recovered = $false +for ($i = 0; $i -lt 90; $i++) { + try { + $resumeHealth = Invoke-WebRequest -Uri 'http://localhost:8123/api/setup/status' -UseBasicParsing -TimeoutSec 5 + if ($resumeHealth.StatusCode -eq 200) { $Recovered = $true; break } + } catch { } + Start-Sleep -Seconds 2 +} +if (-not $Recovered) { Refuse 'snapshot-assisted WSL cold-start equivalent did not recover localhost health' } +Invoke-Distro "test \"`$(systemctl is-active 1helm.service)\" = active; test \"`$(node -p 'require(\"/opt/1helm/current/package.json\").version')\" = '$Version'" +$StateAfter = (& $Wsl -d $Distro -u root --exec sha256sum /var/lib/1helm-oci-v1/phase4-acceptance-state | Out-String).Split()[0] +if ($StateBefore -ne $StateAfter) { Refuse 'WSL data marker changed across update or cold-start equivalent' } + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $UninstallScript -Distro $Distro -InstallRoot $InstallRoot -Force +if ($LASTEXITCODE -ne 0) { Refuse 'scoped uninstall returned failure' } +$distrosAfter = Get-Distros +if ($distrosAfter -ccontains $Distro) { Refuse 'target distribution survived uninstall' } +if (-not ($distrosAfter -ccontains $Unrelated)) { Refuse 'uninstall touched the unrelated WSL control' } +if (Test-Path $InstallRoot) { Refuse 'target install root survived uninstall' } + +$env:HELM_STATE_BEFORE_SHA256 = $StateBefore +$env:HELM_STATE_AFTER_SHA256 = $StateAfter +$env:HELM_PREVIOUS_VERSION = $PreviousVersion +$env:HELM_MACHINE_OS_VERSION = [Environment]::OSVersion.Version.ToString() +$env:HELM_PROVISIONING_EVIDENCE = $Provisioning +node (Join-Path $Root 'scripts\windows-acceptance-evidence.mjs') +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} finally { + # Best-effort teardown touches only the exact Phase 4 names. Any failure + # remains blocked evidence and leaves the VM snapshot disposable. + if ((Get-Distros) -ccontains $Distro) { + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $UninstallScript -Distro $Distro -InstallRoot $InstallRoot -Force | Out-Host + } + if ((Get-Distros) -ccontains $Unrelated) { & $Wsl --unregister $Unrelated | Out-Null } + Remove-Item $unrelatedRoot -Recurse -Force -ErrorAction SilentlyContinue + if ($previousArchive) { Remove-Item $previousArchive -Force -ErrorAction SilentlyContinue } +} diff --git a/package.json b/package.json index e941525..c4dabd3 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "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:phase4": "node --test test/phase4-platform-acceptance.mjs test/phase3-promotion.mjs", "test:fast": "node scripts/run-fast-tests.mjs", "delivery:status": "node scripts/delivery-status.mjs", "stable:status": "node scripts/promotion-status.mjs", diff --git a/scripts/candidate-matrix-status.mjs b/scripts/candidate-matrix-status.mjs new file mode 100644 index 0000000..9122477 --- /dev/null +++ b/scripts/candidate-matrix-status.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { isoNow, REF, REPOSITORY } from "./platform-acceptance-lib.mjs"; + +const result = (value) => value === "success" ? "passed" : value === "failure" || value === "cancelled" ? "failed" : "blocked"; +const env = process.env; +const platforms = { + linux: { build: result(env.HELM_LINUX_BUILD_RESULT), dress_rehearsal: result(env.HELM_LINUX_REHEARSAL_RESULT), acceptance: result(env.HELM_LINUX_ACCEPTANCE_RESULT) }, + macos: { build: result(env.HELM_MAC_BUILD_RESULT), acceptance: result(env.HELM_MAC_ACCEPTANCE_RESULT) }, + windows: { build: "not_applicable", acceptance: result(env.HELM_WINDOWS_ACCEPTANCE_RESULT) }, +}; +for (const value of Object.values(platforms)) { + value.result = Object.values(value).includes("failed") ? "failed" : Object.values(value).includes("blocked") ? "blocked" : "passed"; +} +const status = { + schema: 1, + kind: "1helm-candidate-matrix-status", + repository: REPOSITORY, + ref: REF, + commit: String(env.HELM_CANDIDATE_COMMIT || ""), + version: String(env.HELM_CANDIDATE_VERSION || JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version || ""), + candidate_workflow_run_id: String(env.GITHUB_RUN_ID || ""), + checked_at: isoNow(), + platforms, + promotion_bundle: result(env.HELM_PROMOTION_BUNDLE_RESULT), + complete: Object.values(platforms).every((value) => value.result === "passed") && result(env.HELM_PROMOTION_BUNDLE_RESULT) === "passed", + stable_touched: false, +}; +if (!/^[a-f0-9]{40}$/.test(status.commit) || !/^\d+\.\d+\.\d+$/.test(status.version) || !/^\d+$/.test(status.candidate_workflow_run_id)) { + throw new Error("Candidate matrix status requires exact commit, version, and run identity"); +} +writeFileSync(resolve(env.HELM_CANDIDATE_STATUS_OUTPUT || "candidate-matrix-status.json"), `${JSON.stringify(status, null, 2)}\n`, { mode: 0o600 }); +for (const [platform, value] of Object.entries(platforms)) process.stdout.write(`${platform}: ${value.result}\n`); +process.stdout.write(`promotion bundle: ${status.complete ? "ready" : "blocked"}\nStable touched: NO\n`); diff --git a/scripts/candidate-promotion-skeleton.mjs b/scripts/candidate-promotion-skeleton.mjs index 8b41136..e5e494f 100644 --- a/scripts/candidate-promotion-skeleton.mjs +++ b/scripts/candidate-promotion-skeleton.mjs @@ -2,15 +2,29 @@ import { createHash } from "node:crypto"; import { copyFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; +import { platformEvidenceBlockers } from "./platform-acceptance-lib.mjs"; 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"); +const env = process.env; +const linuxSource = resolve(env.HELM_CANDIDATE_DOWNLOAD || ""); +const macSource = resolve(env.HELM_MAC_CANDIDATE_DOWNLOAD || ""); +const rehearsalPath = resolve(env.HELM_REHEARSAL_EVIDENCE || ""); +const acceptancePaths = { + linux: resolve(env.HELM_LINUX_ACCEPTANCE_EVIDENCE || ""), + macos: resolve(env.HELM_MAC_ACCEPTANCE_EVIDENCE || ""), + windows: resolve(env.HELM_WINDOWS_ACCEPTANCE_EVIDENCE || ""), +}; +const acceptanceContentPath = resolve(env.HELM_ACCEPTANCE_CONTENT || ""); +const output = resolve(env.HELM_PROMOTION_OUTPUT || ""); +const project = resolve(env.HELM_PROJECT_ROOT || "."); +const workflowRunId = String(env.GITHUB_RUN_ID || ""); +const ciRunId = String(env.HELM_CANDIDATE_CI_RUN_ID || ""); +if (!env.HELM_CANDIDATE_DOWNLOAD || !env.HELM_MAC_CANDIDATE_DOWNLOAD || !env.HELM_REHEARSAL_EVIDENCE + || !env.HELM_LINUX_ACCEPTANCE_EVIDENCE || !env.HELM_MAC_ACCEPTANCE_EVIDENCE + || !env.HELM_WINDOWS_ACCEPTANCE_EVIDENCE || !env.HELM_ACCEPTANCE_CONTENT + || !env.HELM_PROMOTION_OUTPUT || !/^\d+$/.test(workflowRunId) || !/^\d+$/.test(ciRunId)) { + throw new Error("Complete Linux/Mac/Windows candidate bytes, evidence, ledger, and run identities are required"); +} mkdirSync(output, { recursive: true, mode: 0o700 }); const digest = sha256File; const record = (sourcePath, name) => { @@ -18,47 +32,108 @@ const record = (sourcePath, name) => { copyFileSync(sourcePath, destination); return { path: name, sha256: digest(destination) }; }; -const candidateSource = join(source, "candidate-evidence", "candidate.json"); + +const candidateSource = join(linuxSource, "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) { +if (candidate?.source?.repository !== STABLE_REPOSITORY || candidate?.source?.ref !== "refs/heads/main" + || candidate?.source?.state !== "trusted-main" || candidate?.ci?.workflow !== "CI" + || candidate?.ci?.run_id !== ciRunId || candidate?.ci?.conclusion !== "success") { + throw new Error("Linux candidate is not the exact successful trusted-main CI candidate"); +} +const linuxArchiveSource = join(linuxSource, candidate.artifact.name); +if (digest(linuxArchiveSource) !== candidate.artifact.sha256 || statSync(linuxArchiveSource).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 = { + +const macManifestPath = join(macSource, "candidate-evidence", "mac-candidate.json"); +const mac = JSON.parse(readFileSync(macManifestPath, "utf8")); +if (mac?.schema !== 1 || mac?.kind !== "1helm-macos-candidate" || mac?.repository !== STABLE_REPOSITORY + || mac?.ref !== "refs/heads/main" || mac?.commit !== commit || mac?.version !== version + || mac?.candidate?.workflow !== "Candidate dress rehearsal" || mac?.candidate?.workflow_path !== ".github/workflows/candidate.yml" + || mac?.candidate?.event !== "workflow_run" || String(mac?.candidate?.run_id) !== workflowRunId + || !/^\d+$/.test(String(mac?.candidate?.run_attempt || "")) || String(mac?.source_ci?.run_id) !== ciRunId + || mac?.source_ci?.conclusion !== "success" || mac?.signing?.identity !== "developer-id-application" + || mac?.signing?.notarization !== "accepted" || mac?.signing?.stapling !== "validated" + || mac?.signing?.gatekeeper !== "accepted" || mac?.builder?.type !== "dedicated-self-hosted" + || mac?.builder?.runner_label !== "1helm-macos-phase4" || mac?.builder?.os !== "macOS" + || mac?.builder?.architecture !== "ARM64") { + throw new Error("Mac candidate manifest does not bind signed/notarized bytes to the exact candidate identity"); +} + +const artifacts = {}; +const linuxArchive = record(linuxArchiveSource, candidate.artifact.name); +artifacts.linux_tgz = { role: "linux_tgz", name: basename(linuxArchive.path), path: linuxArchive.path, sha256: candidate.artifact.sha256, bytes: candidate.artifact.bytes }; +for (const role of ["mac_dmg", "mac_updater_zip"]) { + const item = (Array.isArray(mac.artifacts) ? mac.artifacts : []).find((artifact) => artifact?.role === role); + if (!item || !/^[a-f0-9]{64}$/.test(String(item.sha256 || "")) || !Number.isSafeInteger(item.bytes) || item.bytes <= 0) { + throw new Error(`Mac candidate is missing ${role}`); + } + const source = join(macSource, item.name); + if (digest(source) !== item.sha256 || statSync(source).size !== item.bytes) throw new Error(`${role} bytes changed after the Mac build`); + const copied = record(source, item.name); + artifacts[role] = { role, name: item.name, path: copied.path, sha256: item.sha256, bytes: item.bytes }; +} + +const linuxBundle = record(join(linuxSource, "candidate-evidence", "provenance.bundle.json"), "provenance.bundle.json"); +const linuxProvenanceValue = { 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, + ref: "refs/heads/main", commit, version, builder: "github-hosted", attestation_created: true, + signer_workflow: `${STABLE_REPOSITORY}/.github/workflows/candidate.yml`, + candidate_workflow_run_id: workflowRunId, source_ci_run_id: ciRunId, + artifact: { role: "linux_tgz", name: artifacts.linux_tgz.name, sha256: artifacts.linux_tgz.sha256, bytes: artifacts.linux_tgz.bytes }, + bundle: linuxBundle, }; -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 linuxProvenanceBytes = Buffer.from(`${JSON.stringify(linuxProvenanceValue, null, 2)}\n`); +writeFileSync(join(output, "linux-provenance.json"), linuxProvenanceBytes, { mode: 0o600 }); +artifacts.linux_tgz.provenance = { path: "linux-provenance.json", sha256: createHash("sha256").update(linuxProvenanceBytes).digest("hex") }; +for (const role of ["mac_dmg", "mac_updater_zip"]) { + const provenanceSource = join(macSource, "candidate-evidence", `${role}-provenance.json`); + const provenanceValue = JSON.parse(readFileSync(provenanceSource, "utf8")); + const item = artifacts[role]; + if (provenanceValue?.schema !== 1 || provenanceValue?.kind !== "1helm-artifact-provenance" + || provenanceValue?.repository !== STABLE_REPOSITORY || provenanceValue?.ref !== "refs/heads/main" + || provenanceValue?.commit !== commit || provenanceValue?.version !== version + || String(provenanceValue?.candidate_workflow_run_id) !== workflowRunId || String(provenanceValue?.source_ci_run_id) !== ciRunId + || provenanceValue?.signer_workflow !== `${STABLE_REPOSITORY}/.github/workflows/candidate.yml` + || provenanceValue?.signing !== "developer-id" || provenanceValue?.notarization !== "accepted" + || provenanceValue?.stapling !== "validated" || provenanceValue?.gatekeeper !== "accepted" + || provenanceValue?.artifact?.role !== role || provenanceValue?.artifact?.name !== item.name + || provenanceValue?.artifact?.sha256 !== item.sha256 || Number(provenanceValue?.artifact?.bytes) !== item.bytes) { + throw new Error(`${role} provenance does not bind the exact signed Mac candidate`); + } + artifacts[role].provenance = record(provenanceSource, `${role}-provenance.json`); +} + +const acceptance = {}; +for (const platform of ["macos", "linux", "windows"]) { + const value = JSON.parse(readFileSync(acceptancePaths[platform], "utf8")); + const blockers = platformEvidenceBlockers(value, { platform, commit, version, runId: workflowRunId, + runAttempt: String(mac.candidate.run_attempt), ciRunId, artifacts }); + if (platform === "macos" && value?.runner?.name !== mac?.builder?.runner_name) { + blockers.push("acceptance runner does not match the dedicated Mac builder"); + } + if (blockers.length) throw new Error(`${platform} acceptance cannot enter the promotion bundle: ${blockers.join("; ")}`); + acceptance[platform] = record(acceptancePaths[platform], `${platform}-acceptance.json`); +} + 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, + candidate_manifest: record(candidateSource, "candidate.json"), + mac_candidate_manifest: record(macManifestPath, "mac-candidate.json"), + dress_rehearsal: record(rehearsalPath, "dress-rehearsal.json"), + acceptance, + package: record(join(project, "package.json"), "package.json"), + changelog: record(join(project, "CHANGELOG.md"), "authored-changelog.md"), + acceptance_content: record(acceptanceContentPath, "acceptance.md"), }, acceptance_ledger_required: true, - artifacts: [{ - role: "linux_tgz", name: basename(archive.path), path: archive.path, - sha256: candidate.artifact.sha256, bytes: candidate.artifact.bytes, provenance, - }], + artifacts: [artifacts.mac_dmg, artifacts.mac_updater_zip, artifacts.linux_tgz], }; writeFileSync(join(output, "promotion.json"), `${JSON.stringify(promotion, null, 2)}\n`, { mode: 0o600 }); diff --git a/scripts/linux-acceptance-evidence.mjs b/scripts/linux-acceptance-evidence.mjs new file mode 100644 index 0000000..fb28f76 --- /dev/null +++ b/scripts/linux-acceptance-evidence.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { isoNow, normalizePlatformEvidence } from "./platform-acceptance-lib.mjs"; + +const env = process.env; +const candidate = JSON.parse(readFileSync(resolve(env.HELM_CANDIDATE_MANIFEST || ""), "utf8")); +const checkedAt = isoNow(); +const startedAt = String(env.HELM_ACCEPTANCE_STARTED_AT || ""); +const summary = (text) => ({ result: "passed", checked_at: checkedAt, summary: text }); +const evidence = normalizePlatformEvidence({ + platform: "linux", + commit: candidate.source.commit, + version: candidate.version, + candidate: { run_id: env.GITHUB_RUN_ID, run_attempt: env.GITHUB_RUN_ATTEMPT }, + source_ci: candidate.ci, + started_at: startedAt, + checked_at: checkedAt, + machine: { + id: `${env.RUNNER_NAME}-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, + kind: "github-hosted-ephemeral", + os: env.RUNNER_OS, + os_version: env.HELM_MACHINE_OS_VERSION, + architecture: env.RUNNER_ARCH, + dedicated: true, + production_data: false, + }, + runner: { name: env.RUNNER_NAME, labels: ["ubuntu-latest"], job: env.GITHUB_JOB }, + artifacts: [{ + role: "linux_tgz", name: basename(env.HELM_CANDIDATE_ARCHIVE), + sha256: candidate.artifact.sha256, bytes: candidate.artifact.bytes, + }], + checks: [ + { id: "digest", ...summary("Archive, manifest, embedded identity, and hosted provenance matched the exact candidate.") }, + { id: "clean_install", ...summary("Exact candidate installed on a fresh hosted Linux VM and passed loopback health.") }, + { id: "prior_version_update", ...summary(`Public Stable v${env.HELM_PREVIOUS_VERSION} updated to the exact candidate without rebuilding.`) }, + { id: "health_failure_rollback", ...summary("A controlled derived startup failure restored the exact prior healthy candidate.") }, + { id: "retained_state", ...summary("The dedicated state marker retained the same SHA-256 across update and forced rollback.") }, + { id: "systemd_health", ...summary("1helm.service and loopback setup health were active after clean install, update, and rollback.") }, + ], + state_preservation: { + ...summary("Dedicated acceptance state marker retained byte identity."), + before_sha256: env.HELM_STATE_BEFORE_SHA256, + after_sha256: env.HELM_STATE_AFTER_SHA256, + }, + recovery: { + ...summary("Forced failure rollback restored the candidate symlink, service, loopback health, and state marker."), + before_sha256: env.HELM_STATE_BEFORE_SHA256, + after_sha256: env.HELM_STATE_AFTER_SHA256, + }, + notes: ["This is real ephemeral Linux systemd acceptance, not a fixture or container-only simulation."], +}); +writeFileSync(resolve(env.HELM_ACCEPTANCE_OUTPUT || "linux-acceptance.json"), `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 }); diff --git a/scripts/mac-candidate-manifest.mjs b/scripts/mac-candidate-manifest.mjs new file mode 100644 index 0000000..c1e4d8c --- /dev/null +++ b/scripts/mac-candidate-manifest.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { artifactRecord, CANDIDATE_WORKFLOW, isoNow, REF, REPOSITORY, sha256Bytes } from "./platform-acceptance-lib.mjs"; + +const env = process.env; +const output = resolve(env.HELM_MAC_MANIFEST || ""); +const dmgPath = resolve(env.HELM_MAC_DMG || ""); +const zipPath = resolve(env.HELM_MAC_ZIP || ""); +const commit = String(env.HELM_CANDIDATE_COMMIT || ""); +const version = String(env.HELM_CANDIDATE_VERSION || ""); +const ciRunId = String(env.HELM_CANDIDATE_CI_RUN_ID || ""); +const candidateRunId = String(env.GITHUB_RUN_ID || ""); +const candidateRunAttempt = String(env.GITHUB_RUN_ATTEMPT || ""); +if (!env.HELM_MAC_MANIFEST || !env.HELM_MAC_DMG || !env.HELM_MAC_ZIP + || !/^[a-f0-9]{40}$/.test(commit) || !/^\d+\.\d+\.\d+$/.test(version) + || !/^\d+$/.test(ciRunId) || !/^\d+$/.test(candidateRunId) || !/^\d+$/.test(candidateRunAttempt)) { + throw new Error("Exact Mac candidate paths and trusted candidate identity are required"); +} +const artifacts = [artifactRecord("mac_dmg", dmgPath), artifactRecord("mac_updater_zip", zipPath)]; +const checkedAt = isoNow(); +const manifest = { + schema: 1, + kind: "1helm-macos-candidate", + repository: REPOSITORY, + ref: REF, + commit, + version, + candidate: { + workflow: CANDIDATE_WORKFLOW.name, + workflow_path: CANDIDATE_WORKFLOW.path, + event: CANDIDATE_WORKFLOW.event, + run_id: candidateRunId, + run_attempt: candidateRunAttempt, + }, + source_ci: { workflow: "CI", run_id: ciRunId, conclusion: "success" }, + builder: { + type: "dedicated-self-hosted", + runner_name: String(env.RUNNER_NAME || ""), + runner_label: "1helm-macos-phase4", + os: String(env.RUNNER_OS || "macOS"), + architecture: String(env.RUNNER_ARCH || "ARM64"), + }, + signing: { + identity: "developer-id-application", + notarization: "accepted", + stapling: "validated", + gatekeeper: "accepted", + checked_at: checkedAt, + }, + artifacts, +}; +for (const field of [manifest.builder.runner_name, manifest.builder.os, manifest.builder.architecture]) { + if (!/^[A-Za-z0-9][A-Za-z0-9 ._:/@+()#-]{0,255}$/.test(field)) throw new Error("Mac builder identity is missing or unsafe"); +} +writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); +for (const artifact of artifacts) { + const provenance = { + schema: 1, + kind: "1helm-artifact-provenance", + repository: REPOSITORY, + ref: REF, + commit, + version, + builder: "dedicated-macos", + signer_workflow: `${REPOSITORY}/${CANDIDATE_WORKFLOW.path}`, + candidate_workflow_run_id: candidateRunId, + source_ci_run_id: ciRunId, + signing: "developer-id", + notarization: "accepted", + stapling: "validated", + gatekeeper: "accepted", + checked_at: checkedAt, + artifact, + }; + const bytes = Buffer.from(`${JSON.stringify(provenance, null, 2)}\n`); + const provenancePath = resolve(output, `../${artifact.role}-provenance.json`); + writeFileSync(provenancePath, bytes, { mode: 0o600 }); + process.stdout.write(`${artifact.role} ${artifact.sha256} provenance ${sha256Bytes(bytes)}\n`); +} diff --git a/scripts/macos-acceptance-evidence.mjs b/scripts/macos-acceptance-evidence.mjs new file mode 100644 index 0000000..55664a2 --- /dev/null +++ b/scripts/macos-acceptance-evidence.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { isoNow, normalizePlatformEvidence } from "./platform-acceptance-lib.mjs"; + +const env = process.env; +const source = resolve(env.HELM_MAC_CANDIDATE_DOWNLOAD || ""); +const manifest = JSON.parse(readFileSync(join(source, "candidate-evidence", "mac-candidate.json"), "utf8")); +const checkedAt = isoNow(); +const pass = (summary) => ({ result: "passed", checked_at: checkedAt, summary }); +const evidence = normalizePlatformEvidence({ + platform: "macos", commit: manifest.commit, version: manifest.version, + candidate: { run_id: env.GITHUB_RUN_ID, run_attempt: env.GITHUB_RUN_ATTEMPT }, + source_ci: manifest.source_ci, started_at: env.HELM_ACCEPTANCE_STARTED_AT, checked_at: checkedAt, + machine: { + id: `${env.RUNNER_NAME}-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, + kind: "dedicated-apple-silicon", os: env.RUNNER_OS, os_version: env.HELM_MACHINE_OS_VERSION, + architecture: env.RUNNER_ARCH, dedicated: true, production_data: false, + }, + runner: { name: env.RUNNER_NAME, labels: ["1helm-macos-phase4"], job: env.GITHUB_JOB }, + artifacts: manifest.artifacts, + checks: [ + { id: "signature", ...pass("Strict deep Developer ID verification passed on the mounted DMG app and updater ZIP app.") }, + { id: "notarization", ...pass("Apple notary acceptance declared by the build manifest and verified through Gatekeeper assessment.") }, + { id: "staple", ...pass("Stapler validated the exact DMG and both exact app payloads.") }, + { id: "gatekeeper", ...pass("Gatekeeper accepted the DMG for open and both app payloads for execution.") }, + { id: "clean_install", ...pass("Exact DMG app copied into an isolated Applications directory and launched on the dedicated account.") }, + { id: "prior_version_update", ...pass(`Exact updater ZIP replaced signed prior Stable v${env.HELM_PREVIOUS_VERSION} without touching isolated Application Support.`) }, + { id: "retained_state", ...pass("Application Support state marker retained identical SHA-256 across updater replacement.") }, + { id: "loopback", ...pass("The launched DMG app and updater ZIP app both returned setup health on loopback.") }, + { id: "version", ...pass("DMG and updater ZIP bundle versions matched the candidate package version.") }, + ], + state_preservation: { ...pass("Application Support state marker retained byte identity."), before_sha256: env.HELM_STATE_BEFORE_SHA256, after_sha256: env.HELM_STATE_AFTER_SHA256 }, + recovery: { ...pass("Both isolated launches quit cleanly and transient app copies were removed."), before_sha256: env.HELM_STATE_BEFORE_SHA256, after_sha256: env.HELM_STATE_AFTER_SHA256 }, + notes: ["This is real Apple Silicon signature, ticket, Gatekeeper, launch, loopback, and state evidence."], +}); +writeFileSync(resolve(env.HELM_ACCEPTANCE_OUTPUT || "macos-acceptance.json"), `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 }); diff --git a/scripts/pending-acceptance-evidence.mjs b/scripts/pending-acceptance-evidence.mjs new file mode 100644 index 0000000..bf4aee9 --- /dev/null +++ b/scripts/pending-acceptance-evidence.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { isoNow, normalizePlatformEvidence, PLATFORM_ARTIFACT_ROLES, PLATFORM_CHECKS } from "./platform-acceptance-lib.mjs"; + +const env = process.env; +const platform = String(env.HELM_ACCEPTANCE_PLATFORM || ""); +if (!PLATFORM_CHECKS[platform] || !env.HELM_ACCEPTANCE_OUTPUT || !env.HELM_ACCEPTANCE_STARTED_AT) { + throw new Error("Pending evidence requires a platform, output path, and start timestamp"); +} + +let source; +let artifacts; +if (platform === "macos") { + const root = resolve(env.HELM_MAC_CANDIDATE_DOWNLOAD || ""); + source = JSON.parse(readFileSync(join(root, "candidate-evidence", "mac-candidate.json"), "utf8")); + artifacts = source.artifacts; +} else { + source = JSON.parse(readFileSync(resolve(env.HELM_CANDIDATE_MANIFEST || ""), "utf8")); + artifacts = [{ + role: "linux_tgz", + name: basename(env.HELM_CANDIDATE_ARCHIVE || source.artifact.name), + sha256: source.artifact.sha256, + bytes: source.artifact.bytes, + }]; +} +const commit = platform === "macos" ? source.commit : source.source.commit; +const version = source.version; +const sourceCi = platform === "macos" ? source.source_ci : source.ci; +const checkedAt = isoNow(); +const pending = (summary) => ({ result: "blocked", checked_at: checkedAt, summary }); +const evidence = normalizePlatformEvidence({ + platform, + commit, + version, + candidate: { run_id: env.GITHUB_RUN_ID, run_attempt: env.GITHUB_RUN_ATTEMPT }, + source_ci: sourceCi, + started_at: env.HELM_ACCEPTANCE_STARTED_AT, + checked_at: checkedAt, + machine: { + id: `${env.RUNNER_NAME}-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, + kind: platform === "linux" ? "github-hosted-ephemeral" : `dedicated-${platform}-phase4`, + os: env.RUNNER_OS, + os_version: String(env.HELM_MACHINE_OS_VERSION || "preflight-unknown"), + architecture: env.RUNNER_ARCH, + dedicated: platform !== "windows", + production_data: platform === "windows", + }, + runner: { name: env.RUNNER_NAME, labels: [env.HELM_PHASE4_RUNNER_LABEL], job: env.GITHUB_JOB }, + ...(platform === "windows" ? { continuity: { + mode: "snapshot-assisted-equivalent", + result: "blocked", + checked_at: checkedAt, + summary: "Continuity mode selected; the exact-candidate exercise has not completed.", + } } : {}), + artifacts: PLATFORM_ARTIFACT_ROLES[platform].map((role) => artifacts.find((item) => item.role === role)), + checks: PLATFORM_CHECKS[platform].map((id) => ({ id, ...pending("Required acceptance check has not completed.") })), + state_preservation: { ...pending("State-preservation proof has not completed."), before_sha256: null, after_sha256: null }, + recovery: { ...pending(platform === "windows" ? "Scoped uninstall proof has not completed." : "Rollback or recovery proof has not completed."), before_sha256: null, after_sha256: null }, + notes: [platform === "windows" + ? "Preflight blocker retained before administrator-owned isolation and provisioning evidence is accepted." + : "Preflight blocker retained so an interrupted acceptance run cannot disappear or become a pass."], +}); +writeFileSync(resolve(env.HELM_ACCEPTANCE_OUTPUT), `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 }); diff --git a/scripts/platform-acceptance-evidence.mjs b/scripts/platform-acceptance-evidence.mjs new file mode 100644 index 0000000..f3b6bf4 --- /dev/null +++ b/scripts/platform-acceptance-evidence.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { normalizePlatformEvidence, sha256File } from "./platform-acceptance-lib.mjs"; + +const inputPath = resolve(process.env.HELM_ACCEPTANCE_INPUT || ""); +const outputPath = resolve(process.env.HELM_ACCEPTANCE_OUTPUT || ""); +if (!process.env.HELM_ACCEPTANCE_INPUT || !process.env.HELM_ACCEPTANCE_OUTPUT) { + throw new Error("HELM_ACCEPTANCE_INPUT and HELM_ACCEPTANCE_OUTPUT are required"); +} +const input = JSON.parse(readFileSync(inputPath, "utf8")); +const evidence = normalizePlatformEvidence(input); +writeFileSync(outputPath, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 }); +process.stdout.write(`${evidence.platform} ${evidence.result}: ${sha256File(outputPath)}\n`); +if (evidence.result !== "passed") process.exitCode = 1; diff --git a/scripts/platform-acceptance-lib.mjs b/scripts/platform-acceptance-lib.mjs new file mode 100644 index 0000000..f2a1c95 --- /dev/null +++ b/scripts/platform-acceptance-lib.mjs @@ -0,0 +1,250 @@ +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { basename } from "node:path"; + +export const ACCEPTANCE_KIND = "1helm-platform-acceptance"; +export const ACCEPTANCE_SCHEMA = 1; +export const CANDIDATE_WORKFLOW = Object.freeze({ + name: "Candidate dress rehearsal", + path: ".github/workflows/candidate.yml", + event: "workflow_run", +}); +export const REPOSITORY = "gitcommit90/1Helm"; +export const REF = "refs/heads/main"; + +export const PLATFORM_CHECKS = Object.freeze({ + macos: Object.freeze([ + "signature", "notarization", "staple", "gatekeeper", "clean_install", + "prior_version_update", "retained_state", "loopback", "version", + ]), + linux: Object.freeze([ + "digest", "clean_install", "prior_version_update", "health_failure_rollback", + "retained_state", "systemd_health", + ]), + windows: Object.freeze([ + "non_elevated_install", "single_uac", "restart_resume", "keepalive_reboot", + "onboarding", "prior_version_update", "retained_state", "uninstall_safety", + ]), +}); + +export const PLATFORM_ARTIFACT_ROLES = Object.freeze({ + macos: Object.freeze(["mac_dmg", "mac_updater_zip"]), + linux: Object.freeze(["linux_tgz"]), + windows: Object.freeze(["linux_tgz"]), +}); + +const HEX40 = /^[a-f0-9]{40}$/; +const HEX64 = /^[a-f0-9]{64}$/; +const ID = /^\d+$/; +const VERSION = /^\d+\.\d+\.\d+$/; +const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; +const SAFE_TEXT = /^[A-Za-z0-9][A-Za-z0-9 .,;_'":/\[\]@+()#-]{0,511}$/; + +export const sha256Bytes = (bytes) => createHash("sha256").update(bytes).digest("hex"); +export const sha256File = (path) => sha256Bytes(readFileSync(path)); +export const isoNow = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + +function safeText(value, label, { allowEmpty = false } = {}) { + const text = String(value ?? "").trim(); + if ((!text && allowEmpty) || SAFE_TEXT.test(text)) return text; + throw new Error(`${label} is missing or contains unsafe/unbounded text`); +} + +function exact(value, pattern, label) { + const text = String(value ?? ""); + if (!pattern.test(text)) throw new Error(`${label} is invalid`); + return text; +} + +function uniqueChecks(platform, checks) { + const required = PLATFORM_CHECKS[platform]; + if (!Array.isArray(checks)) throw new Error(`${platform} checks must be an array`); + const map = new Map(); + for (const item of checks) { + const id = String(item?.id || ""); + if (!required.includes(id) || map.has(id)) throw new Error(`${platform} check ${id || ""} is unknown or duplicated`); + const result = String(item?.result || ""); + if (!['passed', 'failed', 'blocked'].includes(result)) throw new Error(`${platform} check ${id} result is invalid`); + map.set(id, { + id, + result, + checked_at: exact(item?.checked_at, ISO_TIME, `${platform} check ${id} timestamp`), + summary: safeText(item?.summary, `${platform} check ${id} summary`), + }); + } + return required.map((id) => map.get(id) || { + id, result: "blocked", checked_at: isoNow(), summary: "Required evidence was not produced.", + }); +} + +function normalizeOutcome(value, label, allowed) { + if (!value || typeof value !== "object") throw new Error(`${label} outcome is missing`); + const result = String(value.result || ""); + if (!allowed.includes(result)) throw new Error(`${label} outcome is invalid`); + return { + result, + checked_at: exact(value.checked_at, ISO_TIME, `${label} timestamp`), + summary: safeText(value.summary, `${label} summary`), + before_sha256: value.before_sha256 == null ? null : exact(value.before_sha256, HEX64, `${label} before digest`), + after_sha256: value.after_sha256 == null ? null : exact(value.after_sha256, HEX64, `${label} after digest`), + }; +} + +function normalizeMachine(value, platform) { + if (!value || typeof value !== "object") throw new Error(`${platform} machine identity is missing`); + return { + id: safeText(value.id, `${platform} machine ID`), + kind: safeText(value.kind, `${platform} machine kind`), + os: safeText(value.os, `${platform} machine OS`), + os_version: safeText(value.os_version, `${platform} machine OS version`), + architecture: safeText(value.architecture, `${platform} architecture`), + dedicated: value.dedicated === true, + production_data: value.production_data === false ? false : true, + }; +} + +function normalizeRunner(value, platform) { + if (!value || typeof value !== "object") throw new Error(`${platform} runner identity is missing`); + const labels = Array.isArray(value.labels) ? value.labels.map((item) => safeText(item, `${platform} runner label`)) : []; + if (labels.length !== 1) throw new Error(`${platform} runner evidence must name its one unique no-default label`); + return { + name: safeText(value.name, `${platform} runner name`), + labels, + job: safeText(value.job, `${platform} runner job`), + }; +} + +function normalizeContinuity(value, platform) { + if (platform !== "windows") return null; + if (!value || typeof value !== "object") throw new Error("windows reboot/resume continuity evidence is missing"); + if (!['real-reboot', 'snapshot-assisted-equivalent'].includes(value.mode)) { + throw new Error("windows reboot/resume continuity mode is invalid"); + } + return { + mode: value.mode, + result: ['passed', 'failed', 'blocked'].includes(value.result) + ? value.result : (() => { throw new Error("windows reboot/resume continuity result is invalid"); })(), + checked_at: exact(value.checked_at, ISO_TIME, "windows reboot/resume continuity timestamp"), + summary: safeText(value.summary, "windows reboot/resume continuity summary"), + }; +} + +export function artifactRecord(role, path) { + const bytes = statSync(path).size; + if (!bytes) throw new Error(`${role} artifact is empty`); + return { role, name: basename(path), sha256: sha256File(path), bytes }; +} + +/** + * Convert runner observations into the one promotion schema. A runner may + * honestly emit failed/blocked evidence, but only a complete set of passed + * checks can produce result=passed. + */ +export function normalizePlatformEvidence(input) { + const platform = String(input?.platform || ""); + if (!PLATFORM_CHECKS[platform]) throw new Error("platform is invalid"); + const checks = uniqueChecks(platform, input.checks); + const artifacts = Array.isArray(input.artifacts) ? input.artifacts.map((item) => ({ + role: String(item?.role || ""), + name: safeText(item?.name, `${platform} artifact name`), + sha256: exact(item?.sha256, HEX64, `${platform} artifact digest`), + bytes: Number(item?.bytes), + })) : []; + const roles = PLATFORM_ARTIFACT_ROLES[platform]; + if (artifacts.length !== roles.length || roles.some((role) => artifacts.filter((item) => item.role === role).length !== 1)) { + throw new Error(`${platform} artifact binding is incomplete or duplicated`); + } + if (artifacts.some((item) => !Number.isSafeInteger(item.bytes) || item.bytes <= 0)) throw new Error(`${platform} artifact byte count is invalid`); + const candidate = input?.candidate || {}; + const sourceCi = input?.source_ci || {}; + const machine = normalizeMachine(input.machine, platform); + const runner = normalizeRunner(input.runner, platform); + const continuity = normalizeContinuity(input.continuity, platform); + const statePreservation = normalizeOutcome(input.state_preservation, `${platform} state preservation`, ["passed", "failed", "blocked"]); + const recovery = normalizeOutcome(input.recovery, `${platform} recovery`, ["passed", "failed", "blocked", "not_applicable"]); + const outcomes = [...checks.map((item) => item.result), statePreservation.result, recovery.result, + ...(continuity ? [continuity.result] : [])]; + const derived = outcomes.every((result) => result === "passed") ? "passed" + : outcomes.some((result) => result === "failed") ? "failed" : "blocked"; + if (input.result && input.result !== derived) throw new Error(`${platform} aggregate result does not match its required outcomes`); + if (derived === "passed" && (!machine.dedicated || machine.production_data)) { + throw new Error(`${platform} passing evidence did not come from a dedicated production-data-free machine`); + } + return { + schema: ACCEPTANCE_SCHEMA, + kind: ACCEPTANCE_KIND, + platform, + result: derived, + repository: REPOSITORY, + ref: REF, + commit: exact(input.commit, HEX40, `${platform} commit`), + version: exact(input.version, VERSION, `${platform} version`), + candidate: { + workflow: CANDIDATE_WORKFLOW.name, + workflow_path: CANDIDATE_WORKFLOW.path, + event: CANDIDATE_WORKFLOW.event, + run_id: exact(candidate.run_id, ID, `${platform} candidate run ID`), + run_attempt: exact(candidate.run_attempt, ID, `${platform} candidate run attempt`), + }, + source_ci: { + workflow: "CI", + run_id: exact(sourceCi.run_id, ID, `${platform} CI run ID`), + conclusion: sourceCi.conclusion === "success" ? "success" : (() => { throw new Error(`${platform} CI conclusion is not success`); })(), + }, + started_at: exact(input.started_at, ISO_TIME, `${platform} start timestamp`), + checked_at: exact(input.checked_at, ISO_TIME, `${platform} completion timestamp`), + machine, + runner, + ...(continuity ? { continuity } : {}), + artifacts, + checks, + state_preservation: statePreservation, + recovery, + notes: Array.isArray(input.notes) ? input.notes.map((item) => safeText(item, `${platform} evidence note`)) : [], + }; +} + +export function platformEvidenceBlockers(value, expected) { + const blockers = []; + const add = (condition, message) => { if (!condition) blockers.push(message); }; + const platform = expected.platform; + add(value?.schema === ACCEPTANCE_SCHEMA && value?.kind === ACCEPTANCE_KIND && value?.platform === platform, "schema, kind, or platform mismatch"); + add(value?.repository === REPOSITORY && value?.ref === REF && value?.commit === expected.commit && value?.version === expected.version, "source identity mismatch"); + add(value?.candidate?.workflow === CANDIDATE_WORKFLOW.name && value?.candidate?.workflow_path === CANDIDATE_WORKFLOW.path + && value?.candidate?.event === CANDIDATE_WORKFLOW.event && String(value?.candidate?.run_id) === String(expected.runId) + && String(value?.candidate?.run_attempt) === String(expected.runAttempt), "candidate workflow identity mismatch"); + add(value?.source_ci?.workflow === "CI" && String(value?.source_ci?.run_id) === String(expected.ciRunId) + && value?.source_ci?.conclusion === "success", "successful CI identity mismatch"); + add(value?.result === "passed" && ISO_TIME.test(String(value?.started_at || "")) && ISO_TIME.test(String(value?.checked_at || "")), "retained result is not a timestamped pass"); + const machineKinds = { linux: "github-hosted-ephemeral", macos: "dedicated-apple-silicon", windows: "dedicated-windows-11-vm" }; + const operatingSystems = { linux: "Linux", macos: "macOS", windows: "Windows" }; + const architectures = { linux: "X64", macos: "ARM64", windows: "X64" }; + const labels = { linux: "ubuntu-latest", macos: "1helm-macos-phase4", windows: "1helm-windows-phase4" }; + add(value?.machine?.dedicated === true && value?.machine?.production_data === false && SAFE_TEXT.test(String(value?.machine?.id || "")) + && value?.machine?.kind === machineKinds[platform] && value?.machine?.os === operatingSystems[platform] + && value?.machine?.architecture === architectures[platform], "dedicated machine identity or platform architecture is missing"); + add(Array.isArray(value?.runner?.labels) && value.runner.labels.length === 1 && value.runner.labels[0] === labels[platform] + && value?.runner?.job === `accept-${platform}`, "unique no-default runner label or job identity is missing"); + if (platform === "windows") { + add(['real-reboot', 'snapshot-assisted-equivalent'].includes(value?.continuity?.mode) + && value?.continuity?.result === "passed" && ISO_TIME.test(String(value?.continuity?.checked_at || "")) + && SAFE_TEXT.test(String(value?.continuity?.summary || "")), "honest reboot/resume or snapshot-assisted equivalent evidence is missing"); + } + const map = new Map((Array.isArray(value?.checks) ? value.checks : []).map((item) => [item?.id, item])); + for (const check of PLATFORM_CHECKS[platform]) { + const item = map.get(check); + add(item?.result === "passed" && ISO_TIME.test(String(item?.checked_at || "")) && SAFE_TEXT.test(String(item?.summary || "")), `${check} evidence is missing or did not pass`); + } + add(value?.state_preservation?.result === "passed" && HEX64.test(String(value?.state_preservation?.before_sha256 || "")) + && value?.state_preservation?.before_sha256 === value?.state_preservation?.after_sha256, "state preservation identity did not pass"); + add(value?.recovery?.result === "passed", `${platform === "windows" ? "scoped uninstall" : "rollback/recovery"} outcome did not pass`); + const records = Array.isArray(value?.artifacts) ? value.artifacts : []; + for (const role of PLATFORM_ARTIFACT_ROLES[platform]) { + const actual = records.filter((item) => item?.role === role); + const wanted = expected.artifacts[role]; + add(actual.length === 1 && actual[0]?.name === wanted?.name && actual[0]?.sha256 === wanted?.sha256 + && Number(actual[0]?.bytes) === Number(wanted?.bytes), `${role} does not match exact candidate bytes`); + } + add(records.length === PLATFORM_ARTIFACT_ROLES[platform].length, "artifact evidence contains unexpected records"); + return blockers; +} diff --git a/scripts/promotion-lib.mjs b/scripts/promotion-lib.mjs index 1968145..0c8f8c2 100644 --- a/scripts/promotion-lib.mjs +++ b/scripts/promotion-lib.mjs @@ -1,6 +1,7 @@ import { lstatSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { basename, join, relative, resolve, sep } from "node:path"; import { candidateIdentityFromArchive } from "./candidate-manifest.mjs"; +import { PLATFORM_CHECKS, platformEvidenceBlockers } from "./platform-acceptance-lib.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"; @@ -10,12 +11,7 @@ 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 PLATFORMS = PLATFORM_CHECKS; const digestFile = sha256File; const add = (blockers, condition, message) => { if (!condition) blockers.push(message); }; @@ -87,11 +83,37 @@ function validateCandidateManifest(value, expected, blockers) { 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 validateMacCandidateManifest(value, expected, artifacts, blockers) { + add(blockers, value?.schema === 1 && value?.kind === "1helm-macos-candidate", "Mac candidate manifest: schema or kind mismatch"); + add(blockers, value?.repository === STABLE_REPOSITORY && value?.ref === "refs/heads/main" + && value?.commit === expected.commit && value?.version === expected.version, "Mac candidate manifest: source identity mismatch"); + add(blockers, value?.candidate?.workflow === "Candidate dress rehearsal" + && value?.candidate?.workflow_path === ".github/workflows/candidate.yml" && value?.candidate?.event === "workflow_run" + && String(value?.candidate?.run_id) === expected.runId && String(value?.candidate?.run_attempt) === expected.runAttempt, + "Mac candidate manifest: candidate run identity mismatch"); + add(blockers, value?.source_ci?.workflow === "CI" && String(value?.source_ci?.run_id) === String(expected.ciRunId) + && value?.source_ci?.conclusion === "success", "Mac candidate manifest: CI identity mismatch"); + add(blockers, value?.builder?.type === "dedicated-self-hosted" && value?.builder?.runner_label === "1helm-macos-phase4" + && value?.builder?.os === "macOS" && value?.builder?.architecture === "ARM64" + && /^[A-Za-z0-9][A-Za-z0-9 ._:/@+()#-]{0,255}$/.test(String(value?.builder?.runner_name || "")), + "Mac candidate manifest: dedicated builder identity mismatch"); + add(blockers, value?.signing?.identity === "developer-id-application" && value?.signing?.notarization === "accepted" + && value?.signing?.stapling === "validated" && value?.signing?.gatekeeper === "accepted", "Mac candidate manifest: signing proof is incomplete"); + const records = Array.isArray(value?.artifacts) ? value.artifacts : []; + for (const role of ["mac_dmg", "mac_updater_zip"]) { + const item = records.filter((record) => record?.role === role); + add(blockers, item.length === 1 && item[0]?.name === artifacts[role]?.name && item[0]?.sha256 === artifacts[role]?.sha256 + && Number(item[0]?.bytes) === Number(artifacts[role]?.bytes), `Mac candidate manifest: ${role} bytes mismatch`); + } + add(blockers, records.length === 2, "Mac candidate manifest: artifact records are incomplete or unexpected"); +} + 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"); + add(blockers, ID.test(String(value?.run_attempt || "")), "candidate workflow: run attempt identity is missing"); } function validateCi(value, expected, blockers) { @@ -109,17 +131,7 @@ function validateArtifactRecord(value, expected, blockers) { 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`); - } + for (const message of platformEvidenceBlockers(value, { ...expected, platform, artifacts })) blockers.push(`${label}: ${message}`); } function releaseNotes(version, commit, promotion, artifacts, changelog, acceptance) { @@ -155,6 +167,7 @@ export function validatePromotionBundle(options) { const records = promotion?.records || {}; const candidateManifest = checkedRecord(bundle, records.candidate_manifest, blockers, "candidate manifest"); + const macCandidateManifest = checkedRecord(bundle, records.mac_candidate_manifest, blockers, "Mac 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"); @@ -163,7 +176,10 @@ export function validatePromotionBundle(options) { expected.ciRunId = String(candidateManifest.value?.ci?.run_id || ""); validateCandidateManifest(candidateManifest.value, expected, blockers); } - if (runRecord) validateRun(runRecord.value, expected, blockers); + if (runRecord) { + expected.runAttempt = String(runRecord.value?.run_attempt || ""); + validateRun(runRecord.value, expected, blockers); + } if (ciRecord) validateCi(ciRecord.value, expected, blockers); if (artifactRecord) validateArtifactRecord(artifactRecord.value, expected, blockers); @@ -183,15 +199,24 @@ export function validatePromotionBundle(options) { 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`); + add(blockers, provenance.value?.version === expected.version && String(provenance.value?.candidate_workflow_run_id) === expected.runId + && String(provenance.value?.source_ci_run_id) === String(expected.ciRunId), `${role} provenance: version or candidate/CI run identity mismatch`); + add(blockers, provenance.value?.artifact?.role === role && provenance.value?.artifact?.name === spec?.name + && provenance.value?.artifact?.sha256 === spec?.sha256 && Number(provenance.value?.artifact?.bytes) === Number(spec?.bytes), `${role} provenance: artifact digest or byte count 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`); + if (role !== "linux_tgz") { + add(blockers, provenance.value?.builder === "dedicated-macos" + && provenance.value?.signer_workflow === "gitcommit90/1Helm/.github/workflows/candidate.yml", `${role} provenance: dedicated Mac builder/workflow identity is missing`); + add(blockers, provenance.value?.signing === "developer-id" && provenance.value?.notarization === "accepted" + && provenance.value?.stapling === "validated" && provenance.value?.gatekeeper === "accepted", `${role} provenance: signing, notarization, stapling, or Gatekeeper evidence is missing`); + } } } add(blockers, (Array.isArray(promotion?.artifacts) ? promotion.artifacts : []).length === 3, "desktop artifact matrix must contain exactly three artifacts"); + if (macCandidateManifest) validateMacCandidateManifest(macCandidateManifest.value, expected, artifacts, blockers); 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"); @@ -210,7 +235,13 @@ export function validatePromotionBundle(options) { 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); + if (record) { + validatePlatformEvidence(platform, record.value, expected, artifacts, blockers); + if (platform === "macos" && macCandidateManifest) { + add(blockers, record.value?.runner?.name === macCandidateManifest.value?.builder?.runner_name, + "macos acceptance: runner does not match the dedicated Mac builder"); + } + } } const packageRecord = checkedRecord(bundle, records.package, blockers, "package version record"); diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 6e88a48..9c59c9d 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/phase3-promotion.mjs", "test/site-stable-manifest.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/phase4-platform-acceptance.mjs", "test/site-stable-manifest.mjs"], ]; let status = 0; diff --git a/scripts/windows-acceptance-evidence.mjs b/scripts/windows-acceptance-evidence.mjs new file mode 100644 index 0000000..5841844 --- /dev/null +++ b/scripts/windows-acceptance-evidence.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { isoNow, normalizePlatformEvidence } from "./platform-acceptance-lib.mjs"; + +const env = process.env; +const candidate = JSON.parse(readFileSync(resolve(env.HELM_CANDIDATE_MANIFEST || ""), "utf8")); +const provision = JSON.parse(readFileSync(resolve(env.HELM_PROVISIONING_EVIDENCE || ""), "utf8")); +const checkedAt = isoNow(); +const pass = (summary) => ({ result: "passed", checked_at: checkedAt, summary }); +const evidence = normalizePlatformEvidence({ + platform: "windows", commit: candidate.source.commit, version: candidate.version, + candidate: { run_id: env.GITHUB_RUN_ID, run_attempt: env.GITHUB_RUN_ATTEMPT }, source_ci: candidate.ci, + started_at: env.HELM_ACCEPTANCE_STARTED_AT, checked_at: checkedAt, + machine: { + id: `${env.RUNNER_NAME}-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, + kind: "dedicated-windows-11-vm", os: env.RUNNER_OS, os_version: env.HELM_MACHINE_OS_VERSION, + architecture: env.RUNNER_ARCH, dedicated: provision.dedicated, production_data: provision.production_data, + }, + runner: { name: env.RUNNER_NAME, labels: ["1helm-windows-phase4"], job: env.GITHUB_JOB }, + continuity: { + mode: "snapshot-assisted-equivalent", result: "passed", checked_at: checkedAt, + summary: "Accepted clean VM snapshot plus exact-candidate WSL cold start proved same-user keepalive, service, and localhost recovery.", + }, + artifacts: [{ role: "linux_tgz", name: basename(env.HELM_CANDIDATE_ARCHIVE), sha256: candidate.artifact.sha256, bytes: candidate.artifact.bytes }], + checks: [ + { id: "non_elevated_install", ...pass("Exact candidate clean-installed through the tracked site path as the dedicated ordinary signed-in user.") }, + { id: "single_uac", ...pass("Root-owned provisioning evidence records exactly one UAC approval for Windows features and Microsoft WSL.") }, + { id: "restart_resume", ...pass("Administrator-owned provisioning evidence records a real required Windows restart and same-user installer resume.") }, + { id: "keepalive_reboot", ...pass(`Exact run ${env.GITHUB_RUN_ID} used the accepted snapshot-assisted cold-start equivalent and recovered its limited-user keepalive, service, and localhost health.`) }, + { id: "onboarding", ...pass("Exact candidate clean install returned localhost setup health with onboarding required.") }, + { id: "prior_version_update", ...pass(`Dedicated runner provisioned prior Stable v${env.HELM_PREVIOUS_VERSION} and updated through the site-equivalent WSL path.`) }, + { id: "retained_state", ...pass("WSL data-root marker retained the same SHA-256 across candidate update.") }, + { id: "uninstall_safety", ...pass("Scoped uninstall removed only the exact target distro/root and retained an unrelated WSL control.") }, + ], + state_preservation: { ...pass("WSL state marker retained byte identity."), before_sha256: env.HELM_STATE_BEFORE_SHA256, after_sha256: env.HELM_STATE_AFTER_SHA256 }, + recovery: { ...pass("Scoped uninstall removed target state while preserving the unrelated distribution."), before_sha256: env.HELM_STATE_BEFORE_SHA256, after_sha256: env.HELM_STATE_AFTER_SHA256 }, + notes: ["Windows publishes no artifact and has no signing claim; this record binds behavior to the exact Linux TGZ.", "The exact candidate used the documented snapshot-assisted equivalent, not a claimed in-job Windows reboot."], +}); +writeFileSync(resolve(env.HELM_ACCEPTANCE_OUTPUT || "windows-acceptance.json"), `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 }); diff --git a/site/public/install.ps1 b/site/public/install.ps1 index 922ddce..7695de1 100644 --- a/site/public/install.ps1 +++ b/site/public/install.ps1 @@ -39,6 +39,10 @@ Optional SHA-256 the local archive must match, checked inside the distribution before anything is installed. Catches a truncated or stale copy. +.PARAMETER LocalRootfs + Repo-scoped acceptance override for the pinned Ubuntu WSL rootfs. Requires + -LocalRootfsSha256, which must also equal the product's compiled-in pin. + .EXAMPLE irm https://1helm.com/install.ps1 | iex @@ -54,6 +58,8 @@ param( [string] $LocalArchive = '', [string] $LocalInstaller = '', [string] $LocalArchiveSha256 = '', + [string] $LocalRootfs = '', + [string] $LocalRootfsSha256 = '', [string] $KeepaliveSource = '', [switch] $HostSetup, [string] $StatusPath = '' @@ -247,6 +253,8 @@ if ([Environment]::OSVersion.Version.Build -lt 22000) { Die "1Helm requires Windows 11 (build 22000 or newer). This is build $([Environment]::OSVersion.Version.Build)." } if ($LocalArchive -and -not $LocalInstaller) { Die "-LocalArchive requires -LocalInstaller." } +if ($LocalRootfs -and -not $LocalRootfsSha256) { Die "-LocalRootfs requires -LocalRootfsSha256." } +if ($LocalRootfsSha256 -and $LocalRootfsSha256 -notmatch '^[a-fA-F0-9]{64}$') { Die "-LocalRootfsSha256 is not a SHA-256 digest." } $null = New-Item -ItemType Directory -Path $InstallRoot -Force @@ -330,7 +338,16 @@ if ((Get-Distros) -contains $Distro) { Say " distribution '$Distro' already exists" } else { $rootfs = Join-Path $InstallRoot 'ubuntu-noble-wsl-amd64.rootfs.tar.gz' - Get-FileWithDigest -Url $RootfsUrl -Destination $rootfs -ExpectedSha $RootfsSha + if ($LocalRootfs) { + if (-not (Test-Path $LocalRootfs)) { Die "-LocalRootfs not found: $LocalRootfs" } + $expectedRootfs = $LocalRootfsSha256.ToLowerInvariant() + if ($expectedRootfs -ne $RootfsSha) { Die "-LocalRootfsSha256 does not match the product's pinned Ubuntu rootfs." } + if ((Get-FileHash $LocalRootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expectedRootfs) { Die "-LocalRootfs did not match its expected SHA-256." } + Copy-Item $LocalRootfs $rootfs -Force + Say " using the digest-verified local Ubuntu rootfs" + } else { + Get-FileWithDigest -Url $RootfsUrl -Destination $rootfs -ExpectedSha $RootfsSha + } $distroDir = Join-Path $InstallRoot 'distro' $null = New-Item -ItemType Directory -Path $distroDir -Force Say " importing '$Distro' (this takes about a minute) ..." diff --git a/test/phase3-promotion.mjs b/test/phase3-promotion.mjs index 05bd927..e7ddab4 100644 --- a/test/phase3-promotion.mjs +++ b/test/phase3-promotion.mjs @@ -49,10 +49,11 @@ function createBundle() { 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, + version, candidate_workflow_run_id: runId, source_ci_run_id: "111", + artifact: { role: item.role, name: item.name, sha256: item.sha256, bytes: item.bytes }, ...extra, }); - macDmg.provenance = provenance(macDmg, { signing: "developer-id", notarization: "accepted" }); - macZip.provenance = provenance(macZip, { signing: "developer-id", notarization: "accepted" }); + macDmg.provenance = provenance(macDmg, { builder: "dedicated-macos", signer_workflow: "gitcommit90/1Helm/.github/workflows/candidate.yml", signing: "developer-id", notarization: "accepted", stapling: "validated", gatekeeper: "accepted" }); + macZip.provenance = provenance(macZip, { builder: "dedicated-macos", signer_workflow: "gitcommit90/1Helm/.github/workflows/candidate.yml", signing: "developer-id", notarization: "accepted", stapling: "validated", gatekeeper: "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", @@ -61,9 +62,17 @@ function createBundle() { artifact: { name: linux.name, sha256: linux.sha256, bytes: linux.bytes }, sealed_oci: { sha256: identity.sealed_oci_sha256 }, }; const candidateRecord = write("candidate.json", candidateManifest); + const macCandidateRecord = write("mac-candidate.json", { + schema: 1, kind: "1helm-macos-candidate", repository: "gitcommit90/1Helm", ref: "refs/heads/main", commit, version, + candidate: { workflow: "Candidate dress rehearsal", workflow_path: ".github/workflows/candidate.yml", event: "workflow_run", run_id: runId, run_attempt: "1" }, + source_ci: { workflow: "CI", run_id: "111", conclusion: "success" }, + builder: { type: "dedicated-self-hosted", runner_name: "macos-fixture-runner", runner_label: "1helm-macos-phase4", os: "macOS", architecture: "ARM64" }, + signing: { identity: "developer-id-application", notarization: "accepted", stapling: "validated", gatekeeper: "accepted", checked_at: "2026-08-04T13:00:00Z" }, + artifacts: [macDmg, macZip].map(({ role, name, sha256, bytes }) => ({ role, name, sha256, bytes })), + }); 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" }, + status: "completed", conclusion: "success", run_attempt: 1, 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", @@ -86,10 +95,27 @@ function createBundle() { const acceptanceArtifacts = { macos: [macDmg, macZip], linux: [linux], windows: [linux] }; const acceptance = {}; for (const platform of Object.keys(checkIds)) { + const marker = sha(`${platform}-retained-state`); 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 })), + result: "passed", started_at: "2026-08-04T13:30:00Z", checked_at: "2026-08-04T14:00:00Z", + candidate: { workflow: "Candidate dress rehearsal", workflow_path: ".github/workflows/candidate.yml", event: "workflow_run", run_id: runId, run_attempt: "1" }, + source_ci: { workflow: "CI", run_id: "111", conclusion: "success" }, + machine: { + id: `${platform}-fixture-machine`, kind: { linux: "github-hosted-ephemeral", macos: "dedicated-apple-silicon", windows: "dedicated-windows-11-vm" }[platform], + os: { linux: "Linux", macos: "macOS", windows: "Windows" }[platform], os_version: "fixture-1", + architecture: platform === "macos" ? "ARM64" : "X64", dedicated: true, production_data: false, + }, + runner: { name: `${platform}-fixture-runner`, labels: [platform === "linux" ? "ubuntu-latest" : `1helm-${platform}-phase4`], job: `accept-${platform}` }, + ...(platform === "windows" ? { continuity: { + mode: "snapshot-assisted-equivalent", result: "passed", checked_at: "2026-08-04T14:00:00Z", + summary: "Fixture continuity equivalent passed.", + } } : {}), + checks: checkIds[platform].map((id) => ({ id, result: "passed", checked_at: "2026-08-04T14:00:00Z", summary: `${id} fixture passed.` })), + artifacts: acceptanceArtifacts[platform].map(({ role, name, sha256, bytes }) => ({ role, name, sha256, bytes })), + state_preservation: { result: "passed", checked_at: "2026-08-04T14:00:00Z", summary: "Fixture state retained byte identity.", before_sha256: marker, after_sha256: marker }, + recovery: { result: "passed", checked_at: "2026-08-04T14:00:00Z", summary: "Fixture recovery completed.", before_sha256: marker, after_sha256: marker }, + notes: ["Promotion integration fixture only."], }); } const packageRecord = write("package.json", { version }); @@ -99,7 +125,7 @@ function createBundle() { 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 }, + records: { candidate_manifest: candidateRecord, mac_candidate_manifest: macCandidateRecord, 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`); diff --git a/test/phase4-platform-acceptance.mjs b/test/phase4-platform-acceptance.mjs new file mode 100644 index 0000000..42ad652 --- /dev/null +++ b/test/phase4-platform-acceptance.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { normalizePlatformEvidence, PLATFORM_ARTIFACT_ROLES, PLATFORM_CHECKS, platformEvidenceBlockers } from "../scripts/platform-acceptance-lib.mjs"; + +const root = join(import.meta.dirname, ".."); +const read = (path) => readFileSync(join(root, path), "utf8"); +const hash = (value) => createHash("sha256").update(value).digest("hex"); +const commit = "a".repeat(40); +const version = "1.2.3"; +const runId = "123"; +const ciRunId = "99"; +const artifactMap = { + mac_dmg: { role: "mac_dmg", name: `1Helm-${version}-arm64.dmg`, sha256: hash("dmg"), bytes: 3 }, + mac_updater_zip: { role: "mac_updater_zip", name: `1Helm-${version}-mac-arm64.zip`, sha256: hash("zip"), bytes: 3 }, + linux_tgz: { role: "linux_tgz", name: `1Helm-${version}-linux-node.tgz`, sha256: hash("tgz"), bytes: 3 }, +}; + +function fixture(platform) { + const checkedAt = "2026-08-04T14:00:00Z"; + const marker = hash(`${platform}-marker`); + return { + platform, commit, version, result: "passed", + candidate: { run_id: runId, run_attempt: "1" }, + source_ci: { workflow: "CI", run_id: ciRunId, conclusion: "success" }, + started_at: "2026-08-04T13:00:00Z", checked_at: checkedAt, + machine: { + id: `${platform}-machine`, kind: { linux: "github-hosted-ephemeral", macos: "dedicated-apple-silicon", windows: "dedicated-windows-11-vm" }[platform], + os: { linux: "Linux", macos: "macOS", windows: "Windows" }[platform], os_version: "11.0", + architecture: platform === "macos" ? "ARM64" : "X64", dedicated: true, production_data: false, + }, + runner: { name: `${platform}-runner`, labels: [platform === "linux" ? "ubuntu-latest" : `1helm-${platform}-phase4`], job: `accept-${platform}` }, + ...(platform === "windows" ? { continuity: { + mode: "snapshot-assisted-equivalent", result: "passed", checked_at: checkedAt, + summary: "Fixture continuity equivalent passed.", + } } : {}), + artifacts: PLATFORM_ARTIFACT_ROLES[platform].map((role) => artifactMap[role]), + checks: PLATFORM_CHECKS[platform].map((id) => ({ id, result: "passed", checked_at: checkedAt, summary: `${id} passed on fixture.` })), + state_preservation: { result: "passed", checked_at: checkedAt, summary: "State retained byte identity.", before_sha256: marker, after_sha256: marker }, + recovery: { result: "passed", checked_at: checkedAt, summary: "Recovery completed safely.", before_sha256: marker, after_sha256: marker }, + notes: ["Fixture, not real hardware evidence."], + }; +} + +test("all platform schemas normalize exact run, CI, machine, state, recovery, and artifact identity", () => { + for (const platform of Object.keys(PLATFORM_CHECKS)) { + const evidence = normalizePlatformEvidence(fixture(platform)); + assert.equal(evidence.result, "passed"); + assert.equal(evidence.candidate.run_id, runId); + assert.equal(evidence.source_ci.run_id, ciRunId); + assert.equal(evidence.machine.production_data, false); + assert.deepEqual(platformEvidenceBlockers(evidence, { platform, commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap }), []); + } +}); + +test("normalization derives blocked/failed and never accepts aggregate pass with missing checks", () => { + const missing = fixture("windows"); + missing.checks = missing.checks.slice(1); + delete missing.result; + const blocked = normalizePlatformEvidence(missing); + assert.equal(blocked.result, "blocked"); + assert.equal(blocked.checks.find((item) => item.id === "non_elevated_install").result, "blocked"); + assert.throws(() => normalizePlatformEvidence({ ...missing, result: "passed" }), /aggregate result/); + const failed = fixture("linux"); + failed.checks[0].result = "failed"; + delete failed.result; + assert.equal(normalizePlatformEvidence(failed).result, "failed"); +}); + +test("digest, byte count, CI run, state mismatch, and default runner label are blockers", () => { + const evidence = normalizePlatformEvidence(fixture("linux")); + evidence.artifacts[0].bytes += 1; + evidence.source_ci.run_id = "100"; + evidence.state_preservation.after_sha256 = hash("changed"); + evidence.runner.labels = ["self-hosted"]; + const blockers = platformEvidenceBlockers(evidence, { platform: "linux", commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap }); + assert.ok(blockers.some((item) => /CI identity/.test(item))); + assert.ok(blockers.some((item) => /state preservation/.test(item))); + assert.ok(blockers.some((item) => /runner label/.test(item))); + assert.ok(blockers.some((item) => /linux_tgz/.test(item))); + const wrongMachine = normalizePlatformEvidence(fixture("linux")); + wrongMachine.machine.kind = "dedicated-fixture"; + assert.ok(platformEvidenceBlockers(wrongMachine, { platform: "linux", commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap }) + .some((item) => /machine identity/.test(item))); + const windows = normalizePlatformEvidence(fixture("windows")); + delete windows.continuity; + assert.ok(platformEvidenceBlockers(windows, { platform: "windows", commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap }) + .some((item) => /snapshot-assisted/.test(item))); +}); + +test("workflow routes no PR/fork code, uses unique labels, fans acceptance out, and assembles only after all pass", () => { + const workflow = read(".github/workflows/candidate.yml"); + assert.match(workflow, /workflow_run:[\s\S]*workflows: \[CI\][\s\S]*branches: \[main\]/); + assert.doesNotMatch(workflow, /pull_request:/); + assert.match(workflow, /head_repository\.full_name == github\.repository/); + assert.match(workflow, /github\.event\.workflow_run\.event == 'push'/); + assert.match(workflow, /runs-on: \[1helm-macos-phase4\]/); + assert.match(workflow, /runs-on: \[1helm-windows-phase4\]/); + assert.match(workflow, /accept-linux:[\s\S]*accept-macos:[\s\S]*accept-windows:/); + assert.match(workflow, /needs: \[build, build-macos, deploy, accept-linux, accept-macos, accept-windows\]/); + assert.match(workflow, /needs\.accept-linux\.result == 'success'[\s\S]*needs\.accept-macos\.result == 'success'[\s\S]*needs\.accept-windows\.result == 'success'/); + assert.match(workflow, /vars\.HELM_PHASE4_MACOS_ENABLED == '1'/); + assert.match(workflow, /vars\.HELM_PHASE4_WINDOWS_ENABLED == '1'/); + assert.doesNotMatch(workflow, /runs-on: \[self-hosted/); + assert.doesNotMatch(workflow, /secrets\./); + assert.doesNotMatch(workflow, /pull_request_target|workflow_dispatch/); + assert.doesNotMatch(workflow.match(/assemble-promotion:[\s\S]*?(?=\n candidate-status:)/)?.[0] || "", /npm (ci|install|run build|run package)/); + assert.match(workflow, /Upload exact Linux acceptance evidence\n if: always\(\)/); + assert.match(workflow.match(/accept-macos:[\s\S]*?(?=\n accept-windows:)/)?.[0] || "", /if: always\(\)[\s\S]*name: 1helm-macos-acceptance-/); + assert.match(workflow.match(/accept-windows:[\s\S]*?(?=\n assemble-promotion:)/)?.[0] || "", /if: always\(\)[\s\S]*name: 1helm-windows-acceptance-/); +}); + +test("an interrupted lane retains normalized blocked evidence before it can pass", async () => { + const scratch = mkdtempSync(join(tmpdir(), "1helm-phase4-pending-")); + try { + const manifest = join(scratch, "candidate.json"); + const output = join(scratch, "linux.json"); + writeFileSync(manifest, JSON.stringify({ + version, source: { commit }, ci: { workflow: "CI", run_id: ciRunId, conclusion: "success" }, + artifact: artifactMap.linux_tgz, + })); + const { spawnSync } = await import("node:child_process"); + const result = spawnSync(process.execPath, [join(root, "scripts/pending-acceptance-evidence.mjs")], { + encoding: "utf8", + env: { ...process.env, HELM_ACCEPTANCE_PLATFORM: "linux", HELM_ACCEPTANCE_OUTPUT: output, + HELM_ACCEPTANCE_STARTED_AT: "2026-08-04T13:00:00Z", HELM_CANDIDATE_MANIFEST: manifest, + HELM_CANDIDATE_ARCHIVE: artifactMap.linux_tgz.name, HELM_PHASE4_RUNNER_LABEL: "ubuntu-latest", + GITHUB_RUN_ID: runId, GITHUB_RUN_ATTEMPT: "1", GITHUB_JOB: "accept-linux", + RUNNER_NAME: "GitHub Actions 1", RUNNER_OS: "Linux", RUNNER_ARCH: "X64" }, + }); + assert.equal(result.status, 0, result.stderr); + const evidence = JSON.parse(readFileSync(output, "utf8")); + assert.equal(evidence.result, "blocked"); + assert.ok(evidence.checks.every((item) => item.result === "blocked")); + assert.notDeepEqual(platformEvidenceBlockers(evidence, { platform: "linux", commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap }), []); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}); + +test("native runner hooks structurally reject every event/job outside the exact trusted candidate lanes", () => { + const hook = read("ops/platform-acceptance/runner-job-started.sh"); + const windowsHook = read("ops/platform-acceptance/runner-job-started.ps1"); + assert.match(hook, /GITHUB_REPOSITORY.*gitcommit90\/1Helm/); + assert.match(hook, /GITHUB_WORKFLOW.*Candidate dress rehearsal/); + assert.match(hook, /GITHUB_WORKFLOW_REF.*\.github\/workflows\/candidate\.yml@refs\/heads\/main/); + assert.match(hook, /GITHUB_EVENT_NAME.*workflow_run/); + assert.match(hook, /build-macos/); + assert.match(hook, /accept-macos/); + assert.match(hook, /run\.get\("event"\) == "push"/); + assert.match(hook, /run\.get\("head_branch"\) == "main"/); + assert.doesNotMatch(hook, /pull_request|NOPASSWD|sudo/); + assert.match(windowsHook, /GITHUB_WORKFLOW_REF.*candidate\.yml@refs\/heads\/main/); + assert.match(windowsHook, /GITHUB_EVENT_NAME.*workflow_run/); + assert.match(windowsHook, /GITHUB_JOB.*accept-windows/); + assert.match(windowsHook, /head_repository\.full_name/); + assert.match(windowsHook, /head_sha.*GITHUB_SHA/); + assert.doesNotMatch(windowsHook, /pull_request|NOPASSWD|sudo/); +}); + +test("matrix status reports disabled or missing machines as blockers, never skipped success", async () => { + const scratch = mkdtempSync(join(tmpdir(), "1helm-phase4-status-")); + try { + const output = join(scratch, "status.json"); + const previous = { ...process.env, + GITHUB_RUN_ID: "123", HELM_CANDIDATE_COMMIT: commit, HELM_CANDIDATE_VERSION: version, + HELM_LINUX_BUILD_RESULT: "success", HELM_MAC_BUILD_RESULT: "skipped", + HELM_LINUX_REHEARSAL_RESULT: "success", HELM_PROMOTION_BUNDLE_RESULT: "skipped", + HELM_LINUX_ACCEPTANCE_RESULT: "success", HELM_MAC_ACCEPTANCE_RESULT: "skipped", + HELM_WINDOWS_ACCEPTANCE_RESULT: "skipped", HELM_CANDIDATE_STATUS_OUTPUT: output, + }; + const { spawnSync } = await import("node:child_process"); + const result = spawnSync(process.execPath, [join(root, "scripts/candidate-matrix-status.mjs")], { env: previous, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const status = JSON.parse(readFileSync(output, "utf8")); + assert.equal(status.complete, false); + assert.equal(status.platforms.macos.result, "blocked"); + assert.equal(status.platforms.windows.result, "blocked"); + assert.equal(status.stable_touched, false); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}); + +test("Windows code publishes no artifact/signing claim and requires honest reboot or snapshot-assisted provisioning evidence", () => { + const workflow = read(".github/workflows/candidate.yml"); + const windows = read("ops/platform-acceptance/windows.ps1") + read("scripts/windows-acceptance-evidence.mjs"); + assert.doesNotMatch(workflow, /package:windows|windows[-_]artifact|setup\.exe/i); + assert.match(windows, /provisioning-evidence\.json/); + assert.match(windows, /snapshot-assisted-equivalent/); + assert.match(windows, /snapshot_baseline/); + assert.doesNotMatch(windows, /reboot-evidence\.json/); + assert.match(windows, /apply-linux-release\.sh/); + assert.match(windows, /LocalRootfs/); + assert.match(read("site/public/install.ps1"), /LocalRootfsSha256/); + assert.match(windows, /no distinct prior Stable release/); + assert.match(windows, /unrelated WSL control/); + assert.match(windows, /Windows publishes no artifact and has no signing claim/); + assert.match(read("ops/platform-acceptance/macos.sh"), /acceptance residue before this job/); +});