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=*.csproj

Delibera.Core, Delibera.Server, Delibera.Redis, Delibera.Grpc, Delibera.Grpc.Client, and src/Delibera.WebUI/package.json.

Then release notes:

  • PackageReleaseNotes in Delibera.Core.csproj and Delibera.Server.csproj
  • CHANGELOG.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.yml packs from the csproj. If you tag v10.5.3 while the csproj still says 10.5.2, the workflow packs 10.5.2.nupkg and dotnet nuget push --skip-duplicate discards it without a word, because 10.5.2 already exists. The job goes green and nothing is published.

release.yml now 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 one

Cut 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-server and techbuzzz/delibera-webui. docker push into a repository that does not exist fails, and the workflow cannot create it.
  • DOCKERHUB_USERNAME and DOCKERHUB_TOKEN must 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 here

The 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 Found

That 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 resolves

npm 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.sh

This 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.