From db9b281c796e1a96d1a5af8135e5bf8f17874871 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 4 Aug 2026 13:46:45 +0000 Subject: [PATCH] build: split app and channel image delivery Reduce normal update downloads by separating immutable OCI image bytes, retain complete offline bundles, and add measured cache-safe artifact budgets. Co-Authored-By: Claude Signed-off-by: Joseph Yaksich --- .github/workflows/candidate.yml | 85 +++- .github/workflows/promote-stable.yml | 4 + config/artifact-budgets.json | 23 ++ config/linux-runtime-package.json | 32 ++ docs/GOVERNANCE.md | 6 +- docs/USER_GUIDE.md | 6 + docs/artifact-size-and-split-delivery.md | 84 ++++ docs/phase4-platform-acceptance.md | 9 +- docs/release-checklist.md | 41 +- docs/release-lifecycle.md | 16 +- docs/release-notes-template.md | 8 +- ops/dress-rehearsal/1helm-candidate-install | 9 +- ops/dress-rehearsal/candidate-boundary.py | 37 +- ops/platform-acceptance/linux.sh | 24 +- ops/platform-acceptance/windows.ps1 | 11 +- package.json | 2 + scripts/artifact-contract.mjs | 126 ++++++ scripts/artifact-size-report.mjs | 224 ++++++++++ scripts/build-oci-channel-image.sh | 164 ++++++-- scripts/candidate-manifest.mjs | 81 ++-- scripts/candidate-promotion-skeleton.mjs | 52 ++- scripts/channel-image-gc-report.mjs | 31 ++ scripts/github-promotion-gates.mjs | 20 + scripts/linux-acceptance-evidence.mjs | 4 + scripts/package-linux-host.mjs | 431 ++++++++++---------- scripts/pending-acceptance-evidence.mjs | 6 + scripts/platform-acceptance-lib.mjs | 22 +- scripts/promotion-lib.mjs | 65 ++- scripts/publish-promotion.mjs | 111 ++++- scripts/stable-manifest-lib.mjs | 27 +- scripts/verify-promotion-attestation.mjs | 34 +- scripts/windows-acceptance-evidence.mjs | 6 +- site/content.mjs | 2 +- site/public/apply-linux-release.sh | 3 + site/public/install-oci-runtime.sh | 93 ++++- site/public/install.sh | 15 +- site/public/update-host.sh | 24 +- site/server.mjs | 11 +- test/connectors.mjs | 1 + test/phase2-candidate.mjs | 39 +- test/phase3-promotion.mjs | 88 +++- test/phase4-platform-acceptance.mjs | 16 +- test/phase5-artifacts.mjs | 181 ++++++++ test/release-governance.mjs | 21 +- test/site.mjs | 4 +- 45 files changed, 1874 insertions(+), 425 deletions(-) create mode 100644 config/artifact-budgets.json create mode 100644 config/linux-runtime-package.json create mode 100644 docs/artifact-size-and-split-delivery.md create mode 100644 scripts/artifact-contract.mjs create mode 100755 scripts/artifact-size-report.mjs create mode 100755 scripts/channel-image-gc-report.mjs create mode 100644 test/phase5-artifacts.mjs diff --git a/.github/workflows/candidate.yml b/.github/workflows/candidate.yml index c53d2cb..d159b15 100644 --- a/.github/workflows/candidate.yml +++ b/.github/workflows/candidate.yml @@ -35,6 +35,7 @@ jobs: commit: ${{ steps.identity.outputs.commit }} version: ${{ steps.identity.outputs.version }} ci-run-id: ${{ steps.identity.outputs.ci_run_id }} + image-digest: ${{ steps.identity.outputs.image_digest }} steps: - name: Check out the exact successful CI commit uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -76,6 +77,39 @@ jobs: sudo apt-get update sudo apt-get install -y podman + - name: Resolve exact OCI and production dependency cache identities + id: packaging-cache + run: | + set -euo pipefail + builder_image=docker.io/library/node:22 + podman pull "$builder_image" + builder_digest="$(podman image inspect "$builder_image" --format '{{.Digest}}' | sed 's/^sha256://')" + node_abi="$(podman run --rm "$builder_image" node -p process.versions.modules)" + case "$(uname -m)" in x86_64|amd64) image_arch=amd64; native_arch=x64 ;; aarch64|arm64) image_arch=arm64; native_arch=arm64 ;; *) exit 1 ;; esac + base_digest="$(sed -n 's/^FROM .*@sha256:\([a-f0-9]\{64\}\)$/\1/p' container/Containerfile.oci)" + containerfile_sha="$(sha256sum container/Containerfile.oci | awk '{print $1}')" + context_sha="$(git ls-files -z container | while IFS= read -r -d '' file; do + case "$file" in container/channel-machine.oci.tar|container/channel-machine.oci.sha256|container/channel-machine.oci.json) continue ;; esac + printf '%s\0' "$file" + sha256sum "$file" | awk '{printf "%s\0", $1}' + done | sha256sum | awk '{print $1}')" + oci_key="$(printf '1helm-channel-image-v1\n%s\n%s\n%s\n%s\n' "$image_arch" "$base_digest" "$containerfile_sha" "$context_sha" | sha256sum | awk '{print $1}')" + dependency_key="$(printf '%s\n%s\n%s\n%s\n%s' "$(sha256sum package-lock.json | awk '{print $1}')" "$(sha256sum config/linux-runtime-package.json | awk '{print $1}')" "$node_abi" "$native_arch" "$builder_digest" | sha256sum | awk '{print $1}')" + [[ "$builder_digest" =~ ^[a-f0-9]{64}$ && "$node_abi" =~ ^[0-9]+$ && "$oci_key" =~ ^[a-f0-9]{64}$ && "$dependency_key" =~ ^[a-f0-9]{64}$ ]] + printf 'oci_key=%s\ndependency_key=%s\n' "$oci_key" "$dependency_key" >> "$GITHUB_OUTPUT" + + - name: Restore only the exact sealed OCI cache + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: dist/cache/channel-images + key: 1helm-phase5-channel-image-${{ steps.packaging-cache.outputs.oci_key }} + + - name: Restore only the exact production dependency cache + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: dist/cache/production-dependencies + key: 1helm-phase5-production-dependencies-${{ steps.packaging-cache.outputs.dependency_key }} + - name: Build sealed OCI image and ready-to-run Linux archive env: HELM_CANDIDATE_REPOSITORY: gitcommit90/1Helm @@ -91,6 +125,17 @@ jobs: set -euo pipefail npm run package:channel-image npm run package:linux + image_name="$(node -p 'require("./container/channel-machine.oci.json").artifact.name')" + cp container/channel-machine.oci.tar "dist/$image_name" + cp container/channel-machine.oci.json "dist/${image_name%.oci.tar}.json" + + - name: Measure split artifact composition and enforce regression budgets + run: | + set -euo pipefail + node scripts/artifact-size-report.mjs \ + --json dist/artifact-size-report.json \ + --text dist/artifact-size-report.txt \ + --check - name: Generate candidate manifest and evidence id: identity @@ -101,9 +146,14 @@ jobs: set -euo pipefail version="$(node -p 'require("./package.json").version')" archive="dist/1Helm-${version}-linux-node.tgz" + offline="dist/1Helm-${version}-linux-node-offline.tgz" + split="dist/1Helm-${version}-linux-split.json" evidence="dist/candidate-evidence" mkdir -p "$evidence" - HELM_CANDIDATE_ARCHIVE="$archive" HELM_CANDIDATE_MANIFEST="$evidence/candidate.json" \ + HELM_CANDIDATE_ARCHIVE="$archive" \ + HELM_CANDIDATE_OFFLINE_ARCHIVE="$offline" \ + HELM_CANDIDATE_SPLIT_MANIFEST="$split" \ + HELM_CANDIDATE_MANIFEST="$evidence/candidate.json" \ node scripts/candidate-manifest.mjs cp "$archive.sha256" "$evidence/archive.sha256" sha256sum "$evidence/candidate.json" > "$evidence/manifest.sha256" @@ -111,12 +161,16 @@ jobs: 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" + printf 'image_digest=%s\n' "$(node -p 'require("./container/channel-machine.oci.json").sha256')" >> "$GITHUB_OUTPUT" - name: Attest archive provenance on the hosted builder id: attest uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: - subject-path: dist/1Helm-*-linux-node.tgz + subject-path: | + dist/1Helm-*-linux-node.tgz + dist/1Helm-*-linux-node-offline.tgz + container/channel-machine.oci.tar - name: Retain signed provenance bundle env: @@ -132,6 +186,11 @@ jobs: name: ${{ steps.identity.outputs.artifact_name }} path: | dist/1Helm-*-linux-node.tgz + dist/1Helm-*-linux-node-offline.tgz + dist/1Helm-*-linux-split.json + dist/artifact-size-report.json + dist/artifact-size-report.txt + container/channel-machine.oci.json dist/candidate-evidence/candidate.json dist/candidate-evidence/archive.sha256 dist/candidate-evidence/manifest.sha256 @@ -139,6 +198,17 @@ jobs: if-no-files-found: error retention-days: 30 + - name: Retain immutable digest-addressed channel image candidate + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: 1helm-channel-image-${{ steps.identity.outputs.image_digest }} + path: | + dist/1Helm-channel-machine-v1-*.oci.tar + dist/1Helm-channel-machine-v1-*.json + container/channel-machine.oci.sha256 + if-no-files-found: error + retention-days: 90 + build-macos: name: Build signed notarized exact Mac candidate if: >- @@ -259,6 +329,9 @@ jobs: 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 + offline="$(find candidate-download -maxdepth 1 -type f -name '1Helm-*-linux-node-offline.tgz' -print -quit)" + test -n "$offline" + install -m 0600 "$offline" /var/lib/1helm-candidate/inbox/candidate-offline.tgz install -m 0600 candidate-download/candidate-evidence/candidate.json /var/lib/1helm-candidate/inbox/candidate.json 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 @@ -318,7 +391,9 @@ jobs: 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)" + export HELM_CANDIDATE_OFFLINE_ARCHIVE="$(find candidate-download -maxdepth 1 -type f -name '1Helm-*-linux-node-offline.tgz' -print -quit)" test -n "$HELM_CANDIDATE_ARCHIVE" + test -n "$HELM_CANDIDATE_OFFLINE_ARCHIVE" bash ops/platform-acceptance/linux.sh - name: Upload exact Linux acceptance evidence @@ -431,6 +506,9 @@ jobs: $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 + $offline = Get-ChildItem candidate-download -Filter '1Helm-*-linux-node-offline.tgz' | Select-Object -First 1 -ExpandProperty FullName + if (-not $offline) { throw 'Exact Linux offline candidate archive is missing.' } + $env:HELM_CANDIDATE_OFFLINE_ARCHIVE = $offline & .\ops\platform-acceptance\windows.ps1 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -463,6 +541,8 @@ jobs: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: { name: "${{ needs.build.outputs.artifact-name }}", path: candidate-download } + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: { name: "1helm-channel-image-${{ needs.build.outputs.image-digest }}", path: channel-image-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 @@ -477,6 +557,7 @@ jobs: - name: Assemble only the retained complete matrix and evidence env: HELM_CANDIDATE_DOWNLOAD: candidate-download + HELM_CHANNEL_IMAGE_DOWNLOAD: channel-image-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 diff --git a/.github/workflows/promote-stable.yml b/.github/workflows/promote-stable.yml index 42f92b4..fac5089 100644 --- a/.github/workflows/promote-stable.yml +++ b/.github/workflows/promote-stable.yml @@ -166,6 +166,10 @@ jobs: promotion-bundle/1Helm-${{ inputs.version }}-arm64.dmg promotion-bundle/1Helm-${{ inputs.version }}-mac-arm64.zip promotion-bundle/1Helm-${{ inputs.version }}-linux-node.tgz + promotion-bundle/1Helm-${{ inputs.version }}-linux-node-offline.tgz + promotion-bundle/1Helm-channel-machine-v1-*.oci.tar + promotion-bundle/channel-image.json + promotion-bundle/channel-image-provenance.json promotion-bundle/1Helm-${{ inputs.version }}-stable.json promotion-bundle/1Helm-${{ inputs.version }}-release-notes.md promotion-bundle/verified-promotion.json diff --git a/config/artifact-budgets.json b/config/artifact-budgets.json new file mode 100644 index 0000000..91b9682 --- /dev/null +++ b/config/artifact-budgets.json @@ -0,0 +1,23 @@ +{ + "schema": 1, + "kind": "1helm-artifact-size-budgets", + "units": "bytes", + "baselines": { + "legacy_linux_complete_tgz": 401045903, + "sealed_oci_image": 203846656, + "legacy_linux_unpacked_node_modules": 447486065 + }, + "budgets": { + "linux_app_tgz": 220000000, + "linux_offline_tgz": 390000000, + "sealed_oci_image": 220000000, + "linux_unpacked_node_modules": 320000000, + "linux_client_assets": 32000000, + "duplicate_bytes": 50000000 + }, + "notes": [ + "The v0.0.41 complete Linux archive and sealed OCI figures were measured from retained local release outputs.", + "Mac artifacts are intentionally unbaselined until their exact signed bytes are present on a Mac builder.", + "Budgets are regression ceilings, not targets, and do not authorize removal of runtime files." + ] +} diff --git a/config/linux-runtime-package.json b/config/linux-runtime-package.json new file mode 100644 index 0000000..c33db64 --- /dev/null +++ b/config/linux-runtime-package.json @@ -0,0 +1,32 @@ +{ + "schema": 1, + "kind": "1helm-linux-runtime-package-allowlist", + "description": "Files needed by the ready-to-run Linux host. Generated assets and production dependencies are added by the packager.", + "source": [ + "LICENSE", + "NOTICE", + "package.json", + "package-lock.json", + "src/server", + "scripts/1helm-oci-runtime", + "scripts/mnemosyne-bridge.py", + "scripts/ensure-node-pty-helper.cjs", + "site/public/apply-linux-release.sh", + "site/public/install-linux-units.sh", + "site/public/install-oci-runtime.sh", + "site/public/install.sh", + "site/public/uninstall-host.sh", + "site/public/update-host.sh", + "deploy/1helm-oci-runtime-v1.conf", + "container/Containerfile.oci" + ], + "built": [ + "public", + "desktop/photon-sidecar.bundle.mjs" + ], + "production_dependency_excludes": { + "directory_names": [".cache", ".github", "__tests__", "doc", "docs", "example", "examples", "test", "tests"], + "file_suffixes": [".d.ts", ".map"], + "file_names": ["CHANGELOG", "CHANGELOG.md", "README", "README.md"] + } +} diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 5b4bbda..acd7a03 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -74,8 +74,10 @@ contract as the slice hardens. - Semantic versioning on `package.json`. - **Do not** reuse a published version tag for different bits. - A desktop release requires one unique version and exact commit, changelog, the - complete three-artifact matrix (`1Helm--arm64.dmg`, - `1Helm--mac-arm64.zip`, `1Helm--linux-node.tgz`), and + complete **four-artifact** split desktop matrix (`1Helm--arm64.dmg`, + `1Helm--mac-arm64.zip`, online `1Helm--linux-node.tgz`, + complete `1Helm--linux-node-offline.tgz`), plus the exact immutable + digest-addressed channel-image manifest, and clean-install plus prior-to-new update evidence on macOS, Linux, and Windows. Windows publishes no artifact; its installer is served by the site, not attached to the release. Partial platform releases under the shared product diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b39a685..1c881b4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -384,6 +384,12 @@ that one fixed operation, but cannot choose an arbitrary URL, command, or target path. The host updater requires a stable GitHub release and its SHA-256 asset digest, installs into a versioned directory, switches the current symlink atomically, restarts, health-checks, and restores the prior release if needed. +The normal online archive omits the large sealed channel image: its exact +digest/architecture/version manifest lets the host reuse already verified bytes +from the shared image store, or fetch and verify them once. For disconnected +recovery, use the complete `linux-node-offline.tgz` bundle; legacy v0.0.41-style +complete archives also remain accepted. Image cleanup is report-only, so +rollback-referenced bytes are retained. Source/developer deployments report that their host operator owns updates. Every host update preserves: diff --git a/docs/artifact-size-and-split-delivery.md b/docs/artifact-size-and-split-delivery.md new file mode 100644 index 0000000..6c5f5b5 --- /dev/null +++ b/docs/artifact-size-and-split-delivery.md @@ -0,0 +1,84 @@ +# Artifact size and split delivery + +Phase 5 separates the large Linux/Windows channel-computer image from ordinary +application releases without relaxing any byte-identity or runtime gate. + +## What users download + +The normal Linux and Windows/WSL install path downloads +`1Helm--linux-node.tgz`. That archive is ready to run: it contains the +server, built browser assets, production dependencies, native add-ons, lifecycle +scripts, and an exact channel-image manifest. It does **not** contain the OCI +archive itself. + +The installer then resolves the manifest's immutable URL, checks its contract +version and host architecture, downloads the image only when that SHA-256 is not +already retained, verifies byte count and SHA-256, and stores it below +`/var/lib/1helm-oci-v1/shared-images/sha256/`. A normal application-only +update that references the same digest reuses those bytes. Every retained prior +application release continues to reference its image digest, so rollback does +not depend on a new download. + +For a disconnected machine, use +`1Helm--linux-node-offline.tgz`. It contains the exact same application +tree and the exact image bytes named by its embedded manifest. Copy that one +archive to the machine and pass it to `install.sh`; no channel-image network +fetch is required. Existing v0.0.41-style complete archives remain supported by +the explicit legacy branch. + +If the image manifest is absent, malformed, for another architecture, or does +not match the downloaded/embedded bytes, installation stops before the runtime +contract changes. Recovery is to retry online, provide the complete offline +bundle, or reinstall the prior verified complete release. No fallback image is +invented. + +## Measured local result + +The deterministic v0.0.41 complete Linux artifact baseline is 401,045,903 bytes. +Its embedded sealed OCI archive is 203,846,656 bytes. The Phase 5 local build +produced: + +- online Linux application: 149,436,110 bytes; +- complete offline bundle: 350,656,134 bytes; +- shared sealed OCI archive: 203,846,656 bytes; +- packaged production dependencies: 280,851,053 unpacked bytes; +- packaged client assets: 25,497,670 unpacked bytes. + +A cold online installation downloads 353,282,766 bytes across the application +and shared image, 47,763,137 bytes (11.91%) less than the v0.0.41 complete +archive. The application artifact itself is 251,609,793 bytes smaller (62.74%); +an application-only update with the unchanged image downloads only that +149,436,110-byte artifact and avoids transferring the 203,846,656-byte image +again. The offline bundle remains complete and is 50,389,769 bytes smaller +(12.56%) than the old archive due to the runtime allowlist and +production-dependency slimming. + +Run `npm run artifacts:report` to regenerate the machine-readable JSON and +concise text report. Inputs may be overridden with `--linux-app`, +`--linux-offline`, `--oci`, `--mac-dmg`, `--mac-zip`, `--vendored`, and +`--client`. Missing Mac or Linux outputs are recorded as `missing`, not treated +as zero-byte artifacts. General deterministic baselines and regression ceilings +live in `config/artifact-budgets.json`; reports never record hostnames, machine +identities, or private filesystem paths. + +## Packaging and cache auditability + +`config/linux-runtime-package.json` is the source allowlist for the Linux +runtime. `npm ci --omit=dev` remains the production dependency authority. The +packager removes only named documentation, test/example/cache directories, +TypeScript declarations, and source maps from the staged production dependency +tree, then requires and fingerprints every native add-on. It does not change the +lockfile or dependencies and never builds on the customer host. + +Production dependency cache identity covers the exact lockfile SHA-256, runtime +packaging-manifest SHA-256, Node ABI, Linux architecture, and native builder +image digest. Channel-image cache identity covers architecture, the pinned base +image digest, Containerfile SHA-256, and complete tracked container-context +SHA-256. Candidate manifests state whether each exact cache was reused; the +canonical digest-addressed image manifest stays identical across reused +candidates. Hosted candidate builds use these full keys with no prefix +fallback. A mismatched key or cache manifest is not reusable. + +`node scripts/channel-image-gc-report.mjs` reports referenced and unreferenced +digest stores. Phase 5 always emits `action: "retain"` and has no deletion path; +garbage collection is deliberately report-only. diff --git a/docs/phase4-platform-acceptance.md b/docs/phase4-platform-acceptance.md index 6d0690a..4ce2860 100644 --- a/docs/phase4-platform-acceptance.md +++ b/docs/phase4-platform-acceptance.md @@ -1,6 +1,7 @@ # Phase 4 cross-platform candidate acceptance -1. Build the ready-to-run Linux TGZ on the hosted builder and retain its +1. Build the ready-to-run online Linux TGZ, complete offline TGZ, and immutable + digest-addressed channel image on the hosted builder and retain their 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 @@ -8,10 +9,12 @@ 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 +4. Retain normalized JSON with exact online/offline artifact SHA-256 and byte + counts plus channel-image architecture, contract version, and SHA-256, 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. + the Linux online/offline pair and shared image contract, and publishes no + Windows 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 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 6377d6c..015959f 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -12,9 +12,13 @@ Do not create the tag or GitHub Release, publish any platform, mark anything latest, or say “done” until all three lanes pass. If one lane is blocked, pause the whole release and report it. -**Three release artifacts, not six.** A complete release attaches exactly -`1Helm--arm64.dmg`, `1Helm--mac-arm64.zip` and -`1Helm--linux-node.tgz`. **Windows publishes nothing.** There is no +**Four application release artifacts, not six.** A complete application release +attaches exactly `1Helm--arm64.dmg`, +`1Helm--mac-arm64.zip`, `1Helm--linux-node.tgz`, and the +complete disconnected-install `1Helm--linux-node-offline.tgz`. The +Stable manifest also binds the exact immutable channel-image Release by digest, +byte count, architecture, and contract version; unchanged OCI bytes are not +uploaded to the application Release again. **Windows publishes nothing.** There is no Windows executable, no Windows installer package, no Windows update manifest, no Electron host on Windows and nothing to code-sign, so no signing status exists to record or disclose. A Windows host is the Linux host running inside a per-user WSL 2 @@ -83,6 +87,7 @@ VERSION="$(node -p "require('./package.json').version")" ```bash HEADLESS="dist/1Helm-${VERSION}-linux-node.tgz" +OFFLINE="dist/1Helm-${VERSION}-linux-node-offline.tgz" DMG="dist/1Helm-${VERSION}-arm64.dmg" UPDATE_ZIP="dist/1Helm-${VERSION}-mac-arm64.zip" ANDROID_APK="dist/1Helm-${VERSION}-universal.apk" @@ -94,7 +99,7 @@ RELEASE_NOTES="dist/1Helm-${VERSION}-release-notes.md" # Windows: no build. A Windows host installs "$HEADLESS" through the # site-served install.ps1; there is no Windows artifact to produce. -for artifact in "$DMG" "$UPDATE_ZIP" "$HEADLESS"; do +for artifact in "$DMG" "$UPDATE_ZIP" "$HEADLESS" "$OFFLINE"; do test -s "$artifact" done # Author RELEASE_NOTES from docs/release-notes-template.md. It must contain the @@ -104,8 +109,10 @@ test -s "$RELEASE_NOTES" rg -q '^1\. ' "$RELEASE_NOTES" # multi-item ships must retain a numbered ledger ``` -Those three files are the whole desktop matrix. Do not invent a fourth desktop -asset, and do not attach `install.ps1`, `uninstall.ps1` or the keepalive payload +Those four files are the whole application desktop matrix. The digest-addressed +channel image, manifest, and provenance live in their own immutable Release; do +not duplicate the image in the application Release. Do not attach `install.ps1`, +`uninstall.ps1` or the keepalive payload to the release: they are served from the site, so a release commit that changes them is not shipped until the site is deployed. Because Windows ships no executable code of its own, there is no Windows signing identity and no Windows @@ -148,8 +155,9 @@ secret `STABLE_PUBLICATION_ENABLED=PROTECTED STABLE ENVIRONMENT ENABLED`, an eligible candidate may be dispatched again with `mode=publish` and the exact confirmation string printed by the dry run. Those settings are not created by this repository or by the workflow. The publish job refuses an existing tag or -release and uploads only the verified DMG, updater ZIP, Linux TGZ, and Stable -manifest. It never rebuilds. +release and uploads only the verified DMG, updater ZIP, online Linux TGZ, +offline Linux TGZ, and Stable manifest. It creates or exactly reuses the +separate immutable channel-image Release and never rebuilds. ### Mobile release gates @@ -190,8 +198,9 @@ Expect first-run / needs_setup on empty data dir. install the **publicly downloaded** DMG/update with preserved Application Support, then verify the new version, loopback health, resident state, and data-directory identity. -- **Linux:** verify the exact `npm run package:linux` archive, its source commit - and SHA-256, then stage equivalent release metadata. In a disposable systemd +- **Linux:** verify the exact online and offline `npm run package:linux` + archives, their source commit and SHA-256 identities, and the split image + contract, then stage equivalent release metadata. In a disposable systemd host running the prior release, invoke the Captain host-update action, observe checking/downloading/installing/restarting, verify the new version and `/var/lib/1helm-oci-v1` identity, and exercise health-failure rollback. @@ -222,10 +231,12 @@ Expect first-run / needs_setup on empty data dir. `wsl --shutdown` and must never unregister a distribution whose name is not an exact match for the target; other distributions on the PC are untouched. - Before publication, compare each uploaded GitHub asset digest with the local - verified digest and assert the release contains the complete **three-file** - desktop matrix: `1Helm--arm64.dmg`, - `1Helm--mac-arm64.zip`, `1Helm--linux-node.tgz`. A missing - asset is a release blocker, not “not applicable.” Windows contributes no + verified digest and assert the application Release contains the complete + **four-file** desktop matrix: `1Helm--arm64.dmg`, + `1Helm--mac-arm64.zip`, `1Helm--linux-node.tgz`, and + `1Helm--linux-node-offline.tgz`; also prove its Stable manifest binds + the exact separate immutable channel-image Release. A missing asset is a + release blocker, not “not applicable.” Windows contributes no asset, so an absent Windows file is correct — an absent Windows **behavioural record** is a blocker. @@ -252,7 +263,7 @@ Windows: -Desktop matrix: +Desktop matrix: Android: iOS: CI: Actions green on main diff --git a/docs/release-lifecycle.md b/docs/release-lifecycle.md index a5d31e8..405ef86 100644 --- a/docs/release-lifecycle.md +++ b/docs/release-lifecycle.md @@ -6,12 +6,19 @@ Process contract from intent to verified deploy. Commands: [release-checklist.md 1Helm has one synchronized desktop-host release train. A named desktop release is one version, one exact source commit, and one GitHub Release containing -exactly these **three** artifacts: +exactly these **four** application desktop artifacts: - `1Helm--arm64.dmg` — Developer ID signed, Apple-notarized and stapled Apple Silicon macOS DMG; - `1Helm--mac-arm64.zip` — the notarized/stapled native updater ZIP; - `1Helm--linux-node.tgz` — the digest-qualified Linux host archive. +- `1Helm--linux-node-offline.tgz` — the complete disconnected-install + bundle containing the exact referenced channel image. + +Linux and Windows manifests also bind the immutable digest-addressed channel +image by SHA-256, byte count, architecture, and contract version. That image has +its own retained candidate/provenance and immutable Release and is not uploaded +again when an application release references unchanged bytes. **Windows publishes nothing.** There is no Windows executable, no Windows installer package, no Windows update manifest, no Electron host on Windows and @@ -131,11 +138,12 @@ Draft PRs are allowed for long slices; mark ready only when the quality bar is m user-visible item must appear once, with the same numbering as the request when available. Include additional fixes, artifacts/digests, and verification evidence in their own sections. -6. Before creating the tag or GitHub Release, finish the complete three-artifact +6. Before creating the tag or GitHub Release, finish the complete split-artifact desktop matrix from the exact merged commit: verified macOS DMG (`1Helm--arm64.dmg`), macOS updater ZIP (`1Helm--mac-arm64.zip`), and Linux host archive - (`1Helm--linux-node.tgz`). Windows produces no artifact and has no + (`1Helm--linux-node.tgz`) plus its complete offline bundle and exact + shared-image contract. Windows produces no artifact and has no signing status to record; complete its behavioural acceptance instead (`docs/release-checklist.md` Section 7). 7. Publish those desktop artifacts and complete release notes together through @@ -205,7 +213,7 @@ workspace state. | Code landed | On `origin/main`, CI green | | Behavior fixed | Tests + manual/API check | | Install path still works | Clean `CTRL_DATA_DIR` boot through the wizard plus platform acceptance | -| Named desktop release | One version/commit, changelog, full numbered notes, exact tag, the complete three-artifact matrix (`1Helm--arm64.dmg`, `1Helm--mac-arm64.zip`, `1Helm--linux-node.tgz`), and clean installation evidence on macOS, Linux, and Windows | +| Named desktop release | One version/commit, changelog, full numbered notes, exact tag, the complete four-artifact application matrix (`1Helm--arm64.dmg`, `1Helm--mac-arm64.zip`, online `1Helm--linux-node.tgz`, offline `1Helm--linux-node-offline.tgz`), an exact immutable shared-image manifest/provenance, and clean installation evidence on macOS, Linux, and Windows | | Mac host update | Published notarized/stapled updater ZIP feed, installed-old-to-new acceptance, and preserved Application Support | | Linux host update | Digest-qualified artifact, real systemd install/update, health check/rollback, and preserved `/var/lib/1helm-oci-v1` | | Windows host | No artifact and no signing status. Install from `https://1helm.com/install.ps1` in a non-elevated PowerShell window with a single UAC prompt, the mid-install restart and resume, a keepalive surviving a reboot, `http://localhost:8123` reached from a browser, a prior-version update through the in-distribution Linux updater with `/var/lib/1helm-oci-v1` retained, and removal via `uninstall.ps1` | diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md index ad1b52b..22d4389 100644 --- a/docs/release-notes-template.md +++ b/docs/release-notes-template.md @@ -24,12 +24,18 @@ so plainly instead of silently omitting it. | `1Helm-x.y.z-arm64.dmg` | `` | | `1Helm-x.y.z-mac-arm64.zip` | `` | | `1Helm-x.y.z-linux-node.tgz` | `` | +| `1Helm-x.y.z-linux-node-offline.tgz` | `` | -These three rows are the whole desktop matrix. Every one is mandatory and must +These four rows are the whole application desktop matrix. Every one is mandatory and must resolve to the same version and source commit. “Not applicable” is forbidden for macOS or Linux. If any row is unavailable, this release must remain unpublished. A release is complete only once macOS, Linux, and Windows have each been accepted. +Shared channel image: `sha256:` (``, contract v``, +`` bytes). It is retained in its separate immutable digest-addressed +Release with its manifest and provenance and is not duplicated in this +application Release. + **Windows publishes no artifact.** A Windows host is the Linux host running inside a per-user WSL 2 distribution named `1helm`, installed with one command in an ordinary PowerShell window: diff --git a/ops/dress-rehearsal/1helm-candidate-install b/ops/dress-rehearsal/1helm-candidate-install index ffcdba7..35488db 100755 --- a/ops/dress-rehearsal/1helm-candidate-install +++ b/ops/dress-rehearsal/1helm-candidate-install @@ -27,7 +27,7 @@ if [[ -f "$LOCAL_PROOF_MARKER" && ! -L "$LOCAL_PROOF_MARKER" \ unlink "$LOCAL_PROOF_MARKER" fi [[ -x "$BOUNDARY" ]] || { echo "The root-owned candidate validator is missing." >&2; exit 1; } -for file in candidate.json candidate.tgz; do +for file in candidate.json candidate.tgz candidate-offline.tgz; do [[ -f "$INBOX/$file" && ! -L "$INBOX/$file" ]] || { echo "Candidate inbox is incomplete." >&2; exit 1; } done @@ -37,6 +37,7 @@ work="$(mktemp -d /var/lib/1helm-candidate/.install.XXXXXX)" trap 'rm -rf -- "$work"' EXIT install -o root -g root -m 0600 "$INBOX/candidate.json" "$work/candidate.json" install -o root -g root -m 0600 "$INBOX/candidate.tgz" "$work/candidate.tgz" +install -o root -g root -m 0600 "$INBOX/candidate-offline.tgz" "$work/candidate-offline.tgz" if [[ "$LOCAL_PROOF" -eq 0 ]]; then [[ -f "$INBOX/provenance.bundle.json" && ! -L "$INBOX/provenance.bundle.json" ]] || { echo "Signed candidate provenance is required." >&2; exit 1; } install -o root -g root -m 0600 "$INBOX/provenance.bundle.json" "$work/provenance.bundle.json" @@ -45,9 +46,10 @@ fi # can then create a fresh set without owning retained candidate bytes. unlink "$INBOX/candidate.json" unlink "$INBOX/candidate.tgz" +unlink "$INBOX/candidate-offline.tgz" [[ "$LOCAL_PROOF" -eq 1 ]] || unlink "$INBOX/provenance.bundle.json" -validate_args=(validate "$work/candidate.json" "$work/candidate.tgz" "$work/verified.json") +validate_args=(validate "$work/candidate.json" "$work/candidate.tgz" "$work/candidate-offline.tgz" "$work/verified.json") [[ "$LOCAL_PROOF" -eq 1 ]] && validate_args+=(--allow-local) python3 "$BOUNDARY" "${validate_args[@]}" commit="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["source"]["commit"])' "$work/verified.json")" @@ -74,7 +76,8 @@ mkdir "$work/source" tar -xzf "$work/candidate.tgz" -C "$work/source" "$prefix/site/public/install.sh" set +e -HELM_RELEASE_SHA256="$digest" bash "$work/source/$prefix/site/public/install.sh" "$work/candidate.tgz" >"$log" 2>&1 +offline_digest="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["offline_bundle"]["sha256"])' "$work/verified.json")" +HELM_RELEASE_SHA256="$offline_digest" bash "$work/source/$prefix/site/public/install.sh" "$work/candidate-offline.tgz" >"$log" 2>&1 install_status=$? set -e result=failed diff --git a/ops/dress-rehearsal/candidate-boundary.py b/ops/dress-rehearsal/candidate-boundary.py index 6927858..51c9176 100755 --- a/ops/dress-rehearsal/candidate-boundary.py +++ b/ops/dress-rehearsal/candidate-boundary.py @@ -51,7 +51,7 @@ def expect(value, pattern, label: str) -> str: return text -def validate(manifest_path: Path, archive_path: Path, allow_local: bool) -> dict: +def validate(manifest_path: Path, archive_path: Path, offline_path: Path, allow_local: bool) -> dict: manifest = load_json(manifest_path) if manifest.get("schema") != 1 or manifest.get("kind") != KIND: fail("candidate manifest schema or kind mismatch") @@ -86,6 +86,15 @@ def validate(manifest_path: Path, archive_path: Path, allow_local: bool) -> dict if sha256_stream(stream) != archive_sha: fail("candidate archive SHA-256 mismatch") oci_sha = expect((manifest.get("sealed_oci") or {}).get("sha256"), HEX64, "sealed OCI digest") + offline = manifest.get("offline_bundle") or {} + if offline.get("name") != f"1Helm-{version}-linux-node-offline.tgz": + fail("candidate offline bundle name/version mismatch") + offline_sha = expect(offline.get("sha256"), HEX64, "offline bundle digest") + if not offline_path.is_file() or offline_path.stat().st_size != offline.get("bytes"): + fail("candidate offline bundle size mismatch") + with offline_path.open("rb") as stream: + if sha256_stream(stream) != offline_sha: + fail("candidate offline bundle SHA-256 mismatch") with tarfile.open(archive_path, "r:gz") as archive: members = archive.getmembers() @@ -96,16 +105,33 @@ def validate(manifest_path: Path, archive_path: Path, allow_local: bool) -> dict identity_members = [member for member in members if re.fullmatch(r"[^/]+/resources/candidate-build\.json", member.name)] package_members = [member for member in members if re.fullmatch(r"[^/]+/package\.json", member.name)] oci_members = [member for member in members if re.fullmatch(r"[^/]+/container/channel-machine\.oci\.tar", member.name)] - if len(identity_members) != 1 or len(package_members) != 1 or len(oci_members) != 1: - fail("candidate archive identity/package/sealed OCI layout mismatch") + image_manifests = [member for member in members if re.fullmatch(r"[^/]+/resources/channel-image\.json", member.name)] + if len(identity_members) != 1 or len(package_members) != 1 or len(image_manifests) != 1 or oci_members: + fail("online candidate archive identity/package/split OCI layout mismatch") try: identity = json.load(archive.extractfile(identity_members[0])) package = json.load(archive.extractfile(package_members[0])) except (TypeError, json.JSONDecodeError) as error: fail(f"candidate embedded identity is invalid: {error}") + try: + embedded_image = json.load(archive.extractfile(image_manifests[0])) + except (TypeError, json.JSONDecodeError) as error: + fail(f"candidate channel image manifest is invalid: {error}") + if embedded_image != identity.get("channel_image") or embedded_image != manifest.get("sealed_oci"): + fail("channel image manifest does not match the candidate identity") + + with tarfile.open(offline_path, "r:gz") as archive: + members = archive.getmembers() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or member.isdev(): + fail("candidate offline bundle contains an unsafe entry") + oci_members = [member for member in members if re.fullmatch(r"[^/]+/container/channel-machine\.oci\.tar", member.name)] + if len(oci_members) != 1: + fail("candidate offline bundle must contain exactly one sealed OCI image") embedded_oci = archive.extractfile(oci_members[0]) if embedded_oci is None or sha256_stream(embedded_oci) != oci_sha: - fail("sealed OCI bytes do not match the candidate identity") + fail("offline sealed OCI bytes do not match the candidate identity") comparisons = { "schema": 1, @@ -197,6 +223,7 @@ def main() -> None: check = sub.add_parser("validate") check.add_argument("manifest", type=Path) check.add_argument("archive", type=Path) + check.add_argument("offline", type=Path) check.add_argument("output", type=Path) check.add_argument("--allow-local", action="store_true") evidence = sub.add_parser("record") @@ -212,7 +239,7 @@ def main() -> None: args = parser.parse_args() try: if args.command == "validate": - value = validate(args.manifest, args.archive, args.allow_local) + value = validate(args.manifest, args.archive, args.offline, args.allow_local) args.output.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") elif args.command == "record": record(args.manifest, args.previous, args.output, args.result, args.health, args.rollback, args.message) diff --git a/ops/platform-acceptance/linux.sh b/ops/platform-acceptance/linux.sh index 4ffee81..7e31e57 100755 --- a/ops/platform-acceptance/linux.sh +++ b/ops/platform-acceptance/linux.sh @@ -3,12 +3,15 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ARCHIVE="${HELM_CANDIDATE_ARCHIVE:?exact Linux candidate archive is required}" +OFFLINE_ARCHIVE="${HELM_CANDIDATE_OFFLINE_ARCHIVE:?exact Linux offline 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")" +OFFLINE_DIGEST="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).offline_bundle.sha256' "$MANIFEST")" +IMAGE_DIGEST="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1])).sealed_oci.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")" @@ -23,23 +26,30 @@ 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; } +[[ "$(sha256sum "$OFFLINE_ARCHIVE" | awk '{print $1}')" == "$OFFLINE_DIGEST" ]] \ + || { echo "Linux offline 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 +gh attestation verify "$OFFLINE_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 }')" +prefix="$(tar -tzf "$OFFLINE_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" +tar -xzf "$OFFLINE_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 env HELM_RELEASE_SHA256="$OFFLINE_DIGEST" bash "$work/$prefix/site/public/install.sh" "$OFFLINE_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" ]] +[[ "$(readlink -f /opt/1helm/current)" == "/opt/1helm/releases/$VERSION-$OFFLINE_DIGEST" ]] +RETAINED_IMAGE="/var/lib/1helm-oci-v1/shared-images/sha256/$IMAGE_DIGEST" +[[ -d "$RETAINED_IMAGE" && "$(find "$RETAINED_IMAGE" -maxdepth 1 -type f -name '*.oci.tar' -exec sha256sum {} \; | awk '{print $1}')" == "$IMAGE_DIGEST" ]] # Resolve the newest immutable public Stable release distinct from this # candidate version. Candidate versions normally remain unchanged during @@ -83,7 +93,10 @@ 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 mkdir -p "$CANDIDATE_RELEASE.tmp" +sudo tar -xzf "$ARCHIVE" -C "$CANDIDATE_RELEASE.tmp" --strip-components=1 +sudo chown -R 1helm:1helm "$CANDIDATE_RELEASE.tmp" +sudo mv "$CANDIDATE_RELEASE.tmp" "$CANDIDATE_RELEASE" 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 @@ -104,6 +117,7 @@ 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" ]] +[[ -d "$RETAINED_IMAGE" && "$(find "$RETAINED_IMAGE" -maxdepth 1 -type f -name '*.oci.tar' -exec sha256sum {} \; | awk '{print $1}')" == "$IMAGE_DIGEST" ]] sudo rm -rf -- "$FAILURE_RELEASE" export HELM_PREVIOUS_VERSION="$PREVIOUS_VERSION" diff --git a/ops/platform-acceptance/windows.ps1 b/ops/platform-acceptance/windows.ps1 index 06b3bbc..33807b8 100644 --- a/ops/platform-acceptance/windows.ps1 +++ b/ops/platform-acceptance/windows.ps1 @@ -4,6 +4,7 @@ param() $ErrorActionPreference = 'Stop' $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $Archive = (Resolve-Path $env:HELM_CANDIDATE_ARCHIVE).Path +$OfflineArchive = (Resolve-Path $env:HELM_CANDIDATE_OFFLINE_ARCHIVE).Path $ManifestPath = (Resolve-Path $env:HELM_CANDIDATE_MANIFEST).Path $ProvenancePath = (Resolve-Path $env:HELM_CANDIDATE_PROVENANCE).Path $Output = $env:HELM_ACCEPTANCE_OUTPUT @@ -12,6 +13,7 @@ $Manifest = Get-Content -Raw $ManifestPath | ConvertFrom-Json $Commit = [string]$Manifest.source.commit $Version = [string]$Manifest.version $Digest = [string]$Manifest.artifact.sha256 +$OfflineDigest = [string]$Manifest.offline_bundle.sha256 $CiRun = [string]$Manifest.ci.run_id $Wsl = Join-Path $env:SystemRoot 'System32\wsl.exe' $Distro = '1helm-phase4' @@ -62,10 +64,15 @@ if ($Provision.schema -ne 1 -or $Provision.kind -ne '1helm-windows-runner-provis 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' } +if ((Get-FileHash $OfflineArchive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $OfflineDigest) { Refuse 'Linux offline 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' } +gh attestation verify $OfflineArchive --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 offline 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' } @@ -82,8 +89,8 @@ try { # 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 ` + -Distro $Distro -InstallRoot $InstallRoot -LocalArchive $OfflineArchive ` + -LocalInstaller (Join-Path $Root 'site\public\install.sh') -LocalArchiveSha256 $OfflineDigest ` -LocalRootfs $Rootfs -LocalRootfsSha256 $RootfsSha ` -KeepaliveSource $KeepaliveSource if ($LASTEXITCODE -ne 0) { Refuse "exact candidate clean install failed with exit $LASTEXITCODE" } diff --git a/package.json b/package.json index c4dabd3..5df8d88 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,12 @@ "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:phase5": "node --test test/phase5-artifacts.mjs test/phase4-platform-acceptance.mjs test/phase3-promotion.mjs test/site-stable-manifest.mjs", "test:fast": "node scripts/run-fast-tests.mjs", "delivery:status": "node scripts/delivery-status.mjs", "stable:status": "node scripts/promotion-status.mjs", "cleanup:report": "node scripts/cleanup-report.mjs", + "artifacts:report": "node scripts/artifact-size-report.mjs", "benchmark:autonomy": "node scripts/autonomy-benchmark.mjs", "helm": "node scripts/1helm-cli.mjs", "test:live": "node test/live-smoke.mjs", diff --git a/scripts/artifact-contract.mjs b/scripts/artifact-contract.mjs new file mode 100644 index 0000000..2f01ac8 --- /dev/null +++ b/scripts/artifact-contract.mjs @@ -0,0 +1,126 @@ +import { basename } from "node:path"; + +export const CHANNEL_IMAGE_KIND = "1helm-sealed-channel-image"; +export const CHANNEL_IMAGE_SCHEMA = 1; +export const CHANNEL_IMAGE_CONTRACT_VERSION = "1"; +export const LINUX_SPLIT_KIND = "1helm-linux-split-artifacts"; +export const LINUX_SPLIT_SCHEMA = 1; + +const HEX64 = /^[a-f0-9]{64}$/; +const ARCHITECTURES = new Set(["amd64", "arm64"]); + +export function channelImageArtifactName({ architecture, sha256 }) { + return `1Helm-channel-machine-v${CHANNEL_IMAGE_CONTRACT_VERSION}-${architecture}-${sha256}.oci.tar`; +} + +export function channelImageManifestName({ architecture, sha256 }) { + return `1Helm-channel-machine-v${CHANNEL_IMAGE_CONTRACT_VERSION}-${architecture}-${sha256}.json`; +} + +export function channelImageProvenanceName({ architecture, sha256 }) { + return `1Helm-channel-machine-v${CHANNEL_IMAGE_CONTRACT_VERSION}-${architecture}-${sha256}.provenance.json`; +} + +export function channelImageReleaseTag({ architecture, sha256 }) { + return `channel-image-v${CHANNEL_IMAGE_CONTRACT_VERSION}-${architecture}-${sha256}`; +} + +export function offlineBundleName(version) { + return `1Helm-${version}-linux-node-offline.tgz`; +} + +export function normalizeChannelImageManifest(value, { requireUrl = false } = {}) { + if (!value || Array.isArray(value) || value.schema !== CHANNEL_IMAGE_SCHEMA || value.kind !== CHANNEL_IMAGE_KIND) { + throw new Error("sealed channel image manifest schema or kind mismatch"); + } + const version = String(value.version || ""); + const architecture = String(value.architecture || ""); + const sha256 = String(value.sha256 || ""); + const bytes = Number(value.bytes); + if (version !== CHANNEL_IMAGE_CONTRACT_VERSION) throw new Error("sealed channel image contract version mismatch"); + if (!ARCHITECTURES.has(architecture)) throw new Error("sealed channel image architecture is unsupported"); + if (!HEX64.test(sha256)) throw new Error("sealed channel image SHA-256 is invalid"); + if (!Number.isSafeInteger(bytes) || bytes < 1) throw new Error("sealed channel image byte count is invalid"); + const expectedName = channelImageArtifactName({ architecture, sha256 }); + if (value.artifact?.name !== expectedName || basename(String(value.artifact?.name || "")) !== expectedName) { + throw new Error("sealed channel image artifact name is not digest-addressed"); + } + const url = value.artifact?.url == null ? null : String(value.artifact.url); + const manifestName = channelImageManifestName({ architecture, sha256 }); + const releaseTag = channelImageReleaseTag({ architecture, sha256 }); + const expectedUrl = `https://github.com/gitcommit90/1Helm/releases/download/${releaseTag}/${expectedName}`; + const manifestUrl = value.artifact?.manifest_url == null ? null : String(value.artifact.manifest_url); + const expectedManifestUrl = `https://github.com/gitcommit90/1Helm/releases/download/${releaseTag}/${manifestName}`; + if (requireUrl && (url !== expectedUrl || manifestUrl !== expectedManifestUrl)) { + throw new Error("sealed channel image release URLs are not immutable digest-addressed URLs"); + } + if (url != null && url !== expectedUrl) throw new Error("sealed channel image artifact URL mismatch"); + if (manifestUrl != null && manifestUrl !== expectedManifestUrl) throw new Error("sealed channel image manifest URL mismatch"); + const inputs = value.inputs || {}; + for (const field of ["containerfile_sha256", "context_sha256", "base_image_digest"]) { + if (!HEX64.test(String(inputs[field] || ""))) throw new Error(`sealed channel image ${field} is invalid`); + } + const cache = value.cache || {}; + if (!HEX64.test(String(cache.key || "")) || typeof cache.reused !== "boolean") { + throw new Error("sealed channel image cache provenance is incomplete"); + } + if (!Array.isArray(value.platforms) || value.platforms.length !== 2 + || value.platforms[0] !== "linux" || value.platforms[1] !== "windows-wsl") { + throw new Error("sealed channel image platform contract is incomplete"); + } + return { + schema: CHANNEL_IMAGE_SCHEMA, + kind: CHANNEL_IMAGE_KIND, + version, + architecture, + sha256, + bytes, + artifact: { name: expectedName, ...(url ? { url, manifest_url: manifestUrl } : {}) }, + platforms: ["linux", "windows-wsl"], + inputs: { + containerfile_sha256: String(inputs.containerfile_sha256), + context_sha256: String(inputs.context_sha256), + base_image_digest: String(inputs.base_image_digest), + }, + cache: { key: String(cache.key), reused: cache.reused }, + }; +} + +export function releasedChannelImageManifest(value) { + const manifest = normalizeChannelImageManifest(value); + const architecture = manifest.architecture; + const sha256 = manifest.sha256; + const tag = channelImageReleaseTag({ architecture, sha256 }); + return normalizeChannelImageManifest({ + ...manifest, + // Reuse is a property of a particular candidate build, not of the sealed + // bytes. Keep the immutable release manifest canonical and record the + // actual build reuse alongside candidate provenance instead. + cache: { ...manifest.cache, reused: false }, + artifact: { + name: channelImageArtifactName({ architecture, sha256 }), + url: `https://github.com/gitcommit90/1Helm/releases/download/${tag}/${channelImageArtifactName({ architecture, sha256 })}`, + manifest_url: `https://github.com/gitcommit90/1Helm/releases/download/${tag}/${channelImageManifestName({ architecture, sha256 })}`, + }, + }, { requireUrl: true }); +} + +export function validateSplitArtifactManifest(value, { version, architecture } = {}) { + if (!value || value.schema !== LINUX_SPLIT_SCHEMA || value.kind !== LINUX_SPLIT_KIND) { + throw new Error("Linux split artifact manifest schema or kind mismatch"); + } + if (version && value.version !== version) throw new Error("Linux split artifact version mismatch"); + const app = value.app || {}; + const offline = value.offline || {}; + if (!/^\d+\.\d+\.\d+$/.test(String(value.version || "")) + || app.name !== `1Helm-${value.version}-linux-node.tgz` + || offline.name !== offlineBundleName(value.version) + || !HEX64.test(String(app.sha256 || "")) || !HEX64.test(String(offline.sha256 || "")) + || !Number.isSafeInteger(app.bytes) || app.bytes < 1 + || !Number.isSafeInteger(offline.bytes) || offline.bytes < 1) { + throw new Error("Linux split artifact byte identities are incomplete"); + } + const channelImage = normalizeChannelImageManifest(value.channel_image); + if (architecture && channelImage.architecture !== architecture) throw new Error("Linux split channel image architecture mismatch"); + return { ...value, app: { ...app }, offline: { ...offline }, channel_image: channelImage }; +} diff --git a/scripts/artifact-size-report.mjs b/scripts/artifact-size-report.mjs new file mode 100755 index 0000000..d2802b9 --- /dev/null +++ b/scripts/artifact-size-report.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + existsSync, lstatSync, mkdtempSync, openSync, closeSync, readFileSync, readSync, + readdirSync, rmSync, statSync, writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, relative, resolve, sep } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const budgets = JSON.parse(readFileSync(join(root, "config", "artifact-budgets.json"), "utf8")); +const version = String(pkg.version); +const argv = process.argv.slice(2); +const option = (name, fallback = "") => { + const index = argv.indexOf(name); + return index < 0 ? fallback : String(argv[index + 1] || ""); +}; +const jsonOutput = resolve(option("--json", join(root, "dist", "artifact-size-report.json"))); +const textOutput = option("--text") ? resolve(option("--text")) : ""; +const checkBudgets = argv.includes("--check"); +const inputs = { + linux_app_tgz: option("--linux-app", join(root, "dist", `1Helm-${version}-linux-node.tgz`)), + linux_offline_tgz: option("--linux-offline", join(root, "dist", `1Helm-${version}-linux-node-offline.tgz`)), + sealed_oci_image: option("--oci", join(root, "container", "channel-machine.oci.tar")), + mac_dmg: option("--mac-dmg", join(root, "dist", `1Helm-${version}-arm64.dmg`)), + mac_zip: option("--mac-zip", join(root, "dist", `1Helm-${version}-mac-arm64.zip`)), + vendored_dependencies: option("--vendored", join(root, "node_modules")), + client_assets: option("--client", join(root, "public")), +}; + +function digestFile(path) { + const hash = createHash("sha256"); + const fd = openSync(path, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { let count; while ((count = readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count)); } + finally { closeSync(fd); } + return hash.digest("hex"); +} + +function files(directory, base = "") { + const result = []; + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const rel = base ? `${base}/${entry.name}` : entry.name; + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) result.push(...files(path, rel)); + else if (entry.isFile()) result.push({ path, relative: rel, bytes: statSync(path).size }); + } + return result; +} + +function directoryRecord(path, budgetKey) { + if (!existsSync(path) || !lstatSync(path).isDirectory()) return { status: "missing" }; + const records = files(path); + const bytes = records.reduce((sum, file) => sum + file.bytes, 0); + return { + status: "present", bytes, files: records.length, + budget: comparison(bytes, budgets.budgets[budgetKey]), + }; +} + +function comparison(actual, ceiling) { + if (!Number.isSafeInteger(ceiling)) return null; + return { ceiling, delta: actual - ceiling, passed: actual <= ceiling }; +} + +function compositionFor(directory) { + const groups = { sealed_oci: 0, vendored_dependencies: 0, client_assets: 0, other_runtime: 0 }; + let count = 0; + for (const file of files(directory)) { + count += 1; + const rel = file.relative.replace(/^[^/]+\//, ""); + if (rel === "container/channel-machine.oci.tar" || rel.endsWith("/container/channel-machine.oci.tar")) groups.sealed_oci += file.bytes; + else if (rel.startsWith("node_modules/") || rel.includes("/node_modules/")) groups.vendored_dependencies += file.bytes; + else if (rel.startsWith("public/") || rel.includes("/public/") || rel === "desktop/photon-sidecar.bundle.mjs" || rel.endsWith("/desktop/photon-sidecar.bundle.mjs")) groups.client_assets += file.bytes; + else groups.other_runtime += file.bytes; + } + return { files: count, unpacked_bytes: Object.values(groups).reduce((sum, value) => sum + value, 0), groups }; +} + +function archiveRecord(path, type, budgetKey) { + if (!existsSync(path) || !lstatSync(path).isFile()) return { status: "missing" }; + const bytes = statSync(path).size; + const record = { status: "present", name: basename(path), bytes, sha256: digestFile(path), budget: comparison(bytes, budgets.budgets[budgetKey]) }; + if (type === "tgz") { + const scratch = mkdtempSync(join(tmpdir(), "1helm-composition-")); + try { + const extract = spawnSync("tar", ["-xzf", path, "-C", scratch], { stdio: "pipe", maxBuffer: 16 * 1024 * 1024 }); + if (extract.status !== 0) throw new Error(`tar refused ${basename(path)}: ${String(extract.stderr || "").trim()}`); + record.composition = compositionFor(scratch); + } finally { rmSync(scratch, { recursive: true, force: true }); } + } else if (type === "oci") { + const listing = spawnSync("tar", ["-tf", path], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + record.composition = listing.status === 0 ? { + entries: String(listing.stdout).trim().split("\n").filter(Boolean).length, + blobs: String(listing.stdout).trim().split("\n").filter((entry) => /(^|\/)blobs\/sha256\/[a-f0-9]{64}$/.test(entry)).length, + } : { status: "unreadable" }; + } else if (type === "zip") { + const scratch = mkdtempSync(join(tmpdir(), "1helm-zip-composition-")); + try { + const extract = spawnSync("unzip", ["-qq", path, "-d", scratch], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + record.composition = extract.status === 0 ? compositionFor(scratch) : { status: "unavailable" }; + } finally { rmSync(scratch, { recursive: true, force: true }); } + } else if (type === "dmg") { + const scratch = mkdtempSync(join(tmpdir(), "1helm-dmg-composition-")); + const mount = join(scratch, "mount"); + let attached = false; + try { + const attach = spawnSync("hdiutil", ["attach", "-readonly", "-nobrowse", "-mountpoint", mount, path], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + attached = attach.status === 0; + record.composition = attached ? compositionFor(mount) : { status: "unavailable" }; + } finally { + if (attached) spawnSync("hdiutil", ["detach", mount], { stdio: "ignore" }); + rmSync(scratch, { recursive: true, force: true }); + } + } + return record; +} + +function duplicateRecord(paths) { + const candidates = []; + for (const [scope, path] of Object.entries(paths)) { + if (!existsSync(path) || !lstatSync(path).isDirectory()) continue; + for (const file of files(path)) if (file.bytes > 0) candidates.push({ ...file, scope }); + } + const bySize = new Map(); + for (const file of candidates) { + const group = bySize.get(file.bytes) || []; + group.push(file); bySize.set(file.bytes, group); + } + const duplicates = []; + for (const [bytes, group] of bySize) { + if (group.length < 2) continue; + const hashes = new Map(); + for (const file of group) { + const digest = digestFile(file.path); + const matches = hashes.get(digest) || []; + matches.push(file); hashes.set(digest, matches); + } + for (const [sha256, matches] of hashes) if (matches.length > 1) duplicates.push({ + sha256, bytes_each: bytes, copies: matches.length, duplicate_bytes: bytes * (matches.length - 1), + files: matches.map((file) => `${file.scope}/${file.relative}`).sort(), + }); + } + duplicates.sort((a, b) => b.duplicate_bytes - a.duplicate_bytes || a.sha256.localeCompare(b.sha256)); + const duplicateBytes = duplicates.reduce((sum, item) => sum + item.duplicate_bytes, 0); + return { bytes: duplicateBytes, groups: duplicates.length, largest_groups: duplicates.slice(0, 20), budget: comparison(duplicateBytes, budgets.budgets.duplicate_bytes) }; +} + +const artifacts = { + linux_app_tgz: archiveRecord(inputs.linux_app_tgz, "tgz", "linux_app_tgz"), + linux_offline_tgz: archiveRecord(inputs.linux_offline_tgz, "tgz", "linux_offline_tgz"), + sealed_oci_image: archiveRecord(inputs.sealed_oci_image, "oci", "sealed_oci_image"), + mac_dmg: archiveRecord(inputs.mac_dmg, "dmg", "mac_dmg"), + mac_zip: archiveRecord(inputs.mac_zip, "zip", "mac_zip"), +}; +let trees; +let duplicates; +if (artifacts.linux_app_tgz.status === "present") { + const scratch = mkdtempSync(join(tmpdir(), "1helm-packaged-trees-")); + try { + const extract = spawnSync("tar", ["-xzf", inputs.linux_app_tgz, "-C", scratch], { stdio: "pipe", maxBuffer: 16 * 1024 * 1024 }); + if (extract.status !== 0) throw new Error(`tar refused ${basename(inputs.linux_app_tgz)}`); + const top = readdirSync(scratch, { withFileTypes: true }).filter((entry) => entry.isDirectory()); + if (top.length !== 1) throw new Error("Linux app archive must have exactly one top-level directory"); + const packaged = join(scratch, top[0].name); + trees = { + vendored_dependencies: directoryRecord(join(packaged, "node_modules"), "linux_unpacked_node_modules"), + client_assets: directoryRecord(join(packaged, "public"), "linux_client_assets"), + }; + duplicates = duplicateRecord({ vendored_dependencies: join(packaged, "node_modules"), client_assets: join(packaged, "public") }); + } finally { rmSync(scratch, { recursive: true, force: true }); } +} else { + trees = { + vendored_dependencies: directoryRecord(inputs.vendored_dependencies, "linux_unpacked_node_modules"), + client_assets: directoryRecord(inputs.client_assets, "linux_client_assets"), + }; + duplicates = duplicateRecord({ vendored_dependencies: inputs.vendored_dependencies, client_assets: inputs.client_assets }); +} +const baselineComparison = { + linux_online_cold_vs_legacy_complete: artifacts.linux_app_tgz.status === "present" && artifacts.sealed_oci_image.status === "present" ? { + baseline: budgets.baselines.legacy_linux_complete_tgz, + bytes: artifacts.linux_app_tgz.bytes + artifacts.sealed_oci_image.bytes, + delta: artifacts.linux_app_tgz.bytes + artifacts.sealed_oci_image.bytes - budgets.baselines.legacy_linux_complete_tgz, + savings: budgets.baselines.legacy_linux_complete_tgz - artifacts.linux_app_tgz.bytes - artifacts.sealed_oci_image.bytes, + savings_percent: Number((((budgets.baselines.legacy_linux_complete_tgz - artifacts.linux_app_tgz.bytes - artifacts.sealed_oci_image.bytes) / budgets.baselines.legacy_linux_complete_tgz) * 100).toFixed(2)), + } : null, + linux_app_vs_legacy_complete: artifacts.linux_app_tgz.status === "present" ? { + baseline: budgets.baselines.legacy_linux_complete_tgz, + delta: artifacts.linux_app_tgz.bytes - budgets.baselines.legacy_linux_complete_tgz, + savings: budgets.baselines.legacy_linux_complete_tgz - artifacts.linux_app_tgz.bytes, + savings_percent: Number((((budgets.baselines.legacy_linux_complete_tgz - artifacts.linux_app_tgz.bytes) / budgets.baselines.legacy_linux_complete_tgz) * 100).toFixed(2)), + } : null, + linux_offline_vs_legacy_complete: artifacts.linux_offline_tgz.status === "present" ? { + baseline: budgets.baselines.legacy_linux_complete_tgz, + delta: artifacts.linux_offline_tgz.bytes - budgets.baselines.legacy_linux_complete_tgz, + savings: budgets.baselines.legacy_linux_complete_tgz - artifacts.linux_offline_tgz.bytes, + savings_percent: Number((((budgets.baselines.legacy_linux_complete_tgz - artifacts.linux_offline_tgz.bytes) / budgets.baselines.legacy_linux_complete_tgz) * 100).toFixed(2)), + } : null, +}; +const report = { schema: 1, kind: "1helm-artifact-size-report", version, artifacts, trees, duplicates, baseline_comparison: baselineComparison, baselines: budgets.baselines, budgets: budgets.budgets }; +const lines = ["1Helm artifact size report", ` Version: ${version}`]; +for (const [name, record] of Object.entries(artifacts)) lines.push(` ${name}: ${record.status === "present" ? `${record.bytes.toLocaleString("en-US")} bytes${record.budget ? ` (${record.budget.passed ? "within" : "OVER"} budget)` : ""}` : "not present"}`); +for (const [name, record] of Object.entries(trees)) lines.push(` ${name}: ${record.status === "present" ? `${record.bytes.toLocaleString("en-US")} unpacked bytes${record.budget ? ` (${record.budget.passed ? "within" : "OVER"} budget)` : ""}` : "not present"}`); +lines.push(` duplicate_bytes: ${duplicates.bytes.toLocaleString("en-US")} bytes across ${duplicates.groups} content groups (${duplicates.budget.passed ? "within" : "OVER"} budget)`); +if (baselineComparison.linux_online_cold_vs_legacy_complete) lines.push(` Linux cold online total (app + image): ${baselineComparison.linux_online_cold_vs_legacy_complete.bytes.toLocaleString("en-US")} bytes; saving vs v0.0.41 complete: ${baselineComparison.linux_online_cold_vs_legacy_complete.savings.toLocaleString("en-US")} bytes (${baselineComparison.linux_online_cold_vs_legacy_complete.savings_percent}%)`); +if (baselineComparison.linux_app_vs_legacy_complete) lines.push(` Linux online saving vs v0.0.41 complete: ${baselineComparison.linux_app_vs_legacy_complete.savings.toLocaleString("en-US")} bytes (${baselineComparison.linux_app_vs_legacy_complete.savings_percent}%)`); +if (baselineComparison.linux_offline_vs_legacy_complete) lines.push(` Linux offline saving vs v0.0.41 complete: ${baselineComparison.linux_offline_vs_legacy_complete.savings.toLocaleString("en-US")} bytes (${baselineComparison.linux_offline_vs_legacy_complete.savings_percent}%)`); +const plain = `${lines.join("\n")}\n`; +const parent = resolve(jsonOutput, ".."); +if (!existsSync(parent)) throw new Error(`report output directory does not exist: ${relative(root, parent).split(sep).join("/")}`); +writeFileSync(jsonOutput, `${JSON.stringify(report, null, 2)}\n`); +if (textOutput) writeFileSync(textOutput, plain); +process.stdout.write(plain); +if (checkBudgets) { + const records = [...Object.values(artifacts), ...Object.values(trees), duplicates]; + const failed = records.filter((record) => record?.status !== "missing" && record?.budget && !record.budget.passed); + if (failed.length) { + process.stderr.write(`${failed.length} present artifact/composition budget(s) exceeded.\n`); + process.exitCode = 1; + } +} diff --git a/scripts/build-oci-channel-image.sh b/scripts/build-oci-channel-image.sh index ddcc82d..e2c7e4d 100755 --- a/scripts/build-oci-channel-image.sh +++ b/scripts/build-oci-channel-image.sh @@ -1,57 +1,143 @@ #!/usr/bin/env bash -# Build the sealed Linux/Windows channel-computer image once on a builder host. -# Output is a digest-pinned OCI archive next to the recipe — not for git. -# Apple/Mac channel machines are unaffected. +# Build or reuse the immutable Linux/Windows channel-computer image. Application +# versions deliberately are not an input: unchanged image source produces the +# same cache key and digest-addressed release candidate. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT/package.json" | head -n1)" -[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "package.json version is required" >&2; exit 1; } +CONTAINERFILE="$ROOT/container/Containerfile.oci" +OUT_TAR="$ROOT/container/channel-machine.oci.tar" +OUT_SHA="$ROOT/container/channel-machine.oci.sha256" +OUT_META="$ROOT/container/channel-machine.oci.json" +CACHE_ROOT="${HELM_OCI_CACHE_DIR:-$ROOT/dist/cache/channel-images}" case "$(uname -m)" in x86_64|amd64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; *) echo "Unsupported builder architecture: $(uname -m)" >&2; exit 1 ;; esac - -IMAGE_REF="local/1helm-channel-machine:${VERSION}" -ENGINE_IMAGE="localhost/${IMAGE_REF}" -CONTAINERFILE="$ROOT/container/Containerfile.oci" -OUT_TAR="$ROOT/container/channel-machine.oci.tar" -OUT_SHA="$ROOT/container/channel-machine.oci.sha256" -OUT_META="$ROOT/container/channel-machine.oci.json" - [[ -r "$CONTAINERFILE" ]] || { echo "Missing $CONTAINERFILE" >&2; exit 1; } command -v podman >/dev/null || { echo "podman is required to build the sealed channel image" >&2; exit 1; } -# Host network avoids Docker FORWARD=DROP and broken IPv6 on many builder hosts. -# This is builder-only; customer hosts load the sealed archive and never apt. -echo "Building sealed channel image ${IMAGE_REF} (${ARCH})…" -podman build --network=host --pull=missing \ - --build-arg AGENT_UID=1000 --build-arg AGENT_GID=1000 \ - --tag "$ENGINE_IMAGE" \ - --file "$CONTAINERFILE" \ - "$ROOT/container" +BASE_DIGEST="$(sed -n 's/^FROM .*@sha256:\([a-f0-9]\{64\}\)$/\1/p' "$CONTAINERFILE")" +[[ "$BASE_DIGEST" =~ ^[a-f0-9]{64}$ ]] || { echo "Containerfile.oci must pin one base image by SHA-256" >&2; exit 1; } +CONTAINERFILE_SHA="$(sha256sum "$CONTAINERFILE" | awk '{print $1}')" +CONTEXT_SHA="$( + cd "$ROOT" + git ls-files -z container \ + | while IFS= read -r -d '' file; do + case "$file" in + container/channel-machine.oci.tar|container/channel-machine.oci.sha256|container/channel-machine.oci.json) continue ;; + esac + printf '%s\0' "$file" + sha256sum "$file" | awk '{printf "%s\0", $1}' + done \ + | sha256sum | awk '{print $1}' +)" +CACHE_KEY="$(printf '1helm-channel-image-v1\n%s\n%s\n%s\n%s\n' "$ARCH" "$BASE_DIGEST" "$CONTAINERFILE_SHA" "$CONTEXT_SHA" | sha256sum | awk '{print $1}')" +CACHE_TAR="$CACHE_ROOT/$CACHE_KEY.oci.tar" +CACHE_META="$CACHE_ROOT/$CACHE_KEY.json" +CACHE_REUSED=false + +validate_cache() { + [[ -f "$CACHE_TAR" && -f "$CACHE_META" ]] || return 1 + python3 - "$CACHE_META" "$CACHE_TAR" "$CACHE_KEY" "$ARCH" "$BASE_DIGEST" "$CONTAINERFILE_SHA" "$CONTEXT_SHA" <<'PY' +import hashlib, json, os, sys +meta_path, archive, key, arch, base, containerfile, context = sys.argv[1:] +try: + meta = json.load(open(meta_path, encoding="utf-8")) +except (OSError, ValueError): + raise SystemExit(1) +h = hashlib.sha256() +with open(archive, "rb") as stream: + while chunk := stream.read(1024 * 1024): h.update(chunk) +valid = ( + meta.get("schema") == 1 and meta.get("kind") == "1helm-sealed-channel-image" + and meta.get("version") == "1" and meta.get("architecture") == arch + and meta.get("sha256") == h.hexdigest() and meta.get("bytes") == os.path.getsize(archive) + and (meta.get("cache") or {}).get("key") == key + and (meta.get("inputs") or {}).get("base_image_digest") == base + and (meta.get("inputs") or {}).get("containerfile_sha256") == containerfile + and (meta.get("inputs") or {}).get("context_sha256") == context +) +raise SystemExit(0 if valid else 1) +PY +} -podman image exists "$ENGINE_IMAGE" -rm -f -- "$OUT_TAR" "$OUT_SHA" "$OUT_META" -podman save --format oci-archive --output "$OUT_TAR" "$ENGINE_IMAGE" -sha256sum "$OUT_TAR" | awk '{print $1}' >"$OUT_SHA" -DIGEST="$(tr -d '[:space:]' <"$OUT_SHA")" -python3 - "$OUT_META" "$IMAGE_REF" "$ARCH" "$VERSION" "$DIGEST" "$OUT_TAR" <<'PY' -import json, os, sys -meta_path, image, arch, version, digest, archive = sys.argv[1:] +mkdir -p "$CACHE_ROOT" +if validate_cache; then + CACHE_REUSED=true + echo "Reusing sealed channel image cache key $CACHE_KEY" +else + [[ ! -e "$CACHE_TAR" && ! -e "$CACHE_META" ]] \ + || { echo "OCI cache key $CACHE_KEY exists but failed exact input/digest validation; refusing to overwrite it" >&2; exit 1; } + IMAGE_REF="localhost/local/1helm-channel-machine:input-$CACHE_KEY" + echo "Building sealed channel image input-$CACHE_KEY ($ARCH)…" + podman build --network=host --pull=missing \ + --build-arg AGENT_UID=1000 --build-arg AGENT_GID=1000 \ + --tag "$IMAGE_REF" --file "$CONTAINERFILE" "$ROOT/container" + podman image exists "$IMAGE_REF" + TEMP_TAR="$(mktemp "$CACHE_ROOT/.channel-image.XXXXXX.oci.tar")" + trap 'rm -f -- "${TEMP_TAR:-}" "${TEMP_META:-}"' EXIT + podman save --format oci-archive --output "$TEMP_TAR" "$IMAGE_REF" + DIGEST="$(sha256sum "$TEMP_TAR" | awk '{print $1}')" + BYTES="$(stat -c %s "$TEMP_TAR")" + ARTIFACT="1Helm-channel-machine-v1-$ARCH-$DIGEST.oci.tar" + TEMP_META="$(mktemp "$CACHE_ROOT/.channel-image.XXXXXX.json")" + python3 - "$TEMP_META" "$ARCH" "$DIGEST" "$BYTES" "$ARTIFACT" "$CACHE_KEY" "$CONTAINERFILE_SHA" "$CONTEXT_SHA" "$BASE_DIGEST" <<'PY' +import json, sys +path, arch, digest, size, artifact, key, containerfile, context, base = sys.argv[1:] json.dump({ - "image": image, - "arch": arch, - "version": version, + "schema": 1, + "kind": "1helm-sealed-channel-image", + "version": "1", + "architecture": arch, "sha256": digest, - "archive": os.path.basename(archive), - "backend": "oci", - "platforms": ["linux", "windows"], -}, open(meta_path, "w", encoding="utf-8"), indent=2) -print(meta_path) + "bytes": int(size), + "artifact": {"name": artifact}, + "platforms": ["linux", "windows-wsl"], + "inputs": { + "containerfile_sha256": containerfile, + "context_sha256": context, + "base_image_digest": base, + }, + "cache": {"key": key, "reused": False}, +}, open(path, "w", encoding="utf-8"), indent=2) +PY + chmod 0644 "$TEMP_TAR" "$TEMP_META" + mv "$TEMP_TAR" "$CACHE_TAR" + mv "$TEMP_META" "$CACHE_META" + TEMP_TAR="" TEMP_META="" +fi + +# The worktree pointers are staging inputs, never the immutable cache authority. +# Preserve any unrelated or mismatched local output instead of deleting it. +for existing in "$OUT_TAR" "$OUT_SHA" "$OUT_META"; do + [[ ! -e "$existing" ]] || { + if [[ "$existing" == "$OUT_TAR" ]] && cmp -s "$existing" "$CACHE_TAR"; then continue; fi + if [[ "$existing" == "$OUT_META" ]] && python3 - "$existing" "$CACHE_KEY" <<'PY' +import json, sys +try: value=json.load(open(sys.argv[1], encoding="utf-8")) +except Exception: raise SystemExit(1) +raise SystemExit(0 if (value.get("cache") or {}).get("key") == sys.argv[2] else 1) +PY + then continue; fi + if [[ "$existing" == "$OUT_SHA" ]] && [[ "$(tr -d '[:space:]' <"$existing")" == "$(sha256sum "$CACHE_TAR" | awk '{print $1}')" ]]; then continue; fi + echo "Refusing to overwrite existing mismatched local artifact: $existing" >&2 + exit 1 + } +done +[[ -e "$OUT_TAR" ]] || cp "$CACHE_TAR" "$OUT_TAR" +DIGEST="$(sha256sum "$CACHE_TAR" | awk '{print $1}')" +[[ -e "$OUT_SHA" ]] || printf '%s\n' "$DIGEST" >"$OUT_SHA" +META_CANDIDATE="$(mktemp "$ROOT/container/.channel-machine.XXXXXX.json")" +python3 - "$CACHE_META" "$META_CANDIDATE" "$CACHE_REUSED" <<'PY' +import json, sys +value=json.load(open(sys.argv[1], encoding="utf-8")) +value["cache"]["reused"] = sys.argv[3] == "true" +json.dump(value, open(sys.argv[2], "w", encoding="utf-8"), indent=2) PY +chmod 0644 "$META_CANDIDATE" +mv "$META_CANDIDATE" "$OUT_META" chmod 0644 "$OUT_TAR" "$OUT_SHA" "$OUT_META" -ls -lh "$OUT_TAR" "$OUT_SHA" "$OUT_META" -printf 'Sealed channel image ready: %s sha256=%s\n' "$OUT_TAR" "$DIGEST" +printf 'Sealed channel image ready: %s sha256=%s cache=%s reused=%s\n' "$OUT_TAR" "$DIGEST" "$CACHE_KEY" "$CACHE_REUSED" diff --git a/scripts/candidate-manifest.mjs b/scripts/candidate-manifest.mjs index 0d75ef4..26256b7 100755 --- a/scripts/candidate-manifest.mjs +++ b/scripts/candidate-manifest.mjs @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { readFileSync, statSync, writeFileSync } from "node:fs"; import { basename, resolve } from "node:path"; import { spawnSync } from "node:child_process"; +import { normalizeChannelImageManifest, validateSplitArtifactManifest } from "./artifact-contract.mjs"; export const CANDIDATE_KIND = "1helm-dress-rehearsal-candidate"; export const CANDIDATE_REPOSITORY = "gitcommit90/1Helm"; @@ -31,6 +32,13 @@ export function validateCandidateBuildIdentity(value, { allowLocal = false } = { : ci.workflow !== "local" || String(ci.run_id) !== "0" || ci.conclusion !== "not_run") { throw new Error("Candidate CI identity does not match its source state"); } + const channelImage = normalizeChannelImageManifest(value.channel_image, { requireUrl: true }); + const sealedOciSha256 = exactString(value.sealed_oci_sha256, /^[a-f0-9]{64}$/, "sealed OCI digest"); + if (channelImage.sha256 !== sealedOciSha256) throw new Error("Candidate sealed OCI digest does not match its channel image manifest"); + const sealedOciCache = value.sealed_oci_cache || {}; + if (sealedOciCache.key !== channelImage.cache.key || typeof sealedOciCache.reused !== "boolean") { + throw new Error("Candidate sealed OCI cache reuse provenance is incomplete"); + } return { ...value, commit: exactString(value.commit, /^[a-f0-9]{40}$/, "commit"), @@ -39,7 +47,9 @@ export function validateCandidateBuildIdentity(value, { allowLocal = false } = { created_at: exactString(value.created_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "creation time"), version: exactString(value.version, /^\d+\.\d+\.\d+$/, "version"), source_archive_sha256: exactString(value.source_archive_sha256, /^[a-f0-9]{64}$/, "source archive digest"), - sealed_oci_sha256: exactString(value.sealed_oci_sha256, /^[a-f0-9]{64}$/, "sealed OCI digest"), + sealed_oci_sha256: sealedOciSha256, + sealed_oci_cache: { key: String(sealedOciCache.key), reused: sealedOciCache.reused }, + channel_image: channelImage, ci: { workflow: ci.workflow, run_id: String(ci.run_id), conclusion: ci.conclusion }, }; } @@ -48,41 +58,56 @@ export function candidateIdentityFromArchive(archivePath, options = {}) { const listed = spawnSync("tar", ["-tzf", archivePath], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); if (listed.status !== 0) throw new Error("Candidate archive is not a readable gzip tar archive"); const entries = String(listed.stdout || "").trim().split("\n").filter(Boolean); - if (entries.some((entry) => entry.startsWith("/") || entry.split("/").includes(".."))) { - throw new Error("Candidate archive contains an unsafe path"); - } + if (entries.some((entry) => entry.startsWith("/") || entry.split("/").includes(".."))) throw new Error("Candidate archive contains an unsafe path"); const identities = entries.filter((entry) => /^[^/]+\/resources\/candidate-build\.json$/.test(entry)); - if (identities.length !== 1) throw new Error("Candidate archive must contain exactly one embedded build identity"); - const extracted = spawnSync("tar", ["-xOzf", archivePath, identities[0]], { encoding: "utf8", maxBuffer: 1024 * 1024 }); + const imageManifests = entries.filter((entry) => /^[^/]+\/resources\/channel-image\.json$/.test(entry)); + if (identities.length !== 1 || imageManifests.length !== 1) throw new Error("Candidate archive must contain exactly one build identity and channel image manifest"); + if (entries.some((entry) => /^[^/]+\/container\/channel-machine\.oci\.tar$/.test(entry))) { + throw new Error("Online candidate archive must not embed sealed OCI bytes"); + } + const extract = (entry) => spawnSync("tar", ["-xOzf", archivePath, entry], { encoding: "utf8", maxBuffer: 1024 * 1024 }); + const extracted = extract(identities[0]); if (extracted.status !== 0) throw new Error("Could not read the embedded candidate build identity"); let parsed; try { parsed = JSON.parse(String(extracted.stdout || "")); } catch { throw new Error("Embedded candidate build identity is not valid JSON"); } - return validateCandidateBuildIdentity(parsed, options); + const identity = validateCandidateBuildIdentity(parsed, options); + let image; + try { image = normalizeChannelImageManifest(JSON.parse(String(extract(imageManifests[0]).stdout || "")), { requireUrl: true }); } + catch (error) { throw new Error(`Embedded channel image manifest is invalid: ${error.message}`); } + if (JSON.stringify(image) !== JSON.stringify(identity.channel_image)) throw new Error("Embedded channel image manifest does not match candidate identity"); + return identity; } -export function createCandidateManifest({ archivePath, outputPath, allowLocal = false }) { +export function createCandidateManifest({ archivePath, offlinePath, splitPath, outputPath, allowLocal = false }) { const archive = resolve(archivePath); + const offline = resolve(offlinePath || ""); const identity = candidateIdentityFromArchive(archive, { allowLocal }); - const artifact = { - name: basename(archive), - sha256: sha256File(archive), - bytes: statSync(archive).size, - }; + const split = validateSplitArtifactManifest(JSON.parse(readFileSync(resolve(splitPath || ""), "utf8")), { + version: identity.version, architecture: identity.channel_image.architecture, + }); + const artifact = { name: basename(archive), sha256: sha256File(archive), bytes: statSync(archive).size, layout: "online-app" }; + const offlineBundle = { name: basename(offline), sha256: sha256File(offline), bytes: statSync(offline).size }; + if (artifact.name !== split.app.name || artifact.sha256 !== split.app.sha256 || artifact.bytes !== split.app.bytes || split.app.contains_channel_image !== false) { + throw new Error("Online candidate archive does not match its split artifact manifest"); + } + if (offlineBundle.name !== split.offline.name || offlineBundle.sha256 !== split.offline.sha256 + || offlineBundle.bytes !== split.offline.bytes || split.offline.contains_channel_image !== true) { + throw new Error("Offline candidate archive does not match its split artifact manifest"); + } + if (JSON.stringify(split.channel_image) !== JSON.stringify(identity.channel_image)) throw new Error("Split artifact channel image does not match embedded candidate identity"); const manifest = { - schema: 1, - kind: CANDIDATE_KIND, + schema: 1, kind: CANDIDATE_KIND, source: { - repository: identity.repository, - ref: identity.ref, - commit: identity.commit, - state: identity.source_state, - source_archive_sha256: identity.source_archive_sha256, + repository: identity.repository, ref: identity.ref, commit: identity.commit, + state: identity.source_state, source_archive_sha256: identity.source_archive_sha256, }, version: identity.version, - build: { identity: identity.build_identity, created_at: identity.created_at }, + build: { identity: identity.build_identity, created_at: identity.created_at, sealed_oci_cache: identity.sealed_oci_cache }, ci: identity.ci, artifact, - sealed_oci: { sha256: identity.sealed_oci_sha256 }, + offline_bundle: offlineBundle, + sealed_oci: identity.channel_image, + production_dependencies: split.production_dependencies, }; writeFileSync(resolve(outputPath), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 }); return manifest; @@ -90,17 +115,15 @@ export function createCandidateManifest({ archivePath, outputPath, allowLocal = if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { const archivePath = process.env.HELM_CANDIDATE_ARCHIVE || ""; + const offlinePath = process.env.HELM_CANDIDATE_OFFLINE_ARCHIVE || ""; + const splitPath = process.env.HELM_CANDIDATE_SPLIT_MANIFEST || ""; const outputPath = process.env.HELM_CANDIDATE_MANIFEST || ""; - if (!archivePath || !outputPath) { - process.stderr.write("Set HELM_CANDIDATE_ARCHIVE and HELM_CANDIDATE_MANIFEST.\n"); + if (!archivePath || !offlinePath || !splitPath || !outputPath) { + process.stderr.write("Set HELM_CANDIDATE_ARCHIVE, HELM_CANDIDATE_OFFLINE_ARCHIVE, HELM_CANDIDATE_SPLIT_MANIFEST, and HELM_CANDIDATE_MANIFEST.\n"); process.exit(2); } try { - const manifest = createCandidateManifest({ - archivePath, - outputPath, - allowLocal: process.env.HELM_CANDIDATE_ALLOW_LOCAL === "1", - }); + const manifest = createCandidateManifest({ archivePath, offlinePath, splitPath, outputPath, allowLocal: process.env.HELM_CANDIDATE_ALLOW_LOCAL === "1" }); process.stdout.write(`Candidate ${manifest.build.identity}: ${manifest.artifact.sha256}\n`); } catch (error) { process.stderr.write(`${error.message}\n`); diff --git a/scripts/candidate-promotion-skeleton.mjs b/scripts/candidate-promotion-skeleton.mjs index e5e494f..51c9de8 100644 --- a/scripts/candidate-promotion-skeleton.mjs +++ b/scripts/candidate-promotion-skeleton.mjs @@ -4,9 +4,11 @@ import { copyFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from " import { basename, join, resolve } from "node:path"; import { platformEvidenceBlockers } from "./platform-acceptance-lib.mjs"; import { sha256File, STABLE_REPOSITORY } from "./stable-manifest-lib.mjs"; +import { normalizeChannelImageManifest, releasedChannelImageManifest } from "./artifact-contract.mjs"; const env = process.env; const linuxSource = resolve(env.HELM_CANDIDATE_DOWNLOAD || ""); +const imageSource = resolve(env.HELM_CHANNEL_IMAGE_DOWNLOAD || ""); const macSource = resolve(env.HELM_MAC_CANDIDATE_DOWNLOAD || ""); const rehearsalPath = resolve(env.HELM_REHEARSAL_EVIDENCE || ""); const acceptancePaths = { @@ -19,7 +21,7 @@ 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 +if (!env.HELM_CANDIDATE_DOWNLOAD || !env.HELM_CHANNEL_IMAGE_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)) { @@ -47,6 +49,19 @@ 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 linuxOfflineSource = join(linuxSource, candidate.offline_bundle.name); +if (digest(linuxOfflineSource) !== candidate.offline_bundle.sha256 || statSync(linuxOfflineSource).size !== candidate.offline_bundle.bytes) { + throw new Error("Candidate Linux offline bundle no longer matches its manifest"); +} +const channelImageSource = join(imageSource, candidate.sealed_oci.artifact.name); +const channelImageManifestSource = join(imageSource, candidate.sealed_oci.artifact.name.replace(/\.oci\.tar$/, ".json")); +const retainedChannelImage = normalizeChannelImageManifest(JSON.parse(readFileSync(channelImageManifestSource, "utf8"))); +if (digest(channelImageSource) !== candidate.sealed_oci.sha256 || statSync(channelImageSource).size !== candidate.sealed_oci.bytes + || JSON.stringify(releasedChannelImageManifest(retainedChannelImage)) !== JSON.stringify(candidate.sealed_oci) + || retainedChannelImage.cache.key !== candidate.build?.sealed_oci_cache?.key + || retainedChannelImage.cache.reused !== candidate.build?.sealed_oci_cache?.reused) { + throw new Error("Retained immutable channel image candidate does not match the application candidate contract"); +} const macManifestPath = join(macSource, "candidate-evidence", "mac-candidate.json"); const mac = JSON.parse(readFileSync(macManifestPath, "utf8")); @@ -66,6 +81,8 @@ if (mac?.schema !== 1 || mac?.kind !== "1helm-macos-candidate" || mac?.repositor 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 }; +const linuxOffline = record(linuxOfflineSource, candidate.offline_bundle.name); +artifacts.linux_offline_tgz = { role: "linux_offline_tgz", name: basename(linuxOffline.path), path: linuxOffline.path, sha256: candidate.offline_bundle.sha256, bytes: candidate.offline_bundle.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) { @@ -89,6 +106,27 @@ const linuxProvenanceValue = { 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") }; +const offlineProvenanceValue = { + ...linuxProvenanceValue, + artifact: { role: "linux_offline_tgz", name: artifacts.linux_offline_tgz.name, sha256: artifacts.linux_offline_tgz.sha256, bytes: artifacts.linux_offline_tgz.bytes }, +}; +const offlineProvenanceBytes = Buffer.from(`${JSON.stringify(offlineProvenanceValue, null, 2)}\n`); +writeFileSync(join(output, "linux-offline-provenance.json"), offlineProvenanceBytes, { mode: 0o600 }); +artifacts.linux_offline_tgz.provenance = { path: "linux-offline-provenance.json", sha256: createHash("sha256").update(offlineProvenanceBytes).digest("hex") }; +const imageRecord = record(channelImageSource, candidate.sealed_oci.artifact.name); +const releasedImageManifestBytes = Buffer.from(`${JSON.stringify(candidate.sealed_oci, null, 2)}\n`); +writeFileSync(join(output, "channel-image.json"), releasedImageManifestBytes, { mode: 0o600 }); +const imageManifestRecord = { path: "channel-image.json", sha256: createHash("sha256").update(releasedImageManifestBytes).digest("hex") }; +const imageProvenance = { + schema: 1, kind: "1helm-channel-image-provenance", repository: STABLE_REPOSITORY, ref: "refs/heads/main", + source_commit: commit, candidate_workflow_run_id: workflowRunId, source_ci_run_id: ciRunId, + artifact: { name: basename(imageRecord.path), sha256: candidate.sealed_oci.sha256, bytes: candidate.sealed_oci.bytes }, + manifest: imageManifestRecord, + inputs: candidate.sealed_oci.inputs, cache: candidate.build.sealed_oci_cache, + signer_workflow: `${STABLE_REPOSITORY}/.github/workflows/candidate.yml`, attestation_created: true, +}; +const imageProvenanceBytes = Buffer.from(`${JSON.stringify(imageProvenance, null, 2)}\n`); +writeFileSync(join(output, "channel-image-provenance.json"), imageProvenanceBytes, { mode: 0o600 }); 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")); @@ -111,7 +149,7 @@ 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 }); + runAttempt: String(mac.candidate.run_attempt), ciRunId, artifacts, channelImage: candidate.sealed_oci }); if (platform === "macos" && value?.runner?.name !== mac?.builder?.runner_name) { blockers.push("acceptance runner does not match the dedicated Mac builder"); } @@ -134,6 +172,14 @@ const promotion = { acceptance_content: record(acceptanceContentPath, "acceptance.md"), }, acceptance_ledger_required: true, - artifacts: [artifacts.mac_dmg, artifacts.mac_updater_zip, artifacts.linux_tgz], + artifacts: [artifacts.mac_dmg, artifacts.mac_updater_zip, artifacts.linux_tgz, artifacts.linux_offline_tgz], + channel_image: { + ...candidate.sealed_oci, + candidate: { + artifact: imageRecord, + manifest: imageManifestRecord, + provenance: { path: "channel-image-provenance.json", sha256: createHash("sha256").update(imageProvenanceBytes).digest("hex") }, + }, + }, }; writeFileSync(join(output, "promotion.json"), `${JSON.stringify(promotion, null, 2)}\n`, { mode: 0o600 }); diff --git a/scripts/channel-image-gc-report.mjs b/scripts/channel-image-gc-report.mjs new file mode 100755 index 0000000..1b4cd06 --- /dev/null +++ b/scripts/channel-image-gc-report.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +const root = resolve(process.argv[2] || "/var/lib/1helm-oci-v1/shared-images/sha256"); +const releases = resolve(process.argv[3] || "/opt/1helm/releases"); +const referenced = new Set(); +if (existsSync(releases)) { + for (const entry of readdirSync(releases, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + const manifest = join(releases, entry.name, "resources", "channel-image.json"); + if (!existsSync(manifest) || lstatSync(manifest).isSymbolicLink()) continue; + try { + const value = JSON.parse(readFileSync(manifest, "utf8")); + if (/^[a-f0-9]{64}$/.test(String(value.sha256 || ""))) referenced.add(value.sha256); + } catch {} + } +} +const images = []; +if (existsSync(root)) for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[a-f0-9]{64}$/.test(entry.name)) continue; + const path = join(root, entry.name); + const bytes = readdirSync(path, { withFileTypes: true }).reduce((sum, item) => item.isFile() && !item.isSymbolicLink() ? sum + statSync(join(path, item.name)).size : sum, 0); + images.push({ sha256: entry.name, bytes, referenced_by_retained_release: referenced.has(entry.name), action: "retain" }); +} +const report = { + schema: 1, kind: "1helm-channel-image-gc-report", mode: "report-only", automatic_deletion: false, + store: basename(root), images: images.sort((a, b) => a.sha256.localeCompare(b.sha256)), + unreferenced_bytes: images.filter((image) => !image.referenced_by_retained_release).reduce((sum, image) => sum + image.bytes, 0), +}; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/scripts/github-promotion-gates.mjs b/scripts/github-promotion-gates.mjs index f9ae5e5..d3c235e 100644 --- a/scripts/github-promotion-gates.mjs +++ b/scripts/github-promotion-gates.mjs @@ -29,6 +29,26 @@ export async function assertRemoteVersionAbsent(version, token, fetchImpl) { } } +export async function remoteTagAndRelease(tag, token, fetchImpl) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/.test(String(tag || "")) || !token) { + throw new Error("Remote immutable artifact check received invalid inputs"); + } + const records = {}; + for (const [kind, path] of [ + ["tag", `/git/ref/tags/${encodeURIComponent(tag)}`], + ["release", `/releases/tags/${encodeURIComponent(tag)}`], + ]) { + const response = await github(path, token, fetchImpl); + if (response.status === 404) { records[kind] = null; continue; } + if (!response.ok) throw new Error(`Could not inspect ${kind} ${tag}: GitHub API ${response.status}`); + records[kind] = await response.json(); + } + if (Boolean(records.tag) !== Boolean(records.release)) { + throw new Error(`Immutable artifact ${tag} has a partial tag/Release identity`); + } + return records; +} + if (process.argv[1] && new URL(`file://${process.argv[1]}`).href === import.meta.url) { const command = process.argv[2]; try { diff --git a/scripts/linux-acceptance-evidence.mjs b/scripts/linux-acceptance-evidence.mjs index fb28f76..d7cf060 100644 --- a/scripts/linux-acceptance-evidence.mjs +++ b/scripts/linux-acceptance-evidence.mjs @@ -29,7 +29,11 @@ const evidence = normalizePlatformEvidence({ artifacts: [{ role: "linux_tgz", name: basename(env.HELM_CANDIDATE_ARCHIVE), sha256: candidate.artifact.sha256, bytes: candidate.artifact.bytes, + }, { + role: "linux_offline_tgz", name: basename(env.HELM_CANDIDATE_OFFLINE_ARCHIVE), + sha256: candidate.offline_bundle.sha256, bytes: candidate.offline_bundle.bytes, }], + channel_image: candidate.sealed_oci, 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.") }, diff --git a/scripts/package-linux-host.mjs b/scripts/package-linux-host.mjs index e5ecfb3..e9d2870 100755 --- a/scripts/package-linux-host.mjs +++ b/scripts/package-linux-host.mjs @@ -1,108 +1,132 @@ #!/usr/bin/env node -import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, closeSync, cpSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, + openSync, readFileSync, readdirSync, readSync, rmSync, statSync, writeFileSync, +} from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { spawnSync } from "node:child_process"; +import { + LINUX_SPLIT_KIND, LINUX_SPLIT_SCHEMA, normalizeChannelImageManifest, offlineBundleName, releasedChannelImageManifest, +} from "./artifact-contract.mjs"; const root = resolve(import.meta.dirname, ".."); -const version = String(JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")).version || "").trim(); +const packageValue = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); +const version = String(packageValue.version || "").trim(); if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("package.json must contain a release version"); -const dist = resolve(root, "dist"); +const dist = resolve(process.env.HELM_LINUX_DIST_DIR || join(root, "dist")); const output = resolve(dist, `1Helm-${version}-linux-node.tgz`); +const offlineOutput = resolve(dist, offlineBundleName(version)); +const splitOutput = resolve(dist, `1Helm-${version}-linux-split.json`); const cloudflaredVersion = "2026.3.0"; const cloudflared = [ - { - arch: "x64", - asset: "cloudflared-linux-amd64", - sha256: "4a9e50e6d6d798e90fcd01933151a90bf7edd99a0a55c28ad18f2e16263a5c30", - }, - { - arch: "arm64", - asset: "cloudflared-linux-arm64", - sha256: "0755ba4cbab59980e6148367fcf53a8f3ec85a97deefd63c2420cf7850769bee", - }, -]; -const sealed = [ - "container/channel-machine.oci.tar", - "container/channel-machine.oci.sha256", - "container/channel-machine.oci.json", -]; -// `git archive` only carries tracked files, so every gitignored build output has -// to be injected into the staging tree the same way the sealed image already is. -// Shipping them means the end-user host never runs `npm run build`. -const builtFiles = [ - "public/bundle.js", - "public/bundle.css", - "public/app.css", - "public/index.html", - "desktop/photon-sidecar.bundle.mjs", + { arch: "x64", asset: "cloudflared-linux-amd64", sha256: "4a9e50e6d6d798e90fcd01933151a90bf7edd99a0a55c28ad18f2e16263a5c30" }, + { arch: "arm64", asset: "cloudflared-linux-arm64", sha256: "0755ba4cbab59980e6148367fcf53a8f3ec85a97deefd63c2420cf7850769bee" }, ]; +const builtFiles = ["public/bundle.js", "public/bundle.css", "public/app.css", "public/index.html", "desktop/photon-sidecar.bundle.mjs"]; const builtTrees = ["public/excalidraw"]; -// Native addons are compiled inside this image, never on the packaging host: it -// is the oldest glibc we support building against (Debian bookworm, glibc 2.36), -// so the resulting binaries stay forward-compatible with every newer target. const nativeBuilderImage = process.env.HELM_LINUX_NATIVE_BUILDER_IMAGE || "docker.io/library/node:22"; const nativeArchitecture = "x64"; +const ociArchitecture = "amd64"; const nativeManifestPath = "resources/linux-native-modules.json"; const requiredNativeModule = "node_modules/node-pty/build/Release/pty.node"; -const digestOf = (file) => createHash("sha256").update(readFileSync(file)).digest("hex"); -const symbolCeiling = (file, prefix) => { +function digestFile(file) { + const hash = createHash("sha256"); + const fd = openSync(file, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { let count; while ((count = readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count)); } + finally { closeSync(fd); } + return hash.digest("hex"); +} +const digestBytes = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const digestText = (...values) => digestBytes(values.join("\n")); +const run = (file, args, options = {}) => spawnSync(file, args, { cwd: root, encoding: "utf8", ...options }); + +function copyRequired(sourceRoot, destinationRoot, rel) { + const source = resolve(sourceRoot, rel); + const confined = relative(sourceRoot, source); + if (!confined || confined === ".." || confined.startsWith(`..${sep}`) || !existsSync(source) || lstatSync(source).isSymbolicLink()) { + throw new Error(`Linux runtime allowlist entry is missing or unsafe: ${rel}`); + } + const destination = resolve(destinationRoot, rel); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { recursive: true, dereference: false, preserveTimestamps: false }); +} + +function symbolCeiling(file, prefix) { const found = new Set(); - const pattern = new RegExp(`${prefix}_([0-9][0-9.]*)`, "g"); - for (const match of readFileSync(file).toString("latin1").matchAll(pattern)) found.add(match[1]); - const ranked = [...found].sort((left, right) => { - const a = left.split(".").map(Number); - const b = right.split(".").map(Number); - for (let index = 0; index < Math.max(a.length, b.length); index += 1) { - if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) - (b[index] || 0); - } + for (const match of readFileSync(file).toString("latin1").matchAll(new RegExp(`${prefix}_([0-9][0-9.]*)`, "g"))) found.add(match[1]); + return [...found].sort((left, right) => { + const a = left.split(".").map(Number); const b = right.split(".").map(Number); + for (let index = 0; index < Math.max(a.length, b.length); index += 1) if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) - (b[index] || 0); return 0; - }); - return ranked.at(-1) || ""; -}; -const nativeAddons = (directory, base = "") => { + }).at(-1) || ""; +} + +function nativeAddons(directory, base = "") { const found = []; for (const entry of readdirSync(directory, { withFileTypes: true })) { - const relative = base ? `${base}/${entry.name}` : entry.name; + const rel = base ? `${base}/${entry.name}` : entry.name; if (entry.isSymbolicLink()) continue; - if (entry.isDirectory()) found.push(...nativeAddons(join(directory, entry.name), relative)); - else if (entry.isFile() && /\/build\/Release\/[^/]+\.node$/.test(`/${relative}`)) found.push(relative); + if (entry.isDirectory()) found.push(...nativeAddons(join(directory, entry.name), rel)); + else if (entry.isFile() && /\/build\/Release\/[^/]+\.node$/.test(`/${rel}`)) found.push(rel); } - return found; -}; + return found.sort(); +} + +function slimDependencies(directory) { + const excludedDirectories = new Set(runtimePackage.production_dependency_excludes.directory_names); + const excludedFiles = new Set(runtimePackage.production_dependency_excludes.file_names); + const excludedSuffixes = runtimePackage.production_dependency_excludes.file_suffixes; + let files = 0; let bytes = 0; + function walk(current) { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (excludedDirectories.has(entry.name)) { rmSync(path, { recursive: true, force: true }); continue; } + walk(path); + } else if (entry.isFile() && (excludedFiles.has(entry.name) || excludedSuffixes.some((suffix) => entry.name.endsWith(suffix)))) { + bytes += statSync(path).size; files += 1; rmSync(path); + } + } + } + walk(directory); + return { files, bytes }; +} -const repository = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8" }); +function deterministicTar(sourceDirectory, prefix, destination) { + const tar = spawnSync("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-cf", "-", "-C", sourceDirectory, prefix], { + encoding: "buffer", maxBuffer: 1024 * 1024 * 1024, + }); + if (tar.status !== 0) throw new Error(`Could not stage ${basename(destination)}`); + const packed = spawnSync("gzip", ["-n", "-c"], { input: tar.stdout, encoding: "buffer", maxBuffer: 1024 * 1024 * 1024 }); + if (packed.status === 0 && packed.stdout?.length) writeFileSync(destination, packed.stdout); + if (packed.status !== 0) throw new Error(`Could not write ${basename(destination)}`); +} + +const repository = run("git", ["rev-parse", "--show-toplevel"]); const repositoryRoot = repository.status === 0 ? resolve(String(repository.stdout || "").trim()) : ""; if (repositoryRoot !== root) { throw new Error("Linux packaging must run from the root of the exact Git checkout; a parent repository or source copy is not release authority"); } -const headPackage = spawnSync("git", ["show", "HEAD:package.json"], { cwd: root, encoding: "utf8" }); +const headPackage = run("git", ["show", "HEAD:package.json"]); let headVersion = ""; -try { headVersion = String(JSON.parse(String(headPackage.stdout || "{}")).version || "").trim(); } catch { } -if (headPackage.status !== 0 || headVersion !== version) { - throw new Error("Linux packaging version does not match package.json at Git HEAD"); -} -const headResult = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }); -const headSha = String(headResult.stdout || "").trim(); -if (headResult.status !== 0 || !/^[a-f0-9]{40}$/.test(headSha)) throw new Error("Could not resolve the exact Linux package source commit"); +try { headVersion = String(JSON.parse(String(headPackage.stdout || "{}")).version || ""); } catch {} +if (headPackage.status !== 0 || headVersion !== version) throw new Error("Linux packaging version does not match package.json at Git HEAD"); +const headSha = String(run("git", ["rev-parse", "HEAD"]).stdout || "").trim(); +if (!/^[a-f0-9]{40}$/.test(headSha)) throw new Error("Could not resolve the exact Linux package source commit"); +const runtimePackage = JSON.parse(readFileSync(join(root, "config", "linux-runtime-package.json"), "utf8")); const candidateRequested = Boolean(process.env.HELM_CANDIDATE_BUILD_ID); const candidateIdentity = candidateRequested ? { - schema: 1, - kind: "1helm-dress-rehearsal-candidate", - repository: String(process.env.HELM_CANDIDATE_REPOSITORY || ""), - ref: String(process.env.HELM_CANDIDATE_REF || ""), - commit: String(process.env.HELM_CANDIDATE_COMMIT || ""), - source_state: String(process.env.HELM_CANDIDATE_SOURCE_STATE || ""), - build_identity: String(process.env.HELM_CANDIDATE_BUILD_ID || ""), - created_at: String(process.env.HELM_CANDIDATE_CREATED_AT || ""), - ci: { - workflow: String(process.env.HELM_CANDIDATE_CI_WORKFLOW || ""), - run_id: String(process.env.HELM_CANDIDATE_CI_RUN_ID || ""), - conclusion: String(process.env.HELM_CANDIDATE_CI_CONCLUSION || ""), - }, + schema: 1, kind: "1helm-dress-rehearsal-candidate", + repository: String(process.env.HELM_CANDIDATE_REPOSITORY || ""), ref: String(process.env.HELM_CANDIDATE_REF || ""), + commit: String(process.env.HELM_CANDIDATE_COMMIT || ""), source_state: String(process.env.HELM_CANDIDATE_SOURCE_STATE || ""), + build_identity: String(process.env.HELM_CANDIDATE_BUILD_ID || ""), created_at: String(process.env.HELM_CANDIDATE_CREATED_AT || ""), + ci: { workflow: String(process.env.HELM_CANDIDATE_CI_WORKFLOW || ""), run_id: String(process.env.HELM_CANDIDATE_CI_RUN_ID || ""), conclusion: String(process.env.HELM_CANDIDATE_CI_CONCLUSION || "") }, version, } : null; if (candidateIdentity) { @@ -110,182 +134,159 @@ if (candidateIdentity) { const validCi = trustedMain ? candidateIdentity.ci.workflow === "CI" && /^\d+$/.test(candidateIdentity.ci.run_id) && candidateIdentity.ci.conclusion === "success" : candidateIdentity.ci.workflow === "local" && candidateIdentity.ci.run_id === "0" && candidateIdentity.ci.conclusion === "not_run"; - if (candidateIdentity.repository !== "gitcommit90/1Helm" - || candidateIdentity.ref !== "refs/heads/main" - || candidateIdentity.commit !== headSha + if (candidateIdentity.repository !== "gitcommit90/1Helm" || candidateIdentity.ref !== "refs/heads/main" || candidateIdentity.commit !== headSha || !["trusted-main", "local-worktree", "rollback-fixture"].includes(candidateIdentity.source_state) || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(candidateIdentity.build_identity) - || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(candidateIdentity.created_at) - || !validCi) { + || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(candidateIdentity.created_at) || !validCi) { throw new Error("Candidate packaging requires the exact trusted repository/ref/commit, successful CI identity, and bounded build identity"); } - const worktree = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }); - if (worktree.status !== 0) throw new Error("Could not inspect the candidate source worktree"); - if (candidateIdentity.source_state === "trusted-main" && String(worktree.stdout || "").trim()) { - throw new Error("Trusted-main candidate packaging requires a clean exact checkout"); - } -} -for (const rel of sealed.slice(0, 2)) { - if (!existsSync(resolve(root, rel))) { - throw new Error(`Linux packaging requires the sealed channel image at ${rel} (run scripts/build-oci-channel-image.sh on a builder host).`); - } + const worktree = run("git", ["status", "--porcelain"]); + if (worktree.status !== 0 || (trustedMain && String(worktree.stdout).trim())) throw new Error("Trusted-main candidate packaging requires a clean exact checkout"); } +if (process.platform !== "linux" || process.arch !== nativeArchitecture) throw new Error(`Linux packaging must run on linux-${nativeArchitecture}`); +const containerRuntime = ["podman", "docker"].find((name) => spawnSync(name, ["--version"], { stdio: "ignore" }).status === 0); +if (!containerRuntime) throw new Error("Linux packaging requires podman or docker for ABI-pinned production dependencies"); -if (process.platform !== "linux" || process.arch !== nativeArchitecture) { - throw new Error(`Linux packaging must run on a linux-${nativeArchitecture} builder so the vendored native addons match the shipped architecture`); -} -const containerRuntime = ["podman", "docker"].find((candidate) => spawnSync(candidate, ["--version"], { stdio: "ignore" }).status === 0); -if (!containerRuntime) { - throw new Error("Linux packaging requires podman or docker so production dependencies compile against the oldest supported glibc"); +const imageTar = join(root, "container", "channel-machine.oci.tar"); +const imageMetaPath = join(root, "container", "channel-machine.oci.json"); +if (!existsSync(imageTar) || !existsSync(imageMetaPath)) throw new Error("Linux packaging requires the sealed channel image and manifest; run npm run package:channel-image"); +const channelImage = normalizeChannelImageManifest(JSON.parse(readFileSync(imageMetaPath, "utf8"))); +if (channelImage.architecture !== ociArchitecture || digestFile(imageTar) !== channelImage.sha256 || statSync(imageTar).size !== channelImage.bytes) { + throw new Error("Sealed channel image bytes, architecture, or manifest do not match"); } -// Build the client and sidecar bundles here, on the release builder, so the -// installed release is already runnable. Everything below only copies results. +for (const path of [output, offlineOutput, splitOutput]) if (existsSync(path)) throw new Error(`Refusing to overwrite existing artifact: ${relative(root, path)}`); const clientBuild = spawnSync("npm", ["run", "build"], { cwd: root, stdio: "inherit" }); if (clientBuild.status !== 0) throw new Error("Could not build the Linux release client and sidecar bundles"); -for (const rel of builtFiles) { - const built = resolve(root, rel); - if (!existsSync(built) || statSync(built).size === 0) throw new Error(`Linux packaging requires the built asset ${rel}`); -} -for (const rel of builtTrees) { - const built = resolve(root, rel); - if (!existsSync(built) || !statSync(built).isDirectory() || readdirSync(built).length === 0) { - throw new Error(`Linux packaging requires the built asset tree ${rel}`); - } -} +for (const rel of builtFiles) if (!existsSync(join(root, rel)) || statSync(join(root, rel)).size === 0) throw new Error(`Missing built asset ${rel}`); +for (const rel of builtTrees) if (!existsSync(join(root, rel)) || readdirSync(join(root, rel)).length === 0) throw new Error(`Missing built asset tree ${rel}`); mkdirSync(dist, { recursive: true }); -rmSync(output, { force: true }); - const stage = mkdtempSync(join(tmpdir(), "1helm-linux-pkg-")); try { const prefix = `1Helm-${version}`; - let archive; + const sourceRoot = join(stage, "source"); + const releaseRoot = join(stage, "online", prefix); + mkdirSync(sourceRoot, { recursive: true }); mkdirSync(releaseRoot, { recursive: true }); + let sourceArchive; if (["local-worktree", "rollback-fixture"].includes(candidateIdentity?.source_state)) { - const files = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { - cwd: root, encoding: "buffer", maxBuffer: 64 * 1024 * 1024, - }); - if (files.status !== 0 || !files.stdout?.length) throw new Error("Could not enumerate the local candidate worktree"); - archive = spawnSync("tar", ["-cf", "-", "--null", "--files-from=-", `--transform=s,^,${prefix}/,`], { - cwd: root, input: files.stdout, encoding: "buffer", maxBuffer: 512 * 1024 * 1024, - }); + const listed = run("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }); + sourceArchive = spawnSync("tar", ["-cf", "-", "--null", "--files-from=-"], { cwd: root, input: listed.stdout, encoding: "buffer", maxBuffer: 512 * 1024 * 1024 }); } else { - archive = spawnSync("git", ["archive", "--format=tar", `--prefix=${prefix}/`, "HEAD"], { - cwd: root, - encoding: "buffer", - maxBuffer: 512 * 1024 * 1024, - }); - } - if (archive.status !== 0) throw new Error("Could not package the exact Git candidate source"); - if (!archive.stdout?.length) throw new Error("Exact Git candidate source archive was empty"); - const sourceArchiveSha256 = createHash("sha256").update(archive.stdout).digest("hex"); - const extract = spawnSync("tar", ["-xf", "-", "-C", stage], { input: archive.stdout, stdio: ["pipe", "inherit", "inherit"] }); - if (extract.status !== 0) throw new Error("Could not extract the Git release source for packaging"); - - const stagedPackage = resolve(stage, prefix, "package.json"); - if (!existsSync(stagedPackage) || String(JSON.parse(readFileSync(stagedPackage, "utf8")).version || "") !== version) { - throw new Error("Exact Git release source archive is missing its versioned package contract"); - } - - const containerDir = join(stage, prefix, "container"); - mkdirSync(containerDir, { recursive: true }); - for (const rel of sealed) { - const src = resolve(root, rel); - if (!existsSync(src)) continue; - copyFileSync(src, join(stage, prefix, rel)); - } - if (candidateIdentity) { - const imageSha256 = String(readFileSync(resolve(root, "container/channel-machine.oci.sha256"), "utf8")).trim(); - if (!/^[a-f0-9]{64}$/.test(imageSha256)) throw new Error("Candidate packaging requires the sealed OCI image SHA-256"); - const identityFile = join(stage, prefix, "resources", "candidate-build.json"); - mkdirSync(dirname(identityFile), { recursive: true }); - writeFileSync(identityFile, `${JSON.stringify({ - ...candidateIdentity, - source_archive_sha256: sourceArchiveSha256, - sealed_oci_sha256: imageSha256, - }, null, 2)}\n`); + sourceArchive = run("git", ["archive", "--format=tar", "HEAD"], { encoding: "buffer", maxBuffer: 512 * 1024 * 1024 }); } + const archive = sourceArchive; + if (archive.status !== 0 || !archive.stdout?.length) throw new Error("Could not create exact Git source identity archive"); + const sourceArchiveSha256 = digestBytes(sourceArchive.stdout); + const extracted = spawnSync("tar", ["-xf", "-", "-C", sourceRoot], { input: sourceArchive.stdout, stdio: ["pipe", "inherit", "inherit"] }); + if (extracted.status !== 0) throw new Error("Could not inspect exact Git source archive"); + const stagedPackage = join(sourceRoot, "package.json"); + let stagedVersion = ""; + try { stagedVersion = String(JSON.parse(readFileSync(stagedPackage, "utf8")).version || "").trim(); } catch {} + if (stagedVersion !== version) throw new Error("Git source archive is missing its versioned package contract"); + for (const rel of runtimePackage.source) copyRequired(sourceRoot, releaseRoot, rel); + for (const rel of runtimePackage.built) copyRequired(root, releaseRoot, rel); - const resourcesDir = join(stage, prefix, "resources"); + const resourcesDir = join(releaseRoot, "resources"); mkdirSync(resourcesDir, { recursive: true }); + const releasedImageManifest = releasedChannelImageManifest(channelImage); + writeFileSync(join(resourcesDir, "channel-image.json"), `${JSON.stringify(releasedImageManifest, null, 2)}\n`); + if (candidateIdentity) writeFileSync(join(resourcesDir, "candidate-build.json"), `${JSON.stringify({ + ...candidateIdentity, source_archive_sha256: sourceArchiveSha256, sealed_oci_sha256: channelImage.sha256, + channel_image: releasedImageManifest, sealed_oci_cache: channelImage.cache, + }, null, 2)}\n`); + for (const connector of cloudflared) { const destination = join(resourcesDir, `cloudflared-linux-${connector.arch}`); - const url = `https://github.com/cloudflare/cloudflared/releases/download/${cloudflaredVersion}/${connector.asset}`; - const download = spawnSync("curl", ["-fsSL", "--proto", "=https", "--tlsv1.2", "--retry", "3", "-o", destination, url], { stdio: "inherit" }); - if (download.status !== 0) throw new Error(`Could not download pinned cloudflared for Linux ${connector.arch}`); - const digest = spawnSync("sha256sum", [destination], { encoding: "utf8" }); - const actual = digest.status === 0 ? String(digest.stdout || "").trim().split(/\s+/)[0] : ""; - if (actual !== connector.sha256) throw new Error(`Pinned cloudflared digest mismatch for Linux ${connector.arch} (got ${actual || "unavailable"})`); + const download = spawnSync("curl", ["-fsSL", "--proto", "=https", "--tlsv1.2", "--retry", "3", "-o", destination, + `https://github.com/cloudflare/cloudflared/releases/download/${cloudflaredVersion}/${connector.asset}`], { stdio: "inherit" }); + if (download.status !== 0 || digestFile(destination) !== connector.sha256) throw new Error(`Pinned cloudflared digest mismatch for ${connector.arch}`); chmodSync(destination, 0o755); } - for (const rel of builtFiles) { - const destination = join(stage, prefix, rel); - mkdirSync(dirname(destination), { recursive: true }); - copyFileSync(resolve(root, rel), destination); - } - for (const rel of builtTrees) { - const destination = join(stage, prefix, rel); - rmSync(destination, { recursive: true, force: true }); - cpSync(resolve(root, rel), destination, { recursive: true }); - } - - // Production dependencies are installed once, here, against the release - // lockfile that `git archive` just staged. The end-user host therefore needs - // neither a compiler nor npm registry access. - const install = spawnSync(containerRuntime, [ - "run", "--rm", "--network=host", - "-v", `${join(stage, prefix)}:/workspace`, - "-w", "/workspace", - "-e", "PUPPETEER_SKIP_DOWNLOAD=1", - "-e", "ELECTRON_SKIP_BINARY_DOWNLOAD=1", - "-e", "npm_config_audit=false", - "-e", "npm_config_fund=false", - "-e", "npm_config_update_notifier=false", - nativeBuilderImage, - "npm", "ci", "--omit=dev", - ], { stdio: "inherit" }); - if (install.status !== 0) throw new Error("Could not install the Linux release production dependencies inside the native builder image"); - - const stagedModules = join(stage, prefix, "node_modules"); - if (!existsSync(stagedModules)) throw new Error("The native builder image did not produce production node_modules"); - const stagedPty = join(stage, prefix, requiredNativeModule); - if (!existsSync(stagedPty)) throw new Error(`The native builder image did not produce ${requiredNativeModule}; terminals would be unavailable on the target host`); const builderNode = spawnSync(containerRuntime, ["run", "--rm", nativeBuilderImage, "node", "-p", "process.versions.modules + ' ' + process.version"], { encoding: "utf8" }); const [builderAbi = "", builderVersion = ""] = String(builderNode.stdout || "").trim().split(/\s+/); - if (!/^\d+$/.test(builderAbi)) throw new Error("Could not read the native builder image Node ABI"); + if (builderNode.status !== 0 || !/^\d+$/.test(builderAbi)) throw new Error("Could not read native builder Node ABI"); + const inspect = spawnSync(containerRuntime, ["image", "inspect", nativeBuilderImage, "--format", "{{.Digest}}"], { encoding: "utf8" }); + const builderDigestMatch = String(inspect.stdout || "").match(/^sha256:([a-f0-9]{64})\s*$/); + if (!builderDigestMatch) throw new Error("Native builder image must resolve to an exact repository digest"); + const builderDigest = builderDigestMatch[1]; + const runtimePackageSha256 = digestFile(join(root, "config", "linux-runtime-package.json")); + const dependencyCacheKey = digestText(digestFile(join(releaseRoot, "package-lock.json")), runtimePackageSha256, + builderAbi, nativeArchitecture, builderDigest); + const cacheRoot = resolve(process.env.HELM_PRODUCTION_CACHE_DIR || join(dist, "cache", "production-dependencies")); + const cacheTar = join(cacheRoot, `${dependencyCacheKey}.tar`); + const cacheMeta = join(cacheRoot, `${dependencyCacheKey}.json`); + let dependencyCacheReused = false; + if (existsSync(cacheTar) && existsSync(cacheMeta)) { + const meta = JSON.parse(readFileSync(cacheMeta, "utf8")); + if (meta.key === dependencyCacheKey && meta.node_abi === builderAbi && meta.architecture === nativeArchitecture + && meta.builder_image_digest === builderDigest && meta.runtime_package_sha256 === runtimePackageSha256 + && meta.tar_sha256 === digestFile(cacheTar)) { + const restore = spawnSync("tar", ["-xf", cacheTar, "-C", releaseRoot], { stdio: "inherit" }); + if (restore.status !== 0) throw new Error("Verified production dependency cache could not be extracted"); + dependencyCacheReused = true; + } + } + if (!dependencyCacheReused) { + const install = spawnSync(containerRuntime, [ + "run", "--rm", "--network=host", "-v", `${releaseRoot}:/workspace`, "-w", "/workspace", + "-e", "PUPPETEER_SKIP_DOWNLOAD=1", "-e", "ELECTRON_SKIP_BINARY_DOWNLOAD=1", + "-e", "npm_config_audit=false", "-e", "npm_config_fund=false", "-e", "npm_config_update_notifier=false", + nativeBuilderImage, "npm", "ci", "--omit=dev", + ], { stdio: "inherit" }); + if (install.status !== 0) throw new Error("Could not install production dependencies inside the native builder image"); + const pruning = slimDependencies(join(releaseRoot, "node_modules")); + console.log(`runtime dependency allowlist excluded ${pruning.files} files / ${pruning.bytes} bytes`); + } + const stagedModules = join(releaseRoot, "node_modules"); + if (!existsSync(join(releaseRoot, requiredNativeModule))) throw new Error(`Production dependencies are missing ${requiredNativeModule}`); const modules = nativeAddons(stagedModules).map((rel) => { const file = join(stagedModules, rel); - return { - path: `node_modules/${rel}`, - sha256: digestOf(file), - glibc: symbolCeiling(file, "GLIBC"), - glibcxx: symbolCeiling(file, "GLIBCXX"), - }; + return { path: `node_modules/${rel}`, sha256: digestFile(file), glibc: symbolCeiling(file, "GLIBC"), glibcxx: symbolCeiling(file, "GLIBCXX") }; }); if (!modules.some((entry) => entry.path === requiredNativeModule)) throw new Error(`Could not fingerprint ${requiredNativeModule}`); - const manifest = { - version, - platform: "linux", - arch: nativeArchitecture, - builderImage: nativeBuilderImage, - builderNodeVersion: builderVersion, - nodeAbi: builderAbi, - modules, + const nativeManifest = { + version, platform: "linux", arch: nativeArchitecture, builderImage: nativeBuilderImage, + builderImageDigest: builderDigest, builderNodeVersion: builderVersion, nodeAbi: builderAbi, modules, + cache: { key: dependencyCacheKey, reused: dependencyCacheReused }, }; - const manifestFile = join(stage, prefix, nativeManifestPath); - mkdirSync(dirname(manifestFile), { recursive: true }); - writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`); - for (const entry of modules) { - console.log(`vendored ${entry.path} — max GLIBC ${entry.glibc || "none"} / GLIBCXX ${entry.glibcxx || "none"}`); + writeFileSync(join(releaseRoot, nativeManifestPath), `${JSON.stringify(nativeManifest, null, 2)}\n`); + if (!dependencyCacheReused) { + mkdirSync(cacheRoot, { recursive: true }); + const cacheWrite = spawnSync("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-cf", cacheTar, + "-C", releaseRoot, "node_modules", nativeManifestPath], { stdio: "inherit" }); + if (cacheWrite.status !== 0) throw new Error("Could not retain production dependency cache"); + writeFileSync(cacheMeta, `${JSON.stringify({ key: dependencyCacheKey, node_abi: builderAbi, architecture: nativeArchitecture, + builder_image_digest: builderDigest, lockfile_sha256: digestFile(join(releaseRoot, "package-lock.json")), + runtime_package_sha256: runtimePackageSha256, tar_sha256: digestFile(cacheTar) }, null, 2)}\n`); } - const pack = spawnSync("tar", ["-czf", output, "-C", stage, prefix], { stdio: "inherit" }); - if (pack.status !== 0) throw new Error("Could not write the Linux host archive"); + deterministicTar(join(stage, "online"), prefix, output); + const onlineDigest = digestFile(output); + writeFileSync(`${output}.sha256`, `${onlineDigest} ${basename(output)}\n`); + + const offlineRoot = join(stage, "offline", prefix); + cpSync(releaseRoot, offlineRoot, { recursive: true, preserveTimestamps: false }); + mkdirSync(join(offlineRoot, "container"), { recursive: true }); + copyFileSync(imageTar, join(offlineRoot, "container", "channel-machine.oci.tar")); + writeFileSync(join(offlineRoot, "container", "channel-machine.oci.sha256"), `${channelImage.sha256}\n`); + copyFileSync(imageMetaPath, join(offlineRoot, "container", "channel-machine.oci.json")); + deterministicTar(join(stage, "offline"), prefix, offlineOutput); + const offlineDigest = digestFile(offlineOutput); + writeFileSync(`${offlineOutput}.sha256`, `${offlineDigest} ${basename(offlineOutput)}\n`); + const split = { + schema: LINUX_SPLIT_SCHEMA, kind: LINUX_SPLIT_KIND, version, + app: { name: basename(output), sha256: onlineDigest, bytes: statSync(output).size, contains_channel_image: false }, + offline: { name: basename(offlineOutput), sha256: offlineDigest, bytes: statSync(offlineOutput).size, contains_channel_image: true }, + channel_image: releasedImageManifest, + production_dependencies: { key: dependencyCacheKey, reused: dependencyCacheReused, node_abi: builderAbi, + architecture: nativeArchitecture, builder_image_digest: builderDigest, runtime_package_sha256: runtimePackageSha256 }, + }; + writeFileSync(splitOutput, `${JSON.stringify(split, null, 2)}\n`); + console.log(`online ${basename(output)} ${statSync(output).size} bytes sha256=${onlineDigest}`); + console.log(`offline ${basename(offlineOutput)} ${statSync(offlineOutput).size} bytes sha256=${offlineDigest}`); + console.log(`production dependency cache ${dependencyCacheReused ? "reused" : "created"} key=${dependencyCacheKey}`); } finally { rmSync(stage, { recursive: true, force: true }); } -const archiveDigest = digestOf(output); -writeFileSync(`${output}.sha256`, `${archiveDigest} ${output.split("/").at(-1)}\n`); -console.log(`sha256 ${archiveDigest}`); -console.log(output); diff --git a/scripts/pending-acceptance-evidence.mjs b/scripts/pending-acceptance-evidence.mjs index bf4aee9..c608467 100644 --- a/scripts/pending-acceptance-evidence.mjs +++ b/scripts/pending-acceptance-evidence.mjs @@ -22,6 +22,11 @@ if (platform === "macos") { name: basename(env.HELM_CANDIDATE_ARCHIVE || source.artifact.name), sha256: source.artifact.sha256, bytes: source.artifact.bytes, + }, { + role: "linux_offline_tgz", + name: basename(env.HELM_CANDIDATE_OFFLINE_ARCHIVE || source.offline_bundle.name), + sha256: source.offline_bundle.sha256, + bytes: source.offline_bundle.bytes, }]; } const commit = platform === "macos" ? source.commit : source.source.commit; @@ -54,6 +59,7 @@ const evidence = normalizePlatformEvidence({ summary: "Continuity mode selected; the exact-candidate exercise has not completed.", } } : {}), artifacts: PLATFORM_ARTIFACT_ROLES[platform].map((role) => artifacts.find((item) => item.role === role)), + ...(platform === "macos" ? {} : { channel_image: source.sealed_oci }), 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 }, diff --git a/scripts/platform-acceptance-lib.mjs b/scripts/platform-acceptance-lib.mjs index f2a1c95..338e06e 100644 --- a/scripts/platform-acceptance-lib.mjs +++ b/scripts/platform-acceptance-lib.mjs @@ -29,8 +29,8 @@ export const PLATFORM_CHECKS = Object.freeze({ 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"]), + linux: Object.freeze(["linux_tgz", "linux_offline_tgz"]), + windows: Object.freeze(["linux_tgz", "linux_offline_tgz"]), }); const HEX40 = /^[a-f0-9]{40}$/; @@ -155,6 +155,17 @@ export function normalizePlatformEvidence(input) { 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`); + let channelImage = null; + if (platform !== "macos") { + const value = input.channel_image || {}; + channelImage = { + version: exact(value.version, /^1$/, `${platform} channel image contract version`), + architecture: value.architecture === "amd64" ? "amd64" : (() => { throw new Error(`${platform} channel image architecture is invalid`); })(), + sha256: exact(value.sha256, HEX64, `${platform} channel image digest`), + bytes: Number(value.bytes), + }; + if (!Number.isSafeInteger(channelImage.bytes) || channelImage.bytes < 1) throw new Error(`${platform} channel image byte count is invalid`); + } const candidate = input?.candidate || {}; const sourceCi = input?.source_ci || {}; const machine = normalizeMachine(input.machine, platform); @@ -197,6 +208,7 @@ export function normalizePlatformEvidence(input) { runner, ...(continuity ? { continuity } : {}), artifacts, + ...(channelImage ? { channel_image: channelImage } : {}), checks, state_preservation: statePreservation, recovery, @@ -246,5 +258,11 @@ export function platformEvidenceBlockers(value, expected) { && 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"); + if (platform !== "macos") { + const wanted = expected.channelImage; + add(value?.channel_image?.version === wanted?.version && value?.channel_image?.architecture === wanted?.architecture + && value?.channel_image?.sha256 === wanted?.sha256 && Number(value?.channel_image?.bytes) === Number(wanted?.bytes), + "shared channel image contract does not match exact candidate bytes/architecture/version"); + } return blockers; } diff --git a/scripts/promotion-lib.mjs b/scripts/promotion-lib.mjs index 0c8f8c2..cf9f900 100644 --- a/scripts/promotion-lib.mjs +++ b/scripts/promotion-lib.mjs @@ -3,6 +3,7 @@ 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"; +import { normalizeChannelImageManifest, releasedChannelImageManifest } from "./artifact-contract.mjs"; export const PROMOTION_KIND = "1helm-stable-promotion-candidate"; export const CONFIRMATION_PREFIX = "PROMOTE EXACT CANDIDATE"; @@ -80,6 +81,8 @@ function validateCandidateManifest(value, expected, blockers) { add(blockers, value?.version === expected.version, "candidate manifest: version does not match intended version"); add(blockers, value?.ci?.workflow === "CI" && ID.test(String(value?.ci?.run_id || "")) && value?.ci?.conclusion === "success", "candidate manifest: CI did not succeed"); add(blockers, value?.artifact?.name === stableArtifactNames(expected.version).linux_tgz && HEX64.test(String(value?.artifact?.sha256 || "")), "candidate manifest: Linux artifact identity is invalid"); + add(blockers, value?.offline_bundle?.name === stableArtifactNames(expected.version).linux_offline_tgz + && HEX64.test(String(value?.offline_bundle?.sha256 || "")), "candidate manifest: Linux offline artifact identity is invalid"); add(blockers, HEX64.test(String(value?.source?.source_archive_sha256 || "")) && HEX64.test(String(value?.sealed_oci?.sha256 || "")), "candidate manifest: source or sealed OCI digest is invalid"); } @@ -129,14 +132,14 @@ function validateArtifactRecord(value, expected, blockers) { add(blockers, String(value?.workflow_run?.id) === expected.runId, "candidate artifact: workflow run mismatch"); } -function validatePlatformEvidence(platform, value, expected, artifacts, blockers) { +function validatePlatformEvidence(platform, value, expected, artifacts, channelImage, blockers) { const label = `${platform} acceptance`; - for (const message of platformEvidenceBlockers(value, { ...expected, platform, artifacts })) blockers.push(`${label}: ${message}`); + for (const message of platformEvidenceBlockers(value, { ...expected, platform, artifacts, channelImage })) blockers.push(`${label}: ${message}`); } -function releaseNotes(version, commit, promotion, artifacts, changelog, acceptance) { +function releaseNotes(version, commit, promotion, artifacts, channelImage, changelog, acceptance) { const digestLines = STABLE_ARTIFACT_ROLES.map((role) => `- \`${artifacts[role].name}\` — \`${artifacts[role].sha256}\``).join("\n"); - return `# 1Helm ${version}\n\n${acceptance.trim()}\n\n## Authored changelog\n\n${changelog.trim()}\n\n## Promoted candidate evidence\n\n- Source: \`${STABLE_REPOSITORY}@${commit}\` on \`main\`\n- Candidate workflow run: \`${promotion.candidate.workflow_run_id}\`\n- Candidate artifact: \`${promotion.candidate.artifact_id}\` (\`${promotion.candidate.artifact_name}\`)\n- Private dress rehearsal: exact Linux commit and digest healthy\n- Platform acceptance: retained macOS, Linux, and Windows records all passed\n\n## Exact release artifacts\n\n${digestLines}\n`; + return `# 1Helm ${version}\n\n${acceptance.trim()}\n\n## Authored changelog\n\n${changelog.trim()}\n\n## Promoted candidate evidence\n\n- Source: \`${STABLE_REPOSITORY}@${commit}\` on \`main\`\n- Candidate workflow run: \`${promotion.candidate.workflow_run_id}\`\n- Candidate artifact: \`${promotion.candidate.artifact_id}\` (\`${promotion.candidate.artifact_name}\`)\n- Private dress rehearsal: exact Linux commit and digest healthy\n- Platform acceptance: retained macOS, Linux, and Windows records all passed\n- Shared channel image: \`${channelImage.architecture}\`, contract v\`${channelImage.version}\`, SHA-256 \`${channelImage.sha256}\`\n- The ordinary Linux artifact omits the shared image; the explicit offline bundle includes its exact bytes.\n\n## Exact release artifacts\n\n${digestLines}\n`; } export function confirmationText(version, runId, artifactId) { @@ -203,11 +206,11 @@ export function validatePromotionBundle(options) { && 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") { + if (["linux_tgz", "linux_offline_tgz"].includes(role)) { 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") { + if (["mac_dmg", "mac_updater_zip"].includes(role)) { 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" @@ -215,11 +218,52 @@ export function validatePromotionBundle(options) { } } } - add(blockers, (Array.isArray(promotion?.artifacts) ? promotion.artifacts : []).length === 3, "desktop artifact matrix must contain exactly three artifacts"); + add(blockers, (Array.isArray(promotion?.artifacts) ? promotion.artifacts : []).length === STABLE_ARTIFACT_ROLES.length, "desktop artifact matrix must contain the complete split artifact set"); if (macCandidateManifest) validateMacCandidateManifest(macCandidateManifest.value, expected, artifacts, blockers); + let channelImage = null; + try { channelImage = normalizeChannelImageManifest(promotion?.channel_image, { requireUrl: true }); } + catch (error) { blockers.push(`channel image manifest: ${error.message}`); } + const imageArtifact = confinedFile(bundle, promotion?.channel_image?.candidate?.artifact?.path, blockers, "channel image artifact"); + const imageManifest = checkedRecord(bundle, promotion?.channel_image?.candidate?.manifest, blockers, "channel image retained manifest"); + const imageProvenance = checkedRecord(bundle, promotion?.channel_image?.candidate?.provenance, blockers, "channel image provenance"); + if (channelImage && imageArtifact) { + add(blockers, basename(imageArtifact) === channelImage.artifact.name && digestFile(imageArtifact) === channelImage.sha256 + && statSync(imageArtifact).size === channelImage.bytes, "channel image exact bytes do not match its immutable manifest"); + } + if (channelImage && imageManifest) { + try { + const retained = normalizeChannelImageManifest(imageManifest.value); + add(blockers, JSON.stringify(releasedChannelImageManifest(retained)) === JSON.stringify(channelImage), + "channel image retained build manifest does not match promotion contract"); + } catch (error) { blockers.push(`channel image retained build manifest: ${error.message}`); } + } + if (channelImage && imageProvenance) { + add(blockers, imageProvenance.value?.schema === 1 && imageProvenance.value?.kind === "1helm-channel-image-provenance" + && imageProvenance.value?.repository === STABLE_REPOSITORY && imageProvenance.value?.ref === "refs/heads/main" + && imageProvenance.value?.source_commit === expected.commit + && String(imageProvenance.value?.candidate_workflow_run_id) === expected.runId + && String(imageProvenance.value?.source_ci_run_id) === String(expected.ciRunId) + && imageProvenance.value?.artifact?.name === channelImage.artifact.name + && imageProvenance.value?.artifact?.sha256 === channelImage.sha256 + && Number(imageProvenance.value?.artifact?.bytes) === channelImage.bytes + && imageProvenance.value?.manifest?.sha256 === promotion?.channel_image?.candidate?.manifest?.sha256 + && imageProvenance.value?.inputs?.containerfile_sha256 === channelImage.inputs.containerfile_sha256 + && imageProvenance.value?.inputs?.context_sha256 === channelImage.inputs.context_sha256 + && imageProvenance.value?.inputs?.base_image_digest === channelImage.inputs.base_image_digest + && imageProvenance.value?.cache?.key === channelImage.cache.key + && typeof imageProvenance.value?.cache?.reused === "boolean" + && imageProvenance.value?.cache?.key === candidateManifest?.value?.build?.sealed_oci_cache?.key + && imageProvenance.value?.cache?.reused === candidateManifest?.value?.build?.sealed_oci_cache?.reused + && imageProvenance.value?.signer_workflow === `${STABLE_REPOSITORY}/.github/workflows/candidate.yml` + && imageProvenance.value?.attestation_created === true, "channel image provenance is incomplete or mismatched"); + add(blockers, options.linuxAttestationVerified === true, "channel image provenance was not cryptographically verified"); + } + 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"); + add(blockers, candidateManifest.value?.offline_bundle?.sha256 === artifacts.linux_offline_tgz?.sha256 + && candidateManifest.value?.offline_bundle?.bytes === artifacts.linux_offline_tgz?.bytes, "Linux candidate manifest does not match promoted offline bundle bytes"); try { const identity = candidateIdentityFromArchive(artifacts.linux_tgz.path); add(blockers, identity.commit === expected.commit && identity.version === expected.version, "Linux embedded commit/version does not match promotion identity"); @@ -236,7 +280,7 @@ 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); + validatePlatformEvidence(platform, record.value, expected, artifacts, channelImage, 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"); @@ -262,7 +306,7 @@ export function validatePromotionBundle(options) { const digest = promotion ? digestFile(join(bundle, "promotion.json")) : ""; const stableManifest = blockers.length ? null : validateStableManifest({ - schema: 1, kind: STABLE_MANIFEST_KIND, repository: STABLE_REPOSITORY, ref: "refs/heads/main", + schema: 2, kind: STABLE_MANIFEST_KIND, repository: STABLE_REPOSITORY, ref: "refs/heads/main", version: expected.version, tag: `v${expected.version}`, commit: expected.commit, promoted_at: options.promotedAt || new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), promotion: { candidate_workflow_run_id: expected.runId, candidate_artifact_id: expected.artifactId, manifest_sha256: digest }, @@ -270,6 +314,7 @@ export function validatePromotionBundle(options) { role, name: artifacts[role].name, sha256: artifacts[role].sha256, bytes: artifacts[role].bytes, url: `https://github.com/${STABLE_REPOSITORY}/releases/download/v${expected.version}/${artifacts[role].name}`, })), + channel_image: channelImage, }); return { schema: 1, kind: "1helm-stable-promotion-report", mode: "dry-run", repository: STABLE_REPOSITORY, @@ -278,7 +323,7 @@ export function validatePromotionBundle(options) { evidence: Object.fromEntries(Object.keys(PLATFORMS).map((platform) => [platform, blockers.some((item) => item.startsWith(`${platform} acceptance`)) ? "blocked" : "passed"])), artifacts: STABLE_ARTIFACT_ROLES.map((role) => ({ role, name: artifacts[role]?.name || expectedNames[role], sha256: artifacts[role]?.sha256 || null })), eligible: blockers.length === 0, blockers, stable_touched: false, stable_manifest: stableManifest, - release_notes: blockers.length ? null : releaseNotes(expected.version, expected.commit, promotion, artifacts, changelog, acceptance), + release_notes: blockers.length ? null : releaseNotes(expected.version, expected.commit, promotion, artifacts, channelImage, changelog, acceptance), }; } diff --git a/scripts/publish-promotion.mjs b/scripts/publish-promotion.mjs index bb69630..1b3814f 100644 --- a/scripts/publish-promotion.mjs +++ b/scripts/publish-promotion.mjs @@ -3,8 +3,9 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { confirmationText } from "./promotion-lib.mjs"; -import { sha256File, STABLE_ARTIFACT_ROLES, validateStableManifest } from "./stable-manifest-lib.mjs"; -import { assertRemoteVersionAbsent } from "./github-promotion-gates.mjs"; +import { sha256, sha256File, STABLE_ARTIFACT_ROLES, validateStableManifest } from "./stable-manifest-lib.mjs"; +import { assertRemoteVersionAbsent, remoteTagAndRelease } from "./github-promotion-gates.mjs"; +import { channelImageManifestName, channelImageProvenanceName, channelImageReleaseTag } from "./artifact-contract.mjs"; const bundle = resolve(process.env.HELM_PROMOTION_BUNDLE || ""); const version = String(process.env.HELM_PROMOTION_VERSION || ""); @@ -38,7 +39,6 @@ if (JSON.stringify(stable) !== JSON.stringify(verified.stable_manifest) } const run = (file, args, options = {}) => execFileSync(file, args, { encoding: "utf8", stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit" }); -const captured = (file, args) => run(file, args, { capture: true }).trim(); const tag = `v${version}`; await assertRemoteVersionAbsent(version, githubToken); run("git", ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]); @@ -54,6 +54,111 @@ const artifactPaths = STABLE_ARTIFACT_ROLES.map((role) => { const notes = join(bundle, `1Helm-${version}-release-notes.md`); if (hash(notes) !== verified.release_notes_sha256) throw new Error("Refusing changed authored release notes after verification"); +// The sealed channel machine has its own immutable digest-addressed Release. +// Reuse an exact existing Release or publish the retained candidate once. It is +// never copied into the ordinary application Release, so unchanged app updates +// cannot redownload it. +const image = stable.channel_image; +const imageTag = channelImageReleaseTag(image); +const imagePath = join(bundle, image.artifact.name); +const imageManifestCandidate = join(bundle, "channel-image.json"); +const imageManifestName = channelImageManifestName(image); +const imageManifestPath = join(bundle, imageManifestName); +const imageProvenanceCandidate = join(bundle, "channel-image-provenance.json"); +const imageProvenanceName = channelImageProvenanceName(image); +const imageProvenancePath = join(bundle, imageProvenanceName); +if (hash(imagePath) !== image.sha256) throw new Error("Refusing changed channel image bytes after verification"); +const imageManifestValue = JSON.parse(readFileSync(imageManifestCandidate, "utf8")); +if (imageManifestValue.sha256 !== image.sha256 || imageManifestValue.architecture !== image.architecture + || imageManifestValue.version !== image.version) throw new Error("Refusing changed channel image manifest after verification"); +run("cp", [imageManifestCandidate, imageManifestPath]); +run("cp", [imageProvenanceCandidate, imageProvenancePath]); +function validateImageProvenance(value, { requireCurrentCandidate = false } = {}) { + if (value?.schema !== 1 || value?.kind !== "1helm-channel-image-provenance" + || value?.repository !== stable.repository || value?.ref !== "refs/heads/main" + || !/^\d+$/.test(String(value?.candidate_workflow_run_id || "")) + || !/^\d+$/.test(String(value?.source_ci_run_id || "")) + || !/^[a-f0-9]{40}$/.test(String(value?.source_commit || "")) + || value?.artifact?.name !== image.artifact.name || value?.artifact?.sha256 !== image.sha256 + || value?.artifact?.bytes !== image.bytes || value?.manifest?.sha256 !== hash(imageManifestPath) + || value?.inputs?.containerfile_sha256 !== image.inputs.containerfile_sha256 + || value?.inputs?.context_sha256 !== image.inputs.context_sha256 + || value?.inputs?.base_image_digest !== image.inputs.base_image_digest + || value?.cache?.key !== image.cache.key || typeof value?.cache?.reused !== "boolean" + || value?.signer_workflow !== `${stable.repository}/.github/workflows/candidate.yml` + || value?.attestation_created !== true) { + throw new Error("Channel image provenance is incomplete or does not bind the immutable image contract"); + } + if (requireCurrentCandidate && String(value.candidate_workflow_run_id) !== runId) { + throw new Error("Channel image provenance is not from the exact promoted candidate workflow"); + } + if (requireCurrentCandidate && value.source_commit !== stable.commit) { + throw new Error("Channel image provenance is not from the exact promoted source commit"); + } +} +validateImageProvenance(JSON.parse(readFileSync(imageProvenancePath, "utf8")), { requireCurrentCandidate: true }); +const imageRemote = await remoteTagAndRelease(imageTag, githubToken); +const imageRelease = imageRemote.release; +const expectedImageAssets = [ + { name: image.artifact.name, sha256: image.sha256 }, + { name: imageManifestName, sha256: hash(imageManifestPath) }, + { name: imageProvenanceName, sha256: hash(imageProvenancePath) }, +]; +function assertImageReleaseAssets(release, { draft }) { + if (!release || release.draft !== draft || release.prerelease || release.tag_name !== imageTag) { + throw new Error("Channel image Release identity is not the expected immutable state"); + } + if (!Array.isArray(release.assets) || release.assets.length !== expectedImageAssets.length) { + throw new Error("Channel image Release asset set is incomplete or unexpected"); + } + for (const item of expectedImageAssets) { + const url = `https://github.com/${stable.repository}/releases/download/${imageTag}/${item.name}`; + if (release.assets.filter((asset) => asset?.name === item.name && asset?.digest === `sha256:${item.sha256}` + && asset?.browser_download_url === url).length !== 1) { + throw new Error(`Channel image Release does not match ${item.name}`); + } + } +} +if (imageRelease) { + // A reused image's provenance belongs to the candidate that first published + // these immutable bytes. Validate that retained record separately from the + // current candidate's honest cache-reuse provenance above. + if (imageRelease.draft || imageRelease.prerelease || imageRelease.tag_name !== imageTag + || !Array.isArray(imageRelease.assets) || imageRelease.assets.length !== expectedImageAssets.length) { + throw new Error("Existing channel image Release identity or asset set is incomplete"); + } + for (const item of expectedImageAssets.slice(0, 2)) { + const url = `https://github.com/${stable.repository}/releases/download/${imageTag}/${item.name}`; + if (imageRelease.assets.filter((asset) => asset?.name === item.name && asset?.digest === `sha256:${item.sha256}` + && asset?.browser_download_url === url).length !== 1) throw new Error(`Existing channel image Release does not match ${item.name}`); + } + const provenanceMatches = imageRelease.assets.filter((asset) => asset?.name === imageProvenanceName + && /^sha256:[a-f0-9]{64}$/.test(String(asset?.digest || "")) + && asset?.browser_download_url === `https://github.com/${stable.repository}/releases/download/${imageTag}/${imageProvenanceName}`); + if (provenanceMatches.length !== 1) throw new Error("Existing channel image Release provenance identity is missing or duplicated"); + const provenanceResponse = await fetch(provenanceMatches[0].browser_download_url, { + headers: { accept: "application/json", "user-agent": "1helm-stable-promotion" }, signal: AbortSignal.timeout(10_000), + }); + if (!provenanceResponse.ok) throw new Error(`Could not download existing channel image provenance: HTTP ${provenanceResponse.status}`); + const provenanceBytes = Buffer.from(await provenanceResponse.arrayBuffer()); + if (sha256(provenanceBytes) !== provenanceMatches[0].digest.slice(7)) throw new Error("Existing channel image provenance digest does not match GitHub"); + validateImageProvenance(JSON.parse(provenanceBytes.toString("utf8"))); +} else { + run("git", ["tag", "-a", imageTag, stable.commit, "-m", `1Helm channel image v${image.version} ${image.architecture} sha256:${image.sha256}`]); + run("git", ["push", "origin", `refs/tags/${imageTag}:refs/tags/${imageTag}`]); + run("gh", ["release", "create", imageTag, imagePath, imageManifestPath, imageProvenancePath, "--repo", stable.repository, "--verify-tag", + "--draft", + "--title", `1Helm immutable channel image ${image.architecture} sha256:${image.sha256.slice(0, 16)}`, + "--notes", `Immutable channel-machine OCI contract v${image.version}; architecture ${image.architecture}; SHA-256 ${image.sha256}. Retain for application rollback.`]); + const imageResponse = await fetch(`https://api.github.com/repos/${stable.repository}/releases/tags/${encodeURIComponent(imageTag)}`, { + headers: { accept: "application/vnd.github+json", authorization: `Bearer ${githubToken}`, "user-agent": "1helm-stable-promotion", "x-github-api-version": "2022-11-28" }, + redirect: "error", signal: AbortSignal.timeout(10_000), + }); + if (!imageResponse.ok) throw new Error(`Could not verify draft channel image Release assets: GitHub API ${imageResponse.status}`); + assertImageReleaseAssets(await imageResponse.json(), { draft: true }); + run("gh", ["release", "edit", imageTag, "--repo", stable.repository, "--draft=false"]); +} + // GitHub cannot atomically create an annotated tag and Release. Push the one // immutable tag only after every check, then create the complete Release in one // command. A failure after the push strands this version; the tag is never diff --git a/scripts/stable-manifest-lib.mjs b/scripts/stable-manifest-lib.mjs index d002ea6..ba61f24 100644 --- a/scripts/stable-manifest-lib.mjs +++ b/scripts/stable-manifest-lib.mjs @@ -1,9 +1,11 @@ import { createHash } from "node:crypto"; import { closeSync, openSync, readFileSync, readSync } from "node:fs"; +import { normalizeChannelImageManifest, offlineBundleName } from "./artifact-contract.mjs"; export const STABLE_MANIFEST_KIND = "1helm-promoted-stable"; export const STABLE_REPOSITORY = "gitcommit90/1Helm"; -export const STABLE_ARTIFACT_ROLES = Object.freeze(["mac_dmg", "mac_updater_zip", "linux_tgz"]); +export const LEGACY_STABLE_ARTIFACT_ROLES = Object.freeze(["mac_dmg", "mac_updater_zip", "linux_tgz"]); +export const STABLE_ARTIFACT_ROLES = Object.freeze(["mac_dmg", "mac_updater_zip", "linux_tgz", "linux_offline_tgz"]); const VERSION = /^\d+\.\d+\.\d+$/; const HEX40 = /^[a-f0-9]{40}$/; @@ -27,6 +29,7 @@ export function stableArtifactNames(version) { mac_dmg: `1Helm-${version}-arm64.dmg`, mac_updater_zip: `1Helm-${version}-mac-arm64.zip`, linux_tgz: `1Helm-${version}-linux-node.tgz`, + linux_offline_tgz: offlineBundleName(version), }; } @@ -35,7 +38,7 @@ function refuse(message) { } export function validateStableManifest(value) { - if (!value || Array.isArray(value) || value.schema !== 1 || value.kind !== STABLE_MANIFEST_KIND) { + if (!value || Array.isArray(value) || ![1, 2].includes(value.schema) || value.kind !== STABLE_MANIFEST_KIND) { refuse("schema or kind mismatch"); } if (value.repository !== STABLE_REPOSITORY || value.ref !== "refs/heads/main") { @@ -51,14 +54,15 @@ export function validateStableManifest(value) { || !HEX64.test(String(promotion.manifest_sha256 || ""))) { refuse("promotion identity is incomplete"); } - if (!Array.isArray(value.artifacts) || value.artifacts.length !== STABLE_ARTIFACT_ROLES.length) { + const roles = value.schema === 1 ? LEGACY_STABLE_ARTIFACT_ROLES : STABLE_ARTIFACT_ROLES; + if (!Array.isArray(value.artifacts) || value.artifacts.length !== roles.length) { refuse("desktop artifact matrix is incomplete"); } const names = stableArtifactNames(version); const byRole = new Map(); for (const artifact of value.artifacts) { const role = String(artifact?.role || ""); - if (!STABLE_ARTIFACT_ROLES.includes(role) || byRole.has(role)) refuse("desktop artifact roles are invalid or duplicated"); + if (!roles.includes(role) || byRole.has(role)) refuse("desktop artifact roles are invalid or duplicated"); const digest = String(artifact.sha256 || ""); const expectedUrl = `https://github.com/${STABLE_REPOSITORY}/releases/download/v${version}/${names[role]}`; if (artifact.name !== names[role] || !HEX64.test(digest) || artifact.url !== expectedUrl) { @@ -69,6 +73,11 @@ export function validateStableManifest(value) { } byRole.set(role, { ...artifact, sha256: digest }); } + let channelImage; + if (value.schema === 2) { + try { channelImage = normalizeChannelImageManifest(value.channel_image, { requireUrl: true }); } + catch (error) { refuse(error.message); } + } return { ...value, version, @@ -78,7 +87,8 @@ export function validateStableManifest(value) { candidate_artifact_id: String(promotion.candidate_artifact_id), manifest_sha256: String(promotion.manifest_sha256), }, - artifacts: STABLE_ARTIFACT_ROLES.map((role) => byRole.get(role)), + artifacts: roles.map((role) => byRole.get(role)), + ...(channelImage ? { channel_image: channelImage } : {}), }; } @@ -105,6 +115,13 @@ export function validateManifestRelease(manifestValue, release) { refuse(`GitHub Release does not match ${artifact.role}`); } } + if (manifest.schema === 2) { + const image = manifest.channel_image; + const imageMatches = releaseAssets.filter((asset) => asset?.name === image.artifact.name); + // The channel image has its own immutable digest-addressed Release. It must + // not be uploaded again into each application Release. + if (imageMatches.length) refuse("application Release unexpectedly duplicates the shared channel image bytes"); + } return manifest; } diff --git a/scripts/verify-promotion-attestation.mjs b/scripts/verify-promotion-attestation.mjs index 2145118..aa5fa99 100644 --- a/scripts/verify-promotion-attestation.mjs +++ b/scripts/verify-promotion-attestation.mjs @@ -6,18 +6,24 @@ import { sha256File } from "./stable-manifest-lib.mjs"; const bundle = resolve(process.env.HELM_PROMOTION_BUNDLE || ""); const promotion = JSON.parse(readFileSync(resolve(bundle, "promotion.json"), "utf8")); -const linux = (Array.isArray(promotion?.artifacts) ? promotion.artifacts : []).find((item) => item?.role === "linux_tgz"); -const path = resolve(bundle, String(linux?.path || "")); -const rel = relative(bundle, path); -if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || lstatSync(path).isSymbolicLink() - || !/^[a-f0-9]{64}$/.test(String(linux?.sha256 || "")) - || sha256File(path) !== linux.sha256 - || !/^[a-f0-9]{40}$/.test(String(promotion?.commit || ""))) { - throw new Error("Refusing invalid Linux artifact identity before attestation verification"); +const subjects = [ + ...(Array.isArray(promotion?.artifacts) ? promotion.artifacts.filter((item) => ["linux_tgz", "linux_offline_tgz"].includes(item?.role)) : []), + promotion?.channel_image?.candidate?.artifact, +]; +if (subjects.length !== 3 || !/^[a-f0-9]{40}$/.test(String(promotion?.commit || ""))) { + throw new Error("Refusing incomplete Linux/image attestation subject set"); +} +for (const subject of subjects) { + const path = resolve(bundle, String(subject?.path || "")); + const rel = relative(bundle, path); + if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || lstatSync(path).isSymbolicLink() + || !/^[a-f0-9]{64}$/.test(String(subject?.sha256 || "")) || sha256File(path) !== subject.sha256) { + throw new Error("Refusing invalid Linux/image artifact identity before attestation verification"); + } + execFileSync("gh", ["attestation", "verify", path, + "--repo", "gitcommit90/1Helm", + "--signer-workflow", "gitcommit90/1Helm/.github/workflows/candidate.yml", + "--source-ref", "refs/heads/main", + "--source-digest", promotion.commit, + "--deny-self-hosted-runners"], { stdio: "inherit" }); } -execFileSync("gh", ["attestation", "verify", path, - "--repo", "gitcommit90/1Helm", - "--signer-workflow", "gitcommit90/1Helm/.github/workflows/candidate.yml", - "--source-ref", "refs/heads/main", - "--source-digest", promotion.commit, - "--deny-self-hosted-runners"], { stdio: "inherit" }); diff --git a/scripts/windows-acceptance-evidence.mjs b/scripts/windows-acceptance-evidence.mjs index 5841844..ba018c3 100644 --- a/scripts/windows-acceptance-evidence.mjs +++ b/scripts/windows-acceptance-evidence.mjs @@ -22,7 +22,11 @@ const evidence = normalizePlatformEvidence({ 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 }], + artifacts: [ + { role: "linux_tgz", name: basename(env.HELM_CANDIDATE_ARCHIVE), sha256: candidate.artifact.sha256, bytes: candidate.artifact.bytes }, + { role: "linux_offline_tgz", name: basename(env.HELM_CANDIDATE_OFFLINE_ARCHIVE), sha256: candidate.offline_bundle.sha256, bytes: candidate.offline_bundle.bytes }, + ], + channel_image: candidate.sealed_oci, 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.") }, diff --git a/site/content.mjs b/site/content.mjs index 97baa49..1ba70e6 100644 --- a/site/content.mjs +++ b/site/content.mjs @@ -30,7 +30,7 @@ const computerDocs = doc("/manual/channel-computers", "Channel computers", "How const connections = doc("/manual/connections", "Connections", "How Gmail, Photon/iMessage, and future host brokers expose narrow capabilities without leaking credentials or personal data.", `

A connection is a host-owned broker, not a secret copied into every resident's shell.

Gmail

When Gmail OAuth accounts exist on the 1Helm host, Captain-authorized Skipper can grant a resident access to named accounts. Current resident operations are account listing, search, message retrieval, and draft creation. Sending is disabled by default.

Photon and iMessage

The connector uses Photon's device-code login and Dashboard/Spectrum APIs to create or reuse a 1Helm project, rotate its one-time secret, register the operator, discover the assigned line, and start a long-lived spectrum-ts gRPC stream in a supervised loopback sidecar.

Only the configured Captain phone is accepted. Its first inbound text opens a durable conversation with Skipper in the Captain's private #main Texts tab; replies return to that same phone conversation. The context survives connector restarts and Photon space-ID changes until the Captain sends /new. The Captain can continue any saved Texts thread on desktop, and desktop-only turns are not replayed as iMessages. Credentials never enter a resident computer.

Current limitation: the reliable contract is text. Attachment events are represented conservatively and full attachment fidelity is still being verified.

Connector standard

New native connections must provide least-privilege scoping, secret isolation, reconnect/recovery, deduplication, an audit trail, deterministic tests, and honest capability status. A prompt saying “use Slack” is not a connector.

`); const installMac = doc("/manual/install-macos", "Install on macOS", "Install the signed, notarized Apple Silicon 1Helm app and initialize per-channel Linux computers.", `

The native consumer product currently targets Apple Silicon Macs.

Requirements

  • Apple Silicon Mac (arm64).
  • macOS 26 for Apple's container runtime.
  • Administrator approval once during verified runtime installation.

Install

  1. Download the current DMG.
  2. Open it and drag 1Helm to Applications.
  3. Open 1Helm. Gatekeeper verifies the Developer ID signature and notarization ticket.
  4. Complete Captain → Providers → Workspace. Approve Apple's signed runtime inline if requested.

Data and upgrades

This generation stores application state under ~/Library/Application Support/1Helm-OCI-v1. Profile → Check for updates asks the Mac hosting 1Helm to download and verify the signed, notarized update. When the host reports it ready, Restart & install quiesces the local service and replaces the app. The browser is never given a DMG as the update action, and Application Support remains in place.

Removal

Use Settings → Admin → Prepare to remove 1Helm before trashing the app. This removes only verified 1Helm-owned channel machines while preserving the application state for a future reinstall.

${button("/download/macos", "Download current DMG", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); -const installLinux = doc("/manual/install-linux", "Install on Linux", "Install 1Helm as a durable systemd service with one OCI container per resident.", `

Linux is a supported headless host product. It persists the control plane under systemd and gives every ordinary channel its own durable Podman container.

Supported baseline

Ubuntu or Debian with systemd and apt, cgroup v2, an x86-64 or arm64 CPU, 4 GiB RAM minimum (8 GiB recommended), and 20 GiB free disk. Each real workload needs additional storage. Nested deployments must permit Podman and delegated cgroups.

${code("linux-install", "curl -fsSLo /tmp/1helm-install.sh https://1helm.com/install.sh\nless /tmp/1helm-install.sh\nsudo bash /tmp/1helm-install.sh", "bash")}

The installer verifies architecture, installs an exact official Node runtime after checking its published SHA-256 manifest, installs Podman and the fixed root-owned OCI helper, creates a restricted 1helm service account, stores control-plane and runtime state in /var/lib/1helm-oci-v1, and atomically switches /opt/1helm/current.

Host-owned updates

A Captain update action creates one private request file. The host—not the browser—downloads the exact stable Linux release artifact, requires GitHub's SHA-256 asset digest, applies the fixed application and OCI contract, restarts, health-checks, and restores the prior release and runtime files on failure.

Open the UI

By default the service listens on port 8123. Use a firewall and an HTTPS reverse proxy before exposing it to the public internet. First boot opens Captain creation.

${code("linux-status", "sudo systemctl status 1helm --no-pager\ncurl -fsS http://127.0.0.1:8123/api/setup/status\nsudo journalctl -u 1helm -f", "bash")}

Back up and remove

Stop the service, then copy /var/lib/1helm-oci-v1 as one coherent unit. The installed /opt/1helm/uninstall-host.sh deletes only exact ownership-checked channel containers and preserves durable recovery state.

`); +const installLinux = doc("/manual/install-linux", "Install on Linux", "Install 1Helm as a durable systemd service with one OCI container per resident.", `

Linux is a supported headless host product. It persists the control plane under systemd and gives every ordinary channel its own durable Podman container.

Supported baseline

Ubuntu or Debian with systemd and apt, cgroup v2, an x86-64 or arm64 CPU, 4 GiB RAM minimum (8 GiB recommended), and 20 GiB free disk. Each real workload needs additional storage. Nested deployments must permit Podman and delegated cgroups.

${code("linux-install", "curl -fsSLo /tmp/1helm-install.sh https://1helm.com/install.sh\nless /tmp/1helm-install.sh\nsudo bash /tmp/1helm-install.sh", "bash")}

The installer verifies architecture, installs an exact official Node runtime after checking its published SHA-256 manifest, installs Podman and the fixed root-owned OCI helper, creates a restricted 1helm service account, stores control-plane and runtime state in /var/lib/1helm-oci-v1, and atomically switches /opt/1helm/current.

Smaller online downloads

The ordinary app archive references the sealed channel image by exact SHA-256, architecture, and contract version instead of embedding it. A verified image already on the host is reused across app updates; otherwise it is downloaded and verified separately. This saves about 252 MB (62.74%) from the measured v0.0.41 app download. Disconnected installs use the explicit complete linux-node-offline.tgz bundle. If image metadata or bytes do not match, installation stops; retry online or use that offline bundle. Prior rollback images are retained and image garbage collection is report-only.

Host-owned updates

A Captain update action creates one private request file. The host—not the browser—downloads the exact stable Linux release artifact, requires its promoted SHA-256, applies the fixed application and OCI contract, restarts, health-checks, and restores the prior release and runtime files on failure.

Open the UI

By default the service listens on port 8123. Use a firewall and an HTTPS reverse proxy before exposing it to the public internet. First boot opens Captain creation.

${code("linux-status", "sudo systemctl status 1helm --no-pager\ncurl -fsS http://127.0.0.1:8123/api/setup/status\nsudo journalctl -u 1helm -f", "bash")}

Back up and remove

Stop the service, then copy /var/lib/1helm-oci-v1 as one coherent unit. The installed /opt/1helm/uninstall-host.sh deletes only exact ownership-checked channel containers and preserves durable recovery state.

`); const installWsl = doc("/manual/install-windows", "Install on Windows", "Install 1Helm on Windows 11 x64 with one PowerShell command, run twice around a single restart. No application, no installer, no SmartScreen - your browser is the interface.", `

There is nothing to download and no Windows application to install. 1Helm runs its ordinary Linux build inside a WSL 2 distribution named 1helm, and your browser is the interface at http://localhost:8123. Because no .exe ships, nothing needs code signing and SmartScreen never appears.

What you need

  • Windows 11 on an x64 processor. Arm64 Windows is not supported by this build.
  • Virtualization enabled in firmware, as WSL 2 requires.
  • Internet access, and roughly 10 GB of free disk.
  • One Windows restart, partway through.
  • You do not need to install WSL first. The command below does that for you.

Install

The whole install is one command, run twice, with a Windows restart in between. This is the command:

${code("win-install", "irm https://1helm.com/install.ps1 | iex", "powershell")}

Now, in order:

  1. Open PowerShell. The ordinary one — do not choose “Run as Administrator”.
  2. Paste the command above and press Enter.
  3. A Windows permission pop-up appears. Click Yes.
  4. Wait about a minute. The window ends by printing “Restart required” and a short numbered list. That is normal. It is not an error and nothing is lost.
  5. Restart the PC.
  6. Sign back in as the same Windows user, and open PowerShell again.
  7. Paste the same command again and press Enter.
  8. Wait about six and a half minutes. Pages of apt output scroll past; that is normal progress. When it is finished it prints the address and opens your default browser on the onboarding page.
  9. Create the Captain, connect a provider, and name the workspace.

Measured end to end on a real Windows 11 machine: about 8 minutes 49 seconds, restart included.

Why the permission pop-up, and why only there

Exactly two operations need administrator rights: turning on the Windows optional features Microsoft-Windows-Subsystem-Linux and VirtualMachinePlatform, and installing Microsoft’s own WSL package. Those run in one separate elevated pass, and that pass is the only pop-up you see. Microsoft’s WSL installer is checked against a pinned SHA-256 and required to carry a valid Microsoft Authenticode signature before it is run.

Everything after that — importing the distribution, installing 1Helm inside it, registering the keepalive — deliberately runs as the signed-in user, because WSL state is per-user. A distribution imported by an elevated session started with different credentials would belong to that administrator instead of to the person using the machine. That is also why step 6 says the same Windows user.

Why the restart

Windows cannot activate those two features without restarting. The first run says so plainly and stops; the second run detects what is already done and carries on from there. Every step is idempotent, so running the command again is always safe.

Things you may see

  • Microsoft’s “Welcome to WSL” window. It may open during the second run. It is Microsoft’s own window, it is harmless, and you can close it.
  • A wait after 1Helm reports it is running. The channel-computer runtime needs roughly another 40 seconds to finish preparing before your first channel computer can be created. It has not hung.
  • Pages of apt output. That is the Linux installer doing the long step — the container runtime and the channel image.

If you download the script instead of piping it

Windows blocks running downloaded .ps1 files, so a saved copy needs the explicit form:

${code("win-bypass", "powershell -NoProfile -ExecutionPolicy Bypass -File .\\install.ps1", "powershell")}

The irm … | iex one-liner is unaffected, because it pipes a string into PowerShell rather than executing a file.

Using it

Open http://localhost:8123 in any browser on that PC; the installer also adds a Start Menu shortcut that opens the same address in your default browser. WSL tears an idle distribution down seconds after its last session closes, so the installer registers a per-user scheduled task — the keepalive — that holds the distribution open, starts again when you sign in, and restarts 1Helm’s service if it stops.

One behaviour difference from earlier versions: #main’s Terminal is now bash inside the WSL distribution, not cmd.exe. Windows commands do not work there. That is deliberate — the host is Linux now.

Updates

A Windows host updates exactly like a Linux host, because it is one: the root-owned updater inside the distribution downloads the exact stable Linux release artifact, requires its published SHA-256 digest, installs into a versioned directory, switches atomically, health-checks, and restores the previous release if anything fails. There is no Windows update feed and no Windows artifact, so there is nothing to sign and no signing status to disclose.

Uninstall

From an ordinary PowerShell window, signed in as the user who installed it:

${code("win-uninstall", "irm https://1helm.com/uninstall.ps1 | iex", "powershell")}

It stops the keepalive, runs 1Helm’s own Linux uninstaller inside the distribution so its containers and services come out cleanly, then unregisters the 1helm distribution and removes C:\\1helm and the Start Menu shortcut. Other WSL distributions on the PC are never touched, and Windows’ own WSL feature is left installed.

This destroys data. Unregistering the distribution deletes its virtual disk, and every channel’s files, the workspace database, and your provider credentials all live on that disk. There is no undo and nothing is copied to Windows first, so download anything irreplaceable from http://localhost:8123 before you start. The script asks you to type remove first; -Force skips that prompt and exists only for scripted removal.

Troubleshooting

It printed “Restart required” and stopped. That is the expected halfway point, not a failure. Restart, sign back in as the same Windows user, open PowerShell, and run the same command again.

“Running scripts is disabled on this system.” You are running a downloaded .ps1 file. Use the -ExecutionPolicy Bypass -File form above, or use the irm … | iex one-liner, which is not affected.

A “Welcome to WSL” window opened. That is Microsoft’s, not ours. Close it and carry on.

Port 8123 is already in use. Windows and every WSL distribution share one network namespace, so anything already listening on 8123 — another distribution, or an ordinary Windows process — stops 1Helm binding it. The installer refuses to continue rather than half-install, and names the port. Stop whatever owns it, then run the command again.

Do I need to install WSL first? No. Nothing to prepare, nothing to download.

The browser cannot reach the address. Give it a moment after the installer finishes, then ask the service inside the distribution how it is doing:

${code("win-status", "wsl -d 1helm -u root --exec systemctl status 1helm", "powershell")}

Arm64 Windows. Not supported by this build. The installer checks first and stops with that exact reason.

${button("/manual/getting-started", "Getting started", "primary")}${button("/manual/install-linux", "Linux guide")}
`); const selfHosting = doc("/manual/self-hosting", "Self-hosting", "Ports, state, backups, HTTPS, upgrades, health checks, and platform boundaries for self-hosted 1Helm.", `

1Helm is the server. The public 1helm.com website is documentation and release distribution, not a dependency of your installed workspace.

Ports

The source runtime defaults to 8123. Native desktop apps choose an ephemeral loopback port. The standalone product website uses 8130. These are separate processes and data trees.

State

Set CTRL_DATA_DIR to a persistent, restricted directory. Never place it in a public web root. Back it up only while the service is stopped or with a filesystem/database-consistent snapshot.

HTTPS

Use Settings → Domains for a workspace-managed Cloudflare tunnel, or put a conventional HTTPS reverse proxy in front of a headless host. Preserve WebSocket upgrades and do not strip Authorization headers.

Health

${code("health", "curl -fsS http://127.0.0.1:8123/api/setup/status\nsystemctl is-active 1helm\njournalctl -u 1helm --since '15 minutes ago'", "bash")}

Upgrades

Use a unique released version. Stop the service, take a state backup, install the tagged source, run npm ci and npm run build, then restart and verify health. Database migrations are additive, but rollback still requires the pre-upgrade data backup.

Resource guidance

A minimal control plane can run in 4 GiB RAM; 8 GiB is a more practical baseline. Model inference usually remains at connected providers, but browser automation, builds, media processing, and several concurrent residents increase CPU, RAM, and storage demand.

`); diff --git a/site/public/apply-linux-release.sh b/site/public/apply-linux-release.sh index 6f82201..ac0c553 100755 --- a/site/public/apply-linux-release.sh +++ b/site/public/apply-linux-release.sh @@ -58,6 +58,9 @@ PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' " && -r "$RELEASE_ROOT/deploy/1helm-oci-runtime-v1.conf" \ && -r "$RELEASE_ROOT/container/Containerfile.oci" ]] \ || { echo "The verified release is missing its Linux host contract." >&2; exit 1; } +{ [[ -f "$RELEASE_ROOT/resources/channel-image.json" ]] \ + || [[ -f "$RELEASE_ROOT/container/channel-machine.oci.tar" && -f "$RELEASE_ROOT/container/channel-machine.oci.sha256" ]]; } \ + || { echo "The verified release is missing its split or legacy channel image contract." >&2; exit 1; } TEMP_ROOT="$(mktemp -d)" chmod 0700 "$TEMP_ROOT" diff --git a/site/public/install-oci-runtime.sh b/site/public/install-oci-runtime.sh index 872b296..db8e386 100755 --- a/site/public/install-oci-runtime.sh +++ b/site/public/install-oci-runtime.sh @@ -8,16 +8,11 @@ HELPER_PATH="/usr/libexec/1helm-oci-runtime" MANIFEST_PATH="/etc/1helm/oci-runtime-v1.conf" RECIPE_ROOT="/usr/lib/1helm-oci" SUDOERS_PATH="/etc/sudoers.d/1helm-oci-runtime" +IMAGE_STORE="$STATE_ROOT/shared-images/sha256" [[ "${EUID}" -eq 0 ]] || { echo "The OCI runtime installer must run as root." >&2; exit 1; } [[ -x "$APP_SOURCE/scripts/1helm-oci-runtime" && -r "$APP_SOURCE/deploy/1helm-oci-runtime-v1.conf" && -r "$APP_SOURCE/container/Containerfile.oci" ]] \ || { echo "The verified 1Helm release is missing its OCI runtime contract." >&2; exit 1; } -[[ -f "$APP_SOURCE/container/channel-machine.oci.tar" && -f "$APP_SOURCE/container/channel-machine.oci.sha256" ]] \ - || { echo "The verified 1Helm release is missing its sealed channel computer image (container/channel-machine.oci.tar)." >&2; exit 1; } -expected_image_sha="$(tr -d '[:space:]' <"$APP_SOURCE/container/channel-machine.oci.sha256")" -[[ "$expected_image_sha" =~ ^[a-f0-9]{64}$ ]] || { echo "The sealed channel image digest file is invalid." >&2; exit 1; } -actual_image_sha="$(sha256sum "$APP_SOURCE/container/channel-machine.oci.tar" | awk '{print $1}')" -[[ "$actual_image_sha" == "$expected_image_sha" ]] || { echo "The sealed channel image digest does not match." >&2; exit 1; } id "$SERVICE_USER" >/dev/null 2>&1 || { echo "The 1Helm service account does not exist." >&2; exit 1; } command -v apt-get >/dev/null || { echo "The OCI Linux runtime currently requires Ubuntu or Debian with apt." >&2; exit 1; } @@ -30,6 +25,83 @@ fi for command in crun find flock getfacl iptables podman python3 setfacl sha256sum stat sudo tar visudo; do command -v "$command" >/dev/null || { echo "Missing OCI prerequisite after setup: $command" >&2; exit 1; }; done [[ "$(stat -fc %T /sys/fs/cgroup)" == cgroup2fs ]] || { echo "1Helm OCI resource controls require cgroup v2." >&2; exit 1; } +# Phase 5 stores sealed images independently from application releases. A +# verified digest-addressed copy is shared by every app version and retained for +# rollback. A v0.0.41-style complete archive remains accepted as the legacy +# branch. Missing or malformed split metadata never falls back to an unbound +# download. +resolve_channel_image() { + local manifest="$APP_SOURCE/resources/channel-image.json" legacy_tar="$APP_SOURCE/container/channel-machine.oci.tar" + local legacy_sha="$APP_SOURCE/container/channel-machine.oci.sha256" arch fields expected_image_sha image_bytes image_name image_url manifest_url + case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) echo "Unsupported architecture: $(uname -m)" >&2; return 1 ;; esac + if [[ ! -f "$manifest" ]]; then + [[ -f "$legacy_tar" && -f "$legacy_sha" ]] || { echo "The verified release has neither a split channel image manifest nor a legacy complete image." >&2; return 1; } + expected_image_sha="$(tr -d '[:space:]' <"$legacy_sha")" + [[ "$expected_image_sha" =~ ^[a-f0-9]{64}$ && "$(sha256sum "$legacy_tar" | awk '{print $1}')" == "$expected_image_sha" ]] \ + || { echo "The legacy sealed channel image digest does not match." >&2; return 1; } + RESOLVED_IMAGE_TAR="$legacy_tar" + RESOLVED_IMAGE_MANIFEST="" + return 0 + fi + fields="$(python3 - "$manifest" "$arch" <<'PY' +import json, os, re, sys +path, host_arch = sys.argv[1:] +try: value=json.load(open(path, encoding="utf-8")) +except (OSError, ValueError): raise SystemExit("channel image manifest is not valid JSON") +digest=str(value.get("sha256") or "") +architecture=str(value.get("architecture") or "") +version=str(value.get("version") or "") +size=value.get("bytes") +artifact=value.get("artifact") or {} +name=str(artifact.get("name") or "") +url=str(artifact.get("url") or "") +manifest_url=str(artifact.get("manifest_url") or "") +if value.get("schema") != 1 or value.get("kind") != "1helm-sealed-channel-image" or version != "1": raise SystemExit("channel image manifest schema/version mismatch") +if architecture != host_arch: raise SystemExit(f"channel image architecture {architecture} does not match host {host_arch}") +if not re.fullmatch(r"[a-f0-9]{64}", digest) or not isinstance(size, int) or size < 1: raise SystemExit("channel image byte identity is invalid") +expected=f"1Helm-channel-machine-v1-{architecture}-{digest}.oci.tar" +tag=f"channel-image-v1-{architecture}-{digest}" +base=f"https://github.com/gitcommit90/1Helm/releases/download/{tag}" +if name != expected or url != f"{base}/{expected}" or manifest_url != f"{base}/1Helm-channel-machine-v1-{architecture}-{digest}.json": raise SystemExit("channel image URLs are not immutable digest-addressed URLs") +print(digest); print(size); print(name); print(url); print(manifest_url) +PY +)" || { echo "The verified release's channel image manifest was refused." >&2; return 1; } + mapfile -t IMAGE_FIELDS <<<"$fields" + [[ "${#IMAGE_FIELDS[@]}" -eq 5 ]] || return 1 + expected_image_sha="${IMAGE_FIELDS[0]}"; image_bytes="${IMAGE_FIELDS[1]}"; image_name="${IMAGE_FIELDS[2]}" + image_url="${IMAGE_FIELDS[3]}"; manifest_url="${IMAGE_FIELDS[4]}" + local retained="$IMAGE_STORE/$expected_image_sha" retained_tar="$IMAGE_STORE/$expected_image_sha/$image_name" + local retained_manifest="$IMAGE_STORE/$expected_image_sha/manifest.json" temp + install -d -o root -g root -m 0700 "$IMAGE_STORE" "$retained" + if [[ -f "$retained_tar" && ! -L "$retained_tar" && "$(stat -c %s "$retained_tar")" == "$image_bytes" \ + && "$(sha256sum "$retained_tar" | awk '{print $1}')" == "$expected_image_sha" && -f "$retained_manifest" \ + && "$(sha256sum "$retained_manifest" | awk '{print $1}')" == "$(sha256sum "$manifest" | awk '{print $1}')" ]]; then + printf 'Reusing verified shared channel image sha256:%s.\n' "$expected_image_sha" + elif [[ -f "$legacy_tar" && "$(stat -c %s "$legacy_tar")" == "$image_bytes" \ + && "$(sha256sum "$legacy_tar" | awk '{print $1}')" == "$expected_image_sha" ]]; then + install -o root -g root -m 0600 "$legacy_tar" "$retained_tar" + install -o root -g root -m 0600 "$manifest" "$retained_manifest" + printf 'Retained offline channel image sha256:%s for shared reuse.\n' "$expected_image_sha" + else + temp="$(mktemp -d)" + curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$temp/manifest.json" "$manifest_url" \ + || { rm -rf -- "$temp"; echo "The referenced channel image manifest could not be downloaded." >&2; return 1; } + cmp -s "$temp/manifest.json" "$manifest" \ + || { rm -rf -- "$temp"; echo "The downloaded channel image manifest does not match the application release." >&2; return 1; } + curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$temp/image.tar" "$image_url" \ + || { rm -rf -- "$temp"; echo "The referenced channel image could not be downloaded." >&2; return 1; } + [[ "$(stat -c %s "$temp/image.tar")" == "$image_bytes" && "$(sha256sum "$temp/image.tar" | awk '{print $1}')" == "$expected_image_sha" ]] \ + || { rm -rf -- "$temp"; echo "The downloaded channel image bytes do not match their manifest." >&2; return 1; } + install -o root -g root -m 0600 "$temp/image.tar" "$retained_tar" + install -o root -g root -m 0600 "$temp/manifest.json" "$retained_manifest" + rm -rf -- "$temp" + fi + RESOLVED_IMAGE_TAR="$retained_tar" + RESOLVED_IMAGE_MANIFEST="$retained_manifest" +} +resolve_channel_image +expected_image_sha="$(sha256sum "$RESOLVED_IMAGE_TAR" | awk '{print $1}')" + # Ubuntu ships an AppArmor attachment for /usr/bin/crun whose nominally # unconfined profile can still inherit the outer container host's address-family # restrictions. On a nested systemd host that manifests inside every resident @@ -61,9 +133,12 @@ install -d -o root -g root -m 0711 "$STATE_ROOT/runtime/oci" "$STATE_ROOT/runtim install -d -o root -g root -m 0700 "$STATE_ROOT/runtime/oci/storage" "$STATE_ROOT/runtime/oci/backups" "$STATE_ROOT/runtime/oci/networks" install -o root -g root -m 0644 "$APP_SOURCE/deploy/1helm-oci-runtime-v1.conf" "$MANIFEST_PATH" install -o root -g root -m 0644 "$APP_SOURCE/container/Containerfile.oci" "$RECIPE_ROOT/Containerfile.oci" -install -o root -g root -m 0644 "$APP_SOURCE/container/channel-machine.oci.tar" "$RECIPE_ROOT/channel-machine.oci.tar" -install -o root -g root -m 0644 "$APP_SOURCE/container/channel-machine.oci.sha256" "$RECIPE_ROOT/channel-machine.oci.sha256" -if [[ -f "$APP_SOURCE/container/channel-machine.oci.json" ]]; then +ln -sfn "$RESOLVED_IMAGE_TAR" "$RECIPE_ROOT/channel-machine.oci.tar" +printf '%s\n' "$expected_image_sha" >"$RECIPE_ROOT/channel-machine.oci.sha256" +chmod 0644 "$RECIPE_ROOT/channel-machine.oci.sha256" +if [[ -n "$RESOLVED_IMAGE_MANIFEST" ]]; then + ln -sfn "$RESOLVED_IMAGE_MANIFEST" "$RECIPE_ROOT/channel-machine.oci.json" +elif [[ -f "$APP_SOURCE/container/channel-machine.oci.json" ]]; then install -o root -g root -m 0644 "$APP_SOURCE/container/channel-machine.oci.json" "$RECIPE_ROOT/channel-machine.oci.json" fi install -o root -g root -m 0755 "$APP_SOURCE/scripts/1helm-oci-runtime" "$HELPER_PATH" diff --git a/site/public/install.sh b/site/public/install.sh index 58d4620..7e4e638 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -240,10 +240,11 @@ fi && -x "$RELEASE_STAGE/scripts/1helm-oci-runtime" \ && -r "$RELEASE_STAGE/deploy/1helm-oci-runtime-v1.conf" \ && -r "$RELEASE_STAGE/container/Containerfile.oci" \ - && -f "$RELEASE_STAGE/container/channel-machine.oci.tar" \ - && -f "$RELEASE_STAGE/container/channel-machine.oci.sha256" \ && -x "$RELEASE_STAGE/resources/cloudflared-linux-$NODE_ARCH" ]] \ - || { echo "The verified Linux artifact is missing its complete OCI runtime contract." >&2; exit 1; } + || { echo "The verified Linux artifact is missing its split or legacy-complete OCI runtime contract." >&2; exit 1; } +{ [[ -f "$RELEASE_STAGE/resources/channel-image.json" && ! -f "$RELEASE_STAGE/container/channel-machine.oci.tar" ]] \ + || [[ -f "$RELEASE_STAGE/container/channel-machine.oci.tar" && -f "$RELEASE_STAGE/container/channel-machine.oci.sha256" ]]; } \ + || { echo "The verified Linux artifact has neither an online split nor legacy complete channel image contract." >&2; exit 1; } chown -R "$SERVICE_USER:$SERVICE_USER" "$RELEASE_STAGE" # The release arrives ready to run: no npm ci, no npm run build, no compiler and @@ -298,11 +299,11 @@ verify_ready_to_run "$RELEASE_STAGE" || exit 1 RELEASE_ROOT="$RELEASES_ROOT/$VERSION-$RELEASE_SHA256" if [[ -e "$RELEASE_ROOT" ]]; then EXISTING_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' "$RELEASE_ROOT/package.json" 2>/dev/null || true)" - [[ "$EXISTING_VERSION" == "$VERSION" \ - && -f "$RELEASE_ROOT/container/channel-machine.oci.tar" \ - && -f "$RELEASE_ROOT/container/channel-machine.oci.sha256" \ - && -x "$RELEASE_ROOT/resources/cloudflared-linux-$NODE_ARCH" ]] \ + [[ "$EXISTING_VERSION" == "$VERSION" && -x "$RELEASE_ROOT/resources/cloudflared-linux-$NODE_ARCH" ]] \ || { echo "Existing release directory does not match the verified v$VERSION Linux artifact." >&2; exit 1; } + { [[ -f "$RELEASE_ROOT/resources/channel-image.json" ]] \ + || [[ -f "$RELEASE_ROOT/container/channel-machine.oci.tar" && -f "$RELEASE_ROOT/container/channel-machine.oci.sha256" ]]; } \ + || { echo "Existing release directory is missing its channel image contract." >&2; exit 1; } # A retained directory from an interrupted earlier run can be incomplete even # though its name carries the verified digest. Prove it is still runnable # before any host file is touched. diff --git a/site/public/update-host.sh b/site/public/update-host.sh index db1c06c..8b9f67b 100755 --- a/site/public/update-host.sh +++ b/site/public/update-host.sh @@ -13,6 +13,7 @@ STATUS_FILE="$STATE_ROOT/host-update-status.json" LOCK_FILE="$INSTALL_ROOT/host-update.lock" SERVICE_NAME="1helm.service" PORT="8123" +RELEASE_METADATA_URL="https://1helm.com/api/releases/linux/latest" case "$(uname -m)" in x86_64|amd64) CONNECTOR_ARCH="x64" ;; aarch64|arm64) CONNECTOR_ARCH="arm64" ;; @@ -144,27 +145,24 @@ cleanup_transaction() { } trap cleanup_transaction EXIT -write_status "checking" "" "The 1Helm host is checking the stable release metadata." -curl -fsSL --proto '=https' --tlsv1.2 \ - -H 'Accept: application/vnd.github+json' \ - -H 'User-Agent: 1Helm-host-updater' \ - "https://api.github.com/repos/$REPOSITORY/releases/latest" \ +write_status "checking" "" "The 1Helm host is checking the exact promoted Stable release metadata." +curl -fsSL --proto '=https' --tlsv1.2 --retry 3 \ + "$RELEASE_METADATA_URL" \ -o "$TEMP_ROOT/release.json" || fail "The host could not reach the 1Helm release service." RELEASE_OUTPUT="$("$NODE_LINK/bin/node" - "$TEMP_ROOT/release.json" <<'NODE' const fs = require("node:fs"); const release = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const version = String(release.tag_name || "").replace(/^v/, ""); -if (!/^\d+\.\d+\.\d+$/.test(version) || release.draft || release.prerelease) process.exit(2); +const version = String(release.version || ""); +if (!/^\d+\.\d+\.\d+$/.test(version)) process.exit(2); const name = `1Helm-${version}-linux-node.tgz`; -const asset = (release.assets || []).find((candidate) => candidate.name === name); -const digest = String(asset?.digest || ""); -const url = String(asset?.browser_download_url || ""); +const digest = String(release.sha256 || ""); +const url = String(release.url || ""); const expectedUrl = `https://github.com/gitcommit90/1Helm/releases/download/v${version}/${name}`; -if (!asset || !/^sha256:[a-f0-9]{64}$/.test(digest) || url !== expectedUrl) process.exit(3); +if (!/^[a-f0-9]{64}$/.test(digest) || url !== expectedUrl) process.exit(3); console.log(version); console.log(url); -console.log(digest.slice(7)); +console.log(digest); NODE )" || fail "The latest release does not contain a digest-qualified Linux host artifact." mapfile -t RELEASE <<<"$RELEASE_OUTPUT" @@ -228,6 +226,8 @@ PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' " || fail "The verified Linux artifact is missing its host updater." [[ -x "$STAGE/site/public/apply-linux-release.sh" && -x "$STAGE/site/public/install-oci-runtime.sh" && -x "$STAGE/site/public/install-linux-units.sh" && -x "$STAGE/site/public/uninstall-host.sh" && -x "$STAGE/scripts/1helm-oci-runtime" && -r "$STAGE/deploy/1helm-oci-runtime-v1.conf" && -r "$STAGE/container/Containerfile.oci" && -x "$STAGE/resources/cloudflared-linux-$CONNECTOR_ARCH" ]] \ || fail "The verified Linux artifact is missing its OCI runtime contract." +{ [[ -f "$STAGE/resources/channel-image.json" ]] || [[ -f "$STAGE/container/channel-machine.oci.tar" && -f "$STAGE/container/channel-machine.oci.sha256" ]]; } \ + || fail "The verified Linux artifact has neither a split nor legacy complete channel image contract." chown -R "$SERVICE_USER:$SERVICE_USER" "$STAGE" write_status "installing" "$TARGET_VERSION" "The host verified v$TARGET_VERSION and is preparing an atomic installation." diff --git a/site/server.mjs b/site/server.mjs index 9b1cecc..77ecd75 100644 --- a/site/server.mjs +++ b/site/server.mjs @@ -161,7 +161,16 @@ async function latestLinuxRelease() { const linux = matrix[2]; const expectedUrl = `https://github.com/${REPO}/releases/download/v${version}/${linux.name}`; if (linux.url !== expectedUrl) throw new Error("latest Linux release URL does not match its version"); - return { version, url: expectedUrl, sha256: linux.sha256 }; + const response = { version, url: expectedUrl, sha256: linux.sha256 }; + if (manifest.schema === 2) { + const offline = manifest.artifacts.find((asset) => asset.role === "linux_offline_tgz"); + if (!offline || !/^[a-f0-9]{64}$/.test(String(offline.sha256 || "")) || !manifest.channel_image) { + throw new Error("latest split Linux release is missing offline or channel image metadata"); + } + response.offline = { url: offline.url, sha256: offline.sha256, bytes: offline.bytes }; + response.channel_image = manifest.channel_image; + } + return response; } const mime = { diff --git a/test/connectors.mjs b/test/connectors.mjs index e674940..3434455 100644 --- a/test/connectors.mjs +++ b/test/connectors.mjs @@ -31,6 +31,7 @@ test("Linux release packaging rejects a source copy nested under another Git che const copiedScripts = join(copiedRoot, "scripts"); await mkdir(copiedScripts); await copyFile(join(projectRoot, "scripts", "package-linux-host.mjs"), join(copiedScripts, "package-linux-host.mjs")); + await copyFile(join(projectRoot, "scripts", "artifact-contract.mjs"), join(copiedScripts, "artifact-contract.mjs")); await writeFile(join(copiedRoot, "package.json"), `${JSON.stringify({ version })}\n`); try { const result = spawnSync(process.execPath, [join(copiedScripts, "package-linux-host.mjs")], { cwd: copiedRoot, encoding: "utf8" }); diff --git a/test/phase2-candidate.mjs b/test/phase2-candidate.mjs index 47f8581..799c04b 100644 --- a/test/phase2-candidate.mjs +++ b/test/phase2-candidate.mjs @@ -15,6 +15,16 @@ function fixture() { const scratch = mkdtempSync(join(tmpdir(), "1helm-phase2-")); const prefix = join(scratch, "source", "1Helm-0.0.41"); const oci = Buffer.from("sealed OCI fixture"); + const imageDigest = sha(oci); + const imageName = `1Helm-channel-machine-v1-amd64-${imageDigest}.oci.tar`; + const imageTag = `channel-image-v1-amd64-${imageDigest}`; + const image = { + schema: 1, kind: "1helm-sealed-channel-image", version: "1", architecture: "amd64", sha256: imageDigest, bytes: oci.length, + artifact: { name: imageName, url: `https://github.com/gitcommit90/1Helm/releases/download/${imageTag}/${imageName}`, + manifest_url: `https://github.com/gitcommit90/1Helm/releases/download/${imageTag}/${imageName.replace(/\.oci\.tar$/, ".json")}` }, + inputs: { containerfile_sha256: "1".repeat(64), context_sha256: "2".repeat(64), base_image_digest: "3".repeat(64) }, + cache: { key: "4".repeat(64), reused: false }, platforms: ["linux", "windows-wsl"], + }; const identity = { schema: 1, kind: "1helm-dress-rehearsal-candidate", @@ -27,16 +37,29 @@ function fixture() { ci: { workflow: "CI", run_id: "123", conclusion: "success" }, version: "0.0.41", source_archive_sha256: "b".repeat(64), - sealed_oci_sha256: sha(oci), + sealed_oci_sha256: sha(oci), sealed_oci_cache: { ...image.cache, reused: true }, channel_image: image, }; mkdirSync(join(prefix, "resources"), { recursive: true }); mkdirSync(join(prefix, "container"), { recursive: true }); writeFileSync(join(prefix, "resources", "candidate-build.json"), JSON.stringify(identity)); writeFileSync(join(prefix, "package.json"), JSON.stringify({ version: identity.version })); - writeFileSync(join(prefix, "container", "channel-machine.oci.tar"), oci); + writeFileSync(join(prefix, "resources", "channel-image.json"), JSON.stringify(image)); const archive = join(scratch, "1Helm-0.0.41-linux-node.tgz"); execFileSync("tar", ["-czf", archive, "-C", join(scratch, "source"), "1Helm-0.0.41"]); - return { scratch, archive, identity }; + const offlinePrefix = join(scratch, "offline", "1Helm-0.0.41"); + mkdirSync(join(scratch, "offline"), { recursive: true }); + execFileSync("cp", ["-a", prefix, offlinePrefix]); + mkdirSync(join(offlinePrefix, "container"), { recursive: true }); + writeFileSync(join(offlinePrefix, "container", "channel-machine.oci.tar"), oci); + const offline = join(scratch, "1Helm-0.0.41-linux-node-offline.tgz"); + execFileSync("tar", ["-czf", offline, "-C", join(scratch, "offline"), "1Helm-0.0.41"]); + const split = join(scratch, "split.json"); + writeFileSync(split, JSON.stringify({ schema: 1, kind: "1helm-linux-split-artifacts", version: identity.version, + app: { name: "1Helm-0.0.41-linux-node.tgz", sha256: sha(readFileSync(archive)), bytes: readFileSync(archive).length, contains_channel_image: false }, + offline: { name: "1Helm-0.0.41-linux-node-offline.tgz", sha256: sha(readFileSync(offline)), bytes: readFileSync(offline).length, contains_channel_image: true }, + channel_image: image, production_dependencies: { key: "5".repeat(64), reused: false }, + })); + return { scratch, archive, offline, split, identity }; } test("candidate manifest binds the outer digest to the embedded trusted-main identity", () => { @@ -44,7 +67,7 @@ test("candidate manifest binds the outer digest to the embedded trusted-main ide try { assert.deepEqual(candidateIdentityFromArchive(item.archive), item.identity); const output = join(item.scratch, "candidate.json"); - const manifest = createCandidateManifest({ archivePath: item.archive, outputPath: output }); + const manifest = createCandidateManifest({ archivePath: item.archive, offlinePath: item.offline, splitPath: item.split, outputPath: output }); assert.equal(manifest.source.commit, item.identity.commit); assert.equal(manifest.source.ref, "refs/heads/main"); assert.equal(manifest.ci.conclusion, "success"); @@ -57,20 +80,20 @@ test("root boundary rejects digest, source, and sealed OCI mismatches", () => { const item = fixture(); try { const manifestPath = join(item.scratch, "candidate.json"); - createCandidateManifest({ archivePath: item.archive, outputPath: manifestPath }); + createCandidateManifest({ archivePath: item.archive, offlinePath: item.offline, splitPath: item.split, outputPath: manifestPath }); const validator = join(root, "ops", "dress-rehearsal", "candidate-boundary.py"); const output = join(item.scratch, "verified.json"); - execFileSync("python3", [validator, "validate", manifestPath, item.archive, output]); + execFileSync("python3", [validator, "validate", manifestPath, item.archive, item.offline, output]); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); manifest.source.commit = "c".repeat(40); writeFileSync(manifestPath, JSON.stringify(manifest)); - let failed = spawnSync("python3", [validator, "validate", manifestPath, item.archive, output], { encoding: "utf8" }); + let failed = spawnSync("python3", [validator, "validate", manifestPath, item.archive, item.offline, output], { encoding: "utf8" }); assert.notEqual(failed.status, 0); assert.match(failed.stderr, /embedded candidate commit mismatch/); manifest.source.commit = item.identity.commit; manifest.artifact.sha256 = "d".repeat(64); writeFileSync(manifestPath, JSON.stringify(manifest)); - failed = spawnSync("python3", [validator, "validate", manifestPath, item.archive, output], { encoding: "utf8" }); + failed = spawnSync("python3", [validator, "validate", manifestPath, item.archive, item.offline, output], { encoding: "utf8" }); assert.notEqual(failed.status, 0); assert.match(failed.stderr, /archive SHA-256 mismatch/); } finally { rmSync(item.scratch, { recursive: true, force: true }); } diff --git a/test/phase3-promotion.mjs b/test/phase3-promotion.mjs index e7ddab4..8b2713b 100644 --- a/test/phase3-promotion.mjs +++ b/test/phase3-promotion.mjs @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { confirmationText, validatePromotionBundle } from "../scripts/promotion-lib.mjs"; -import { assertRemoteVersionAbsent } from "../scripts/github-promotion-gates.mjs"; +import { assertRemoteVersionAbsent, remoteTagAndRelease } from "../scripts/github-promotion-gates.mjs"; const root = join(import.meta.dirname, ".."); const sha = (value) => createHash("sha256").update(value).digest("hex"); @@ -23,22 +23,51 @@ function createBundle() { return { path: name, sha256: sha(content) }; }; const oci = Buffer.from("sealed-oci-phase3"); + const imageDigest = sha(oci); + const imageName = `1Helm-channel-machine-v1-amd64-${imageDigest}.oci.tar`; + const imageTag = `channel-image-v1-amd64-${imageDigest}`; + const image = { + schema: 1, kind: "1helm-sealed-channel-image", version: "1", architecture: "amd64", sha256: imageDigest, bytes: oci.length, + artifact: { name: imageName, url: `https://github.com/gitcommit90/1Helm/releases/download/${imageTag}/${imageName}`, + manifest_url: `https://github.com/gitcommit90/1Helm/releases/download/${imageTag}/${imageName.replace(/\.oci\.tar$/, ".json")}` }, + inputs: { containerfile_sha256: "1".repeat(64), context_sha256: "2".repeat(64), base_image_digest: "3".repeat(64) }, + cache: { key: "4".repeat(64), reused: false }, platforms: ["linux", "windows-wsl"], + }; const identity = { schema: 1, kind: "1helm-dress-rehearsal-candidate", repository: "gitcommit90/1Helm", ref: "refs/heads/main", commit, source_state: "trusted-main", build_identity: "candidate-111-12345.1", created_at: "2026-08-04T12:00:00Z", ci: { workflow: "CI", run_id: "111", conclusion: "success" }, version, source_archive_sha256: "b".repeat(64), sealed_oci_sha256: sha(oci), + sealed_oci_cache: { ...image.cache, reused: true }, channel_image: image, }; const stage = join(bundle, "stage", `1Helm-${version}`); mkdirSync(join(stage, "resources"), { recursive: true }); mkdirSync(join(stage, "container"), { recursive: true }); writeFileSync(join(stage, "resources", "candidate-build.json"), JSON.stringify(identity)); + writeFileSync(join(stage, "resources", "channel-image.json"), JSON.stringify(image)); writeFileSync(join(stage, "package.json"), JSON.stringify({ version })); - writeFileSync(join(stage, "container", "channel-machine.oci.tar"), oci); const linuxName = `1Helm-${version}-linux-node.tgz`; execFileSync("tar", ["-czf", join(bundle, linuxName), "-C", join(bundle, "stage"), `1Helm-${version}`]); const linuxBytes = readFileSync(join(bundle, linuxName)); const linux = { role: "linux_tgz", name: linuxName, path: linuxName, sha256: sha(linuxBytes), bytes: linuxBytes.length }; + const offlineStage = join(bundle, "offline-stage", `1Helm-${version}`); + mkdirSync(join(bundle, "offline-stage"), { recursive: true }); + execFileSync("cp", ["-a", stage, offlineStage]); + mkdirSync(join(offlineStage, "container"), { recursive: true }); + writeFileSync(join(offlineStage, "container", "channel-machine.oci.tar"), oci); + const offlineName = `1Helm-${version}-linux-node-offline.tgz`; + execFileSync("tar", ["-czf", join(bundle, offlineName), "-C", join(bundle, "offline-stage"), `1Helm-${version}`]); + const offlineBytes = readFileSync(join(bundle, offlineName)); + const offline = { role: "linux_offline_tgz", name: offlineName, path: offlineName, sha256: sha(offlineBytes), bytes: offlineBytes.length }; + writeFileSync(join(bundle, imageName), oci); + const imageManifestRecord = write("channel-image.json", image); + const imageProvenanceRecord = write("channel-image-provenance.json", { + schema: 1, kind: "1helm-channel-image-provenance", repository: "gitcommit90/1Helm", ref: "refs/heads/main", + source_commit: commit, candidate_workflow_run_id: runId, source_ci_run_id: "111", + artifact: { name: imageName, sha256: imageDigest, bytes: oci.length }, manifest: imageManifestRecord, + inputs: image.inputs, cache: identity.sealed_oci_cache, + signer_workflow: "gitcommit90/1Helm/.github/workflows/candidate.yml", attestation_created: true, + }); const macDmgBytes = Buffer.from("exact retained signed notarized DMG bytes"); const macZipBytes = Buffer.from("exact retained signed notarized updater bytes"); const artifact = (role, name, bytes) => { @@ -55,11 +84,13 @@ function createBundle() { 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" }); + offline.provenance = provenance(offline, { builder: "github-hosted", attestation_created: true, signer_workflow: "gitcommit90/1Helm/.github/workflows/candidate.yml" }); const candidateManifest = { schema: 1, kind: "1helm-dress-rehearsal-candidate", source: { repository: "gitcommit90/1Helm", ref: "refs/heads/main", commit, state: "trusted-main", source_archive_sha256: identity.source_archive_sha256 }, - version, build: { identity: identity.build_identity, created_at: identity.created_at }, ci: identity.ci, - artifact: { name: linux.name, sha256: linux.sha256, bytes: linux.bytes }, sealed_oci: { sha256: identity.sealed_oci_sha256 }, + version, build: { identity: identity.build_identity, created_at: identity.created_at, sealed_oci_cache: identity.sealed_oci_cache }, ci: identity.ci, + artifact: { name: linux.name, sha256: linux.sha256, bytes: linux.bytes }, + offline_bundle: { name: offline.name, sha256: offline.sha256, bytes: offline.bytes }, sealed_oci: image, }; const candidateRecord = write("candidate.json", candidateManifest); const macCandidateRecord = write("mac-candidate.json", { @@ -92,7 +123,7 @@ function createBundle() { linux: ["digest", "clean_install", "prior_version_update", "health_failure_rollback", "retained_state", "systemd_health"], windows: ["non_elevated_install", "single_uac", "restart_resume", "keepalive_reboot", "onboarding", "prior_version_update", "retained_state", "uninstall_safety"], }; - const acceptanceArtifacts = { macos: [macDmg, macZip], linux: [linux], windows: [linux] }; + const acceptanceArtifacts = { macos: [macDmg, macZip], linux: [linux, offline], windows: [linux, offline] }; const acceptance = {}; for (const platform of Object.keys(checkIds)) { const marker = sha(`${platform}-retained-state`); @@ -113,6 +144,7 @@ function createBundle() { } } : {}), 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 })), + ...(platform === "macos" ? {} : { channel_image: { version: image.version, architecture: image.architecture, sha256: image.sha256, bytes: image.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."], @@ -126,10 +158,14 @@ function createBundle() { acceptance_ledger_required: true, candidate: { workflow_run_id: runId, artifact_id: artifactId, artifact_name: `1helm-promotion-candidate-${commit}` }, 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], + artifacts: [macDmg, macZip, linux, offline], + channel_image: { ...image, candidate: { + artifact: { path: imageName, sha256: imageDigest }, manifest: imageManifestRecord, provenance: imageProvenanceRecord, + } }, }; writeFileSync(join(bundle, "promotion.json"), `${JSON.stringify(promotion, null, 2)}\n`); rmSync(join(bundle, "stage"), { recursive: true, force: true }); + rmSync(join(bundle, "offline-stage"), { recursive: true, force: true }); return bundle; } @@ -144,7 +180,7 @@ test("complete retained evidence is eligible and generated output names only exa const report = validatePromotionBundle(options(bundle)); assert.equal(report.eligible, true, report.blockers.join("\n")); assert.equal(report.stable_touched, false); - assert.equal(report.stable_manifest.artifacts.length, 3); + assert.equal(report.stable_manifest.artifacts.length, 4); assert.deepEqual(report.stable_manifest.artifacts.map(({ sha256 }) => sha256), report.artifacts.map(({ sha256 }) => sha256)); assert.match(report.release_notes, /Authored changelog/); assert.doesNotMatch(report.release_notes, /generated notes/i); @@ -167,6 +203,23 @@ test("missing and mismatched evidence fail closed", () => { } finally { rmSync(bundle, { recursive: true, force: true }); } }); +test("image manifest inputs and provenance identities are exact promotion blockers", () => { + const bundle = createBundle(); + try { + const promotionPath = join(bundle, "promotion.json"); + const promotion = JSON.parse(readFileSync(promotionPath, "utf8")); + const provenancePath = join(bundle, promotion.channel_image.candidate.provenance.path); + const provenance = JSON.parse(readFileSync(provenancePath, "utf8")); + provenance.source_commit = "0".repeat(40); + writeFileSync(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`); + promotion.channel_image.candidate.provenance.sha256 = sha(readFileSync(provenancePath)); + writeFileSync(promotionPath, `${JSON.stringify(promotion, null, 2)}\n`); + const report = validatePromotionBundle(options(bundle)); + assert.equal(report.eligible, false); + assert.ok(report.blockers.some((item) => /channel image provenance is incomplete or mismatched/.test(item))); + } finally { rmSync(bundle, { recursive: true, force: true }); } +}); + test("metacharacters in the version input are compared literally without regex construction", () => { const bundle = createBundle(); try { @@ -198,6 +251,19 @@ test("remote tag/release gates distinguish absence from API failure", async () = await assert.rejects(assertRemoteVersionAbsent(version, "fixture-token", responses([200])), /tag v9\.8\.7 already exists/); }); +test("immutable image reuse requires both its exact tag and Release identity", async () => { + const present = (value) => new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } }); + let calls = 0; + const complete = await remoteTagAndRelease("channel-image-v1-amd64-" + "a".repeat(64), "token", async () => present( + ++calls === 1 ? { ref: "refs/tags/image" } : { tag_name: "image", assets: [] }, + )); + assert.ok(complete.tag && complete.release); + calls = 0; + await assert.rejects(() => remoteTagAndRelease("channel-image-v1-amd64-" + "a".repeat(64), "token", async () => ( + ++calls === 1 ? present({ ref: "refs/tags/image" }) : new Response("", { status: 404 }) + )), /partial tag\/Release identity/); +}); + test("the owner command reports dry-run eligibility, platform evidence, blockers, and Stable state", () => { const bundle = createBundle(); try { @@ -246,3 +312,11 @@ test("workflow is manual-only, permission-separated, environment-gated, and cont assert.doesNotMatch(readFileSync(join(root, "scripts/publish-promotion.mjs"), "utf8"), /npm|package-linux|package-mac/); assert.match(readFileSync(join(root, "scripts/publish-promotion.mjs"), "utf8"), /"--draft"[\s\S]*Draft Release bytes[\s\S]*"--draft=false"/); }); + +test("shared image publication requires immutable bytes, manifest, and provenance assets", () => { + const publisher = readFileSync(join(root, "scripts", "publish-promotion.mjs"), "utf8"); + assert.match(publisher, /channelImageProvenanceName[\s\S]*expectedImageAssets[\s\S]*assets\.length !== expectedImageAssets\.length/); + assert.match(publisher, /provenanceMatches[\s\S]*provenanceResponse[\s\S]*provenance digest does not match GitHub/); + assert.match(publisher, /release", "create", imageTag, imagePath, imageManifestPath, imageProvenancePath[\s\S]*"--draft"[\s\S]*assertImageReleaseAssets/); + assert.match(publisher, /requireCurrentCandidate: true/); +}); diff --git a/test/phase4-platform-acceptance.mjs b/test/phase4-platform-acceptance.mjs index 42ad652..e4e735c 100644 --- a/test/phase4-platform-acceptance.mjs +++ b/test/phase4-platform-acceptance.mjs @@ -17,7 +17,9 @@ 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 }, + linux_offline_tgz: { role: "linux_offline_tgz", name: `1Helm-${version}-linux-node-offline.tgz`, sha256: hash("offline"), bytes: 7 }, }; +const channelImage = { version: "1", architecture: "amd64", sha256: hash("image"), bytes: 5 }; function fixture(platform) { const checkedAt = "2026-08-04T14:00:00Z"; @@ -38,6 +40,7 @@ function fixture(platform) { summary: "Fixture continuity equivalent passed.", } } : {}), artifacts: PLATFORM_ARTIFACT_ROLES[platform].map((role) => artifactMap[role]), + ...(platform === "macos" ? {} : { channel_image: channelImage }), 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 }, @@ -52,7 +55,7 @@ test("all platform schemas normalize exact run, CI, machine, state, recovery, an 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 }), []); + assert.deepEqual(platformEvidenceBlockers(evidence, { platform, commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap, channelImage }), []); } }); @@ -76,18 +79,18 @@ test("digest, byte count, CI run, state mismatch, and default runner label are b 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 }); + const blockers = platformEvidenceBlockers(evidence, { platform: "linux", commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap, channelImage }); 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 }) + assert.ok(platformEvidenceBlockers(wrongMachine, { platform: "linux", commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap, channelImage }) .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 }) + assert.ok(platformEvidenceBlockers(windows, { platform: "windows", commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap, channelImage }) .some((item) => /snapshot-assisted/.test(item))); }); @@ -121,6 +124,8 @@ test("an interrupted lane retains normalized blocked evidence before it can pass writeFileSync(manifest, JSON.stringify({ version, source: { commit }, ci: { workflow: "CI", run_id: ciRunId, conclusion: "success" }, artifact: artifactMap.linux_tgz, + offline_bundle: artifactMap.linux_offline_tgz, + sealed_oci: channelImage, })); const { spawnSync } = await import("node:child_process"); const result = spawnSync(process.execPath, [join(root, "scripts/pending-acceptance-evidence.mjs")], { @@ -128,6 +133,7 @@ test("an interrupted lane retains normalized blocked evidence before it can pass 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", + HELM_CANDIDATE_OFFLINE_ARCHIVE: artifactMap.linux_offline_tgz.name, GITHUB_RUN_ID: runId, GITHUB_RUN_ATTEMPT: "1", GITHUB_JOB: "accept-linux", RUNNER_NAME: "GitHub Actions 1", RUNNER_OS: "Linux", RUNNER_ARCH: "X64" }, }); @@ -135,7 +141,7 @@ test("an interrupted lane retains normalized blocked evidence before it can pass 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 }), []); + assert.notDeepEqual(platformEvidenceBlockers(evidence, { platform: "linux", commit, version, runId, runAttempt: "1", ciRunId, artifacts: artifactMap, channelImage }), []); } finally { rmSync(scratch, { recursive: true, force: true }); } }); diff --git a/test/phase5-artifacts.mjs b/test/phase5-artifacts.mjs new file mode 100644 index 0000000..883bed3 --- /dev/null +++ b/test/phase5-artifacts.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + channelImageArtifactName, normalizeChannelImageManifest, validateSplitArtifactManifest, +} from "../scripts/artifact-contract.mjs"; +import { candidateIdentityFromArchive, createCandidateManifest } from "../scripts/candidate-manifest.mjs"; + +const root = join(import.meta.dirname, ".."); +const sha = (value) => createHash("sha256").update(value).digest("hex"); +const read = (path) => readFileSync(join(root, path), "utf8"); +const imageBytes = Buffer.from("phase5 sealed image"); +const imageDigest = sha(imageBytes); + +function imageManifest(overrides = {}) { + const architecture = overrides.architecture || "amd64"; + const digest = overrides.sha256 || imageDigest; + const tag = `channel-image-v1-${architecture}-${digest}`; + const name = channelImageArtifactName({ architecture, sha256: digest }); + return { + schema: 1, kind: "1helm-sealed-channel-image", version: "1", architecture, sha256: digest, + bytes: imageBytes.length, artifact: { + name, url: `https://github.com/gitcommit90/1Helm/releases/download/${tag}/${name}`, + manifest_url: `https://github.com/gitcommit90/1Helm/releases/download/${tag}/${name.replace(/\.oci\.tar$/, ".json")}`, + }, + inputs: { containerfile_sha256: "a".repeat(64), context_sha256: "b".repeat(64), base_image_digest: "c".repeat(64) }, + cache: { key: "d".repeat(64), reused: false }, platforms: ["linux", "windows-wsl"], + }; +} + +function fixture() { + const scratch = mkdtempSync(join(tmpdir(), "1helm-phase5-")); + const prefix = join(scratch, "online", "1Helm-1.2.3"); + const offlinePrefix = join(scratch, "offline", "1Helm-1.2.3"); + const image = imageManifest(); + const identity = { + schema: 1, kind: "1helm-dress-rehearsal-candidate", repository: "gitcommit90/1Helm", ref: "refs/heads/main", + commit: "e".repeat(40), source_state: "trusted-main", build_identity: "candidate-1-2.1", + created_at: "2026-08-04T12:00:00Z", ci: { workflow: "CI", run_id: "1", conclusion: "success" }, + version: "1.2.3", source_archive_sha256: "f".repeat(64), sealed_oci_sha256: image.sha256, + sealed_oci_cache: { ...image.cache, reused: true }, channel_image: image, + }; + for (const dir of [prefix, offlinePrefix]) { + mkdirSync(join(dir, "resources"), { recursive: true }); mkdirSync(join(dir, "container"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "1.2.3" })); + writeFileSync(join(dir, "resources", "candidate-build.json"), JSON.stringify(identity)); + writeFileSync(join(dir, "resources", "channel-image.json"), JSON.stringify(image)); + } + writeFileSync(join(offlinePrefix, "container", "channel-machine.oci.tar"), imageBytes); + writeFileSync(join(offlinePrefix, "container", "channel-machine.oci.sha256"), `${image.sha256}\n`); + const online = join(scratch, "1Helm-1.2.3-linux-node.tgz"); + const offline = join(scratch, "1Helm-1.2.3-linux-node-offline.tgz"); + execFileSync("tar", ["-czf", online, "-C", join(scratch, "online"), "1Helm-1.2.3"]); + execFileSync("tar", ["-czf", offline, "-C", join(scratch, "offline"), "1Helm-1.2.3"]); + const split = join(scratch, "split.json"); + writeFileSync(split, JSON.stringify({ + schema: 1, kind: "1helm-linux-split-artifacts", version: "1.2.3", + app: { name: "1Helm-1.2.3-linux-node.tgz", sha256: sha(readFileSync(online)), bytes: statSync(online).size, contains_channel_image: false }, + offline: { name: "1Helm-1.2.3-linux-node-offline.tgz", sha256: sha(readFileSync(offline)), bytes: statSync(offline).size, contains_channel_image: true }, + channel_image: image, + production_dependencies: { key: "1".repeat(64), reused: false, node_abi: "127", architecture: "x64", builder_image_digest: "2".repeat(64) }, + })); + return { scratch, online, offline, split, identity, image }; +} + +test("online candidate excludes OCI bytes while offline bundle includes exact bytes", () => { + const item = fixture(); + try { + const onlineEntries = execFileSync("tar", ["-tzf", item.online], { encoding: "utf8" }); + assert.doesNotMatch(onlineEntries, /channel-machine\.oci\.tar/); + const offlineBytes = execFileSync("tar", ["-xOzf", item.offline, "1Helm-1.2.3/container/channel-machine.oci.tar"]); + assert.equal(sha(offlineBytes), imageDigest); + assert.equal(candidateIdentityFromArchive(item.online).sealed_oci_sha256, imageDigest); + } finally { rmSync(item.scratch, { recursive: true, force: true }); } +}); + +test("split candidate binds online, offline, and channel image exact bytes", () => { + const item = fixture(); + try { + const output = join(item.scratch, "candidate.json"); + const candidate = createCandidateManifest({ archivePath: item.online, offlinePath: item.offline, splitPath: item.split, outputPath: output }); + assert.equal(candidate.artifact.layout, "online-app"); + assert.equal(candidate.offline_bundle.sha256, sha(readFileSync(item.offline))); + assert.equal(candidate.sealed_oci.sha256, imageDigest); + assert.equal(candidate.build.sealed_oci_cache.reused, true); + const changed = JSON.parse(readFileSync(item.split)); changed.offline.sha256 = "0".repeat(64); writeFileSync(item.split, JSON.stringify(changed)); + assert.throws(() => createCandidateManifest({ archivePath: item.online, offlinePath: item.offline, splitPath: item.split, outputPath: output }), /[Oo]ffline candidate archive/); + } finally { rmSync(item.scratch, { recursive: true, force: true }); } +}); + +test("immutable image manifests do not change when a later candidate reuses the same bytes", async () => { + const { releasedChannelImageManifest } = await import("../scripts/artifact-contract.mjs"); + const created = imageManifest(); + const reused = { ...created, cache: { ...created.cache, reused: true } }; + assert.deepEqual(releasedChannelImageManifest(created), releasedChannelImageManifest(reused)); + assert.equal(releasedChannelImageManifest(reused).cache.reused, false); +}); + +test("image digest, missing manifest, architecture, and cache input mismatches fail closed", () => { + assert.throws(() => normalizeChannelImageManifest({ ...imageManifest(), sha256: "0".repeat(64) }, { requireUrl: true }), /artifact name/); + assert.throws(() => normalizeChannelImageManifest({ ...imageManifest(), architecture: "s390x" }), /architecture/); + const missingInput = imageManifest(); delete missingInput.inputs.context_sha256; + assert.throws(() => normalizeChannelImageManifest(missingInput), /context_sha256/); + const item = fixture(); + try { + rmSync(join(item.scratch, "online", "1Helm-1.2.3", "resources", "channel-image.json")); + execFileSync("tar", ["-czf", item.online, "-C", join(item.scratch, "online"), "1Helm-1.2.3"]); + assert.throws(() => candidateIdentityFromArchive(item.online), /channel image manifest/); + } finally { rmSync(item.scratch, { recursive: true, force: true }); } +}); + +test("cache keys cover exact architecture, ABI, lockfile, builder, and Containerfile inputs", () => { + const packager = read("scripts/package-linux-host.mjs"); + const imageBuilder = read("scripts/build-oci-channel-image.sh"); + const workflow = read(".github/workflows/candidate.yml"); + assert.match(packager, /digestText\(digestFile\(join\(releaseRoot, "package-lock\.json"\)\), runtimePackageSha256,[\s\S]*builderAbi, nativeArchitecture, builderDigest\)/); + assert.match(workflow, /sha256sum config\/linux-runtime-package\.json/); + assert.match(imageBuilder, /ARCH.*BASE_DIGEST.*CONTAINERFILE_SHA.*CONTEXT_SHA/s); + assert.match(packager, /dependencyCacheReused = true/); + assert.match(imageBuilder, /CACHE_REUSED=true/); + assert.doesNotMatch(imageBuilder, /VERSION=.*package\.json/); + assert.match(workflow, /actions\/cache@[a-f0-9]{40}[\s\S]*channel-images[\s\S]*packaging-cache\.outputs\.oci_key/); + assert.match(workflow, /actions\/cache@[a-f0-9]{40}[\s\S]*production-dependencies[\s\S]*packaging-cache\.outputs\.dependency_key/); + assert.doesNotMatch(workflow, /restore-keys:/, "hosted caches never fall back to a prefix-matched ABI, architecture, or source identity"); +}); + +test("legacy complete bundles remain supported and image GC is report-only", () => { + const installer = read("site/public/install-oci-runtime.sh"); + assert.match(installer, /legacy sealed channel image digest does not match/); + assert.match(installer, /shared-images\/sha256/); + const scratch = mkdtempSync(join(tmpdir(), "1helm-phase5-gc-")); + try { + const image = join(scratch, "images", imageDigest); mkdirSync(image, { recursive: true }); writeFileSync(join(image, "image.oci.tar"), imageBytes); + const output = execFileSync(process.execPath, [join(root, "scripts", "channel-image-gc-report.mjs"), join(scratch, "images"), join(scratch, "releases")], { encoding: "utf8" }); + const report = JSON.parse(output); assert.equal(report.mode, "report-only"); assert.equal(report.automatic_deletion, false); assert.equal(report.images[0].action, "retain"); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}); + +test("artifact report tolerates missing platform artifacts and emits deterministic general paths", () => { + const scratch = mkdtempSync(join(tmpdir(), "1helm-phase5-report-")); + try { + const output = join(scratch, "report.json"); + const emptyVendored = join(scratch, "vendored"); const emptyClient = join(scratch, "client"); + mkdirSync(emptyVendored); mkdirSync(emptyClient); + const result = spawnSync(process.execPath, [join(root, "scripts", "artifact-size-report.mjs"), "--json", output, + "--linux-app", join(scratch, "missing.tgz"), "--linux-offline", join(scratch, "missing-offline.tgz"), + "--oci", join(scratch, "missing.oci.tar"), "--mac-dmg", join(scratch, "missing.dmg"), "--mac-zip", join(scratch, "missing.zip"), + "--vendored", emptyVendored, "--client", emptyClient], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); const report = JSON.parse(readFileSync(output, "utf8")); + assert.equal(report.artifacts.mac_dmg.status, "missing"); assert.doesNotMatch(JSON.stringify(report), /\/tmp\//); + assert.match(result.stdout, /mac_dmg: not present/); + const checked = spawnSync(process.execPath, [join(root, "scripts", "artifact-size-report.mjs"), "--json", output, "--check", + "--linux-app", join(scratch, "missing.tgz"), "--linux-offline", join(scratch, "missing-offline.tgz"), + "--oci", join(scratch, "missing.oci.tar"), "--mac-dmg", join(scratch, "missing.dmg"), "--mac-zip", join(scratch, "missing.zip"), + "--vendored", emptyVendored, "--client", emptyClient], { encoding: "utf8" }); + assert.equal(checked.status, 0, checked.stderr); + } finally { rmSync(scratch, { recursive: true, force: true }); } +}); + +test("artifact report distinguishes cold online transfer from an unchanged-image app update", () => { + const item = fixture(); + try { + const output = join(item.scratch, "report.json"); + const oci = join(item.scratch, "image.oci.tar"); writeFileSync(oci, imageBytes); + const vendored = join(item.scratch, "vendored"); const client = join(item.scratch, "client"); + mkdirSync(vendored); mkdirSync(client); + const result = spawnSync(process.execPath, [join(root, "scripts", "artifact-size-report.mjs"), "--json", output, + "--linux-app", item.online, "--linux-offline", item.offline, "--oci", oci, + "--mac-dmg", join(item.scratch, "missing.dmg"), "--mac-zip", join(item.scratch, "missing.zip"), + "--vendored", vendored, "--client", client], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const report = JSON.parse(readFileSync(output, "utf8")); + const cold = report.baseline_comparison.linux_online_cold_vs_legacy_complete; + assert.equal(cold.bytes, report.artifacts.linux_app_tgz.bytes + report.artifacts.sealed_oci_image.bytes); + assert.match(result.stdout, /Linux cold online total \(app \+ image\)/); + } finally { rmSync(item.scratch, { recursive: true, force: true }); } +}); diff --git a/test/release-governance.mjs b/test/release-governance.mjs index 230b904..3856306 100644 --- a/test/release-governance.mjs +++ b/test/release-governance.mjs @@ -37,8 +37,9 @@ test("one retained Mac Studio owns the complete macOS release gate", () => { assert.match(tracked, /Application Support preservation/i); }); -// The tracked release authority: three published artifacts, and Windows accepted -// by behaviour because it publishes nothing at all. +// The tracked release authority: four application artifacts plus one separately +// retained immutable image contract, and Windows accepted by behaviour because +// it publishes nothing at all. const RELEASE_DOCS = { "docs/release-checklist.md": read("docs/release-checklist.md"), "docs/release-lifecycle.md": read("docs/release-lifecycle.md"), @@ -61,25 +62,25 @@ test("desktop releases fail closed unless Mac, Linux, and Windows ship together" assert.doesNotMatch(notes, /Other published artifact, or `Not applicable`/i); }); -test("every release document names the same three published artifacts", () => { +test("every release document names the same split application artifacts", () => { for (const [path, source] of Object.entries(RELEASE_DOCS)) { - for (const artifact of ["arm64.dmg", "mac-arm64.zip", "linux-node.tgz"]) { + for (const artifact of ["arm64.dmg", "mac-arm64.zip", "linux-node.tgz", "linux-node-offline.tgz"]) { assert.match(source, new RegExp(`1Helm-[^\\s\`]*${artifact.replaceAll(".", "\\.")}`, "i"), `${path} must name the ${artifact} release artifact`); } - assert.match(source, /three[\s\S]{0,40}(?:artifacts?|files?|rows?)/i, - `${path} must state that the desktop matrix is exactly three artifacts`); + assert.match(source, /four[\s\S]{0,60}(?:artifacts?|files?|rows?)/i, + `${path} must state that the application desktop matrix is exactly four artifacts`); assert.doesNotMatch(source, /six[- ](?:file|artifact)|complete six/i, `${path} must not describe a six-artifact desktop matrix`); } const checklist = RELEASE_DOCS["docs/release-checklist.md"]; - assert.match(checklist, /for artifact in "\$DMG" "\$UPDATE_ZIP" "\$HEADLESS"; do/, - "the checklist verifies exactly the three built artifacts"); + assert.match(checklist, /for artifact in "\$DMG" "\$UPDATE_ZIP" "\$HEADLESS" "\$OFFLINE"; do/, + "the checklist verifies exactly the four application artifacts"); const promotion = read("scripts/publish-promotion.mjs"); assert.match(promotion, /STABLE_ARTIFACT_ROLES\.map/, - "the publish command derives exactly the three validated artifact roles"); + "the publish command derives exactly the four validated application artifact roles"); assert.match(promotion, /"release", "create", tag, \.\.\.artifactPaths, stablePath/, - "the publish command attaches the three artifacts plus their Stable manifest"); + "the publish command attaches the four application artifacts plus their Stable manifest"); assert.match(promotion, /"--draft"[\s\S]*expectedAssets[\s\S]*"--draft=false"/, "publication exposes Stable only after the complete draft matrix is digest-verified"); }); diff --git a/test/site.mjs b/test/site.mjs index 4211837..54f0a15 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -292,9 +292,9 @@ test("installer assets are explicit and syntax-valid", () => { // it works. That is why the first live update failed and every fresh install // passed. Writing unit files must not depend on reopening stdin. assert.doesNotMatch(linuxUnits, /^\s*install\b[^\n]*\/dev\/stdin/m, "unit files must not be installed by reopening /dev/stdin (breaks under systemd-run)"); - assert.match(updater, /browser_download_url/); + assert.match(updater, /RELEASE_METADATA_URL="https:\/\/1helm\.com\/api\/releases\/linux\/latest"/, "updates consume the website's already validated Stable manifest projection"); assert.match(linuxUnits, /Environment=HELM_APP_ROOT=\$INSTALL_ROOT\/current/, "Linux explicitly exposes the active packaged root to runtime resource resolvers"); - assert.match(updater, /\^sha256:\[a-f0-9\]\{64\}\$/, "the Linux updater requires GitHub's exact SHA-256 asset digest"); + assert.match(updater, /\^\[a-f0-9\]\{64\}\$/, "the Linux updater requires the website's exact SHA-256 asset digest"); assert.match(updater, /sha256sum -c -/); assert.match(updater, /CONNECTOR_ARCH[\s\S]*resources\/cloudflared-linux-\$CONNECTOR_ARCH/, "Linux updates reject archives without the connector for the current architecture"); assert.match(updater, /mv -Tf .*current/);