diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..13185c2 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,60 @@ +name: Auto-merge + +# The `automerge` label is the interface and it has not changed: label a PR and +# it merges when CI is green. What changed is everything underneath. +# +# This turns on GitHub's native auto-merge and gets out of the way. GitHub holds +# the merge until the branch ruleset's required `ci` context reports green, then +# squashes. There is no polling, no CI-result inspection and no waiting job, +# because there is no longer anything to wait for here — the ruleset is the gate. +# +# The previous version of this workflow did all of that by hand, correctly, for +# a constraint that no longer exists: the org was on the free plan, where private +# repos get no branch protection. It moved to Team on 2026-09-10. +on: + pull_request: + types: [labeled, opened, reopened, synchronize] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: contains(github.event.pull_request.labels.*.name, 'automerge') + runs-on: ubuntu-latest + steps: + # Enabling auto-merge on a PR with no required checks does not wait — it + # merges immediately. So an ungated repo is not a harmless no-op here, it + # is a merge that CI never saw. Fail loudly instead. + - name: Refuse to queue a merge nothing will gate + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BASE: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + gated=$(gh api "repos/$REPO/rules/branches/$BASE" \ + --jq '[ .[] | select(.type == "required_status_checks") + | .parameters.required_status_checks[].context ] + | index("ci") != null') + if [ "$gated" != "true" ]; then + echo "::error::$BASE on $REPO does not require the 'ci' status check, so" + echo "auto-merge would merge this PR without waiting for CI. Refusing." + echo "Fix the ruleset, or drop the automerge label and merge by hand." + exit 1 + fi + echo "'ci' is required on $BASE — safe to queue." + + # AUTOMERGE_PAT where it exists: a merge attributed to GITHUB_TOKEN does + # not trigger the downstream deploy workflow (GitHub's recursion guard). + # Repos with no deploy job do not set the secret and fall back cleanly. + - name: Queue the merge + env: + GH_TOKEN: ${{ secrets.AUTOMERGE_PAT || github.token }} + REPO: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + gh pr merge "$NUMBER" --repo "$REPO" --auto --squash --delete-branch + echo "Auto-merge enabled on #$NUMBER; GitHub will land it when 'ci' is green." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3040ff2..65228d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,12 @@ name: test -on: [push, pull_request] +# A same-repo branch used to fire this twice — once as `push`, once as +# `pull_request` — so every context, `ci` included, was reported twice for the +# one commit. A required status check reported twice is ambiguous; scope the +# push trigger to the default branch, as the rest of the fleet does. +on: + push: + branches: [main] + pull_request: jobs: test: runs-on: ubuntu-latest @@ -10,3 +17,35 @@ jobs: ruby-version: "3.3" bundler-cache: true - run: bundle exec ruby -Ilib -Itest test/trellis_test.rb + + # The single status context the branch ruleset requires. The job key IS the + # context name, so do not rename it. + # + # It carries no `paths:` filter and no `if:` of its own, so it always runs and + # therefore always reports. That is the whole point: a required context that + # can be skipped leaves its PR pending forever, with no timeout. + # + # `if: always()` runs it even when a need failed or the run was cancelled — + # otherwise the gate would itself be skipped and report nothing. The step + # decides the verdict: a SKIPPED need is success (a path-filtered or + # conditional job that correctly did not run is not a failure), while FAILURE + # and CANCELLED are not. + ci: + if: always() + needs: [ test ] + runs-on: ubuntu-latest + steps: + - name: Every gated job succeeded or was skipped + env: + NEEDS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + echo "$NEEDS" | jq -r 'to_entries[] | " \(.key): \(.value.result)"' + bad=$(echo "$NEEDS" | jq -r 'to_entries[] + | select(.value.result != "success" and .value.result != "skipped") + | .key') + if [ -n "$bad" ]; then + echo "::error::CI gate failed. Not successful or skipped:" $bad + exit 1 + fi + echo "CI gate passed."