fix(typo3-extension): let a draft PR get its approval when it is marked ready #432
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: Auto-merge dependency PRs | |
| on: | |
| pull_request: | |
| workflow_call: | |
| inputs: | |
| safe-updates-only: | |
| description: >- | |
| When true, only auto-merge safe updates (semver minor/patch for | |
| Dependabot, or Docker non-major bumps). Majors are approved but | |
| left for a human to merge. Renovate is always trusted — its own | |
| config (renovate.json) decides what it opens. | |
| type: boolean | |
| default: false | |
| merge-strategy: | |
| description: >- | |
| Override the merge strategy. One of "squash", "merge", "rebase", | |
| or "" (default) to auto-detect from repo settings. Use this when | |
| the repo has `required_linear_history` or other constraints the | |
| auto-detector can't see. | |
| type: string | |
| default: "" | |
| checks-timeout-minutes: | |
| description: >- | |
| How long to wait for the other checks when the repository has | |
| auto-merge DISABLED and this workflow therefore has to gate the | |
| merge itself (see the note on the merge step). Ignored on repos | |
| with auto-merge enabled, where GitHub does the waiting. Capped by | |
| the job's own 30-minute timeout, so values above 25 have no | |
| effect. Timing out is not a failure: the PR stays approved and | |
| open for a human. | |
| type: number | |
| default: 15 | |
| # CALLER REQUIREMENTS | |
| # =================== | |
| # When calling this reusable workflow, the caller's job-level | |
| # `permissions:` block MUST grant at least this full set, otherwise | |
| # the run fails with `startup_failure` before any job executes | |
| # (GitHub rejects the workflow at startup when the reusable declares | |
| # a permission the caller did not grant). NOTE: setting ANY scope in a | |
| # `permissions:` block forces every UNLISTED scope to `none`, and a job | |
| # that omits `permissions:` inherits the repository default | |
| # (default_workflow_permissions) — so a repo hardened to `read` will | |
| # startup-fail here unless these scopes are granted explicitly. | |
| # | |
| # jobs: | |
| # auto-merge: | |
| # uses: netresearch/.github/.github/workflows/auto-merge-deps.yml@main | |
| # permissions: | |
| # contents: write | |
| # pull-requests: write | |
| permissions: {} | |
| jobs: | |
| auto-merge: | |
| name: Auto-merge dependency PRs | |
| runs-on: ubuntu-latest | |
| # Hard ceiling for the check-gating path below. GitHub expressions | |
| # have no arithmetic, so this cannot be derived from the input — | |
| # it is a fixed bound and `checks-timeout-minutes` above 25 has no | |
| # effect. The auto-merge path finishes in seconds either way. | |
| timeout-minutes: 30 | |
| # The label check sits here as well as on the merge step, so a labelled | |
| # update is not approved either — an approved-but-unmerged pull request | |
| # looks cleared when nobody has looked at it. See the note on that step. | |
| # | |
| # Both label names are honoured: the preset renames deps-major to | |
| # deps-no-automerge, and whichever of the two changes lands first, the | |
| # other name is still in circulation on pull requests Renovate has not | |
| # rewritten yet. Accepting both means the order of the two merges cannot | |
| # open a window in which labelled updates merge unseen. The old name can | |
| # be dropped once no open pull request carries it. | |
| if: >- | |
| (github.event.pull_request.user.login == 'dependabot[bot]' || | |
| github.event.pull_request.user.login == 'renovate[bot]') && | |
| !contains(github.event.pull_request.labels.*.name, 'deps-no-automerge') && | |
| !contains(github.event.pull_request.labels.*.name, 'deps-major') | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 | |
| with: | |
| egress-policy: audit | |
| - name: Approve PR | |
| env: | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: gh pr review --approve "$PR_URL" | |
| - name: Dependabot metadata | |
| id: metadata | |
| if: inputs.safe-updates-only && github.event.pull_request.user.login == 'dependabot[bot]' | |
| uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| # The `deps-no-automerge` label comes from netresearch/renovate-config | |
| # and marks an update Renovate deliberately did NOT mark for automerge: | |
| # a major, or a replacement that swaps the dependency for a differently | |
| # named successor. Without it this workflow merged those too - the | |
| # Dependabot metadata action produces no update-type for Renovate, so | |
| # the condition below waved every Renovate pull request through on the | |
| # reasoning that "Renovate's own config decides what it opens". Opening | |
| # is not the same as wanting it merged, and the result was that this | |
| # workflow overrode the org policy it was supposed to follow. | |
| # | |
| # Checked on the job as well as the step, so the label also stops the | |
| # approval - approving one and then not merging it would leave a pull | |
| # request that looks cleared when nobody has looked at it. | |
| - name: Enable auto-merge | |
| if: >- | |
| !contains(github.event.pull_request.labels.*.name, 'deps-no-automerge') && | |
| !contains(github.event.pull_request.labels.*.name, 'deps-major') && | |
| (!inputs.safe-updates-only || | |
| github.event.pull_request.user.login == 'renovate[bot]' || | |
| steps.metadata.outputs.update-type == 'version-update:semver-minor' || | |
| steps.metadata.outputs.update-type == 'version-update:semver-patch' || | |
| (steps.metadata.outputs.package-ecosystem == 'docker' && | |
| steps.metadata.outputs.update-type != 'version-update:semver-major')) | |
| env: | |
| PR_URL: ${{ github.event.pull_request.html_url }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| STRATEGY_OVERRIDE: ${{ inputs.merge-strategy }} | |
| CHECKS_TIMEOUT_MINUTES: ${{ inputs.checks-timeout-minutes || 15 }} | |
| run: | | |
| set -euo pipefail | |
| REPO_JSON=$(gh api "repos/$REPO") | |
| if [[ -n "$STRATEGY_OVERRIDE" ]]; then | |
| STRATEGY="--$STRATEGY_OVERRIDE" | |
| else | |
| STRATEGY=$(printf '%s' "$REPO_JSON" | jq -r ' | |
| if .allow_squash_merge then "--squash" | |
| elif .allow_merge_commit then "--merge" | |
| elif .allow_rebase_merge then "--rebase" | |
| else "--squash" end') | |
| fi | |
| echo "Using merge strategy: $STRATEGY" | |
| ALLOW_AUTO_MERGE=$(printf '%s' "$REPO_JSON" | jq -r '.allow_auto_merge // false') | |
| # `--auto` is only a GATE where GitHub actually has something to hold | |
| # the merge on. Native auto-merge waits for REQUIRED status checks and | |
| # required reviews - not for "whatever is currently running". On a | |
| # branch with none required it does not wait at all: the PR either | |
| # merges the moment it is mergeable, before CI reports, or the command | |
| # errors out because the PR is already in a clean state. | |
| # | |
| # Branching on `allow_auto_merge` alone therefore silently drops the | |
| # CI gate on every repo that permits auto-merge without requiring any | |
| # check - which, measured across this org on 2026-07-23, is nearly all | |
| # of them (netresearch/.github included; only timetracker carried a | |
| # required-status-checks rule). So establish that a real gate exists | |
| # before trusting GitHub to enforce one. | |
| NATIVE_GATE=false | |
| if [ "$ALLOW_AUTO_MERGE" = "true" ]; then | |
| # Rulesets are readable with ordinary read access. | |
| if RULES=$(gh api "repos/$REPO/rules/branches/$BASE_REF" 2>/dev/null); then | |
| if printf '%s' "$RULES" | jq -e 'any(.[]?; .type == "required_status_checks")' >/dev/null 2>&1; then | |
| NATIVE_GATE=true | |
| fi | |
| fi | |
| # Classic branch protection needs admin rights, so this call may | |
| # 403. That is fine: an unreadable protection config counts as | |
| # "cannot prove a gate exists" and falls through to gating here - | |
| # slower, never unsafe. | |
| if [ "$NATIVE_GATE" != "true" ] && PROT=$(gh api "repos/$REPO/branches/$BASE_REF/protection" 2>/dev/null); then | |
| REQ_COUNT=$(printf '%s' "$PROT" | jq '((.required_status_checks.contexts // []) + (.required_status_checks.checks // [])) | length' 2>/dev/null || echo 0) | |
| if [ "${REQ_COUNT:-0}" -gt 0 ]; then | |
| NATIVE_GATE=true | |
| fi | |
| fi | |
| fi | |
| if [ "$NATIVE_GATE" = "true" ]; then | |
| echo "Base branch '$BASE_REF' has required status checks - letting GitHub hold the merge." | |
| # Re-arm rather than re-request. An auto-merge request already | |
| # attached to this PR is NOT refreshed by asking again: `gh pr | |
| # merge --auto` succeeds, prints nothing unusual, and leaves the | |
| # OLD request in place. That matters because the old request was | |
| # evaluated against the branch rules as they were when it was | |
| # created — GitHub does not re-evaluate it when those rules change. | |
| # A request created before a rule change can therefore sit on the | |
| # PR forever, never completing. | |
| # | |
| # Worse, while it sits there it silently swallows every later | |
| # attempt to merge or enqueue the PR by hand: the command reports | |
| # success and nothing happens. Four dependency PRs across this org | |
| # were stuck that way on 2026-08-08, the oldest since 2026-07-30, | |
| # and the state is invisible unless you query autoMergeRequest | |
| # directly. | |
| # | |
| # Dropping the request first makes each run of this workflow | |
| # create a fresh one under the rules in force right now. | |
| if gh pr view "$PR_URL" --json autoMergeRequest \ | |
| --jq '.autoMergeRequest != null' 2>/dev/null | grep -q true; then | |
| echo "::notice::Clearing a pre-existing auto-merge request before re-arming." | |
| gh pr merge --disable-auto "$PR_URL" || true | |
| fi | |
| # `enablePullRequestAutoMerge` is refused outright when the PR | |
| # touches .github/workflows and the token has no `workflows` | |
| # scope: "refusing to allow a GitHub App to create or update | |
| # workflow ... without `workflows` permission". Renovate bumping | |
| # a GitHub Action hits this every time, and the scope cannot | |
| # simply be added here - the caller has to grant it too, so a | |
| # new permission in this file startup-fails every caller that | |
| # has not been updated yet (see CALLER REQUIREMENTS above). | |
| # | |
| # Falling through to the gating path below is not a workaround | |
| # but the correct handling: it waits for the same checks itself | |
| # and then merges over REST, which is not subject to that | |
| # restriction. Measured 2026-08-09 in netresearch/git-workflow- | |
| # skill, where PR #153 changed a workflow file and merged over | |
| # exactly this path with these permissions. | |
| if gh pr merge --auto "$STRATEGY" "$PR_URL" 2>"$RUNNER_TEMP/automerge.err"; then | |
| exit 0 | |
| fi | |
| sed 's/^/ /' "$RUNNER_TEMP/automerge.err" >&2 || true | |
| echo "::notice::Native auto-merge was refused - gating on the checks here instead." | |
| fi | |
| # Reached three ways: auto-merge is disabled, it is enabled but | |
| # nothing is required on the base branch, or GitHub refused the | |
| # native request above. In all three nothing outside this job | |
| # keeps an unverified dependency bump from landing, so gate here. | |
| # | |
| # This is why repos in that state used to keep a hand-rolled copy of | |
| # this workflow. They no longer have to. | |
| if [ "$NATIVE_GATE" != "true" ]; then | |
| echo "No required status checks on '$BASE_REF' (allow_auto_merge=$ALLOW_AUTO_MERGE) - gating on the checks here." | |
| fi | |
| DEADLINE=$(( $(date +%s) + ${CHECKS_TIMEOUT_MINUTES:-15} * 60 )) | |
| while :; do | |
| if [ "$(date +%s)" -ge "$DEADLINE" ]; then | |
| echo "::notice::Checks did not all pass within ${CHECKS_TIMEOUT_MINUTES} minutes — approved, not merged." | |
| exit 0 | |
| fi | |
| # This job's own check must be excluded, or it waits on itself | |
| # forever. Match by SUFFIX, not equality: when this reusable is | |
| # called as a job, the check surfaces as "<caller-job> / Auto-merge | |
| # dependency PRs" (the caller's job id/name is prefixed), so an | |
| # exact `!= "Auto-merge dependency PRs"` never matches and the loop | |
| # counts its own IN_PROGRESS check as pending — waiting on itself | |
| # until the timeout, and orphaning every bot PR in a repo without | |
| # required checks. `endswith` matches whether the caller prefixes it | |
| # or not (this reusable owns the job name that forms the suffix). | |
| # | |
| # A FAILED API call must never be read as "no checks": falling back | |
| # to an empty array here would make the two counters below zero and | |
| # the loop would conclude everything passed and merge an unverified | |
| # PR on a transient error. Retry until the deadline instead. | |
| if ! CHECKS=$(gh pr checks "$PR_URL" --json name,state \ | |
| --jq '[.[] | select((.name | endswith("Auto-merge dependency PRs")) | not)]' 2>/dev/null); then | |
| echo "Could not read the check list — retrying." | |
| sleep 15 | |
| continue | |
| fi | |
| FAILED=$(printf '%s' "$CHECKS" | jq '[.[] | select(.state == "FAILURE" or .state == "ERROR" or .state == "TIMED_OUT" or .state == "CANCELLED")] | length') | |
| PENDING=$(printf '%s' "$CHECKS" | jq '[.[] | select(.state == "PENDING" or .state == "QUEUED" or .state == "IN_PROGRESS" or .state == "WAITING")] | length') | |
| if [ "$FAILED" != "0" ]; then | |
| echo "::notice::${FAILED} check(s) failed — approved, not merged." | |
| exit 0 | |
| fi | |
| if [ "$PENDING" = "0" ]; then | |
| echo "All checks passed." | |
| break | |
| fi | |
| echo "Waiting for ${PENDING} check(s)..." | |
| sleep 15 | |
| done | |
| # A refused merge leaves the PR approved and open, which is the | |
| # same outcome as the two notices above - so report it the same | |
| # way rather than failing the job. A failed job turns this into a | |
| # red check on the PR, and a red check is exactly what holds the | |
| # PR at UNSTABLE and keeps every other route to merging it shut. | |
| # Failing here would therefore make the PR harder to land by hand | |
| # than if this workflow had never run. | |
| if ! gh pr merge "$STRATEGY" "$PR_URL" 2>"$RUNNER_TEMP/merge.err"; then | |
| sed 's/^/ /' "$RUNNER_TEMP/merge.err" >&2 || true | |
| echo "::notice::Merge was refused — approved, not merged." | |
| fi |