Skip to content

ci(release-pr): open release PRs on push to main - #81

Merged
michen00 merged 3 commits into
mainfrom
ci/release-pr-push-trigger
Sep 15, 2026
Merged

michen00 merged 3 commits into
mainfrom
ci/release-pr-push-trigger

Conversation

@michen00

@michen00 michen00 commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Opens the release PR automatically instead of waiting for someone to run the workflow by hand. This is the one thing release-please would have added over what this repo already does; adopting it outright would have cost the GPG-signed annotated tag, the release environment gate, and the cliff.toml changelog format.

What changed

.github/workflows/release-pr.yml gains push: branches: [main] alongside the existing dispatch, split into two jobs.

resolve (read-only permissions) decides whether there is anything to do:

Situation Push Manual dispatch
Nothing releasable ::notice::, job ends fails
Head commit is chore(release): prepare … job skipped n/a
A prepared version is still untagged ::notice::, job ends fails

release-pr runs only when resolve says so. It is unchanged except for a new final step that closes superseded same-repo release/* PRs.

CONTRIBUTING.md is updated to match: the release flow now starts by itself on a releasable merge, and the two guards below are written down where someone running a release will find them.

Why each guard exists

  • Nothing releasable is the normal case. git-cliff bumps only for feat, fix and breaking changes, so chore, docs and build merges — the weekly hook autoupdate and Dependabot among them — propose nothing. That has to end quietly on a push. A dispatch is a human asking for a release, where silence would read as success, so that path still fails loudly.
  • Merging a release PR is itself a push to main, and the tag does not exist at that moment — release-tag.yml is waiting on the release environment approval. Unguarded, the merge would immediately propose a second PR for the release just merged.
  • The approval window is a hole of its own. A fix landing while Release Tag waits would get a duplicate PR for a version already on its way out. Refusing while any prepared version is untagged covers it. This deliberately also latches when an approval is rejected; the way out is the manual tag-push fallback already documented in CONTRIBUTING, which both publishes that release and clears the check. The lookup behind that guard is deliberately unsuppressed — a failed git log must stop the step rather than read as "nothing prepared" and wave the release through (18b887e).
  • A release PR carries its version in the branch name, and release-tag.yml derives the tag it mints from that name — deliberately, since a same-repo release/* branch is not attacker-controlled the way a title or body is. So the branch cannot be made stable, and a version that moves between runs (a feat on top of a pending patch) leaves a stale PR whose merge path would tag the wrong version. The new run closes it. Cleanup is scoped to same-repo branches for the same reason release-tag.yml trusts only those: a fork PR whose head happens to start with release/ is not part of this release process and must not be closed by it (99708db).

What this does not change

No new path to a tag or a published artifact. A release still takes the same two human actions: merging the release PR, then approving the Release Tag run in the protected release environment. The release PR is also outside bot-automerge.yml's author allowlist (dependabot[bot], pre-commit-ci[bot]), so it will not merge itself — #72's checks show bot-automerge SKIPPED.

Test plan

Both shell blocks were extracted from the YAML and executed, rather than reasoned about.

  • resolve, 9 scenarios — push and dispatch × releasable and not; explicit and malformed version input; and an in-flight release simulated in a scratch clone (an untagged chore(release): prepare v9.9.9 with a fix on top), including that the guard clears once the tag appears. All as specified: push exits 0 with release=false, dispatch exits 1.
  • Cleanup step, 5 scenarios with a stubbed gh — supersession, nothing to close, empty list, and a failing gh pr list, which exits non-zero rather than reading as "nothing superseded". That fail-closed shape follows the convention already written into bot-automerge.yml.
  • actionlint clean — it caught an SC2129 and yamllint caught a >100-char line during drafting; both fixed.
  • make check green (8 unit suites, 3 integration, pre-commit across all files).

Two fixes landed after the first review round, so their evidence is separate from the extracted-block runs above:

  • make check green before each of 18b887e and 99708db; actionlint and check-github-workflows clean on both.
  • The no-match behaviour behind 18b887e was checked directly rather than assumed: git log -1 --grep with no matching commit already exits 0 and prints nothing, so the removed || true could only ever have masked a real failure.
  • The new cleanup filter was exercised against a representative gh pr list payload: of a same-repo release/* PR, two fork release/* PRs and a same-repo non-release PR, only the first survives the jq selection.
  • The 5-scenario cleanup run above predates the fork filter and was not re-run against it.

Note on merging

The first push to main after this lands will open release/v0.1.2, because #79's fix is sitting unreleased. That is the feature working, not a surprise.

🤖 Generated with Claude Code


Closes #82 (review-convergence bulletin)

git-cliff bumps only for feat, fix and breaking changes, so most
merges to main propose nothing and the run ends quietly. A manual
dispatch still fails loudly, where silence would read as success.

Two guards come with the trigger. Merging a release PR is itself a
push, and the tag does not exist until release-tag.yml clears its
environment approval, so a run is skipped for a release-prep head
commit and refused while any prepared version is still untagged.
Separately, release-tag.yml derives the tag it mints from the
release/* branch name, so a version that moves between runs leaves
a stale PR that would tag the wrong commit; the new run closes it.

Resolution moves into its own job so that nothing-to-release shows
as a skipped job rather than a green one that did nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 47.98%. Comparing base (f67e530) to head (99708db).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #81      +/-   ##
==========================================
+ Coverage   47.93%   47.98%   +0.05%     
==========================================
  Files          31       31              
  Lines        1863     1863              
==========================================
+ Hits          893      894       +1     
+ Misses        970      969       -1     
Flag Coverage Δ
integration 1.05% <ø> (ø)
unit 47.31% <ø> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Open release PRs automatically on pushes to main

✨ Enhancement ⚙️ Configuration changes 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Opens release PRs automatically when releasable commits reach main.
• Skips speculative runs during empty or in-flight releases while preserving manual failures.
• Closes superseded versioned release PRs and documents the updated release flow.
Diagram

sequenceDiagram
    actor Trigger as Workflow Trigger
    participant Resolve as Resolve Job
    participant Git as Git History
    participant Prep as Release PR Job
    participant API as GitHub PR API
    Trigger->>Resolve: Push or dispatch
    Resolve->>Git: Inspect commits and tags
    Git-->>Resolve: Return release state
    alt Releasable and idle
        Resolve->>Prep: Pass release metadata
        Prep->>API: Create release PR
        API-->>Prep: Return PR number
        Prep->>API: Close stale PRs
    else Speculative push blocked
        Resolve-->>Trigger: Notice and skip
    else Manual request blocked
        Resolve-->>Trigger: Fail request
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt release-please
  • ➕ Provides a mature, widely used automated release-PR implementation.
  • ➕ Reduces custom workflow state and maintenance logic.
  • ➖ Requires adapting or replacing the existing cliff.toml changelog format.
  • ➖ Complicates preservation of GPG-signed annotated tags and the protected release gate.
  • ➖ Introduces a broader release-system migration for one missing trigger behavior.
2. Keep a single workflow job
  • ➕ Avoids a second checkout and git-cliff installation.
  • ➕ Keeps version resolution and PR creation in one execution context.
  • ➖ Requires conditions across every write step to prevent accidental execution.
  • ➖ Represents expected no-release pushes as successful no-op jobs rather than skipped work.
  • ➖ Cannot narrow resolver permissions independently from PR creation.
3. Extract release resolution script
  • ➕ Makes in-flight and no-release behavior directly unit-testable.
  • ➕ Allows local reproduction independent of GitHub Actions expressions.
  • ➖ Adds another public script contract and output protocol.
  • ➖ Still requires workflow-specific branching for notices, failures, and job skipping.

Recommendation: The PR’s split-job approach is the best fit because it adds automatic triggering while preserving the repository’s custom changelog, signed-tag, and approval model. The read-only resolver provides a clear permissions boundary and skipped-job semantics; extracting its shell logic would become worthwhile only if release-state rules continue to expand.

Files changed (2) +161 / -16

Enhancement (1) +142 / -13
release-pr.ymlAutomate guarded release PR creation on main pushes +142/-13

Automate guarded release PR creation on main pushes

• Adds a 'main' push trigger and separates read-only version resolution from the write-capable release PR job. The resolver quietly skips speculative pushes with no release or an in-flight release while manual dispatches still fail, and the creation job closes superseded 'release/*' PRs.

.github/workflows/release-pr.yml

Documentation (1) +19 / -3
CONTRIBUTING.mdDocument automatic release PR behavior and safeguards +19/-3

Document automatic release PR behavior and safeguards

• Updates the release guide to describe automatic PR creation, non-releasable push handling, and manual version overrides. It also explains the untagged-release latch, approval rejection recovery, and cleanup of stale versioned PRs.

CONTRIBUTING.md

@qodo-code-review

qodo-code-review Bot commented Sep 15, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Fork release PRs are wrongly closed 🐞
Description
The cleanup query selects PRs using only headRefName, although the tagging workflow treats a
release branch as trusted only when its head repository is the current repository. Whenever a fork
contributor opens a release/* branch against main, the next generated release PR reaches the
loop and closes that contributor's PR as superseded.
Code

.github/workflows/release-pr.yml[R255-258]

+          open_release_prs="$(gh pr list --state open --base main \
+            --json number,headRefName \
+            --jq '.[] | select(.headRefName | startswith("release/"))
+                      | "\(.number)\t\(.headRefName)"')"
Evidence
The new query requests only the PR number and unqualified head branch name, then closes every
returned release/* PR except the retained branch. The release-tag workflow explicitly requires
head.repo.full_name == github.repository, proving that fork branches with this prefix are outside
the trusted release flow and should not be cleaned up.

.github/workflows/release-pr.yml[255-266]
.github/workflows/release-tag.yml[18-26]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The superseded-release cleanup includes fork PRs whose branch names begin with `release/`, even though only same-repository release branches belong to the release process. Such contributor PRs can be closed and given a misleading supersession comment during an automated release.

## Fix Focus Areas
- .github/workflows/release-pr.yml[255-266]
- .github/workflows/release-tag.yml[18-26]

## Recommended Fix
Request repository-origin information in the `gh pr list --json` result and filter the jq selection to same-repository PRs before entering the close loop. For example, include `isCrossRepository` and require it to be false alongside the existing `release/` branch-name check.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Release history errors are hidden 📘
Description
The pending-release lookup appends || true to git log, converting every repository or command
failure into an empty prep value. When that lookup fails, execution skips the untagged-release
guard and continues into version resolution and release PR creation.
Code

.github/workflows/release-pr.yml[103]

+          prep="$(git log -1 --format=%s --grep='^chore(release): prepare v' || true)"
Evidence
Rules 406873 and 406886 prohibit masking meaningful CI command failures with || true unless
failure is explicitly and safely ignorable. The changed lookup at line 103 suppresses all git log
errors even though its output controls the pending-release check at lines 104–110.

Rule 406873: Do not silently ignore exit codes in shell scripts
Rule 406886: Shell scripts must not ignore exit codes and must handle errors explicitly
.github/workflows/release-pr.yml[103-110]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The pending-release lookup masks every `git log` failure with `|| true`, causing failures to look like an empty result and bypassing the guard.

## Fix Focus Areas
- .github/workflows/release-pr.yml[103-103]

## Recommended Fix
Remove `|| true` from the command substitution so `set -euo pipefail` stops the step when `git log` fails. A successful search with no matching commit already produces empty output without requiring failure suppression.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 26 rules
Review mode: ⚖️ Balanced: This push changes release workflow control flow and pull-request branch handling, creating real CI and repository-state risks that warrant a complete single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can reply 'qodo' on any finding to push back, ask questions, or dig deeper

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread .github/workflows/release-pr.yml Outdated
Comment thread .github/workflows/release-pr.yml

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The workflow introduces a likely-invalid inputs.version reference on push runs and a cleanup step that can close unrelated release/* PRs unless further constrained.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR updates the release automation to open release preparation PRs automatically on pushes to main, while keeping manual dispatch semantics for “nothing to release,” and documents the new behavior in CONTRIBUTING.

Changes:

  • Adds a push trigger on main and splits the workflow into a read-only resolve job and a gated release-pr job.
  • Implements guards for “nothing releasable,” “release PR just merged,” and “prepared-but-untagged release in flight,” with different behavior for push vs dispatch.
  • Adds a post-create cleanup step to close superseded release/* PRs, and updates release documentation accordingly.
File summaries
File Description
CONTRIBUTING.md Documents the new auto-opening release PR behavior and the new guards/cleanup semantics.
.github/workflows/release-pr.yml Adds push trigger, splits into resolve + release-pr, and closes superseded release PRs after creating a new one.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/release-pr.yml
Comment thread .github/workflows/release-pr.yml
michen00 and others added 2 commits September 15, 2026 00:32
The pending-release lookup appended `|| true` to `git log`, turning
any real failure into an empty `prep` value. That reads as "no release
in flight", so the untagged-release guard below is skipped and a second
PR is prepared for the release already on its way out.

Finding nothing already exits 0 and prints nothing, so the suppression
could never have been load-bearing for the no-match case -- it only
ever caught genuine errors. Dropping it lets `set -euo pipefail` stop
the step.

Reported by Qodo on #81.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The superseded-release cleanup selected PRs by `headRefName` alone, so
any open PR against main whose branch starts with `release/` was in
scope -- including one from a fork. The next release this workflow
prepared would close that contributor's PR as superseded, comment that
it was replaced, and point `--delete-branch` at their branch.

release-tag.yml already treats a release branch as trusted only when
its head repo is this repo. Filtering the listing on
`isCrossRepository` puts the cleanup on that same footing.

Reported by Qodo and Copilot on #81.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michen00
michen00 requested a lite review from Copilot September 15, 2026 07:53
@michen00

Copy link
Copy Markdown
Owner Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 99708db

@michen00

Copy link
Copy Markdown
Owner Author

[Review-Convergence] Round 1: active

  • Head: 99708db
  • Base ref: main
  • Base: f67e530
  • CI: pending (10 passed, 1 skipped, 1 in progress)
  • Bot threads: 0 unresolved (4 triaged and resolved this round)
  • Human threads: 0 unresolved
  • Clean signals: none yet at this head
  • Pending reviewers: Copilot, Qodo
  • Catch-up: none
  • Next action: wait for Copilot and Qodo reviews at the new head
  • Next wakeup: ~4min (reviewer-pending first-tier floor)
  • Bulletin: Review convergence: PR #81 #82
Round 1 dispositions
  • fixed — Qodo, release-pr.yml:103|| true masked git log failures in the pending-release lookup, so a failed lookup read as "no release in flight" and skipped the untagged-release guard — 18b887e
  • fixed — Qodo, release-pr.yml:258 — superseded-release cleanup selected on headRefName alone and would close (and --delete-branch) a fork's release/* PR — 99708db
  • fixed — Copilot, release-pr.yml:258 — same root cause; filtered on isCrossRepository rather than --author so the cleanup matches the trust check release-tag.yml already makes — 99708db
  • wontfix — Copilot, release-pr.yml:68inputs.version on push. The inputs context is defined-but-empty for non-dispatch events, so it renders as an empty string rather than failing. release-publish.yml on main ships the identical pattern, and actionlint + check-github-workflows are green on this head.

make check passed (exit 0) before each commit.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The workflow changes are well-guarded for push vs dispatch behavior and safely constrain cleanup to same-repo release/* PRs.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@michen00

Copy link
Copy Markdown
Owner Author

[Review-Convergence] Round 2: converged

  • Head: 99708db
  • Base ref: main
  • Base: f67e530
  • CI: green (11 passed, 1 skipped)
  • Bot threads: 0 unresolved
  • Human threads: 0 unresolved
  • Clean signals: Copilot (github), Qodo (qodo) — quorum 2/2, both earned at this head
  • Pending reviewers: none
  • Catch-up: none (base is an ancestor of head)
  • Next action: none — bot-review convergence reached
  • Bulletin: Review convergence: PR #81 #82
Clean signal evidence
  • Copilot — review at 99708db submitted 2026-09-15T07:56:22Z, "🟢 Approval recommended", 0 comments generated, 0 unresolved Copilot threads.
  • Qodo — persistent summary refreshed 2026-09-15T07:54:32Z with a real-review marker naming this head, 0 active summary findings, 0 unresolved Qodo threads.

Codex never engaged with this PR, so it was not eligible for re-request; quorum was met from the two engaged families.

This runner does not merge, approve, or resolve human threads. Merge policy is yours to decide.

@michen00
michen00 merged commit cfd9bf4 into main Sep 15, 2026
13 checks passed
@michen00
michen00 deleted the ci/release-pr-push-trigger branch September 15, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Review convergence: PR #81

3 participants