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
60 changes: 60 additions & 0 deletions .github/workflows/automerge.yml
Original file line number Diff line number Diff line change
@@ -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."
41 changes: 40 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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."
Loading