Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions .github/workflows/approve-automerge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
# SPDX-License-Identifier: Apache-2.0

# 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 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
# 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 Renovate 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 Renovate 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 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 Renovate 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: 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 '$AUTHOR' is not a bot ($TYPE); skipping."; echo "::endgroup::"; continue
fi
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
# (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
93 changes: 93 additions & 0 deletions .github/workflows/create-release-pr.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading