diff --git a/CHANGELOG.md b/CHANGELOG.md index 5570c9d..08cc206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Merged branches are deleted by default, and release tags can be reset on purpose.** + `scripts/apply-rulesets.sh` now also sets `delete_branch_on_merge` to `true` with `--apply` + (`--keep-branches` sets it to `false`); with the branch ruleset requiring passing checks, a branch + is deleted only after a merge that passed them. `scripts/prune-merged-branches.sh [CHECKOUT] + [--apply]` removes the local copies: after `git fetch --prune` it deletes a branch only when its + upstream is gone and its tip is on the default branch, keeps the current, default, never-pushed + and unmerged ones with the reason printed, and removes each deleted branch's config section. + `scripts/reset-release-tags.py OWNER/REPO (--move | --fresh) vX.Y.Z --to SHA` moves one tag, or + replaces every `v*`/`src/v*` tag and release with one; it is a dry run listing every tag, release + and cached copy it contradicts (Packagist, Composer dist caches, the Go proxy), needs + `--apply --confirm OWNER/REPO` typed exactly, refuses a commit not on the default branch, never + deletes a non-version tag, and disables the "Protect release tags" ruleset only for the work, + re-enabling it in a `finally`. See `docs/tools/repository-rulesets.md`. - **`scripts/apply-tag-ruleset.py` and `scripts/apply-rulesets.sh` — branch and release-tag protection for any repository in one command.** The tag script creates or updates, by name, a ruleset blocking update and deletion of `v*` and `src/v*`; the wrapper runs it after diff --git a/README.md b/README.md index c18e92d..c535208 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Hosted at **[opensource.simtabi.com/documentation/laranail/package-tools](https: - [Public names](docs/tools/public-names.md) — why views take `vendor/package::` while Blade tags cannot - [Publishing](docs/tools/publishing.md) — publish tags, asset groups and orphan pruning - [Rate limiters](docs/tools/rate-limiters.md) — fluent rate limiters -- [Repository rulesets](docs/tools/repository-rulesets.md) — one command to protect any repository's default branch and release tags +- [Repository rulesets](docs/tools/repository-rulesets.md) — protect any repository's default branch and release tags, delete merged branches, and reset tags on purpose - [Route name aliases](docs/tools/route-name-aliases.md) — deprecated bare route names that still resolve, and `has()` for them - [Resilience](docs/tools/resilience.md) — retries, backoff and circuit breaking - [Runtime services](docs/tools/runtime-services.md) — the services the toolkit resolves at runtime diff --git a/docs/tools/repository-rulesets.md b/docs/tools/repository-rulesets.md index 5c922a1..768be71 100644 --- a/docs/tools/repository-rulesets.md +++ b/docs/tools/repository-rulesets.md @@ -1,7 +1,8 @@ # Repository rulesets -Two scripts protect any GitHub repository's default branch and its release tags, and one command -applies both. +Two scripts protect any GitHub repository's default branch and its release tags, one command +applies both and turns on branch deletion after merge, and two more prune merged local branches +and reset release tags on purpose. ## The two rulesets @@ -38,6 +39,10 @@ placeholders: zsh reads `` as a redirect and stops with `parse error near into `~/.local/share/py-ruleset` (override with `APPLY_RULESETS_VENV`), never into the system Python. - **The personal `gh` login.** The GitHub App token cannot manage rulesets. +- **Merged branches are deleted by default.** With `--apply` the wrapper also sets the + repository's `delete_branch_on_merge` to `true`, so GitHub deletes a pull request's branch once + it merges. Pass `--keep-branches` to set it to `false` instead. A dry run prints the value it + would set and changes nothing. Read the result back: @@ -48,17 +53,77 @@ gh api repos/laranail/settings/rulesets --jq '.[] | "\(.id) \(.target) \(.enforc The branch must list `deletion`, `non_fast_forward`, `pull_request` and `required_status_checks`. +## Deleting branches after merge + +`delete_branch_on_merge` is a repository setting, not a ruleset, so the wrapper sets it before the +rulesets and it works on a free-plan private repository too. The branch ruleset only lets a pull +request merge once its required checks pass, so on a repository carrying both, a branch is deleted +only after a merge that passed validation and tests. Opt out per repository: + +```bash +cd ~/Artisan/projects/opensource && laranail/packages/package-tools/scripts/apply-rulesets.sh laranail/settings --keep-branches --apply +``` + +GitHub deletes only the remote branch. `scripts/prune-merged-branches.sh` removes the local copy: + +```bash +laranail/packages/package-tools/scripts/prune-merged-branches.sh laranail/packages/settings +laranail/packages/package-tools/scripts/prune-merged-branches.sh laranail/packages/settings --apply +``` + +It runs `git fetch --prune`, then deletes a local branch only when its upstream is gone **and** its +tip is on `origin/` (or `git cherry` finds nothing missing from it), along with its +`branch.` config section. It keeps the current branch, the default branch, a branch that was +never pushed, a branch whose upstream still exists, and a gone branch whose work is not on the +default branch, and prints the reason for each. A squash-merged branch is kept, because its +commits are not on the default branch. Without `--apply` it only prints. + ## Private repositories Rulesets are unavailable on private repositories on the GitHub Free plan: the API answers `403 Upgrade to GitHub Pro` and both scripts exit `3` with a message. Hold the same rules by practice there. -## Moving a tag on purpose +## Resetting tags on purpose + +The tag ruleset blocks every move, including a deliberate one, because a published tag is already +cached. `scripts/reset-release-tags.py` is the one sanctioned way through. Use it only for a planned +history reset or re-tag that the owner explicitly approved, after reading what its dry run says the +change contradicts. + +Re-point one tag (and its `src/` alias, if there is one) at a commit: + +```bash +cd ~/Artisan/projects/opensource && laranail/packages/package-tools/scripts/reset-release-tags.py laranail/settings --move v0.1.0 --to 588b6b8 +``` + +Replace every `v*` and `src/v*` tag, and their GitHub releases, with one fresh tag: + +```bash +cd ~/Artisan/projects/opensource && laranail/packages/package-tools/scripts/reset-release-tags.py laranail/settings --fresh v0.1.0 --to 588b6b8 +``` + +Both are dry runs. The dry run prints every tag it would move or delete with its current and new +SHAs, every release it would delete, the tags it leaves alone (anything that is not `vX.Y.Z` or +`src/vX.Y.Z`, such as `v1` or `latest`), and the cached copies that would then disagree with git: +the Packagist listing (`https://repo.packagist.org/p2/laranail/settings.json` and its HTTP status), +every Composer dist cache, which keeps the old archive until `composer clear-cache`, and the Go +proxy when `src/v*` tags are involved. To carry it out, add `--apply` and the repository, typed +exactly: + +```bash +cd ~/Artisan/projects/opensource && laranail/packages/package-tools/scripts/reset-release-tags.py laranail/settings --fresh v0.1.0 --to 588b6b8 --apply --confirm laranail/settings +``` -The tag ruleset blocks every move, including a deliberate one. Disable it, move the tag, then run -the wrapper again with `--apply` to restore it. A move is the owner's decision, after a checklist -naming every cached copy of the old tag it will contradict. +- **The commit must be on the default branch.** It is checked with GitHub's compare endpoint, and + anything ahead of or diverged from the default branch is refused. +- **The ruleset is bracketed.** "Protect release tags" is set to `disabled` for the work and back + to `active` in a `finally`, even when a call fails part-way, and its state is printed at the end. +- **`--move` updates the ref in place**, so the tag's GitHub release stays published instead of + reverting to a draft. `--fresh` deletes the releases, deletes the tags and creates one tag. +- **The release workflow runs again by itself**, because the new tag arrives on GitHub as a push. +- **Only version tags are ever deleted.** +- **The personal `gh` login**, as for the rulesets. ## Inside a Claude Code sandbox diff --git a/scripts/apply-rulesets.sh b/scripts/apply-rulesets.sh index dae975b..69ccbb6 100755 --- a/scripts/apply-rulesets.sh +++ b/scripts/apply-rulesets.sh @@ -1,27 +1,51 @@ #!/usr/bin/env bash # Apply both repository rulesets to any GitHub repository: the default-branch ruleset # (pull request + passing checks, no force-push or deletion) and the release-tag ruleset -# (v* and src/v* cannot be moved or deleted). +# (v* and src/v* cannot be moved or deleted). It also turns on GitHub's +# delete_branch_on_merge, so a branch is deleted once its pull request merges. # # Usage: -# apply-rulesets.sh OWNER/REPO [--pr NUMBER] [--apply] +# apply-rulesets.sh OWNER/REPO [--pr NUMBER] [--keep-branches] [--apply] # # Dry run by default. Runs from any directory. Re-running with --apply updates both # rulesets in place, which is how the required checks are refreshed after a workflow -# is added or renamed. Needs the personal `gh` login (the app token cannot manage -# rulesets). The branch script needs PyYAML; it is installed once into its own -# environment ($APPLY_RULESETS_VENV, default ~/.local/share/py-ruleset), never into the -# system Python. +# is added or renamed. --keep-branches sets delete_branch_on_merge to false instead. +# A pull request can only merge once its required checks pass, so a branch is deleted +# only after a merge that passed validation and tests. Needs the personal `gh` login +# (the app token cannot manage rulesets). The branch script needs PyYAML; it is +# installed once into its own environment ($APPLY_RULESETS_VENV, default +# ~/.local/share/py-ruleset), never into the system Python. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" venv="${APPLY_RULESETS_VENV:-$HOME/.local/share/py-ruleset}" if [ "$#" -lt 1 ] || [[ "$1" != */* ]]; then - sed -n '6,7p' "$0" | sed 's/^# //' >&2 + sed -n '7,8p' "$0" | sed 's/^# //' >&2 exit 2 fi +repo="$1" +apply=0 +delete_on_merge=true +branch_args=() +for arg in "$@"; do + case "$arg" in + --keep-branches) delete_on_merge=false ;; + --apply) apply=1; branch_args+=("$arg") ;; + *) branch_args+=("$arg") ;; + esac +done + +# The repository setting first: it is not a ruleset, so it also works on a free-plan +# private repository, where the ruleset scripts stop with exit 3. +if [ "$apply" -eq 1 ]; then + gh api -X PATCH "repos/${repo}" -F "delete_branch_on_merge=${delete_on_merge}" --jq '.delete_branch_on_merge' >/dev/null + echo "${repo}: delete_branch_on_merge=${delete_on_merge}" +else + echo "${repo}: would set delete_branch_on_merge=${delete_on_merge} (dry run)" +fi + if ! [ -x "$venv/bin/python" ]; then python3 -m venv "$venv" fi @@ -29,11 +53,9 @@ if ! "$venv/bin/python" -c 'import yaml' 2>/dev/null; then "$venv/bin/pip" install --quiet --disable-pip-version-check pyyaml fi -"$venv/bin/python" "$here/apply-branch-ruleset.py" "$@" +"$venv/bin/python" "$here/apply-branch-ruleset.py" "${branch_args[@]}" # The tag script takes no --pr; pass only the repository and --apply. -tag_args=("$1") -for arg in "$@"; do - [ "$arg" = "--apply" ] && tag_args+=("--apply") -done +tag_args=("$repo") +[ "$apply" -eq 1 ] && tag_args+=("--apply") "$venv/bin/python" "$here/apply-tag-ruleset.py" "${tag_args[@]}" diff --git a/scripts/prune-merged-branches.sh b/scripts/prune-merged-branches.sh new file mode 100755 index 0000000..9ebf7b8 --- /dev/null +++ b/scripts/prune-merged-branches.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Delete local branches whose remote branch is gone and whose work is on the default branch. +# +# Usage: +# prune-merged-branches.sh [CHECKOUT] [--apply] +# +# The local half of "delete a branch after it merges". GitHub deletes the remote branch on +# merge (delete_branch_on_merge, set by apply-rulesets.sh); this removes the local copy once +# `git fetch --prune` shows the remote one gone. A branch is deleted only when its upstream is +# gone AND its tip is contained in origin/ (or `git cherry` finds no commit missing +# from it). Kept, with the reason printed: the current branch, the default branch, a branch +# that was never pushed, a branch whose upstream still exists, and a gone branch whose work is +# not on the default branch. Dry run unless --apply. Deleting also removes the branch's +# `branch.` config section. +set -euo pipefail + +checkout="." +apply=0 +for arg in "$@"; do + case "$arg" in + --apply) apply=1 ;; + -*) sed -n '4,5p' "$0" | sed 's/^# //' >&2; exit 2 ;; + *) checkout="$arg" ;; + esac +done + +if ! git -C "$checkout" rev-parse --git-dir >/dev/null 2>&1; then + echo "$checkout: not a git checkout." >&2 + exit 2 +fi + +git -C "$checkout" fetch --prune --quiet origin + +default="$(git -C "$checkout" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" +default="${default#origin/}" +if [ -z "$default" ]; then + for candidate in main master; do + if git -C "$checkout" show-ref --verify --quiet "refs/remotes/origin/${candidate}"; then + default="$candidate" + break + fi + done +fi +if [ -z "$default" ]; then + echo "$checkout: cannot tell the default branch (no origin/HEAD, origin/main or origin/master)." >&2 + exit 1 +fi + +current="$(git -C "$checkout" symbolic-ref --quiet --short HEAD || true)" +base="origin/${default}" +deleted=0 +kept=0 + +while IFS=$'\t' read -r branch upstream track; do + reason="" + if [ "$branch" = "$current" ]; then + reason="current branch" + elif [ "$branch" = "$default" ]; then + reason="default branch" + elif [ -z "$upstream" ]; then + reason="never pushed (no upstream)" + elif [ "$track" != "[gone]" ]; then + reason="upstream ${upstream} still exists" + elif git -C "$checkout" merge-base --is-ancestor "$branch" "$base"; then + reason="" + elif [ -z "$(git -C "$checkout" cherry "$base" "$branch" | grep '^+' || true)" ]; then + reason="" + else + reason="upstream gone, but not merged into ${base}" + fi + + if [ -n "$reason" ]; then + printf 'keep %s (%s)\n' "$branch" "$reason" + kept=$((kept + 1)) + continue + fi + + printf 'delete %s (upstream gone, merged into %s)\n' "$branch" "$base" + deleted=$((deleted + 1)) + if [ "$apply" -eq 1 ]; then + git -C "$checkout" branch -D --quiet "$branch" + git -C "$checkout" config --remove-section "branch.${branch}" 2>/dev/null || true + fi +done < <(git -C "$checkout" for-each-ref --format='%(refname:short)%09%(upstream:short)%09%(upstream:track)' refs/heads) + +if [ "$apply" -eq 1 ]; then + echo "Deleted ${deleted}, kept ${kept}." +else + echo "Dry run: would delete ${deleted}, keep ${kept}. Re-run with --apply." +fi diff --git a/scripts/reset-release-tags.py b/scripts/reset-release-tags.py new file mode 100755 index 0000000..2ca5ce9 --- /dev/null +++ b/scripts/reset-release-tags.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Reset a repository's release tags on purpose: move one tag, or replace them all with one. + +Release tags are final. A version tag is cached the first time anything fetches it +(Packagist, Composer's dist cache, the Go proxy and sum.golang.org, npm), and moving +or deleting it afterwards only makes those caches disagree with git. The +"Protect release tags" ruleset blocks it for that reason. This script is the one +sanctioned way through, for a planned history reset or re-tag the owner approved. + +Two modes: + + --move vX.Y.Z --to SHA re-point one existing tag (and its src/vX.Y.Z alias, if + there is one). The ref is updated in place, so its GitHub + release stays published rather than reverting to a draft. + --fresh vX.Y.Z --to SHA delete every v* and src/v* version tag and their GitHub + releases, then create the one fresh tag. + +Either way the tag lands on GitHub as a push, so the tag-driven release workflow +runs again on its own; nothing else is needed. + +Safety: + + - Dry run by default. It lists every tag and release it would delete or move, with + SHAs, and every cached copy that would then disagree with git. + - --apply also needs --confirm OWNER/REPO, typed exactly, or it refuses. + - SHA must be on the default branch (GitHub's compare endpoint), or it refuses. + - The "Protect release tags" ruleset is disabled for the work and re-enabled in a + `finally`, even when the work fails. Its state is printed at the end. + - Only version tags (v1.2.3, src/v1.2.3, with an optional -pre-release suffix) + are ever deleted. Every other tag is listed as untouched. + +PACKAGIST_BASE overrides https://repo.packagist.org for the cache probe (tests). + +Usage: + reset-release-tags.py OWNER/REPO (--move TAG | --fresh TAG) --to SHA [--apply --confirm OWNER/REPO] +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +RULESET = "Protect release tags" +VERSION_TAG = re.compile(r"^(src/)?v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$") +SHA = re.compile(r"^[0-9a-f]{7,40}$") + + +def usage() -> int: + print(__doc__.split("Usage:")[1].strip(), file=sys.stderr) + return 2 + + +def gh(*args: str, stdin: str | None = None) -> str: + result = subprocess.run(["gh", *args], input=stdin, capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or f"gh {' '.join(args)} failed") + return result.stdout + + +def gh_pages(path: str) -> list: + """Every item of a paginated array endpoint. `gh --paginate` prints one array per page.""" + text = gh("api", "--paginate", path) + decoder, items, i = json.JSONDecoder(), [], 0 + while i < len(text): + if text[i].isspace(): + i += 1 + continue + page, i = decoder.raw_decode(text, i) + items.extend(page) + return items + + +def tags(repo: str) -> dict[str, str]: + """Tag name -> commit SHA, dereferencing annotated tags.""" + out = {} + for ref in gh_pages(f"repos/{repo}/git/matching-refs/tags"): + name = ref["ref"].removeprefix("refs/tags/") + sha = ref["object"]["sha"] + if ref["object"]["type"] == "tag": + sha = json.loads(gh("api", f"repos/{repo}/git/tags/{sha}"))["object"]["sha"] + out[name] = sha + return out + + +def on_default_branch(repo: str, sha: str) -> tuple[str, str | None]: + """(default branch, full SHA) when SHA is on it, else (default branch, None).""" + branch = json.loads(gh("api", f"repos/{repo}"))["default_branch"] + compare = json.loads(gh("api", f"repos/{repo}/compare/{branch}...{sha}")) + if compare["status"] not in ("behind", "identical"): + return branch, None + return branch, compare["merge_base_commit"]["sha"] + + +def packagist(repo: str) -> str: + base = os.environ.get("PACKAGIST_BASE", "https://repo.packagist.org") + url = f"{base}/p2/{repo.lower()}.json" + try: + with urllib.request.urlopen(url, timeout=10) as response: + return f"{getattr(response, 'status', 200) or 200} (listed)" + except urllib.error.HTTPError as e: + return f"{e.code} (not listed)" if e.code == 404 else f"{e.code}" + except (urllib.error.URLError, OSError) as e: + return f"unknown ({e})" + + +def find_ruleset(repo: str) -> dict | None: + try: + rulesets = json.loads(gh("api", f"repos/{repo}/rulesets?includes_parents=false")) + except RuntimeError as e: + if "403" in str(e) or "Upgrade to GitHub Pro" in str(e): + return None + raise + return next((r for r in rulesets if r["name"] == RULESET and r["target"] == "tag"), None) + + +def set_enforcement(repo: str, ruleset_id: int, enforcement: str) -> None: + gh("api", "-X", "PUT", f"repos/{repo}/rulesets/{ruleset_id}", "--input", "-", + stdin=json.dumps({"enforcement": enforcement})) + + +def parse(args: list[str]) -> dict | None: + opts = {"apply": "--apply" in args} + taking = {"--move", "--fresh", "--to", "--confirm"} + positional = [] + i = 0 + while i < len(args): + a = args[i] + if a in taking: + if i + 1 >= len(args): + return None + opts[a[2:]] = args[i + 1] + i += 2 + continue + if a.startswith("--") and a != "--apply": + return None + if a != "--apply": + positional.append(a) + i += 1 + if len(positional) != 1 or "/" not in positional[0]: + return None + if ("move" in opts) == ("fresh" in opts) or "to" not in opts: + return None + opts["repo"] = positional[0] + return opts + + +def main(argv: list[str]) -> int: + opts = parse(argv[1:]) + if opts is None: + return usage() + repo, apply = opts["repo"], opts["apply"] + mode = "move" if "move" in opts else "fresh" + tag = opts[mode] + + if not VERSION_TAG.match(tag) or tag.startswith("src/"): + print(f"{tag!r} is not a version tag (vX.Y.Z).", file=sys.stderr) + return 2 + if not SHA.match(opts["to"]): + print(f"{opts['to']!r} is not a commit SHA.", file=sys.stderr) + return 2 + if apply and opts.get("confirm") != repo: + print(f"Refusing: --apply needs --confirm {repo}, typed exactly.", file=sys.stderr) + return 2 + + branch, sha = on_default_branch(repo, opts["to"]) + if sha is None: + print(f"Refusing: {opts['to']} is not on {repo}'s default branch {branch}.", file=sys.stderr) + return 1 + + current = tags(repo) + versions = {n: s for n, s in current.items() if VERSION_TAG.match(n)} + others = sorted(n for n in current if n not in versions) + + if mode == "move": + if tag not in versions: + print(f"Refusing: {tag} does not exist on {repo}; use --fresh, or push it normally.", file=sys.stderr) + return 1 + moves = [n for n in (tag, f"src/{tag}") if n in versions] + deletes, releases = [], [] + else: + moves = [] + deletes = sorted(versions) + releases = [r for r in gh_pages(f"repos/{repo}/releases") if VERSION_TAG.match(r["tag_name"])] + + print(f"{repo}: {mode} {tag} -> {sha[:12]} (on {branch})") + for n in moves: + print(f" move {n} {versions[n][:12]} -> {sha[:12]}") + for n in deletes: + print(f" delete {n} {versions[n][:12]}") + for r in releases: + print(f" delete release {r['tag_name']} (id {r['id']}{', draft' if r.get('draft') else ''})") + if mode == "fresh": + print(f" create {tag} {sha[:12]}") + for n in others: + print(f" keep {n} {current[n][:12]} (not a version tag)") + + changed = moves or deletes + print("\nCached copies this contradicts:") + print(f" Packagist https://repo.packagist.org/p2/{repo.lower()}.json -> {packagist(repo)}") + print(" Composer every dist cache keeps the old archive for " + f"{', '.join(changed) or 'these tags'} until `composer clear-cache`.") + if any(n.startswith("src/") for n in moves + deletes): + print(" Go proxy proxy.golang.org and sum.golang.org keep any src/v* version they fetched, for good.") + + if not apply: + print(f"\nDry run. Nothing changed. Re-run with --apply --confirm {repo}.") + return 0 + + ruleset = find_ruleset(repo) + failed = False + try: + if ruleset: + set_enforcement(repo, ruleset["id"], "disabled") + print(f"\nDisabled ruleset {ruleset['id']} ({RULESET}).") + else: + print(f"\nNo {RULESET!r} ruleset to disable.") + for n in moves: + gh("api", "-X", "PATCH", f"repos/{repo}/git/refs/tags/{n}", "--input", "-", + stdin=json.dumps({"sha": sha, "force": True})) + print(f"moved {n} -> {sha[:12]}") + for r in releases: + gh("api", "-X", "DELETE", f"repos/{repo}/releases/{r['id']}") + print(f"deleted release {r['tag_name']}") + for n in deletes: + gh("api", "-X", "DELETE", f"repos/{repo}/git/refs/tags/{n}") + print(f"deleted {n}") + if mode == "fresh": + gh("api", "-X", "POST", f"repos/{repo}/git/refs", "--input", "-", + stdin=json.dumps({"ref": f"refs/tags/{tag}", "sha": sha})) + print(f"created {tag} -> {sha[:12]}") + except RuntimeError as e: + failed = True + print(f"Failed: {e}", file=sys.stderr) + finally: + if ruleset: + set_enforcement(repo, ruleset["id"], "active") + state = json.loads(gh("api", f"repos/{repo}/rulesets/{ruleset['id']}"))["enforcement"] + print(f"Ruleset {ruleset['id']} ({RULESET}): {state}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/Feature/ApplyTagRulesetScriptTest.php b/tests/Feature/ApplyTagRulesetScriptTest.php index 76116ad..b4f90e7 100644 --- a/tests/Feature/ApplyTagRulesetScriptTest.php +++ b/tests/Feature/ApplyTagRulesetScriptTest.php @@ -90,5 +90,64 @@ function runTagRuleset(array $args, string $listing = '[]', int $listingExit = 0 $process->run(); expect($process->getExitCode())->toBe(2) - ->and($process->getErrorOutput())->toContain('apply-rulesets.sh OWNER/REPO [--pr NUMBER] [--apply]'); + ->and($process->getErrorOutput())->toContain('apply-rulesets.sh OWNER/REPO [--pr NUMBER] [--keep-branches] [--apply]'); +})->skipOnWindows(); + +/* + * The wrapper's own work is the delete_branch_on_merge setting. A stub virtualenv stands in for + * the two ruleset scripts (tested above and in their own right), so only the wrapper's calls run. + */ +function runRulesetsWrapper(array $args): array +{ + $bin = sys_get_temp_dir() . '/rulesets-wrapper-' . bin2hex(random_bytes(6)); + mkdir($bin . '/venv/bin', 0o755, true); + $log = $bin . '/calls.log'; + + file_put_contents($bin . '/gh', sprintf(<<<'SH' + #!/usr/bin/env bash + echo "gh $*" >> %s + echo true + SH, escapeshellarg($log))); + file_put_contents($bin . '/venv/bin/python', sprintf(<<<'SH' + #!/usr/bin/env bash + [ "$1" = -c ] && exit 0 + echo "python $(basename "$1") ${*:2}" >> %s + SH, escapeshellarg($log))); + chmod($bin . '/gh', 0o755); + chmod($bin . '/venv/bin/python', 0o755); + + $process = new Process(['bash', dirname(__DIR__, 2) . '/scripts/apply-rulesets.sh', ...$args], null, [ + 'PATH' => $bin . ':' . getenv('PATH'), + 'APPLY_RULESETS_VENV' => $bin . '/venv', + ]); + $process->run(); + + return [$process, is_file($log) ? (string) file_get_contents($log) : '']; +} + +it('turns on delete_branch_on_merge by default when applying', function (): void { + [$process, $calls] = runRulesetsWrapper(['acme/widget', '--apply']); + + expect($process->getExitCode())->toBe(0) + ->and($process->getOutput())->toContain('acme/widget: delete_branch_on_merge=true') + ->and($calls)->toContain('gh api -X PATCH repos/acme/widget -F delete_branch_on_merge=true') + ->toContain('python apply-branch-ruleset.py acme/widget --apply') + ->toContain('python apply-tag-ruleset.py acme/widget --apply'); +})->skipOnWindows(); + +it('turns delete_branch_on_merge off with --keep-branches, and passes the flag to neither script', function (): void { + [$process, $calls] = runRulesetsWrapper(['acme/widget', '--keep-branches', '--apply']); + + expect($process->getExitCode())->toBe(0) + ->and($calls)->toContain('gh api -X PATCH repos/acme/widget -F delete_branch_on_merge=false') + ->not->toContain('--keep-branches'); +})->skipOnWindows(); + +it('only says what it would set on a dry run', function (): void { + [$process, $calls] = runRulesetsWrapper(['acme/widget', '--pr', '12']); + + expect($process->getExitCode())->toBe(0) + ->and($process->getOutput())->toContain('would set delete_branch_on_merge=true (dry run)') + ->and($calls)->not->toContain('gh ') + ->toContain('python apply-branch-ruleset.py acme/widget --pr 12'); })->skipOnWindows(); diff --git a/tests/Feature/PruneMergedBranchesScriptTest.php b/tests/Feature/PruneMergedBranchesScriptTest.php new file mode 100644 index 0000000..aa1c506 --- /dev/null +++ b/tests/Feature/PruneMergedBranchesScriptTest.php @@ -0,0 +1,120 @@ + '/dev/null', + 'GIT_CONFIG_NOSYSTEM' => '1', + 'GIT_AUTHOR_NAME' => 'Test', 'GIT_AUTHOR_EMAIL' => 'test@example.invalid', + 'GIT_COMMITTER_NAME' => 'Test', 'GIT_COMMITTER_EMAIL' => 'test@example.invalid', + ]); + $process->mustRun(); + + return trim($process->getOutput()); +} + +function pruneCommit(string $work, string $file): void +{ + file_put_contents($work . '/' . $file, $file); + pruneGit($work, 'add', $file); + pruneGit($work, 'commit', '--quiet', '-m', 'Add ' . $file); +} + +/** + * main, plus four branches: merged and gone, unmerged and gone, never pushed, and the current + * branch, which is merged and gone too, so only the current-branch rule keeps it. + */ +function pruneRepository(): string +{ + $root = sys_get_temp_dir() . '/prune-' . bin2hex(random_bytes(6)); + mkdir($root, 0o755, true); + $bare = $root . '/remote.git'; + $work = $root . '/work'; + + (new Process(['git', 'init', '--quiet', '--bare', '-b', 'main', $bare]))->mustRun(); + (new Process(['git', 'init', '--quiet', '-b', 'main', $work]))->mustRun(); + pruneGit($work, 'remote', 'add', 'origin', $bare); + pruneCommit($work, 'README'); + pruneGit($work, 'push', '--quiet', '-u', 'origin', 'main'); + + foreach (['feat/merged', 'feat/unmerged', 'feat/current'] as $branch) { + pruneGit($work, 'switch', '--quiet', '-c', $branch, 'main'); + pruneCommit($work, str_replace('/', '-', $branch)); + pruneGit($work, 'push', '--quiet', '-u', 'origin', $branch); + } + pruneGit($work, 'switch', '--quiet', '-c', 'feat/local', 'main'); + pruneCommit($work, 'local'); + + pruneGit($work, 'switch', '--quiet', 'main'); + pruneGit($work, 'merge', '--quiet', '--no-ff', '-m', 'Merge merged', 'feat/merged'); + pruneGit($work, 'merge', '--quiet', '--no-ff', '-m', 'Merge current', 'feat/current'); + pruneGit($work, 'push', '--quiet', 'origin', 'main'); + + foreach (['feat/merged', 'feat/unmerged', 'feat/current'] as $branch) { + pruneGit($bare, 'branch', '-D', $branch); + } + pruneGit($work, 'switch', '--quiet', 'feat/current'); + + return $work; +} + +function runPrune(string $work, bool $apply): Process +{ + $process = new Process(['bash', dirname(__DIR__, 2) . '/scripts/prune-merged-branches.sh', $work, ...($apply ? ['--apply'] : [])], null, [ + 'GIT_CONFIG_GLOBAL' => '/dev/null', + 'GIT_CONFIG_NOSYSTEM' => '1', + ]); + $process->run(); + + return $process; +} + +it('deletes only the branch that is merged and gone, and its config section', function (): void { + $work = pruneRepository(); + + $process = runPrune($work, true); + + expect($process->getExitCode())->toBe(0) + ->and($process->getOutput()) + ->toContain('delete feat/merged (upstream gone, merged into origin/main)') + ->toContain('keep feat/unmerged (upstream gone, but not merged into origin/main)') + ->toContain('keep feat/local (never pushed (no upstream))') + ->toContain('keep feat/current (current branch)') + ->toContain('keep main (default branch)') + ->toContain('Deleted 1, kept 4.') + ->and(explode("\n", pruneGit($work, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'))) + ->toBe(['feat/current', 'feat/local', 'feat/unmerged', 'main']) + ->and(pruneGit($work, 'config', '--get-regexp', '^branch\.'))->not->toContain('feat/merged'); +})->skipOnWindows(); + +it('deletes nothing on a dry run', function (): void { + $work = pruneRepository(); + + $process = runPrune($work, false); + + expect($process->getExitCode())->toBe(0) + ->and($process->getOutput())->toContain('delete feat/merged') + ->toContain('Dry run: would delete 1, keep 4.') + ->and(pruneGit($work, 'for-each-ref', '--format=%(refname:short)', 'refs/heads'))->toContain('feat/merged'); +})->skipOnWindows(); + +it('refuses a directory that is not a git checkout', function (): void { + $dir = sys_get_temp_dir() . '/prune-empty-' . bin2hex(random_bytes(6)); + mkdir($dir, 0o755, true); + + $process = runPrune($dir, false); + + expect($process->getExitCode())->toBe(2) + ->and($process->getErrorOutput())->toContain('not a git checkout'); +})->skipOnWindows(); diff --git a/tests/Feature/ResetReleaseTagsScriptTest.php b/tests/Feature/ResetReleaseTagsScriptTest.php new file mode 100644 index 0000000..6e04b22 --- /dev/null +++ b/tests/Feature/ResetReleaseTagsScriptTest.php @@ -0,0 +1,184 @@ + ['default_branch' => 'main'], + 'api repos/acme/widget/compare/main...' . RESET_SHA => ['status' => $compareStatus, 'merge_base_commit' => ['sha' => RESET_SHA]], + 'api --paginate repos/acme/widget/git/matching-refs/tags' => [ + ['ref' => 'refs/tags/v0.1.0', 'object' => ['sha' => RESET_OLD, 'type' => 'commit']], + ['ref' => 'refs/tags/v0.2.0', 'object' => ['sha' => '3333333333333333333333333333333333333333', 'type' => 'tag']], + ['ref' => 'refs/tags/src/v0.1.0', 'object' => ['sha' => RESET_OLD, 'type' => 'commit']], + ['ref' => 'refs/tags/latest', 'object' => ['sha' => RESET_OLD, 'type' => 'commit']], + ['ref' => 'refs/tags/v1', 'object' => ['sha' => RESET_OLD, 'type' => 'commit']], + ], + 'api repos/acme/widget/git/tags/3333333333333333333333333333333333333333' => ['object' => ['sha' => '4444444444444444444444444444444444444444']], + 'api --paginate repos/acme/widget/releases' => [ + ['id' => 11, 'tag_name' => 'v0.1.0', 'draft' => false], + ['id' => 12, 'tag_name' => 'v0.2.0', 'draft' => true], + ['id' => 13, 'tag_name' => 'nightly', 'draft' => false], + ], + 'api repos/acme/widget/rulesets?includes_parents=false' => [['id' => 7, 'name' => 'Protect release tags', 'target' => 'tag']], + 'api repos/acme/widget/rulesets/7' => ['id' => 7, 'enforcement' => 'active'], + ]; +} + +function runResetTags(array $args, ?array $fixture = null, string $failOn = ''): array +{ + $root = dirname(__DIR__, 2); + $bin = sys_get_temp_dir() . '/reset-tags-' . bin2hex(random_bytes(6)); + mkdir($bin . '/packagist/p2/acme', 0o755, true); + file_put_contents($bin . '/packagist/p2/acme/widget.json', '{"packages": {}}'); + file_put_contents($bin . '/fixture.json', json_encode($fixture ?? resetFixture())); + file_put_contents($bin . '/gh', <<<'PY' + #!/usr/bin/env python3 + import json, os, sys + here = os.path.dirname(os.path.abspath(__file__)) + args = sys.argv[1:] + stdin = sys.stdin.read() if "--input" in args else "" + key = " ".join(a for a in args if a not in ("--input", "-")) + with open(os.path.join(here, "calls.log"), "a") as log: + log.write(key + (" " + stdin if stdin else "") + "\n") + fail = os.environ.get("FAKE_GH_FAIL", "") + if fail and fail in key: + print("HTTP 422: fake failure", file=sys.stderr) + sys.exit(1) + fixture = json.load(open(os.path.join(here, "fixture.json"))) + print(json.dumps(fixture.get(key, {}))) + PY); + chmod($bin . '/gh', 0o755); + + $process = new Process(['python3', $root . '/scripts/reset-release-tags.py', ...$args], null, [ + 'PATH' => $bin . ':' . getenv('PATH'), + 'PACKAGIST_BASE' => 'file://' . $bin . '/packagist', + 'FAKE_GH_FAIL' => $failOn, + ]); + $process->run(); + + return [$process, is_file($bin . '/calls.log') ? (string) file_get_contents($bin . '/calls.log') : '']; +} + +function resetWriteCalls(string $calls): array +{ + return array_values(array_filter(explode("\n", $calls), fn (string $line): bool => (bool) preg_match('/^api -X /', $line))); +} + +it('lists what a fresh reset would do on a dry run and writes nothing', function (): void { + [$process, $calls] = runResetTags(['acme/widget', '--fresh', 'v0.1.0', '--to', RESET_SHA]); + + expect($process->getExitCode())->toBe(0) + ->and($process->getOutput()) + ->toContain('delete v0.1.0 222222222222') + ->toContain('delete v0.2.0 444444444444') + ->toContain('delete src/v0.1.0 222222222222') + ->toContain('delete release v0.2.0 (id 12, draft)') + ->toContain('create v0.1.0 111111111111') + ->toContain('keep latest') + ->toContain('keep v1 ') + ->toContain('p2/acme/widget.json -> 200 (listed)') + ->toContain('composer clear-cache') + ->toContain('proxy.golang.org') + ->toContain('Dry run. Nothing changed.') + ->not->toContain('release nightly') + ->and(resetWriteCalls($calls))->toBe([]); +})->skipOnWindows(); + +it('refuses --apply without the exact --confirm, before calling GitHub', function (array $extra): void { + [$process, $calls] = runResetTags(['acme/widget', '--fresh', 'v0.1.0', '--to', RESET_SHA, '--apply', ...$extra]); + + expect($process->getExitCode())->toBe(2) + ->and($process->getErrorOutput())->toContain('--confirm acme/widget, typed exactly') + ->and($calls)->toBe(''); +})->with([ + 'no confirm' => [[]], + 'wrong confirm' => [['--confirm', 'acme/other']], + 'wrong case' => [['--confirm', 'Acme/Widget']], +])->skipOnWindows(); + +it('refuses a SHA that is not on the default branch', function (string $status): void { + [$process, $calls] = runResetTags( + ['acme/widget', '--move', 'v0.1.0', '--to', RESET_SHA, '--apply', '--confirm', 'acme/widget'], + resetFixture($status), + ); + + expect($process->getExitCode())->toBe(1) + ->and($process->getErrorOutput())->toContain("is not on acme/widget's default branch main") + ->and(resetWriteCalls($calls))->toBe([]); +})->with(['ahead', 'diverged'])->skipOnWindows(); + +it('moves one tag and its src alias between disabling and re-enabling the ruleset', function (): void { + [$process, $calls] = runResetTags(['acme/widget', '--move', 'v0.1.0', '--to', RESET_SHA, '--apply', '--confirm', 'acme/widget']); + + expect($process->getExitCode())->toBe(0) + ->and(resetWriteCalls($calls))->toBe([ + 'api -X PUT repos/acme/widget/rulesets/7 {"enforcement": "disabled"}', + 'api -X PATCH repos/acme/widget/git/refs/tags/v0.1.0 {"sha": "' . RESET_SHA . '", "force": true}', + 'api -X PATCH repos/acme/widget/git/refs/tags/src/v0.1.0 {"sha": "' . RESET_SHA . '", "force": true}', + 'api -X PUT repos/acme/widget/rulesets/7 {"enforcement": "active"}', + ]) + ->and($process->getOutput())->toContain('Ruleset 7 (Protect release tags): active'); +})->skipOnWindows(); + +it('replaces every version tag and release with one fresh tag, leaving other tags alone', function (): void { + [$process, $calls] = runResetTags(['acme/widget', '--fresh', 'v0.1.0', '--to', RESET_SHA, '--apply', '--confirm', 'acme/widget']); + + $writes = resetWriteCalls($calls); + + expect($process->getExitCode())->toBe(0) + ->and($writes)->toBe([ + 'api -X PUT repos/acme/widget/rulesets/7 {"enforcement": "disabled"}', + 'api -X DELETE repos/acme/widget/releases/11', + 'api -X DELETE repos/acme/widget/releases/12', + 'api -X DELETE repos/acme/widget/git/refs/tags/src/v0.1.0', + 'api -X DELETE repos/acme/widget/git/refs/tags/v0.1.0', + 'api -X DELETE repos/acme/widget/git/refs/tags/v0.2.0', + 'api -X POST repos/acme/widget/git/refs {"ref": "refs/tags/v0.1.0", "sha": "' . RESET_SHA . '"}', + 'api -X PUT repos/acme/widget/rulesets/7 {"enforcement": "active"}', + ]) + ->and(implode("\n", $writes))->not->toContain('tags/latest')->not->toContain('tags/v1 ')->not->toContain('releases/13'); +})->skipOnWindows(); + +it('re-enables the ruleset even when the work fails part-way', function (): void { + [$process, $calls] = runResetTags( + ['acme/widget', '--fresh', 'v0.1.0', '--to', RESET_SHA, '--apply', '--confirm', 'acme/widget'], + null, + 'git/refs/tags/v0.1.0', + ); + + $writes = resetWriteCalls($calls); + + expect($process->getExitCode())->toBe(1) + ->and($process->getErrorOutput())->toContain('fake failure') + ->and(end($writes))->toBe('api -X PUT repos/acme/widget/rulesets/7 {"enforcement": "active"}') + ->and(implode("\n", $writes))->not->toContain('-X POST') + ->and($process->getOutput())->toContain('Ruleset 7 (Protect release tags): active'); +})->skipOnWindows(); + +it('refuses a malformed invocation', function (array $args): void { + [$process, $calls] = runResetTags($args); + + expect($process->getExitCode())->toBe(2) + ->and($calls)->toBe(''); +})->with([ + 'no repository' => [['--fresh', 'v0.1.0', '--to', RESET_SHA]], + 'both modes' => [['acme/widget', '--move', 'v0.1.0', '--fresh', 'v0.1.0', '--to', RESET_SHA]], + 'no target' => [['acme/widget', '--move', 'v0.1.0']], + 'not a version' => [['acme/widget', '--fresh', 'latest', '--to', RESET_SHA]], + 'not a sha' => [['acme/widget', '--fresh', 'v0.1.0', '--to', 'main']], + 'unknown option' => [['acme/widget', '--fresh', 'v0.1.0', '--to', RESET_SHA, '--force']], +])->skipOnWindows();