From b7ab40fa15c5c258172bba50322d743aefeb2839 Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Wed, 30 Sep 2026 00:59:51 -0500 Subject: [PATCH] ci: move gates into merge-bot, drop ff-promote --- .github/workflows/ff-promote.yml | 184 ------------------------------- .github/workflows/merge-bot.yml | 49 +++++++- 2 files changed, 48 insertions(+), 185 deletions(-) delete mode 100644 .github/workflows/ff-promote.yml diff --git a/.github/workflows/ff-promote.yml b/.github/workflows/ff-promote.yml deleted file mode 100644 index 6105048..0000000 --- a/.github/workflows/ff-promote.yml +++ /dev/null @@ -1,184 +0,0 @@ -name: Promote to main (fast-forward) - -# pull_request_target: GitHub runs THIS file as it exists on main, so a PR -# cannot rewrite the promote logic that holds the app key. Nothing from the PR -# is ever checked out or executed here; the jobs only run git and gh commands -# against the PR's commit objects. - -on: - pull_request_target: - branches: [main] - types: [opened, synchronize, reopened] - -permissions: {} - -# A new push cancels the older run and its pending approval. -concurrency: - group: promote-${{ github.event.pull_request.number }} - cancel-in-progress: true - -env: - # Checks that must be `success` on the PR head BEFORE an approval is even - # requested. Names must match exactly: - # gh api repos/OWNER/REPO/commits//check-runs --jq '.check_runs[].name' - # A REPLACE-* entry fails the run immediately (fail closed). - REQUIRED_CHECKS: | - Run Tests & Security Scans - Analyze (actions) - CodeQL - # Space-separated GitHub logins whose commits may be promoted. - ALLOWED_AUTHORS: patrickryankenneth - -jobs: - gates: - name: Gates (checks, signatures, ancestry) - # Same-repo branches only; fork heads are never promoted. - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 35 - permissions: - contents: read - checks: read - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - BASE_REF: ${{ github.base_ref }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - steps: - - name: Checkout base branch (full history, PR code is NOT checked out) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - fetch-depth: 0 - - - name: Refuse placeholder check names - run: | - if grep -q '^REPLACE-' <<<"$REQUIRED_CHECKS"; then - echo "::error::REQUIRED_CHECKS still has a REPLACE-* placeholder" - exit 1 - fi - - - name: Fetch PR head objects and confirm the SHA - run: | - set -euo pipefail - git fetch origin "refs/pull/$PR/head" - [ "$(git rev-parse FETCH_HEAD)" = "$HEAD_SHA" ] \ - || { echo "::error::PR head moved since this run started"; exit 1; } - - - name: Linear history and fast-forwardable - run: | - set -euo pipefail - git fetch origin "$BASE_REF" - if [ -n "$(git rev-list --merges "origin/$BASE_REF..$HEAD_SHA")" ]; then - echo "::error::PR contains merge commits"; exit 1 - fi - git merge-base --is-ancestor "origin/$BASE_REF" "$HEAD_SHA" \ - || { echo "::error::PR is behind $BASE_REF; rebase locally"; exit 1; } - - - name: Every commit is signature-verified and by an allowed author - run: | - set -euo pipefail - bad=0 - for sha in $(git rev-list --reverse "origin/$BASE_REF..$HEAD_SHA"); do - IFS=$'\t' read -r verified reason login < <( - gh api "repos/$REPO/commits/$sha" \ - --jq '[.commit.verification.verified, .commit.verification.reason, (.author.login // "none")] | @tsv') - echo "$sha verified=$verified reason=$reason author=$login" - if [ "$verified" != true ] || [ "$reason" != valid ]; then - echo "::error::$sha has no valid verified signature"; bad=1 - fi - case " $ALLOWED_AUTHORS " in - *" $login "*) ;; - *) echo "::error::$sha author '$login' is not allowed"; bad=1 ;; - esac - done - [ "$bad" = 0 ] - - - name: Wait for required checks, require success - run: | - set -euo pipefail - deadline=$((SECONDS + 1500)) - fetch() { - gh api --paginate "repos/$REPO/commits/$HEAD_SHA/check-runs?per_page=100" \ - --jq '.check_runs[] | [.name, .status, (.conclusion // ""), .started_at] | @tsv' - } - while :; do - rows=$(fetch) - pending=0; failed=0 - while IFS= read -r name; do - [ -n "$name" ] || continue - row=$(awk -F'\t' -v n="$name" '$1==n' <<<"$rows" | sort -t$'\t' -k4 | tail -n1) - if [ -z "$row" ]; then - state=missing; pending=1 - else - status=$(cut -f2 <<<"$row"); concl=$(cut -f3 <<<"$row") - if [ "$status" != completed ]; then - state=$status; pending=1 - elif [ "$concl" = success ]; then - state=success - else - state="failed:$concl"; failed=1 - fi - fi - echo "$name -> $state" - done <<<"$REQUIRED_CHECKS" - [ "$failed" = 0 ] || { echo "::error::a required check failed"; exit 1; } - [ "$pending" = 1 ] || break - [ "$SECONDS" -lt "$deadline" ] || { echo "::error::timed out waiting for checks"; exit 1; } - sleep 20 - done - - promote: - name: Fast-Forward to Main - needs: gates - runs-on: ubuntu-latest - timeout-minutes: 15 - # Pauses for your approval. Deployment branches for this environment - # should be limited to `main`, and "Prevent self-review" must stay OFF - # (you are the trigger and the only approver). - environment: promote-to-main - permissions: - contents: read - pull-requests: read - env: - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - BASE_REF: ${{ github.base_ref }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - steps: - - name: Mint short-lived app token (contents write, this repo only) - id: app-token - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 - with: - app-id: ${{ secrets.BOT_APP_ID }} - private-key: ${{ secrets.BOT_PRIVATE_KEY }} - repositories: ${{ github.event.repository.name }} - permission-contents: write - - - name: Checkout base branch with app token (PR code is NOT checked out) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - name: Re-check after approval, then fast-forward push - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - git fetch origin "refs/pull/$PR/head" - [ "$(git rev-parse FETCH_HEAD)" = "$HEAD_SHA" ] \ - || { echo "::error::PR head moved during approval"; exit 1; } - - [ "$(gh api "repos/$REPO/pulls/$PR" --jq '.state + " " + .head.sha')" = "open $HEAD_SHA" ] \ - || { echo "::error::PR is no longer open at $HEAD_SHA"; exit 1; } - - git fetch origin "$BASE_REF" - git merge-base --is-ancestor "origin/$BASE_REF" "$HEAD_SHA" \ - || { echo "::error::$BASE_REF moved; rebase and re-run"; exit 1; } - - # Exact tested SHA, no --force: anything non-fast-forward is rejected. - git push origin "$HEAD_SHA:refs/heads/$BASE_REF" - - echo "Fast-forwarded $BASE_REF to $HEAD_SHA (PR #$PR)." >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/.github/workflows/merge-bot.yml b/.github/workflows/merge-bot.yml index 4514f4a..9958c36 100644 --- a/.github/workflows/merge-bot.yml +++ b/.github/workflows/merge-bot.yml @@ -7,6 +7,7 @@ on: permissions: contents: read + checks: read # One run per PR; a new push cancels the older run (and its pending approval). concurrency: @@ -71,6 +72,52 @@ jobs: echo "PR head $HEAD_SHA is linear and fast-forwardable onto $BASE_REF." + # =============================================================== + # JOB 2b: Gates the bot enforces before it may push + # Signed commits, author is the owner, CodeQL green. Runs no PR code. + # =============================================================== + gates: + name: Gates (signatures, author, CodeQL) + runs-on: ubuntu-latest + permissions: + contents: read + checks: read + env: + GH_TOKEN: ${{ github.token }} + BASE_REF: ${{ github.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + ALLOWED_AUTHOR: patrickryankenneth + steps: + - name: Signatures, author, fast-forward + run: | + set -euo pipefail + CMP=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_REF...$HEAD_SHA") + jq -e '.status == "ahead" or .status == "identical"' <<<"$CMP" >/dev/null \ + || { echo "::error::PR is not a fast-forward of $BASE_REF"; exit 1; } + jq -e '[.commits[] | select(.commit.verification.verified != true)] | length == 0' <<<"$CMP" >/dev/null \ + || { echo "::error::unsigned or unverified commit in PR"; exit 1; } + jq -e --arg a "$ALLOWED_AUTHOR" '[.commits[] | select(.author.login != $a)] | length == 0' <<<"$CMP" >/dev/null \ + || { echo "::error::commit not authored by $ALLOWED_AUTHOR"; exit 1; } + echo "Signatures, author, fast-forward OK." + + - name: Wait for CodeQL checks + run: | + set -euo pipefail + for i in $(seq 1 50); do + ok=1 + for name in "CodeQL" "Analyze (actions)"; do + c=$(gh api "repos/$GITHUB_REPOSITORY/commits/$HEAD_SHA/check-runs?per_page=100" \ + --jq "[.check_runs[] | select(.name == \"$name\")] | sort_by(.started_at) | last | .conclusion // \"none\"") + echo "$name: $c" + if [ "$c" = "none" ] || [ "$c" = "null" ]; then ok=0 + elif [ "$c" != "success" ]; then echo "::error::$name concluded $c"; exit 1 + fi + done + [ "$ok" = 1 ] && { echo "CodeQL checks green."; exit 0; } + sleep 30 + done + echo "::error::timed out waiting for CodeQL"; exit 1 + # =============================================================== # JOB 3: Human approval gate, then fast-forward push via GitHub App # The environment pauses here until you approve. BOT_APP_ID and @@ -80,7 +127,7 @@ jobs: # =============================================================== fast-forward-merge: name: Fast-Forward to Main - needs: [ci-and-security, verify-fast-forward] + needs: [ci-and-security, verify-fast-forward, gates] # Same-repo branches only (fork heads can't be fetched from origin) if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest