diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 000000000..14af65c5d --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,129 @@ +name: V5 prerelease candidate + +# Called through the existing Release entry dispatched on next. Preparing never publishes. +on: + workflow_call: + inputs: + version: + type: string + required: true + source_revision: + type: string + required: true + publish: + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: v5-prerelease-${{ inputs.version }} + cancel-in-progress: false + +env: + RELEASE_CHANNEL: prerelease + HACK_PRERELEASE_VERSION: ${{ inputs.version }} + HACK_PRERELEASE_SOURCE_REVISION: ${{ inputs.source_revision }} + HACK_PRERELEASE_PUBLISH: ${{ inputs.publish }} + +jobs: + plan: + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + checks: read + statuses: read + deployments: read + outputs: + version: ${{ steps.plan.outputs.version }} + source_revision: ${{ steps.plan.outputs.source_revision }} + archive: ${{ steps.plan.outputs.archive }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.4.2" + - name: Validate immutable plan + id: plan + run: bun scripts/prerelease-plan.ts plan + - name: Verify publication prerequisites before scheduling approval + if: ${{ inputs.publish }} + env: + GH_TOKEN: ${{ github.token }} + run: bun scripts/prerelease-plan.ts verify + + build: + needs: plan + runs-on: macos-15 + timeout-minutes: 60 + permissions: + contents: read + env: + HACK_PRERELEASE_BUNDLE: ${{ runner.temp }}/native-candidate + HACK_PRERELEASE_OUTPUT: ${{ runner.temp }}/prerelease-assets + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.plan.outputs.source_revision }} + persist-credentials: false + - uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.4.2" + - uses: dtolnay/rust-toolchain@1.97.1 + with: + targets: aarch64-unknown-linux-musl + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4 + with: + version: "2026.9.12" + install_args: zig + add_shims_to_path: false + cache: false + env: false + - run: bun install --frozen-lockfile + - name: Build and verify complete native bundle + run: mise exec zig -- scripts/build-native-candidate.sh "$HACK_PRERELEASE_BUNDLE" "--version=$HACK_PRERELEASE_VERSION" + - name: Package verified payload and outer checksum + run: bun scripts/prerelease-plan.ts package + - name: Retain reviewable artifacts + uses: actions/upload-artifact@v4 + with: + name: v5-prerelease-${{ needs.plan.outputs.version }} + path: ${{ runner.temp }}/prerelease-assets/* + if-no-files-found: error + retention-days: 14 + + publish: + if: ${{ inputs.publish }} + needs: [plan, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + # The plan checks that this already exists with real required-reviewer protection. + environment: v5-prerelease + permissions: + contents: write + actions: read + checks: read + statuses: read + deployments: read + env: + HACK_PRERELEASE_OUTPUT: ${{ runner.temp }}/prerelease-assets + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.plan.outputs.source_revision }} + persist-credentials: false + - uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.4.2" + - uses: actions/download-artifact@v4 + with: + name: v5-prerelease-${{ needs.plan.outputs.version }} + path: ${{ runner.temp }}/prerelease-assets + - name: Recheck exact head, CI, human approval and tag absence; publish once + run: bun scripts/prerelease-plan.ts publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca0b09aed..e982a111d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,12 +6,49 @@ on: tag: description: 'Release tag (e.g., v0.1.0)' required: false + channel: + description: Release channel + type: choice + options: [stable, prerelease] + default: stable + prerelease_version: + description: 'Explicit v5 candidate version (5.0.0-next.N)' + type: string + required: false + source_revision: + description: 'Full approved next commit SHA for the candidate' + type: string + required: false + publish_prerelease: + description: 'Request publication after exact-head CI and environment review (default prepares artifacts only)' + type: boolean + default: false push: tags: - "v*" + - "!v*-*" + - "!v*+*" + +permissions: + contents: read jobs: + prerelease: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'prerelease' }} + uses: ./.github/workflows/prerelease.yml + permissions: + contents: write + actions: read + checks: read + statuses: read + deployments: read + with: + version: ${{ inputs.prerelease_version }} + source_revision: ${{ inputs.source_revision }} + publish: ${{ inputs.publish_prerelease }} + create-release: + if: ${{ github.event_name == 'push' || inputs.channel == 'stable' }} runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write @@ -29,22 +66,10 @@ jobs: bun-version: "1.4.2" - name: Resolve version id: version - run: | - TARGET_TAG="${{ github.event.inputs.tag }}" - if [ -z "$TARGET_TAG" ]; then - TARGET_TAG="$GITHUB_REF_NAME" - fi - if [ -z "$TARGET_TAG" ]; then - echo "Missing tag. Provide workflow input 'tag' or run on a tag ref." - exit 1 - fi - VERSION="$(bun -e "const pkg = await Bun.file('package.json').json(); console.log(pkg.version)")" - if [ "v$VERSION" != "$TARGET_TAG" ]; then - echo "Tag $TARGET_TAG does not match package.json version $VERSION." - exit 1 - fi - echo "tag=$TARGET_TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + env: + RELEASE_CHANNEL: ${{ inputs.channel || 'stable' }} + RELEASE_TAG_INPUT: ${{ inputs.tag }} + run: bun scripts/prerelease-plan.ts stable - name: Create GitHub release uses: softprops/action-gh-release@v2 with: @@ -75,6 +100,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ needs.create-release.outputs.tag }} - name: Setup Bun uses: oven-sh/setup-bun@v1 with: @@ -83,10 +110,14 @@ jobs: run: bun install - name: Build release artifacts if: ${{ !matrix.skip_tests }} - run: bun run build:release --version="${{ needs.create-release.outputs.version }}" + env: + RELEASE_VERSION: ${{ needs.create-release.outputs.version }} + run: bun run build:release "--version=$RELEASE_VERSION" - name: Build release artifacts (skip tests) if: ${{ matrix.skip_tests }} - run: bun run build:release --version="${{ needs.create-release.outputs.version }}" --skip-tests + env: + RELEASE_VERSION: ${{ needs.create-release.outputs.version }} + run: bun run build:release "--version=$RELEASE_VERSION" --skip-tests - name: Upload tarball env: GH_TOKEN: ${{ github.token }} @@ -146,6 +177,7 @@ jobs: done update-homebrew-tap: + if: ${{ github.event_name == 'push' || inputs.channel == 'stable' }} needs: [create-release, build] runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: @@ -175,13 +207,17 @@ jobs: - name: Render formula env: GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.create-release.outputs.tag }} + RELEASE_VERSION: ${{ needs.create-release.outputs.version }} run: | bun run scripts/update-homebrew-tap.ts \ - --tag="${{ needs.create-release.outputs.tag }}" \ - --version="${{ needs.create-release.outputs.version }}" \ + --tag="$RELEASE_TAG" \ + --version="$RELEASE_VERSION" \ --tap-dir=homebrew-tap - name: Commit and push tap update working-directory: homebrew-tap + env: + RELEASE_TAG: ${{ needs.create-release.outputs.tag }} run: | set -euo pipefail git add Formula/hack.rb @@ -191,5 +227,5 @@ jobs: fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "build(hack): update formula to ${{ needs.create-release.outputs.tag }}" + git commit -m "build(hack): update formula to $RELEASE_TAG" git push origin HEAD:main diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md new file mode 100644 index 000000000..015bd6520 --- /dev/null +++ b/docs/guides/candidate-install.md @@ -0,0 +1,152 @@ +# Install a native prerelease alongside stable Hack + +Native prereleases are opt-in Apple Silicon macOS bundles identified by +`5.0.0-next.N`, with a positive number and no leading zero. Install a specific +published GitHub prerelease. Download the installer from that same explicit tag +over HTTPS, review the saved file, then run it locally: + +```sh +curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ + https://raw.githubusercontent.com/hack-dance/hack/v5.0.0-next.1/scripts/install-prerelease.py \ + --output hack-next-install.py +less hack-next-install.py +python3 hack-next-install.py install --version 5.0.0-next.1 +``` + +The installer needs Python 3.9 or newer and macOS `codesign`; it does not need Bun, +Rust, Zig, or a source checkout at runtime. The bundle includes the compiled normal +CLI, native executor, Linux guest relay, launcher, provider pins, documentation, +version metadata, and checksums. A reviewed source checkout can instead use +`python3 scripts/install-prerelease.py install --version 5.0.0-next.1`. There is no +latest-version lookup or automatic updater. + +By default installation creates a private, current-user-owned mode-`0700` +`~/.hack-next` directory. Each immutable version directory contains a complete +bundle, its own `native-home`, and its own `cli-home`. An installation receipt binds +the bundle hashes and both homes' filesystem identities. Stable Homebrew `hack`, +its links, and `~/.hack` are untouched. No shell profile or PATH is edited. + +Run the candidate explicitly: + +```sh +"$HOME/.hack-next/bin/hack-next" --version +"$HOME/.hack-next/bin/hack-next" ps --path /absolute/project +python3 "$HOME/.hack-next/manager.py" --root "$HOME/.hack-next" status +``` + +`hack-next` selects the adjacent verified native executor and the selected version's +private native and CLI homes. These settings override inherited installation +selectors, including `HACK_GLOBAL_CONFIG_PATH`. Explicit native project adaptation, +dependency, AWS profile, routing, and source-sharing settings still reach the CLI. +Arguments, standard input and output, exit codes, and signals reach the selected +candidate. Concurrent observation and stop commands can accompany a foreground +candidate command; selecting another bundle refuses while any launcher is active. + +Provider setup and actual application acceptance remain separate steps. Installation +does not install the provider, boot a VM, enroll a project, move volumes, or activate +system DNS or trust. Follow [native candidate preparation](native-candidate.md) +using the selected bundle and its `native-home`. Candidate `hack-next update` +retains the existing refusal; use the channel manager's explicit upgrade command. + +## Upgrade and return to a retained candidate + +Stop each candidate graph with its ordinary retained-data shutdown, then stop that +version's owned runtime through the native executor. For the default installation +of `5.0.0-next.1`: + +```sh +"$HOME/.hack-next/bin/hack-next" down --path /absolute/project +"$HOME/.hack-next/versions/5.0.0-next.1/bundle/hack-native" \ + --candidate-root "$HOME/.hack-next/versions/5.0.0-next.1/native-home" \ + runtime down --json +python3 "$HOME/.hack-next/manager.py" --root "$HOME/.hack-next" \ + upgrade --version 5.0.0-next.2 +``` + +Upgrade requires a strictly newer version. Before and immediately before switching, +the manager verifies the selected bundle and receipts, asks that executor for +`runtime status --json`, requires an explicitly stopped runtime, confirms the +supported `runtime down --json` under the executor's ownership lock, and checks +status again. A running, failed, timed-out, malformed, or ambiguous observation +refuses selection. An uninitialized runtime is accepted only when its native home +is completely empty; unexplained leftover state requires inspection through its +owning CLI. The manager never treats a missing PID or a failed command as idle. + +Every new version starts with **fresh native and CLI homes**. Prepare its provider +and application explicitly. Upgrading selects software; it does not copy or migrate +the previous candidate's VM, project registry, volumes, environment store, or data. +The previous bundle and homes remain in place. + +After stopping the newly selected candidate in the same way, return to the previous +retained version and its original homes: + +```sh +python3 "$HOME/.hack-next/manager.py" --root "$HOME/.hack-next" rollback +"$HOME/.hack-next/bin/hack-next" --version +``` + +Both the current and target candidate must pass the same quiescence checks. Use +`rollback --version 5.0.0-next.1` to select another already registered retained +version. Read back a saved application marker after restoring its owned runtime +before claiming application rollback worked. Bundle selection alone does not prove +application or provider compatibility. + +## Return to stable Hack + +Stop the selected graphs and runtime, then deselect the candidate: + +```sh +python3 "$HOME/.hack-next/manager.py" --root "$HOME/.hack-next" stable +hack --version +``` + +Use your existing stable `hack` command and stable projects. `hack-next` reports +that no candidate is selected after this operation. All candidate bundles and +homes are retained. Candidate settings are confined to launcher children, so the +launcher does not leave exports in your shell. If you previously exported native +selectors manually, unset `HACK_RUNTIME_BACKEND`, `HACK_NATIVE_BINARY`, +`HACK_NATIVE_HOME`, `HACK_HOME`, and `HACK_GLOBAL_CONFIG_PATH` before running stable +Hack. This is software selection and coexistence, not conversion of native v5 VM +application data into the stable Docker runtime. + +## Verification, isolated installs, and interruption + +The network path accepts only the explicit tag in `hack-dance/hack` on GitHub. +GitHub must classify it as a published prerelease. The exact archive name is +`hack-VERSION-darwin-arm64-native.tar.gz`; the separate `prerelease.json` and outer +`SHA256SUMS` release assets must agree with it. HTTPS and official download hosts +are required, including redirects. Metadata binds the version, tag, full source +revision, and platform before extraction. The official tag reference must point +directly to that exact commit; moved, annotated, or malformed references refuse +installation before downloading the archive. The archive digest, every inner file +digest, exact payload inventory, and both macOS executable signatures are verified +before activation. An ad-hoc signature checks code integrity; it is not Apple +notarization or publisher authentication. The official pinned release and its +checksums remain the source identity boundary. + +For a private qualification installation, use an explicit canonical root whose +parent already exists. On macOS, use `/private/tmp` rather than the `/tmp` symlink. +A root must be empty or already belong to this manager; foreign paths, symlink +components, hard-linked payloads, changed receipts, and archive links, duplicate +entries, or traversal are rejected. A reviewed local archive can be installed with +an independently obtained expected digest: + +```sh +python3 scripts/install-prerelease.py --root /absolute/private/new-install \ + install --version 5.0.0-next.1 \ + --archive /absolute/reviewed/hack-5.0.0-next.1-darwin-arm64-native.tar.gz \ + --sha256 EXPECTED_ARCHIVE_SHA256 +``` + +Run that installation's `bin/hack-next` and pass the same `--root` to its manager. +Repeating an installation of the selected unchanged version is idempotent. Status, +launch, upgrade, and rollback revalidate the installed bundle, homes, and receipts. + +Selection changes commit by one atomic receipt replacement. A failed extraction, +checksum, signature, quiescence check, or interrupted pointer replacement preserves +the earlier selection. Interrupted staging directories and a complete bundle +published before an interrupted pointer switch are retained for inspection and +never adopted automatically. The selected prior bundle remains usable when its +own receipt is unchanged. A later attempt refuses to overwrite the uncommitted +version. Preserve the evidence and inspect it before an authorized cleanup; the +manager has no automatic pruning or receipt-repair command. diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index eb3c3e896..8a6b1eb94 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -23,6 +23,10 @@ interpreter or shared-library requirements before publishing the bundle. It cont state, project data, or credentials. It can be copied outside the source checkout; Rust, Zig, Bun and the checkout are not needed to run the resulting executable. Host system utilities and the pinned provider remain runtime prerequisites. +For versioned candidate packages, channel rules and publishing gates are described +in the [prerelease guide](https://github.com/hack-dance/hack/blob/next/docs/guides/prereleases.md), +and the separate `hack-next` installation path is described in the +[candidate installer guide](https://github.com/hack-dance/hack/blob/next/docs/guides/candidate-install.md). The build re-signs the final compiled frontend with a local ad-hoc signature and strictly verifies both macOS executables before generating checksums. This checks code integrity; an ad-hoc signature does not establish a publisher identity or @@ -63,9 +67,10 @@ or symlink only the script). It selects the adjacent executor even if inherited backend variables select another binary. It preserves arguments, exit codes and signals through `exec`, and never replaces the installed `hack`. Project-specific native adaptation, dependency and routing selections remain explicit; this entrypoint -does not prepare a provider or migrate existing projects. The embedded frontend -currently reports the repository package version; `hack-v5` identifies the opt-in -candidate channel, not a published v5 release. Unsupported native workflows still +does not prepare a provider or migrate existing projects. An unversioned local +build reports the repository package version. A versioned prerelease build reports +its explicit candidate version and includes source provenance. The `hack-v5` +entrypoint alone does not identify a published release. Unsupported native workflows still report their existing refusal rather than falling back to Docker. Native `up` accepts an explicit branch or the branch inferred from a linked Git @@ -95,8 +100,9 @@ inside one VM pool. ## Manual candidate upgrade and rollback -Candidate bundles are selected by their full path. There is no automatic candidate -updater or state migration manager. `hack-v5 update` refuses before discovering an +Manual candidate bundles are selected by their full path. The separate +[prerelease installer](https://github.com/hack-dance/hack/blob/next/docs/guides/candidate-install.md) manages verified versioned selections +with independent homes; it does not migrate runtime data. `hack-v5 update` refuses before discovering an installed binary or contacting a release server; install a separately reviewed, complete bundle to change the candidate. diff --git a/docs/guides/prereleases.md b/docs/guides/prereleases.md new file mode 100644 index 000000000..f4a30e240 --- /dev/null +++ b/docs/guides/prereleases.md @@ -0,0 +1,111 @@ +# V5 prerelease channel + +V5 native prereleases use a separate release and installation channel. Stable +Homebrew `hack` keeps its existing executable and state. A candidate is selected +explicitly through `hack-next`; installing one does not migrate a project, start a +VM, install DNS or trust, or replace stable Hack. + +The first supported package is for Apple Silicon macOS. Provider installation, +native project adaptations and host routing remain explicit prerequisites in the +[native candidate guide](native-candidate.md). A packaged candidate is not a claim +of full Compose parity, lower resource use, or compatibility with every project. + +## Versions and artifacts + +Candidate versions have the form `5.0.0-next.N`, where `N` is a positive integer +without leading zeros. The matching Git tag is `v5.0.0-next.N`. Each version is +immutable: a failed or superseded release gets a new number rather than replacing +its tag or assets. + +The native archive is named `hack-5.0.0-next.N-darwin-arm64-native.tar.gz`. It +contains the complete frontend, executor, guest relay, launcher, provider pins, +guide, checksums and `prerelease.json` provenance. The compiled frontend reports +the candidate version. The provenance records the exact source commit and +platform; filenames alone do not establish either. + +Verify the archive checksum and every bundled file before executing the candidate. +The macOS executable signatures are ad-hoc code-integrity checks. They do not +establish a publisher identity or Apple notarization. The pinned official GitHub +release is the distribution source. + +## Prepare and publish + +Preparation and publication are separate operations. Preparation validates the +version and source, builds the complete native package, and retains reviewable +artifacts without creating a Git tag or release. + +After the channel workflow is merged into `next`, dispatch the existing `Release` +workflow on that branch with an explicit version and its full commit SHA: + +```sh +gh workflow run release.yml --ref next \ + -f channel=prerelease \ + -f prerelease_version=5.0.0-next.1 \ + -f source_revision=FULL_NEXT_COMMIT_SHA \ + -f publish_prerelease=false +``` + +Use the intended candidate's actual SHA in place of `FULL_NEXT_COMMIT_SHA` and an +unused version number. Download the `v5-prerelease-VERSION` Actions artifact from +that run to review the archive, provenance and checksum. The reusable candidate +workflow resolves from the same commit as its caller; no new default-branch +workflow registration is needed for this entry point. + +A local versioned build uses the same native bundle builder and requires a clean, +committed source checkout: + +```sh +mise exec -- scripts/build-native-candidate.sh /absolute/new/candidate-bundle \ + --version=5.0.0-next.1 +``` + +This builds software and provenance only; it does not reserve the version number, +create a tag, or publish a release. + +Publication requires an explicit publish request for the exact protected `next` +commit, successful checks for that commit, and approval through a configured +`v5-prerelease` GitHub environment. An environment without required reviewers is +not an approval gate. Publication refuses missing protection or approval, changed +source, failed checks, and an existing tag or release. + +To request publication after reviewing the candidate, dispatch the same command +with `publish_prerelease=true`. The workflow checks protection and exact-head CI +before scheduling review, then checks the head, checks, human approval and tag +absence again before publication. Only the first attempt of a workflow run can +publish. If publication fails after creating a tag, preserve it for inspection +and use a new version; do not force-push or overwrite it. + +Candidate releases are marked as GitHub prereleases and excluded from “latest.” +They never update the stable Homebrew formula. The stable release workflow rejects +prerelease tags; stable semantic releases continue to use `main`. + +The branch dispatch and same-commit workflow selection follow GitHub's +[manual dispatch](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow) +and [reusable workflow](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows) +contracts. The publish build runs on the Apple Silicon `macos-15` runner; application +VM tests remain on a separately qualified native host. + +## Install, upgrade and return to stable + +Use the [candidate installer guide](candidate-install.md) for the explicit +version-pinned installation commands and lifecycle controls. The installer keeps +candidate bundles and homes separate from stable `hack` and `~/.hack`; it does not +change shell configuration or silently select a candidate. + +An upgrade retains the previous bundle and home. Selection changes require the +owning candidate CLI to confirm quiescence. A running runtime, timeout, malformed +receipt or uncertain ownership blocks the switch. Stop owned applications through +their ordinary commands before retrying; a failed probe is not evidence of an idle +runtime. + +Rollback selects a retained, verified candidate installation. Each installation +keeps its own home; selecting a different one does not copy VM disks, credentials +or application data. Returning to stable selects the existing stable executable +and its original Docker state. Preserve candidate data for a later retry rather +than trying to make a v4 executable read v5 receipts. + +Before accepting a candidate for a project, verify normal start, status, logs, +exec, run, restart and retaining shutdown, then exercise its browser sign-in, +data-backed pages and assets. Record the bundle version and source commit with +the result. Unit tests, hosted CI and a responding health endpoint do not replace +that application check. diff --git a/scripts/build-native-candidate.sh b/scripts/build-native-candidate.sh index 12cd7a2a2..da1ce2133 100755 --- a/scripts/build-native-candidate.sh +++ b/scripts/build-native-candidate.sh @@ -3,10 +3,21 @@ set -eu umask 077 repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) -if [ "$#" -ne 1 ]; then - echo "Usage: scripts/build-native-candidate.sh /absolute/new/bundle-directory" >&2 +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "Usage: scripts/build-native-candidate.sh /absolute/new/bundle-directory [--version=5.0.0-next.N]" >&2 exit 64 fi +version= +metadata= +if [ "$#" -eq 2 ]; then + case "$2" in --version=*) version=${2#--version=} ;; *) echo "Unknown build option" >&2; exit 64 ;; esac + source_revision=$(git -C "$repo" rev-parse HEAD) + metadata=$(HACK_PRERELEASE_VERSION="$version" HACK_PRERELEASE_SOURCE_REVISION="$source_revision" bun "$repo/scripts/prerelease-plan.ts" metadata) + if [ -n "$(git -C "$repo" status --porcelain --untracked-files=normal)" ]; then + echo "Versioned prereleases require a clean source checkout" >&2 + exit 73 + fi +fi case "$1" in /*) ;; *) echo "Output must be an absolute new directory" >&2; exit 64 ;; esac if [ "$(uname -s)" != Darwin ] || [ "$(uname -m)" != arm64 ]; then echo "Native candidate bundle currently supports Apple Silicon macOS only" >&2 @@ -62,7 +73,11 @@ cp "$guest" "$out/hack-relay-guest" chmod 755 "$out/hack-native" "$out/hack-relay-guest" python3 scripts/verify-native-relay.py "$out/hack-relay-guest" cp packages/runtime-core/provider-pins.json "$out/provider-pins.json" -bun build index.ts --compile --outfile "$out/hack-cli" +if [ -n "$version" ]; then + bun build index.ts --compile --define "__HACK_BUILD_VERSION__=\"$version\"" --outfile "$out/hack-cli" +else + bun build index.ts --compile --outfile "$out/hack-cli" +fi # Bun appends the compiled program to its runtime. Re-sign those final bytes; # preserve runtime metadata rather than trusting the embedded runtime's signature. /usr/bin/codesign --force --sign - --preserve-metadata=entitlements,flags,runtime "$out/hack-cli" @@ -71,9 +86,20 @@ bun build index.ts --compile --outfile "$out/hack-cli" cp scripts/hack-v5.sh "$out/hack-v5" chmod 755 "$out/hack-cli" "$out/hack-v5" cp docs/guides/native-candidate.md "$out/README.md" +if [ -n "$version" ]; then + printf '%s\n' "$metadata" > "$out/prerelease.json" + if [ "$("$out/hack-cli" --version)" != "hack v$version" ]; then + echo "Compiled CLI did not report the prerelease version" >&2 + exit 65 + fi +fi ( cd "$out" - shasum -a 256 hack-native hack-relay-guest hack-cli hack-v5 provider-pins.json README.md > SHA256SUMS + if [ -n "$version" ]; then + shasum -a 256 hack-native hack-relay-guest hack-cli hack-v5 provider-pins.json README.md prerelease.json > SHA256SUMS + else + shasum -a 256 hack-native hack-relay-guest hack-cli hack-v5 provider-pins.json README.md > SHA256SUMS + fi ) echo "Candidate bundle: $out" echo "Verify SHA256SUMS before copying. Create a separate mode-0700 candidate home." diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py new file mode 100644 index 000000000..f54da98ba --- /dev/null +++ b/scripts/install-prerelease.py @@ -0,0 +1,630 @@ +#!/usr/bin/env python3 +"""Explicit, side-by-side native prerelease selection (Python 3.9+, standard library). + +The single atomic selection receipt is the commit point. Bundles and homes are +retained by version; this manager never migrates runtime state or edits shell PATH. +""" + +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import signal +import stat +import subprocess +import sys +import tarfile +import tempfile +import urllib.parse +import urllib.request +import uuid + + +VERSION = re.compile(r"5\.0\.0-next\.([1-9][0-9]*)\Z") +SHA256 = re.compile(r"[0-9a-f]{64}\Z") +REVISION = re.compile(r"[0-9a-f]{40}\Z") +PAYLOAD = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", + "provider-pins.json", "README.md", "prerelease.json")) +EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5")) +BUNDLE_FILES = PAYLOAD | {"SHA256SUMS"} +MAX_ARCHIVE = 512 * 1024 * 1024 +METADATA_KEYS = {"schema", "version", "tag", "source_revision", "platform"} +REPOSITORY = "hack-dance/hack" +DOWNLOAD_HOSTS = {"api.github.com", "github.com", "release-assets.githubusercontent.com", + "objects.githubusercontent.com"} + + +class Refusal(Exception): + """Untrusted, changed or ambiguous input: preserve the current selection.""" + + +def require(condition, message): + if not condition: + raise Refusal(message) + + +def version_number(value): + match = VERSION.fullmatch(value) if isinstance(value, str) else None + require(match is not None, "Version must be 5.0.0-next.N (positive N, no leading zero).") + return int(match.group(1)) + + +def digest(path): + value = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def json_bytes(value): + return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode("utf-8") + + +def parse_json(raw): + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON field.") + result[key] = value + return result + try: + value = json.loads(raw, object_pairs_hook=unique) + except (ValueError, UnicodeError) as error: + raise Refusal("Malformed JSON.") from error + require(isinstance(value, dict), "Expected a JSON object.") + return value + + +def metadata(raw, version): + value = parse_json(raw) + require(set(value) == METADATA_KEYS, "Unexpected prerelease metadata fields.") + require(value["schema"] == "hack.prerelease/v1" and value["version"] == version + and value["tag"] == "v" + version and value["platform"] == "darwin-arm64" + and isinstance(value["source_revision"], str) + and REVISION.fullmatch(value["source_revision"]), "Prerelease identity mismatch.") + return value + + +def canonical(path): + path = Path(path) + require(path.is_absolute() and path == path.resolve(), + "Use a canonical absolute path without symlinks or traversal.") + return path + + +def owned(path, directory=False, mode=None): + info = path.lstat() + expected = stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode) + require(expected and info.st_uid == os.getuid(), "Foreign or aliased path: " + str(path)) + if not directory: + require(info.st_nlink == 1, "Hard-linked file: " + str(path)) + if mode is not None: + require(stat.S_IMODE(info.st_mode) == mode, "Unsafe mode: " + str(path)) + return info + + +def private_json(path): + owned(path, mode=0o600) + require(path.stat().st_size <= 1024 * 1024, "Oversized installation receipt.") + return parse_json(path.read_bytes()) + + +def sync_directory(path): + descriptor = os.open(str(path), os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_file(path, contents, mode=0o600): + descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + with os.fdopen(descriptor, "wb") as target: + os.fchmod(target.fileno(), mode) + target.write(contents) + target.flush() + os.fsync(target.fileno()) + + +def atomic_json(path, value): + temporary = path.parent / (".selection-" + uuid.uuid4().hex) + try: + write_file(temporary, json_bytes(value)) + os.replace(str(temporary), str(path)) + sync_directory(path.parent) + finally: + if temporary.exists(): + temporary.unlink() + + +def checksums(raw, expected): + try: + lines = raw.decode("ascii").splitlines() + except UnicodeError as error: + raise Refusal("Malformed SHA256SUMS.") from error + result = {} + for line in lines: + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9.-]+)", line) + require(match is not None, "Malformed SHA256SUMS entry.") + checksum, name = match.groups() + require(name in expected and name not in result, "Duplicate or foreign checksum entry.") + result[name] = checksum + require(set(result) == set(expected), "Incomplete checksum manifest.") + return result + + +def verify_bundle(bundle, version): + owned(bundle, directory=True, mode=0o700) + require({entry.name for entry in bundle.iterdir()} == BUNDLE_FILES, + "Incomplete or foreign candidate bundle.") + for name in BUNDLE_FILES: + owned(bundle / name, mode=0o755 if name in EXECUTABLES else 0o600) + require((bundle / "SHA256SUMS").stat().st_size <= 64 * 1024 + and (bundle / "prerelease.json").stat().st_size <= 64 * 1024, + "Oversized bundle metadata or checksum manifest.") + manifest = checksums((bundle / "SHA256SUMS").read_bytes(), PAYLOAD) + for name, checksum in manifest.items(): + require(digest(bundle / name) == checksum, "Candidate checksum mismatch: " + name) + identity = metadata((bundle / "prerelease.json").read_bytes(), version) + return identity, manifest + + +def verify_signatures(bundle): + for name in ("hack-native", "hack-cli"): + try: + result = subprocess.run(["/usr/bin/codesign", "--verify", "--strict", str(bundle / name)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refusal("Cannot verify candidate code signature.") from error + require(result.returncode == 0, "Candidate code signature failed: " + name) + + +def extract_archive(archive, checksum, destination, version, release_metadata=None): + owned(archive) + require(SHA256.fullmatch(checksum) is not None, "Expected archive SHA-256 is required.") + require(archive.stat().st_size <= MAX_ARCHIVE and digest(archive) == checksum, + "Archive checksum or size mismatch.") + try: + with tarfile.open(str(archive), "r:gz") as source: + entries = [] + seen = set() + total = 0 + for entry in source: + require(len(entries) < len(BUNDLE_FILES), "Incomplete or duplicate archive entries.") + require(entry.name in BUNDLE_FILES and entry.name not in seen, + "Foreign, duplicate or traversing archive entry.") + require(entry.isfile() and not entry.issparse() and entry.size > 0 + and entry.size <= MAX_ARCHIVE, "Only bounded regular archive files are accepted.") + seen.add(entry.name) + total += entry.size + require(total <= MAX_ARCHIVE, "Oversized archive.") + entries.append(entry) + require(seen == BUNDLE_FILES, "Incomplete archive.") + by_name = {entry.name: entry for entry in entries} + require(by_name["prerelease.json"].size <= 64 * 1024 + and by_name["SHA256SUMS"].size <= 64 * 1024, + "Oversized archive metadata or checksum manifest.") + with source.extractfile(by_name["prerelease.json"]) as incoming: + identity = metadata(incoming.read(), version) + require(release_metadata is None or identity == release_metadata, + "Archive metadata differs from the pinned release.") + with source.extractfile(by_name["SHA256SUMS"]) as incoming: + manifest = checksums(incoming.read(), PAYLOAD) + # Preflight hashes before writing any payload. Revalidation after + # copying also catches a source changed between these two passes. + for entry in entries: + name = entry.name + if name not in PAYLOAD: + continue + value = hashlib.sha256() + with source.extractfile(entry) as incoming: + for chunk in iter(lambda: incoming.read(1024 * 1024), b""): + value.update(chunk) + require(value.hexdigest() == manifest[name], "Candidate checksum mismatch: " + name) + destination.mkdir(mode=0o700) + for entry in entries: + incoming = source.extractfile(entry) + require(incoming is not None, "Unreadable archive payload.") + with incoming: + descriptor = os.open(str(destination / entry.name), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o755 if entry.name in EXECUTABLES else 0o600) + with os.fdopen(descriptor, "wb") as target: + os.fchmod(target.fileno(), 0o755 if entry.name in EXECUTABLES else 0o600) + remaining = entry.size + while remaining: + chunk = incoming.read(min(1024 * 1024, remaining)) + require(bool(chunk), "Truncated archive payload.") + target.write(chunk) + remaining -= len(chunk) + target.flush() + os.fsync(target.fileno()) + except (tarfile.TarError, EOFError) as error: + raise Refusal("Invalid candidate archive.") from error + identity, manifest = verify_bundle(destination, version) + require(release_metadata is None or identity == release_metadata, + "Archive metadata differs from the pinned release.") + verify_signatures(destination) + sync_directory(destination) + return identity, manifest + + +def secure_url(url): + parsed = urllib.parse.urlsplit(url) + require(parsed.scheme == "https" and parsed.hostname in DOWNLOAD_HOSTS + and parsed.port in (None, 443) and not parsed.username and not parsed.password + and not parsed.fragment, "Refusing nonofficial or non-HTTPS download.") + + +class OfficialRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + secure_url(url) + return super().redirect_request(request, response, code, message, headers, url) + + +def download(url, target, limit): + secure_url(url) + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json", + "User-Agent": "hack-prerelease-installer"}) + opener = urllib.request.build_opener(OfficialRedirect()) + with opener.open(request, timeout=30) as response: + secure_url(response.geturl()) + descriptor = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as output: + size = 0 + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + size += len(chunk) + require(size <= limit, "Release download exceeds its size budget.") + output.write(chunk) + output.flush() + os.fsync(output.fileno()) + + +def official_archive(stage, version): + tag = "v" + version + api_path = stage / "release-api.json" + download("https://api.github.com/repos/" + REPOSITORY + "/releases/tags/" + tag, + api_path, 2 * 1024 * 1024) + release = parse_json(api_path.read_bytes()) + require(release.get("tag_name") == tag and release.get("prerelease") is True + and release.get("draft") is False, "Expected a published, pinned official prerelease.") + archive_name = "hack-" + version + "-darwin-arm64-native.tar.gz" + wanted = {archive_name, "SHA256SUMS", "prerelease.json"} + assets = release.get("assets") + require(isinstance(assets, list), "Missing release assets.") + selected = {} + for asset in assets: + require(isinstance(asset, dict), "Malformed release asset.") + name = asset.get("name") + require(isinstance(name, str), "Malformed release asset name.") + if name in wanted: + require(name not in selected, "Duplicate release asset.") + expected = "https://github.com/" + REPOSITORY + "/releases/download/" + tag + "/" + name + require(asset.get("browser_download_url") == expected, "Foreign release asset URL.") + selected[name] = expected + require(set(selected) == wanted, "Incomplete official prerelease assets.") + download(selected["prerelease.json"], stage / "release-metadata.json", 64 * 1024) + identity = metadata((stage / "release-metadata.json").read_bytes(), version) + download("https://api.github.com/repos/" + REPOSITORY + "/git/ref/tags/" + tag, + stage / "release-tag.json", 64 * 1024) + reference = parse_json((stage / "release-tag.json").read_bytes()) + commit = reference.get("object") + require(reference.get("ref") == "refs/tags/" + tag and isinstance(commit, dict) + and commit.get("type") == "commit" and commit.get("sha") == identity["source_revision"], + "Official prerelease tag does not match its source revision.") + download(selected["SHA256SUMS"], stage / "release-checksums", 64 * 1024) + checksum = checksums((stage / "release-checksums").read_bytes(), {archive_name})[archive_name] + archive = stage / archive_name + download(selected[archive_name], archive, MAX_ARCHIVE) + return archive, checksum, identity + + +class Channel: + """Owned private layout; the selection file records immutable receipt hashes.""" + + def __init__(self, root): + self.root = canonical(root) + self.state = None + + def initialize(self): + if not self.root.exists(): + owned(self.root.parent, directory=True) + self.root.mkdir(mode=0o700) + owned(self.root, directory=True, mode=0o700) + if (self.root / ".channel.json").exists(): + return + require(not list(self.root.iterdir()), "Refusing to adopt a nonempty installation root.") + (self.root / "bin").mkdir(mode=0o700) + (self.root / "versions").mkdir(mode=0o700) + write_file(self.root / "manager.py", Path(__file__).read_bytes()) + launcher = ("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd -P)\n" + "exec /usr/bin/python3 -I -S \"$root/manager.py\" --root \"$root\" run -- \"$@\"\n") + write_file(self.root / "bin/hack-next", launcher.encode(), 0o755) + root_info = self.root.stat() + write_file(self.root / ".channel.json", json_bytes({ + "schema": "hack.prerelease-install/v1", "root": str(self.root), "uid": os.getuid(), + "device": root_info.st_dev, "inode": root_info.st_ino, + "manager_sha256": digest(self.root / "manager.py"), + "launcher_sha256": digest(self.root / "bin/hack-next")})) + write_file(self.root / ".selection.json", json_bytes({ + "schema": "hack.prerelease-selection/v1", "installed": {}, + "selected": None, "previous": None})) + write_file(self.root / ".manager.lock", b"") + sync_directory(self.root) + + @contextlib.contextmanager + def lock(self, shared=False): + owned(self.root, directory=True, mode=0o700) + owned(self.root / ".manager.lock", mode=0o600) + descriptor = os.open(str(self.root / ".manager.lock"), os.O_RDWR | os.O_NOFOLLOW) + with os.fdopen(descriptor, "rb") as handle: + try: + fcntl.flock(handle, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) + except BlockingIOError as error: + raise Refusal("Another candidate manager or launcher is active.") from error + self.validate() + yield + + def validate(self): + root_info = owned(self.root, directory=True, mode=0o700) + marker = private_json(self.root / ".channel.json") + require(set(marker) == {"schema", "root", "uid", "device", "inode", + "manager_sha256", "launcher_sha256"} + and marker["schema"] == "hack.prerelease-install/v1" + and marker["root"] == str(self.root) and marker["uid"] == os.getuid() + and marker["device"] == root_info.st_dev and marker["inode"] == root_info.st_ino, + "Foreign or malformed installation receipt.") + owned(self.root / "manager.py", mode=0o600) + owned(self.root / "bin", directory=True, mode=0o700) + owned(self.root / "bin/hack-next", mode=0o755) + require(digest(self.root / "manager.py") == marker["manager_sha256"] + and digest(self.root / "bin/hack-next") == marker["launcher_sha256"], + "Installation manager or launcher changed.") + require({p.name for p in (self.root / "bin").iterdir()} == {"hack-next"}, + "Foreign launcher entry.") + state = private_json(self.root / ".selection.json") + require(set(state) == {"schema", "installed", "selected", "previous"} + and state["schema"] == "hack.prerelease-selection/v1" + and isinstance(state["installed"], dict), "Malformed selection receipt.") + installed = state["installed"] + require(len(installed) <= 256, "Too many candidate versions; inspection required.") + for version, checksum in installed.items(): + version_number(version) + require(isinstance(checksum, str) and SHA256.fullmatch(checksum), "Malformed receipt hash.") + self.verify_version(version, checksum) + for key in ("selected", "previous"): + require(state[key] is None or isinstance(state[key], str) and state[key] in installed, + "Unknown selected or previous candidate.") + require(state["selected"] is None or state["selected"] != state["previous"], + "Malformed candidate history.") + owned(self.root / "versions", directory=True, mode=0o700) + for entry in (self.root / "versions").iterdir(): + if entry.name not in installed: + version_number(entry.name) + # Rename can precede selection by a power-loss boundary. Validate + # the retained directory, but never select/adopt it automatically. + private_json(entry / ".receipt.json") + self.verify_version(entry.name, digest(entry / ".receipt.json")) + allowed = {".channel.json", ".selection.json", ".manager.lock", "manager.py", "bin", "versions"} + for entry in self.root.iterdir(): + if entry.name in allowed: + continue + if re.fullmatch(r"\.stage-[0-9a-f]{32}", entry.name): + owned(entry, directory=True, mode=0o700) + continue + if re.fullmatch(r"\.selection-[0-9a-f]{32}", entry.name): + owned(entry, mode=0o600) + continue + raise Refusal("Foreign installation entry: " + entry.name) + self.state = state + + def verify_version(self, version, checksum): + directory = self.root / "versions" / version + owned(directory, directory=True, mode=0o700) + require({p.name for p in directory.iterdir()} == {"bundle", "native-home", "cli-home", ".receipt.json"}, + "Incomplete or foreign version installation.") + receipt_path = directory / ".receipt.json" + receipt = private_json(receipt_path) + require(digest(receipt_path) == checksum, "Candidate installation receipt changed.") + require(set(receipt) == {"schema", "metadata", "archive_sha256", "checksums", "homes"} + and receipt["schema"] == "hack.prerelease-version/v1" + and isinstance(receipt["archive_sha256"], str) + and SHA256.fullmatch(receipt["archive_sha256"]), "Malformed version receipt.") + identity, manifest = verify_bundle(directory / "bundle", version) + require(receipt["metadata"] == identity and receipt["checksums"] == manifest, + "Candidate identity or checksum receipt changed.") + require(isinstance(receipt["homes"], dict) and set(receipt["homes"]) == {"native-home", "cli-home"}, + "Malformed candidate home receipt.") + for name in ("native-home", "cli-home"): + info = owned(directory / name, directory=True, mode=0o700) + require(receipt["homes"][name] == {"device": info.st_dev, "inode": info.st_ino}, + "Candidate home identity changed.") + return directory + + def environment(self, version): + directory = self.root / "versions" / version + environment = dict(os.environ) + environment.pop("HACK_GLOBAL_CONFIG_PATH", None) + environment.update({"HACK_RUNTIME_BACKEND": "native", + "HACK_NATIVE_BINARY": str(directory / "bundle/hack-native"), + "HACK_NATIVE_HOME": str(directory / "native-home"), + "HACK_HOME": str(directory / "cli-home")}) + return environment + + def require_quiescent(self, version): + if version is None: + return + self.validate() + directory = self.verify_version(version, self.state["installed"][version]) + verify_signatures(directory / "bundle") + for action in ("status", "down", "status"): + command = [str(directory / "bundle/hack-native"), "--candidate-root", + str(directory / "native-home"), "runtime", action, "--json"] + try: + result = subprocess.run(command, env=self.environment(version), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refusal("Candidate quiescence is unavailable; selection preserved.") from error + require(result.returncode == 0 and len(result.stdout) <= 1024 * 1024, + "Candidate quiescence check failed; failure never means idle.") + status = parse_json(result.stdout) + phase = status.get("phase") + require(phase in ("uninitialized", "stopped") and status.get("process_alive") is False, + "Candidate runtime is active or ambiguous; stop it through its supported CLI first.") + if phase == "uninitialized": + require(not list((directory / "native-home").iterdir()), + "Uninitialized home contains state; inspect it through its owning CLI.") + self.validate() + + def select(self, version, installed=None): + self.require_quiescent(self.state["selected"]) + if version is not None: + self.require_quiescent(version) + # Recheck receipts at the effect boundary, then commit exactly one pointer. + self.validate() + state = dict(self.state) + if installed is not None: + state["installed"] = installed + if version != state["selected"]: + state["previous"] = state["selected"] + state["selected"] = version + atomic_json(self.root / ".selection.json", state) + self.state = state + + def install(self, version, archive=None, checksum=None, upgrade=False): + version_number(version) + current = self.state["selected"] + if version in self.state["installed"]: + require(version == current, "Version is already installed; use rollback to select it.") + if archive is not None: + archive = canonical(archive) + owned(archive) + require(archive is None or digest(archive) == checksum + == private_json(self.root / "versions" / version / ".receipt.json")["archive_sha256"], + "Installed version differs from the supplied archive.") + return + require(upgrade or current is None, "A candidate is selected; use upgrade with a newer version.") + require(current is None or version_number(version) > version_number(current), + "Upgrade must increase the prerelease version; use rollback for an installed version.") + self.require_quiescent(current) + stage = self.root / (".stage-" + uuid.uuid4().hex) + stage.mkdir(mode=0o700) + if archive is None: + archive, checksum, release_metadata = official_archive(stage, version) + else: + archive = canonical(archive) + release_metadata = None + installation = stage / "installation" + installation.mkdir(mode=0o700) + identity, manifest = extract_archive(archive, checksum, installation / "bundle", version, + release_metadata) + homes = {} + for name in ("native-home", "cli-home"): + (installation / name).mkdir(mode=0o700) + info = (installation / name).stat() + homes[name] = {"device": info.st_dev, "inode": info.st_ino} + write_file(installation / ".receipt.json", json_bytes({ + "schema": "hack.prerelease-version/v1", "metadata": identity, + "archive_sha256": checksum, "checksums": manifest, "homes": homes})) + sync_directory(installation) + # Final refusal happens before publication. A power loss after rename retains + # an uncommitted directory for inspection, while the old selection stays valid. + self.require_quiescent(current) + self.validate() + destination = self.root / "versions" / version + require(not destination.exists(), "Refusing to overwrite a candidate version.") + os.rename(str(installation), str(destination)) + sync_directory(destination.parent) + installed = dict(self.state["installed"]) + installed[version] = digest(destination / ".receipt.json") + state = dict(self.state, installed=installed, selected=version, previous=current) + atomic_json(self.root / ".selection.json", state) + self.state = state + + def run(self, arguments): + version = self.state["selected"] + require(version is not None, "No candidate selected. Use your ordinary stable hack command.") + directory = self.verify_version(version, self.state["installed"][version]) + verify_signatures(directory / "bundle") + # Shared launcher locks permit ps/down alongside foreground up, while an + # exclusive selection switch refuses any in-flight launcher. Background + # runtime state is gated by the owning executor on later switches. + child = subprocess.Popen([str(directory / "bundle/hack-v5"), *arguments], + env=self.environment(version)) + handlers = {} + def forward(signum, _frame): + child.send_signal(signum) + try: + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + handlers[signum] = signal.signal(signum, forward) + code = child.wait() + return code if code >= 0 else 128 - code + finally: + for signum, handler in handlers.items(): + signal.signal(signum, handler) + + +def parser(): + arguments = argparse.ArgumentParser(description=__doc__) + arguments.add_argument("--root", type=Path, default=Path.home() / ".hack-next", + help="private canonical installation root (default ~/.hack-next)") + commands = arguments.add_subparsers(dest="command", required=True) + for command in ("install", "upgrade"): + install = commands.add_parser(command) + install.add_argument("--version", required=True) + install.add_argument("--archive", type=Path, help="explicit local archive instead of GitHub") + install.add_argument("--sha256", help="required expected digest for --archive") + rollback = commands.add_parser("rollback") + rollback.add_argument("--version", help="retained version; default previous selection") + commands.add_parser("stable", help="deselect candidate; retain all bundles and homes") + commands.add_parser("status") + run = commands.add_parser("run") + run.add_argument("arguments", nargs=argparse.REMAINDER) + return arguments + + +def main(argv=None): + args = parser().parse_args(argv) + os.umask(0o077) + require(platform.system() == "Darwin" and platform.machine() == "arm64", + "Native prereleases support Apple Silicon macOS only.") + channel = Channel(args.root) + if args.command in ("install", "upgrade"): + version_number(args.version) + require(bool(args.archive) == bool(args.sha256), "--archive and --sha256 must be supplied together.") + channel.initialize() + with channel.lock(shared=args.command == "run"): + if args.command in ("install", "upgrade"): + channel.install(args.version, args.archive, args.sha256, args.command == "upgrade") + elif args.command == "rollback": + version = args.version or channel.state["previous"] + require(version is not None and version in channel.state["installed"], "No retained rollback version.") + channel.select(version) + elif args.command == "stable": + channel.select(None) + elif args.command == "run": + arguments = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments + return channel.run(arguments) + print(json.dumps({"selected": channel.state["selected"], "previous": channel.state["previous"], + "installed": sorted(channel.state["installed"], key=version_number), + "launcher": str(channel.root / "bin/hack-next")})) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (Refusal, OSError, ValueError) as error: + print("hack-next: " + str(error), file=sys.stderr) + sys.exit(1) diff --git a/scripts/prerelease-plan.ts b/scripts/prerelease-plan.ts new file mode 100644 index 000000000..1d6c05e8a --- /dev/null +++ b/scripts/prerelease-plan.ts @@ -0,0 +1,689 @@ +#!/usr/bin/env bun +import { appendFile, lstat, mkdir, readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export const PRERELEASE_ENVIRONMENT = "v5-prerelease"; +export const PRERELEASE_PAYLOAD = [ + "hack-native", + "hack-relay-guest", + "hack-cli", + "hack-v5", + "provider-pins.json", + "README.md", + "prerelease.json", +] as const; +const VERSION = /^5\.0\.0-next\.[1-9][0-9]*$/; +const REVISION = /^[0-9a-f]{40}$/; +const STABLE_TAG = /^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/; + +export function prereleaseMetadata({ + version, + sourceRevision, +}: { + readonly version: string; + readonly sourceRevision: string; +}) { + if (!VERSION.test(version) || version.trim() !== version) { + throw new Error( + "Version must be 5.0.0-next.N with a positive integer and no leading zeros" + ); + } + if (!REVISION.test(sourceRevision) || sourceRevision.length !== 40) { + throw new Error( + "Source revision must be a full lowercase 40-character commit SHA" + ); + } + return { + schema: "hack.prerelease/v1", + version, + tag: `v${version}`, + source_revision: sourceRevision, + platform: "darwin-arm64", + } as const; +} + +export function createPrereleasePlan({ + version, + sourceRevision, + ref, + eventName, + channel, + publish = "false", +}: { + readonly version: string; + readonly sourceRevision: string; + readonly ref: string; + readonly eventName: string; + readonly channel: string; + readonly publish?: string; +}) { + const metadata = prereleaseMetadata({ version, sourceRevision }); + if ( + channel !== "prerelease" || + ref !== "refs/heads/next" || + eventName !== "workflow_dispatch" + ) { + throw new Error( + "Prereleases require an explicit prerelease dispatch from refs/heads/next" + ); + } + if (publish !== "false" && publish !== "true") { + throw new Error("Publish must be explicitly true or false"); + } + return { + ...metadata, + archive: `hack-${version}-darwin-arm64-native.tar.gz`, + publish: publish === "true", + environment: PRERELEASE_ENVIRONMENT, + assets: [ + `hack-${version}-darwin-arm64-native.tar.gz`, + "prerelease.json", + "SHA256SUMS", + ], + }; +} + +export type PrereleasePlan = ReturnType; + +export function stableReleaseVersion({ + channel, + tag, + packageVersion, +}: { + readonly channel: string; + readonly tag: string; + readonly packageVersion: string; +}) { + if ( + channel !== "stable" || + !STABLE_TAG.test(tag) || + tag.trim() !== tag || + tag !== `v${packageVersion}` + ) { + throw new Error( + "Stable releases require a stable vX.Y.Z tag matching package.json" + ); + } + return packageVersion; +} + +/** A name alone can auto-create an unprotected environment. Verify actual protection. */ +export function requireReviewerProtection(value: unknown): number { + const environment = object(value); + const rules = array(environment.protection_rules); + const protectedReview = rules.some((value) => { + const rule = object(value); + return ( + rule.type === "required_reviewers" && + array(rule.reviewers).some((value) => { + const entry = object(value); + const reviewer = object(entry.reviewer); + return ( + (entry.type === "User" || entry.type === "Team") && + typeof reviewer.id === "number" && + Number.isSafeInteger(reviewer.id) && + reviewer.id > 0 + ); + }) + ); + }); + if ( + environment.name !== PRERELEASE_ENVIRONMENT || + typeof environment.id !== "number" || + !Number.isSafeInteger(environment.id) || + environment.id <= 0 || + environment.can_admins_bypass !== false || + !protectedReview + ) { + throw new Error( + "v5-prerelease must have required reviewers and administrator bypass disabled" + ); + } + return environment.id; +} + +export function requireHumanApproval({ + reviews, + environmentId, +}: { + readonly reviews: unknown; + readonly environmentId: number; +}) { + const relevant = array(reviews) + .map(object) + .filter((review) => + array(review.environments).some( + (value) => object(value).id === environmentId + ) + ); + const approved = relevant.some((review) => { + const user = object(review.user); + return ( + review.state === "approved" && + user.type === "User" && + typeof user.id === "number" && + Number.isSafeInteger(user.id) && + user.id > 0 + ); + }); + if (!approved || relevant.some((review) => review.state === "rejected")) { + throw new Error( + "This workflow run needs a recorded human approval for v5-prerelease" + ); + } +} + +const CI_JOBS = [ + "Runtime state models", + "Candidate core (ubuntu-latest)", + "Candidate core (macos-latest)", + "secret-scan", + "runtime-images", + "docker-e2e", + "test", + "linux-process-lifetime", +] as const; + +export function requireExactHeadCi({ + run, + jobs, + sourceRevision, +}: { + readonly run: unknown; + readonly jobs: unknown; + readonly sourceRevision: string; +}) { + const ci = object(run); + if ( + ci.head_sha !== sourceRevision || + ci.head_branch !== "next" || + ci.event !== "push" || + ci.status !== "completed" || + ci.conclusion !== "success" || + ci.path !== ".github/workflows/ci.yml" + ) { + throw new Error( + "A successful next push CI run is required on the exact source SHA" + ); + } + const completed = array(jobs).map(object); + for (const name of CI_JOBS) { + if ( + !completed.some( + (job) => + job.name === name && + job.head_sha === sourceRevision && + job.status === "completed" && + job.conclusion === "success" + ) + ) { + throw new Error(`Exact-head CI job did not succeed: ${name}`); + } + } + if ( + completed.some( + (job) => job.status !== "completed" || job.conclusion !== "success" + ) + ) { + throw new Error( + "Every job in the exact-head CI run must succeed without skips" + ); + } +} + +type Api = (path: string, allowMissing?: boolean) => Promise; + +/** Rechecked after environment approval, immediately before any release mutation. */ +export async function verifyPublishGate({ + plan, + api, + runId, + approved, +}: { + readonly plan: PrereleasePlan; + readonly api: Api; + readonly runId: string; + readonly approved: boolean; +}) { + const branch = object(await api("branches/next")); + if ( + branch.protected !== true || + object(branch.commit).sha !== plan.source_revision + ) { + throw new Error("Source SHA must still be the protected next head"); + } + const environmentId = requireReviewerProtection( + await api(`environments/${PRERELEASE_ENVIRONMENT}`) + ); + const runs = object( + await api( + `actions/workflows/ci.yml/runs?branch=next&event=push&head_sha=${plan.source_revision}&per_page=1` + ) + ); + const run = object(array(runs.workflow_runs)[0]); + if (typeof run.id !== "number") { + throw new Error("No exact-head next CI run exists"); + } + const jobs = await pages({ + api, + path: `actions/runs/${run.id}/jobs`, + field: "jobs", + }); + requireExactHeadCi({ run, jobs, sourceRevision: plan.source_revision }); + await requireBranchChecks({ api, sourceRevision: plan.source_revision }); + if ( + (await api(`git/ref/tags/${plan.tag}`, true)) !== null || + (await api(`releases/tags/${plan.tag}`, true)) !== null + ) { + throw new Error("Refusing to replace an existing tag or release"); + } + if (approved) { + if (!/^[1-9][0-9]*$/.test(runId)) { + throw new Error("Publishing needs the current workflow run id"); + } + requireHumanApproval({ + reviews: await api(`actions/runs/${runId}/approvals`), + environmentId, + }); + } +} + +async function requireBranchChecks({ + api, + sourceRevision, +}: { + readonly api: Api; + readonly sourceRevision: string; +}) { + const rules = array(await api("rules/branches/next")).map(object); + const classic = await api( + "branches/next/protection/required_status_checks", + true + ); + const requirements = rules + .filter((rule) => rule.type === "required_status_checks") + .flatMap((rule) => array(object(rule.parameters).required_status_checks)) + .map(object); + if (classic !== null) { + const policy = object(classic); + requirements.push( + ...array(policy.checks).map((value) => { + const check = object(value); + return { context: check.context, integration_id: check.app_id }; + }) + ); + requirements.push( + ...array(policy.contexts).map((context) => ({ context })) + ); + } + if (requirements.length === 0) { + throw new Error("next must require status checks"); + } + const checks = ( + await pages({ + api, + path: `commits/${sourceRevision}/check-runs?filter=latest`, + field: "check_runs", + }) + ).map(object); + const statuses = ( + await pages({ api, path: `commits/${sourceRevision}/statuses` }) + ).map(object); + for (const requirement of requirements) { + if ( + typeof requirement.context !== "string" || + requirement.context.length === 0 + ) { + throw new Error("Invalid required-check policy"); + } + const check = checks.find( + (check) => + check.name === requirement.context && + (requirement.integration_id == null || + requirement.integration_id === -1 || + object(check.app).id === requirement.integration_id) + ); + const status = statuses.find( + (status) => status.context === requirement.context + ); + const success = check + ? check.head_sha === sourceRevision && + check.status === "completed" && + check.conclusion === "success" + : requirement.integration_id == null && status?.state === "success"; + if (!success) { + throw new Error( + `Required exact-head check did not succeed: ${requirement.context}` + ); + } + } +} + +async function pages({ + api, + path, + field, +}: { + readonly api: Api; + readonly path: string; + readonly field?: string; +}) { + const all: unknown[] = []; + for (let page = 1; page <= 10; page++) { + const result = await api( + `${path}${path.includes("?") ? "&" : "?"}per_page=100&page=${page}` + ); + const items = array(field ? object(result)[field] : result); + all.push(...items); + if (items.length < 100) { + return all; + } + } + throw new Error("GitHub pagination limit exceeded"); +} + +export async function packagePrerelease({ + plan, + bundle, + output, +}: { + readonly plan: PrereleasePlan; + readonly bundle: string; + readonly output: string; +}) { + if (!(await lstat(bundle)).isDirectory()) { + throw new Error("Native bundle root must be a directory, not an alias"); + } + const expected = [...PRERELEASE_PAYLOAD, "SHA256SUMS"].sort(); + if ( + JSON.stringify((await readdir(bundle)).sort()) !== JSON.stringify(expected) + ) { + throw new Error( + "Native prerelease bundle must contain exactly the complete payload and checksums" + ); + } + for (const name of expected) { + const entry = await lstat(join(bundle, name)); + if (!entry.isFile() || entry.nlink !== 1) { + throw new Error(`Native bundle payload must be a regular file: ${name}`); + } + } + const metadata = prereleaseMetadata({ + version: plan.version, + sourceRevision: plan.source_revision, + }); + const actualMetadata: unknown = await Bun.file( + join(bundle, "prerelease.json") + ).json(); + if (JSON.stringify(actualMetadata) !== JSON.stringify(metadata)) { + throw new Error( + "Bundle metadata must match the requested version and revision" + ); + } + const checksums = await renderChecksums({ + root: bundle, + names: PRERELEASE_PAYLOAD, + }); + if ((await Bun.file(join(bundle, "SHA256SUMS")).text()) !== checksums) { + throw new Error( + "Native bundle checksums do not match its complete payload" + ); + } + await mkdir(output, { mode: 0o700 }); + // macOS tar otherwise adds AppleDouble members for source extended attributes. + // GNU tar ignores COPYFILE_DISABLE, so the fixed payload remains portable. + await run( + [ + "tar", + "-czf", + join(output, plan.archive), + "-C", + bundle, + ...PRERELEASE_PAYLOAD, + "SHA256SUMS", + ], + { env: { ...process.env, COPYFILE_DISABLE: "1" } } + ); + await Bun.write( + join(output, "prerelease.json"), + `${JSON.stringify(metadata, null, 2)}\n` + ); + await Bun.write( + join(output, "SHA256SUMS"), + await renderChecksums({ root: output, names: [plan.archive] }) + ); +} + +export async function renderChecksums({ + root, + names, +}: { + readonly root: string; + readonly names: readonly string[]; +}) { + const lines: string[] = []; + for (const name of names) { + const bytes = await Bun.file(join(root, name)).arrayBuffer(); + lines.push( + `${new Bun.CryptoHasher("sha256").update(bytes).digest("hex")} ${name}` + ); + } + return `${lines.join("\n")}\n`; +} + +export async function verifyReleaseAssets({ + plan, + output, +}: { + readonly plan: PrereleasePlan; + readonly output: string; +}) { + if ( + JSON.stringify((await readdir(output)).sort()) !== + JSON.stringify([...plan.assets].sort()) + ) { + throw new Error( + "Expected exactly the archive, metadata and outer checksum release assets" + ); + } + for (const name of plan.assets) { + const entry = await lstat(join(output, name)); + if (!entry.isFile() || entry.nlink !== 1) { + throw new Error("Release assets must be regular files"); + } + } + const expected = prereleaseMetadata({ + version: plan.version, + sourceRevision: plan.source_revision, + }); + const metadata: unknown = await Bun.file( + join(output, "prerelease.json") + ).json(); + if (JSON.stringify(metadata) !== JSON.stringify(expected)) { + throw new Error("Release asset metadata does not match the approved plan"); + } + if ( + (await Bun.file(join(output, "SHA256SUMS")).text()) !== + (await renderChecksums({ root: output, names: [plan.archive] })) + ) { + throw new Error("Release archive does not match its outer checksum"); + } +} + +function object(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Invalid GitHub or release object"); + } + return value as Record; +} + +function array(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw new Error("Invalid GitHub array"); + } + return value; +} + +async function run( + cmd: string[], + options: { readonly env?: NodeJS.ProcessEnv } = {} +) { + const child = Bun.spawn(cmd, { + ...options, + stdout: "inherit", + stderr: "inherit", + }); + if ((await child.exited) !== 0) { + throw new Error(`Command failed: ${cmd[0]}`); + } +} + +function env(name: string): string { + return process.env[name] ?? ""; +} + +function githubApi(): Api { + if ( + env("GITHUB_ACTIONS") !== "true" || + env("GITHUB_REPOSITORY") !== "hack-dance/hack" || + !env("GH_TOKEN") + ) { + throw new Error( + "Publish verification requires this repository's GitHub workflow token" + ); + } + return async (path, allowMissing = false) => { + const response = await fetch( + `https://api.github.com/repos/hack-dance/hack/${path}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${env("GH_TOKEN")}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(30_000), + } + ); + if (allowMissing && response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error( + `GitHub verification failed (${response.status}): ${path}` + ); + } + return response.json(); + }; +} + +async function main() { + const mode = Bun.argv[2] ?? "plan"; + if (mode === "stable") { + const pkg = object(await Bun.file("package.json").json()); + const tag = env("RELEASE_TAG_INPUT") || env("GITHUB_REF_NAME"); + const version = stableReleaseVersion({ + channel: env("RELEASE_CHANNEL") || "stable", + tag, + packageVersion: String(pkg.version), + }); + await appendFile(env("GITHUB_OUTPUT"), `tag=${tag}\nversion=${version}\n`); + return; + } + const version = env("HACK_PRERELEASE_VERSION"); + const sourceRevision = env("HACK_PRERELEASE_SOURCE_REVISION"); + if (mode === "metadata") { + process.stdout.write( + `${JSON.stringify(prereleaseMetadata({ version, sourceRevision }), null, 2)}\n` + ); + return; + } + const plan = createPrereleasePlan({ + version, + sourceRevision, + ref: env("GITHUB_REF"), + eventName: env("GITHUB_EVENT_NAME"), + channel: env("RELEASE_CHANNEL"), + publish: env("HACK_PRERELEASE_PUBLISH") || "false", + }); + if (env("GITHUB_SHA") && env("GITHUB_SHA") !== sourceRevision) { + throw new Error( + "Dispatch SHA must equal the requested full source revision" + ); + } + if (mode === "package") { + await packagePrerelease({ + plan, + bundle: resolve(env("HACK_PRERELEASE_BUNDLE")), + output: resolve(env("HACK_PRERELEASE_OUTPUT")), + }); + } else if (mode === "verify" || mode === "publish") { + if (!plan.publish || env("GITHUB_RUN_ATTEMPT") !== "1") { + throw new Error( + "Publishing requires explicit publish=true and a fresh workflow run" + ); + } + await verifyPublishGate({ + plan, + api: githubApi(), + runId: env("GITHUB_RUN_ID"), + approved: mode === "publish", + }); + if (mode === "publish") { + const output = resolve(env("HACK_PRERELEASE_OUTPUT")); + await verifyReleaseAssets({ plan, output }); + // Exclusive reference creation refuses a tag created since gate verification. + // Failure retains this tag for inspection; reruns must never replace it. + await run([ + "gh", + "api", + "--method", + "POST", + "repos/hack-dance/hack/git/refs", + "--raw-field", + `ref=refs/tags/${plan.tag}`, + "--raw-field", + `sha=${plan.source_revision}`, + ]); + await run([ + "gh", + "release", + "create", + plan.tag, + ...plan.assets.map((name) => join(output, name)), + "--repo", + "hack-dance/hack", + "--target", + plan.source_revision, + "--verify-tag", + "--prerelease", + "--latest=false", + "--title", + `Hack ${plan.version}`, + "--notes", + `Native macOS ARM64 candidate from ${plan.source_revision}. Requires explicit opt-in; stable installation is unchanged.`, + ]); + } + } else if (mode !== "plan") { + throw new Error( + "Expected plan, metadata, package, verify, publish, or stable" + ); + } + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + if (mode === "plan" && env("GITHUB_OUTPUT")) { + await appendFile( + env("GITHUB_OUTPUT"), + `version=${plan.version}\ntag=${plan.tag}\nsource_revision=${plan.source_revision}\narchive=${plan.archive}\n` + ); + } +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : "Prerelease failed"}\n` + ); + process.exitCode = 1; + } +} diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 136dc4408..3c2aedc3f 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -47,9 +47,15 @@ type PackageJsonType = { } & Record; const packageJson = pkg as unknown as PackageJsonType; +/** Set only by the native prerelease compiler; ordinary builds use package.json. */ +declare const __HACK_BUILD_VERSION__: string | undefined; + export const CLI_SPEC = defineCli({ name: "hack", - version: packageJson.version, + version: + typeof __HACK_BUILD_VERSION__ === "string" + ? __HACK_BUILD_VERSION__ + : packageJson.version, summary: "Local development without the port-collision tax", highlights: [ "Run multiple repos or branches at the same time without port conflicts.", diff --git a/tests/prerelease-install.test.ts b/tests/prerelease-install.test.ts new file mode 100644 index 000000000..2e8b5da45 --- /dev/null +++ b/tests/prerelease-install.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +test("prerelease manager preserves stable Hack and refuses unsafe selection changes", () => { + const result = Bun.spawnSync( + [ + "python3", + "-I", + "-B", + fileURLToPath( + new URL("./python/test_prerelease_install.py", import.meta.url) + ), + ], + { stdout: "pipe", stderr: "pipe", timeout: 60_000 } + ); + if (result.exitCode !== 0) { + throw new Error(new TextDecoder().decode(result.stderr)); + } + expect(result.exitCode).toBe(0); +}); diff --git a/tests/prerelease-plan.test.ts b/tests/prerelease-plan.test.ts new file mode 100644 index 000000000..3b999d4e7 --- /dev/null +++ b/tests/prerelease-plan.test.ts @@ -0,0 +1,669 @@ +import { describe, expect, test } from "bun:test"; +import { chmod, link, mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createPrereleasePlan, + PRERELEASE_PAYLOAD, + packagePrerelease, + prereleaseMetadata, + renderChecksums, + requireExactHeadCi, + requireHumanApproval, + requireReviewerProtection, + stableReleaseVersion, + verifyPublishGate, + verifyReleaseAssets, +} from "../scripts/prerelease-plan.ts"; + +const sha = "a".repeat(40); +const input = { + version: "5.0.0-next.1", + sourceRevision: sha, + ref: "refs/heads/next", + eventName: "workflow_dispatch", + channel: "prerelease", +}; +const plan = createPrereleasePlan(input); +const environment = { + name: "v5-prerelease", + id: 42, + can_admins_bypass: false, + protection_rules: [ + { + type: "required_reviewers", + reviewers: [{ type: "User", reviewer: { id: 1 } }], + }, + ], +}; +const approval = [ + { + state: "approved", + environments: [{ id: 42 }], + user: { type: "User", id: 1 }, + }, +]; +const ciRun = { + id: 77, + head_sha: sha, + head_branch: "next", + event: "push", + status: "completed", + conclusion: "success", + path: ".github/workflows/ci.yml", +}; +const jobNames = [ + "Runtime state models", + "Candidate core (ubuntu-latest)", + "Candidate core (macos-latest)", + "secret-scan", + "runtime-images", + "docker-e2e", + "test", + "linux-process-lifetime", +]; +const jobs = jobNames.map((name) => ({ + name, + head_sha: sha, + status: "completed", + conclusion: "success", +})); + +function gateApi(overrides: Record = {}) { + const data: Record = { + "branches/next": { protected: true, commit: { sha } }, + "environments/v5-prerelease": environment, + [`actions/workflows/ci.yml/runs?branch=next&event=push&head_sha=${sha}&per_page=1`]: + { workflow_runs: [ciRun] }, + "actions/runs/77/jobs?per_page=100&page=1": { jobs }, + "rules/branches/next": [ + { + type: "required_status_checks", + parameters: { + required_status_checks: [{ context: "test", integration_id: 15_368 }], + }, + }, + ], + "branches/next/protection/required_status_checks": null, + [`commits/${sha}/check-runs?filter=latest&per_page=100&page=1`]: { + check_runs: [ + { + name: "test", + head_sha: sha, + app: { id: 15_368 }, + status: "completed", + conclusion: "success", + }, + ], + }, + [`commits/${sha}/statuses?per_page=100&page=1`]: [], + [`git/ref/tags/${plan.tag}`]: null, + [`releases/tags/${plan.tag}`]: null, + "actions/runs/99/approvals": approval, + ...overrides, + }; + return (path: string): Promise => { + if (!(path in data)) { + throw new Error(`Unexpected API request ${path}`); + } + return Promise.resolve(data[path]); + }; +} + +describe("prerelease decision boundary", () => { + test("plans exact immutable assets without publishing by default", () => { + expect(plan.publish).toBe(false); + expect(plan.tag).toBe("v5.0.0-next.1"); + expect(plan.archive).toBe("hack-5.0.0-next.1-darwin-arm64-native.tar.gz"); + expect(plan.assets).toEqual([ + plan.archive, + "prerelease.json", + "SHA256SUMS", + ]); + expect( + createPrereleasePlan({ + ...input, + version: "5.0.0-next.22", + publish: "true", + }).publish + ).toBe(true); + }); + + test("refuses invalid versions, revisions, event, channel and publish input", () => { + for (const version of [ + "5.0.0", + "v5.0.0-next.1", + "5.0.0-next.0", + "5.0.0-next.01", + "5.0.0-next.1+foo", + "5.0.0-next.1\n", + "5.0.0-next.1;touch /tmp/injected", + "5.0.0-rc.1", + "4.2.1-next.1", + ]) { + expect(() => createPrereleasePlan({ ...input, version })).toThrow(); + } + for (const sourceRevision of [ + "abc", + "A".repeat(40), + `${sha}\n`, + "refs/heads/next", + ]) { + expect(() => + createPrereleasePlan({ ...input, sourceRevision }) + ).toThrow(); + } + for (const ref of ["refs/heads/main", "refs/tags/v5.0.0-next.1", "next"]) { + expect(() => createPrereleasePlan({ ...input, ref })).toThrow(); + } + expect(() => + createPrereleasePlan({ ...input, channel: "stable" }) + ).toThrow(); + expect(() => + createPrereleasePlan({ ...input, eventName: "push" }) + ).toThrow(); + expect(() => createPrereleasePlan({ ...input, publish: "yes" })).toThrow(); + }); + + test("stable path refuses every prerelease and metadata tag even on manual dispatch", () => { + expect( + stableReleaseVersion({ + channel: "stable", + tag: "v4.2.1", + packageVersion: "4.2.1", + }) + ).toBe("4.2.1"); + for (const version of [ + "5.0.0-next.1", + "4.2.1-beta.2", + "4.2.1+candidate", + "04.2.1", + "4.02.1", + "4.2.01", + ]) { + expect(() => + stableReleaseVersion({ + channel: "stable", + tag: `v${version}`, + packageVersion: version, + }) + ).toThrow(); + } + expect(() => + stableReleaseVersion({ + channel: "prerelease", + tag: "v4.2.1", + packageVersion: "4.2.1", + }) + ).toThrow(); + expect(() => + stableReleaseVersion({ + channel: "stable", + tag: "v4.2.2", + packageVersion: "4.2.1", + }) + ).toThrow(); + }); + + test("requires concrete protection and human approval", () => { + expect(requireReviewerProtection(environment)).toBe(42); + for (const invalid of [ + { ...environment, protection_rules: [] }, + { ...environment, can_admins_bypass: true }, + { + ...environment, + protection_rules: [{ type: "required_reviewers", reviewers: [] }], + }, + { ...environment, name: "production" }, + { + ...environment, + protection_rules: [{ type: "required_reviewers", reviewers: [{}] }], + }, + { + ...environment, + protection_rules: [ + { + type: "required_reviewers", + reviewers: [{ type: "Bot", reviewer: { id: 1 } }], + }, + ], + }, + { + ...environment, + protection_rules: [ + { + type: "required_reviewers", + reviewers: [{ type: "User", reviewer: { id: "1" } }], + }, + ], + }, + ]) { + expect(() => requireReviewerProtection(invalid)).toThrow(); + } + requireHumanApproval({ reviews: approval, environmentId: 42 }); + for (const reviews of [ + [], + [{ ...approval[0], user: { type: "Bot" } }], + [{ ...approval[0], user: { type: "User", id: "1" } }], + [{ ...approval[0], environments: [{ id: 43 }] }], + [{ ...approval[0], state: "rejected" }], + ]) { + expect(() => + requireHumanApproval({ reviews, environmentId: 42 }) + ).toThrow(); + } + }); + + test("requires actual exact-head CI without skipped jobs", () => { + requireExactHeadCi({ run: ciRun, jobs, sourceRevision: sha }); + for (const run of [ + { ...ciRun, event: "pull_request" }, + { ...ciRun, head_sha: "b".repeat(40) }, + { ...ciRun, head_branch: "main" }, + { ...ciRun, conclusion: "failure" }, + ]) { + expect(() => + requireExactHeadCi({ run, jobs, sourceRevision: sha }) + ).toThrow(); + } + expect(() => + requireExactHeadCi({ + run: ciRun, + jobs: jobs.slice(1), + sourceRevision: sha, + }) + ).toThrow(); + expect(() => + requireExactHeadCi({ + run: ciRun, + jobs: [ + ...jobs, + { name: "additional", status: "completed", conclusion: "skipped" }, + ], + sourceRevision: sha, + }) + ).toThrow(); + }); + + test("combined publish gate refuses moved head, missing protection/CI, wrong app, existing assets and no approval", async () => { + await verifyPublishGate({ + plan, + api: gateApi(), + runId: "99", + approved: true, + }); + for (const overrides of [ + { "branches/next": { protected: true, commit: { sha: "b".repeat(40) } } }, + { + "environments/v5-prerelease": { ...environment, protection_rules: [] }, + }, + { "actions/runs/77/jobs?per_page=100&page=1": { jobs: jobs.slice(1) } }, + { + [`commits/${sha}/check-runs?filter=latest&per_page=100&page=1`]: { + check_runs: [ + { + name: "test", + head_sha: sha, + app: { id: 999 }, + status: "completed", + conclusion: "success", + }, + ], + }, + }, + { [`git/ref/tags/${plan.tag}`]: { ref: `refs/tags/${plan.tag}` } }, + { [`releases/tags/${plan.tag}`]: { id: 1 } }, + { "actions/runs/99/approvals": [] }, + ]) { + await expect( + verifyPublishGate({ + plan, + api: gateApi(overrides), + runId: "99", + approved: true, + }) + ).rejects.toThrow(); + } + await expect( + verifyPublishGate({ + plan, + api: () => Promise.reject(new Error("API unavailable")), + runId: "99", + approved: true, + }) + ).rejects.toThrow("API unavailable"); + }); +}); + +async function fixture(root: string) { + const bundle = join(root, "bundle"); + await mkdir(bundle); + for (const name of PRERELEASE_PAYLOAD) { + const value = + name === "prerelease.json" + ? JSON.stringify(prereleaseMetadata(input)) + : `fixture ${name}\n`; + await Bun.write(join(bundle, name), value); + } + await Bun.write( + join(bundle, "SHA256SUMS"), + await renderChecksums({ root: bundle, names: PRERELEASE_PAYLOAD }) + ); + return bundle; +} + +/** Python exposes AppleDouble entries that macOS tar's own listing hides. */ +async function archiveMembers(archive: string) { + const listing = Bun.spawn( + [ + "python3", + "-c", + "import json, sys, tarfile\nwith tarfile.open(sys.argv[1], 'r:gz') as archive:\n print(json.dumps([{'name': member.name, 'file': member.isfile()} for member in archive.getmembers()]))", + archive, + ], + { stdout: "pipe", stderr: "pipe" } + ); + const [exitCode, stdout, stderr] = await Promise.all([ + listing.exited, + new Response(listing.stdout).text(), + new Response(listing.stderr).text(), + ]); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + return JSON.parse(stdout) as Array<{ name: string; file: boolean }>; +} + +test("packages complete metadata-bound archive and refuses replacement, tampering and symlinks", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-prerelease-")); + try { + const bundle = await fixture(root); + const output = join(root, "assets"); + await packagePrerelease({ plan, bundle, output }); + await verifyReleaseAssets({ plan, output }); + const members = await archiveMembers(join(output, plan.archive)); + expect(members.map((member) => member.name).sort()).toEqual( + [...PRERELEASE_PAYLOAD, "SHA256SUMS"].sort() + ); + expect(members.every((member) => member.file)).toBe(true); + await expect(packagePrerelease({ plan, bundle, output })).rejects.toThrow(); + await Bun.write(join(output, plan.archive), "tampered"); + await expect(verifyReleaseAssets({ plan, output })).rejects.toThrow( + "outer checksum" + ); + await Bun.write( + join(bundle, "prerelease.json"), + JSON.stringify({ + ...prereleaseMetadata(input), + source_revision: "b".repeat(40), + }) + ); + await expect( + packagePrerelease({ plan, bundle, output: join(root, "bad-metadata") }) + ).rejects.toThrow("metadata"); + await rm(bundle, { recursive: true }); + await fixture(root); + await Bun.write(join(bundle, "hack-native"), "tampered"); + await expect( + packagePrerelease({ plan, bundle, output: join(root, "bad-payload") }) + ).rejects.toThrow("checksums"); + await rm(join(bundle, "hack-native")); + await symlink("hack-cli", join(bundle, "hack-native")); + await expect( + packagePrerelease({ plan, bundle, output: join(root, "alias") }) + ).rejects.toThrow("regular file"); + await rm(join(bundle, "hack-native")); + await link(join(bundle, "hack-cli"), join(bundle, "hack-native")); + await expect( + packagePrerelease({ plan, bundle, output: join(root, "hardlink") }) + ).rejects.toThrow("regular file"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("packages exactly eight native payload members despite macOS source extended attributes", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-prerelease-xattr-")); + try { + const bundle = await fixture(root); + const names = [...PRERELEASE_PAYLOAD, "SHA256SUMS"]; + if (process.platform === "darwin") { + const attribute = Bun.spawn( + [ + "/usr/bin/xattr", + "-w", + "com.hack.prerelease-fixture", + "inventory-regression", + ...names.map((name) => join(bundle, name)), + ], + { stdout: "pipe", stderr: "pipe" } + ); + expect(await new Response(attribute.stderr).text()).toBe(""); + expect(await attribute.exited).toBe(0); + const controlPath = join(root, "with-metadata.tar.gz"); + const { COPYFILE_DISABLE: _copyfileDisabled, ...controlEnv } = + process.env; + const control = Bun.spawn( + ["tar", "-czf", controlPath, "-C", bundle, ...names], + { env: controlEnv, stdout: "pipe", stderr: "pipe" } + ); + expect(await new Response(control.stderr).text()).toBe(""); + expect(await control.exited).toBe(0); + const controlMembers = await archiveMembers(controlPath); + expect(controlMembers.map((member) => member.name)).toContain( + "._hack-native" + ); + expect(controlMembers).toHaveLength(16); + } + const output = join(root, "assets"); + await packagePrerelease({ plan, bundle, output }); + await verifyReleaseAssets({ plan, output }); + const members = await archiveMembers(join(output, plan.archive)); + expect(members.map((member) => member.name).sort()).toEqual(names.sort()); + expect(members).toHaveLength(8); + expect(members.every((member) => member.file)).toBe(true); + if (process.platform === "darwin") { + const preserved = Bun.spawn( + [ + "/usr/bin/xattr", + "-p", + "com.hack.prerelease-fixture", + join(bundle, "hack-native"), + ], + { stdout: "pipe", stderr: "pipe" } + ); + expect(await new Response(preserved.stdout).text()).toBe( + "inventory-regression\n" + ); + expect(await preserved.exited).toBe(0); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("local dry-run has no publishing effects and refuses SHA mismatch and injected input", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-prerelease-plan-")); + try { + const gh = join(root, "gh"); + await Bun.write(gh, "#!/bin/sh\nexit 98\n"); + await chmod(gh, 0o755); + const env = { + ...process.env, + PATH: `${root}:${process.env.PATH}`, + GITHUB_OUTPUT: "", + GITHUB_REF: input.ref, + GITHUB_SHA: sha, + GITHUB_EVENT_NAME: input.eventName, + RELEASE_CHANNEL: input.channel, + HACK_PRERELEASE_VERSION: input.version, + HACK_PRERELEASE_SOURCE_REVISION: sha, + HACK_PRERELEASE_PUBLISH: "false", + }; + for (const override of [ + {}, + { GITHUB_SHA: "b".repeat(40) }, + { HACK_PRERELEASE_VERSION: "5.0.0-next.1\narchive=wrong" }, + ]) { + const child = Bun.spawn( + [process.execPath, "scripts/prerelease-plan.ts", "plan"], + { env: { ...env, ...override }, stdout: "pipe", stderr: "pipe" } + ); + const text = await new Response(child.stdout).text(); + expect(await child.exited).toBe( + Object.keys(override).length === 0 ? 0 : 1 + ); + if (Object.keys(override).length === 0) { + expect(JSON.parse(text).publish).toBe(false); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("registered Release routes explicit prerelease to the same-commit reusable workflow and keeps stable/tap isolated", async () => { + const release = Bun.YAML.parse( + await Bun.file(".github/workflows/release.yml").text() + ) as { + on: { + push: { tags: string[] }; + workflow_dispatch: { inputs: Record }; + }; + jobs: Record< + string, + { + if?: string; + uses?: string; + secrets?: string; + steps?: Array<{ run?: string }>; + } + >; + }; + expect(release.on.workflow_dispatch.inputs.channel?.default).toBe("stable"); + expect(release.on.workflow_dispatch.inputs.publish_prerelease?.default).toBe( + false + ); + expect(release.on.push.tags).toEqual(["v*", "!v*-*", "!v*+*"]); + expect(release.jobs.prerelease?.uses).toBe( + "./.github/workflows/prerelease.yml" + ); + expect(release.jobs.prerelease?.if).toContain( + "inputs.channel == 'prerelease'" + ); + expect(release.jobs.prerelease?.secrets).toBeUndefined(); + expect(release.jobs["create-release"]?.if).toContain( + "inputs.channel == 'stable'" + ); + expect(release.jobs["update-homebrew-tap"]?.if).toContain( + "inputs.channel == 'stable'" + ); + const prerelease = Bun.YAML.parse( + await Bun.file(".github/workflows/prerelease.yml").text() + ) as { + on: { workflow_call: { inputs: Record } }; + jobs: Record< + string, + { + environment?: string; + if?: string; + permissions: { contents: string }; + steps: Array<{ run?: string; env?: Record }>; + } + >; + }; + expect(prerelease.on.workflow_call.inputs.publish?.default).toBe(false); + expect(prerelease.jobs.plan?.permissions.contents).toBe("read"); + expect(prerelease.jobs.build?.permissions.contents).toBe("read"); + expect(prerelease.jobs.publish?.environment).toBe("v5-prerelease"); + expect(prerelease.jobs.publish?.if).toContain("inputs.publish"); + for (const workflow of [release, prerelease]) { + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps ?? []) { + expect(step.run ?? "").not.toContain("${{"); + } + } + } + expect(JSON.stringify(prerelease)).not.toContain("RELEASE_PAT"); +}); + +test("compiled CLI reports the embedded candidate version and ordinary source retains package version", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-prerelease-cli-")); + try { + const pkg = await Bun.file("package.json").text(); + const executable = join(root, "hack-cli"); + const build = Bun.spawn( + [ + process.execPath, + "build", + "index.ts", + "--compile", + "--define", + '__HACK_BUILD_VERSION__="5.0.0-next.22"', + "--outfile", + executable, + ], + { stdout: "pipe", stderr: "pipe" } + ); + const errors = await new Response(build.stderr).text(); + expect(await build.exited).toBe(0); + expect(errors).not.toContain("error:"); + for (const args of [ + [executable, "--version"], + [executable, "version"], + [process.execPath, "index.ts", "--version"], + ]) { + const child = Bun.spawn(args, { + env: { + ...process.env, + HACK_BUILD_VERSION: "wrong", + __HACK_BUILD_VERSION__: "wrong", + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(await new Response(child.stdout).text()).toBe( + args[0] === executable + ? "hack v5.0.0-next.22\n" + : `hack v${JSON.parse(pkg).version}\n` + ); + expect(await child.exited).toBe(0); + } + expect(await Bun.file("package.json").text()).toBe(pkg); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("stable CLI writes only validated version and tag outputs on manual input", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-stable-plan-")); + try { + const pkg = await Bun.file("package.json").json(); + const output = join(root, "outputs"); + const env = { + ...process.env, + GITHUB_OUTPUT: output, + RELEASE_CHANNEL: "stable", + RELEASE_TAG_INPUT: `v${pkg.version}`, + GITHUB_REF_NAME: "main", + }; + const success = Bun.spawn( + [process.execPath, "scripts/prerelease-plan.ts", "stable"], + { env, stdout: "pipe", stderr: "pipe" } + ); + expect(await success.exited).toBe(0); + const expected = `tag=v${pkg.version}\nversion=${pkg.version}\n`; + expect(await Bun.file(output).text()).toBe(expected); + const refused = Bun.spawn( + [process.execPath, "scripts/prerelease-plan.ts", "stable"], + { + env: { ...env, RELEASE_TAG_INPUT: "v5.0.0-next.1" }, + stdout: "pipe", + stderr: "pipe", + } + ); + expect(await refused.exited).toBe(1); + expect(await Bun.file(output).text()).toBe(expected); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py new file mode 100644 index 000000000..4cec89052 --- /dev/null +++ b/tests/python/test_prerelease_install.py @@ -0,0 +1,527 @@ +"""Isolated channel controls. Subprocess stand-ins never execute candidate software.""" + +import contextlib +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + + +SOURCE = Path(__file__).resolve().parents[2] / "scripts/install-prerelease.py" +SPEC = importlib.util.spec_from_file_location("install_prerelease", SOURCE) +installer = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(installer) + + +def sha(contents): + return hashlib.sha256(contents).hexdigest() + + +class ChannelTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="hack-prerelease-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.old_umask = os.umask(0o077) + self.addCleanup(os.umask, self.old_umask) + self.stable_home = self.root / "stable-home" + (self.stable_home / ".hack").mkdir(parents=True, mode=0o700) + (self.stable_home / ".hack/data").write_bytes(b"stable app data\x00unchanged") + self.brew = self.root / "homebrew" + self.brew.mkdir(mode=0o700) + (self.brew / "hack-4.2.1").write_bytes(b"stable executable unchanged") + (self.brew / "hack").symlink_to("hack-4.2.1") + self.stable_before = self.stable_snapshot() + self.addCleanup(self.assert_stable_unchanged) + self.calls = [] + self.runtime_responses = [] + self.run_patch = mock.patch.object(installer.subprocess, "run", side_effect=self.process) + self.run_patch.start() + self.addCleanup(self.run_patch.stop) + self.env_patch = mock.patch.dict(os.environ, { + "HOME": str(self.stable_home), "HACK_HOME": str(self.stable_home / ".hack"), + "HACK_GLOBAL_CONFIG_PATH": str(self.stable_home / ".hack/config.json"), + "HACK_NATIVE_HOME": str(self.stable_home / "foreign-native"), + "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "docker", + "HACK_NATIVE_ADAPTATION": "/private/project/adaptation.json", + "HACK_NATIVE_DEPENDENCIES": "/private/project/dependencies.json", + "HACK_NATIVE_AWS_PROFILE": "fixture-qa", + "HACK_NATIVE_HTTPS_PORT": "443", "HACK_NATIVE_SHARED_SOURCE": "1"}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + self.channel = installer.Channel(self.root / "channel") + self.channel.initialize() + + def stable_snapshot(self): + return {(str(path.relative_to(self.root)), "symlink" if path.is_symlink() else "file"): + os.readlink(path) if path.is_symlink() else sha(path.read_bytes()) + for directory in (self.stable_home, self.brew) + for path in directory.rglob("*") if path.is_file() or path.is_symlink()} + + def assert_stable_unchanged(self): + self.assertEqual(self.stable_snapshot(), self.stable_before) + + def process(self, arguments, **options): + self.calls.append((arguments, options)) + if arguments[0] == "/usr/bin/codesign": + return subprocess.CompletedProcess(arguments, 0) + self.assertEqual(arguments[1], "--candidate-root") + home = Path(arguments[2]) + self.assertTrue(home.is_relative_to(self.channel.root / "versions")) + self.assertEqual(arguments[3], "runtime") + self.assertIn(arguments[4], ("status", "down")) + self.assertEqual(arguments[5], "--json") + self.assertEqual(options["env"]["HACK_NATIVE_HOME"], str(home)) + self.assertEqual(options["env"]["HACK_HOME"], str(home.parent / "cli-home")) + self.assertNotIn("HACK_GLOBAL_CONFIG_PATH", options["env"]) + response = self.runtime_responses.pop(0) if self.runtime_responses else { + "phase": "stopped" if list(home.iterdir()) else "uninitialized", "process_alive": False} + if isinstance(response, Exception): + raise response + if isinstance(response, tuple): + return subprocess.CompletedProcess(arguments, response[0], response[1]) + return subprocess.CompletedProcess(arguments, 0, json.dumps(response).encode()) + + def archive(self, version="5.0.0-next.1", mutate=None): + identity = {"schema": "hack.prerelease/v1", "version": version, "tag": "v" + version, + "source_revision": "a" * 40, "platform": "darwin-arm64"} + files = {"hack-native": b"reviewed native fixture", "hack-cli": b"reviewed CLI fixture", + "hack-relay-guest": b"reviewed guest fixture", "hack-v5": b"reviewed launcher fixture", + "provider-pins.json": b"{}\n", "README.md": b"candidate documentation\n", + "prerelease.json": json.dumps(identity).encode()} + files["SHA256SUMS"] = "".join(sha(value) + " " + name + "\n" + for name, value in sorted(files.items())).encode() + entries = [(name, value, tarfile.REGTYPE) for name, value in files.items()] + if mutate: + entries = mutate(entries) + archive = self.root / ("archive-" + str(len(list(self.root.glob("archive-*")))) + ".tar.gz") + with tarfile.open(archive, "w:gz") as target: + for name, value, kind in entries: + entry = tarfile.TarInfo(name) + entry.type = kind + entry.mode = 0o777 + entry.size = len(value) if kind == tarfile.REGTYPE else 0 + if kind in (tarfile.SYMTYPE, tarfile.LNKTYPE): + entry.linkname = str(self.brew / "hack") + target.addfile(entry, io.BytesIO(value) if kind == tarfile.REGTYPE else None) + return archive, installer.digest(archive) + + def install(self, version="5.0.0-next.1", upgrade=False): + archive, checksum = self.archive(version) + with self.channel.lock(): + self.channel.install(version, archive, checksum, upgrade) + return archive, checksum + + def selection(self): + return installer.private_json(self.channel.root / ".selection.json") + + def rejects_install(self, mutate, message): + if not self.selection()["installed"]: + self.install() + before = (self.channel.root / ".selection.json").read_bytes() + archive, checksum = self.archive("5.0.0-next.2", mutate) + with self.channel.lock(): + with self.assertRaisesRegex(installer.Refusal, message): + self.channel.install("5.0.0-next.2", archive, checksum, True) + self.assertEqual((self.channel.root / ".selection.json").read_bytes(), before) + with self.channel.lock(): + self.assertEqual(self.channel.state["selected"], "5.0.0-next.1") + + def test_install_upgrade_rollback_and_stable_preserve_prior_homes(self): + self.install() + first_home = self.channel.root / "versions/5.0.0-next.1/native-home" + (first_home / "saved-application-data").write_bytes(b"first-version-marker") + self.install("5.0.0-next.2", True) + second_home = self.channel.root / "versions/5.0.0-next.2/native-home" + self.assertEqual(list(second_home.iterdir()), []) + (second_home / "saved-application-data").write_bytes(b"second-version-marker") + self.assertEqual(self.selection()["selected"], "5.0.0-next.2") + self.assertEqual(self.selection()["previous"], "5.0.0-next.1") + with self.channel.lock(): + self.channel.select("5.0.0-next.1") + self.assertEqual(first_home.joinpath("saved-application-data").read_bytes(), b"first-version-marker") + self.assertEqual(second_home.joinpath("saved-application-data").read_bytes(), b"second-version-marker") + with self.channel.lock(): + self.channel.select(None) + self.assertIsNone(self.selection()["selected"]) + self.assertEqual(self.selection()["previous"], "5.0.0-next.1") + self.assertEqual(len(self.selection()["installed"]), 2) + actions = [arguments[4] for arguments, _ in self.calls if arguments[0] != "/usr/bin/codesign"] + self.assertEqual(actions, ["status", "down", "status"] * 5) + + def test_idempotent_install_revalidates_without_mutating_selection(self): + archive, checksum = self.install() + before = (self.channel.root / ".selection.json").read_bytes() + with self.channel.lock(): + self.channel.install("5.0.0-next.1", archive, checksum) + self.assertEqual(before, (self.channel.root / ".selection.json").read_bytes()) + (self.channel.root / "versions/5.0.0-next.1/bundle/hack-cli").write_bytes(b"changed") + with self.assertRaisesRegex(installer.Refusal, "checksum mismatch"): + with self.channel.lock(): + self.fail("Changed installation was accepted") + + def test_active_unknown_failed_and_timed_out_status_never_trigger_down(self): + self.install() + cases = [{"phase": "running", "process_alive": True}, + {"phase": "process-exited", "process_alive": False}, + {"phase": "stopped", "process_alive": None}, + {"phase": "stopped", "process_alive": 0}, + {"phase": "initializing", "process_alive": False}, + (1, b""), (0, b"not json"), + subprocess.TimeoutExpired(["status"], 30)] + for response in cases: + with self.subTest(response=response): + before = (self.channel.root / ".selection.json").read_bytes() + self.calls = [] + self.runtime_responses = [response] + with self.channel.lock(): + with self.assertRaises(installer.Refusal): + self.channel.select(None) + actions = [args[4] for args, _ in self.calls if args[0] != "/usr/bin/codesign"] + self.assertEqual(actions, ["status"]) + self.assertEqual((self.channel.root / ".selection.json").read_bytes(), before) + + def test_down_failure_and_post_down_running_refuse_switch(self): + self.install() + stopped = {"phase": "stopped", "process_alive": False} + for responses in ([stopped, (1, b"failure")], + [stopped, stopped, {"phase": "running", "process_alive": True}]): + with self.subTest(responses=responses): + self.runtime_responses = list(responses) + with self.channel.lock(): + with self.assertRaises(installer.Refusal): + self.channel.select(None) + self.assertEqual(self.selection()["selected"], "5.0.0-next.1") + + def test_uninitialized_home_with_leftover_state_is_ambiguous(self): + self.install() + native_home = self.channel.root / "versions/5.0.0-next.1/native-home" + (native_home / ".hack-local").mkdir(mode=0o700) + (native_home / ".hack-local/leftover-owner.json").write_text("{}") + self.runtime_responses = [{"phase": "uninitialized", "process_alive": False}] + with self.channel.lock(): + with self.assertRaisesRegex(installer.Refusal, "contains state"): + self.channel.select(None) + self.assertEqual(self.selection()["selected"], "5.0.0-next.1") + + def test_receipt_changed_during_runtime_probe_is_rejected(self): + self.install() + original = self.process + def changed(arguments, **options): + result = original(arguments, **options) + if arguments[0] != "/usr/bin/codesign": + path = self.channel.root / "versions/5.0.0-next.1/.receipt.json" + path.write_bytes(path.read_bytes() + b" ") + return result + with mock.patch.object(installer.subprocess, "run", side_effect=changed): + with self.channel.lock(): + with self.assertRaisesRegex(installer.Refusal, "receipt changed"): + self.channel.select(None) + self.assertEqual(self.selection()["selected"], "5.0.0-next.1") + + def test_bad_archive_checksum_preserves_previous_selected_bundle(self): + self.install() + archive, _ = self.archive("5.0.0-next.2") + with self.channel.lock(): + with self.assertRaisesRegex(installer.Refusal, "Archive checksum"): + self.channel.install("5.0.0-next.2", archive, "0" * 64, True) + self.assertEqual(self.selection()["selected"], "5.0.0-next.1") + with self.channel.lock(): + pass + + def test_duplicate_and_traversing_archive_entries_are_rejected(self): + mutations = [lambda entries: [*entries[:-1], entries[0]], + lambda entries: [("../stable-home/.hack/data", *entries[0][1:]), *entries[1:]], + lambda entries: [("/tmp/hack", *entries[0][1:]), *entries[1:]], + lambda entries: [("bundle/hack-native", *entries[0][1:]), *entries[1:]]] + for mutate in mutations: + with self.subTest(mutate=mutate): + self.rejects_install(mutate, "archive entry|archive entries") + + def test_symlink_hardlink_and_nonregular_archive_entries_are_rejected(self): + for kind in (tarfile.SYMTYPE, tarfile.LNKTYPE, tarfile.FIFOTYPE, tarfile.DIRTYPE): + with self.subTest(kind=kind): + self.rejects_install(lambda entries: [(entries[0][0], b"", kind), *entries[1:]], + "regular archive files") + + def test_manifest_and_metadata_are_verified_before_activation(self): + def replace(entries, name, content): + return [(key, content if key == name else value, kind) for key, value, kind in entries] + mutations = [lambda entries: replace(entries, "hack-cli", b"tampered"), + lambda entries: replace(entries, "SHA256SUMS", b"not checksums"), + lambda entries: replace(entries, "SHA256SUMS", b"0" * 64 + b" foreign\n")] + for mutate in mutations: + with self.subTest(mutate=mutate): + self.rejects_install(mutate, "checksum|SHA256SUMS") + + def test_wrong_version_revision_platform_and_duplicate_json_fields_refuse(self): + for field, value in (("version", "5.0.0-next.99"), ("source_revision", "short"), + ("platform", "linux-arm64"), ("schema", "hack.prerelease/v2")): + with self.subTest(field=field): + identity = {"schema": "hack.prerelease/v1", "version": "5.0.0-next.2", + "tag": "v5.0.0-next.2", "source_revision": "a" * 40, "platform": "darwin-arm64"} + identity[field] = value + with self.assertRaises(installer.Refusal): + installer.metadata(json.dumps(identity), "5.0.0-next.2") + with self.assertRaisesRegex(installer.Refusal, "Duplicate JSON"): + installer.parse_json('{"selected":null,"selected":"foreign"}') + for version in ("5.0.0-next.0", "5.0.0-next.01", "5.0.0-next.-1", "4.2.1", "5.0.0-next.1/../x"): + with self.assertRaises(installer.Refusal): + installer.version_number(version) + + def test_checksummed_malformed_metadata_is_rejected_before_extraction(self): + def malformed(entries): + payload = {name: value for name, value, _kind in entries if name != "SHA256SUMS"} + identity = json.loads(payload["prerelease.json"]) + identity["source_revision"] = "short" + payload["prerelease.json"] = json.dumps(identity).encode() + payload["SHA256SUMS"] = "".join(sha(value) + " " + name + "\n" + for name, value in sorted(payload.items())).encode() + return [(name, value, tarfile.REGTYPE) for name, value in payload.items()] + archive, checksum = self.archive(mutate=malformed) + destination = self.root / "must-not-be-extracted" + with self.assertRaisesRegex(installer.Refusal, "identity mismatch"): + installer.extract_archive(archive, checksum, destination, "5.0.0-next.1") + self.assertFalse(destination.exists()) + + def test_failed_signature_verification_preserves_previous_version(self): + self.install() + archive, checksum = self.archive("5.0.0-next.2") + original = self.process + def fail_signature(arguments, **options): + if arguments[0] == "/usr/bin/codesign" and ".stage-" in arguments[-1]: + return subprocess.CompletedProcess(arguments, 1) + return original(arguments, **options) + with mock.patch.object(installer.subprocess, "run", side_effect=fail_signature): + with self.channel.lock(): + with self.assertRaisesRegex(installer.Refusal, "signature failed"): + self.channel.install("5.0.0-next.2", archive, checksum, True) + self.assertEqual(self.selection()["selected"], "5.0.0-next.1") + + def test_interrupted_pointer_commit_keeps_old_selection_usable(self): + self.install() + before = (self.channel.root / ".selection.json").read_bytes() + archive, checksum = self.archive("5.0.0-next.2") + with mock.patch.object(installer.os, "replace", side_effect=OSError("interrupted switch")): + with self.channel.lock(): + with self.assertRaisesRegex(OSError, "interrupted switch"): + self.channel.install("5.0.0-next.2", archive, checksum, True) + self.assertEqual((self.channel.root / ".selection.json").read_bytes(), before) + self.assertTrue((self.channel.root / "versions/5.0.0-next.2/.receipt.json").is_file()) + with self.channel.lock(): + self.assertEqual(self.channel.state["selected"], "5.0.0-next.1") + with self.channel.lock(): + with self.assertRaisesRegex(installer.Refusal, "overwrite"): + self.channel.install("5.0.0-next.2", archive, checksum, True) + + def test_partial_extraction_keeps_old_selection_usable(self): + self.install() + archive, checksum = self.archive("5.0.0-next.2") + with mock.patch.object(installer, "extract_archive", side_effect=OSError("partial extraction")): + with self.channel.lock(): + with self.assertRaisesRegex(OSError, "partial extraction"): + self.channel.install("5.0.0-next.2", archive, checksum, True) + with self.channel.lock(): + self.assertEqual(self.channel.state["selected"], "5.0.0-next.1") + + def test_changed_home_symlink_and_malformed_receipts_are_refused(self): + self.install() + home = self.channel.root / "versions/5.0.0-next.1/native-home" + retained = home.with_name("saved-native-home") + home.rename(retained) + home.symlink_to(retained) + with self.assertRaises(installer.Refusal): + with self.channel.lock(): + self.fail("Aliased home accepted") + home.unlink() + retained.rename(home) + receipt = self.channel.root / "versions/5.0.0-next.1/.receipt.json" + receipt.write_text("{}") + with self.assertRaisesRegex(installer.Refusal, "receipt changed"): + with self.channel.lock(): + self.fail("Malformed receipt accepted") + + def test_foreign_root_alias_and_hardlinked_payload_are_refused(self): + foreign = self.root / "foreign" + foreign.mkdir(mode=0o700) + (foreign / "data").write_bytes(b"do not replace") + with self.assertRaisesRegex(installer.Refusal, "nonempty"): + installer.Channel(foreign).initialize() + alias = self.root / "alias" + alias.symlink_to(self.channel.root) + with self.assertRaisesRegex(installer.Refusal, "canonical"): + installer.Channel(alias) + self.install() + binary = self.channel.root / "versions/5.0.0-next.1/bundle/hack-cli" + os.link(binary, self.root / "hardlink") + with self.assertRaisesRegex(installer.Refusal, "Hard-linked"): + with self.channel.lock(): + self.fail("Hard-linked payload accepted") + + def test_manager_and_selected_receipt_changes_refuse_status(self): + self.install() + manager = self.channel.root / "manager.py" + before = manager.read_bytes() + manager.write_bytes(before + b"# changed\n") + with self.assertRaisesRegex(installer.Refusal, "manager or launcher changed"): + with self.channel.lock(): + self.fail("Changed manager accepted") + manager.write_bytes(before) + selection = self.selection() + selection["selected"] = "5.0.0-next.999" + (self.channel.root / ".selection.json").write_bytes(installer.json_bytes(selection)) + with self.assertRaisesRegex(installer.Refusal, "Unknown selected"): + with self.channel.lock(): + self.fail("Unknown selection accepted") + + def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launchers(self): + self.install() + child = mock.Mock() + child.wait.return_value = 23 + with self.channel.lock(shared=True): + with self.channel.lock(shared=True): + with mock.patch.object(installer.subprocess, "Popen", return_value=child) as spawn: + self.assertEqual(self.channel.run(["ps", "--path", "a path"]), 23) + command = spawn.call_args.args[0] + self.assertEqual(command[1:], ["ps", "--path", "a path"]) + environment = spawn.call_args.kwargs["env"] + self.assertEqual(environment["HACK_HOME"], str(self.channel.root / "versions/5.0.0-next.1/cli-home")) + self.assertEqual(environment["HACK_NATIVE_HOME"], str(self.channel.root / "versions/5.0.0-next.1/native-home")) + self.assertEqual(environment["HACK_NATIVE_BINARY"], str(self.channel.root / "versions/5.0.0-next.1/bundle/hack-native")) + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_DEPENDENCIES", "HACK_NATIVE_AWS_PROFILE", + "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): + self.assertEqual(environment[name], os.environ[name]) + self.assertNotIn("HACK_GLOBAL_CONFIG_PATH", environment) + with self.assertRaisesRegex(installer.Refusal, "active"): + with self.channel.lock(): + self.fail("Selection switch raced active launcher") + child.wait.return_value = -15 + with self.channel.lock(shared=True), mock.patch.object(installer.subprocess, "Popen", return_value=child): + self.assertEqual(self.channel.run(["ps"]), 143) + + def test_host_and_local_archive_cli_contract(self): + for system, machine in (("Linux", "aarch64"), ("Darwin", "x86_64")): + with mock.patch.object(installer.platform, "system", return_value=system), \ + mock.patch.object(installer.platform, "machine", return_value=machine): + with self.assertRaisesRegex(installer.Refusal, "Apple Silicon"): + installer.main(["--root", str(self.root / "not-created"), "install", "--version", "5.0.0-next.1"]) + self.assertFalse((self.root / "not-created").exists()) + with mock.patch.object(installer.platform, "system", return_value="Darwin"), \ + mock.patch.object(installer.platform, "machine", return_value="arm64"): + with self.assertRaisesRegex(installer.Refusal, "supplied together"): + installer.main(["--root", str(self.root / "not-created"), "install", "--version", "5.0.0-next.1", + "--archive", str(self.root / "archive")]) + + def test_only_pinned_official_github_prerelease_assets_are_downloaded(self): + version = "5.0.0-next.2" + tag = "v" + version + archive_name = "hack-" + version + "-darwin-arm64-native.tar.gz" + archive, checksum = self.archive(version) + identity = {"schema": "hack.prerelease/v1", "version": version, "tag": tag, + "source_revision": "a" * 40, "platform": "darwin-arm64"} + base = "https://github.com/hack-dance/hack/releases/download/" + tag + "/" + release = {"tag_name": tag, "prerelease": True, "draft": False, + "assets": [{"name": name, "browser_download_url": base + name} + for name in (archive_name, "prerelease.json", "SHA256SUMS")]} + downloaded = [] + def fetch(url, path, limit): + downloaded.append(url) + if "/git/ref/tags/" in url: + content = json.dumps({"ref": "refs/tags/" + tag, + "object": {"type": "commit", "sha": identity["source_revision"]}}).encode() + elif "api.github.com" in url: + content = json.dumps(release).encode() + elif url.endswith("prerelease.json"): + content = json.dumps(identity).encode() + elif url.endswith("SHA256SUMS"): + content = (checksum + " " + archive_name + "\n").encode() + else: + content = archive.read_bytes() + installer.write_file(path, content) + with mock.patch.object(installer, "download", side_effect=fetch): + with self.channel.lock(): + self.channel.install(version) + self.assertEqual(downloaded[0], "https://api.github.com/repos/hack-dance/hack/releases/tags/" + tag) + self.assertIn("https://api.github.com/repos/hack-dance/hack/git/ref/tags/" + tag, downloaded) + self.assertEqual(self.selection()["selected"], version) + self.assertEqual(len(downloaded), 5) + for url in ("http://github.com/hack-dance/hack/x", "https://example.com/x", + "https://github.com.evil.test/x", "https://user@github.com/x", "file:///tmp/x"): + with self.assertRaises(installer.Refusal): + installer.secure_url(url) + + def test_moved_annotated_foreign_and_malformed_tags_never_activate(self): + self.install() + version = "5.0.0-next.2" + tag = "v" + version + archive_name = "hack-" + version + "-darwin-arm64-native.tar.gz" + identity = {"schema": "hack.prerelease/v1", "version": version, "tag": tag, + "source_revision": "a" * 40, "platform": "darwin-arm64"} + base = "https://github.com/hack-dance/hack/releases/download/" + tag + "/" + release = {"tag_name": tag, "prerelease": True, "draft": False, + "assets": [{"name": name, "browser_download_url": base + name} + for name in (archive_name, "prerelease.json", "SHA256SUMS")]} + valid = {"ref": "refs/tags/" + tag, "object": {"type": "commit", "sha": "a" * 40}} + references = [dict(valid, object={"type": "commit", "sha": "b" * 40}), + dict(valid, ref="refs/tags/v5.0.0-next.99"), + dict(valid, object={"type": "tag", "sha": "a" * 40}), + dict(valid, object={"type": "commit", "sha": "short"}), + dict(valid, object={"sha": "a" * 40}), dict(valid, object=None), + {"ref": "refs/tags/" + tag}, {}] + before = (self.channel.root / ".selection.json").read_bytes() + for reference in references: + with self.subTest(reference=reference): + downloaded = [] + def fetch(url, path, _limit): + downloaded.append(url) + if "/git/ref/tags/" in url: + content = reference + elif "api.github.com" in url: + content = release + elif url.endswith("prerelease.json"): + content = identity + else: + self.fail("Artifact download started before tag provenance passed") + installer.write_file(path, json.dumps(content).encode()) + with mock.patch.object(installer, "download", side_effect=fetch): + with self.channel.lock(): + with self.assertRaisesRegex(installer.Refusal, "tag does not match"): + self.channel.install(version, upgrade=True) + self.assertEqual(len(downloaded), 3) + self.assertFalse((self.channel.root / "versions" / version).exists()) + self.assertEqual((self.channel.root / ".selection.json").read_bytes(), before) + + def test_draft_stable_missing_duplicate_and_foreign_release_assets_refuse(self): + version = "5.0.0-next.1" + tag = "v" + version + names = ("hack-" + version + "-darwin-arm64-native.tar.gz", "prerelease.json", "SHA256SUMS") + base = "https://github.com/hack-dance/hack/releases/download/" + tag + "/" + valid = {"tag_name": tag, "prerelease": True, "draft": False, + "assets": [{"name": name, "browser_download_url": base + name} for name in names]} + cases = [dict(valid, draft=True), dict(valid, prerelease=False), dict(valid, tag_name="latest"), + dict(valid, assets=valid["assets"][:-1]), + dict(valid, assets=valid["assets"] + [valid["assets"][0]]), + dict(valid, assets=[dict(valid["assets"][0], browser_download_url="https://example.com/a"), + *valid["assets"][1:]])] + for index, release in enumerate(cases): + with self.subTest(index=index): + stage = self.root / ("download-" + str(index)) + stage.mkdir(mode=0o700) + def fetch(_url, path, _limit): + installer.write_file(path, json.dumps(release).encode()) + with mock.patch.object(installer, "download", side_effect=fetch) as download: + with self.assertRaises(installer.Refusal): + installer.official_archive(stage, version) + self.assertEqual(download.call_count, 1) + + +if __name__ == "__main__": + unittest.main()