Reusable GitHub Actions workflows implementing the Endatix release model. Adopting them in a repo gives you:
- A canary release on every push to
main—vX.Y.Z-canary.N(git tag, GitHub prerelease, artifacts published). Numbering is dense and collision-free; it resets after each stable release. - Human-reviewed stable releases — you trigger a workflow that creates a draft GitHub release with generated notes; you edit the notes, click Publish release, and the canary's artifacts are promoted to the stable version. No stable release ever happens without a human decision.
All release logic (versioning, tags, notes, drafts, promotion, credentials) lives in this repo. Your repo contributes only: how to build and push its own artifacts.
Create these two files in your repo. The shared workflows execute them by this exact path and name — the release fails if they are missing.
Both receive the release version as the first argument (you never compute versions yourself) and run with these environment variables set:
| Variable | Value | Meaning |
|---|---|---|
DOCKER_IMAGE |
from your stub's docker-image input |
image name to build/push |
RELEASE_CHANNEL |
canary or stable |
see the rule below |
scripts/release-prepare.sh — build every artifact, stamped with the
given version. Example (a .NET service using SDK container publish):
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:?usage: release-prepare.sh <version>}"
dotnet publish MyApi.csproj -c Release /t:PublishContainer \
-p:Version="${VERSION}" \
-p:ContainerRepository="${DOCKER_IMAGE}" \
-p:ContainerImageTags="\"${VERSION};latest\""scripts/release-publish.sh — push what prepare built. The one rule:
when RELEASE_CHANNEL=canary, do NOT move floating pointers (docker
latest, npm latest dist-tag, …) — those may only follow stable releases:
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:?usage: release-publish.sh <version>}"
CHANNEL="${RELEASE_CHANNEL:-stable}"
docker push "${DOCKER_IMAGE}:${VERSION}"
if [ "$CHANNEL" = "stable" ]; then
docker push "${DOCKER_IMAGE}:latest"
fiMake both executable and adapt the build/push commands to your repo (Gradle, npm, Helm, …):
chmod +x scripts/release-prepare.sh scripts/release-publish.shA working reference lives in endatix/test-release.
Optional: scripts/release-test.sh — if present, the pipeline runs it on
PRs and again before every canary (main may differ from what the PR
validated), but never on the stable rebuild (a stable promotes a commit whose
canary already required green tests). It may read GITHUB_REF /
GITHUB_EVENT_NAME and parallelize internally. Repos with heavy multi-runner
test matrices can instead keep tests as local jobs in ci.yml gating the
pipeline via needs: (separate logs and per-suite checks — see
endatix/endatix).
git branch stable main && git push origin stableRequired once, by semantic-release's branch validation. Nothing is ever committed to it and no release runs from it — just leave it alone.
Copy these into .github/workflows/, replacing <repo> and the toolchain
inputs (dotnet-version / java-version — set the ones your release scripts
need; omit the others).
ci.yml — one call covers both PR validation and the canary release. Each
event builds exactly once: on PRs/branches the shared pipeline runs your
release-prepare.sh at the throwaway version 0.0.0-ci (build check, nothing
publishes); on pushes to main the canary release's prepare step is the build.
name: Build, Validate & Release
on:
pull_request: { branches: [main] }
push: { branches: [main] }
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
id-token: write
jobs:
pipeline:
uses: endatix/release-workflows/.github/workflows/canary.yml@v1
with:
docker-image: ghcr.io/endatix/<repo>
dotnet-version: "10.x"
secrets: inheritRepo-specific checks beyond the build (tests, linting) go in additional local
jobs alongside pipeline.
release-start.yml — the manual trigger you run to begin a stable release:
name: Start Stable Release
on:
workflow_dispatch:
inputs:
canary_tag:
description: "Canary tag to promote (leave empty for the newest canary)"
required: false
default: ""
bump:
description: "Version bump relative to the last stable release"
type: choice
options: [minor, patch, major]
default: minor
permissions:
contents: write
jobs:
draft:
uses: endatix/release-workflows/.github/workflows/release-start.yml@v1
with:
canary-tag: ${{ inputs.canary_tag }}
bump: ${{ inputs.bump }}release-hotfix.yml — the one-field entry point for hotfix lines (always a
patch on the chosen line; see the Hotfixes section):
name: Start Hotfix Release
on:
workflow_dispatch:
inputs:
branch:
description: "Hotfix line branch to release from (e.g. hotfix/0.8.x)"
required: true
permissions:
contents: write
jobs:
draft:
uses: endatix/release-workflows/.github/workflows/release-start.yml@v1
with:
ref: ${{ inputs.branch }}
bump: patchrelease-artifacts.yml — reacts when you publish the reviewed draft:
name: Publish Stable Artifacts
on:
release: { types: [published] }
permissions:
contents: read
packages: write
jobs:
promote:
if: ${{ !github.event.release.prerelease }}
uses: endatix/release-workflows/.github/workflows/release-artifacts.yml@v1
with:
docker-image: ghcr.io/endatix/<repo>
dotnet-version: "10.x"If your repo publishes package artifacts besides the Docker image (NuGet, npm,
Helm), add promotion: rebuild — package versions are baked into the artifacts,
so unlike a Docker image they can't be digest-retagged; the stable release is
rebuilt at the tagged commit via your release scripts instead (see
endatix/api-contracts for a
multi-artifact example).
Notes:
docker-imageis optional everywhere — omit it if your repo publishes no image (see endatix/endatix, NuGet-only).- If your stable publish needs secrets beyond the job token, store them in
Infisical (prod) and add
fetch-secrets: true+id-token: writeto your release-artifacts caller. The canary workflow injects Infisical secrets automatically. - Publishing stables to nuget.org: add
nuget-login: true(withfetch-secrets: true) — a short-lived API key is minted per run via OIDC trusted publishing and exported asNUGET_API_KEY; no long-lived key is stored. One-time setup: the nuget.org username in Infisical asENDATIX_NUGET_ORG_USERNAME, and a trusted publishing policy on nuget.org (repo owner/name + workflow filerelease-artifacts.yml). - To gate the canary on tests, put your test jobs in
ci.ymland give thepipelinejobneeds: [your-test-jobs].
Push to main. The run should end with a new vX.Y.Z-canary.N tag, a
GitHub Pre-release, and your artifacts in the registry.
- Ship a canary: push to
main. That's it. - Cut a stable release: Actions → Start Stable Release → pick the bump
(and optionally a specific canary tag) → open the draft it creates → edit the
release notes → Publish release. The tag, the release, and the promoted
artifacts (including
latest) follow automatically. - Abandon a started release: delete the draft. Nothing was tagged or shipped.
To patch a stable release without shipping the unreleased work on main, each
version line gets one long-lived branch named hotfix/X.Y.x (the line, not
a single version — it produces X.Y.1, X.Y.2, … over its lifetime):
git checkout -b hotfix/0.8.x v0.8.0 # branch from the line's stable tag
git push -u origin hotfix/0.8.xThe branch behaves like a mini-main for its line:
- Fixes arrive by PR targeting
hotfix/0.8.x(thehotfix/**entries in the caller'spull_request/pushfilters make PRs validate exactly like PRs to main), or by cherry-pick from main. - Every push/merge to the branch publishes a prerelease build
0.8.1-hotfix.N— same artifacts, git tag, and GitHub prerelease as a canary. The distinct-hotfixidentifier can never collide with main's-canarybuilds even when both lines share a base version. Nothing floating moves; npm uses thehotfixdist-tag. - Releasing: Actions → Start Hotfix Release → enter the line branch
(
hotfix/0.8.x) — that's the only input; the bump is alwayspatch→ review the draft → Publish release. The tag is created at the branch's commit — the release contains the line plus its fixes and never anything from main, regardless of any merges before or after. (The underlying reusable workflow is the same as Start Stable Release; the dedicated one-field entry point exists so a hotfix can't accidentally be cut with a minor/major bump or from the wrong source.) - Repeated patches just work: after
v0.8.1is published (its tag lives on the branch), further pushes build0.8.2-hotfix.N, and the next promotion yieldsv0.8.2. The version base is always the nearest stable tag reachable from the branch — the branch name is never parsed.
Rules that keep this safe:
- Older-line releases become maintenance automatically. If a newer
stable exists (releasing
v0.8.1whilev0.9.0is out), the draft is created un-"Latest"-ed and publishing moves no floating pointers: dockerlateststays, npm getsmaintenanceinstead oflatest, nuget.org sorts by semver on its own. - Carry fixes across lines by cherry-pick, not branch merges. These repos
squash-merge, and repeatedly squash-merging a long-lived branch into main
re-presents already-merged commits. Either land the fix on main first and
cherry-pick it into
hotfix/X.Y.x, or fix the line first and cherry-pick to main via a small PR. Both directions are fine — but the fix must reach main, or it regresses in the next release. - One hotfix branch per line (the
X.Y.xname makes this natural). Two branches on the same line would race for the same-hotfix.Nnumbers. - Main's numbering is untouched by all of this. Canaries and stables from main always select the highest reachable release; hotfix tags on the side line sort lower (or aren't reachable at all under squash-merge) and are simply never selected.
- Delete the branch only when the line goes out of support. If it was deleted
early, recreate it from the line's newest tag:
git checkout -b hotfix/0.8.x v0.8.1— tags are the durable anchors.
| Concern | Lives in |
|---|---|
| Version computation, canary numbering | here (canary.yml) |
| Git tags, GitHub releases, release notes | here |
| Draft/review/promote stable flow | here (release-start.yml, release-artifacts.yml) |
| Credentials (Infisical, GitHub App, GHCR login) | here |
| How to build artifacts | your repo (scripts/release-prepare.sh) |
| How to push artifacts | your repo (scripts/release-publish.sh) |
| PR build validation | here (runs your release-prepare.sh @ 0.0.0-ci on non-main) |
| Extra checks (tests, linting) | your repo (additional local jobs) |
Consumers pin @v1. Breaking changes to the contract (inputs, script
interface) bump to v2 — consumers move deliberately; never track @main.
- This repo is private: Actions access is set to organization
(
gh api -X PUT repos/endatix/release-workflows/actions/permissions/access -f access_level=organization). - The Infisical machine identity and the Endatix Automations GitHub App cover
all org repos;
canary.ymlreadsENDATIX_PROJECT_AUTOMATIONS_APP_ID/ENDATIX_PROJECT_AUTOMATIONS_PRIVATE_KEYvia Infisical.