chore: autoupdate pre-commit hooks #136
Workflow file for this run
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: Bot auto-merge | |
| # Arms GitHub's native auto-merge (squash) on Dependabot and pre-commit-ci pull requests | |
| # that pass the eligibility rules below. GitHub performs the merge only once the pull | |
| # request is mergeable -- both `Run CI` legs green, a review recorded, no unresolved review | |
| # threads -- so a review comment pauses it until that thread is resolved. | |
| # | |
| # There is deliberately no in-workflow check polling. The previous version of this file | |
| # called `gh pr checks --required --watch`, which races check registration: it reports "no | |
| # required checks reported" before CI has posted and fails the job on a pull request that | |
| # was never unhealthy. Native auto-merge gates on the ruleset server-side, which is both | |
| # correct and free. `main-protect` requires `Run CI (ubuntu-latest)` and | |
| # `Run CI (macos-latest)`, and those requirements are what auto-merge waits on; without at | |
| # least one required check GitHub rejects `--auto` outright, so removing them would break | |
| # this workflow rather than relax it. | |
| # | |
| # `main-protect` also carries a Copilot review rule, and `require-maintainer-review` asks | |
| # for an approving review on top. Both are satisfied server-side in their own time; nothing | |
| # here waits on either, which is the point of letting auto-merge do the waiting. | |
| # | |
| # The author guard reads `pull_request.user.login` and not `github.actor`. `github.actor` | |
| # is whoever triggered *this event* rather than whoever opened the pull request, so any | |
| # later push by another identity makes that synchronize event's actor the other identity | |
| # and the job skips -- leaving only the `opened` event as a chance to act, with no retry | |
| # afterwards. `user.login` stays the bot for the life of the pull request. It is not | |
| # spoofable at that level: GitHub records it as the authenticated author, never derives it | |
| # from pull request content, and the `[bot]` suffix cannot occur in a human account name. | |
| # It says nothing about the individual *commits*, which is what the provenance step below | |
| # is for. | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, labeled, unlabeled] | |
| # Narrower than it was. The previous version also held `packages: read`, which nothing in | |
| # this repository ever read -- there are no package downloads here, only shell scripts and | |
| # BATS. Because declaring this block sets every unlisted scope to `none`, a scope kept for | |
| # symmetry grants real authority for no reader. Leave it at what the calls below use. | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| concurrency: | |
| group: bot-automerge-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| bot-automerge: | |
| # `do not auto-merge` is the kill switch, and this is the path that needs one: arming | |
| # takes no human action at all, so without a label there is nothing to withhold. | |
| # | |
| # This check alone only stops the job from arming *again*. Auto-merge already enabled | |
| # lives server-side, so a label applied afterwards would not turn it off -- | |
| # bot-automerge-disarm.yml does that, and is the half that makes | |
| # arming defensible. | |
| # | |
| # A fork head is a stranger's branch. Same-repository only. | |
| if: >- | |
| github.event.pull_request.head.repo.full_name == github.repository && | |
| (github.event.pull_request.user.login == 'dependabot[bot]' || | |
| github.event.pull_request.user.login == 'pre-commit-ci[bot]') && | |
| !contains(github.event.pull_request.labels.*.name, 'do not auto-merge') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| env: | |
| GH_REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| steps: | |
| # No checkout step: nothing below reads the repository. | |
| # Optional, and inert unless both settings resolve. Arming under an App matters | |
| # because auto-merge is completed by whoever armed it, and GitHub creates no workflow | |
| # run for an event caused by `GITHUB_TOKEN` -- so a `GITHUB_TOKEN` merge fires no | |
| # `push` and no `pull_request_target: closed` trigger downstream, while an App merge | |
| # does. | |
| # | |
| # Neither setting exists here yet, so this resolves false on every run and the whole | |
| # App path is inert -- which is why it is written as a detection rather than a | |
| # requirement. Setting them buys the downstream triggers described above and nothing | |
| # else; the merge itself already works. Note that a Dependabot-triggered run is handed | |
| # the *Dependabot* secret store rather than the Actions one, so whenever these are | |
| # added the private key has to go into both stores or the App path will stay inert on | |
| # exactly the pull requests it was added for. Saying so here is the point: the failure | |
| # is otherwise entirely silent -- the token comes back empty and the fallback takes | |
| # over with nothing logged. | |
| - name: Detect App credentials | |
| id: creds | |
| env: | |
| APP_ID_VALUE: ${{ vars.APP_ID }} | |
| APP_KEY_VALUE: ${{ secrets.APP_PRIVATE_KEY }} | |
| run: | | |
| set -euo pipefail | |
| if [ -n "$APP_ID_VALUE" ] && [ -n "$APP_KEY_VALUE" ]; then | |
| echo "available=true" >>"$GITHUB_OUTPUT" | |
| else | |
| echo "available=false" >>"$GITHUB_OUTPUT" | |
| echo "::notice::No App credential on this run; arming under the default" \ | |
| "token. The merge will fire no downstream push or closed event." | |
| fi | |
| # `APP_ID` holds the App's numeric id, which is what `create-github-app-token` wants; | |
| # the client id is the separate `Iv23li...` string and is not what goes here. The | |
| # sibling repository that already has this wired uses the same two names, so a key | |
| # rotation is one operation across both rather than two spellings to remember. It is a | |
| # variable rather than a secret because `if:` can read `vars` and cannot read | |
| # `secrets`, and that test is what keeps this path optional. | |
| - name: Mint an App token, if one is configured | |
| id: app-token | |
| if: steps.creds.outputs.available == 'true' | |
| continue-on-error: true | |
| uses: actions/create-github-app-token@v3 | |
| with: | |
| app-id: ${{ vars.APP_ID }} | |
| private-key: ${{ secrets.APP_PRIVATE_KEY }} | |
| # Without these the token inherits every permission the installation holds, which | |
| # is more than this job declares for itself. Narrowing the borrowed identity to | |
| # match the default one keeps the two carrying the same authority. | |
| permission-contents: write | |
| permission-pull-requests: write | |
| # `continue-on-error` above means a failed mint does not stop the job, so it has to be | |
| # reported here or it passes unnoticed. Worth knowing while reading a run: a | |
| # `continue-on-error` step is recorded as `conclusion: success` by the jobs API even | |
| # when its log carries a hard refusal, so this notice is the only honest signal short | |
| # of opening the log. | |
| - name: Report a mint that failed | |
| if: >- | |
| steps.creds.outputs.available == 'true' && | |
| (steps.app-token.outcome != 'success' || steps.app-token.outputs.token == '') | |
| run: | | |
| echo "::warning::App credentials are set but minting failed; falling back to" \ | |
| "the default token. Auto-merge still works; downstream triggers will not." | |
| - name: Fetch Dependabot metadata | |
| id: meta | |
| if: github.event.pull_request.user.login == 'dependabot[bot]' | |
| uses: dependabot/fetch-metadata@v3 | |
| # Dependabot's metadata trailer omits update-type for some ecosystems. v3 recovers it | |
| # by parsing the "Updates X from A to B" line in the commit message; v2 did not, and | |
| # under v2 those ecosystems silently stopped merging. Keep this at v3 or newer. | |
| # | |
| # When even that recovery finds no versions there is nothing to gate on, so hold for a | |
| # human -- but say so, because a green run that merged nothing is otherwise | |
| # indistinguishable from a deliberate hold. | |
| - name: Report metadata that cannot be gated on | |
| if: >- | |
| github.event.pull_request.user.login == 'dependabot[bot]' && | |
| !steps.meta.outputs.update-type | |
| env: | |
| ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} | |
| run: | | |
| echo "::notice::No update-type for ${ECOSYSTEM:-unknown}; leaving this for a human." | |
| - name: Verify every commit was created by GitHub | |
| id: provenance | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| AUTHOR: ${{ github.event.pull_request.user.login }} | |
| run: | | |
| # Every commit must have been created by GitHub itself, and be the bot's own | |
| # unless it is a merge. This is the guard the author check cannot be: | |
| # `.author.login` is resolved from the commit's author email, so anyone who can | |
| # push to this repository can set it to the bot's noreply address and be read as | |
| # the bot. | |
| # | |
| # `committer.login == "web-flow"` is the load-bearing half, and it is deliberately | |
| # not the payload text. GitHub resolves that field from the signing key itself, so | |
| # it is not git metadata a pusher can set: measured on real pull requests, both | |
| # Dependabot and pre-commit-ci commits report `web-flow` with `verified: true` and | |
| # `reason: valid`, while a human's own commits report that human's login. | |
| # | |
| # The payload check stays as a second, independent condition rather than as the | |
| # argument. On its own it would rest on an unstated premise -- that GitHub declines | |
| # to mark a commit verified when its committer email is not a verified email of the | |
| # signing key's owner -- and a guard should not rest on a premise its reader cannot | |
| # check. Requiring both means the guard holds whether or not that premise does. | |
| # A locally pushed commit is signed by the pusher's own key, or not at all, and | |
| # either way it does not come back as `web-flow`. | |
| # | |
| # Merges are exempt from the author check but not from the signature check, and | |
| # that pairing is what makes the exemption safe. A merge commit's tree is not | |
| # constrained to be the mechanical merge of its parents, so a local "evil merge" | |
| # can carry content present in neither parent while contributing no non-merge | |
| # commits to the range. Requiring GitHub's signature rules that out: the only | |
| # merges GitHub signs are the mechanical ones its own interface produces, and the | |
| # button cannot inject a tree. That is what lets a required Update branch merge | |
| # through without reopening the hole. | |
| # | |
| # `set -e` does the fail-closed work. Every hand-written error branch here would | |
| # be a way to fail open instead, so there is no `2>/dev/null` and no | |
| # `|| fallback`: if a query fails the step dies, `trusted` is never set, every | |
| # step below skips, and the error is in the log for whoever is debugging. | |
| set -euo pipefail | |
| jq_rows='.[] | [ | |
| (if (.commit.verification.verified == true) | |
| and (.commit.verification.reason == "valid") | |
| and ((.committer.login // "") == "web-flow") | |
| and ((.commit.verification.payload // "") | split("\n") | |
| | any(startswith("committer GitHub <noreply@github.com>"))) | |
| then "github" else "elsewhere" end), | |
| (if (.parents | length) >= 2 then "merge" else "plain" end), | |
| (.author.login // "<none>") | |
| ] | join("|")' | |
| # This endpoint returns at most 250 commits and `--paginate` still exits 0, so a | |
| # longer pull request would be inspected only in part and the unseen tail could be | |
| # anything. Count first and refuse rather than under-inspect. A bot pull request | |
| # reaching 250 commits suggests something is wrong regardless. | |
| total=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}" --jq '.commits') | |
| if [ "$total" -gt 250 ]; then | |
| echo "::warning::${total} commits exceeds the 250 this API returns; holding." | |
| echo "trusted=false" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| rows=$(gh api "repos/${GH_REPO}/pulls/${PR_NUMBER}/commits" --paginate --jq "$jq_rows") | |
| seen=$(printf '%s\n' "$rows" | grep -c . || true) | |
| if [ "$seen" -ne "$total" ]; then | |
| echo "::warning::inspected ${seen} of ${total} commits; holding." | |
| echo "trusted=false" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| rejected=0 | |
| while IFS='|' read -r origin kind commit_author; do | |
| [ -z "$origin" ] && continue | |
| if [ "$origin" != "github" ]; then | |
| echo "::warning::a commit was not created by GitHub; holding." | |
| rejected=$((rejected + 1)) | |
| elif [ "$kind" != "merge" ] && [ "$commit_author" != "$AUTHOR" ]; then | |
| echo "::warning::non-merge commit authored by ${commit_author}; holding." | |
| rejected=$((rejected + 1)) | |
| fi | |
| done <<< "$rows" | |
| if [ "$rejected" -ne 0 ]; then | |
| echo "trusted=false" >>"$GITHUB_OUTPUT" | |
| else | |
| echo "trusted=true" >>"$GITHUB_OUTPUT" | |
| fi | |
| - name: Decide eligibility | |
| id: eligible | |
| if: steps.provenance.outputs.trusted == 'true' | |
| env: | |
| AUTHOR: ${{ github.event.pull_request.user.login }} | |
| UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} | |
| ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$AUTHOR" = "pre-commit-ci[bot]" ]; then | |
| echo "::notice::pre-commit-ci hook bump; eligible." | |
| echo "should_merge=true" >>"$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| case "$UPDATE_TYPE" in | |
| version-update:semver-patch|version-update:semver-minor) | |
| echo "::notice::Dependabot ${UPDATE_TYPE}; eligible." | |
| echo "should_merge=true" >>"$GITHUB_OUTPUT" | |
| ;; | |
| version-update:semver-major) | |
| # Majors are gated on ecosystem, because ecosystem decides whether a green | |
| # pull request is evidence about the change. | |
| # | |
| # `github-actions` arms, and correcting that is most of why this file changed. | |
| # The actions here are pinned to floating major tags, so Dependabot raises an | |
| # actions pull request only when a major moves -- which made a blanket major | |
| # hold a blanket hold on every actions pull request there will ever be. That | |
| # is not a policy but an outage. The cooldown in dependabot.yml is the soak | |
| # period that replaces the hold -- and it covers every actions update rather | |
| # than only majors, because that ecosystem accepts no major-specific key. | |
| # | |
| # Its effective length is longer than the number suggests, which is worth | |
| # knowing before tuning it: the actions ecosystem is polled on the 1st and | |
| # 15th, so an update whose cooldown expires just after a poll waits until the | |
| # next one. Thirty days of cooldown therefore lands a pull request roughly | |
| # thirty to forty-five days after the release. | |
| # | |
| # No per-workflow hold list here, unlike the sibling repositories that publish | |
| # something. This repository publishes nothing: every workflow except | |
| # changelog-autoupdate.yml and pre-commit-autoupdate.yml runs on pull | |
| # requests, so an actions break surfaces at review time, and a break in either | |
| # of those two is a scheduled or dispatched job that fails visibly and costs a | |
| # rerun. A list would be maintenance with nothing behind it, and a stale list | |
| # fails silently in the arming direction. | |
| if [ "$ECOSYSTEM" = "github-actions" ]; then | |
| echo "::notice::actions major; nothing here publishes unexercised; eligible." | |
| echo "should_merge=true" >>"$GITHUB_OUTPUT" | |
| else | |
| echo "::warning::${ECOSYSTEM:-unknown} major is not exercised end to end; holding." | |
| echo "should_merge=false" >>"$GITHUB_OUTPUT" | |
| fi | |
| ;; | |
| *) | |
| echo "::warning::Holding ${UPDATE_TYPE:-unknown update type} for a human." | |
| echo "should_merge=false" >>"$GITHUB_OUTPUT" | |
| ;; | |
| esac | |
| # The label is re-read live rather than taken from the event payload. A label applied | |
| # while this run was in flight is not in that payload, and the disarm workflow runs in | |
| # its own concurrency group, so it can finish before this step arms -- leaving the pull | |
| # request armed with the kill switch applied. Checking here closes that ordering gap; | |
| # the disarm workflow covers the label arriving afterwards. | |
| - name: Re-check the kill switch, then approve and arm | |
| if: steps.eligible.outputs.should_merge == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} | |
| run: | | |
| # Read into a variable first, under `set -e`. Piping the query straight into grep | |
| # would read a failed query as "label absent" -- grep sees no input, matches | |
| # nothing, and the step arms without having verified the switch at all. | |
| set -euo pipefail | |
| labels=$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name') | |
| # Case-insensitive and fixed-string: GitHub treats label names as unique | |
| # case-insensitively, so "Do Not Auto-Merge" is the same switch to a human and has | |
| # to be to this check too. | |
| if grep -qixF 'do not auto-merge' <<< "$labels"; then | |
| echo "::warning::do not auto-merge applied since this run started; not arming." | |
| exit 0 | |
| fi | |
| # Approving and arming in one step, so the two stand or fall together. Approving in | |
| # a step of its own left a standing approval on a pull request the next step then | |
| # declined to arm, and nothing withdraws it: the disarm workflow turns auto-merge | |
| # off and does not dismiss a review. | |
| # | |
| # Not optional here, unlike in the siblings that require no review: `main-protect` | |
| # and `require-maintainer-review` each ask for one approving review, so without | |
| # this step auto-merge would arm and then wait forever on a requirement no bot | |
| # pull request can satisfy by itself. The failure is still tolerated rather than | |
| # fatal: if Actions approval is ever disallowed at the repository or organization | |
| # level, auto-merge waits for a human, which is the safe direction to fail in. | |
| decision=$(gh pr view "$PR_NUMBER" --json reviewDecision --jq .reviewDecision) | |
| if [ "$decision" = "APPROVED" ]; then | |
| echo "::notice::Already approved." | |
| else | |
| gh pr review --approve "$PR_NUMBER" || | |
| echo "::warning::approval refused; auto-merge will wait for a reviewer." | |
| fi | |
| gh pr merge --auto --squash "$PR_NUMBER" |