From f9db222070bf06d366d67e33248096b0db947dff Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:27:31 +0100 Subject: [PATCH] ci(guard): accept Dependabot security updates into main (#571) Dependabot security updates always target the default branch, so the main source guard rejected every one of them. Move the decision into scripts/main-branch-guard.zsh and also allow a same-repository dependabot/* branch whose pull-request author is dependabot[bot]. The workflow checks out only that script from the base branch, so it never runs pull-request code, and needs contents: read for that. The new Main Branch Guard job runs tests/main-branch-guard.zsh. --- .github/workflows/main-branch-guard.yml | 28 +++++++++-------- .github/workflows/zsh-n.yml | 12 ++++++++ AGENTS.md | 1 + scripts/main-branch-guard.zsh | 34 +++++++++++++++++++++ tests/main-branch-guard.zsh | 40 +++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 12 deletions(-) create mode 100755 scripts/main-branch-guard.zsh create mode 100644 tests/main-branch-guard.zsh diff --git a/.github/workflows/main-branch-guard.yml b/.github/workflows/main-branch-guard.yml index 297795ab..b9d8cd57 100644 --- a/.github/workflows/main-branch-guard.yml +++ b/.github/workflows/main-branch-guard.yml @@ -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 }} @@ -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 diff --git a/.github/workflows/zsh-n.yml b/.github/workflows/zsh-n.yml index 63c3294e..33aefaf8 100644 --- a/.github/workflows/zsh-n.yml +++ b/.github/workflows/zsh-n.yml @@ -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/**" @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 6f94dfd1..846ee166 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/scripts/main-branch-guard.zsh b/scripts/main-branch-guard.zsh new file mode 100755 index 00000000..ef43bbe6 --- /dev/null +++ b/scripts/main-branch-guard.zsh @@ -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 diff --git a/tests/main-branch-guard.zsh b/tests/main-branch-guard.zsh new file mode 100644 index 00000000..307ddf3a --- /dev/null +++ b/tests/main-branch-guard.zsh @@ -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'