ci-auto-merge #188
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |