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
28 changes: 16 additions & 12 deletions .github/workflows/main-branch-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ on:
branches: [main]
types: [opened, reopened, synchronize, edited]

permissions: {}
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
Expand All @@ -17,19 +18,22 @@ jobs:
name: Guard main branch source
runs-on: ubuntu-latest
steps:
# pull_request_target checks out the base branch, so the guard never
# runs code from the pull request it is judging.
- name: Check out trusted guard
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: scripts/main-branch-guard.zsh
sparse-checkout-cone-mode: false

- name: Set up Zsh
uses: z-shell/.github/actions/setup-zsh@f30d6596347581e5a323aafb434a492769b983e2 # main

- name: Verify pull request source branch
env:
HEAD_REF: ${{ github.head_ref }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
REPOSITORY: ${{ github.repository }}
run: |
if [[ "${HEAD_REPOSITORY}" != "${REPOSITORY}" ]]; then
echo "::error::Pull requests into main must come from this repository (got '${HEAD_REPOSITORY}')."
exit 1
fi
if [[ "${HEAD_REF}" == "next" || "${HEAD_REF}" == hotfix-* ]]; then
echo "Head branch '${HEAD_REF}' is allowed to target main."
exit 0
fi
echo "::error::Pull requests into main must come from 'next' or a 'hotfix-*' branch (got '${HEAD_REF}'). See ADR-0019 (z-shell/.github) for the branching model."
exit 1
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: zsh -f scripts/main-branch-guard.zsh
12 changes: 12 additions & 0 deletions .github/workflows/zsh-n.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ on:
- "contracts/package-manifest-v1.json"
- "scripts/validate-package-manifest.py"
- "scripts/release-plan.zsh"
- "scripts/main-branch-guard.zsh"
- "scripts/verify-promotion-release.zsh"
- "scripts/publish-promotion-release.zsh"
- "tests/**"
Expand Down Expand Up @@ -167,6 +168,17 @@ jobs:
- name: Test release tag verification
run: zsh -f tests/release-tag-verification.zsh

main-branch-guard:
name: Main Branch Guard
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Zsh
run: sudo apt update && sudo apt-get install -yq zsh
- name: Test main branch guard
run: zsh -f tests/main-branch-guard.zsh

release-plan:
name: Release Plan
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Zi is the canonical Zsh plugin manager for the organization. Changes can affect
- Neither `main` nor `next` may require linear history; both promotion and hotfix synchronization preserve merge ancestry.
- A successful promotion needs no routine back-merge. Merge a `main` hotfix forward into `next` before ordinary development continues.
- `hotfix-*` branches may target `main` directly.
- A `dependabot/*` branch opened by `dependabot[bot]` may also target `main`, because Dependabot security updates ignore `target-branch: next`. `scripts/main-branch-guard.zsh` enforces the allowed sources.
- Keep `delete_branch_on_merge` disabled because `next` is persistent.
- A pull request merged into `next` leaves its issue open: GitHub closes issues only from the default branch. Link the issue for the Development sidebar (a closing keyword, or `addCloseIssueReferences` when the link is missing) and close the accumulated issues by hand when `next` is promoted to `main`; do not close them early.

Expand Down
34 changes: 34 additions & 0 deletions scripts/main-branch-guard.zsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env zsh

# Decide whether a pull request may target main. The Main Branch Source Guard
# workflow runs this from the base branch, never from the pull request head.
# Policy: z-shell/.github runbooks/branch-protection.md, "Main-branch source guard".

emulate -L zsh
setopt err_return no_unset pipe_fail

head_ref=${HEAD_REF:-}
head_repository=${HEAD_REPOSITORY:-}
repository=${REPOSITORY:-}
author=${PR_AUTHOR:-}

if [[ -z $repository || $head_repository != "$repository" ]]; then
print -r -- "::error::Pull requests into main must come from this repository (got '${head_repository}')."
exit 1
fi

if [[ $head_ref == next || $head_ref == hotfix-* ]]; then
print -r -- "Head branch '${head_ref}' is allowed to target main."
exit 0
fi

# Dependabot security updates always target the default branch, whatever
# target-branch says. The event payload login is dependabot[bot]; gh shows the
# same account as app/dependabot, which never appears here.
if [[ $head_ref == dependabot/* && $author == 'dependabot[bot]' ]]; then
print -r -- "Dependabot branch '${head_ref}' is allowed to target main."
exit 0
fi

print -r -- "::error::Pull requests into main must come from 'next', a 'hotfix-*' branch, or a 'dependabot/*' branch opened by dependabot[bot] (got '${head_ref}' by '${author}'). See ADR-0019 (z-shell/.github) for the branching model."
exit 1
40 changes: 40 additions & 0 deletions tests/main-branch-guard.zsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env zsh

emulate -L zsh
setopt err_exit no_unset pipe_fail

root=${0:A:h:h}
repo=z-shell/zi
failures=0

# expect STATUS HEAD_REF HEAD_REPOSITORY PR_AUTHOR
expect() {
local want=$1 ref=$2 head_repo=$3 author=$4 got=0
HEAD_REF=$ref HEAD_REPOSITORY=$head_repo REPOSITORY=$repo PR_AUTHOR=$author \
zsh -f "$root/scripts/main-branch-guard.zsh" >/dev/null || got=$?
if (( got != want )); then
print -u2 -- "guard returned $got, want $want: ref='$ref' repo='$head_repo' author='$author'"
failures=$(( failures + 1 ))
fi
}

expect 0 next "$repo" ss-o
expect 0 hotfix-571 "$repo" ss-o
expect 0 dependabot/npm_and_yarn/lodash-4.17.21 "$repo" 'dependabot[bot]'
expect 0 dependabot/github_actions/actions/checkout-5 "$repo" 'dependabot[bot]'

# A person can open a pull request from a dependabot/ branch name.
expect 1 dependabot/npm_and_yarn/lodash-4.17.21 "$repo" ss-o
# gh reports the author as app/dependabot; the event payload never does.
expect 1 dependabot/npm_and_yarn/lodash-4.17.21 "$repo" app/dependabot
expect 1 dependabot/npm_and_yarn/lodash-4.17.21 "$repo" ''
# The bot author alone does not allow an arbitrary branch.
expect 1 renovate/lodash "$repo" 'dependabot[bot]'
# Fork heads never pass, whatever their name or author.
expect 1 next someone/zi ss-o
expect 1 dependabot/npm_and_yarn/lodash-4.17.21 someone/zi 'dependabot[bot]'
expect 1 feature-571 "$repo" ss-o
expect 1 '' "$repo" ss-o

(( failures == 0 )) || exit 1
print 'main branch guard tests passed'
Loading