diff --git a/.github/scripts/branch-policy/check-scope.sh b/.github/scripts/branch-policy/check-scope.sh new file mode 100755 index 0000000..b22d500 --- /dev/null +++ b/.github/scripts/branch-policy/check-scope.sh @@ -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/ 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" diff --git a/.github/scripts/branch-policy/wait-required-workflow.sh b/.github/scripts/branch-policy/wait-required-workflow.sh new file mode 100755 index 0000000..8a02d76 --- /dev/null +++ b/.github/scripts/branch-policy/wait-required-workflow.sh @@ -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 diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml index 498fedc..cf27f97 100644 --- a/.github/workflows/branch-policy.yml +++ b/.github/workflows/branch-policy.yml @@ -17,81 +17,7 @@ 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: @@ -99,46 +25,4 @@ jobs: 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 diff --git a/AGENTS.md b/AGENTS.md index d4070cd..0b4af16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` branch (e.g., `bootstrap/ubuntu`). Long-lived. Scope is restricted by `branch-policy`: +- When developing any OS-specific bootstrap, check out to a `bootstrap/` branch (e.g., `bootstrap/ubuntu`). Scope is restricted by `branch-policy`: - `bootstrap/` (where `` is `ubuntu`, `debian`, `fedora`, `opensuse-tumbleweed`, or `cachyos`): only `os-init/-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/` branch (e.g., `dev/zsh`). Long-lived. -- When writing or updating documentation, check out to a `docs/` 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/` 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/` 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/` branch (e.g., `dev/zsh`). `` 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/` 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/` 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/` 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