How every repository in vaam-apps cuts a release, and — more usefully — the
list of ways this has already gone wrong. Every failure below actually
happened here, most of them on 2026-09-18, and each one shares a shape: a
mechanism that looked configured, reported success, and did nothing.
This document is the source of truth. The reference implementation is
vaam-apps/vsms — copy from that repo's
real files, not from memory and not from the snippets here, which will drift.
sequenceDiagram
participant Dev as Contributor
participant Main as main
participant RP as release-please.yml
participant PR as "chore: release" PR
participant Tag as vX.Y.Z tag
participant Rel as release.yml
Dev->>Main: squash-merge, conventional title
Main->>RP: push event
RP->>RP: mint GitHub App token
RP->>PR: open or update, bump every annotated file
RP->>PR: refresh lockfiles on the release branch
Note over PR: ordinary CI runs here — because an App token<br/>raised the events, GITHUB_TOKEN would not
Dev->>PR: merge
PR->>Tag: release-please creates the tag
Tag->>Rel: tag push event
Rel->>Rel: guard — tag version == every manifest version
Rel->>Rel: publish crates, npm, images, chart
The lifecycle, and where it silently stops:
stateDiagram-v2
[*] --> Unreleased
Unreleased --> Proposed: conventional commit on main
Unreleased --> Invisible: subject not conventional
Unreleased --> Invisible: commit body breaks the PEG parser
Invisible --> [*]: no release PR, nothing reports why
Proposed --> Tagged: release PR merged
Tagged --> PublishedNothing: tag made with GITHUB_TOKEN
PublishedNothing --> [*]: release.yml never triggers
Tagged --> Published: tag made with the App token
Published --> [*]
Both Invisible and PublishedNothing are green. Nothing fails. That is
the whole reason this document exists.
Five things. Fewer than five and it is not wired up, whatever the CI says.
| File | Does |
|---|---|
release-please-config.json |
Which files carry a version, and how the changelog is grouped |
.release-please-manifest.json |
The current version. release-please owns this — never hand-edit |
.github/workflows/release-please.yml |
Opens and maintains the release PR |
.github/workflows/pr-title.yml |
Refuses a non-conventional PR title, and parses the squash message |
ci/commit-message-parse/ |
The squash-message parser, pinned to release-please's own grammar |
Repos that publish artifacts also need release.yml, triggered on v*.*.*.
No secrets to set up. RELEASE_PLEASE_APP_CLIENT_ID and
RELEASE_PLEASE_APP_PRIVATE_KEY are organization secrets with
visibility: all. Every repo, public or private, already has them.
GitHub raises no workflow events for anything done with the default
GITHUB_TOKEN — deliberate loop prevention. So a release-please run using it
creates the vX.Y.Z tag and release.yml never fires. No images, no
packages, no chart, and nothing anywhere fails to say so. The release PR
also gets no CI at all, which is the same class of gap as a workflow that only
triggers on push-to-main: its first execution of changed code is after merge.
Mint an App token with actions/create-github-app-token and use it for every
write. Note the input is client-id — the Iv23li… Client ID, not the
numeric App ID; app-id is deprecated and they are different values on the
same settings page.
There is deliberately no fallback to GITHUB_TOKEN. A fallback produces
exactly the silent publish-nothing release above.
Scope the token. create-github-app-token with no permission inputs mints
a token carrying every permission the App installation holds org-wide —
here that is actions, issues, security-events, workflows and more — into a job
that only opens a PR and pushes a branch. zizmor flags this as github-app,
and it is a real over-scope rather than a false positive:
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.RELEASE_PLEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: writeThose two are all release-please needs — open and update the release PR, push its branch, create the tag.
This one destroyed files. extra-files accepts bare strings, and
release-please then infers an updater from the file extension:
| extension | updater it picks |
|---|---|
.json |
GenericJson('$.version') + Generic |
.yaml / .yml |
GenericYaml('$.version') + Generic |
.toml |
GenericToml('$.version') + Generic |
.xml |
GenericXml('/*/version') + Generic |
| anything else | Generic alone |
GenericYaml reparses and re-serialises the whole document. In the sibling
vpay repo that turned a 48-line Chart.yaml into 13 lines — every comment
destroyed, the wrong version: key bumped (0.2.0 → 0.1.1, a downgrade, and
the one field the config deliberately excluded), and appVersion left
untouched because the annotation had just been serialised away.
Always write the object form, which routes to Generic alone whatever the
extension:
{ "type": "generic", "path": "deploy/docker-compose.yml" }For JSON, name the path explicitly instead:
{ "type": "json", "path": "package.json", "jsonpath": "$.version" }vaam-apps/vsms escaped this by luck, not design: its two .yaml entries are
compose files, and a modern compose file has no top-level version: key, so
GenericYaml('$.version') found nothing to change. Add one and the luck runs
out.
Generic matches x-release-please-version on a line and then performs a
single replace of the first semver-shaped substring on it:
image: ghcr.io/vaam-apps/vsms/sms-gateway:v0.4.0 # x-release-please-versionIf the first semver on the line is not the one you mean — a pinned base image, a port that looks like a version — it silently corrupts. Check every line before annotating it.
A version in a file the config does not list, or a versioned line with no
annotation, simply never moves. Nothing reports it. In vsms that is guarded
by cargo xtask release-versions, which fails a PR when the 22 version
references disagree, when a listed extra-file has no annotation left in it,
and when a versioned image default has no annotation. A repo without an xtask
should still check what it can.
release-please reads commit subjects. A subject it does not recognise contributes nothing: no changelog entry, no version bump. No release PR appears and nothing says why.
When this was introduced, 58 of the previous 60 subjects on vsms were
invisible to it.
So: pr-title.yml refuses a non-conventional PR title, and the repository's
squash setting must be PR_TITLE, not COMMIT_OR_PR_TITLE. Under the
latter, a single-commit PR takes its commit's subject as the squash subject,
so a conventional title can be bypassed by a PR whose one commit was titled
anything — the lint passes and the release still never sees it.
And PR_TITLE only governs squash merges. Measured across this org on
2026-09-19: vsms and vpay both had PR_TITLE set correctly and still
allowed merge commits and rebase merges — as did all seven other repos. A
merge-commit or rebase merge lands the branch's individual commit subjects
on main; the PR title never becomes a commit subject at all. So a branch of
wip: commits merged that way gives release-please nothing it will act on,
pr-title.yml passes, and ci/commit-message-parse/ validates a squash
message that was never created.
Setting PR_TITLE without also disabling allow_merge_commit and
allow_rebase_merge leaves the bypass wide open. Nothing in CI can see which
merge button someone pressed, so this is a repository setting or it is not
enforced at all.
Closed 2026-09-19: all ten repos are squash-only. Ten, not nine — the
first sweep covered the nine public repos and missed vaam-apps/vaam-apps,
which is private. That is the second time in two days an enumeration of this
org came up one or more short; prefer gh repo list vaam-apps over any list
written down, including this sentence. The canonical shape,
which sync-repo-settings should assert rather than leave to memory:
| setting | value |
|---|---|
allow_squash_merge |
true |
allow_merge_commit |
false |
allow_rebase_merge |
false |
squash_merge_commit_title |
PR_TITLE |
squash_merge_commit_message |
COMMIT_MESSAGES |
COMMIT_MESSAGES matters as much as PR_TITLE: it is what produces the body
that ci/commit-message-parse/ reconstructs and parses. Change it and that
check starts validating a message shape that no longer occurs.
Worse than 4, because the subject is fine. release-please uses
@conventional-commits/parser, a strict PEG parser. A body line that begins
with identifier( reads to that grammar as a type-and-scope header, so nested
parentheses inside it are a syntax error — and the entire commit is dropped:
❯ commit could not be parsed: 7ef89ec fix(ci): …
❯ error message: Error: unexpected token '(' at 8:30, valid tokens [)]
❯ commits: 0
✔ No commits for path: ., skipping
The workflow reported success. That commit was the only one since the last release, so no release was proposed at all.
| body line | parses? |
|---|---|
see A(B(c)) here |
yes — a word precedes it |
A(b) here |
yes — nothing nests |
A(B(c)) here |
no — line-initial and nested |
`A(B(c))` here |
no — a backtick does not help |
ci/commit-message-parse/ reconstructs the squash message and parses it with
the exact pinned version release-please uses (@conventional-commits/parser
0.4.1). A looser range is the one way this check could confidently report a
verdict that does not match reality.
It checks the squash result, not each commit — branches legitimately carry
wip: commits, and a first cut that checked each one rejected 24 of the last
40 on main. A guard that loud gets deleted rather than obeyed.
release.yml compares the tag against every manifest version. In vsms it did
that with an anchored sed:
sed -n 's/^version = "\(.*\)"$/\1/p'Then the annotation was added, and the line became:
version = "0.3.2" # x-release-please-versionwhich no longer ends with ". The pattern matched nothing, the variable came
out empty, the comparison failed, and v0.3.2 published no image, no
chart and neither SDK. The only trace was one line in a log nobody reads on
a green day:
tag=0.3.2 workspace= rust-sdk= node-sdk=0.3.2
Two facts about how to read a version lived in two files with nothing holding
them together. Make every extraction tolerate trailing content
("\([^"]*\)".*$), and have the version check run the release workflow's own
extractions rather than restating them.
A package name must already exist on the registry before a Trusted Publisher can be attached to it. For a brand-new package there is nothing to attach to, so OIDC cannot work and the first publish must be manual and token-based.
Symptoms differ and both mislead:
ENEEDAUTH— no credential reached npm at all. Neither a token nor OIDC. This is whatvaam-apps/vpayv0.2.0hit: every image published, both npm packages failed, and neither@vaam-apps/vpay-sdknor@vaam-apps/vpay-stripe-jshad ever existed under any scope.E404— npm masking an unauthorized write as "not found" rather than confirming a package exists to an unauthenticated caller. Read it as auth, never as a missing package. Runnpm whoamibefore chasing anything else.
Two further traps on the manual publish:
- Never pass
--provenance. It only works inside a recognised CI OIDC provider and otherwise hard-errorsEUSAGE— before uploading anything. EOTPmeans 2FA-on-publish. Use an Automation-type token, npm's own OTP-exempt class for exactly this.
A Trusted Publisher is bound to owner/repo plus a workflow filename, so a
repository rename or transfer breaks it until the entry is updated. This org
moved twice (vymalo → vaam-store → vaam-apps); each move broke publishing
until someone re-pointed it.
Bumping a workspace version rewrites every Cargo.lock that names it, and
pnpm-lock.yaml likewise. Production Dockerfiles and CI here build with
--locked / --frozen-lockfile, so a release PR with stale lockfiles is red
on arrival.
vsms's release-please.yml handles this by checking out the release branch
after release-please force-pushes it and running cargo metadata in the
directory of every Cargo.lock that git ls-files finds — not a hardcoded
list, because a fifth copy of "which Rust roots exist" is the duplicated-list
failure this document keeps describing.
Found by running a bump rather than reading the schema. A workspace whose crates depend on each other by path usually also pins a version:
vaam-domain = { path = "...", version = "0.1.0" }
vaam-backend = { path = "...", version = "0.1.0" }Annotate only [workspace.package] version and the bump produces a lockfile
that cannot resolve:
failed to select a version for the requirement `vaam-backend = "^0.1.0"`
Every one of those version = fields is its own extra-file, in its own
file, and cargo metadata is what surfaces it. In vaam-apps/vaam-apps that
meant tools/seed-demo/Cargo.toml as well as the root.
This is why the lockfile-refresh step matters beyond tidiness: it is the thing that turns a silently-wrong release PR into a loud one, on the PR, before anyone merges it.
The quietest failure found so far. Observed on ui and flutter-sign-keypair
the first time each cut a release.
release-type: node takes a component name from package.json; dart takes
one from pubspec.yaml. The release PR's title carries no component
(chore: release main), so when release-please goes to tag the merged PR it
cannot match them:
❯ Found pull request #20: 'chore: release main'
⚠ PR component: undefined does not match configured component: ui
⚠ Could not find releases.
❯ looking for tagName: v0.2.1
⚠ There are untagged, merged release PRs outstanding - aborting
What that produces: the release PR merges, CHANGELOG.md and the manifest are
written, every check is green, the label stays autorelease: pending — and
no tag is ever created, so nothing publishes and nothing reports why. The
only outward sign is a version in the manifest with no tag to match it.
"component": "" does not fix it. An empty string is falsy, so the
manifest-derived default wins anyway. Setting include-component-in-tag: false does not fix it either — that governs the tag's shape, not the
PR-matching.
Use release-type: simple with explicit extra-files. It derives no
component, which is why every repo here using it tagged correctly. Reach for a
language strategy only when it does something simple cannot — and check
first, because usually it does not: node only writes package.json's
version, which a {"type": "json", "jsonpath": "$.version"} entry does
identically, and dart's build-number increment (step 7 above) is worth
nothing to a pubspec.yaml with no +build suffix.
How to tell you have hit this, since nothing fails: compare
.release-please-manifest.json against git tag. A manifest ahead of the
newest tag, with a merged release PR still labelled autorelease: pending,
is this. Fix the config and re-run the workflow; it tags retroactively.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: falseKeyed per ref, and never cancelling. Two reasons, both load-bearing:
- Every job in
release.ymleither publishes or gates something that does, and a publish interrupted halfway is the one failure here that cannot be re-run into a clean state. - Under a global key, when a run is pending and a newer one joins the
group, GitHub cancels the older pending run. So a routine push to
maincan silently cancel a queued tag release, and the symptom is a release that never happened with nothing failing anywhere.
Accepted, and recorded rather than solved: three pushes to main in quick
succession still drop the middle run while pending, and two different tags
pushed close together still race for :latest. Cut one release at a time.
A repo whose example app depends on its own published package cannot bump that dependency in the release PR — the version does not exist on npm until minutes after the PR merges. It is a follow-up commit, every time.
And pnpm's 24-hour minimumReleaseAge quarantine will then reject the
freshly-published version. minimumReleaseAgeExclude is silently unread
under --ignore-workspace; --trust-lockfile is the documented escape for a
committed, reviewed lockfile.
Markdown lint versus the changelog. release-please writes one
### Bug Fixes heading per release section — correct changelog form, and
exactly what markdownlint's MD024/no-duplicate-heading forbids. This broke
every release PR in the org until lint.yml grew a default markdownlint config
setting MD024: siblings_only: true. Fixed centrally in this repo; nothing
per-repo is needed.
The near-miss is worth keeping: a minimal MD024 config would have been a
regression, because markdownlint configs replace rather than extend — it
would have dropped super-linter's extends: markdownlint/style/prettier preset
and re-enabled every formatting rule that preset disables, MD013 line length
included.
A lint that only sees changed files cannot tell you your untouched files are
clean. super-linter lints the diff. So vsms's own release-please.yml
carried five unpinned actions — @v3, @v5, @v7, @stable, @v2 — in the
workflow that mints a write-scoped App token, and the gate never once looked at
it, because the file had not changed since the gate was adopted. It surfaced
only when another repo copied it and zizmor scanned it as a new file.
Every workflow in every repo that predates the lint's adoption is in that same
position. A validate-all run would find them; nothing schedules one.
A SHA-pinned caller does not receive upstream fixes. That is the point of pinning. It also means every fix to a reusable workflow here reaches nobody until each caller is re-pinned, and nothing in this org notices. On 2026-09-18 all nine adopting repos had to be re-pinned twice, and both times the need was found by accident.
-
Copy
release-please-config.jsonand.release-please-manifest.jsonfromvaam-apps/vsmsand adapt. Set the manifest to the repo's current version — if it has published releases, match the latest tag, or release-please will propose a version that already exists. -
Enumerate every file carrying a version. Annotate each line with
# x-release-please-version(or$.versionfor JSON) and list it inextra-filesin object form. Check each annotated line's first semver. -
Copy
release-please.ymlandpr-title.yml, plusci/commit-message-parse/. Drop the lockfile-refresh step if the repo has no lockfile; keep it if it has any. -
Set the repository squash setting to
PR_TITLE. -
If the repo publishes artifacts, make
release.yml's version guard tolerate trailing content on a version line, and have the PR-time check run those same extractions. -
For a repo publishing a new npm package, do the manual bootstrap publish before the first release, then attach the Trusted Publisher.
-
For a Dart or Flutter package, use
release-type: dart, notsimple. A Flutterpubspec.yamlreadsversion: 1.2.3+45, where+45is the AndroidversionCode/ iOSCFBundleVersionand must increase with every store upload.simpleplus a generic annotation rewrites only the first semver on the line, giving1.3.0+45— a stale build number, which CI cannot see and the store rejects at submission. Thedartupdater increments it (src/updaters/dart/pubspec-yaml.ts, read directly):const parsedBuild = parseInt(buildNumber); if (!isNaN(parsedBuild)) { buildNumber = `+${parsedBuild + 1}`;
It is also an anchored regex replace, not the reparse-and-reserialise
GenericYamlof trap 2, and it needs no annotation on that file at all.But first ask whether anything already owns that field. In
vaam-apps/vaam-appsthe right answer turned out to be don't touchpubspec.yamlat all:store-release.ymlalready bumps its patch on every dispatch and commits it back, andmobile-platform.ymlrestamps the build number fromrun_number * 100 + run_attempt— a mechanism added after a real TestFlight rejection (altool409, duplicatecfBundleVersion). That repo had already removed apush: tags:trigger for being "a second authority for the same fact". A correctly-behaved third writer is still a third writer.dartis the right release type when release-please owns the version; it is the wrong tool when something else already does.
- Nothing notices a stale reusable-workflow pin. Both re-pin sweeps on 2026-09-18 were triggered by accident.
- Dependabot PRs fail
pr-title. There is no.github/dependabot.ymlin most repos; the durable fix iscommit-message.prefix: chore, which also switches on version-update PRs nobody asked for. - An annotated line in a file the config does not list is only caught by a whole-tree walk that nothing performs.
- Nothing enumerates this org reliably except the API. Two sweeps on
consecutive days each missed a repo — once two skills repos that had never
been listed, once the one private repo. Any script that hardcodes the repo
list will drift; read
gh repo list vaam-appsinstead. - Two repos deliberately do not use release-please:
vpay-skillsandvsms-skillsversion byvYYYY-MM-DD-<upstream-sha>, assigned when a human re-verifies the skills against a specific upstream commit. Their ownVERSIONING.mdexplains why a semver derived from this repo's commit types would assert something untrue. That is a considered exception, not a gap — do not "fix" it.