Release process
The order below matters, and it is not the order it looks like it should be. Every step that looks skippable has a way of failing silently rather than loudly, and two of them did.
Throughout: <Version> is the value in src/Delibera.Core/Delibera.Core.csproj. That file is the
single source of truth — not the tag, not CHANGELOG.md.
Before you tag#
1. Bump the version#
# All five packaged projects, plus the Web UI manifest.
grep -rl '<Version>10\.5\.2</Version>' src --include=*.csprojDelibera.Core, Delibera.Server, Delibera.Redis, Delibera.Grpc, Delibera.Grpc.Client,
and src/Delibera.WebUI/package.json.
Then release notes:
PackageReleaseNotesinDelibera.Core.csprojandDelibera.Server.csprojCHANGELOG.md— convert[Unreleased]into## [<Version>] - <YYYY-MM-DD>docs/WhatsNew-v<Version>.md— hand-written; this is what the release page is built from
⚠️ The tag does not set the package version.
publish-nuget.ymlpacks from the csproj. If you tagv10.5.3while the csproj still says10.5.2, the workflow packs10.5.2.nupkganddotnet nuget push --skip-duplicatediscards it without a word, because10.5.2already exists. The job goes green and nothing is published.
release.ymlnow fails loudly on exactly this mismatch — but only for the release page, and only after the packages are already gone.
2. Open a PR and merge it#
CI runs three gates on the PR: build & test, build (verify only), webui tests. Wait for all
three. Merge with a merge commit — not squash.
Squash was what made branch handling expensive: the squashed PR's commits are not ancestors of
main, so git merge-base --is-ancestor <branch> main cannot answer whether the work shipped,
and merging main back into a long-lived feature branch produces a disconnected merge rather
than a fast-forward. A merge commit keeps the ancestry, which makes the back-merge ordinary and
the ancestry check meaningful. git log --first-parent main still reads as the release list.
Delete the branch after merging. It is never reopened: a new change starts from a fresh branch.
3. Verify before tagging#
dotnet build Delibera.slnx -c Release -warnaserror
dotnet test Delibera.slnx -c Release --no-build
# The check that actually catches the silent failure:
dotnet pack src/Delibera.Core/Delibera.Core.csproj -c Release --output ./artifacts
ls ./artifacts # must contain <Version>.nupkg — not the previous oneCut the tag#
git tag -a v<Version> -m "v<Version>"
git push origin v<Version>Pushing v* starts four things in parallel:
| Workflow | What it does |
|---|---|
publish-nuget |
packs Core/Server/Redis, pushes to nuget.org (OIDC, no stored API key) |
publish-docker |
builds linux/amd64 + linux/arm64 for both images, pushes, then runtime-smokes them |
release |
creates the GitHub Release page from the WhatsNew doc + auto PR notes |
dockerhub-metadata |
PATCHes the Docker Hub description, overview and categories |
Docker Hub requires two things that are easy to miss#
- The repositories must already exist.
techbuzzz/delibera-serverandtechbuzzz/delibera-webui.docker pushinto a repository that does not exist fails, and the workflow cannot create it. DOCKERHUB_USERNAMEandDOCKERHUB_TOKENmust be set as repository secrets. Settings → Secrets and variables → Actions. A scoped access token with Read & Write — Docker Hub has retired classic PATs.
Missing secrets fail in zero seconds with Username and password required. That timing is
the tell: a wrong token takes about a second and says unauthorized.
After tagging#
Approve the publish#
Both publish workflows run their publish job inside a GitHub environment — nuget-prod and
dockerhub-prod. A tag push queues the run and it waits at that job for approval, once
Required reviewers is enabled on the environment.
To approve: Actions → the run → the waiting job → Review deployments → Approve and deploy.
This is a separate layer from the tag gate, and it is the one that actually protects the
registries. if: startsWith(github.ref, 'refs/tags/v') already stops a merge to main from
publishing anything — but it does nothing about a mistyped tag, or about anyone able to push a
v* tag. That path overwrites latest on a registry consumers pull from, and re-tagging does
not undo it.
Enabling required reviewers is a repository setting, not a workflow change:
Settings → Environments → nuget-prod / dockerhub-prod → Required reviewers. Until then
the environment only scopes secrets, the deployment URL and the history.
The DOCKERHUB_USERNAME / DOCKERHUB_TOKEN secrets can be moved from repository scope into
dockerhub-prod at the same time — the job already runs inside that environment, so they stop
being readable by every other workflow.
Check what actually landed#
Green pipelines are not proof that something was published:
curl -s https://api.nuget.org/v3-flatcontainer/delibera.core/index.json | grep <Version>
curl -s https://hub.docker.com/v2/repositories/techbuzzz/delibera-server/tags | grep <Version>If the nupkg pushed fine but the csproj version was stale, NuGet looks untouched and the run is
green. That is the failure mode --skip-duplicate creates.
Backfilling a release page#
Nothing happens on a merge any more. release is tag-only, so the page is written once when the
tag lands and is not rewritten by unrelated commits afterwards — which matters, because a merge
that happened to touch the header or the WhatsNew document used to silently edit an already
published release.
Actions → release → Run workflow repairs a page: name the tag in the tag input to rebuild
that release, or leave it empty to refresh the newest reachable one. It creates or updates; it
never duplicates.
The workflow still distinguishes two situations on purpose:
| Trigger | Behaviour on a tag/<Version> mismatch |
|---|---|
v* tag pushed, or dispatch naming a tag |
fails — that tag exists to publish something specific |
| dispatch with no tag (sync mode) | exits 0 — an unreleased version bump is a normal state, not an error |
Backfilling Docker Hub cards#
Normally also nothing to do: dockerhub-metadata runs on any merge to main that touches
dockerhub/** or the workflow itself.
Actions → dockerhub-metadata → Run workflow forces it without a code change. The cards are
empty by default when a repository is created through the API — docker push writes no metadata.
Both backfills need the workflow on main first#
workflow_dispatch only exists for workflows that GitHub can see on the default branch. A
new release.yml or dockerhub-metadata.yml sitting on a feature branch has no Actions entry,
so "Run workflow" does not merely fail — the button is not there.
Confirm what is actually dispatchable before promising a backfill:
curl -s https://api.github.com/repos/techbuzzz/Delibera/actions/workflows \
| grep '"name"' # only workflows on main appear hereThe symptom if you skip this check: the tags are on Docker Hub, the pulls counter is climbing, and the overview is still empty — with every pipeline green, because nothing in the tag-push path writes metadata on its own.
GitHub Pages (one-time setup)#
Settings → Pages → Source → GitHub Actions. Do this before merging the workflow that
deploys: pages.yml runs on every push to main, and merging it into a repository whose Pages
site does not exist yet produces a red run on main with
Get Pages site failed.
Please verify that the repository has Pages enabled and configured to build using GitHub Actions
Error: Not FoundThat failure is about the site, not the workflow: pushing a workflow file does not create a
Pages site, and enablement: true does not help either, because creating one is a repository
administration operation and the automatic GITHUB_TOKEN can never administer the repository — no
permissions: block changes that. The site is created once, by a human in Settings, or by an
authenticated POST /repos/OWNER/REPO/pages -f build_type=workflow.
Do not try to diagnose this over the API. Neither signal answers the question:
| Probe | What it says | What it does not say |
|---|---|---|
GET /repos/OWNER/REPO/pages (unauthenticated) |
404 | nothing — it answers 404 for lack of permission just as readily as for a missing site |
"has_pages": true on the repository |
Pages is available to this repo | that a site exists: https://OWNER.github.io/REPO/ returns a GitHub 404 while the field reads true |
The check that settles it is the one GitHub itself serves: does the URL answer 200?
curl -s -o /dev/null -w '%{http_code}\n' https://techbuzzz.github.io/Delibera/A 404 there, served by GitHub.com itself, means nothing has ever been deployed to this Pages site. Only the Source dropdown in Settings says whether it is configured to accept deploys.
pages.yml builds the Next.js site in site/ and publishes the static export in site/out
on every merge to main. The site is a view over this repository: the guides and release notes in
docs/, CHANGELOG.md, and the debate templates in DebateTemplate.cs. Nothing is copied into a
second location, so a page cannot advertise a version, a feature or a measurement the code does not
have.
The version is read from Delibera.Core.csproj at build time — the project publish-nuget.yml
actually packs. A tag can be pushed before the package reaches nuget.org and the changelog is edited
by hand, so neither is a trustworthy source. If the version cannot be resolved the build fails
rather than publishing a placeholder.
The Web UI is not deployed to Pages. It ships in the techbuzzz/delibera-webui container image
and is meant to run next to Delibera.Server, which provides the same-origin BFF route
/api/delibera/**. A static export has no Nitro server and therefore no BFF, so the UI would need
a separate CORS-enabled API origin — a deliberate owner decision, not something the Pages workflow
should configure silently.
Reproduce the Pages build locally#
The workflow runs npm ci, then next build with PAGES_BASE_PATH set to the base path
actions/configure-pages computes for this repo (/Delibera). Set the same variable locally or the
export will differ from production:
cd site
npm ci
PAGES_BASE_PATH=/Delibera npm run build # writes out/
npm run verify # every internal link, asset and anchor resolvesnpm run verify is not decoration. A green next build proves the pages compiled, not that they
link to each other, and three failure modes only appear on the live site: an internal href with no
emitted file behind it, a missing /Delibera prefix, and a #fragment pointing at a renamed
heading. verify fails the deploy on all three.
Changing a workflow#
Workflow files are only parsed by GitHub when they trigger — for release and
dockerhub-metadata, that is the tag push. A broken run: block therefore surfaces at the worst
possible moment.
Before pushing a workflow change, both of these are cheap and have each caught a real bug here:
# structure: tabs, block scalars, `on:` key
# bash syntax of every extracted run: body (Git Bash or any Linux)
bash -n <script>GitHub expressions (${{ ... }}) must be stripped before bash -n — they are invalid bash and
mask every real error behind noise.
When the workflow checks out a tag, syntax checking is not enough. Clone shallow at that tag
and run the extracted block against the clone — a dispatch resolves actions/checkout to the
tag, so any file authored on main afterwards is simply not there:
git clone --depth 1 --branch v<Version> file:///path/to/repo /tmp/sim
cd /tmp/sim && VERSION=<Version> bash ../step.shThis is how the release-notes step was caught: cp of a file that exists on main and not at the
tag is valid bash, passes bash -n, and fails on the only run that matters.
Source: docs/ReleaseProcess.md on GitHub — edit it there and this page updates on the next deploy.
