From b67e3e6105230f005c7db9b9519d202e6dd35515 Mon Sep 17 00:00:00 2001 From: Doreen Date: Tue, 22 Sep 2026 13:56:04 +0200 Subject: [PATCH 1/3] fix: stop auto-approving pushes onto Renovate branches --- .github/workflows/renovate-auto-approve.yml | 23 +++++++++++++++++---- docs/NEW_REPO.md | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/renovate-auto-approve.yml b/.github/workflows/renovate-auto-approve.yml index 9b50826..6d81fef 100644 --- a/.github/workflows/renovate-auto-approve.yml +++ b/.github/workflows/renovate-auto-approve.yml @@ -9,9 +9,12 @@ name: renovate-auto-approve # pull-requests: write # # It approves a Renovate PR that carries the automerge label and not the block -# label (security). If a PR later gains the block label, it revokes its own -# earlier approval so a human is required again. The merge itself always stays -# gated on the repo's required status checks. +# label (security), and only while the branch still holds exactly one commit, +# authored by Renovate and signed. Once anyone pushes onto the branch the PR +# stops qualifying, because those changes were never reviewed by anyone. +# If a PR later gains the block label, the workflow revokes its own earlier +# approval so a human is required again. The merge itself always stays gated on +# the repo's required status checks. # # Which update types are eligible (the automerge label) is decided by the # renovate-config presets, not here: github>opendefensecloud/renovate-config. @@ -48,7 +51,7 @@ jobs: (contains(github.event.pull_request.labels.*.name, inputs.automerge-label) || contains(github.event.pull_request.labels.*.name, inputs.block-label)) steps: - - name: Approve, or revoke a prior approval if the block label appears + - name: Approve an unmodified Renovate PR, or revoke a prior approval env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -56,6 +59,7 @@ jobs: LABEL: ${{ inputs.automerge-label }} BLOCK_LABEL: ${{ inputs.block-label }} HAS_BLOCK: ${{ contains(github.event.pull_request.labels.*.name, inputs.block-label) }} + RENOVATE_ACTOR: ${{ inputs.renovate-actor }} run: | set -euo pipefail # Latest review state left by this workflow's identity (github-actions[bot]). @@ -71,6 +75,17 @@ jobs: fi exit 0 fi + # The job-level gate only checks who opened the PR. Also check that + # Renovate is the only contributor to the branch, that it pushed + # exactly one commit, and that the commit is signed. + commits=$(gh api "repos/$GH_REPO/pulls/$PR_NUMBER/commits") + if ! jq -e --arg actor "$RENOVATE_ACTOR" ' + length == 1 and .[0].author.login == $actor + and .[0].commit.verification.verified == true' <<<"$commits" >/dev/null; then + echo "Not approving: the branch must hold exactly one signed $RENOVATE_ACTOR commit," \ + "but it has more than one commit, or its commit is unsigned or not from $RENOVATE_ACTOR." + exit 0 + fi if [ "$latest_state" = "APPROVED" ]; then echo "Already approved by github-actions; nothing to do." exit 0 diff --git a/docs/NEW_REPO.md b/docs/NEW_REPO.md index 95dda7c..466a727 100644 --- a/docs/NEW_REPO.md +++ b/docs/NEW_REPO.md @@ -104,7 +104,7 @@ preCommitHooks = { ### Renovate auto-approve (optional) -To let Renovate auto-merge digest, patch, and minor PRs without a manual approval, copy `example/.github/workflows/renovate-auto-approve.yml` into your project and pin the `uses:` ref to a dev-kit release tag or commit SHA. It calls dev-kit's reusable `renovate-auto-approve.yml`, which approves Renovate PRs labeled `automerge` and skips (and revokes) anything labeled `security`. +To let Renovate auto-merge digest, patch, and minor PRs without a manual approval, copy `example/.github/workflows/renovate-auto-approve.yml` into your project and pin the `uses:` ref to a dev-kit release tag or commit SHA. It calls dev-kit's reusable `renovate-auto-approve.yml`, which approves Renovate PRs labeled `automerge` and skips (and revokes) anything labeled `security`. It approves a PR only while the branch is still exactly one signed Renovate commit, so pushing onto a Renovate branch hands the review back to a human. Two more things are needed: From d111a198c38f1df2a23669a5a5182772d725e325 Mon Sep 17 00:00:00 2001 From: Doreen Date: Fri, 25 Sep 2026 13:20:53 +0200 Subject: [PATCH 2/3] fix: ensure the branch has been pushed by Renovate and dismiss stale approval --- .github/workflows/renovate-auto-approve.yml | 39 ++++++++++++------- docs/NEW_REPO.md | 2 +- .../workflows/renovate-auto-approve.yml | 2 + 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.github/workflows/renovate-auto-approve.yml b/.github/workflows/renovate-auto-approve.yml index 6d81fef..6d65d3c 100644 --- a/.github/workflows/renovate-auto-approve.yml +++ b/.github/workflows/renovate-auto-approve.yml @@ -6,15 +6,16 @@ name: renovate-auto-approve # approve: # uses: opendefensecloud/dev-kit/.github/workflows/renovate-auto-approve.yml@ # permissions: +# contents: read # pull-requests: write # # It approves a Renovate PR that carries the automerge label and not the block # label (security), and only while the branch still holds exactly one commit, -# authored by Renovate and signed. Once anyone pushes onto the branch the PR -# stops qualifying, because those changes were never reviewed by anyone. -# If a PR later gains the block label, the workflow revokes its own earlier -# approval so a human is required again. The merge itself always stays gated on -# the repo's required status checks. +# authored by Renovate and signed, and pushed by Renovate. Once someone pushes +# onto the branch it stops qualifying, because that change was never reviewed by +# anyone. If a PR stops qualifying or later gains the block label, the workflow +# revokes its own earlier approval so a human is required again. The merge +# itself always stays gated on the repo's required status checks. # # Which update types are eligible (the automerge label) is decided by the # renovate-config presets, not here: github>opendefensecloud/renovate-config. @@ -36,6 +37,7 @@ on: default: renovate[bot] permissions: + contents: read # repository activity API, to see who pushed the head pull-requests: write concurrency: @@ -56,6 +58,7 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} LABEL: ${{ inputs.automerge-label }} BLOCK_LABEL: ${{ inputs.block-label }} HAS_BLOCK: ${{ contains(github.event.pull_request.labels.*.name, inputs.block-label) }} @@ -76,14 +79,24 @@ jobs: exit 0 fi # The job-level gate only checks who opened the PR. Also check that - # Renovate is the only contributor to the branch, that it pushed - # exactly one commit, and that the commit is signed. - commits=$(gh api "repos/$GH_REPO/pulls/$PR_NUMBER/commits") - if ! jq -e --arg actor "$RENOVATE_ACTOR" ' - length == 1 and .[0].author.login == $actor - and .[0].commit.verification.verified == true' <<<"$commits" >/dev/null; then - echo "Not approving: the branch must hold exactly one signed $RENOVATE_ACTOR commit," \ - "but it has more than one commit, or its commit is unsigned or not from $RENOVATE_ACTOR." + # the branch holds exactly one commit, authored by Renovate, signed, and + # pushed by Renovate. + commits=$(gh api -X GET "repos/$GH_REPO/pulls/$PR_NUMBER/commits" -f per_page=2) + activity=$(gh api -X GET "repos/$GH_REPO/activity" -f ref="refs/heads/$HEAD_REF" -f per_page=1) + if ! jq -e -n --arg actor "$RENOVATE_ACTOR" --argjson commits "$commits" --argjson activity "$activity" ' + ($commits | length) == 1 + and $commits[0].author.login == $actor + and $commits[0].commit.verification.verified == true + and $activity[0].after == $commits[0].sha + and $activity[0].actor.login == $actor' >/dev/null; then + echo "Not approving: the branch must hold exactly one signed $RENOVATE_ACTOR commit, pushed by $RENOVATE_ACTOR," \ + "but it has more than one commit, or its commit is unsigned, not from $RENOVATE_ACTOR, or pushed by someone else." + # Dismiss a stale approval + if [ "$latest_state" = "APPROVED" ]; then + echo "Revoking the earlier automated approval." + gh pr review "$PR_NUMBER" --request-changes \ + --body "Revoked: the branch is no longer a single signed commit pushed by \`$RENOVATE_ACTOR\` and needs a human review." + fi exit 0 fi if [ "$latest_state" = "APPROVED" ]; then diff --git a/docs/NEW_REPO.md b/docs/NEW_REPO.md index 466a727..1c27715 100644 --- a/docs/NEW_REPO.md +++ b/docs/NEW_REPO.md @@ -104,7 +104,7 @@ preCommitHooks = { ### Renovate auto-approve (optional) -To let Renovate auto-merge digest, patch, and minor PRs without a manual approval, copy `example/.github/workflows/renovate-auto-approve.yml` into your project and pin the `uses:` ref to a dev-kit release tag or commit SHA. It calls dev-kit's reusable `renovate-auto-approve.yml`, which approves Renovate PRs labeled `automerge` and skips (and revokes) anything labeled `security`. It approves a PR only while the branch is still exactly one signed Renovate commit, so pushing onto a Renovate branch hands the review back to a human. +To let Renovate auto-merge digest, patch, and minor PRs without a manual approval, copy `example/.github/workflows/renovate-auto-approve.yml` into your project and pin the `uses:` ref to a dev-kit release tag or commit SHA. It calls dev-kit's reusable `renovate-auto-approve.yml`, which approves Renovate PRs labeled `automerge` and skips (and revokes) anything labeled `security`. It approves a PR only while the branch is still exactly one signed Renovate commit that Renovate itself pushed, so pushing onto a Renovate branch, including amending or rebasing its commit, revokes the automated approval and hands the review back to a human. Two more things are needed: diff --git a/example/.github/workflows/renovate-auto-approve.yml b/example/.github/workflows/renovate-auto-approve.yml index 9c762a4..9704520 100644 --- a/example/.github/workflows/renovate-auto-approve.yml +++ b/example/.github/workflows/renovate-auto-approve.yml @@ -6,10 +6,12 @@ on: pull_request: types: [opened, reopened, synchronize, labeled, unlabeled] permissions: + contents: read pull-requests: write jobs: approve: # Pin @ to a dev-kit release tag or commit SHA before using this. uses: opendefensecloud/dev-kit/.github/workflows/renovate-auto-approve.yml@ permissions: + contents: read pull-requests: write From fb22bc9ed9efee5f1cb58c525a48a8474579103c Mon Sep 17 00:00:00 2001 From: Doreen Date: Fri, 25 Sep 2026 13:56:34 +0200 Subject: [PATCH 3/3] fix: ensure the automerge label was added by Renovate --- .github/workflows/renovate-auto-approve.yml | 54 ++++++++++++++------- docs/NEW_REPO.md | 2 +- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/.github/workflows/renovate-auto-approve.yml b/.github/workflows/renovate-auto-approve.yml index 6d65d3c..7078d44 100644 --- a/.github/workflows/renovate-auto-approve.yml +++ b/.github/workflows/renovate-auto-approve.yml @@ -9,9 +9,10 @@ name: renovate-auto-approve # contents: read # pull-requests: write # -# It approves a Renovate PR that carries the automerge label and not the block -# label (security), and only while the branch still holds exactly one commit, -# authored by Renovate and signed, and pushed by Renovate. Once someone pushes +# It approves a Renovate PR that carries the automerge label, added by Renovate, +# and not the block label (security), and only while the branch still holds +# exactly one commit, authored by Renovate and signed, and pushed by Renovate. +# Once someone pushes # onto the branch it stops qualifying, because that change was never reviewed by # anyone. If a PR stops qualifying or later gains the block label, the workflow # revokes its own earlier approval so a human is required again. The merge @@ -78,26 +79,45 @@ jobs: fi exit 0 fi - # The job-level gate only checks who opened the PR. Also check that - # the branch holds exactly one commit, authored by Renovate, signed, and - # pushed by Renovate. - commits=$(gh api -X GET "repos/$GH_REPO/pulls/$PR_NUMBER/commits" -f per_page=2) - activity=$(gh api -X GET "repos/$GH_REPO/activity" -f ref="refs/heads/$HEAD_REF" -f per_page=1) - if ! jq -e -n --arg actor "$RENOVATE_ACTOR" --argjson commits "$commits" --argjson activity "$activity" ' - ($commits | length) == 1 - and $commits[0].author.login == $actor - and $commits[0].commit.verification.verified == true - and $activity[0].after == $commits[0].sha - and $activity[0].actor.login == $actor' >/dev/null; then - echo "Not approving: the branch must hold exactly one signed $RENOVATE_ACTOR commit, pushed by $RENOVATE_ACTOR," \ - "but it has more than one commit, or its commit is unsigned, not from $RENOVATE_ACTOR, or pushed by someone else." + # Stops without approving, and revokes an earlier automated approval. + reject() { + echo "Not approving: $1." # Dismiss a stale approval if [ "$latest_state" = "APPROVED" ]; then echo "Revoking the earlier automated approval." gh pr review "$PR_NUMBER" --request-changes \ - --body "Revoked: the branch is no longer a single signed commit pushed by \`$RENOVATE_ACTOR\` and needs a human review." + --body "Revoked: $1. This PR needs a human review." fi exit 0 + } + # The job-level gate only checks who opened the PR. Also check that + # the branch holds exactly one commit, authored by Renovate, signed, and + # pushed by Renovate, and that Renovate itself added the automerge label. + commits=$(gh api -X GET "repos/$GH_REPO/pulls/$PR_NUMBER/commits" -f per_page=2) + if [ "$(jq 'length' <<<"$commits")" != 1 ]; then + reject "the branch holds more than one commit" + fi + head_sha=$(jq -r '.[0].sha' <<<"$commits") + author=$(jq -r '.[0].author.login // empty' <<<"$commits") + if [ "$author" != "$RENOVATE_ACTOR" ]; then + reject "commit $head_sha is authored by '${author:-unknown}', not $RENOVATE_ACTOR" + fi + if [ "$(jq -r '.[0].commit.verification.verified' <<<"$commits")" != true ]; then + reject "commit $head_sha is not signed" + fi + activity=$(gh api -X GET "repos/$GH_REPO/activity" -f ref="refs/heads/$HEAD_REF" -f per_page=1) + if [ "$(jq -r '.[0].after // empty' <<<"$activity")" != "$head_sha" ]; then + reject "the latest push to $HEAD_REF is not the push of commit $head_sha" + fi + pusher=$(jq -r '.[0].actor.login // empty' <<<"$activity") + if [ "$pusher" != "$RENOVATE_ACTOR" ]; then + reject "commit $head_sha was pushed by '$pusher', not $RENOVATE_ACTOR" + fi + # Events come oldest first; keep who added the automerge label last. + labeler=$(gh api --paginate "repos/$GH_REPO/issues/$PR_NUMBER/events" \ + --jq '.[] | select(.event == "labeled" and .label.name == env.LABEL) | .actor.login' | tail -n 1) + if [ "$labeler" != "$RENOVATE_ACTOR" ]; then + reject "the $LABEL label was added by '$labeler', not $RENOVATE_ACTOR" fi if [ "$latest_state" = "APPROVED" ]; then echo "Already approved by github-actions; nothing to do." diff --git a/docs/NEW_REPO.md b/docs/NEW_REPO.md index 1c27715..872ccfe 100644 --- a/docs/NEW_REPO.md +++ b/docs/NEW_REPO.md @@ -104,7 +104,7 @@ preCommitHooks = { ### Renovate auto-approve (optional) -To let Renovate auto-merge digest, patch, and minor PRs without a manual approval, copy `example/.github/workflows/renovate-auto-approve.yml` into your project and pin the `uses:` ref to a dev-kit release tag or commit SHA. It calls dev-kit's reusable `renovate-auto-approve.yml`, which approves Renovate PRs labeled `automerge` and skips (and revokes) anything labeled `security`. It approves a PR only while the branch is still exactly one signed Renovate commit that Renovate itself pushed, so pushing onto a Renovate branch, including amending or rebasing its commit, revokes the automated approval and hands the review back to a human. +To let Renovate auto-merge digest, patch, and minor PRs without a manual approval, copy `example/.github/workflows/renovate-auto-approve.yml` into your project and pin the `uses:` ref to a dev-kit release tag or commit SHA. It calls dev-kit's reusable `renovate-auto-approve.yml`, which approves Renovate PRs labeled `automerge` by Renovate itself and skips (and revokes) anything labeled `security`. It approves a PR only while the branch is still exactly one signed Renovate commit that Renovate itself pushed, so pushing onto a Renovate branch, including amending or rebasing its commit, revokes the automated approval and hands the review back to a human. Two more things are needed: