Skip to content

How Cyber Fidget firmware releases work

This page explains the project's versioning philosophy and release flow -- useful if you're a contributor (so you know what's expected of you, or not), a user choosing which firmware to install, or someone trying to understand why a binary you have reports a particular version.

For the firmware-side technical reference (banner format, serial CLI, runtime APIs), see Firmware version.

Versioning philosophy

Semantic versioning, decided by humans

Cyber Fidget firmware uses SemVer 2.0.0 and the release manager makes the bump decision (major / minor / patch) by hand based on the actual semantic impact of changes since the last release. There's no commit-message-driven auto-bump (yet).

Why this matters to you:

  • If you're an app author writing apps for The Archives: the min_firmware: 1.4.2 field on your app pins against a real semver contract. A 1.4.x device will run any app that requires >=1.4.0; a 1.3.x device won't.
  • If you're a firmware contributor: you don't need to think about versioning when opening a PR. Just describe the impact in your PR description (bug fix? feature? breaking change?) and the release manager will fold that into the next bump decision.
  • If you're an end user: the version number you see on your device means something specific. 1.4.2 → 1.4.3 is a patch (bug fixes); 1.4 → 1.5 is a minor (new features, backward-compatible); 1.x → 2.0 is a major (breaking changes).

Bump-on-release, not bump-per-commit

The version number embedded in the firmware advances only when a release is cut. Between releases, dev/CI builds report the upcoming release version with a +commit-hash suffix that uniquely identifies the build:

  • A clean release: 1.2.0+abc1234
  • A CI build of someone's PR after the 1.2.0 release: 1.2.1+def5678 (the version has been pre-bumped to the next-patch target so dev builds advertise where they're heading)
  • A local dirty build: 1.2.1+abc1234.dirty

This is the firmware-industry default -- it matches how ESP-IDF, Arduino, Raspberry Pi firmware, and Postgres all version themselves. Versions mean something; commit hashes uniquely identify what was actually built.

Release tiers

In order of significance (and audience):

Tier Tag shape Audience When to use it
Stable (also called "GA" or "General Availability" in enterprise contexts) v1.2.0 Everyone (default channel) "Confident this is ready"
Release Candidate (RC) v1.2.0-rc1, -rc2 Bleeding-edge opt-in users "Feature-complete, want eyes before promoting to Stable"
Beta v1.2.0-beta, -beta.2 Wider tester pool "Feature-mostly-complete, may still tweak"
Alpha v1.2.0-alpha, -alpha.2 Internal / insiders "Early; expect churn"

The default release channel is Stable only. You'll see Stable releases on the GitHub Releases page without any special opt-in. RCs and below are marked as "Pre-release" by GitHub and are intended for users who explicitly want to test upcoming features and report issues.

Below RC sit non-release builds (nightly, CI, local dev) -- those are internal development scaffolding and aren't normally distributed. See the build-types section in the firmware-version reference for what these mean in serial output.

Where releases live

  • Stable + Pre-release artifacts: GitHub Releases page on the firmware repo (github.com/CyberFidget/cyberfidget-firmware/releases)
  • Latest Stable is the release at the top with no "Pre-release" tag
  • To opt into prereleases: scroll past the top Stable release; RCs and betas appear immediately below, marked with a yellow "Pre-release" badge
  • App Builder firmware selector (planned): defaults to "Latest Stable" with a checkbox for "Include pre-release builds"

For contributors: what you need to do (and what you don't)

If you're submitting a PR to the firmware repo:

Don't: - Don't edit version.txt. The release manager owns it; touching it in a PR will get pushback during review. - Don't push tags. Tag creation is release-manager territory. - Don't worry about which version your PR will land in. The release manager folds it into whichever release happens next.

Do: - Note your change's semver impact in the PR description (bug fix / feature / breaking). The release manager uses this when deciding the next bump. - Test against a CI build of your own PR. Every commit you push to a PR branch triggers a CI build with a unique identifying string -- something like fw=1.2.1+yourhash type=ci-dev. Flash that build, see the matching banner on the device, run your tests. The build summary printed during compile and the device's boot banner will report the same string character-for-character; if they don't match, something's wrong (stale cache, wrong serial port, partial flash) -- investigate before continuing. - Add a line to the [Unreleased] section of CHANGELOG.md, under Added / Changed / Fixed / Removed, in the same pull request as your change. Write it for someone holding the device rather than someone reading the code: name what the change does, not how it's built. This is what becomes the summary at the top of the release notes, so writing it while the change is fresh beats reconstructing it from commit subjects months later.

That's the whole contributor flow. You never touch the release machinery.

For release managers: how releases get cut

Two paths exist, both supported. Use whichever you prefer.

Path A - manual via git tag

# Bump version
$EDITOR cyberfidget-firmware/version.txt    # 1.1.0 → 1.2.0
git add cyberfidget-firmware/version.txt
git commit -m "chore(release): bump to 1.2.0"
git push origin main

# Tag and push
git tag v1.2.0           # or v1.2.0-rc1 for an RC
git push origin v1.2.0   # ditto

# CI does the rest (build → release artifact attached to GitHub Release)

# (For Stable only) post-release bump so dev builds advertise the upcoming version
$EDITOR cyberfidget-firmware/version.txt    # 1.2.0 → 1.2.1
git add cyberfidget-firmware/version.txt
git commit -m "chore: post-release bump to 1.2.1"
git push origin main

CI asserts that the tag matches version.txt before doing any compile work. If they disagree, the build aborts with a clear error in ~1 second.

Path A does not stamp CHANGELOG.md for you. The release notes still pick up whatever is in [Unreleased], so the notes come out right, but the changelog keeps calling that section unreleased after it has shipped. Tidy it by hand afterwards, or use Path B, which does it as part of the run. This is the tradeoff for Path A being three commands you can run during an incident.

Path B - run the release workflow

Instead of typing commands:

  1. Open github.com/CyberFidget/cyberfidget-firmware/actions
  2. Click "Build & Release Firmware" in the left sidebar
  3. Click "Run workflow", and pick the branch to release from
  4. Fill in:
  5. Version: 1.2.0 (no leading v)
  6. Release tier: rc (default), beta, alpha, or release for Stable
  7. Iteration: leave blank to auto-pick the next free one (rc1, then rc2, ...), or name it explicitly
  8. Post-release bump: patch (default) / minor / major / skip. Applies to Stable releases only -- an RC series leaves version.txt alone
  9. Rehearse: tick to build and print the plan without pushing or publishing anything
  10. Permit a version lower than the highest released one: leave unticked. It exists so a deliberate rollback is possible, and so a misclick isn't
  11. Click "Run workflow"

The workflow does the version.txt bump, the changelog stamp, the commit, tag creation and push, the build, the release with its notes and artifacts, and (for Stable) the post-release bump -- all in one run. Manual git tag from Path A continues to work in parallel.

It refuses to publish rather than publish something wrong: a tag that already exists, a version below the highest already-released one, a build whose embedded version string doesn't match the tag, or a changelog with nothing written in it will each stop the run.

Rehearse first when you're unsure

Ticking Rehearse runs the whole thing -- version resolution, the bump, the build, the version check -- and stops before anything is pushed or published. It's the cheap way to confirm a branch actually builds, or to see which tag the auto-picker would choose, without committing to a release.

Signing and publishing

Both paths end the same way. The build job first checks that the firmware's official key table (lib/CloudSync/UpdateSigning.cpp) is filled in and well-formed (scripts/release_public_key.py --check-table), and stops if it is not. It creates the GitHub release as a draft, with its notes and files, and hands the exact firmware.bin it built and checked to a second job, sign. Only sign publishes the release, and it publishes only a signed one. A draft is invisible to the website's update manifest, so no Fidget is offered a release until sign finishes. A Rehearse run creates no release and runs no sign job.

sign runs in a GitHub Environment named release-signing. Set that environment up (repository Settings > Environments) with a required reviewer: the job then waits until a reviewer approves it, and only an approved job can read the signing key. It uses two settings:

Setting Kind What it holds
FIRMWARE_SIGNING_KEY Secret The private signing key as Privacy-Enhanced Mail (PEM) text. It must be an Elliptic Curve P-256 key.
FIRMWARE_SIGNING_KEY_ID Variable The key's name: 1 to 31 lowercase letters, digits or hyphens. It must match the name of the key's public half in the firmware's key table, lib/CloudSync/UpdateSigning.cpp.

What sign does:

  • It checks the key id's format and reads the public key with that id from the firmware's key table at the release's tag. It signs the exact firmware.bin, refuses a key that is not P-256, and refuses a private key whose public half does not match the firmware's public key for that id (FIRMWARE_SIGNING_KEY does not match the firmware public key for <id>). It then verifies the signature over the same bytes with the firmware's public key, uploads firmware.bin.sig and firmware.bin.sig.keyid to the draft (see Signature fields), and publishes the release. A release that a Fidget with those keys could not verify is never published.
  • If FIRMWARE_SIGNING_KEY or FIRMWARE_SIGNING_KEY_ID is missing, the job fails with FIRMWARE_SIGNING_KEY is missing; release remains a draft (or the same for the id). There is no unsigned fallback. A key id that is not in the firmware's key table also fails, and the release stays a draft.
  • If any step fails, or the reviewer rejects the job, the release stays a draft and nothing is offered to Fidgets. The tag (and, for a Stable release cut with Path B, the post-release bump) has already been pushed by then. The build hands the image to sign for one day, so fix the cause and re-run the failed sign job within that day; the signature upload replaces any partial one.
  • A Path B run from a branch other than the default branch does not run sign at all, so its release stays a draft. Cut releases meant for Fidgets from the default branch.

Key rollout order

A Fidget checks a signed release against the public keys built into the firmware it is running. A signed release whose key id that firmware does not know is not installed over WiFi, even on a Fidget with the USB opt-in; the Fidget keeps the offer and sends its owner to the website instead. The key table holds two official keys: cf-release-1, which signs every release (so FIRMWARE_SIGNING_KEY_ID is normally cf-release-1), and cf-backup-1, which never signs routinely. Both private keys are kept outside the repository.

A Fidget on firmware from before these keys were built in does not know cf-release-1, so it cannot take signed releases over WiFi. Its owner sees the website message, and one update from the website over USB installs firmware that has the keys; later releases then install over WiFi.

Every new key id follows the same rule: it must first ship inside a release signed by an id Fidgets already trust, and only then may anything be signed with it. Until that release is out, Fidgets that do not have it yet treat anything signed with the new id as an unknown key.

Key rotation. The backup key is what makes rotation possible. If the release key is lost or exposed, sign an update with the backup key, which Fidgets already trust: set FIRMWARE_SIGNING_KEY to the backup private key and FIRMWARE_SIGNING_KEY_ID to cf-backup-1 for that one release (the sign job checks it against the backup public key the same way). That update removes the bad public key from the table and can add a replacement. Then follow the rule above before signing with the replacement. Never use the test key (test-only-1, known only to test builds) as FIRMWARE_SIGNING_KEY.

What goes in the release notes

Release notes are assembled from two sources:

  • The summary at the top comes from the [Unreleased] section of CHANGELOG.md in the firmware repo, written by hand in Added / Changed / Fixed / Removed form. Whoever lands a change adds their line in the same pull request, so the summary is already written when the release is cut.
  • The full commit list underneath is generated by GitHub from everything since the previous release.

When the release is cut, the workflow stamps the [Unreleased] heading with the version and date and opens a fresh empty one for the next cycle. A release with an empty [Unreleased] section fails on purpose: a release nobody can read is not ready to ship.

Why we chose these specific workflows

A few decisions worth making explicit:

  • Human-managed semver, not auto-increment: firmware versions are a contract that user-apps depend on. Contracts need human judgment about what's breaking. We can revisit Conventional-Commits-driven automation later if contributor count grows enough to make commit-message discipline pay off.
  • Bump-on-release, not bump-per-commit: shipped firmware is a contract; thousands of "versions" with no semantic meaning would be noise. Commit hashes already disambiguate dev builds.
  • Stable gets a post-release bump; RC doesn't: an RC series iterates toward a single Stable target, so version.txt stays put through rc1, rc2, rc3. After Stable ships, advancing version.txt keeps subsequent dev builds from advertising the just-shipped version.
  • Both manual and workflow-dispatch release paths: optionality has near-zero cost. Manual is faster for emergency hotfixes; workflow- dispatch is friendlier for the standard ritual and harder to forget the post-release bump.
  • Default release channel is Stable-only: most users want stability. RC/beta/alpha exist but are opt-in.
  • Downgrade is supported: if you flash an older firmware over a newer one, the device wipes settings and proceeds. We don't refuse downgrades -- sometimes you want to roll back.

See also