Read the exposure section first - this repo's situation differs from the other repos I am cross-posting to.
.github/release-please-config.json exists here (one package ".", release-type: node, no component), but there is no .github/workflows/release-please.yml. The workflows present are audit.yml, ci.yml, dependabot-auto-merge.yml and publish.yml.
So the config is currently orphaned - nothing reads it. That is worth resolving on its own terms (either wire up release-please or delete the stale config, so the next person does not assume releases are automated when they are not), and it means the bug below is latent here rather than live.
What happened in swephrs
Release PR morphatic/swephrs#64 (v0.13.0) merged. The release-please workflow ran and reported success. No v0.13.0 tag was created. Nothing anywhere said so - no failed check, no annotation, no comment. It surfaced days later only because someone happened to notice the tag was missing, and the release had to be cut by hand.
Tracked as morphatic/swephrs#65, fixed in morphatic/swephrs#82.
Root cause
release-please's github-release phase does not re-derive what to release from the commits. It reconstructs it by parsing the merged release PR's body.
The v0.13.0 body had been hand-enriched with better changelog prose, and that edit introduced a wrapper:
<details><summary>swephrs: 0.13.0</summary>
swephrs is a single package with no component declared, so the body release-please generates has the changelog at the top level with no <details> wrapper. Parsing a body that does have one, it read swephrs as a component name, found no package configured under that name, produced zero releases to create, and exited success - because doing nothing is not an error.
The give-away in that run's log was an empty component field:
No latest release found for path: ., component: , but a previous version (0.13.0) was specified in the manifest.
Correlation across all four swephrs release PRs - the failure tracks the wrapper, not the editing:
| PR |
version |
body edited? |
<details> wrapper? |
tagged? |
| #56 |
0.12.0 |
yes |
no |
yes |
| #64 |
0.13.0 |
yes |
yes |
no |
| #67 |
0.14.0 |
no |
no |
yes |
| #81 |
0.15.0 |
no |
no |
yes |
v0.12.0 was rewritten just as heavily and tagged fine, which is what made the obvious hypothesis - "editing the body breaks it" - look falsified for a while.
Your exposure
I checked this repo's .github/release-please-config.json: one package ".", no component field, include-component-in-tag: false.
That is the identical shape to swephrs. Both failure modes apply here:
- The specific cause - if anyone ever enriches a release PR body and adds a
<details><summary>...</summary> wrapper, the release will silently not tag, exactly as swephrs' did. This is an easy mistake to make, because that wrapper is release-please's own format - just for multi-package repos.
- The silence - a release PR can merge, the workflow can go green, and no tag can exist, with nothing reporting it.
Since no workflow runs today, nothing can fail today. But the config's shape is the vulnerable one, so if release-please is ever wired up here, include the guard from day one rather than discovering this the way swephrs did.
Why the fix is a CI guard and not a rule
The narrow lesson is "never introduce a component wrapper into a release PR body". But a rule you have to remember on every release is brittle across a growing project history, and it only covers the one cause we happened to hit.
So swephrs enforces the general failure signature instead: a merged PR that still carries the autorelease: pending label. release-please relabels to autorelease: tagged only after it creates the release, so that label on a merged PR means "this merged and was never released" - regardless of what caused it.
The part worth internalizing: the failing run had already computed this. Its There are untagged, merged release PRs outstanding - aborting line is release-please finding exactly the PRs the guard queries. It knew, logged it, and exited 0. The guard only promotes an existing finding to an exit code.
This is a specific instance of a general principle: a green signal is not evidence the thing happened.
The change to make
Three edits to .github/workflows/release-please.yml.
1. Give the release-please step an id so its outputs are addressable:
- uses: googleapis/release-please-action@v5
id: release
2. Add a concurrency block at the top level of the workflow (after permissions:):
concurrency:
group: release-please-${{ github.ref }}
cancel-in-progress: false
3. Add this as the last step of the job:
# --- Guard: a merged release PR must actually have produced a tag ---
#
# #65: PR #64 (v0.13.0) merged, this workflow reported `success`, and no
# `v0.13.0` tag existed. Nothing anywhere said so; the release had to be
# cut by hand once someone happened to notice.
#
# Cause: release-please's `github-release` phase rebuilds *what* to
# release by parsing the release PR's body. This repo is a single
# package with no `component` (see release-please-config.json, and the
# empty `component: ` in that run's log), so the body it generates is
# the plain top-level form. The v0.13.0 body had been enriched with a
# `<details><summary>swephrs: 0.13.0</summary>` wrapper — correct for a
# multi-package manifest, but here it declared a component no package
# answers to. The phase produced zero releases and exited successfully,
# because doing nothing is not an error.
#
# The guard deliberately does NOT check for that wrapper. The defect
# worth catching is the *silence*, which any future cause would share:
# release-please leaves a merged-but-unreleased PR labelled
# `autorelease: pending` (it relabels to `autorelease: tagged` only
# after creating the release), so that label on a merged PR is the
# signature of every silent no-op, whatever caused it.
#
# Note what this means: the failing run had *already computed this*. Its
# "There are untagged, merged release PRs outstanding - aborting" line is
# release-please finding exactly the PRs queried below. It knew, logged
# it, and exited 0. The guard only promotes that finding to an exit code.
#
# Turning it red beats remembering a process deviation on every release
# — the same reason CLAUDE.md requires verifying issue closure after a
# merge rather than trusting the PR text. A green signal is not evidence
# the thing happened.
- name: Verify merged release PRs were tagged
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || github.token }}
REPO: ${{ github.repository }}
TAG_NAME: ${{ steps.release.outputs.tag_name }}
run: |
set -euo pipefail
# 1. If this run says it cut a release, that tag must resolve. The
# job has no checkout, so ask the API, not `git ls-remote`.
if [ -n "${TAG_NAME:-}" ]; then
if gh api "repos/${REPO}/git/ref/tags/${TAG_NAME}" >/dev/null 2>&1; then
echo "Verified tag ${TAG_NAME} exists."
else
echo "::error::release-please reported it created ${TAG_NAME}, but that tag does not exist."
exit 1
fi
fi
# 2. No merged release PR may still be awaiting its release.
untagged=$(gh pr list \
--state merged \
--label 'autorelease: pending' \
--limit 50 \
--json number,title,mergedAt \
--jq '.[] | " #\(.number) \(.title) (merged \(.mergedAt))"')
if [ -n "$untagged" ]; then
echo "::error::A release PR was merged but never tagged. See issue #65."
echo "Merged release PRs still labelled 'autorelease: pending':"
echo "$untagged"
echo ""
echo "release-please exited successfully without creating a release."
echo "Most likely the release PR body was edited into a shape it could"
echo "not parse — this repo is single-package with an unnamed component,"
echo "so the body must stay in the plain top-level form and must NOT"
echo "carry a '<details><summary>swephrs: X.Y.Z</summary>' wrapper."
echo ""
echo "To recover: restore the body to the generated form, then"
echo "re-run this workflow (or push an empty commit to main)."
exit 1
fi
echo "No merged release PRs are awaiting a tag."
The step needs no actions/checkout - it uses the API for the tag lookup precisely because the job has none.
Qualify the issue references when you paste it. The comment block says #65 and #64, which are swephrs issue numbers. Unqualified they will point at whatever issues happen to carry those numbers here. Rewrite them as morphatic/swephrs#65 and morphatic/swephrs#64 so the comment stays true.
Verify it before trusting it
Both branches were exercised against the live repo rather than assumed. Do the same here:
# Should print nothing in a healthy repo -> the guard passes
gh pr list --state merged --label 'autorelease: pending' --limit 50 --json number,title,mergedAt
# Should resolve for a real tag, and 404 for a bogus one
gh api repos/OWNER/REPO/git/ref/tags/vX.Y.Z
gh api repos/OWNER/REPO/git/ref/tags/v9.9.9
If the first command prints anything, you have an untagged release right now - that is the bug this guard exists to catch, and it would need cutting by hand before the guard can go green.
Honest limitation: this cannot be end-to-end tested until your next release PR merges. That is the point - it is the change that makes that merge self-verifying.
Found via: swephrs' v0.13.0 release silently failing to tag (morphatic/swephrs#65), diagnosed and fixed 2026-08-31 in morphatic/swephrs#82. Filed here because this repo runs the same release-please setup and would fail the same silent way. Cross-posted to every repo in the family that uses release-please.
Read the exposure section first - this repo's situation differs from the other repos I am cross-posting to.
.github/release-please-config.jsonexists here (one package".",release-type: node, nocomponent), but there is no.github/workflows/release-please.yml. The workflows present areaudit.yml,ci.yml,dependabot-auto-merge.ymlandpublish.yml.So the config is currently orphaned - nothing reads it. That is worth resolving on its own terms (either wire up release-please or delete the stale config, so the next person does not assume releases are automated when they are not), and it means the bug below is latent here rather than live.
What happened in swephrs
Release PR morphatic/swephrs#64 (v0.13.0) merged. The
release-pleaseworkflow ran and reportedsuccess. Nov0.13.0tag was created. Nothing anywhere said so - no failed check, no annotation, no comment. It surfaced days later only because someone happened to notice the tag was missing, and the release had to be cut by hand.Tracked as morphatic/swephrs#65, fixed in morphatic/swephrs#82.
Root cause
release-please's
github-releasephase does not re-derive what to release from the commits. It reconstructs it by parsing the merged release PR's body.The v0.13.0 body had been hand-enriched with better changelog prose, and that edit introduced a wrapper:
swephrs is a single package with no
componentdeclared, so the body release-please generates has the changelog at the top level with no<details>wrapper. Parsing a body that does have one, it readswephrsas a component name, found no package configured under that name, produced zero releases to create, and exitedsuccess- because doing nothing is not an error.The give-away in that run's log was an empty component field:
Correlation across all four swephrs release PRs - the failure tracks the wrapper, not the editing:
<details>wrapper?v0.12.0 was rewritten just as heavily and tagged fine, which is what made the obvious hypothesis - "editing the body breaks it" - look falsified for a while.
Your exposure
I checked this repo's
.github/release-please-config.json: one package".", nocomponentfield,include-component-in-tag: false.That is the identical shape to swephrs. Both failure modes apply here:
<details><summary>...</summary>wrapper, the release will silently not tag, exactly as swephrs' did. This is an easy mistake to make, because that wrapper is release-please's own format - just for multi-package repos.Since no workflow runs today, nothing can fail today. But the config's shape is the vulnerable one, so if release-please is ever wired up here, include the guard from day one rather than discovering this the way swephrs did.
Why the fix is a CI guard and not a rule
The narrow lesson is "never introduce a component wrapper into a release PR body". But a rule you have to remember on every release is brittle across a growing project history, and it only covers the one cause we happened to hit.
So swephrs enforces the general failure signature instead: a merged PR that still carries the
autorelease: pendinglabel. release-please relabels toautorelease: taggedonly after it creates the release, so that label on a merged PR means "this merged and was never released" - regardless of what caused it.The part worth internalizing: the failing run had already computed this. Its
There are untagged, merged release PRs outstanding - abortingline is release-please finding exactly the PRs the guard queries. It knew, logged it, and exited 0. The guard only promotes an existing finding to an exit code.This is a specific instance of a general principle: a green signal is not evidence the thing happened.
The change to make
Three edits to
.github/workflows/release-please.yml.1. Give the release-please step an
idso its outputs are addressable:2. Add a
concurrencyblock at the top level of the workflow (afterpermissions:):3. Add this as the last step of the job:
The step needs no
actions/checkout- it uses the API for the tag lookup precisely because the job has none.Qualify the issue references when you paste it. The comment block says
#65and#64, which are swephrs issue numbers. Unqualified they will point at whatever issues happen to carry those numbers here. Rewrite them asmorphatic/swephrs#65andmorphatic/swephrs#64so the comment stays true.Verify it before trusting it
Both branches were exercised against the live repo rather than assumed. Do the same here:
If the first command prints anything, you have an untagged release right now - that is the bug this guard exists to catch, and it would need cutting by hand before the guard can go green.
Honest limitation: this cannot be end-to-end tested until your next release PR merges. That is the point - it is the change that makes that merge self-verifying.
Found via: swephrs' v0.13.0 release silently failing to tag (morphatic/swephrs#65), diagnosed and fixed 2026-08-31 in morphatic/swephrs#82. Filed here because this repo runs the same release-please setup and would fail the same silent way. Cross-posted to every repo in the family that uses release-please.