From 71782f14a848e01996b8b4fdfddd3e79ebc1f545 Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 17:54:00 +0800 Subject: [PATCH 1/2] Automate the release flow to one command and two clicks scripts/prepare-release.py opens the version-bump pull request from a release/v branch after verifying a clean, up-to-date main; it updates Cargo.toml, Cargo.lock, and the pinned installer links in the README. Merging that pull request triggers create-release-tag.yml, which validates the branch/version match and tags the merge commit with the dedicated RELEASE_TOKEN credential (GITHUB_TOKEN tag pushes do not trigger the release workflow). The release environment gate moves from the draft job to a new publish job: approval is now the publication decision, and the publish job re-verifies the six-asset contract before flipping the draft live. docs/releasing.md documents the new flow, the one-time token setup, and the manual fallback. --- .github/workflows/create-release-tag.yml | 80 +++++++++++ .github/workflows/release.yml | 53 ++++++- CONTRIBUTING.md | 2 +- docs/releasing.md | 49 ++++--- scripts/prepare-release.py | 176 +++++++++++++++++++++++ 5 files changed, 333 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/create-release-tag.yml create mode 100755 scripts/prepare-release.py diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml new file mode 100644 index 0000000..ef38060 --- /dev/null +++ b/.github/workflows/create-release-tag.yml @@ -0,0 +1,80 @@ +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 "release branch $HEAD_BRANCH does not match Cargo version $version" >&2 + exit 1 + fi + if [[ -n "$(git ls-remote origin "refs/tags/v$version")" ]]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "tag v$version already exists; nothing to do" + 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..7f6b301 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: @@ -315,3 +312,53 @@ jobs: fi gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '"url=" + .url' \ >> "$GITHUB_OUTPUT" + + 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: + - 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 + mapfile -t assets < <(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '.assets[].name' | 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 + + - 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..f4c1027 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. 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..819d8f3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -22,28 +22,30 @@ 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 +## One-time setup -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. +Automatic tagging uses a dedicated credential because tags pushed with the default `GITHUB_TOKEN` do not trigger the tag-driven release workflow: -The tag must exactly match the Cargo package version. Prerelease suffixes and moving branch references are rejected. +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. -## Create the draft release +## Standard release -From an up-to-date `main` checkout, create and push an annotated tag: +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 +55,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. verifies the draft still matches the six-asset contract and publishes it. -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/prepare-release.py b/scripts/prepare-release.py new file mode 100755 index 0000000..0ad5002 --- /dev/null +++ b/scripts/prepare-release.py @@ -0,0 +1,176 @@ +#!/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, 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. After that 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. +""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VERSION_PATTERN = re.compile(r'^version = "(\d+)\.(\d+)\.(\d+)"$', re.MULTILINE) +PINNED_DOWNLOAD_PATTERN = re.compile(r"releases/download/v\d+\.\d+\.\d+/") + + +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 "" + fail(f"`{' '.join(command)}` failed{suffix}") + return (result.stdout or "").strip() if capture else "" + + +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 + remote = run("git", "ls-remote", "origin", ref, capture=True) + return bool(remote) + + +def current_version() -> tuple[int, int, int]: + manifest = (ROOT / "Cargo.toml").read_text(encoding="utf-8") + match = VERSION_PATTERN.search(manifest) + if match is None: + fail("Cargo.toml carries no exact SemVer package version") + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def next_version(level: str) -> tuple[str, str]: + major, minor, patch = current_version() + current = f"{major}.{minor}.{patch}" + if level == "major": + return current, f"{major + 1}.0.0" + if level == "minor": + return current, f"{major}.{minor + 1}.0" + return current, f"{major}.{minor}.{patch + 1}" + + +def ensure_ready() -> 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 rewrite_files(current: str, version: str) -> None: + manifest_path = ROOT / "Cargo.toml" + manifest = manifest_path.read_text(encoding="utf-8") + updated = manifest.replace( + f'version = "{current}"', f'version = "{version}"', 1 + ) + if updated == manifest: + fail(f"Cargo.toml does not carry version {current}") + manifest_path.write_text(updated, encoding="utf-8") + + run("cargo", "update", "--quiet", "--package", "gitveil") + + readme_path = ROOT / "README.md" + readme = readme_path.read_text(encoding="utf-8") + rewritten = PINNED_DOWNLOAD_PATTERN.sub( + f"releases/download/v{version}/", readme + ) + if rewritten == readme: + fail("README.md carries no pinned installer download links to update") + readme_path.write_text(rewritten, encoding="utf-8") + + +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 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() + + ensure_ready() + current, version = next_version(arguments.level) + branch = f"release/v{version}" + print(f"current version: {current}") + print(f"next version: {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") + + 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 + + rewrite_files(current, version) + run("git", "switch", "-c", branch) + run("git", "add", "Cargo.toml", "Cargo.lock", "README.md") + run("git", "commit", "-m", f"Bump version to {version}") + run("git", "push", "-u", "origin", branch) + url = run( + "gh", + "pr", + "create", + "--title", + f"Bump version to {version}", + "--body", + pull_request_body(current, version), + capture=True, + ) + run("git", "switch", "main") + 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() From d2eeafdbfc391dce2a68cd427a32e53abfd772b6 Mon Sep 17 00:00:00 2001 From: Shuqian Date: Mon, 24 Aug 2026 18:21:49 +0800 Subject: [PATCH 2/2] Respond to review: verify bytes at the publish gate, fail loudly on taken versions The publish job now re-derives the facts approval relies on inside the gate: it downloads every draft asset, checks SHA256SUMS, and verifies each artifact attestation, because draft assets stay mutable between draft creation and approval. docs/releasing.md discloses that a tag push drafts the release unattended and the environment gates publication only. create-release-tag treats an existing tag as a safe no-op only when it already points at the merge commit; a tag pointing elsewhere fails with guidance to bump again, and the branch/version mismatch error explains the reserved release/ prefix, which is now documented in CONTRIBUTING.md. prepare-release.py fronts every content and tool check (including gh auth) before the first write, rolls the three release files back on a write-phase failure, and prints exact recovery commands when the pull request cannot be created after the push; its version arithmetic and [package]-anchored rewriting moved to scripts/_lib/release_version.py so the writer agrees with the structured readers in the workflows. tests/release_prepare_contract.rs covers the refusal paths, the rollback, the post-push recovery state, and the pure version logic. The dead url output in the draft job is removed. --- .github/workflows/create-release-tag.yml | 18 +- .github/workflows/release.yml | 16 +- CONTRIBUTING.md | 2 +- docs/releasing.md | 4 +- scripts/_lib/release_version.py | 79 +++++ scripts/prepare-release.py | 193 +++++++----- tests/release_prepare_contract.rs | 380 +++++++++++++++++++++++ 7 files changed, 610 insertions(+), 82 deletions(-) create mode 100644 scripts/_lib/release_version.py create mode 100644 tests/release_prepare_contract.rs diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml index ef38060..1dc0db9 100644 --- a/.github/workflows/create-release-tag.yml +++ b/.github/workflows/create-release-tag.yml @@ -46,12 +46,24 @@ jobs: exit 1 fi if [[ "$HEAD_BRANCH" != "release/v$version" ]]; then - echo "release branch $HEAD_BRANCH does not match Cargo version $version" >&2 + 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 - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "tag v$version already exists; nothing to do" + # 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f6b301..d3a37d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -288,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 }} @@ -310,8 +309,6 @@ jobs: --generate-notes \ --title "Gitveil $RELEASE_TAG" fi - gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json url --jq '"url=" + .url' \ - >> "$GITHUB_OUTPUT" publish: name: Publish release @@ -327,6 +324,10 @@ jobs: 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 }} @@ -338,8 +339,9 @@ jobs: echo "release $RELEASE_TAG is not a draft; refusing to touch a published release" >&2 exit 1 fi - mapfile -t assets < <(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --json assets --jq '.assets[].name' | sort) + 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" \ @@ -352,6 +354,10 @@ jobs: 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4c1027..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. diff --git a/docs/releasing.md b/docs/releasing.md index 819d8f3..de2a853 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -25,6 +25,8 @@ Repository settings enforce the publication boundary: - 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. +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. + ## One-time setup Automatic tagging uses a dedicated credential because tags pushed with the default `GITHUB_TOKEN` do not trigger the tag-driven release workflow: @@ -57,7 +59,7 @@ The tag-triggered release workflow: 7. attests all six final assets; 8. creates a draft release with generated release notes; 9. waits for approval of the `release` environment; and -10. verifies the draft still matches the six-asset contract and publishes it. +10. downloads the draft assets, verifies the six-asset contract, `SHA256SUMS`, and every artifact attestation, and publishes the release. The workflow refuses to touch a release that is already published. 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 index 0ad5002..458b22b 100755 --- a/scripts/prepare-release.py +++ b/scripts/prepare-release.py @@ -3,23 +3,37 @@ Usage: scripts/prepare-release.py {patch|minor|major} -The script verifies the local checkout is 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. After that 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. +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 re 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 -VERSION_PATTERN = re.compile(r'^version = "(\d+)\.(\d+)\.(\d+)"$', re.MULTILINE) -PINNED_DOWNLOAD_PATTERN = re.compile(r"releases/download/v\d+\.\d+\.\d+/") +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": @@ -38,10 +52,15 @@ def run(*command: str, capture: bool = False) -> str: if result.returncode != 0: detail = (result.stderr or "").strip() if capture else "" suffix = f": {detail}" if detail else "" - fail(f"`{' '.join(command)}` failed{suffix}") + 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], @@ -51,29 +70,10 @@ def ref_exists(ref: str) -> bool: ) if local.returncode == 0: return True - remote = run("git", "ls-remote", "origin", ref, capture=True) - return bool(remote) - - -def current_version() -> tuple[int, int, int]: - manifest = (ROOT / "Cargo.toml").read_text(encoding="utf-8") - match = VERSION_PATTERN.search(manifest) - if match is None: - fail("Cargo.toml carries no exact SemVer package version") - return int(match.group(1)), int(match.group(2)), int(match.group(3)) - - -def next_version(level: str) -> tuple[str, str]: - major, minor, patch = current_version() - current = f"{major}.{minor}.{patch}" - if level == "major": - return current, f"{major + 1}.0.0" - if level == "minor": - return current, f"{major}.{minor + 1}.0" - return current, f"{major}.{minor}.{patch + 1}" + return bool(run("git", "ls-remote", "origin", ref, capture=True)) -def ensure_ready() -> None: +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}") @@ -86,26 +86,65 @@ def ensure_ready() -> None: fail("local main is not up to date with origin/main; run `git pull --ff-only`") -def rewrite_files(current: str, version: str) -> None: - manifest_path = ROOT / "Cargo.toml" - manifest = manifest_path.read_text(encoding="utf-8") - updated = manifest.replace( - f'version = "{current}"', f'version = "{version}"', 1 - ) - if updated == manifest: - fail(f"Cargo.toml does not carry version {current}") - manifest_path.write_text(updated, encoding="utf-8") +def preflight(level: str) -> tuple[str, str, str, str]: + """Runs every check and content computation before the first write.""" + ensure_clean_main() - run("cargo", "update", "--quiet", "--package", "gitveil") + 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)) - readme_path = ROOT / "README.md" - readme = readme_path.read_text(encoding="utf-8") - rewritten = PINNED_DOWNLOAD_PATTERN.sub( - f"releases/download/v{version}/", readme - ) - if rewritten == readme: - fail("README.md carries no pinned installer download links to update") - readme_path.write_text(rewritten, encoding="utf-8") + 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: @@ -124,6 +163,28 @@ def pull_request_body(current: str, version: str) -> str: """ +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"]) @@ -134,39 +195,27 @@ def main() -> None: ) arguments = parser.parse_args() - ensure_ready() - current, version = next_version(arguments.level) + 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 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") - 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 - rewrite_files(current, version) - run("git", "switch", "-c", branch) - run("git", "add", "Cargo.toml", "Cargo.lock", "README.md") - run("git", "commit", "-m", f"Bump version to {version}") - run("git", "push", "-u", "origin", branch) - url = run( - "gh", - "pr", - "create", - "--title", - f"Bump version to {version}", - "--body", - pull_request_body(current, version), - capture=True, - ) - run("git", "switch", "main") + 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.") 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")); +}