From 6cabd07542a9c37976905e3fcbbd39a689961f99 Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 12:02:31 +0800 Subject: [PATCH 1/4] Add native release distribution --- .github/workflows/release.yml | 317 +++++++++++++++++ AGENTS.md | 2 + CONTRIBUTING.md | 2 +- README.md | 42 ++- SECURITY.md | 2 + docs/releasing.md | 84 +++++ scripts/_lib/release_archive.py | 52 ++- scripts/_lib/release_installer.py | 322 +++++++++++++++++ scripts/assemble-release-assets.py | 35 ++ scripts/install-from-source.py | 16 +- tests/release_installer_contract.rs | 513 ++++++++++++++++++++++++++++ 11 files changed, 1333 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/releasing.md create mode 100644 scripts/_lib/release_installer.py create mode 100755 scripts/assemble-release-assets.py create mode 100644 tests/release_installer_contract.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9f1a77d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,317 @@ +name: Release + +on: + pull_request: + paths: + - ".github/workflows/release.yml" + - "Cargo.lock" + - "Cargo.toml" + - "LICENSE" + - "licenses/**" + - "scripts/_lib/age_keygen_artifacts.py" + - "scripts/_lib/release_archive.py" + - "scripts/_lib/release_installer.py" + - "scripts/_lib/sops_artifacts.py" + - "scripts/assemble-release-assets.py" + - "scripts/build-release-archive.py" + - "scripts/fetch-test-age-keygen.py" + - "scripts/fetch-test-sops.py" + - "tests/package_contract.rs" + - "tests/release_installer_contract.rs" + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +env: + CARGO_INCREMENTAL: "0" + CARGO_TERM_COLOR: always + PYTHONDONTWRITEBYTECODE: "1" + +jobs: + validate: + name: Validate release input + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.release.outputs.version }} + steps: + - name: Check out the release revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate the release identity + id: release + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + manifest_version="$(cargo metadata --no-deps --format-version 1 --locked \ + | python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(package["version"] for package in data["packages"] if package["name"] == "gitveil"))')" + version="$manifest_version" + if [[ "$EVENT_NAME" == "push" ]]; then + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "release tag must be an exact vMAJOR.MINOR.PATCH value" >&2 + exit 1 + fi + version="${RELEASE_TAG#v}" + if [[ "$version" != "$manifest_version" ]]; then + echo "release tag $RELEASE_TAG does not match Cargo version $manifest_version" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then + echo "release tag must point to a commit on origin/main" >&2 + exit 1 + fi + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.system }}-${{ matrix.machine }} + needs: validate + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + system: linux + machine: x86_64 + uname_system: Linux + uname_machine: x86_64 + - runner: ubuntu-24.04-arm + system: linux + machine: aarch64 + uname_system: Linux + uname_machine: aarch64 + - runner: macos-15-intel + system: darwin + machine: x86_64 + uname_system: Darwin + uname_machine: x86_64 + - runner: macos-15 + system: darwin + machine: aarch64 + uname_system: Darwin + uname_machine: arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + permissions: + contents: read + steps: + - name: Check out the release revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Verify the native runner + env: + EXPECTED_MACHINE: ${{ matrix.uname_machine }} + EXPECTED_SYSTEM: ${{ matrix.uname_system }} + run: | + [[ "$(uname -s)" == "$EXPECTED_SYSTEM" ]] + [[ "$(uname -m)" == "$EXPECTED_MACHINE" ]] + rustc --version + cargo --version + + - name: Install the pinned test runner + uses: taiki-e/install-action@6cd13508893c0e7eab5f273c2575d3859bd7229a # v2 + with: + tool: cargo-nextest@0.9.140 + checksum: true + fallback: none + + - name: Fetch checksum-pinned test sidecars + run: | + sops_bin="$(./scripts/fetch-test-sops.py)" + age_keygen_bin="$(./scripts/fetch-test-age-keygen.py)" + age_keygen_archive="$(./scripts/fetch-test-age-keygen.py --print-archive)" + { + echo "SOPS_BIN=$sops_bin" + echo "AGE_KEYGEN_BIN=$age_keygen_bin" + echo "AGE_KEYGEN_ARCHIVE=$age_keygen_archive" + } >> "$GITHUB_ENV" + + - name: Run native host tests + run: cargo nextest run --profile ci-host --all-features --locked --no-tests fail + + - name: Build the native release binary + run: cargo build --release --locked + + - name: Build the complete native archive + env: + EXPECTED_ARCHIVE: gitveil-v${{ needs.validate.outputs.version }}-${{ matrix.system }}-${{ matrix.machine }}.tar.gz + run: | + mkdir release-assets + ./scripts/build-release-archive.py \ + --skip-build \ + --binary target/release/gitveil \ + --sops-bin "$SOPS_BIN" \ + --age-keygen-bin "$AGE_KEYGEN_BIN" \ + --age-keygen-archive "$AGE_KEYGEN_ARCHIVE" \ + --output-dir release-assets + test -f "release-assets/$EXPECTED_ARCHIVE" + test -f "release-assets/$EXPECTED_ARCHIVE.sha256" + echo "ARCHIVE=release-assets/$EXPECTED_ARCHIVE" >> "$GITHUB_ENV" + echo "ARCHIVE_ROOT=${EXPECTED_ARCHIVE%.tar.gz}" >> "$GITHUB_ENV" + + - name: Verify the archive checksum and executables + run: | + if command -v sha256sum >/dev/null 2>&1; then + (cd release-assets && sha256sum --check "$(basename "$ARCHIVE").sha256") + else + (cd release-assets && shasum -a 256 --check "$(basename "$ARCHIVE").sha256") + fi + mkdir release-smoke + tar -xzf "$ARCHIVE" -C release-smoke + "release-smoke/$ARCHIVE_ROOT/bin/gitveil" --version + "release-smoke/$ARCHIVE_ROOT/libexec/gitveil/sops" --disable-version-check --version + "release-smoke/$ARCHIVE_ROOT/libexec/gitveil/age-keygen" --version + + - name: Upload the native archive + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-${{ matrix.system }}-${{ matrix.machine }} + path: | + ${{ env.ARCHIVE }} + ${{ env.ARCHIVE }}.sha256 + if-no-files-found: error + retention-days: 7 + + assemble: + name: Assemble release assets + needs: + - validate + - build + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Check out the release revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Download all native archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-* + path: release-assets + merge-multiple: true + + - name: Verify native archive transfer checksums + run: | + mapfile -t archives < <(find release-assets -maxdepth 1 -type f -name 'gitveil-v*.tar.gz' | sort) + mapfile -t checksums < <(find release-assets -maxdepth 1 -type f -name 'gitveil-v*.tar.gz.sha256' | sort) + [[ "${#archives[@]}" -eq 4 ]] + [[ "${#checksums[@]}" -eq 4 ]] + for checksum in "${checksums[@]}"; do + (cd release-assets && sha256sum --check "$(basename "$checksum")") + done + rm -- "${checksums[@]}" + + - name: Build the version-pinned installer and manifest + run: | + ./scripts/assemble-release-assets.py \ + --version "${{ needs.validate.outputs.version }}" \ + --archive-dir release-assets \ + --output-dir release-assets + sh -n release-assets/gitveil-installer.sh + (cd release-assets && sha256sum --check SHA256SUMS) + + - name: Upload the assembled release + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: assembled-release + path: release-assets/ + if-no-files-found: error + retention-days: 7 + + attest: + name: Attest release assets + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + needs: + - validate + - assemble + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Download the assembled release + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: assembled-release + path: release-assets + + - name: Attest every final release asset + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4 + with: + subject-path: release-assets/* + + draft: + name: Create draft release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + needs: + - validate + - assemble + - attest + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: release + url: ${{ steps.release.outputs.url }} + permissions: + contents: write + steps: + - name: Download the assembled release + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: assembled-release + path: release-assets + + - name: Verify the final release assets + run: | + [[ "$(find release-assets -maxdepth 1 -type f | wc -l | tr -d ' ')" -eq 6 ]] + (cd release-assets && sha256sum --check SHA256SUMS) + + - name: Create or update the draft release + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + is_draft="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" + [[ "$is_draft" == "true" ]] || { + echo "refusing to replace assets on a published release" >&2 + exit 1 + } + gh release upload "$RELEASE_TAG" release-assets/* \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + else + gh release create "$RELEASE_TAG" release-assets/* \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --draft \ + --generate-notes \ + --title "Gitveil $RELEASE_TAG" + fi + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '"url=" + .url' \ + >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index 28cf80b..2b911c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Inspect the existing implementation and tests before proposing changes. Do not i - The Rust version is pinned in `rust-toolchain.toml`. - Compatible SOPS and age-keygen builds and artifact checksums are declared in `scripts/_lib/sops_artifacts.py` and `scripts/_lib/age_keygen_artifacts.py`. - Files directly under `scripts/` are executable entry points; import-only Python modules belong under `scripts/_lib/` and must not be executable. +- GitHub Releases is the only authoritative distribution source. Release installers are immutable version assets with embedded native-archive checksums; never install from a moving branch or publish an incomplete binary-only distribution. - `Cargo.toml` intentionally has `publish = false`; do not publish the crate or create releases unless explicitly requested. - Keep all tracked text, code comments, tests, commit messages, and pull request content in English. - Do not add private keys, plaintext secrets, local absolute paths, generated build output, unpublished planning material, or tool transcripts. @@ -96,6 +97,7 @@ Do not change test expectations merely to make a gate pass. Fix the implementati - Update `README.md` when commands, configuration, installation, security boundaries, or release layout change. - Update `docs/getting-started.md` when the first-use path changes. - Update `CONTRIBUTING.md` when contributor tooling or quality gates change. +- Update `docs/releasing.md` when release targets, assets, permissions, attestations, or publication controls change. - Keep relative links in public Markdown valid; `scripts/check-doc-links.py` enforces them. - Keep dependency changes intentional and locked. Explain why a new runtime dependency belongs at its architectural boundary. - Preserve third-party license files and notices in release archives. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e08106..fae18f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Complete validation runs only in GitHub Actions. `.config/nextest.toml` defines | `Linux` | Ubuntu | Native CLI, Git, SOPS, identity, filesystem, runtime, release build, and package validation | | `macOS` | macOS | The same native host suite on macOS | -Every pull request and push to `main` runs all three checks on GitHub-hosted runners. CI uses no repository secrets and does not publish artifacts or releases. Do not add aggregate local gate scripts or a Docker replica of the hosted Linux runner. +Every pull request and push to `main` runs all three checks on GitHub-hosted runners. The merge-gate workflow uses no repository secrets and never publishes releases. Release-related pull requests additionally exercise the four-target native archive workflow without requesting attestation or publication permissions. Exact version tags run the trusted attestation and draft-release jobs described in [`docs/releasing.md`](docs/releasing.md). Do not add aggregate local gate scripts or a Docker replica of the hosted Linux runner. ## Pull requests diff --git a/README.md b/README.md index 99f96e0..bf1d880 100644 --- a/README.md +++ b/README.md @@ -14,27 +14,40 @@ Gitveil does not require a system SOPS or age executable. Release archives inclu ## Installation -Install from a source checkout for the current user: +Install the latest immutable release on macOS or Linux: ```bash -./scripts/install-from-source.py +curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/Sukitly/gitveil/releases/latest/download/gitveil-installer.sh | + sh ``` -The default prefix is `${CARGO_HOME:-$HOME/.cargo}`. To select another prefix: +The release-specific installer is version-pinned and embeds the SHA-256 values of all four native archives. It downloads only the archive matching the current OS and architecture, validates its checksum and exact member layout before extraction, verifies all three executables, and transactionally installs the complete distribution under `$HOME/.local`. It never invokes `sudo`, modifies shell configuration, or creates an identity. Select another absolute prefix with: ```bash -./scripts/install-from-source.py --prefix ~/.local +curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/Sukitly/gitveil/releases/latest/download/gitveil-installer.sh | + sh -s -- --prefix "$HOME/.local" ``` -The installer performs a locked release build, fetches and verifies official SOPS 3.13.3 and age-keygen 1.3.1 artifacts, validates the complete release archive, and transactionally installs the binary, private sidecars, and license files. Do not use `cargo install --path .`: Cargo installs only the binary and omits the private sidecars. +For a reproducible installation, replace `latest` with an immutable version: -The user-facing installation unit is a native release archive. Maintainers build one on each target platform with: +```bash +curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/Sukitly/gitveil/releases/download/v0.1.0/gitveil-installer.sh | + sh +``` + +To inspect and verify the installer before execution, download it and verify its GitHub artifact attestation: ```bash -./scripts/build-release-archive.py +curl --proto '=https' --tlsv1.2 -LO \ + https://github.com/Sukitly/gitveil/releases/download/v0.1.0/gitveil-installer.sh +gh attestation verify gitveil-installer.sh --repo Sukitly/gitveil +sh gitveil-installer.sh ``` -The archive is written to `dist/gitveil-v--.tar.gz` with this layout: +Each release also provides `SHA256SUMS` and native archives for Linux and macOS on x86_64 and aarch64. Every archive contains one complete installation unit: ```text bin/gitveil @@ -47,7 +60,18 @@ share/licenses/gitveil/AGE-BSD-3-Clause.txt share/licenses/gitveil/AGE-NOTICE.txt ``` -Install all three top-level directories under the same prefix and place `/bin` on `PATH`. Gitveil never downloads executables at runtime. +Place `/bin` on `PATH`. Gitveil never downloads executables at runtime. + +### Install from source + +From a source checkout, build and transactionally install the same complete distribution with: + +```bash +./scripts/install-from-source.py +./scripts/install-from-source.py --prefix ~/.local +``` + +The source installer defaults to `${CARGO_HOME:-$HOME/.cargo}`. Do not use `cargo install --path .`: Cargo installs only the Gitveil binary and omits its private SOPS and age-keygen sidecars. Source development and tests may explicitly override the private sidecars with `SOPS_BIN` and `AGE_KEYGEN_BIN`; each override must still have its pinned version. These variables are not part of the user installation contract. diff --git a/SECURITY.md b/SECURITY.md index 86843ca..b6763e0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,8 @@ Before the first tagged release, security fixes target `main`. After releases begin, fixes target `main` and the latest released version. Older releases are not supported unless a security advisory states otherwise. +Published GitHub Releases are immutable and include SHA-256 manifests plus GitHub artifact attestations. Installation scripts and native archives obtained from another source are not official Gitveil distributions. + ## Reporting a vulnerability Do not open a public issue or discussion containing vulnerability details. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..4a0a7db --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,84 @@ +# Releasing Gitveil + +GitHub Releases is Gitveil's only authoritative distribution source. Releases contain complete native archives for Linux and macOS, a version-pinned installer, a checksum manifest, and GitHub artifact attestations. Gitveil is not published to crates.io, and no release artifact may omit the private SOPS or age-keygen sidecar. + +## Release contract + +A stable release contains exactly these six assets: + +```text +gitveil-v-darwin-aarch64.tar.gz +gitveil-v-darwin-x86_64.tar.gz +gitveil-v-linux-aarch64.tar.gz +gitveil-v-linux-x86_64.tar.gz +gitveil-installer.sh +SHA256SUMS +``` + +Every native archive contains `bin/`, `libexec/`, and `share/licenses/` as one installation unit. `gitveil-installer.sh` embeds the four archive digests and a fixed `vMAJOR.MINOR.PATCH` download path. It never resolves a moving branch or downloads an executable that is not covered by its embedded checksum table. + +Published releases are immutable. Their tag and assets cannot be replaced or deleted. A release that needs a code or packaging correction must use a new patch version. + +Repository settings enforce the publication boundary: + +- the `Protect release tags` ruleset prevents updates and deletion for `refs/tags/v*`; +- the `release` environment accepts only `v*` tags and requires maintainer approval before the draft job receives write permission; and +- GitHub release immutability locks the tag and all assets when the draft is published. + +## Prepare the version + +1. Update the package version in `Cargo.toml` through a pull request. Update `Cargo.lock` and user documentation when necessary. +2. Merge only after the required `Core`, `Linux`, and `macOS` checks pass. Release-related pull requests also build all four native target archives through `.github/workflows/release.yml`. +3. Confirm that the release commit is on `origin/main`, the working tree is clean, and the version has not already been released. + +The tag must exactly match the Cargo package version. Prerelease suffixes and moving branch references are rejected. + +## Create the draft release + +From an up-to-date `main` checkout, create and push an annotated tag: + +```bash +git switch main +git pull --ff-only origin main +git tag -a v0.1.0 -m "Gitveil v0.1.0" +git push origin v0.1.0 +``` + +The tag-triggered release workflow: + +1. verifies that the tag is exact SemVer, matches `Cargo.toml`, and points to `origin/main`; +2. runs the native host suite on Linux x86_64, Linux aarch64, macOS Intel, and macOS aarch64; +3. builds the Gitveil binary and fetches only the current runner's checksum-pinned official sidecars; +4. constructs and smoke-tests the complete native archive on each runner; +5. transfers all four archives with per-archive checksums; +6. generates the version-pinned installer and aggregate `SHA256SUMS` manifest; +7. attests all six final assets; +8. waits for approval of the `release` environment; and +9. creates or updates a draft GitHub Release with generated release notes. + +The workflow refuses to replace assets on a published release. + +## Verify and publish + +After the workflow succeeds, inspect the draft release before publishing it: + +```bash +rm -rf release-review +mkdir release-review +gh release download v0.1.0 --repo Sukitly/gitveil --dir release-review +(cd release-review && sha256sum --check SHA256SUMS) +for asset in release-review/*; do + gh attestation verify "$asset" --repo Sukitly/gitveil +done +``` + +On macOS, use `shasum -a 256 --check SHA256SUMS` when `sha256sum` is unavailable. Confirm that the draft contains exactly the six declared assets, all attestations verify, generated notes describe the intended changes, and every release workflow job has no unresolved annotation. + +Publish the draft through the GitHub Releases interface. Publication makes the release and its assets immutable and updates the `releases/latest` installer URL. + +## Failed releases + +- Retry a failed workflow job when the failure is transient and no artifact input changed. +- Never move or reuse a release tag after artifacts have been published. +- If source, dependencies, packaging, checksums, or installer behavior must change, fix them through a pull request and publish a new patch version. +- For a security release, coordinate through GitHub Private Vulnerability Reporting and publish the associated security advisory when disclosure is appropriate. diff --git a/scripts/_lib/release_archive.py b/scripts/_lib/release_archive.py index 735b0f7..a758941 100644 --- a/scripts/_lib/release_archive.py +++ b/scripts/_lib/release_archive.py @@ -5,7 +5,7 @@ import gzip import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath import shutil import subprocess import tarfile @@ -18,6 +18,17 @@ from .sops_artifacts import VERSION as SOPS_VERSION from .sops_artifacts import artifact_for, fetch_verified, verify +RELEASE_FILES = ( + ("share/licenses/gitveil/LICENSE", 0o644), + ("share/licenses/gitveil/SOPS-MPL-2.0.txt", 0o644), + ("share/licenses/gitveil/SOPS-NOTICE.txt", 0o644), + ("share/licenses/gitveil/AGE-BSD-3-Clause.txt", 0o644), + ("share/licenses/gitveil/AGE-NOTICE.txt", 0o644), + ("libexec/gitveil/sops", 0o755), + ("libexec/gitveil/age-keygen", 0o755), + ("bin/gitveil", 0o755), +) + def package_version(root: Path) -> str: manifest = (root / "Cargo.toml").resolve() @@ -81,23 +92,13 @@ def write_archive(staging_root: Path, archive: Path) -> None: def expected_archive_members(archive_root: str) -> set[str]: - return { - f"{archive_root}", - f"{archive_root}/bin", - f"{archive_root}/bin/gitveil", - f"{archive_root}/libexec", - f"{archive_root}/libexec/gitveil", - f"{archive_root}/libexec/gitveil/sops", - f"{archive_root}/libexec/gitveil/age-keygen", - f"{archive_root}/share", - f"{archive_root}/share/licenses", - f"{archive_root}/share/licenses/gitveil", - f"{archive_root}/share/licenses/gitveil/LICENSE", - f"{archive_root}/share/licenses/gitveil/SOPS-MPL-2.0.txt", - f"{archive_root}/share/licenses/gitveil/SOPS-NOTICE.txt", - f"{archive_root}/share/licenses/gitveil/AGE-BSD-3-Clause.txt", - f"{archive_root}/share/licenses/gitveil/AGE-NOTICE.txt", - } + root = PurePosixPath(archive_root) + files = {root / relative for relative, _ in RELEASE_FILES} + directories = {root} + for file in files: + directories.update(file.parents) + directories.discard(PurePosixPath(".")) + return {str(path) for path in files | directories} def validate_archive(archive: Path, archive_root: str) -> None: @@ -112,25 +113,14 @@ def validate_archive(archive: Path, archive_root: str) -> None: extra = sorted(set(members) - expected) raise RuntimeError(f"invalid release archive layout; missing={missing}, extra={extra}") - files = { - f"{archive_root}/bin/gitveil", - f"{archive_root}/libexec/gitveil/sops", - f"{archive_root}/libexec/gitveil/age-keygen", - f"{archive_root}/share/licenses/gitveil/LICENSE", - f"{archive_root}/share/licenses/gitveil/SOPS-MPL-2.0.txt", - f"{archive_root}/share/licenses/gitveil/SOPS-NOTICE.txt", - f"{archive_root}/share/licenses/gitveil/AGE-BSD-3-Clause.txt", - f"{archive_root}/share/licenses/gitveil/AGE-NOTICE.txt", - } + files = {f"{archive_root}/{relative}" for relative, _ in RELEASE_FILES} for name, member in members.items(): if name in files and not member.isfile(): raise RuntimeError(f"release member is not a file: {name}") if name not in files and not member.isdir(): raise RuntimeError(f"release member is not a directory: {name}") for executable in [ - f"{archive_root}/bin/gitveil", - f"{archive_root}/libexec/gitveil/sops", - f"{archive_root}/libexec/gitveil/age-keygen", + f"{archive_root}/{relative}" for relative, mode in RELEASE_FILES if mode & 0o111 ]: if members[executable].mode & 0o111 == 0: raise RuntimeError(f"release executable is not executable: {executable}") diff --git a/scripts/_lib/release_installer.py b/scripts/_lib/release_installer.py new file mode 100644 index 0000000..f049a02 --- /dev/null +++ b/scripts/_lib/release_installer.py @@ -0,0 +1,322 @@ +"""Assemble version-pinned native release installer assets.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +import re +import shutil + +from .release_archive import RELEASE_FILES, expected_archive_members + +TARGETS = ( + ("darwin", "aarch64"), + ("darwin", "x86_64"), + ("linux", "aarch64"), + ("linux", "x86_64"), +) +INSTALLER_NAME = "gitveil-installer.sh" +CHECKSUM_MANIFEST_NAME = "SHA256SUMS" +_VERSION_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") + + +def archive_name(version: str, system: str, machine: str) -> str: + return f"gitveil-v{version}-{system}-{machine}.tar.gz" + + +def digest(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def validate_version(version: str) -> None: + if _VERSION_PATTERN.fullmatch(version) is None: + raise ValueError("release version must be an exact MAJOR.MINOR.PATCH value") + + +def expected_archives(version: str, archive_directory: Path) -> dict[str, Path]: + validate_version(version) + archives = { + archive_name(version, system, machine): archive_directory + / archive_name(version, system, machine) + for system, machine in TARGETS + } + missing = [name for name, path in archives.items() if not path.is_file()] + if missing: + raise ValueError(f"release archives are missing: {', '.join(sorted(missing))}") + return archives + + +def copy_atomic(source: Path, destination: Path, mode: int) -> None: + if source.resolve() == destination.resolve(): + return + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f"{destination.name}.tmp") + temporary.unlink(missing_ok=True) + try: + shutil.copyfile(source, temporary) + temporary.chmod(mode) + temporary.replace(destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def write_atomic(path: Path, content: bytes, mode: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f"{path.name}.tmp") + temporary.unlink(missing_ok=True) + try: + with temporary.open("xb") as output: + output.write(content) + output.flush() + temporary.chmod(mode) + temporary.replace(path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def render_installer(version: str, checksums: dict[str, str]) -> str: + cases = [] + for system, machine in TARGETS: + name = archive_name(version, system, machine) + uname_system = "Darwin" if system == "darwin" else "Linux" + cases.append( + f''' "{uname_system}/{machine}") + archive='{name}' + expected_sha256='{checksums[name]}' + ;;''' + ) + target_cases = "\n".join(cases) + expected_members = "\n".join(sorted(expected_archive_members("$archive_root"))) + rollback_files = "\n".join( + f" rollback_file {index} {relative}" + for index, (relative, _) in reversed(list(enumerate(RELEASE_FILES, start=1))) + ) + verify_files = "\n".join( + f"verify_file {relative} {mode:04o}" for relative, mode in RELEASE_FILES + ) + preflight_files = "\n".join( + f"preflight_file {relative}" for relative, _ in RELEASE_FILES + ) + commit_files = "\n".join( + f"commit_file {index} {relative}" + for index, (relative, _) in enumerate(RELEASE_FILES, start=1) + ) + return f'''#!/bin/sh +set -eu + +VERSION='{version}' +REPOSITORY='Sukitly/gitveil' + +fail() {{ + printf 'gitveil installer: %s\\n' "$1" >&2 + exit 1 +}} + +usage() {{ + cat <<'USAGE' +Install Gitveil and its private SOPS and age-keygen sidecars. + +Usage: gitveil-installer.sh [--prefix PATH] + +Options: + --prefix PATH Installation prefix (default: $HOME/.local) + -h, --help Show this help +USAGE +}} + +prefix= +while [ "$#" -gt 0 ]; do + case "$1" in + --prefix) + [ "$#" -ge 2 ] || fail "--prefix requires a path" + prefix=$2 + [ -n "$prefix" ] || fail "installation prefix cannot be empty" + shift 2 + ;; + --prefix=*) + prefix=${{1#--prefix=}} + [ -n "$prefix" ] || fail "installation prefix cannot be empty" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +if [ -z "$prefix" ]; then + [ -n "${{HOME-}}" ] || fail "HOME is required when --prefix is not provided" + prefix=$HOME/.local +fi +case "$prefix" in + /*) ;; + *) fail "installation prefix must be an absolute path" ;; +esac + +for required_command in curl tar mktemp sort cmp awk sed uname mkdir rm mv chmod; do + command -v "$required_command" >/dev/null 2>&1 \ + || fail "required command is unavailable: $required_command" +done + +system=$(uname -s) +machine=$(uname -m) +case "$machine" in + arm64) machine=aarch64 ;; + amd64|x64) machine=x86_64 ;; +esac +case "$system/$machine" in +{target_cases} + *) fail "unsupported platform: $system/$machine" ;; +esac + +archive_root=${{archive%.tar.gz}} +download_url="https://github.com/$REPOSITORY/releases/download/v{version}/$archive" +umask 077 +mkdir -p "$prefix" +work=$(mktemp -d "$prefix/.gitveil-install.XXXXXX") \ + || fail "could not create installation staging directory" +mkdir -p "$work/backups" "$work/backed-up" "$work/committed" +complete=0 + +rollback_file() {{ + index=$1 + relative=$2 + destination=$prefix/$relative + if [ -f "$work/backed-up/$index" ]; then + rm -f "$destination" + mv "$work/backups/$index" "$destination" || : + elif [ -f "$work/committed/$index" ]; then + rm -f "$destination" + fi +}} + +cleanup() {{ + status=$? + trap - EXIT HUP INT TERM + if [ "$complete" -ne 1 ]; then +{rollback_files} + fi + rm -rf "$work" + exit "$status" +}} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +archive_path=$work/$archive +curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + --output "$archive_path" "$download_url" + +if command -v sha256sum >/dev/null 2>&1; then + actual_sha256=$(sha256sum "$archive_path" | awk '{{print $1}}') +elif command -v shasum >/dev/null 2>&1; then + actual_sha256=$(shasum -a 256 "$archive_path" | awk '{{print $1}}') +else + fail "neither sha256sum nor shasum is available" +fi +[ "$actual_sha256" = "$expected_sha256" ] \ + || fail "archive checksum verification failed" + +cat > "$work/expected-members" < "$work/archive-members" +cmp -s "$work/expected-members" "$work/archive-members" \ + || fail "release archive contains an unexpected member layout" +tar -tvzf "$archive_path" > "$work/archive-member-types" +awk ' + substr($0, 1, 1) != "d" && substr($0, 1, 1) != "-" {{ exit 1 }} +' "$work/archive-member-types" \ + || fail "release archive contains a non-file or non-directory member" + +mkdir "$work/extracted" +tar -xzf "$archive_path" -C "$work/extracted" +payload=$work/payload +mv "$work/extracted/$archive_root" "$payload" + +verify_file() {{ + relative=$1 + mode=$2 + path=$payload/$relative + [ -f "$path" ] && [ ! -L "$path" ] \ + || fail "release payload member is not a regular file: $relative" + chmod "$mode" "$path" +}} +{verify_files} + +"$payload/bin/gitveil" --version >/dev/null 2>&1 \ + || fail "Gitveil executable validation failed" +"$payload/libexec/gitveil/sops" --disable-version-check --version >/dev/null 2>&1 \ + || fail "SOPS sidecar validation failed" +"$payload/libexec/gitveil/age-keygen" --version >/dev/null 2>&1 \ + || fail "age-keygen sidecar validation failed" + +preflight_file() {{ + relative=$1 + destination=$prefix/$relative + if [ -d "$destination" ] && [ ! -L "$destination" ]; then + fail "install destination is a directory: $destination" + fi +}} +{preflight_files} + +commit_file() {{ + index=$1 + relative=$2 + source=$payload/$relative + destination=$prefix/$relative + destination_parent=${{destination%/*}} + mkdir -p "$destination_parent" + if [ -e "$destination" ] || [ -L "$destination" ]; then + mv "$destination" "$work/backups/$index" + : > "$work/backed-up/$index" + fi + if ! mv "$source" "$destination"; then + return 1 + fi + : > "$work/committed/$index" +}} +{commit_files} +complete=1 + +printf 'Installed Gitveil %s to %s\\n' "$VERSION" "$prefix" +case ":${{PATH-}}:" in + *":$prefix/bin:"*) ;; + *) printf 'Add %s/bin to PATH.\\n' "$prefix" ;; +esac +''' + + +def assemble_release_assets( + version: str, archive_directory: Path, output_directory: Path +) -> tuple[Path, Path]: + archives = expected_archives(version, archive_directory) + checksums = {name: digest(path) for name, path in archives.items()} + for name, source in archives.items(): + copy_atomic(source, output_directory / name, 0o644) + installer = output_directory / INSTALLER_NAME + write_atomic(installer, render_installer(version, checksums).encode("utf-8"), 0o755) + + manifest_entries = { + **checksums, + INSTALLER_NAME: digest(installer), + } + manifest = "".join( + f"{checksum} {name}\n" + for name, checksum in sorted(manifest_entries.items()) + ) + checksum_manifest = output_directory / CHECKSUM_MANIFEST_NAME + write_atomic(checksum_manifest, manifest.encode("utf-8"), 0o644) + return installer, checksum_manifest diff --git a/scripts/assemble-release-assets.py b/scripts/assemble-release-assets.py new file mode 100755 index 0000000..b45347b --- /dev/null +++ b/scripts/assemble-release-assets.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Assemble the version-pinned installer and checksum manifest for a release.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from _lib.release_installer import assemble_release_assets + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--archive-dir", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + args = parser.parse_args() + + try: + installer, checksum_manifest = assemble_release_assets( + args.version, + args.archive_dir.resolve(), + args.output_dir.resolve(), + ) + except (OSError, ValueError) as error: + print(f"could not assemble release assets: {error}", file=sys.stderr) + return 1 + print(installer) + print(checksum_manifest) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install-from-source.py b/scripts/install-from-source.py index 6a3c14b..b02d76c 100755 --- a/scripts/install-from-source.py +++ b/scripts/install-from-source.py @@ -13,23 +13,13 @@ import tempfile from _lib.release_archive import ( + RELEASE_FILES, archive_root_name, build_archive, package_version, validate_archive, ) -_INSTALL_MEMBERS = ( - ("share/licenses/gitveil/LICENSE", 0o644), - ("share/licenses/gitveil/SOPS-MPL-2.0.txt", 0o644), - ("share/licenses/gitveil/SOPS-NOTICE.txt", 0o644), - ("share/licenses/gitveil/AGE-BSD-3-Clause.txt", 0o644), - ("share/licenses/gitveil/AGE-NOTICE.txt", 0o644), - ("libexec/gitveil/sops", 0o755), - ("libexec/gitveil/age-keygen", 0o755), - ("bin/gitveil", 0o755), -) - def default_prefix() -> Path: cargo_home = os.environ.get("CARGO_HOME") @@ -40,7 +30,7 @@ def extract_payload(archive: Path, archive_root: str, staging: Path) -> Path: validate_archive(archive, archive_root) payload = staging / "payload" with tarfile.open(archive, "r:gz") as package: - for relative, mode in _INSTALL_MEMBERS: + for relative, mode in RELEASE_FILES: member_name = f"{archive_root}/{relative}" member = package.getmember(member_name) source = package.extractfile(member) @@ -90,7 +80,7 @@ def path_exists(path: Path) -> bool: def commit_payload(payload: Path, prefix: Path, staging: Path) -> None: - destinations = [(payload / relative, prefix / relative) for relative, _ in _INSTALL_MEMBERS] + destinations = [(payload / relative, prefix / relative) for relative, _ in RELEASE_FILES] for _, destination in destinations: if is_directory_without_following_symlinks(destination): raise RuntimeError(f"install destination is a directory: {destination}") diff --git a/tests/release_installer_contract.rs b/tests/release_installer_contract.rs new file mode 100644 index 0000000..34e65bb --- /dev/null +++ b/tests/release_installer_contract.rs @@ -0,0 +1,513 @@ +pub mod support; + +use std::fs; +use std::io::Write; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; +use support::{assert_success, command_output}; + +const VERSION: &str = "9.8.7"; +const TARGETS: [(&str, &str); 4] = [ + ("darwin", "aarch64"), + ("darwin", "x86_64"), + ("linux", "aarch64"), + ("linux", "x86_64"), +]; +const INSTALL_FILES: [(&str, u32); 8] = [ + ("share/licenses/gitveil/LICENSE", 0o644), + ("share/licenses/gitveil/SOPS-MPL-2.0.txt", 0o644), + ("share/licenses/gitveil/SOPS-NOTICE.txt", 0o644), + ("share/licenses/gitveil/AGE-BSD-3-Clause.txt", 0o644), + ("share/licenses/gitveil/AGE-NOTICE.txt", 0o644), + ("libexec/gitveil/sops", 0o755), + ("libexec/gitveil/age-keygen", 0o755), + ("bin/gitveil", 0o755), +]; + +#[derive(Clone, Copy, Eq, PartialEq)] +enum CurrentArchiveMutation { + None, + UnexpectedMember, + SymlinkedSidecar, +} + +struct ReleaseFixture { + temporary: tempfile::TempDir, + archives: PathBuf, + output: PathBuf, + home: PathBuf, +} + +impl ReleaseFixture { + fn new(mutation: CurrentArchiveMutation) -> Self { + let temporary = tempfile::tempdir().expect("release fixture"); + let archives = temporary.path().join("archives"); + let output = temporary.path().join("output"); + let home = temporary.path().join("home"); + fs::create_dir_all(&archives).expect("archive directory"); + fs::create_dir_all(&home).expect("isolated home"); + for (system, machine) in TARGETS { + let selected_mutation = if (system, machine) == current_target() { + mutation + } else { + CurrentArchiveMutation::None + }; + create_archive( + temporary.path(), + &archives, + system, + machine, + selected_mutation, + ); + } + Self { + temporary, + archives, + output, + home, + } + } + + fn assemble(&self) -> Output { + self.assemble_version(VERSION) + } + + fn assemble_version(&self, version: &str) -> Output { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + command_output( + Command::new(root.join("scripts/assemble-release-assets.py")) + .current_dir(root) + .arg("--version") + .arg(version) + .arg("--archive-dir") + .arg(&self.archives) + .arg("--output-dir") + .arg(&self.output), + ) + } + + fn installer(&self) -> PathBuf { + self.output.join("gitveil-installer.sh") + } + + fn current_archive(&self) -> PathBuf { + let (system, machine) = current_target(); + self.archives.join(archive_name(system, machine)) + } + + fn fake_bin(&self, fail_during_commit: bool) -> PathBuf { + let fake_bin = self.temporary.path().join(if fail_during_commit { + "fake-bin-failing-mv" + } else { + "fake-bin" + }); + fs::create_dir_all(&fake_bin).expect("fake executable directory"); + write_executable( + &fake_bin.join("curl"), + r#"#!/bin/sh +set -eu +output= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + output=$2 + shift 2 + ;; + https://*) + url=$1 + shift + ;; + *) + shift + ;; + esac +done +[ -n "$output" ] +[ -n "$url" ] +printf '%s\n' "$url" > "$FAKE_CURL_LOG" +cp "$FAKE_RELEASE_ASSETS/${url##*/}" "$output" +"#, + ); + write_executable( + &fake_bin.join("sudo"), + r#"#!/bin/sh +printf 'sudo was invoked\n' > "$FAKE_SUDO_MARKER" +exit 99 +"#, + ); + if fail_during_commit { + write_executable( + &fake_bin.join("mv"), + r#"#!/bin/sh +set -eu +case "$1" in + */payload/libexec/gitveil/sops) + exit 73 + ;; +esac +exec /bin/mv "$@" +"#, + ); + } + fake_bin + } + + fn run_installer(&self, prefix: Option<&Path>, fail_during_commit: bool) -> Output { + let fake_bin = self.fake_bin(fail_during_commit); + let inherited_path = std::env::var_os("PATH").expect("test PATH"); + let path = std::env::join_paths( + std::iter::once(fake_bin.as_path()).chain( + std::env::split_paths(&inherited_path) + .collect::>() + .iter() + .map(PathBuf::as_path), + ), + ) + .expect("isolated PATH"); + let profile = self.home.join(".profile"); + if !profile.exists() { + fs::write(&profile, b"profile-canary\n").expect("profile canary"); + } + let curl_log = self.temporary.path().join("curl.log"); + let sudo_marker = self.temporary.path().join("sudo-invoked"); + let mut command = Command::new("sh"); + command + .arg(self.installer()) + .env("HOME", &self.home) + .env("PATH", path) + .env("FAKE_RELEASE_ASSETS", &self.archives) + .env("FAKE_CURL_LOG", curl_log) + .env("FAKE_SUDO_MARKER", sudo_marker); + if let Some(prefix) = prefix { + command.arg("--prefix").arg(prefix); + } + command_output(&mut command) + } +} + +fn current_target() -> (&'static str, &'static str) { + let system = match std::env::consts::OS { + "macos" => "darwin", + "linux" => "linux", + other => panic!("unsupported test system: {other}"), + }; + let machine = match std::env::consts::ARCH { + "aarch64" => "aarch64", + "x86_64" => "x86_64", + other => panic!("unsupported test architecture: {other}"), + }; + (system, machine) +} + +fn archive_name(system: &str, machine: &str) -> String { + format!("gitveil-v{VERSION}-{system}-{machine}.tar.gz") +} + +fn archive_root(system: &str, machine: &str) -> String { + format!("gitveil-v{VERSION}-{system}-{machine}") +} + +fn write_executable(path: &Path, content: &str) { + fs::write(path, content).expect("write executable fixture"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("executable fixture mode"); +} + +fn create_archive( + temporary: &Path, + archive_directory: &Path, + system: &str, + machine: &str, + mutation: CurrentArchiveMutation, +) { + let root_name = archive_root(system, machine); + let staging = temporary + .join("staging") + .join(format!("{system}-{machine}")); + let root = staging.join(&root_name); + for (relative, mode) in INSTALL_FILES { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("release member parent")) + .expect("release member directory"); + if mutation == CurrentArchiveMutation::SymlinkedSidecar + && relative == "libexec/gitveil/age-keygen" + { + symlink("sops", &path).expect("symlinked sidecar fixture"); + } else if relative.starts_with("share/licenses/") { + fs::write(&path, format!("fixture {relative}\n")).expect("license fixture"); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)) + .expect("license fixture mode"); + } else { + write_executable( + &path, + &format!("#!/bin/sh\nprintf '%s\\n' 'fixture {relative} {VERSION}'\n"), + ); + } + } + let archive = archive_directory.join(archive_name(system, machine)); + if mutation == CurrentArchiveMutation::UnexpectedMember { + let python = r#"import io +import pathlib +import sys +import tarfile +root = pathlib.Path(sys.argv[1]) +archive = pathlib.Path(sys.argv[2]) +with tarfile.open(archive, "w:gz") as output: + output.add(root, arcname=root.name) + payload = b"must-not-extract" + member = tarfile.TarInfo(f"{root.name}/../escape") + member.size = len(payload) + output.addfile(member, io.BytesIO(payload)) +"#; + assert_success( + command_output( + Command::new("python3") + .arg("-c") + .arg(python) + .arg(&root) + .arg(&archive), + ), + "malicious archive fixture", + ); + } else { + assert_success( + command_output( + Command::new("tar") + .arg("-czf") + .arg(&archive) + .arg("-C") + .arg(&staging) + .arg(&root_name), + ), + "release archive fixture", + ); + } +} + +fn digest(path: &Path) -> String { + let bytes = fs::read(path).expect("digest input"); + hex::encode(Sha256::digest(bytes)) +} + +fn assert_complete_install(prefix: &Path) { + for (relative, mode) in INSTALL_FILES { + let path = prefix.join(relative); + let metadata = fs::symlink_metadata(&path).expect("installed release member"); + assert!(metadata.is_file(), "{} must be a file", path.display()); + assert_eq!(metadata.permissions().mode() & 0o777, mode, "{relative}"); + } +} + +#[test] +fn release_asset_assembler_emits_a_version_pinned_installer_and_manifest() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::None); + assert_success(fixture.assemble(), "release asset assembly"); + + let installer = fixture.installer(); + assert_eq!( + fs::metadata(&installer) + .expect("installer metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + let script = fs::read_to_string(&installer).expect("generated installer"); + assert!(script.contains("releases/download/v9.8.7")); + assert!(!script.contains("releases/latest")); + assert!(!script.contains("/main/")); + for (system, machine) in TARGETS { + let archive = fixture.archives.join(archive_name(system, machine)); + let assembled = fixture.output.join(archive_name(system, machine)); + assert_eq!( + fs::read(&assembled).expect("assembled native archive"), + fs::read(&archive).expect("input native archive") + ); + assert!(script.contains(&archive_name(system, machine))); + assert!(script.contains(&digest(&archive))); + } + + let mut assets = TARGETS + .iter() + .map(|(system, machine)| fixture.archives.join(archive_name(system, machine))) + .collect::>(); + assets.push(installer); + assets.sort_by_key(|path| path.file_name().map(ToOwned::to_owned)); + let expected = assets + .iter() + .map(|path| { + format!( + "{} {}\n", + digest(path), + path.file_name() + .expect("release asset name") + .to_string_lossy() + ) + }) + .collect::(); + assert_eq!( + fs::read_to_string(fixture.output.join("SHA256SUMS")).expect("checksum manifest"), + expected + ); +} + +#[test] +fn release_asset_assembler_rejects_a_non_exact_version_without_output() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::None); + + let output = fixture.assemble_version("v9.8.7"); + assert!(!output.status.success()); + assert!(!fixture.output.exists()); +} + +#[test] +fn release_asset_assembler_requires_all_four_native_archives() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::None); + fs::remove_file(fixture.archives.join(archive_name("linux", "aarch64"))) + .expect("remove release target fixture"); + + let output = fixture.assemble(); + assert!(!output.status.success()); + assert!(!fixture.output.exists()); +} + +#[test] +fn release_installer_selects_the_current_platform_and_installs_the_complete_layout() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::None); + assert_success(fixture.assemble(), "release asset assembly"); + + let output = assert_success(fixture.run_installer(None, false), "release installation"); + let prefix = fixture.home.join(".local"); + assert_complete_install(&prefix); + assert!(String::from_utf8_lossy(&output.stdout).contains(prefix.to_string_lossy().as_ref())); + let requested = fs::read_to_string(fixture.temporary.path().join("curl.log")) + .expect("requested release URL"); + let current_archive = fixture.current_archive(); + let expected_suffix = format!( + "/{}\n", + current_archive + .file_name() + .expect("current archive name") + .to_string_lossy() + ); + assert!(requested.ends_with(&expected_suffix)); + assert_eq!( + fs::read(fixture.home.join(".profile")).expect("preserved shell profile"), + b"profile-canary\n" + ); + assert!(!fixture.temporary.path().join("sudo-invoked").exists()); + assert!(!fixture.home.join(".config/sops/age/keys.txt").exists()); +} + +#[test] +fn release_installer_help_does_not_require_home_or_download() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::None); + assert_success(fixture.assemble(), "release asset assembly"); + + let output = assert_success( + command_output( + Command::new("sh") + .arg(fixture.installer()) + .arg("--help") + .env_remove("HOME"), + ), + "release installer help", + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("--prefix PATH")); +} + +#[test] +fn release_installer_rejects_a_relative_prefix_without_downloading() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::None); + assert_success(fixture.assemble(), "release asset assembly"); + + let output = fixture.run_installer(Some(Path::new("relative-prefix")), false); + assert!(!output.status.success()); + assert!(!fixture.temporary.path().join("curl.log").exists()); +} + +#[test] +fn release_installer_rejects_a_checksum_mismatch_without_changing_an_existing_install() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::None); + assert_success(fixture.assemble(), "release asset assembly"); + let prefix = fixture.temporary.path().join("prefix"); + write_existing_install(&prefix); + fs::OpenOptions::new() + .append(true) + .open(fixture.current_archive()) + .expect("mutated archive") + .write_all(b"mutated") + .expect("archive mutation"); + + let output = fixture.run_installer(Some(&prefix), false); + assert!(!output.status.success()); + assert_existing_install(&prefix); +} + +#[test] +fn release_installer_rejects_unexpected_archive_members_before_extraction() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::UnexpectedMember); + assert_success(fixture.assemble(), "release asset assembly"); + let prefix = fixture.temporary.path().join("prefix"); + + let output = fixture.run_installer(Some(&prefix), false); + assert!(!output.status.success()); + assert!(!prefix.join("bin/gitveil").exists()); + assert!(!fixture.temporary.path().join("escape").exists()); +} + +#[test] +fn release_installer_rejects_symlinked_archive_members() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::SymlinkedSidecar); + assert_success(fixture.assemble(), "release asset assembly"); + let prefix = fixture.temporary.path().join("prefix"); + + let output = fixture.run_installer(Some(&prefix), false); + assert!(!output.status.success()); + assert!(!prefix.join("libexec/gitveil/age-keygen").exists()); +} + +#[test] +fn release_installer_rolls_back_every_file_when_commit_fails() { + let fixture = ReleaseFixture::new(CurrentArchiveMutation::None); + assert_success(fixture.assemble(), "release asset assembly"); + let prefix = fixture.temporary.path().join("prefix"); + write_existing_install(&prefix); + + let output = fixture.run_installer(Some(&prefix), true); + assert!(!output.status.success()); + assert_existing_install(&prefix); +} + +fn write_existing_install(prefix: &Path) { + for (relative, mode) in INSTALL_FILES { + let path = prefix.join(relative); + fs::create_dir_all(path.parent().expect("existing member parent")) + .expect("existing install directory"); + fs::write(&path, format!("existing:{relative}\n")).expect("existing install member"); + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("existing member mode"); + } +} + +fn assert_existing_install(prefix: &Path) { + for (relative, mode) in INSTALL_FILES { + let path = prefix.join(relative); + assert_eq!( + fs::read(&path).expect("preserved install member"), + format!("existing:{relative}\n").as_bytes(), + "{relative} must be restored" + ); + assert_eq!( + fs::metadata(path) + .expect("preserved member metadata") + .permissions() + .mode() + & 0o777, + mode, + "{relative} mode must be restored" + ); + } +} From 75f3a9278c3c7f43b815ce2b54b29af459ce81c3 Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 12:09:13 +0800 Subject: [PATCH 2/4] Fix release contract lint --- tests/release_installer_contract.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/tests/release_installer_contract.rs b/tests/release_installer_contract.rs index 34e65bb..5d16e44 100644 --- a/tests/release_installer_contract.rs +++ b/tests/release_installer_contract.rs @@ -1,7 +1,8 @@ pub mod support; +use std::fmt::Write as _; use std::fs; -use std::io::Write; +use std::io::Write as _; use std::os::unix::fs::{PermissionsExt, symlink}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -336,18 +337,18 @@ fn release_asset_assembler_emits_a_version_pinned_installer_and_manifest() { .collect::>(); assets.push(installer); assets.sort_by_key(|path| path.file_name().map(ToOwned::to_owned)); - let expected = assets - .iter() - .map(|path| { - format!( - "{} {}\n", - digest(path), - path.file_name() - .expect("release asset name") - .to_string_lossy() - ) - }) - .collect::(); + let mut expected = String::new(); + for path in &assets { + writeln!( + expected, + "{} {}", + digest(path), + path.file_name() + .expect("release asset name") + .to_string_lossy() + ) + .expect("checksum manifest expectation"); + } assert_eq!( fs::read_to_string(fixture.output.join("SHA256SUMS")).expect("checksum manifest"), expected From eba34490316cb62d10b430709c84499a80253a14 Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 12:16:14 +0800 Subject: [PATCH 3/4] Use Node 24 artifact actions --- .github/workflows/release.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f1a77d..0ff29d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,7 +181,7 @@ jobs: "release-smoke/$ARCHIVE_ROOT/libexec/gitveil/age-keygen" --version - name: Upload the native archive - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: release-${{ matrix.system }}-${{ matrix.machine }} path: | @@ -206,7 +206,7 @@ jobs: persist-credentials: false - name: Download all native archives - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: pattern: release-* path: release-assets @@ -233,7 +233,7 @@ jobs: (cd release-assets && sha256sum --check SHA256SUMS) - name: Upload the assembled release - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: assembled-release path: release-assets/ @@ -254,7 +254,7 @@ jobs: attestations: write steps: - name: Download the assembled release - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: name: assembled-release path: release-assets @@ -280,7 +280,7 @@ jobs: contents: write steps: - name: Download the assembled release - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: name: assembled-release path: release-assets From 4fc27924faa17a1186ee7aa81f5d8e85c26f9a05 Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 12:25:21 +0800 Subject: [PATCH 4/4] Use current artifact actions --- .github/workflows/release.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ff29d2..c3553c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,7 +181,7 @@ jobs: "release-smoke/$ARCHIVE_ROOT/libexec/gitveil/age-keygen" --version - name: Upload the native archive - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-${{ matrix.system }}-${{ matrix.machine }} path: | @@ -206,7 +206,7 @@ jobs: persist-credentials: false - name: Download all native archives - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: release-* path: release-assets @@ -233,7 +233,7 @@ jobs: (cd release-assets && sha256sum --check SHA256SUMS) - name: Upload the assembled release - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: assembled-release path: release-assets/ @@ -254,7 +254,7 @@ jobs: attestations: write steps: - name: Download the assembled release - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: assembled-release path: release-assets @@ -280,7 +280,7 @@ jobs: contents: write steps: - name: Download the assembled release - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: assembled-release path: release-assets