Skip to content

Releasing

Releases are lockstep: all eleven modules carry one version, because they ship as one stack behind one Helm chart. A single v* tag builds and publishes everything.

Every component is pinned to that version explicitly, whether or not it changed in the release. Nothing resolves a version at deploy time: the chart’s image tags are written out in full rather than falling back to appVersion, and a values file copied out of the chart stays on the version it was copied from. scripts/set-version.sh writes all 26 of those places, and the release refuses to build unless they all agree with the tag.

Releases are driven by release-please. You never tag by hand in the normal flow:

  1. You merge work into main using Conventional Commit messages.
  2. .github/workflows/release-please.yml keeps a single open Release PR up to date. It accumulates every change since the last release, works out the next version from the commit prefixes, and rewrites CHANGELOG.md along with all eleven module manifests.
  3. Nothing is published while that PR is open. Reviewing it is reviewing the release.
  4. Merging the Release PR is the release. release-please tags the merge commit, opens the GitHub Release with the changelog, and hands off to release.yml, which builds and publishes everything.

release-please opens the Release PR as the workflow’s own identity, and by default that identity is not allowed to. A repository where Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests is unchecked fails the run with:

Error: release-please failed: GitHub Actions is not permitted to create or approve pull requests.

Either of two things clears it:

  • A RELEASE_PLEASE_TOKEN secret holding a fine-grained PAT or GitHub App token with contents: write and pull-requests: write on this repository. The workflow prefers it and falls back to GITHUB_TOKEN when it is absent. This is the better option: a pull request opened by GITHUB_TOKEN starts no workflow run, so the Release PR would otherwise sit there with no CI against the release commit.
  • Ticking the setting, which lets GITHUB_TOKEN open the PR. Simpler, but the Release PR still gets no CI run.

The setting is also available at organisation level, where it overrides the per-repository one.

Every release job runs on the self-hosted fleet (runs-on: self-hosted), the same runners ci.yml uses. This is not a preference. A GitHub-hosted job bills against the account, and when that billing lapses the job is not queued — it is failed, in about four seconds, with:

The job was not started because recent account payments have failed or your
spending limit needs to be increased.

The release workflows were left on ubuntu-latest when CI moved to the fleet, so every release run after that died there with nothing to show for it. If the fleet is down, the release waits for it rather than falling back — which is the right failure, because a release that waits is recoverable and a release that half-published is not.

PrefixEffect on the versionAppears in the changelog
fix:patch — 1.0.0 → 1.0.1Bug Fixes
feat:minor — 1.0.0 → 1.1.0Features
feat!:, or a BREAKING CHANGE: footermajor — 1.0.0 → 2.0.0Breaking, at the top
perf: refactor: deps: build: ci: docs:none on their ownIts own section
test: chore:noneHidden

A stretch of docs:-only commits leaves the Release PR at the same version rather than inventing one. The version only moves when something that changes behaviour lands.

Scopes are optional and free-form — feat(core): …, fix(dashboard): … — and show up as a prefix on the changelog line.

The tag trigger still works as an escape hatch, for a version release-please would not compute on its own:

Terminal window
scripts/set-version.sh 1.1.0
git commit -am "chore: release 1.1.0"
git push
git tag v1.1.0 && git push origin v1.1.0

scripts/set-version.sh stamps the version into every component:

KindPlaces
pyproject.tomlplatform, core, the four analyzers, task-scheduler — seven files
package.jsondashboard, packages/platform-ui, docs
version.txtthe repository’s own version
Annotated linesthe chart’s version and appVersion, every image tag in values.yaml, values-dev.yaml and values-prod.yaml, and the terraform provider’s build-time default

The last row is any line carrying an x-release-please-version comment, whatever the file’s syntax — the same rule release-please’s own “generic” updater follows, applied by scripts/version-annotated.py. values-eks.yaml has no image block of its own and inherits the pins from values.yaml.

FlagWhat it does
--showlist the current version of every place
--check 1.1.0exit 1 unless they all already agree
--auditexit 1 if this script and release-please’s extra-files disagree about which files carry a version

--audit exists because the two lists are maintained separately and drifting apart fails silently in the worst direction: release-please stamps a file the check never looks at, so the release gate passes on a release whose manifests disagree. That is not hypothetical — OpenTremor-analyzer-python-code-change sat outside the check for its whole existence. CI runs both --audit and --check on every pull request.

The terraform provider still takes its version at build time through -ldflags "-X main.version=…", and the release workflow passes the tag there, so the ldflags value always wins for a published binary. Its var version default is stamped too, which is what makes a go build ./... out of a release tag report that release rather than dev.

.github/workflows/release.yml runs once release-please has tagged — or on any v*.*.* tag pushed by hand:

  1. version — resolves the version and runs set-version.sh --check against it. A release whose manifests disagree fails here, before anything is built or pushed. Under release-please they always agree; the gate matters for the hand-tagged path, and catches a botched extra-files config in either. It also catches a chart whose pinned image tags point at images this release is not going to build.
  2. ci — the full test suite, called as a reusable workflow. It is the same run a pull request gets, so a tag can never ship code that hasn’t passed it.
  3. images — builds and pushes four images to GHCR.
  4. chart — helm package at the release version, pushed as an OCI artifact.
  5. release — cross-compiles the terraform provider for linux/darwin/windows on amd64/arm64 and attaches the binaries plus a SHA256SUMS file. release-please has normally created the GitHub Release already, so this appends to it rather than replacing its changelog; a hand-pushed tag has no release yet, and one is created.
ArtifactLocation
Core (bundles platform + all three analyzers)ghcr.io/tguisep/opentremor-core:<version>
Dashboardghcr.io/tguisep/opentremor-dashboard:<version>
Docsghcr.io/tguisep/opentremor-docs:<version>
Task schedulerghcr.io/tguisep/opentremor-task-scheduler:<version>
Helm chartoci://ghcr.io/tguisep/charts/opentremor:<version>
Terraform provider binariesGitHub Release assets

A version with a pre-release suffix (v1.1.0-rc.1) publishes normally but never moves the latest tag, and marks the GitHub Release as a pre-release.

Terminal window
helm install opentremor oci://ghcr.io/tguisep/charts/opentremor --version 1.1.0 \
--set config.auth.apiKey="$(openssl rand -base64 32)" \
--set mongodb.enabled=true

Image tags are pinned in the chart’s values, so a chart at 1.1.0 pulls 1.1.0 images without any extra --set — and helm show values oci://ghcr.io/tguisep/charts/opentremor --version 1.1.0 states outright which image every component runs, rather than leaving it to be inferred from appVersion. The templates keep a | default .Chart.AppVersion fallback, so setting a tag back to "" still resolves rather than rendering ":".

Pinning one component to something else is the usual --set:

Terminal window
helm upgrade opentremor oci://ghcr.io/tguisep/charts/opentremor --version 1.1.0 \
--set dashboard.image.tag=1.1.1

The published images bake in the chart’s path-prefixed ingress shape — the dashboard at /dashboard, docs at /docs, and same-origin API calls. That is a build-time decision, not a runtime one: Next.js inlines NEXT_PUBLIC_* and its basePath when the image is built. A deployment that puts each app on its own subdomain needs its own images built with different arguments, which is exactly what the single-VM compose stack does locally through scripts/autodeploy.sh.

The repository is private, so its GHCR packages are private too. A cluster needs a pull secret before it can fetch them:

Terminal window
kubectl create secret docker-registry ghcr-pull-secret \
--namespace opentremor \
--docker-server=ghcr.io \
--docker-username=<github-username> \
--docker-password=<PAT with read:packages>

The values-dev.yaml and values-prod.yaml profiles already reference this secret by name.