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
118 changes: 118 additions & 0 deletions .github/scripts/branch-policy/check-scope.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
set -euo pipefail

# Inputs (env):
# HEAD_REF PR head branch name
# BASE_SHA base commit
# HEAD_SHA head commit
# Optional override for local dry-run:
# BR_POLICY_FILES_OVERRIDE newline-separated file list; skips git diff

branch="$HEAD_REF"
echo "Branch: $branch"

if [[ -n "${BR_POLICY_FILES_OVERRIDE:-}" ]]; then
mapfile -t files <<< "$BR_POLICY_FILES_OVERRIDE"
else
mapfile -t files < <(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
fi
printf 'Changed files:\n'
printf ' %s\n' "${files[@]}"

is_doc() { [[ "$1" == *.md || "$1" == *.org ]]; }
is_ci() { [[ "$1" == .github/* ]]; }
is_shared_bootstrap() {
[[ "$1" == init.sh || "$1" == scripts/shell-init.sh || "$1" == justfile ]]
}
is_mac_bootstrap() {
[[ "$1" == flake.nix || "$1" == flake.lock \
|| "$1" == nix/* || "$1" == hosts/* \
|| "$1" == .github/actions/mac-nix-setup/* ]]
}
is_linux_container_setup() {
[[ "$1" == .github/actions/linux-container-setup/* ]]
}

list_public_recipes() {
awk '
/^alias[[:space:]]+[a-zA-Z][a-zA-Z0-9_-]*[[:space:]]*:=/ { print $2; next }
/^[a-zA-Z][a-zA-Z0-9_-]*[[:space:]]*:=/ { next }
/^[a-zA-Z][a-zA-Z0-9_-]*([[:space:]][a-zA-Z0-9_=" -]*)?:/ {
sub(":.*$", "", $0); sub("[[:space:]].*$", "", $0); print
}
' justfile
}

case "$branch" in
docs/*)
for f in "${files[@]}"; do
if ! is_doc "$f"; then
echo "docs/ branch must only touch *.md or *.org: $f" >&2
exit 1
fi
done
;;
ci/*)
for f in "${files[@]}"; do
if ! is_ci "$f" && ! is_doc "$f"; then
echo "ci/ branch must only touch .github/ or *.md/*.org: $f" >&2
exit 1
fi
done
;;
bootstrap/shared)
for f in "${files[@]}"; do
if ! is_shared_bootstrap "$f" && ! is_doc "$f"; then
echo "bootstrap/shared must only touch init.sh, scripts/shell-init.sh, justfile, or *.md/*.org: $f" >&2
exit 1
fi
done
;;
bootstrap/mac)
for f in "${files[@]}"; do
if ! is_mac_bootstrap "$f" && ! is_doc "$f"; then
echo "bootstrap/mac must only touch flake.nix, flake.lock, nix/**, hosts/**, .github/actions/mac-nix-setup/**, or *.md/*.org: $f" >&2
exit 1
fi
done
;;
bootstrap/*)
distro="${branch#bootstrap/}"
for f in "${files[@]}"; do
if [[ "$f" != "os-init/${distro}-init.sh" ]] && ! is_linux_container_setup "$f" && ! is_doc "$f"; then
echo "bootstrap/${distro} must only touch os-init/${distro}-init.sh, .github/actions/linux-container-setup/**, or *.md/*.org: $f" >&2
exit 1
fi
done
;;
dev/*)
recipe="${branch#dev/}"
mapfile -t public_recipes < <(list_public_recipes)
match=0
for r in "${public_recipes[@]}"; do
if [[ "$r" == "$recipe" ]]; then
match=1
break
fi
done
if (( match == 0 )); then
echo "dev/<name> must match a public recipe in justfile: '$recipe' not found" >&2
printf ' available: %s\n' "${public_recipes[@]}" >&2
exit 1
fi
for f in "${files[@]}"; do
if is_shared_bootstrap "$f" || is_mac_bootstrap "$f" \
|| is_linux_container_setup "$f" || [[ "$f" == os-init/* ]]; then
echo "dev/$recipe cannot touch bootstrap-scope file: $f" >&2
exit 1
fi
done
;;
unsafe/*)
;;
*)
echo "branch '$branch' does not match an allowed prefix (docs/, ci/, bootstrap/, dev/, unsafe/)" >&2
exit 1
;;
esac
echo "scope OK"
50 changes: 50 additions & 0 deletions .github/scripts/branch-policy/wait-required-workflow.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail

# Inputs (env):
# HEAD_REF PR head branch name
# HEAD_SHA head commit
# GH_TOKEN GitHub token (for gh CLI)
# REPO owner/name slug

branch="$HEAD_REF"
case "$branch" in
docs/*) required="" ;;
ci/*|bootstrap/shared) required="bootstrap" ;;
bootstrap/*) required="distro" ;;
dev/*) required="recipe" ;;
unsafe/*) required="bootstrap" ;;
*) echo "unexpected branch '$branch'" >&2; exit 1 ;;
esac

if [[ -z "$required" ]]; then
echo "No workflow required for $branch"
exit 0
fi

echo "Waiting for workflow '$required' on $HEAD_SHA"
deadline=$(( $(date +%s) + 3600 ))
while :; do
run_json=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
--jq "[.workflow_runs[] | select(.name==\"$required\")] | first")
if [[ -z "$run_json" || "$run_json" == "null" ]]; then
echo "no '$required' run yet for $HEAD_SHA; waiting..."
else
status=$(echo "$run_json" | jq -r .status)
conclusion=$(echo "$run_json" | jq -r .conclusion)
echo "status=$status conclusion=$conclusion"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" != "success" ]]; then
echo "'$required' ended with '$conclusion'" >&2
exit 1
fi
echo "'$required' succeeded"
exit 0
fi
fi
if (( $(date +%s) > deadline )); then
echo "timed out waiting for '$required'" >&2
exit 1
fi
sleep 20
done
120 changes: 2 additions & 118 deletions .github/workflows/branch-policy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,128 +17,12 @@ jobs:
HEAD_REF: ${{ github.head_ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
branch="$HEAD_REF"
echo "Branch: $branch"

mapfile -t files < <(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
printf 'Changed files:\n'
printf ' %s\n' "${files[@]}"

is_doc() { [[ "$1" == *.md || "$1" == *.org ]]; }
is_ci() { [[ "$1" == .github/* ]]; }
is_shared_bootstrap() {
[[ "$1" == init.sh || "$1" == scripts/shell-init.sh ]]
}
is_mac_bootstrap() {
[[ "$1" == flake.nix || "$1" == flake.lock \
|| "$1" == nix/* || "$1" == hosts/* \
|| "$1" == .github/actions/mac-nix-setup/* ]]
}
is_linux_container_setup() {
[[ "$1" == .github/actions/linux-container-setup/* ]]
}

case "$branch" in
docs/*)
for f in "${files[@]}"; do
if ! is_doc "$f"; then
echo "docs/ branch must only touch *.md or *.org: $f" >&2
exit 1
fi
done
;;
ci/*)
for f in "${files[@]}"; do
if ! is_ci "$f" && ! is_doc "$f"; then
echo "ci/ branch must only touch .github/ or *.md/*.org: $f" >&2
exit 1
fi
done
;;
bootstrap/shared)
for f in "${files[@]}"; do
if ! is_shared_bootstrap "$f" && ! is_doc "$f"; then
echo "bootstrap/shared must only touch init.sh, scripts/shell-init.sh, or *.md/*.org: $f" >&2
exit 1
fi
done
;;
bootstrap/mac)
for f in "${files[@]}"; do
if ! is_mac_bootstrap "$f" && ! is_doc "$f"; then
echo "bootstrap/mac must only touch flake.nix, flake.lock, nix/**, hosts/**, .github/actions/mac-nix-setup/**, or *.md/*.org: $f" >&2
exit 1
fi
done
;;
bootstrap/*)
distro="${branch#bootstrap/}"
for f in "${files[@]}"; do
if [[ "$f" != "os-init/${distro}-init.sh" ]] && ! is_linux_container_setup "$f" && ! is_doc "$f"; then
echo "bootstrap/${distro} must only touch os-init/${distro}-init.sh, .github/actions/linux-container-setup/**, or *.md/*.org: $f" >&2
exit 1
fi
done
;;
dev/*)
;;
unsafe/*)
;;
*)
echo "branch '$branch' does not match an allowed prefix (docs/, ci/, bootstrap/, dev/, unsafe/)" >&2
exit 1
;;
esac
echo "scope OK"
run: bash .github/scripts/branch-policy/check-scope.sh

- name: Wait for required workflow
env:
HEAD_REF: ${{ github.head_ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
branch="$HEAD_REF"
case "$branch" in
docs/*) required="" ;;
ci/*|bootstrap/shared) required="bootstrap" ;;
bootstrap/*) required="distro" ;;
dev/*) required="recipe" ;;
unsafe/*) required="bootstrap" ;;
*) echo "unexpected branch '$branch'" >&2; exit 1 ;;
esac

if [[ -z "$required" ]]; then
echo "No workflow required for $branch"
exit 0
fi

echo "Waiting for workflow '$required' on $HEAD_SHA"
deadline=$(( $(date +%s) + 3600 ))
while :; do
run_json=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
--jq "[.workflow_runs[] | select(.name==\"$required\")] | first")
if [[ -z "$run_json" || "$run_json" == "null" ]]; then
echo "no '$required' run yet for $HEAD_SHA; waiting..."
else
status=$(echo "$run_json" | jq -r .status)
conclusion=$(echo "$run_json" | jq -r .conclusion)
echo "status=$status conclusion=$conclusion"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" != "success" ]]; then
echo "'$required' ended with '$conclusion'" >&2
exit 1
fi
echo "'$required' succeeded"
exit 0
fi
fi
if (( $(date +%s) > deadline )); then
echo "timed out waiting for '$required'" >&2
exit 1
fi
sleep 20
done
run: bash .github/scripts/branch-policy/wait-required-workflow.sh
17 changes: 8 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,20 @@ Common types: `feat`, `fix`, `chore`, `refactor`, `docs`, `style`, `test`.

To protect `master`, follow these rules:

- When developing any OS-specific bootstrap, check out to a `bootstrap/<name>` branch (e.g., `bootstrap/ubuntu`). Long-lived. Scope is restricted by `branch-policy`:
- When developing any OS-specific bootstrap, check out to a `bootstrap/<name>` branch (e.g., `bootstrap/ubuntu`). Scope is restricted by `branch-policy`:
- `bootstrap/<distro>` (where `<distro>` is `ubuntu`, `debian`, `fedora`, `opensuse-tumbleweed`, or `cachyos`): only `os-init/<distro>-init.sh` and `.github/actions/linux-container-setup/**` (plus `*.md`/`*.org`).
- `bootstrap/mac`: only `flake.nix`, `flake.lock`, `nix/**`, `hosts/**`, and `.github/actions/mac-nix-setup/**` (plus `*.md`/`*.org`).
- `bootstrap/shared`: only `init.sh` and `scripts/shell-init.sh` (plus `*.md`/`*.org`). This is the only bootstrap branch whose PR triggers the full `bootstrap.yml` matrix — use it for edits that affect bootstrap across all distros.
- When developing any recipe, check out to a `dev/<name>` branch (e.g., `dev/zsh`). Long-lived.
- When writing or updating documentation, check out to a `docs/<name>` branch (e.g., `docs/readme`) to avoid merge conflicts with parallel code work. Short-lived. Only `*.md` and `*.org` files may change.
- When updating CI config under `.github/**`, check out to a `ci/<name>` branch (e.g., `ci/branch-policy`). Short-lived. Only files under `.github/**` and `*.md`/`*.org` may change.
- For cross-cutting changes that cannot fit any other prefix (e.g., renaming files across multiple scopes), check out to an `unsafe/<name>` branch. Short-lived. No file-scope restriction — analogous to Rust's `unsafe`, requires extra scrutiny on review.
- `bootstrap/shared`: only `init.sh`, `scripts/shell-init.sh`, and `justfile` (plus `*.md`/`*.org`). This is the only bootstrap branch whose PR triggers the full `bootstrap.yml` matrix — use it for edits that affect bootstrap across all distros.
- When developing any recipe, check out to a `dev/<name>` branch (e.g., `dev/zsh`). `<name>` must match a public recipe declared in `justfile` (verified by `branch-policy`); `dev/*` PRs cannot touch bootstrap-scope paths (`init.sh`, `scripts/shell-init.sh`, `justfile`, `os-init/**`, mac/linux-container action paths).
- When writing or updating documentation, check out to a `docs/<name>` branch (e.g., `docs/readme`) to avoid merge conflicts with parallel code work. Only `*.md` and `*.org` files may change.
- When updating CI config under `.github/**`, check out to a `ci/<name>` branch (e.g., `ci/branch-policy`). Only files under `.github/**` and `*.md`/`*.org` may change.
- For cross-cutting changes that cannot fit any other prefix (e.g., renaming files across multiple scopes), check out to an `unsafe/<name>` branch. No file-scope restriction — analogous to Rust's `unsafe`, requires extra scrutiny on review.
- Any other branch name is rejected by the `policy` status check on PRs to `master`.
- Never commit directly to `master`. All changes must land via a pull request.
- Do not open, merge, or auto-close PRs, or delete branches. These are human-triggered actions.
- Short-lived branches (`docs/*`, `ci/*`, `unsafe/*`) should be deleted by a human after their PR merges. Long-lived branches (`bootstrap/*`, `dev/*`) must be kept.
- Do not reuse long-lived branch names (`bootstrap/*`, `dev/*`) for throwaway test pushes. If a verification probe must push to such a name, land a revert commit after the probe PR is closed — do not force-reset or delete the branch.
- All branches should be deleted by a human after their PR merges.
- Before editing: `git pull` on `master`, checkout the target branch, `git pull --ff-only`, then `git rebase master` and immediately `git push --force-with-lease`.
- Never `git commit --amend` a pushed commit. Never `git push --force` (including `--force-with-lease`) except immediately after rebasing a long-lived branch onto `master` as described above. If a pushed commit needs fixing for any other reason, add a follow-up commit or open a new branch.
- Never `git commit --amend` a pushed commit. Never `git push --force` (including `--force-with-lease`) except immediately after rebasing a branch onto `master` as described above. If a pushed commit needs fixing for any other reason, add a follow-up commit or open a new branch.

## GitHub Actions

Expand Down