Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 71 additions & 6 deletions docs/tools/repository-rulesets.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -38,6 +39,10 @@ placeholders: zsh reads `<owner>` 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:

Expand All @@ -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/<default>` (or `git cherry` finds nothing missing from it), along with its
`branch.<name>` 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

Expand Down
46 changes: 34 additions & 12 deletions scripts/apply-rulesets.sh
Original file line number Diff line number Diff line change
@@ -1,39 +1,61 @@
#!/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
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[@]}"
90 changes: 90 additions & 0 deletions scripts/prune-merged-branches.sh
Original file line number Diff line number Diff line change
@@ -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/<default> (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.<name>` 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
Loading
Loading