Releases are cut by the Release workflow
(.github/workflows/release.yml), not by pushing a tag by hand. Tagging
manually skips every check below and leaves the tag pushed with nothing built.
- Make sure what you want to release is merged to
main. - Actions β Release β Run workflow.
- Use workflow from: the branch or commit to release. This is the ref
that gets tagged and the ref the checks run against β they cannot
disagree. It must be reachable from
main. - version: leave empty to have it worked out from the Conventional
Commits since the last tag (see "Auto-detected versions" below). If you do
pass one, it must be bare, full three-part semver β
2.0.0, not2.0, notv2.0.0(see CONTRIBUTING.md) β and it has to agree with the auto-detected value unless you also tick force. - force: allow a version that disagrees with the auto-detected one. The run warns and records what it overrode. That is all it overrides β it is not a general "release anyway" switch, and in particular it cannot release a commit that already carries a tag (see below).
- dry-run: defaults to on. Resolves the version, checks the ref is
unreleased and on
mainand that the tag is free, generates the release notes, and writes it all to the job summary without building or publishing anything. Uncheck it to actually release.
- Use workflow from: the branch or commit to release. This is the ref
that gets tagged and the ref the checks run against β they cannot
disagree. It must be reachable from
- The full CI suite runs β firmware build, host tests, lint, API specs, workflow lint β so what you approve next is already green.
- The run pauses for approval. The job that tags is bound to the
stableenvironment, so it sits pending until a required reviewer approves it on the run page. There is no draft to publish by hand; this is the confirmation step. - On approval it tags the ref locally, builds, and asserts the version baked
into the firmware matches the tag β for both
autolee.binand the factory image. Only then does it push the tag and create the GitHub Release as a prerelease with its artifacts already attached. - The published assets are downloaded back over the public path and re-checked:
checksums against what was built, factory image exactly 4 MB, and the
esp_app_desc_t.versioninside each binary equal to the tag. - Only then is the prerelease promoted to the latest release β one flag flip, assets already in place.
Both come from git-cliff and
cliff.toml, reading the Conventional Commit subjects since the
previous tag. Under squash-merge those subjects are the PR titles, which
check-semantic-pr.yml already validates β so the input to this is checked long
before a release runs.
| Highest-ranked commit since the last tag | Bump |
|---|---|
any !: (e.g. feat!:) |
major |
feat: |
minor |
fix:, docs:, refactor:, β¦ |
patch |
only ci: / build: / tools: |
none |
That last row is deliberate: a range containing nothing but maintainer-facing changes has nothing in it for whoever flashes the board, so auto-detect returns the tag that already exists and the run fails on "nothing to release". Pass a version explicitly (with force) if you really do want to ship it.
A commit that is already tagged cannot be released again, whatever version you
pass and whether or not force is ticked. prepare rejects it in seconds
with "is already released as β¦".
This is not bookkeeping fussiness. The version the firmware reports comes from
git describe at CMake-configure time, and git describe picks arbitrarily
between two tags sitting on the same commit β so tagging 1.0.1 onto the commit
already tagged 1.0.0 builds a binary that reports 1.0.0. The readback
assertion in the release job catches it, but not before an approval and a cold
ESP-IDF build have been spent.
If a release attempt failed and you want to retry, the fix is to land the
commits you meant to ship, not to bump the number: git describe counts from
the tagged commit, so anything new on main is a distinct commit and releases
cleanly. Release tags are created annotated for the same reason β where a
commit does end up multi-tagged, describe at least resolves to the newest.
Two cases where you must pass the version yourself:
- The first tag in the repository. With no tags to bump from, git-cliff
answers
0.1.0regardless of history. - Any deliberate jump β declaring
1.0.0, skipping a number, or releasing a version the commit history does not justify.
The mapping above is pinned by tools/tests/test_cliff_config.py, which runs the
real git-cliff against throwaway repos on every PR (the release-config job in
.github/workflows/lint.yml) β otherwise a cliff.toml mistake would only
surface as a wrong release. Run it locally with git-cliff on PATH:
pytest tools/tests # skipped, not failed, if git-cliff is missingThe notes are generated in the prepare job, shown in the job summary at the
approval gate, and handed to the release job as an artifact β so the notes that
ship are provably the ones that were approved, not a regenerated copy.
| File | What it is |
|---|---|
autolee-X.Y.Z-factory.bin |
Full 4 MB flash image β bootloader, partition table, app. Fresh install, flash at 0x0. |
autolee-X.Y.Z-ota.bin |
App image only. The web UI's Firmware page takes this one; it can also be written over serial at the ota_0 offset. |
SHA256SUMS.txt |
Checksums for both. |
The literal v in those filenames is prose. The tag, and the version the
firmware reports, never has one.
The release has to be verifiable before anyone can reach it, and those two pull in opposite directions.
A draft's assets are not downloadable without an authenticated token, so the verify job could not exercise the same path a user gets β it would be checking something other than what ships.
A prerelease is publicly readable by exact tag, but /releases/latest
excludes it, and the release page does not badge it as Latest. So it is fully
testable while no one is being pointed at it.
If verification fails, the release stays a prerelease: nobody was served it. The tag and prerelease are left in place for a human to delete or supersede β fixing forward with a patch version is usually cleaner than deleting a pushed tag.
This closes a window that used to exist. The old workflow fired on
release: published, so the release went public first and the firmware was
built afterwards: for the length of a cold ESP-IDF build, a live release
advertised binaries that did not exist, and a failed build left it public and
permanently empty.
api/openapi.yaml and api/asyncapi.yaml carry info.version, and it is the
firmware release version, not a separately-evolving document version. They
are the only hand-maintained version strings in the repo β everything else is
derived from the git tag (see "What the version assertion is for" below).
So: bump both to the version you are about to release, in the PR that makes the
last API-affecting change, before the release runs. Nothing enforces this yet
β the workflow asserts the firmware binaries match the tag, but it does not look
at the specs, so a stale info.version ships quietly and points whoever reads
it at the wrong contract. If that turns out to bite, the fix is a one-line CI
check comparing info.version against the resolved release version.
There is no version constant in the source. esp_app_desc_t.version comes from
git describe --always --tags --dirty, evaluated by CMake at configure time,
and that is what the boot banner, /api/v1/state and
/api/v1/diagnostics/info all report. The artifact filenames, by contrast,
come from the workflow input. Three documented things can make those disagree
without anything looking wrong:
PROJECT_VERis cached at configure time, not recomputed per build;git describepicks the nearest tag by commit-graph distance, not the highest semver;- a dirty tree appends
-dirty.
tools/app_desc.py reads the descriptor back out of the built image, and the
release fails rather than shipping a binary that misreports its own version.
CI runners are clean checkouts, so the first and third are unlikely there β the
assertion mostly earns its place as a backstop for the second, and for anything
that ever changes about how the version is derived.
Important
The stable environment must exist with a required reviewer.
GitHub auto-creates a named environment on first use with no protection rules at all. If nobody configures it, the approval gate in step 4 runs straight through and the release publishes unattended β the gate is there but does nothing, with no error to tell you.
Settings β Environments β New environment β stable β Required
reviewers β add a maintainer.
Environment protection rules are free on public repositories; the paid tier applies to private and internal ones only.
Other settings worth applying by hand (this repo has no settings.yml and no
Settings app installed, so none of these are enforced as code):
- Squash merges only, with squash commit title = PR title. GitHub's
default (
COMMIT_OR_PR_TITLE) uses the commit subject when a PR has exactly one commit β so a PR whose title was validated bycheck-semantic-pr.ymlcan still land onmainwith an unchecked subject line. - Delete branch on merge (currently off).
- Branch protection on
main: require the CI checks andValidate PR titleto pass. Note that requiring an approving review locks a solo maintainer out of merging their own PRs.