Skip to content

ci-auto-merge

ci-auto-merge #188

Workflow file for this run

name: ci-auto-merge
# the unattended arming path: no human acts, the machine decides.
#
# when `ci` finishes green for a pull request, wait for every other check on
# that exact head sha to finish too, and if none of them failed, arm native
# auto-merge through arm-auto-merge.yml. the two bars there are unchanged and
# unweakened by this path: 100% diff coverage of the changed python under
# src/vouch/, and a closing reference to an issue plind-junior opened. a PR
# that clears neither of them is refused here exactly as on the label path.
#
# so an unattended merge needs all three, and the machine checks all three:
# every check green, every changed line tested, and the work was asked for by
# the owner.
#
# nothing here checks out or executes PR code — only metadata is read, because
# the arming job holds a write token. `workflow_run` runs the copy of this file
# on the DEFAULT branch, so this only takes effect once it lands on main.
on:
workflow_run: # zizmor: ignore[dangerous-triggers] runs from the base repo on ci completion; reads metadata only, never checks out or runs PR code
workflows: ["ci"]
types: [completed]
permissions: {}
# a later ci run for the same head supersedes an in-flight wait.
concurrency:
group: ci-auto-merge-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
jobs:
resolve:
# only PR runs of ci, and only green ones. a red ci never reaches the wait.
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
checks: read
outputs:
pr: ${{ steps.pr.outputs.pr }}
eligible: ${{ steps.checks.outputs.eligible }}
steps:
# workflow_run.pull_requests is empty for fork PRs — resolve via the
# commit->pulls endpoint instead (base token, no PR code executed).
- name: resolve the pull request
id: pr
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
pr=$(gh api "repos/$REPO/commits/$HEAD_SHA/pulls" --jq '.[0].number' 2>/dev/null || true)
if [ -z "$pr" ] || [ "$pr" = "null" ]; then
echo "no open PR for $HEAD_SHA"
echo "pr=" >> "$GITHUB_OUTPUT"
exit 0
fi
# the PR must still be open, undrafted, and still sitting on this sha.
# a merged/closed PR, or one that moved on, is not ours to touch.
read -r state draft head < <(gh pr view "$pr" --repo "$REPO" \
--json state,isDraft,headRefOid --jq '[.state,.isDraft,.headRefOid]|@tsv')
if [ "$state" != "OPEN" ] || [ "$draft" = "true" ] || [ "$head" != "$HEAD_SHA" ]; then
echo "PR #$pr not eligible (state=$state draft=$draft head=$head sha=$HEAD_SHA)"
echo "pr=" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "pr=$pr" >> "$GITHUB_OUTPUT"
# `ci` is only one of the workflows on a PR — the gates, the score job,
# the schema check and the label jobs are separate. "all the ci passed"
# means all of them, so wait them out rather than trusting branch
# protection (which `test` does not have).
# arm-auto-merge enforces this bar authoritatively and comments when a PR
# misses it. that comment is right for a human who just asked to arm, and
# wrong here — unattended, it would repeat on every push of every PR that
# has no owner ticket. so read the same link first and stay silent.
- name: require a closing issue opened by the owner
id: owner
if: steps.pr.outputs.pr != ''
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ steps.pr.outputs.pr }}
run: |
# SC2016: the query is single-quoted on purpose — $owner/$name/$pr are
# graphql variables bound by -f/-F, not shell expansions.
# shellcheck disable=SC2016
owners=$(gh api graphql \
-f owner="${REPO%/*}" -f name="${REPO#*/}" -F pr="$PR" \
-f query='query($owner:String!,$name:String!,$pr:Int!){
repository(owner:$owner,name:$name){
pullRequest(number:$pr){
closingIssuesReferences(first:50){nodes{number author{login}}}
}
}
}' \
--jq '[.data.repository.pullRequest.closingIssuesReferences.nodes[]
| select(.author.login=="plind-junior") | .number] | join(", ")' \
2>/dev/null || true)
if [ -n "$owners" ]; then
echo "closes owner-authored issue(s): $owners"
echo "ok=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::notice::PR #$PR closes no issue opened by plind-junior; not arming auto-merge"
echo "ok=false" >> "$GITHUB_OUTPUT"
- name: wait for every check on the head sha, then require none failed
id: checks
if: steps.owner.outputs.ok == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
RUN_ID: ${{ github.run_id }}
run: |
echo "eligible=false" >> "$GITHUB_OUTPUT"
for _ in $(seq 1 40); do
# this workflow's own check run is excluded: it cannot wait on itself.
runs=$(gh api "repos/$REPO/commits/$HEAD_SHA/check-runs" --paginate \
--jq ".check_runs[] | select((.details_url // \"\") | contains(\"/runs/$RUN_ID/\") | not)
| [.status, (.conclusion // \"\")] | @tsv")
pending=$(printf '%s\n' "$runs" | grep -cv '^completed' || true)
if [ "$pending" -eq 0 ]; then
# success / skipped / neutral are all "did not fail". anything
# else — failure, cancelled, timed_out, action_required — blocks.
bad=$(printf '%s\n' "$runs" \
| awk -F'\t' '$2!="success" && $2!="skipped" && $2!="neutral"' | wc -l)
if [ "$bad" -eq 0 ]; then
echo "eligible=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::checks failed on $HEAD_SHA; not arming auto-merge"
fi
exit 0
fi
sleep 30
done
echo "::notice::checks still running on $HEAD_SHA after 20m; not arming auto-merge"
# visible on the PR, and it is what makes deauthorize-on-push announce
# itself when a later push voids this. a label added with GITHUB_TOKEN
# does not re-trigger auto-merge.yml (github's token-recursion guard),
# so this does not double-arm.
- name: mark the PR as machine-authorized
if: steps.checks.outputs.eligible == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ steps.pr.outputs.pr }}
run: gh pr edit "$PR" --repo "$REPO" --add-label auto-merge || true
arm:
needs: resolve
if: needs.resolve.outputs.eligible == 'true'
# a called workflow can only downgrade the caller's token, and this file
# starts from `permissions: {}` — so the grant has to be made here too.
permissions:
contents: write
pull-requests: write
checks: read
uses: ./.github/workflows/arm-auto-merge.yml
with:
pr: ${{ needs.resolve.outputs.pr }}
head_sha: ${{ github.event.workflow_run.head_sha }}