Releasing Aqualink Automate¶
For maintainers cutting a tagged release. Building from source lives in INSTALL.md; the workflow internals live in docs/ci-cd.md (the original redesign plan is in docs/design/cicd-redesign.md).
Version scheme¶
This project follows Semantic Versioning 2.0.0:
- MAJOR — incompatible API/protocol changes
- MINOR — new features, backward-compatible
- PATCH — bug fixes, backward-compatible
Prerelease labels¶
Prerelease versions use the format <label>.<N>:
| Label | Purpose | Example |
|---|---|---|
alpha |
Early development, unstable | 1.0.0-alpha.1 |
beta |
Feature-complete, testing in progress | 1.0.0-beta.2 |
rc |
Release candidate, final validation | 1.0.0-rc.1 |
The release workflow validates every tag against v<M>.<M>.<P>[-(alpha|beta|rc).<N>]. Keep tag naming aligned with the conventions in CONTRIBUTING.md.
Version source of truth¶
- CI builds: Version is derived from git tags at configure time via
cmake/GitVersionDerivation.cmake. The tagv1.0.0produces version1.0.0;v1.0.0-beta.2produces version1.0.0with prereleasebeta.2. - Local builds without tags: Falls back to
0.0.0-dev, making it obvious this is not a release build. - CMake
project(): Receives only theM.M.Pportion (CMake doesn't support prerelease inVERSION). The full version including prerelease is available viaPROJECT_VERSION_FULL.
Repository configuration¶
Docker Hub credentials (optional but recommended)¶
The runtime/ci Docker images build FROM ubuntu:26.04 and FROM node:24-bookworm-slim, which are pulled from Docker Hub. Anonymous pulls share the runner's egress IP and are subject to Docker Hub's anonymous per-IP rate limit — a recurring CI reliability risk, especially on self-hosted runners (one shared IP across many jobs) and during Docker Hub CDN hiccups.
Configure these optional repository secrets to authenticate base-image pulls and get a higher rate limit:
| Secret | Value |
|---|---|
DOCKERHUB_USERNAME |
A Docker Hub account username |
DOCKERHUB_TOKEN |
A Docker Hub access token (Account Settings → Personal access tokens; Public Repo Read-only scope is sufficient) |
Both the release docker-publish job and the CI docker-verify job log in to Docker Hub only when both secrets are present (if: env.DOCKERHUB_USERNAME != ''). If they are unset — forks, or before they are configured — the workflows fall back to anonymous pulls and still work. Nothing else is required.
The Docker build steps are additionally wrapped in a bounded retry (nick-fields/retry, 3 attempts), so a single transient base-image blob timeout retries instead of failing the whole release; the retry reuses the BuildKit layer cache, so it only re-fetches the layer that timed out.
Self-hosted runners: for heavily-used self-hosted runners you can additionally configure a pull-through registry mirror on the runner host (a local registry caching Docker Hub, referenced via
registry-mirrorsin/etc/docker/daemon.jsonand the BuildKit builder config). This is host/daemon configuration — not part of these workflow files — and complements the authentication above.
Pre-release checklist¶
Before creating a release:
- CI is green on
main(all platforms pass). - All intended changes are merged to
main. Real releases must be cut from main: the release commit has to be an ancestor oforigin/main. Theresolve-versionjob enforces this withgit merge-base --is-ancestorand hard-fails any non-dry-run release whose commit is not contained inmain(mergedevelop→mainfirst, then tagmain). Dry runs are exempt, so you can validate the pipeline fromdevelop. - Example configs in
examples/are current. - Release notes are reviewed. The
github-releasejob seeds the release body withgh release create --generate-notes(a flat list of merged PRs / commits since the previous tag) — treat that as a first draft only. Make sure CHANGELOG.md and the PR titles read well (they feed the draft), then curate the published body to the project's established pattern — this is a required step, described under Post-release. - Run a local build to verify version output:
./aqualink-automate --version. - Decide the version tag. You don't have to compute the SemVer bump by hand: the
develop→mainrelease PR gets an automatic suggested next tag comment (theSuggest Versionworkflow runsscripts/next-version.sh, which classifies the Conventional Commits being published and applies the 0.x policy below). Run the same script locally any time withscripts/next-version.sh(or--markdown/--tag). The suggestion is advisory — you still choose the tag and push it; nothing is auto-tagged.
| Highest-impact change | Bump (while 0.x) |
|---|---|
feat, or a breaking change (type!: / BREAKING CHANGE:) |
MINOR (0.3.x → 0.4.0) |
fix, perf |
PATCH (0.3.0 → 0.3.1) |
docs, ci, chore, refactor, test, style, build |
not release-worthy on their own |
Within an in-progress prerelease line, advancing the counter (-beta.N → -beta.N+1) carries the accumulated changes toward the same target; starting a new minor line is the human "the release line advances" decision the suggester surfaces but never makes for you. (1.0.0 is reserved for the first stable, so breaking changes bump the minor until then.)
- Bump the being-released channel's Home Assistant add-on version to match the tag you just decided on, before tagging:
./scripts/sync-homeassistant-addon-version.ps1 -Channel <edge|stable> -Version <version>—edgefor a prerelease tag (-alpha/-beta/-rcsuffix),stablefor a plainM.M.Ptag. Commit this tomainfirst: theresolve-versionjob fails fast (before any build) if the being-released channel'sconfig.yamlversion doesn't already match the tag, so bumping it after tagging just means deleting the tag and re-cutting it once the bump commit is onmain.
resolve-version also requires that channel's own CHANGELOG.md to carry a ## <version> heading matching the tag (same script, -Check mode) — the version bump and the changelog entry are two separate manual edits to the same channel, and a release with one but not the other fails fast rather than shipping a Supervisor update dialog with stale notes. Since aqualink-automate-edge/ is generated verbatim from aqualink-automate/, add the entry to the stable add-on's CHANGELOG.md and re-run scripts/gen-homeassistant-edge-addon.ps1 so it propagates to edge; the generator preserves each channel's own version across regeneration, so bumping edge's version (above) either before or after regenerating is fine.
Run the test suites by label¶
CTest tags each suite with a label (unit, integration, perf), so you can gate which tests run before tagging:
ctest --preset test-windows-msvc-debug -L unit # fast suite — local / PR gate
ctest --preset test-windows-msvc-debug -L integration # slower fixture-replay suite
ctest --preset test-windows-msvc-debug -L perf # Google Benchmark performance tests
CTest --preset only accepts a test preset, so use the test-* preset that matches the configure preset you built with — swap the config- prefix for test- (see INSTALL.md). Omit -L to run every registered suite.
Option A: tag-based release¶
This is the standard release method. Pushing a v* tag triggers the release workflow automatically.
# Ensure you're on the latest main
git checkout main
git pull origin main
# Create and push the tag
git tag v1.0.0
git push origin v1.0.0
For a prerelease:
Monitor the release at: Actions > Release in GitHub.
Option B: manual dispatch¶
Use the GitHub Actions UI to trigger a release manually. This is useful for creating releases from a specific commit (still constrained to commits already on main) or for testing.
- Go to Actions > Release > Run workflow.
- Fill in the inputs:
- version: e.g.
v1.0.0orv1.0.0-rc.1. - prerelease: check if this is a prerelease (auto-detected from tag suffix).
- dry_run: check to build packages without creating a GitHub Release.
For a manual dispatch the tag does not exist yet. The resolve-version job fails fast if the tag is already present (so you don't build every package only to collide at the end), and the tag is created and pushed by the github-release job at the end of a successful run — after the build, Docker publish, and package jobs all pass. A failed dispatch therefore leaves no tag behind, so you can fix the problem and re-run the same version.
Dry run¶
A dry run builds packages on all platforms without creating a GitHub Release or publishing Docker images. Use this to verify packaging before a real release. Dry runs are exempt from the "cut from main" guard, so you can run one from develop.
- Go to Actions > Release > Run workflow.
- Enter the version tag.
- Check dry_run.
- Download build artifacts from the workflow run to inspect packages.
Post-release¶
After a release is published:
- Curate the release notes to the established pattern (required). The auto-generated body (
--generate-notes) is only a starting point; rewrite it so the notes read as a coherent, user-facing changelog consistent with the previous releases. Use a prior release as the template (gh release view v0.2.0-beta.1 --json body --jq .body) and mirror the matching## [x.y.z]section of CHANGELOG.md. The structure, in this order: ## What's Changed since v<prev>heading (for the very first release:## What's Changed).- A one-line summary of the release.
- The changes as
###subsections — grouped by subsystem (e.g. Trends, Web UI) or as Added / Changed / Fixed — with bold lead-in bullets in user-facing terms (not raw commit subjects). A short release may use a flat bold-lead-in bullet list instead of subsections. - An
## Artifactssection: the "Self-contained, SHA-512-summed" table (Linux / Windows / macOS / Docker, including theghcr.io/<owner>/aqualink-automate:<version>image tag), then theaqualink-automate --help/ INSTALL.md pointer line. - The
**Full Changelog**: …/compare/v<prev>...v<this>link (keep the one from the generated notes; omit for the first release). - For prereleases, a trailing
> **Pre-release.**caveat blockquote (e.g. the unverified-Pentair-decoding caveat).
Apply the curated body with:
- Verify the GitHub Release page has all expected artifacts.
- Verify Docker images are published to GHCR.
- Verify the curated notes render correctly and read well; tweak the body if anything reads poorly.
Release artifacts¶
Each release includes:
| Platform | Packages |
|---|---|
| Linux (amd64) | .tgz, .deb, .rpm |
| Linux (arm64) | .tgz, .deb, .rpm |
| Windows | .zip, .exe (NSIS) |
| macOS | .tgz, .dmg |
Additionally:
- Checksums: .sha512 files for every package (CPACK_PACKAGE_CHECKSUM SHA512), plus an aggregate SHA512SUMS manifest.
- GPG signatures: a detached armored .asc beside every binary, a signed SHA512SUMS.asc, and the exported public key aqualink-automate-signing-key.asc — produced with the same REPO_GPG_PRIVATE_KEY that signs the APT/DNF repos. Skipped if that secret is unset.
- Build-provenance attestations: keyless SLSA provenance (Sigstore/OIDC) for every package and the Docker image, proving they were built by this repo's release workflow from this commit. See Verifying a download.
- SBOM: an SPDX SBOM for the release artifacts and one for the Docker image, each attached as a signed SBOM attestation (the image SBOM is also pushed to GHCR).
- Bundled runtime libraries: the vcpkg-provided shared libraries ship inside each package (private lib dir with RPATH/loader-path), so the binary runs without a separate dependency install.
- Example configs: the examples/*.conf files are bundled in each package.
- Home Assistant companion package: aqualink-automate-homeassistant-companion-<version>.zip — the dashboard, blueprints, and helpers package from homeassistant/companion/ (minus the CI test harness, plus a VERSION stamp). It sits in release-artifacts/ before the signing/attestation steps, so it is GPG-signed and attested like the binaries. See Home Assistant companion package.
- Docker image: Published to ghcr.io/<owner>/aqualink-automate. Each release publishes:
| Tag | Example | When | Mutability |
|---|---|---|---|
<version> |
0.3.0-beta.1 |
every release | immutable |
sha-<commit> |
sha-90202d6 |
every release | immutable |
<major>.<minor> |
0.3 |
stable only (skipped for prereleases by docker/metadata-action) |
floats |
latest |
latest |
stable only (non-prerelease) | floats |
edge |
edge |
prerelease only | floats |
So :latest tracks the newest stable release and :edge tracks the newest prerelease — pin whichever channel you want in your compose file (image: ghcr.io/<owner>/aqualink-automate:edge) and it floats to new releases without edits (run docker compose pull to fetch the moving tag; Docker caches by tag). Before edge existed, prereleases had no floating tag at all, so :latest did not resolve until the first stable release.
These packages are produced by CPack via the matching pack-* presets. pack-* presets exist only for the Release configure presets (those with no -debug/-coverage suffix), so swap the config- prefix for pack- only on a Release preset — for example config-linux-gcc → pack-linux-gcc. See INSTALL.md for the local pack-* preset workflow.
The Docker image is multi-arch (linux/amd64 + linux/arm64) — a single tag serves both, so a Raspberry Pi pulls the arm64 variant automatically.
Verifying a download¶
Every release artifact carries two independent proofs of origin, so a user can confirm a download really came from this project's pipeline and was not swapped for a poisoned build. See SECURITY.md > Verifying build authenticity for the user-facing walkthrough; in short:
- Build provenance (keyless, always on). Attested with Sigstore via the workflow's GitHub OIDC identity — no key to leak. Verify with the GitHub CLI:
# A downloaded package (offline bytes on disk)
gh attestation verify aqualink-automate_<version>_amd64.deb \
--repo iainchesworthlabs/aqualink-automate
# The container image (by tag or digest)
gh attestation verify oci://ghcr.io/iainchesworthlabs/aqualink-automate:<version> \
--repo iainchesworthlabs/aqualink-automate
A pass prints the workflow, commit and repository that produced the artifact.
- GPG signature (human identity). Present only when
REPO_GPG_PRIVATE_KEYis configured. Import the release public key once, then verify the manifest (or any single binary):
gpg --import aqualink-automate-signing-key.asc
gpg --verify SHA512SUMS.asc SHA512SUMS && sha512sum -c SHA512SUMS
# or a single file:
gpg --verify aqualink-automate_<version>_amd64.deb.asc aqualink-automate_<version>_amd64.deb
The two are complementary: provenance ties the bytes to this repository and build, the GPG signature ties them to the maintainer's key.
Package repositories (APT + DNF)¶
.github/workflows/publish-repos.yml publishes GPG-signed APT (reprepro) and
DNF (createrepo_c) repositories to the gh-pages branch when a release is
published, so users can apt install / dnf install and get apt upgrade updates
(see INSTALL.md). It no-ops until configured, so it is safe to
merge first. One-time setup:
- Generate a signing key (no passphrase keeps CI simplest):
- Add the secret
REPO_GPG_PRIVATE_KEY(Settings → Secrets → Actions) with the contents ofaqualink-repo-private.asc. If the key has a passphrase, also addREPO_GPG_PASSPHRASE. Then delete the local private-key file. - Enable GitHub Pages (Settings → Pages) serving from the
gh-pagesbranch (the first publish creates it). The repo base URL ishttps://<owner>.github.io/aqualink-automate/.
To (re)publish an existing release into the repos, run the workflow manually with its
tag (workflow_dispatch). The publish job preserves previously-published packages
(keep_files: true), so the repo accumulates the full version history.
Troubleshooting¶
Version shows 0.0.0-dev in CI¶
The checkout step needs fetch-depth: 0 so git describe can find tags. Verify the workflow has this setting.
Version shows 0.0.0-dev locally¶
No v* tag is reachable from HEAD. Create a tag or fetch tags:
git describe returns unexpected results¶
Ensure tags follow the v<M>.<M>.<P> format exactly. Lightweight and annotated tags both work.
Package filename doesn't include prerelease¶
The prerelease suffix is only added when PROJECT_VERSION_PRERELEASE is non-empty. Verify the tag includes a prerelease suffix (e.g. v1.0.0-beta.1).
Release fails because the commit is not on main¶
The resolve-version job rejects any non-dry-run release whose commit is not an ancestor of origin/main (git merge-base --is-ancestor). Merge your changes to main first (develop → main), then tag or dispatch from a commit on main. To validate the pipeline from another branch without merging, use a dry run.
Docker build fails pulling a base image (rate limit or CDN timeout)¶
Symptoms: docker-publish (release) or docker-verify (CI) fails while pulling ubuntu:26.04 or node:24-bookworm-slim, with errors like toomanyrequests: ... rate limit or dial tcp ...(production.cloudfront.docker.com): i/o timeout.
- The build steps already retry up to 3 times, so a one-off CDN timeout should self-heal — check whether the run eventually succeeded on a later attempt.
- If you see rate-limit (
toomanyrequests) errors, configure theDOCKERHUB_USERNAME/DOCKERHUB_TOKENsecrets (see Docker Hub credentials) so pulls are authenticated. - On self-hosted runners, consider a pull-through registry mirror (same section) to remove the dependency on Docker Hub's CDN for repeat builds.
Manual dispatch tag already exists¶
The resolve-version job rejects a dispatch whose tag already exists on the remote (rather than failing later at the push step). Delete the existing tag first, then re-run: