From 019a8133388453df322fa2fd0bc8bffac6672a8a Mon Sep 17 00:00:00 2001 From: Alejandro Ponce Date: Wed, 22 Jul 2026 13:51:54 +0300 Subject: [PATCH 1/3] Add release automation and Renovate auto-merge Mirrors toolhive's releaseo-driven release flow, adapted for toolhive-core (VERSION-only bumps, no Helm chart steps), and turns on Renovate auto-merge gated on green required checks. First upstream hop of the CVE remediation cascade (stacklok-enterprise-platform#2190). - VERSION: seed to 0.0.26 (current latest tag) - renovate.json: automerge + platformAutomerge, minimumReleaseAge 0, add the `automerge` approver-app label - create-release-pr.yml: manual patch/minor/major release PR (VERSION only) - create-release-tag.yml: tag + Release on VERSION change to main - release-check.yml: daily, idempotent single release PR, classifies dependency-only vs functional and gates functional on a human Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/create-release-pr.yml | 93 ++++++++ .github/workflows/create-release-tag.yml | 203 +++++++++++++++++ .github/workflows/release-check.yml | 272 +++++++++++++++++++++++ VERSION | 1 + renovate.json | 6 +- 5 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/create-release-pr.yml create mode 100644 .github/workflows/create-release-tag.yml create mode 100644 .github/workflows/release-check.yml create mode 100644 VERSION diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml new file mode 100644 index 0000000..c6d8a14 --- /dev/null +++ b/.github/workflows/create-release-pr.yml @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +# SPDX-License-Identifier: Apache-2.0 + +# Create Release PR workflow using releaseo +# +# Manually cuts a release PR that bumps ONLY the VERSION file. toolhive-core +# ships no Helm chart, so unlike toolhive there are no chart/values/helm-docs +# steps here. +# +# Auto-releases (the daily release-check job) always use a `patch` bump. Use +# this workflow for the `minor`/`major` bumps that must stay behind a human. +# +# Usage: Actions tab -> "Create Release PR" -> Run workflow, or +# gh workflow run create-release-pr.yml -f bump_type=minor + +name: Create Release PR + +on: + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + pull-requests: write + +jobs: + release: + name: Create Release PR + runs-on: ubuntu-latest + steps: + - name: Generate release app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Remove a stale release branch left by a previous failed run, so releaseo + # doesn't fail with "Reference already exists" when it recreates the branch. + # Only deletes when the branch exists with no open PR. + - name: Clean up stale release branch from previous failed run + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BUMP_TYPE: ${{ inputs.bump_type }} + run: | + CURRENT=$(tr -d '[:space:]v' < VERSION) + IFS='.' read -r x y z <<< "$CURRENT" + case "$BUMP_TYPE" in + patch) z=$((z+1));; + minor) y=$((y+1)); z=0;; + major) x=$((x+1)); y=0; z=0;; + *) echo "Unknown bump type: $BUMP_TYPE"; exit 1;; + esac + NEW_VERSION="${x}.${y}.${z}" + BRANCH="release/v${NEW_VERSION}" + OPEN_PR=$(gh pr list --head "$BRANCH" --state open --json number -q 'length' 2>/dev/null || echo "0") + if [ "$OPEN_PR" = "0" ] || [ -z "$OPEN_PR" ]; then + echo "Deleting stale branch $BRANCH if it exists (from previous failed run)..." + gh api -X DELETE "/repos/${{ github.repository }}/git/refs/heads/${BRANCH}" 2>/dev/null || true + else + echo "Branch $BRANCH has an open PR - skipping cleanup. Close or merge the existing PR first." + exit 1 + fi + + - name: Create Release PR + id: release + uses: stacklok/releaseo@80e8d8131d41cf8763254d02360f2c5ce9b7c0df # v0.0.4 + with: + releaseo_version: v0.0.4 + bump_type: ${{ inputs.bump_type }} + token: ${{ steps.app-token.outputs.token }} + + - name: Summary + run: | + { + echo "## Release PR Created" + echo "" + echo "- **Version**: ${{ steps.release.outputs.version }}" + echo "- **PR**: #${{ steps.release.outputs.pr_number }}" + echo "- **URL**: ${{ steps.release.outputs.pr_url }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml new file mode 100644 index 0000000..375ab33 --- /dev/null +++ b/.github/workflows/create-release-tag.yml @@ -0,0 +1,203 @@ +# SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +# SPDX-License-Identifier: Apache-2.0 + +# Create Release Tag Workflow +# +# Triggered when the VERSION file changes on main (i.e. a release PR merged). +# It verifies the commit came from a release PR, then creates the git tag AND +# the GitHub Release in one atomic `gh release create` call. +# +# Why atomic (differs from toolhive): toolhive's downstream releaser triggers on +# the `release` event, so it pushes the tag first and creates the Release after. +# toolhive-core's existing release.yml triggers on `push: tags: v*` and runs +# `gh release upload`, which needs the Release to already exist. Creating the tag +# via `gh release create` makes the tag and Release appear together, so by the +# time release.yml fires on the tag push the Release is there to upload into. +# +# The app token (not GITHUB_TOKEN) is used so the tag push can trigger +# release.yml -- events from GITHUB_TOKEN do not cascade to other workflows. + +name: Create Release Tag + +on: + push: + branches: + - main + paths: + - 'VERSION' + +permissions: + contents: write + +jobs: + create-tag: + runs-on: ubuntu-latest + steps: + - name: Generate release app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Read version + id: version + run: | + VERSION=$(tr -d '[:space:]' < VERSION) + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: VERSION file does not contain valid semver: $VERSION" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Read version: $VERSION" + + - name: Verify release PR + id: verify + run: | + VERSION="${{ steps.version.outputs.version }}" + + COMMIT_MSG=$(git log -1 --pretty=%s) + COMMIT_SHA=$(git rev-parse HEAD) + + echo "Commit SHA: $COMMIT_SHA" + echo "Commit message: $COMMIT_MSG" + echo "" + + VERIFIED=true + + # Check 1: commit message matches a release pattern. + # Squash merge: "Release v1.0.0 (#123)" + # Merge commit: "Merge pull request #123 from stacklok/release/v1.0.0" + # Direct: "Release v1.0.0" + if [[ "$COMMIT_MSG" =~ ^Release\ v[0-9]+\.[0-9]+\.[0-9]+ ]] || \ + [[ "$COMMIT_MSG" =~ release/v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "✅ Commit message matches release pattern" + else + echo "❌ Commit message does not match release pattern" + echo "Expected: 'Release v{semver}' or merge from 'release/v{semver}'" + echo "Got: '$COMMIT_MSG'" + VERIFIED=false + fi + + # Check 2: the version in the commit message matches the VERSION file. + if [[ "$COMMIT_MSG" =~ v${VERSION}(\ |$|\)) ]]; then + echo "✅ VERSION file matches version in commit message" + else + echo "❌ VERSION file does not match version in commit message" + echo "VERSION file: $VERSION" + echo "Commit message: $COMMIT_MSG" + VERIFIED=false + fi + + echo "" + if [ "$VERIFIED" = true ]; then + echo "✅ All verification checks passed" + else + echo "❌ Verification failed" + echo "" + echo "This could indicate:" + echo " - A manual VERSION file edit (not via release PR)" + echo " - An unexpected commit message format" + echo "" + echo "Blocking release. Please investigate." + exit 1 + fi + + - name: Extract release triggering actor + id: actor + run: | + # The Release-Triggered-By trailer is added by releaseo to preserve the + # original workflow triggerer. + TRIGGERED_BY=$(git log -1 --format='%(trailers:key=Release-Triggered-By,valueonly)' | tr -d '[:space:]') + if [ -n "$TRIGGERED_BY" ]; then + echo "✅ Found release triggering actor: $TRIGGERED_BY" + echo "triggered_by=$TRIGGERED_BY" >> "$GITHUB_OUTPUT" + else + echo "⚠️ No Release-Triggered-By trailer found in commit" + echo "triggered_by=" >> "$GITHUB_OUTPUT" + fi + + - name: Check if git tag exists + id: check-tag + run: | + TAG="v${{ steps.version.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "Tag $TAG does not exist" + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Check if GitHub Release exists + id: check-release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + TAG="v${{ steps.version.outputs.version }}" + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub Release $TAG already exists" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "GitHub Release $TAG does not exist" + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + # Create the tag and Release together. `gh release create` creates the tag + # ref at --target when it doesn't exist, and points at the existing tag when + # it does, so this is idempotent and safe to re-run. + - name: Create tag and GitHub Release + if: steps.check-release.outputs.exists == 'false' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + TAG="v${{ steps.version.outputs.version }}" + SHA=$(git rev-parse HEAD) + TRIGGERED_BY="${{ steps.actor.outputs.triggered_by }}" + + # Use a GitHub App token (not GITHUB_TOKEN) so the tag push triggers + # release.yml. Include actor metadata as an HTML comment if available. + if [ -n "$TRIGGERED_BY" ]; then + gh release create "$TAG" \ + --target "$SHA" \ + --title "Release $TAG" \ + --generate-notes \ + --notes "" + else + gh release create "$TAG" \ + --target "$SHA" \ + --title "Release $TAG" \ + --generate-notes + fi + echo "Created tag and GitHub Release: $TAG" + + - name: Summary + run: | + TAG="v${{ steps.version.outputs.version }}" + TAG_EXISTED="${{ steps.check-tag.outputs.exists }}" + RELEASE_EXISTED="${{ steps.check-release.outputs.exists }}" + + { + echo "## Release Summary for \`$TAG\`" + echo "" + echo "| Action | Result |" + echo "|--------|--------|" + if [ "$RELEASE_EXISTED" == "true" ]; then + echo "| Git Tag | Already existed |" + echo "| GitHub Release | Already existed |" + else + if [ "$TAG_EXISTED" == "true" ]; then + echo "| Git Tag | Reused existing |" + else + echo "| Git Tag | ✅ Created |" + fi + echo "| GitHub Release | ✅ Created |" + echo "" + echo "\`release.yml\` will now upload schema artifacts to the Release." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml new file mode 100644 index 0000000..e6a4507 --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,272 @@ +# SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +# SPDX-License-Identifier: Apache-2.0 + +# Daily Release Check +# +# Opens or updates EXACTLY ONE release PR for the commits merged to main since +# the last tag, then classifies it: +# +# - dependency-only: every merged PR since the last tag was authored by an +# automation identity (Renovate / the approver bot) AND carries the +# `dependencies` label. Gets the `automerge` label and GitHub auto-merge, so +# it merges once required checks are green (the approver app clears review). +# +# - functional: any merged PR is human-authored or unlabeled. Gets the +# `needs-human` label, no auto-merge, and the PR body lists which PRs tipped +# it to functional. +# +# Fail-safe: if classification is uncertain (e.g. commits exist since the tag but +# no merged PRs can be listed), it is treated as functional. Never auto-release +# when in doubt. +# +# Idempotent: reuses the single open release PR if one exists (never stacks), and +# only creates a new one via releaseo when none is open. Auto-releases always use +# a `patch` bump; minor/major stay behind create-release-pr.yml. + +name: Daily Release Check + +on: + schedule: + # 06:17 UTC daily. toolhive-core is the head of the dependency cascade, so it + # runs early; downstream repos stagger later. Off-zero minute on purpose. + - cron: '17 6 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + release-check: + name: Release Check + runs-on: ubuntu-latest + steps: + - name: Generate release app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Ensure classification labels exist + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh label create automerge --color 0e8a16 --description "Approver app may clear review; auto-merges on green CI" 2>/dev/null || true + gh label create needs-human --color d93f0b --description "Requires human review before merge" 2>/dev/null || true + gh label create dependencies --color ededed --description "Dependency updates" 2>/dev/null || true + + - name: Analyze unreleased commits and classify + id: analyze + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + # Logins (as reported by `gh ... --json author`) treated as automation. + # Renovate is `app/renovate`. Append the approver/release bot login here + # if it ever authors dependency PRs. + AUTOMATION_LOGINS: "app/renovate renovate[bot]" + run: | + set -euo pipefail + + LAST_TAG=$(git tag --list 'v*' --sort=-creatordate | head -1) + if [ -z "$LAST_TAG" ]; then + echo "No existing tag found; cannot establish a baseline. Treating as functional." + LAST_TAG="" + fi + + if [ -n "$LAST_TAG" ]; then + BOUNDARY_EPOCH=$(git log -1 --format=%ct "$LAST_TAG") + SINCE_DATE=$(git log -1 --format=%cI "$LAST_TAG" | cut -dT -f1) + COMMITS=$(git rev-list --count "${LAST_TAG}..HEAD") + else + BOUNDARY_EPOCH=0 + SINCE_DATE="1970-01-01" + COMMITS=$(git rev-list --count HEAD) + fi + echo "last_tag=${LAST_TAG:-} commits_since=$COMMITS boundary_epoch=$BOUNDARY_EPOCH" + + if [ "$COMMITS" -eq 0 ]; then + echo "No unreleased commits since ${LAST_TAG}. Nothing to do." + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # List PRs merged into main on/after the tag date, then filter precisely + # by merge timestamp. Querying merged PRs (not commit messages) is robust + # to squash vs merge-commit strategies. + gh pr list --repo "$REPO" --state merged \ + --search "base:main merged:>=$SINCE_DATE" \ + --limit 200 \ + --json number,title,author,labels,mergedAt > prs.json + + CLASS_JSON=$(jq -c \ + --argjson boundary "$BOUNDARY_EPOCH" \ + --arg autos "$AUTOMATION_LOGINS" ' + ($autos | split(" ")) as $auto + | [ .[] | select((.mergedAt | fromdateiso8601) > $boundary) ] as $rel + | if ($rel | length) == 0 then + { total: 0, functional: true, + bad: ["(commits exist since the last tag but no merged PRs were found; treating as functional)"] } + else + [ $rel[] + | select( + ( ([.author.login] | inside($auto)) and + ([.labels[].name] | index("dependencies") != null) + ) | not + ) + | "- #\(.number) \(.title) (@\(.author.login))" + ] as $bad + | { total: ($rel | length), functional: ($bad | length > 0), bad: $bad } + end + ' prs.json) + + echo "classification json: $CLASS_JSON" + FUNCTIONAL=$(echo "$CLASS_JSON" | jq -r '.functional') + TOTAL=$(echo "$CLASS_JSON" | jq -r '.total') + + if [ "$FUNCTIONAL" = "true" ]; then + CLASSIFICATION="functional" + else + CLASSIFICATION="dependency-only" + fi + + # Write the "why functional" list for the PR body. + echo "$CLASS_JSON" | jq -r '.bad[]' > functional_reasons.txt || true + + # Target version = patch bump of the current VERSION file. + CURRENT=$(tr -d '[:space:]v' < VERSION) + IFS='.' read -r x y z <<< "$CURRENT" + TARGET="${x}.${y}.$((z+1))" + + # Find the single open release PR, if any (idempotency: never stack). + gh pr list --repo "$REPO" --state open \ + --json number,headRefName \ + --jq '[.[] | select(.headRefName | startswith("release/"))] | sort_by(.number)' > open_release_prs.json + EXISTING=$(jq -r '.[0].number // empty' open_release_prs.json) + OPEN_COUNT=$(jq 'length' open_release_prs.json) + if [ "$OPEN_COUNT" -gt 1 ]; then + echo "::warning::Found $OPEN_COUNT open release PRs. Reusing the lowest-numbered (#$EXISTING); close the extras manually." + fi + + { + echo "proceed=true" + echo "classification=$CLASSIFICATION" + echo "target_version=$TARGET" + echo "existing_pr=$EXISTING" + echo "merged_pr_total=$TOTAL" + } >> "$GITHUB_OUTPUT" + + echo "Classification: $CLASSIFICATION (target v$TARGET, existing PR: ${EXISTING:-none})" + + # Only when we will create a fresh PR: clear a stale release branch left by + # a previous failed run so releaseo doesn't hit "Reference already exists". + - name: Clean up stale release branch + if: steps.analyze.outputs.proceed == 'true' && steps.analyze.outputs.existing_pr == '' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + BRANCH="release/v${{ steps.analyze.outputs.target_version }}" + echo "Deleting stale branch $BRANCH if it exists (no open PR uses it)..." + gh api -X DELETE "/repos/${{ github.repository }}/git/refs/heads/${BRANCH}" 2>/dev/null || true + + - name: Create release PR (patch) + id: create + if: steps.analyze.outputs.proceed == 'true' && steps.analyze.outputs.existing_pr == '' + uses: stacklok/releaseo@80e8d8131d41cf8763254d02360f2c5ce9b7c0df # v0.0.4 + with: + releaseo_version: v0.0.4 + bump_type: patch + token: ${{ steps.app-token.outputs.token }} + + - name: Apply classification, labels and auto-merge + if: steps.analyze.outputs.proceed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + CLASSIFICATION: ${{ steps.analyze.outputs.classification }} + TARGET: ${{ steps.analyze.outputs.target_version }} + EXISTING_PR: ${{ steps.analyze.outputs.existing_pr }} + CREATED_PR: ${{ steps.create.outputs.pr_number }} + run: | + set -euo pipefail + PR="${EXISTING_PR:-$CREATED_PR}" + if [ -z "$PR" ]; then + echo "::error::No release PR number resolved; cannot apply classification." + exit 1 + fi + echo "Applying '$CLASSIFICATION' to PR #$PR (v$TARGET)" + + # Rebuild the release-check-managed block in the PR body, preserving the + # rest of the body (releaseo's checklist) across runs. + MARK_START="" + MARK_END="" + CUR=$(gh pr view "$PR" --repo "$REPO" --json body --jq '.body // ""') + # Drop any prior release-check block, then trim trailing blank lines so + # the block doesn't drift down the body on repeated daily runs. + printf '%s' "$CUR" | awk -v s="$MARK_START" -v e="$MARK_END" ' + $0==s {skip=1; next} + $0==e {skip=0; next} + skip==0 {print} + ' | awk '{ a[NR]=$0 } NF { last=NR } END { for (i=1; i<=last; i++) print a[i] }' > body.clean + + { + cat body.clean + echo "" + echo "$MARK_START" + if [ "$CLASSIFICATION" = "dependency-only" ]; then + echo "## Release classification: dependency-only" + echo "" + echo "Every PR merged since the last tag is an automated dependency update." + echo "Labeled \`automerge\`; GitHub auto-merge is enabled and it will merge once" + echo "the required status checks are green and the approver app has approved." + else + echo "## Release classification: needs human review" + echo "" + echo "This release is **not** dependency-only, so it will not auto-merge." + echo "A maintainer needs to review and merge it." + echo "" + echo "PRs that made this a functional release:" + echo "" + if [ -s functional_reasons.txt ]; then + cat functional_reasons.txt + else + echo "- (unable to enumerate; see workflow logs)" + fi + fi + echo "$MARK_END" + } > body.new + + gh pr edit "$PR" --repo "$REPO" --body-file body.new + + if [ "$CLASSIFICATION" = "dependency-only" ]; then + gh pr edit "$PR" --repo "$REPO" --add-label automerge --remove-label needs-human + # Enable GitHub-native auto-merge (squash). Required status checks + + # the approver app's review gate the actual merge. + gh pr merge "$PR" --repo "$REPO" --auto --squash + else + gh pr edit "$PR" --repo "$REPO" --add-label needs-human --remove-label automerge + # Belt and suspenders: make sure auto-merge is not left enabled. + gh pr merge "$PR" --repo "$REPO" --disable-auto 2>/dev/null || true + fi + + - name: Summary + if: always() + run: | + { + echo "## Daily Release Check" + echo "" + if [ "${{ steps.analyze.outputs.proceed }}" != "true" ]; then + echo "No unreleased commits. No release PR needed." + else + echo "- **Classification**: ${{ steps.analyze.outputs.classification }}" + echo "- **Target version**: v${{ steps.analyze.outputs.target_version }}" + echo "- **Merged PRs since last tag**: ${{ steps.analyze.outputs.merged_pr_total }}" + echo "- **Reused existing PR**: ${{ steps.analyze.outputs.existing_pr || 'no (created new)' }}" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..c4475d3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.26 diff --git a/renovate.json b/renovate.json index e3b6c50..11fb674 100644 --- a/renovate.json +++ b/renovate.json @@ -5,6 +5,10 @@ "helpers:pinGitHubActionDigests", ":semanticCommitsDisabled" ], - "labels": ["dependencies"], + "labels": ["dependencies", "automerge"], + "minimumReleaseAge": "0", + "automerge": true, + "automergeType": "pr", + "platformAutomerge": true, "postUpdateOptions": ["gomodTidy"] } From 0d62fe6aea668f261f7770e9cfec34ced5152c4b Mon Sep 17 00:00:00 2001 From: Alejandro Ponce Date: Wed, 22 Jul 2026 16:13:06 +0300 Subject: [PATCH 2/3] Approve automation PRs via a workflow instead of Renovate auto-merge The repo ruleset requires 1 approval, so Renovate's own auto-merge can never complete (a bot can't approve its own PR). Drop the auto-merge behavior from renovate.json and add an approve-automerge workflow that supplies the approval from a separate app identity. - renovate.json: remove automerge/automergeType/platformAutomerge; keep the `automerge` label + minimumReleaseAge 0 - approve-automerge.yml: on CI workflow_run success, approve eligible bot PRs (automerge label + bot author, not the approver's own PR) once the head SHA's full check set is green, then enable GitHub auto-merge (squash) - release-check.yml: wording now refers to the approve-automerge workflow Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/approve-automerge.yml | 151 ++++++++++++++++++++++++ .github/workflows/release-check.yml | 7 +- renovate.json | 3 - 3 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/approve-automerge.yml diff --git a/.github/workflows/approve-automerge.yml b/.github/workflows/approve-automerge.yml new file mode 100644 index 0000000..0785f5c --- /dev/null +++ b/.github/workflows/approve-automerge.yml @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +# SPDX-License-Identifier: Apache-2.0 + +# Approve automation PRs +# +# The repo ruleset requires 1 approval, so GitHub-native auto-merge (or +# Renovate's) can never complete on its own: a bot cannot approve its own PR. +# This workflow supplies that approval from a SEPARATE app identity, which counts +# toward the "require 1 approval" rule. +# +# Mechanism: +# 1. Triggers on `workflow_run` (completed) for the CI workflow. +# 2. Only proceeds when that run succeeded and was for a pull_request. +# 3. Resolves the PR from the run's head SHA and checks eligibility: +# the PR carries the `automerge` label AND is authored by a bot (Renovate or +# the release-bot) AND is not authored by the approver app itself. +# 4. Confirms the full check set on the head SHA is green. +# 5. Submits an approving review with the approver app token, then enables +# GitHub auto-merge (squash). Required checks still gate the actual merge, so +# a red PR never merges. +# +# Requires an approver GitHub App (distinct from Renovate and the release app) +# with Pull requests: write + Contents: read, exposed as: +# vars.APPROVER_APP_CLIENT_ID / secrets.APPROVER_APP_PRIVATE_KEY + +name: Approve automation PRs + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + +permissions: + contents: read + pull-requests: write + +concurrency: + group: approve-automerge-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + approve: + name: Approve automation PRs + # Only act on a successful CI run that was triggered by a pull request. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Generate approver app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.APPROVER_APP_CLIENT_ID }} + private-key: ${{ secrets.APPROVER_APP_PRIVATE_KEY }} + + - name: Approve and enable auto-merge for eligible bot PRs + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + # App slug -> bot login, used to avoid self-approval and to dedupe reviews. + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + # This job's name, so its own check run is excluded from the green check. + SELF_CHECK_NAME: "Approve automation PRs" + run: | + set -euo pipefail + + # Identity of the approver app (installation tokens can't call /user, so + # derive the bot login from the app slug instead). + ME="${APP_SLUG}[bot]" + echo "Approver identity: $ME" + + # Resolve open PR(s) for the CI run's head SHA. Using the head SHA is + # robust whether or not workflow_run.pull_requests is populated. + mapfile -t PRS < <(gh api "/repos/$REPO/commits/$HEAD_SHA/pulls" \ + --jq '.[] | select(.state=="open") | .number') + + if [ "${#PRS[@]}" -eq 0 ]; then + echo "No open PR found for $HEAD_SHA; nothing to do." + exit 0 + fi + + for PR in "${PRS[@]}"; do + echo "::group::PR #$PR" + + META=$(gh api "/repos/$REPO/pulls/$PR" \ + --jq '{author: .user.login, type: .user.type, labels: [.labels[].name]}') + AUTHOR=$(echo "$META" | jq -r '.author') + TYPE=$(echo "$META" | jq -r '.type') + HAS_AUTOMERGE=$(echo "$META" | jq -r 'if (.labels | index("automerge")) then "yes" else "no" end') + echo "author=$AUTHOR type=$TYPE automerge_label=$HAS_AUTOMERGE" + + # Eligibility: automerge label + bot author, and not the approver's own PR. + if [ "$HAS_AUTOMERGE" != "yes" ]; then + echo "Not labeled 'automerge'; skipping."; echo "::endgroup::"; continue + fi + if [ "$TYPE" != "Bot" ]; then + echo "Author is not a bot ($TYPE); skipping (human PRs are never auto-approved)."; echo "::endgroup::"; continue + fi + if [ -n "$ME" ] && [ "$AUTHOR" = "$ME" ]; then + echo "PR authored by the approver app itself; cannot self-approve. Skipping."; echo "::endgroup::"; continue + fi + + # Confirm the full check set on the head SHA is green: every check run + # (except this workflow's own) must be completed and not failing, and + # no legacy commit status may be failing. + CHECKS=$(gh api --paginate "/repos/$REPO/commits/$HEAD_SHA/check-runs" \ + --jq ".check_runs[] | select(.name != \"$SELF_CHECK_NAME\") | [.name, .status, (.conclusion // \"\")] | @tsv") + + GREEN=true + while IFS=$'\t' read -r cname cstatus cconc; do + [ -z "$cname" ] && continue + if [ "$cstatus" != "completed" ]; then + echo "Check '$cname' is $cstatus (not completed); not green." + GREEN=false + elif [ "$cconc" != "success" ] && [ "$cconc" != "neutral" ] && [ "$cconc" != "skipped" ]; then + echo "Check '$cname' concluded '$cconc'; not green." + GREEN=false + fi + done <<< "$CHECKS" + + STATUS_STATE=$(gh api "/repos/$REPO/commits/$HEAD_SHA/status" --jq '.state' 2>/dev/null || echo "") + if [ "$STATUS_STATE" = "failure" ] || [ "$STATUS_STATE" = "error" ]; then + echo "Legacy commit status is '$STATUS_STATE'; not green." + GREEN=false + fi + + if [ "$GREEN" != "true" ]; then + echo "Head SHA is not fully green; not approving PR #$PR."; echo "::endgroup::"; continue + fi + + # Skip if this approver already has an approving review for the PR. + ALREADY=$(gh api --paginate "/repos/$REPO/pulls/$PR/reviews" \ + --jq "[.[] | select(.user.login==\"$ME\" and .state==\"APPROVED\")] | length" 2>/dev/null || echo "0") + if [ "${ALREADY:-0}" -gt 0 ]; then + echo "Already approved PR #$PR; ensuring auto-merge is enabled." + else + echo "Approving PR #$PR." + gh pr review "$PR" --repo "$REPO" --approve \ + --body "Auto-approved: automation-authored PR labeled \`automerge\` with all required checks green." + fi + + # Enable GitHub-native auto-merge. Required checks + this approval gate + # the actual merge, so nothing merges red. + gh pr merge "$PR" --repo "$REPO" --auto --squash || \ + echo "Could not enable auto-merge for #$PR (may already be enabled or merged)." + + echo "::endgroup::" + done diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index e6a4507..aa9c617 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -9,7 +9,8 @@ # - dependency-only: every merged PR since the last tag was authored by an # automation identity (Renovate / the approver bot) AND carries the # `dependencies` label. Gets the `automerge` label and GitHub auto-merge, so -# it merges once required checks are green (the approver app clears review). +# it merges once required checks are green (the approve-automerge workflow +# clears the required review). # # - functional: any merged PR is human-authored or unlabeled. Gets the # `needs-human` label, no auto-merge, and the PR body lists which PRs tipped @@ -224,7 +225,7 @@ jobs: echo "" echo "Every PR merged since the last tag is an automated dependency update." echo "Labeled \`automerge\`; GitHub auto-merge is enabled and it will merge once" - echo "the required status checks are green and the approver app has approved." + echo "the required status checks are green and the approve-automerge workflow has approved." else echo "## Release classification: needs human review" echo "" @@ -247,7 +248,7 @@ jobs: if [ "$CLASSIFICATION" = "dependency-only" ]; then gh pr edit "$PR" --repo "$REPO" --add-label automerge --remove-label needs-human # Enable GitHub-native auto-merge (squash). Required status checks + - # the approver app's review gate the actual merge. + # the approve-automerge workflow's review gate the actual merge. gh pr merge "$PR" --repo "$REPO" --auto --squash else gh pr edit "$PR" --repo "$REPO" --add-label needs-human --remove-label automerge diff --git a/renovate.json b/renovate.json index 11fb674..c526232 100644 --- a/renovate.json +++ b/renovate.json @@ -7,8 +7,5 @@ ], "labels": ["dependencies", "automerge"], "minimumReleaseAge": "0", - "automerge": true, - "automergeType": "pr", - "platformAutomerge": true, "postUpdateOptions": ["gomodTidy"] } From 3f1037fd45dc038ffe1d07edb3902769ee8d0a79 Mon Sep 17 00:00:00 2001 From: Alejandro Ponce Date: Wed, 22 Jul 2026 16:18:44 +0300 Subject: [PATCH 3/3] Scope approve-automerge to Renovate PRs only Gate eligibility on the PR being authored by Renovate (not just any bot), so the workflow never auto-approves human PRs, release PRs, or other bots' PRs. Update release-check wording: dependency-only release PRs now need a maintainer's approval (the approver only covers Renovate PRs). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/approve-automerge.yml | 37 ++++++++++++++++--------- .github/workflows/release-check.yml | 15 ++++++---- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/.github/workflows/approve-automerge.yml b/.github/workflows/approve-automerge.yml index 0785f5c..a2fbe0c 100644 --- a/.github/workflows/approve-automerge.yml +++ b/.github/workflows/approve-automerge.yml @@ -1,19 +1,22 @@ # SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. # SPDX-License-Identifier: Apache-2.0 -# Approve automation PRs +# Approve Renovate PRs # # The repo ruleset requires 1 approval, so GitHub-native auto-merge (or # Renovate's) can never complete on its own: a bot cannot approve its own PR. # This workflow supplies that approval from a SEPARATE app identity, which counts # toward the "require 1 approval" rule. # +# Scope: Renovate PRs ONLY. It never approves human PRs, release PRs, or any +# other bot's PRs. Release PRs are handled separately by release-check.yml and +# still require a human approval. +# # Mechanism: # 1. Triggers on `workflow_run` (completed) for the CI workflow. # 2. Only proceeds when that run succeeded and was for a pull_request. # 3. Resolves the PR from the run's head SHA and checks eligibility: -# the PR carries the `automerge` label AND is authored by a bot (Renovate or -# the release-bot) AND is not authored by the approver app itself. +# the PR is authored by Renovate AND carries the `automerge` label. # 4. Confirms the full check set on the head SHA is green. # 5. Submits an approving review with the approver app token, then enables # GitHub auto-merge (squash). Required checks still gate the actual merge, so @@ -23,7 +26,7 @@ # with Pull requests: write + Contents: read, exposed as: # vars.APPROVER_APP_CLIENT_ID / secrets.APPROVER_APP_PRIVATE_KEY -name: Approve automation PRs +name: Approve Renovate PRs on: workflow_run: @@ -41,7 +44,7 @@ concurrency: jobs: approve: - name: Approve automation PRs + name: Approve Renovate PRs # Only act on a successful CI run that was triggered by a pull request. if: >- github.event.workflow_run.conclusion == 'success' && @@ -55,15 +58,18 @@ jobs: client-id: ${{ vars.APPROVER_APP_CLIENT_ID }} private-key: ${{ secrets.APPROVER_APP_PRIVATE_KEY }} - - name: Approve and enable auto-merge for eligible bot PRs + - name: Approve and enable auto-merge for Renovate PRs env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} HEAD_SHA: ${{ github.event.workflow_run.head_sha }} # App slug -> bot login, used to avoid self-approval and to dedupe reviews. APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + # Renovate author login(s). The REST API returns `renovate[bot]`; the + # `app/renovate` form is accepted defensively. + RENOVATE_LOGINS: "renovate[bot] app/renovate" # This job's name, so its own check run is excluded from the green check. - SELF_CHECK_NAME: "Approve automation PRs" + SELF_CHECK_NAME: "Approve Renovate PRs" run: | set -euo pipefail @@ -92,15 +98,20 @@ jobs: HAS_AUTOMERGE=$(echo "$META" | jq -r 'if (.labels | index("automerge")) then "yes" else "no" end') echo "author=$AUTHOR type=$TYPE automerge_label=$HAS_AUTOMERGE" - # Eligibility: automerge label + bot author, and not the approver's own PR. - if [ "$HAS_AUTOMERGE" != "yes" ]; then - echo "Not labeled 'automerge'; skipping."; echo "::endgroup::"; continue + # Eligibility: Renovate-authored AND labeled `automerge`. This workflow + # never approves human PRs, release PRs, or any other bot's PRs. + IS_RENOVATE=no + for login in $RENOVATE_LOGINS; do + if [ "$AUTHOR" = "$login" ]; then IS_RENOVATE=yes; break; fi + done + if [ "$IS_RENOVATE" != "yes" ]; then + echo "Author '$AUTHOR' is not Renovate; skipping."; echo "::endgroup::"; continue fi if [ "$TYPE" != "Bot" ]; then - echo "Author is not a bot ($TYPE); skipping (human PRs are never auto-approved)."; echo "::endgroup::"; continue + echo "Author '$AUTHOR' is not a bot ($TYPE); skipping."; echo "::endgroup::"; continue fi - if [ -n "$ME" ] && [ "$AUTHOR" = "$ME" ]; then - echo "PR authored by the approver app itself; cannot self-approve. Skipping."; echo "::endgroup::"; continue + if [ "$HAS_AUTOMERGE" != "yes" ]; then + echo "Not labeled 'automerge'; skipping."; echo "::endgroup::"; continue fi # Confirm the full check set on the head SHA is green: every check run diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index aa9c617..2a43e4a 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -9,8 +9,9 @@ # - dependency-only: every merged PR since the last tag was authored by an # automation identity (Renovate / the approver bot) AND carries the # `dependencies` label. Gets the `automerge` label and GitHub auto-merge, so -# it merges once required checks are green (the approve-automerge workflow -# clears the required review). +# it merges once required checks are green and it has one approving review. +# (The approve-automerge workflow only auto-approves Renovate PRs, not +# release PRs, so a maintainer approves the release PR itself.) # # - functional: any merged PR is human-authored or unlabeled. Gets the # `needs-human` label, no auto-merge, and the PR body lists which PRs tipped @@ -224,8 +225,11 @@ jobs: echo "## Release classification: dependency-only" echo "" echo "Every PR merged since the last tag is an automated dependency update." - echo "Labeled \`automerge\`; GitHub auto-merge is enabled and it will merge once" - echo "the required status checks are green and the approve-automerge workflow has approved." + echo "Labeled \`automerge\`; GitHub auto-merge is enabled and it will merge once the" + echo "required status checks are green and a maintainer has approved this release PR." + echo "" + echo "(The approve-automerge workflow auto-approves Renovate PRs only, not release" + echo "PRs, so this one needs a human approval.)" else echo "## Release classification: needs human review" echo "" @@ -248,7 +252,8 @@ jobs: if [ "$CLASSIFICATION" = "dependency-only" ]; then gh pr edit "$PR" --repo "$REPO" --add-label automerge --remove-label needs-human # Enable GitHub-native auto-merge (squash). Required status checks + - # the approve-automerge workflow's review gate the actual merge. + # a maintainer's approving review gate the actual merge (the + # approve-automerge workflow covers Renovate PRs only, not this one). gh pr merge "$PR" --repo "$REPO" --auto --squash else gh pr edit "$PR" --repo "$REPO" --add-label needs-human --remove-label automerge