Releasing
Maintainers only. Releases are cut with release-please, the convention of every repository in the vaam-apps organization. Its guide, docs/releasing.md in vaam-apps/.github, lists the ways this has failed silently and is worth reading before changing anything here.
Nobody bumps a version by hand, edits .release-please-manifest.json, or runs a workflow to publish (the one exception is the recovery for a release PR merged before its pins, below).
The flow.
Land conventional commits. The repository squash-merges and the pull request title becomes the commit subject, which is all release-please reads:
feat:,fix:,docs:,ci:and the other conventional types.pr-titlerefuses anything else; a subject it cannot read is ignored, and no release PR appears.featandfixdecide the bump (before 1.0 a breaking change bumps the minor); every other visible type is a patch.- To force a version, put a
Release-As: X.Y.Zfooter in a commit.
The release PR. Every push to
mainupdates one standing pull request from the branchrelease-please--branches--main. It bumps the version incli/Cargo.toml,packages/fespalier/pubspec.yaml, theref:thatfsp initprints, the READMEs' and docs pages' install snippets and.release-please-manifest.json, and it writes the rootCHANGELOG.mdabove the hand-written history. Therelease-pleaseworkflow refreshescli/Cargo.lockon the branch, because the build is--locked.- Every spelled-out version carries a release-please annotation and is listed in
release-please-config.json. The trailing comment is why every reader of those files must tolerate one (seescripts/read-version.sh). - Nothing else sits on an annotated line or inside a start/end block, because the updater replaces the first version on each of them, so a dependency's range there would be overwritten.
- Every spelled-out version carries a release-please annotation and is listed in
Pins, on the PR. The
Release pinsworkflow buildsfspfor the five targets on the PR branch, stages the archives, and commits their SHA-256s to the branch aschore: pin fsp X.Y.Z checksums(packages/fespalier/lib/src/release_checksums.dart,scripts/pin_checksums.py).- release-please force-pushes the branch whenever
mainmoves, which removes that commit; the workflow then runs again on the new head. It recognises its own commit and does not loop. - The
fspbuild reads onlycli/, neverrelease_checksums.dart, so the pin commit does not change the binaries. - Never merge the release PR before the
Release pins gatecheck is green on its head. It is a required check onmain(see "Repository settings" below), so GitHub refuses the merge until then. The pin commit lands minutes after the PR opens, and a PR merged before it is tagged without its pins (v0.17.1, v0.20.0 and v0.21.1, before the check was required: see "The release PR was merged before its pins"). Other pull requests pass the check by skipping. - The archives wait in a staging draft release named
fsp-staging(visible to maintainers, replaced by every build, deleted after the release), not in workflow artifacts, which expire.
- release-please force-pushes the branch whenever
Merge the release PR. release-please (as the org's GitHub App, so that the tag raises a workflow event) creates the tag
vX.Y.Zat the merge commit and a draft GitHub Release. TheReleaseworkflow, triggered by the tag, then- checks that the tag equals the Cargo, pubspec, manifest and lockfile versions;
- fetches the staged archives and refuses anything that is not the pinned build (
scripts/verify-staged.sh): they must be this version, built from thecli/tree that is tagged, and every archive's SHA-256 must equal the pin in the tagged tree. It never rebuilds: builds are not reproducible, so a rebuild could not match the pins; - regenerates the
.sha256files and rendersfsp.rb(Homebrew) andfsp.json(Scoop) from the verified archives (scripts/packaging.py), attaches all of it to the draft Release, publishes it (only now is it public and the latest release), and deletes the staging draft; - if the repository variable
HOMEBREW_TAPis set (owner/repo), pushesfsp.rbto that Homebrew tap, and ifSCOOP_BUCKETis set, pushesfsp.jsonto that Scoop bucket. Each is its own job and optional: unset, it is skipped and the release is complete, withfsp.rbandfsp.jsonstill attached as assets.
A git dependency on the new tag therefore carries the pins, and
dart run fespalierrefuses any download that does not match them (a checksum served next to the binary can be replaced together with it; one in the package cannot).
0.8.0 was tagged but never published (its binaries were built before the last change); the wave it carried ships as 0.8.1.
Between releases, main still carries the last release's pins. On an open release PR, before its pin commit, the pubspec is ahead of them; the launcher then falls back to the release's .sha256 with a warning, as for any development build. cli/tests/versions.rs accepts pins for the current or an older version, never a newer one.
If something fails. A failed Release run leaves the release a draft (nobody sees it, and latest does not move): fix the cause and re-run the failed jobs.
- The usual causes are a
cli/change that reachedmainafter the last build (the release PR was merged in the minute after another pull request reachedmain, before release-please rebuilt its head:Release pins gatedoes not catch that, because the old head is pinned and verified, so merge a release PR only when GitHub does not say its branch is out of date), staged binaries that were replaced or deleted, or a release PR merged before its pins (next). - If the merged commit cannot be made to match, delete the draft and the tag and fix forward with the next release.
- A manual run of
Release(Run workflow) is the recovery below, not a smoke test: it needs atagand apinscommit and publishes that tag's release. (It used to build the five targets and publish nothing; a release PR'sRelease pinsrun builds them now.)
The release PR was merged before its pins. This is what happened to v0.17.1. Release pins builds the five binaries on the release PR, stages them in fsp-staging and only then pushes chore: pin fsp X.Y.Z checksums to the release branch, which takes many minutes. release-please opened the PR for 0.17.1 and it was merged a minute later, so the tag was made at a commit whose release_checksums.dart still pinned 0.17.0; the binaries were staged thirteen minutes after the merge and the pin commit pushed to the release branch after that. Nothing can be re-run into shape: the tag is fixed, and the tag's Release run either failed at "Fetch the binaries staged on the release PR" (no staging draft 'fsp-staging', because nothing was staged yet) or, once staged, fails the third check of scripts/verify-staged.sh (the tag pins 0.17.0, not 0.17.1). The pin commit exists, for exactly the tagged cli/, and the staging draft holds the archives it pins. So publish the tag against that commit:
Wait for the
Release pinsrun on the release PR's last head to finish. It stages the archives and pushes the pin commit; the staging draftfsp-stagingis then among the repository's releases (drafts are visible to maintainers).Find the pin commit's full SHA.
git fetch origin release-please--branches--main, thengit log origin/release-please--branches--main --grep '^chore: pin fsp X.Y.Z checksums$' --format=%H. If release-please has since rewritten the branch for the next release, the commit is gone from that log but its SHA still fetches: theRelease pinsrun's summary ("Pinned fsp X.Y.Z") shows its abbreviated hash.Actions, Release, Run workflow, Use workflow from:
main(any other branch is refused: the workflow definition must be the default branch's), withtag: the existing tag,vX.Y.Z;pins: that 40-character lowercase SHA.
For v0.17.1 these are
v0.17.1and2c606fd9381d9ebb25c9ce24f0a482b923bdd358.
The run is the tag push's run with two differences, both in its guard and publish jobs. The guard refuses it unless the tag exists, the tag equals the Cargo, pubspec, manifest and lockfile versions, pins is the commit chore: pin fsp X.Y.Z checksums for the tag's version, its cli/ tree equals the tag's (so the pins were made for exactly the tagged source) and its release_checksums.dart pins X.Y.Z. And the staged archives are verified against that commit's release_checksums.dart instead of the tag's: the log says so (archives are verified against the pins in <sha>, not against the pins in the tag), also when the tag's own pins happen to be right. The other two checks stay: build-info.json must say X.Y.Z and carry the tag's cli/ tree. The workflow file is main's, but everything it runs after the guard (the verification, the rendering, the release scripts) is the tag's own tree. From the verification on it is the tag push again: it attaches, publishes the draft Release release-please made for the tag, drops the staging draft and updates Homebrew and Scoop. It shares the concurrency group of the tag's push run.
A package at such a tag has pins for another version: v0.17.1's release_checksums.dart says pinnedVersion = '0.17.0', so dart run fespalier finds no pins for its own version and falls back to the release's .sha256 file with a warning, as for a development build (the comment at the top of that file). The release itself is complete and its archives are the pinned ones. The next release's pin commit writes the file again; until then main carries the older pins, which cli/tests/versions.rs accepts.
Release pins gate is a required status check on main (below), so GitHub now refuses to merge a release PR whose head is not the verified pin commit; v0.20.0 and v0.21.1, merged before it was required, needed the same recovery.
The post-publish smoke. Once the Release is public, the post-publish-smoke workflow (its own file, started by the release: published event that the publish step raises with the App token) installs the fsp that users get with install.sh at that tag, which checks the archive's SHA-256, then runs fsp create twice, for the base app and for --features all, without --local-packages, so the generated pubspec.yaml depends on the new tag in git. Each app gets flutter analyze and flutter test on FLUTTER_VERSION (equal to ci.yml's; cli/tests/versions.rs checks it). It is the one check that needs the published tag; ci.yml's create jobs check the same apps against the checkout before.
- It never gates a release. It is a separate workflow, not a job of
Release, so it cannot fail, delay or cancel one, and the release is already public when it starts. A red run means a user who installs that version may get a broken app: read the failed step (the job summary names the tag and the case), fix forward, and re-run it once the fix is released or the cause is outside the tag (a dependency that moved). - Re-run it against any release: Actions, post-publish-smoke, Run workflow, with the tag (
vX.Y.Z; anything else is refused). Or re-run the failed run from the Actions page. - It opens no issue and posts nowhere: the repository has no such pattern, and a write permission would be the only reason for the job to hold more than
contents: read.
Repository settings (not enforceable from a workflow): squash merging only, with the squash commit title set to the pull request title and the message to the commit messages; merge commits and rebase merges off. Release pins gate and Maestro flows open their routes on the web are required status checks on main (Settings, Rules, the main ruleset, Require status checks to pass, each with GitHub Actions as its source, so a status from any other app cannot satisfy it). Without the first, nothing stops a release PR from being merged before Release pins has pinned its head, which is how v0.17.1 was tagged with 0.17.0's pins and needed the manual recovery above (v0.20.0 and v0.21.1 too). Every other pull request passes it by skipping its jobs, so it costs nothing elsewhere. The second is the web-routes job (Development), which replays the shop's Maestro flows in a pinned Chromium on every pull request. Require branches to be up to date before merging is off: it would make every pull request run CI again whenever main moves, and the one case it would catch, a release PR in the minute before release-please rebuilds it, is in "If something fails" above. The release-please GitHub App must be installed on this repository, and on the tap and the bucket if you set HOMEBREW_TAP or SCOOP_BUCKET. To see which checks main requires:
gh api repos/fespalier/fespalier/rules/branches/main \
--jq '.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context'The Homebrew tap and the Scoop bucket are optional. A release publishes without them, with fsp.rb and fsp.json attached as assets. Each is its own job in the Release workflow, gated on a repository variable holding owner/repo, so either alone works:
HOMEBREW_TAP=fespalier/homebrew-tap. The job writesFormula/fsp.rb. Thehomebrew-prefix is what letsbrew tap fespalier/tapandbrew install fespalier/tap/fspfind it.SCOOP_BUCKET=fespalier/scoop-bucket. The job writesbucket/fsp.json. The manifest'scheckverandautoupdatelet Scoop's own tooling keep the bucket current too.
To set them up, once:
- Create the two repositories, and install the release-please App on them with
contents: write. - Set the variables under Settings, Secrets and variables, Actions, Variables here.
The repositories may be brand new with only a README. The job checks out the repository's default branch (main for a new repository; it never assumes master: it pushes back to whichever branch the checkout is on), creates Formula/ or bucket/ when it is missing, and commits fsp X.Y.Z. A repository with no commit at all cannot be checked out, so give it that README first.
If the variable is set but the App is not installed on the repository, the job fails at the token step after the release is already published: install the App and re-run that job. A release that went out before the variables were set can be copied by hand from its fsp.rb and fsp.json assets. Unset, the job is skipped.
By hand, per release (the editor plugins are versioned on their own and not part of the release PR):
- JetBrains Marketplace (IntelliJ plugin). Build the plugin from the tag with
cd editors/intellij && ./gradlew buildPlugin(JDK 21) and uploadbuild/distributions/fespalier-intellij-<version>.zipon the plugin's page in the JetBrains Marketplace, or runPUBLISH_TOKEN=<token> ./gradlew publishPlugin. Bumpversionineditors/intellij/build.gradle.ktsfirst. The first upload needs a vendor account and a manual review; later ones can use a permanent token from the Marketplace's My Tokens page. - VS Code extension. Not published to a marketplace: build the
.vsixfrom source (seeeditors/vscode/README.md). Bumpversionineditors/vscode/package.jsonfirst if you distribute a build.