diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml new file mode 100644 index 0000000..1dc0db9 --- /dev/null +++ b/.github/workflows/create-release-tag.yml @@ -0,0 +1,92 @@ +name: Create release tag + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: read + +concurrency: + group: create-release-tag + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + tag: + name: Tag the merged release + if: | + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # The dedicated fine-grained RELEASE_TOKEN (contents read/write on this + # repository only) is required because tags pushed with GITHUB_TOKEN do + # not trigger the tag-driven release workflow. + - name: Check out the merge commit with the tagging credential + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Read and validate the release version + id: version + env: + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + version="$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["package"]["version"])')" + if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Cargo.toml version $version is not exact SemVer" >&2 + exit 1 + fi + if [[ "$HEAD_BRANCH" != "release/v$version" ]]; then + echo "the release/ branch prefix is reserved for scripts/prepare-release.py (see docs/releasing.md);" >&2 + echo "$HEAD_BRANCH does not match the Cargo version $version (expected release/v$version), so no tag was created" >&2 + exit 1 + fi + if [[ -n "$(git ls-remote origin "refs/tags/v$version")" ]]; then + # An existing tag is only a safe no-op when it already points at + # this merge commit (idempotent re-run); pointing anywhere else + # means the version number is taken and needs a human. + git fetch --quiet --no-tags origin "refs/tags/v$version:refs/tags/v$version" + tagged="$(git rev-parse "v$version^{commit}")" + if [[ "$tagged" == "$(git rev-parse HEAD)" ]]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "tag v$version already points at this merge commit; nothing to do" + else + echo "tag v$version already exists and points at $tagged, not this merge commit;" >&2 + echo "the version number is taken: run scripts/prepare-release.py again to bump to the next version" >&2 + exit 1 + fi + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Create and push the annotated tag + if: steps.version.outputs.exists == 'false' + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "Gitveil v$VERSION" + git push origin "v$VERSION" + echo "created tag v$VERSION at $(git rev-parse HEAD)" + + - name: Delete the merged release branch + env: + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + if [[ -n "$(git ls-remote --heads origin "$HEAD_BRANCH")" ]]; then + git push origin --delete "$HEAD_BRANCH" + echo "deleted branch $HEAD_BRANCH" + else + echo "branch $HEAD_BRANCH is already gone" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3553c7..d3a37d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -273,9 +273,6 @@ jobs: - attest runs-on: ubuntu-latest timeout-minutes: 15 - environment: - name: release - url: ${{ steps.release.outputs.url }} permissions: contents: write steps: @@ -291,7 +288,6 @@ jobs: (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 }} @@ -313,5 +309,62 @@ jobs: --generate-notes \ --title "Gitveil $RELEASE_TAG" fi + + publish: + name: Publish release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + needs: + - validate + - draft + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: release + url: ${{ steps.publish.outputs.url }} + permissions: + contents: write + steps: + # Draft assets stay mutable until publication, so the facts the + # approval relies on are re-derived here, inside the gate: the six + # assets are downloaded and verified byte-for-byte against SHA256SUMS + # and their build attestations before anything goes live. + - name: Verify the draft release before publication + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + state="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" + if [[ "$state" != "true" ]]; then + echo "release $RELEASE_TAG is not a draft; refusing to touch a published release" >&2 + exit 1 + fi + mkdir release-review + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir release-review + mapfile -t assets < <(cd release-review && find . -maxdepth 1 -type f -exec basename {} \; | sort) + expected=$(printf '%s\n' \ + "SHA256SUMS" \ + "gitveil-installer.sh" \ + "gitveil-v$VERSION-darwin-aarch64.tar.gz" \ + "gitveil-v$VERSION-darwin-x86_64.tar.gz" \ + "gitveil-v$VERSION-linux-aarch64.tar.gz" \ + "gitveil-v$VERSION-linux-x86_64.tar.gz" | sort) + if [[ "$(printf '%s\n' "${assets[@]}")" != "$expected" ]]; then + echo "draft assets do not match the six-asset release contract" >&2 + printf 'actual:\n%s\n' "${assets[@]}" >&2 + exit 1 + fi + (cd release-review && sha256sum --check SHA256SUMS) + for asset in release-review/*; do + gh attestation verify "$asset" --repo "$GITHUB_REPOSITORY" + done + + - name: Publish the release + id: publish + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '"url=" + .url' \ >> "$GITHUB_OUTPUT" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fae18f8..69493ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ GitHub Actions installs the pinned `cargo-nextest` and `cargo-deny` versions use ## Making changes -Create a branch from the latest `main`. Keep commits focused and use English for commit messages, pull request titles, descriptions, code comments, tests, and documentation. +Create a branch from the latest `main`. Keep commits focused and use English for commit messages, pull request titles, descriptions, code comments, tests, and documentation. Branch names starting with `release/` are reserved for `scripts/prepare-release.py`: merging such a branch triggers the automated release tagging described in [`docs/releasing.md`](docs/releasing.md), so never use that prefix for ordinary work. When changing core behavior, add or update the relevant test first and confirm that it fails for the expected reason before implementing the change. Pure decisions belong in pure modules; filesystem, process, Git, SOPS, environment, clock, and random effects stay at explicit boundaries. @@ -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. 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. +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. Merging a `release/v*` pull request tags the merge commit, and the exact version tag runs the trusted attestation, draft, and approval-gated publish 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/docs/releasing.md b/docs/releasing.md index 4a0a7db..de2a853 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -22,28 +22,32 @@ Published releases are immutable. Their tag and assets cannot be replaced or del 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. +- the `release` environment accepts only `v*` tags and requires maintainer approval before the publish job receives write permission; and +- GitHub release immutability locks the tag and all assets at publication. -## Prepare the version +A version tag push builds, attests, and drafts the release without human involvement: draft creation and asset upload are not behind the approval gate. Drafts are invisible to users and carry no immutability, so their assets remain mutable until publication. The `release` environment therefore gates publication only, and the publish job re-derives the facts the approval relies on inside the gate: it downloads every draft asset, checks `SHA256SUMS`, and verifies each artifact attestation before anything goes live. -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. +## One-time setup -The tag must exactly match the Cargo package version. Prerelease suffixes and moving branch references are rejected. +Automatic tagging uses a dedicated credential because tags pushed with the default `GITHUB_TOKEN` do not trigger the tag-driven release workflow: -## Create the draft release +1. Create a fine-grained personal access token scoped to this repository only, with `Contents: Read and write` and no other permission, and a bounded expiration. +2. Store it as the `RELEASE_TOKEN` repository Actions secret. +3. When it expires, generate a replacement and overwrite the secret; nothing else changes. -From an up-to-date `main` checkout, create and push an annotated tag: +## Standard release + +A release is one command and two clicks: ```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 +scripts/prepare-release.py patch # or minor / major ``` +The script verifies a clean, up-to-date `main`, computes the next version, updates `Cargo.toml`, `Cargo.lock`, and the pinned installer links in `README.md`, and opens a pull request from a `release/v` branch. Because the pull request touches `Cargo.toml`, the release workflow also builds all four native target archives as a pre-release rehearsal. + +1. **Merge the release pull request** after the required `Core`, `Linux`, and `macOS` checks and the rehearsal pass. On merge, `create-release-tag.yml` validates that the branch name matches the Cargo version, tags the merge commit `v`, and deletes the release branch. The tag push starts the release workflow. +2. **Approve the `release` environment** in the workflow run once the build, assembly, and attestation jobs finish. Approval is the publication decision: the publish job re-verifies the six-asset contract against the draft and then publishes it. There is no separate manual publish step. + The tag-triggered release workflow: 1. verifies that the tag is exact SemVer, matches `Cargo.toml`, and points to `origin/main`; @@ -53,32 +57,33 @@ The tag-triggered release workflow: 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. +8. creates a draft release with generated release notes; +9. waits for approval of the `release` environment; and +10. downloads the draft assets, verifies the six-asset contract, `SHA256SUMS`, and every artifact attestation, and publishes the release. -The workflow refuses to replace assets on a published release. +The workflow refuses to touch a release that is already published. -## Verify and publish +## Inspecting before approval -After the workflow succeeds, inspect the draft release before publishing it: +The draft and its generated notes are visible on the Releases page before the environment is approved. For an independent check on a trusted machine, download the draft assets and verify them: ```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) +gh release download v --repo Sukitly/gitveil --dir release-review +(cd release-review && shasum -a 256 --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. +Release notes remain editable after publication; assets and the tag do not. ## 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. +- If `create-release-tag.yml` fails because `RELEASE_TOKEN` expired, refresh the secret and re-run the job; the tag step is idempotent. +- Manual fallback: the automated path is equivalent to tagging by hand. From an up-to-date `main` checkout of the release commit, `git tag -a v -m "Gitveil v" && git push origin v` starts the same tag-triggered workflow. - 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_version.py b/scripts/_lib/release_version.py new file mode 100644 index 0000000..9ee7f50 --- /dev/null +++ b/scripts/_lib/release_version.py @@ -0,0 +1,79 @@ +"""Pure version arithmetic and document rewriting for release preparation. + +The package version is defined as the `version` key inside the `[package]` +section of Cargo.toml. Reads and writes are anchored to that section so this +module agrees with the structured readers in the release workflows +(`tomllib` in create-release-tag.yml, `cargo metadata` in release.yml) even +when other sections carry their own `version` lines. +""" + +from __future__ import annotations + +import re + +_PACKAGE_HEADER_PATTERN = re.compile(r"^\[package\][ \t]*$", re.MULTILINE) +_SECTION_HEADER_PATTERN = re.compile(r"^\[", re.MULTILINE) +_VERSION_LINE_PATTERN = re.compile(r'^version = "(\d+)\.(\d+)\.(\d+)"$', re.MULTILINE) +_PINNED_DOWNLOAD_PATTERN = re.compile(r"releases/download/v\d+\.\d+\.\d+/") + + +class ReleaseVersionError(ValueError): + """A manifest or document does not carry the expected release shape.""" + + +def _package_section_span(manifest: str) -> tuple[int, int]: + header = _PACKAGE_HEADER_PATTERN.search(manifest) + if header is None: + raise ReleaseVersionError("Cargo.toml carries no [package] section") + start = header.end() + following = _SECTION_HEADER_PATTERN.search(manifest, start) + end = len(manifest) if following is None else following.start() + return start, end + + +def package_version(manifest: str) -> str: + """Returns the exact SemVer version of the `[package]` section.""" + start, end = _package_section_span(manifest) + match = _VERSION_LINE_PATTERN.search(manifest, start, end) + if match is None: + raise ReleaseVersionError( + "the [package] section carries no exact SemVer version" + ) + return ".".join(match.groups()) + + +def bumped_version(current: str, level: str) -> str: + """Returns `current` bumped by one `major`, `minor`, or `patch` step.""" + major, minor, patch = (int(part) for part in current.split(".")) + if level == "major": + return f"{major + 1}.0.0" + if level == "minor": + return f"{major}.{minor + 1}.0" + if level == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ReleaseVersionError(f"unknown bump level {level!r}") + + +def set_package_version(manifest: str, version: str) -> str: + """Rewrites the `[package]` version; other sections stay untouched.""" + start, end = _package_section_span(manifest) + section, count = _VERSION_LINE_PATTERN.subn( + f'version = "{version}"', manifest[start:end], count=1 + ) + if count != 1: + raise ReleaseVersionError( + "the [package] section carries no exact SemVer version" + ) + return manifest[:start] + section + manifest[end:] + + +def rewrite_pinned_downloads(document: str, version: str) -> str: + """Repoints `releases/download/vX.Y.Z/` links; `releases/latest` stays.""" + rewritten, count = _PINNED_DOWNLOAD_PATTERN.subn( + f"releases/download/v{version}/", document + ) + if count == 0: + raise ReleaseVersionError( + "the document carries no pinned release download links" + ) + return rewritten diff --git a/scripts/prepare-release.py b/scripts/prepare-release.py new file mode 100755 index 0000000..458b22b --- /dev/null +++ b/scripts/prepare-release.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Create the version-bump pull request that starts a release. + +Usage: scripts/prepare-release.py {patch|minor|major} + +The script verifies the local checkout is a clean, up-to-date main and that +every input it needs is present, then updates Cargo.toml, Cargo.lock, and +the pinned installer links in README.md and opens a pull request from a +release/v branch. All content and tool checks run before the first +write; a failure during the write phase rolls the three files back. After +the pull request merges, create-release-tag.yml tags the merge commit, and +the tag-triggered release workflow builds, attests, and publishes the +release once the release environment is approved. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from _lib.release_version import ( + ReleaseVersionError, + bumped_version, + package_version, + rewrite_pinned_downloads, + set_package_version, +) + +ROOT = Path(__file__).resolve().parent.parent +RELEASE_FILES = ("Cargo.toml", "Cargo.lock", "README.md") + + +class CommandError(Exception): + """An external command failed; the message names it and its stderr.""" + + +def fail(message: str) -> "sys.NoReturn": + print(f"error: {message}", file=sys.stderr) + sys.exit(1) + + +def run(*command: str, capture: bool = False) -> str: + result = subprocess.run( + command, + cwd=ROOT, + check=False, + text=True, + capture_output=capture, + ) + if result.returncode != 0: + detail = (result.stderr or "").strip() if capture else "" + suffix = f": {detail}" if detail else "" + raise CommandError(f"`{' '.join(command)}` failed{suffix}") + return (result.stdout or "").strip() if capture else "" + + +def restore(*command: str) -> None: + """Best-effort rollback step; a rollback must never mask the failure.""" + subprocess.run(command, cwd=ROOT, check=False, capture_output=True) + + +def ref_exists(ref: str) -> bool: + local = subprocess.run( + ["git", "rev-parse", "--quiet", "--verify", ref], + cwd=ROOT, + check=False, + capture_output=True, + ) + if local.returncode == 0: + return True + return bool(run("git", "ls-remote", "origin", ref, capture=True)) + + +def ensure_clean_main() -> None: + branch = run("git", "branch", "--show-current", capture=True) + if branch != "main": + fail(f"run from main; the current branch is {branch}") + if run("git", "status", "--porcelain", capture=True): + fail("the working tree has uncommitted changes") + run("git", "fetch", "origin", "main") + local = run("git", "rev-parse", "HEAD", capture=True) + remote = run("git", "rev-parse", "origin/main", capture=True) + if local != remote: + fail("local main is not up to date with origin/main; run `git pull --ff-only`") + + +def preflight(level: str) -> tuple[str, str, str, str]: + """Runs every check and content computation before the first write.""" + ensure_clean_main() + + manifest = (ROOT / "Cargo.toml").read_text(encoding="utf-8") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + try: + current = package_version(manifest) + version = bumped_version(current, level) + bumped_manifest = set_package_version(manifest, version) + rewritten_readme = rewrite_pinned_downloads(readme, version) + except ReleaseVersionError as error: + fail(str(error)) + + branch = f"release/v{version}" + if ref_exists(f"refs/heads/{branch}"): + fail(f"branch {branch} already exists") + if ref_exists(f"refs/tags/v{version}"): + fail(f"tag v{version} already exists") + + try: + run("cargo", "--version", capture=True) + except CommandError: + fail("cargo is not available on PATH") + try: + run("gh", "auth", "status", capture=True) + except CommandError: + fail("`gh auth status` failed; authenticate with `gh auth login` first") + + return current, version, bumped_manifest, rewritten_readme + + +def write_release_files(bumped_manifest: str, rewritten_readme: str) -> None: + """Applies the three-file bump; any failure restores all of them.""" + try: + (ROOT / "Cargo.toml").write_text(bumped_manifest, encoding="utf-8") + run("cargo", "update", "--quiet", "--package", "gitveil", capture=True) + (ROOT / "README.md").write_text(rewritten_readme, encoding="utf-8") + except (CommandError, OSError) as error: + restore("git", "checkout", "--", *RELEASE_FILES) + fail(f"{error}; Cargo.toml, Cargo.lock, and README.md were rolled back") + + +def publish_branch(branch: str, version: str) -> None: + try: + run("git", "switch", "-c", branch) + run("git", "add", *RELEASE_FILES) + run("git", "commit", "-m", f"Bump version to {version}") + except CommandError as error: + restore("git", "checkout", "--", *RELEASE_FILES) + restore("git", "switch", "main") + restore("git", "branch", "-D", branch) + fail(f"{error}; the working tree and branches were rolled back") + try: + run("git", "push", "-u", "origin", branch) + except CommandError as error: + restore("git", "switch", "main") + restore("git", "branch", "-D", branch) + fail(f"{error}; nothing was pushed and the local branch was removed") + + +def pull_request_body(current: str, version: str) -> str: + return f"""## Summary + +Prepares the v{version} release per `docs/releasing.md`: + +- `Cargo.toml` / `Cargo.lock`: version {current} -> {version}. +- README: pinned installer download links now reference `v{version}` (valid once the release is published). + +Because this pull request touches `Cargo.toml`, the release workflow also builds all four native target archives as a pre-release rehearsal. + +## After merge + +Merging this pull request tags the merge commit automatically and starts the tag-triggered release workflow. Approving the `release` environment in Actions publishes the verified release; no further manual step exists after the approval. +""" + + +def create_pull_request(branch: str, current: str, version: str) -> str: + try: + return run( + "gh", + "pr", + "create", + "--title", + f"Bump version to {version}", + "--body", + pull_request_body(current, version), + capture=True, + ) + except CommandError as error: + restore("git", "switch", "main") + fail( + f"{error}; the branch {branch} is already pushed. " + f"Retry with `gh pr create --head {branch}`, or abandon the " + f"release with `git push origin --delete {branch}` and " + f"`git branch -D {branch}`" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("level", choices=["patch", "minor", "major"]) + parser.add_argument( + "--yes", + action="store_true", + help="skip the interactive confirmation", + ) + arguments = parser.parse_args() + + try: + current, version, bumped_manifest, rewritten_readme = preflight(arguments.level) + except CommandError as error: + fail(str(error)) + branch = f"release/v{version}" + print(f"current version: {current}") + print(f"next version: {version}") + + if not arguments.yes: + answer = input(f"create the release pull request for v{version}? [y/N] ") + if answer.strip().lower() != "y": + print("release preparation cancelled") + return + + write_release_files(bumped_manifest, rewritten_readme) + publish_branch(branch, version) + url = create_pull_request(branch, current, version) + try: + run("git", "switch", "main") + except CommandError as error: + fail(f"{error}; the pull request exists: {url}") + print(f"release pull request: {url}") + print("next: merge the pull request; the tag and release run are automatic.") + print("then: approve the release environment in Actions to publish.") + + +if __name__ == "__main__": + main() diff --git a/tests/release_prepare_contract.rs b/tests/release_prepare_contract.rs new file mode 100644 index 0000000..0366615 --- /dev/null +++ b/tests/release_prepare_contract.rs @@ -0,0 +1,380 @@ +//! Failure-path contract for `scripts/prepare-release.py`: every content and +//! tool check runs before the first write, a write-phase failure rolls the +//! three release files back, and a push that loses its pull request leaves a +//! recoverable, documented state. The happy path past `gh pr create` +//! requires an authenticated GitHub CLI and is exercised by real releases, +//! not here; `gh` is stubbed only at that external boundary. + +pub mod support; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use support::command_output; + +const MANIFEST: &str = "[package]\n\ +name = \"gitveil\"\n\ +version = \"0.1.0\"\n\ +edition = \"2021\"\n"; + +const README: &str = "# Fixture\n\ +Pinned: https://github.com/Sukitly/gitveil/releases/download/v0.1.0/gitveil-installer.sh\n\ +Latest: https://github.com/Sukitly/gitveil/releases/latest/download/gitveil-installer.sh\n"; + +struct ReleaseFixture { + root: tempfile::TempDir, + home: tempfile::TempDir, + origin: tempfile::TempDir, + stub_path: Option, +} + +impl ReleaseFixture { + fn new(readme: &str, manifest: &str) -> Self { + let root = tempfile::tempdir().expect("fixture root"); + let home = tempfile::tempdir().expect("fixture home"); + let origin = tempfile::tempdir().expect("fixture origin"); + fs::write( + home.path().join(".gitconfig"), + "[user]\n\tname = Release Fixture\n\temail = release@example.invalid\n", + ) + .expect("fixture gitconfig"); + + fs::write(root.path().join("Cargo.toml"), manifest).expect("fixture manifest"); + fs::write(root.path().join("README.md"), readme).expect("fixture readme"); + fs::create_dir(root.path().join("src")).expect("fixture src"); + fs::write(root.path().join("src/main.rs"), "fn main() {}\n").expect("fixture main"); + let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .canonicalize() + .expect("repository root"); + let scripts = root.path().join("scripts"); + fs::create_dir_all(scripts.join("_lib")).expect("fixture scripts"); + for relative in [ + "scripts/prepare-release.py", + "scripts/_lib/__init__.py", + "scripts/_lib/release_version.py", + ] { + fs::copy(repository_root.join(relative), root.path().join(relative)) + .expect("copy release script"); + } + + let fixture = Self { + root, + home, + origin, + stub_path: None, + }; + fixture.git(&["init", "-q"]); + fixture.git(&["symbolic-ref", "HEAD", "refs/heads/main"]); + assert!( + fixture + .cargo(&["generate-lockfile", "--offline"]) + .status + .success(), + "fixture lockfile" + ); + fixture.git(&["add", "-A"]); + fixture.git(&["commit", "-qm", "fixture"]); + let origin_path = fixture.origin.path().join("origin.git"); + assert!( + fixture + .command("git") + .args(["init", "-q", "--bare"]) + .arg(&origin_path) + .output() + .expect("bare origin") + .status + .success() + ); + fixture.git(&[ + "remote", + "add", + "origin", + origin_path.to_str().expect("utf-8"), + ]); + fixture.git(&["push", "-qu", "origin", "main"]); + fixture + } + + /// Installs a `gh` stub: `auth status` succeeds, everything else follows + /// `pr_create_succeeds`. This is the external boundary the tests cannot + /// exercise for real. + fn stub_gh(&mut self, pr_create_succeeds: bool) { + use std::os::unix::fs::PermissionsExt; + + // The stub lives outside the repository so it never dirties the + // worktree the script is about to inspect. + let directory = self.home.path().join("stub-bin"); + fs::create_dir_all(&directory).expect("stub directory"); + let exit = i32::from(!pr_create_succeeds); + let body = format!( + "#!/bin/sh\nif [ \"$1\" = auth ]; then exit 0; fi\n\ + if [ \"$1\" = pr ]; then echo https://example.invalid/pr/1; exit {exit}; fi\nexit 1\n" + ); + let stub = directory.join("gh"); + fs::write(&stub, body).expect("stub gh"); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o700)).expect("stub mode"); + self.stub_path = Some(directory); + } + + fn command(&self, program: &str) -> Command { + let mut command = Command::new(program); + command + .current_dir(self.root.path()) + .env("HOME", self.home.path()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE"); + if let Some(stub) = &self.stub_path { + let path = std::env::var_os("PATH").expect("PATH"); + let mut entries = vec![stub.clone()]; + entries.extend(std::env::split_paths(&path)); + command.env("PATH", std::env::join_paths(entries).expect("join PATH")); + } + command + } + + fn git(&self, args: &[&str]) -> Output { + let output = command_output(self.command("git").args(args)); + assert!(output.status.success(), "git {args:?} must succeed"); + output + } + + fn cargo(&self, args: &[&str]) -> Output { + command_output(self.command("cargo").args(args)) + } + + fn prepare_release(&self, level: &str) -> Output { + command_output( + self.command("python3") + .arg("scripts/prepare-release.py") + .arg(level) + .arg("--yes"), + ) + } + + fn read(&self, path: &str) -> Vec { + fs::read(self.root.path().join(path)).expect("read fixture file") + } + + fn assert_untouched(&self, manifest: &str, readme: &str) { + let status = command_output(self.command("git").args(["status", "--porcelain"])); + assert!( + status.stdout.is_empty(), + "the working tree must stay clean: {}", + String::from_utf8_lossy(&status.stdout) + ); + let branch = command_output(self.command("git").args(["branch", "--show-current"])); + assert_eq!(String::from_utf8_lossy(&branch.stdout).trim(), "main"); + assert_eq!(self.read("Cargo.toml"), manifest.as_bytes()); + assert_eq!(self.read("README.md"), readme.as_bytes()); + } +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +#[test] +fn refusals_happen_before_any_write() { + // Off main. + let fixture = ReleaseFixture::new(README, MANIFEST); + fixture.git(&["switch", "-qc", "feature"]); + let output = fixture.prepare_release("patch"); + assert!(!output.status.success()); + assert!( + stderr_of(&output).contains("main"), + "{}", + stderr_of(&output) + ); + fixture.git(&["switch", "-q", "main"]); + fixture.assert_untouched(MANIFEST, README); + + // Dirty worktree. + fs::write(fixture.root.path().join("README.md"), "dirty\n").expect("dirty readme"); + let output = fixture.prepare_release("patch"); + assert!(!output.status.success()); + assert!( + stderr_of(&output).contains("uncommitted"), + "{}", + stderr_of(&output) + ); + fixture.git(&["checkout", "--", "README.md"]); + + // Behind origin/main. + fs::write(fixture.root.path().join("extra.txt"), "extra\n").expect("extra file"); + fixture.git(&["add", "extra.txt"]); + fixture.git(&["commit", "-qm", "extra"]); + fixture.git(&["push", "-q", "origin", "main"]); + fixture.git(&["reset", "-q", "--hard", "HEAD~1"]); + let output = fixture.prepare_release("patch"); + assert!(!output.status.success()); + assert!( + stderr_of(&output).contains("not up to date"), + "{}", + stderr_of(&output) + ); +} + +#[test] +fn a_readme_without_pinned_links_is_refused_before_the_first_write() { + let readme = "# Fixture\nLatest only: releases/latest/download/gitveil-installer.sh\n"; + let fixture = ReleaseFixture::new(readme, MANIFEST); + let output = fixture.prepare_release("patch"); + assert!(!output.status.success()); + assert!( + stderr_of(&output).contains("pinned release download links"), + "{}", + stderr_of(&output) + ); + fixture.assert_untouched(MANIFEST, readme); +} + +#[test] +fn an_existing_branch_or_tag_is_refused_before_the_first_write() { + let fixture = ReleaseFixture::new(README, MANIFEST); + fixture.git(&["tag", "v0.1.1"]); + let output = fixture.prepare_release("patch"); + assert!(!output.status.success()); + assert!( + stderr_of(&output).contains("tag v0.1.1 already exists"), + "{}", + stderr_of(&output) + ); + fixture.git(&["tag", "-d", "v0.1.1"]); + + fixture.git(&["push", "-q", "origin", "main:refs/heads/release/v0.1.1"]); + let output = fixture.prepare_release("patch"); + assert!(!output.status.success()); + assert!( + stderr_of(&output).contains("branch release/v0.1.1 already exists"), + "{}", + stderr_of(&output) + ); + fixture.assert_untouched(MANIFEST, README); +} + +// A write-phase failure (here: `cargo update` on a manifest whose dependency +// cannot resolve) must restore all three release files and leave main clean. +#[test] +fn a_write_phase_failure_rolls_back_every_release_file() { + // The lockfile is generated while the manifest is healthy; the broken + // dependency lands afterwards so only the script's `cargo update` fails. + let mut fixture = ReleaseFixture::new(README, MANIFEST); + let broken = format!("{MANIFEST}\n[dependencies]\nmissing = {{ path = \"does-not-exist\" }}\n"); + fs::write(fixture.root.path().join("Cargo.toml"), &broken).expect("break the manifest"); + fixture.git(&["add", "Cargo.toml"]); + fixture.git(&["commit", "-qm", "break dependency"]); + fixture.git(&["push", "-q", "origin", "main"]); + fixture.stub_gh(true); + let output = fixture.prepare_release("patch"); + assert!(!output.status.success()); + assert!( + stderr_of(&output).contains("rolled back"), + "the rollback must be reported: {}", + stderr_of(&output) + ); + fixture.assert_untouched(&broken, README); + let remote = command_output(fixture.command("git").args([ + "ls-remote", + "--heads", + "origin", + "release/v0.1.1", + ])); + assert!(remote.stdout.is_empty(), "nothing may be pushed"); +} + +// When the push succeeded but the pull request was not created, the state is +// recoverable and the error says exactly how. +#[test] +fn a_failed_pull_request_reports_the_recovery_commands() { + let mut fixture = ReleaseFixture::new(README, MANIFEST); + fixture.stub_gh(false); + let output = fixture.prepare_release("patch"); + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!( + stderr.contains("gh pr create --head release/v0.1.1"), + "retry command must be named: {stderr}" + ); + assert!( + stderr.contains("git push origin --delete release/v0.1.1"), + "abandon command must be named: {stderr}" + ); + // The branch is pushed (that is the recoverable state), and the local + // checkout is back on a clean main. + let remote = command_output(fixture.command("git").args([ + "ls-remote", + "--heads", + "origin", + "release/v0.1.1", + ])); + assert!(!remote.stdout.is_empty(), "the pushed branch must survive"); + let branch = command_output(fixture.command("git").args(["branch", "--show-current"])); + assert_eq!(String::from_utf8_lossy(&branch.stdout).trim(), "main"); + assert_eq!(fixture.read("Cargo.toml"), MANIFEST.as_bytes()); + assert_eq!(fixture.read("README.md"), README.as_bytes()); +} + +// The pure version logic agrees with the release contract: bumps, the +// [package]-anchored read/write, and the pinned-link rewrite that must not +// touch releases/latest. +#[test] +fn pure_version_logic_matches_the_release_contract() { + let script = r#" +import importlib.util +import sys + +spec = importlib.util.spec_from_file_location( + "release_version", "scripts/_lib/release_version.py" +) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +assert module.bumped_version("0.2.9", "patch") == "0.2.10" +assert module.bumped_version("0.2.9", "minor") == "0.3.0" +assert module.bumped_version("0.2.9", "major") == "1.0.0" + +manifest = ( + "[dependencies.other]\nversion = \"9.9.9\"\n\n" + "[package]\nname = \"gitveil\"\nversion = \"0.2.0\"\n\n" + "[dev-dependencies.more]\nversion = \"8.8.8\"\n" +) +assert module.package_version(manifest) == "0.2.0" +rewritten = module.set_package_version(manifest, "0.3.0") +assert 'version = "0.3.0"' in rewritten +assert 'version = "9.9.9"' in rewritten +assert 'version = "8.8.8"' in rewritten +assert 'version = "0.2.0"' not in rewritten + +document = ( + "releases/download/v0.2.0/gitveil-installer.sh\n" + "releases/latest/download/gitveil-installer.sh\n" +) +updated = module.rewrite_pinned_downloads(document, "0.3.0") +assert "releases/download/v0.3.0/" in updated +assert "releases/latest/download/" in updated + +for malformed in ("version = \"1.2.3\"\n", "[package]\nname = \"gitveil\"\n"): + try: + module.package_version(malformed) + except module.ReleaseVersionError: + pass + else: + raise AssertionError(f"malformed manifest accepted: {malformed!r}") + +print("OK") +"#; + let output = command_output( + Command::new("python3") + .arg("-c") + .arg(script) + .current_dir(env!("CARGO_MANIFEST_DIR")), + ); + assert!( + output.status.success(), + "pure logic assertions failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("OK")); +}