diff --git a/.github/workflows/approve-automerge.yml b/.github/workflows/approve-automerge.yml new file mode 100644 index 0000000..a2fbe0c --- /dev/null +++ b/.github/workflows/approve-automerge.yml @@ -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 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..2a43e4a --- /dev/null +++ b/.github/workflows/release-check.yml @@ -0,0 +1,278 @@ +# 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 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 +# 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 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 "" + 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 + + # 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 + # 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..c526232 100644 --- a/renovate.json +++ b/renovate.json @@ -5,6 +5,7 @@ "helpers:pinGitHubActionDigests", ":semanticCommitsDisabled" ], - "labels": ["dependencies"], + "labels": ["dependencies", "automerge"], + "minimumReleaseAge": "0", "postUpdateOptions": ["gomodTidy"] }