Skip to content

v10.5.2c - Update CI and pages + docker hub delivery - #27

Merged
techbuzzz merged 19 commits into
mainfrom
feat/v10.5.2c
Oct 10, 2026
Merged

techbuzzz merged 19 commits into
mainfrom
feat/v10.5.2c

Conversation

@techbuzzz

@techbuzzz techbuzzz commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Pull Request

Description

Related Issues

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that changes existing behaviour)
  • 📚 Documentation update
  • 🧹 Refactor / chore

Checklist

  • My code follows the project's coding standards (see CONTRIBUTING.md)
  • The solution builds with 0 errors and 0 warnings (dotnet build --configuration Release)
  • I have added/updated XML documentation for public APIs
  • I have updated relevant documentation (README.md, QuickStart.md)
  • I have tested my changes (examples run as expected)

Additional Notes

Publishes the three things that were missing around the 10.5.2 tag.

GitHub Release (release.yml). A tag existed but no release page, so nothing
announced the release. The body is header.md + docs/WhatsNew-v<version>.md +
GitHub's auto-generated PR notes. It refuses to publish when the tag and
<Version> disagree: publish-nuget packs from the csproj, not the tag, so a
mismatch ships a nupkg whose metadata lies about its own version. That is
exactly the silent-skip failure 10.5.2 hit.

GitHub Pages (pages.yml + site/index.html). Hand-written static landing: header,
versions, quick links to the key docs. No docs toolchain -- a landing page does
not justify adding Python to a .NET repo. The version is injected from the same
csproj that drives `dotnet pack`, so the page cannot advertise an unpublished
version, and the build fails loudly on a leftover placeholder.

Docker Hub cards (dockerhub-metadata.yml + dockerhub/*.md). Both repos showed
empty cards because `docker push` writes no metadata. The overviews now live in
the repo and are PATCHed onto both repositories on every v* tag using the
existing DOCKERHUB_TOKEN. Deliberately a separate workflow: a metadata failure
must never turn a successful image publish into a red run. Description and
categories go in separate calls so a taxonomy rejection costs the categories,
not the overview.

README (EN + RU). Adds Docker Hub, pulls, Release and Pages badges plus a second
CI gate. Installation was a single `dotnet add package Delibera.Core` line while
three packages and two images are published; it is now a three-way choice, with
the localhost-binding warning placed where users will actually hit it.

Also fixes the runtime-smoke step in publish-docker.yml, left over from the
earlier session: both images declare ENTRYPOINT, so `docker run IMAGE
dotnet --version` appended to it and booted the web host instead of printing a
version, hanging the job for 20+ minutes. It also asked `dotnet --version`,
which needs an SDK that a runtime image does not carry -- the fix would have
failed a perfectly good image. Now uses --entrypoint with --list-runtimes,
asserts the AspNetCore runtime is present, checks the published payload layout,
and is bounded by `timeout` plus a job-level timeout-minutes that did not exist
before.

Verified locally against the real published 10.5.2 images: `--list-runtimes`
prints Microsoft.AspNetCore.App 10.0.12 (exit 0), `node --version` prints
v22.23.3 (exit 0), and /app/Delibera.Server.dll and
/app/.output/server/index.mjs are both present. Site and release-body assembly
were simulated locally: zero unsubstituted placeholders in either output.
Replaces the static landing page from the previous commit with what was actually asked for: the
Web UI itself, statically exported to GitHub Pages via `nuxt generate`.

Two real defects surfaced by running the export rather than reading the docs, both of which
would have shipped a broken site behind a green build:

- Output location. Nuxt 4 emits to `.output/public`, not `dist`. GitHub's own sample workflow
  still uploads ./dist because it predates Nuxt 4, and copying it verbatim uploads nothing.
- No prerendered routes. With no explicit list, `nuxt generate` emitted only the 200.html and
  404.html SPA fallbacks and nothing for the real pages — a deploy that is a blank site. The
  workflow now asserts index.html exists instead of trusting the exit code.

useDebates resolves its API base from NUXT_PUBLIC_DELIBERA_API_BASE instead of a hardcoded
`/api/delibera`. Empty by default, so the container path is byte-for-byte unchanged: same-origin
BFF, and the browser still never touches the API origin. The variable exists because a static
export has no Nitro server and therefore no BFF route. It is read from import.meta.env rather
than useRuntimeConfig() because useDebates() is also called from useDebateStream's start() and
poll timer, which run after setup has unwound and Vue's current instance is gone.

Not changed, deliberately: Delibera.Server still registers no CORS policy. Widening CORS on an
unauthenticated API is an owner decision, so the static build deploys with API calls 404ing
until that is made deliberately and an origin is set. That state is documented in the workflow,
in nuxt.config.ts and on the Docker Hub card.

Also adds the missing `generate` script to package.json — the workflow calls it and the manifest
did not define it.

Verified locally: nuxt typecheck exit 0, vitest 34/34 across 4 files, nuxt generate exit 0
producing index.html (6691 bytes) and debates/new/index.html.
…nable

Both found by validating the workflows rather than reading them.

release.yml asked `gh release view --json url,tagName,isLatest`. `isLatest` is not
one of the fields `gh release view` exposes — it belongs to `gh release list`
(https://cli.github.com/manual/gh_release_view). The command fails with
"Unknown JSON field", and it fails AFTER the release has been created, so a
successful publish would have been reported as a red run. Now queries
url,tagName,isDraft,isPrerelease.

dockerhub-metadata.yml discarded the response from the categories PATCH into
/dev/null, so a rejected slug produced a warning that said nothing about what to
change. Docker Hub echoes the accepted values in the error body; it is now
captured and printed. The description write is unaffected either way — that part
was already a separate, fatal call.

Categories are keyed by slug, not by the display name shown in the Docker Hub UI
("Machine learning & AI" and friends), so the slugs are the part most likely to
need editing. They are inline and documented for that reason.

Validation: all five workflow files parse under YamlDotNet with no tabs, and all
25 extracted `run:` bodies pass `bash -n` under Git Bash 5.2.
Captures the order that actually matters, written after this cycle hit every
silent failure mode it describes.

The two worth stating plainly, because neither raises an error:

- Tagging does not set the package version. publish-nuget packs from the csproj,
  so a tag ahead of the csproj packs the previous version and
  --skip-duplicate discards it without a word. The job is green and nothing
  ships. release.yml now fails loudly on that mismatch, but only for the release
  page, and only after the packages are gone.

- Docker Hub cards stay empty unless the two repositories exist and
  DOCKERHUB_USERNAME/DOCKERHUB_TOKEN are set. Missing secrets fail in zero
  seconds, which is how you tell them apart from a wrong token.

Also records the pre-tag check that actually catches the first one -- pack the
package and confirm the filename -- and the Pages source toggle, which the API
cannot create.
@techbuzzz techbuzzz self-assigned this Oct 9, 2026
`Generate PR notes` runs `gh api`, but `GH_TOKEN` was set only on the final
step. A GitHub-hosted runner has no ambient credentials, so the unauthenticated
call failed, `set -e` aborted the job, and the release was never created — a
100% failure rate on the one workflow whose whole purpose is publishing the
release. GH_TOKEN now sits at job level, covering every step.

Also makes the generate-notes call explicit JSON via `--input -` instead of
`-f`. `gh api -f` sends a form-encoded body; this endpoint wants
application/json, and the mismatch returns a 415 that reads like a permissions
problem rather than a content-type one.

And makes note generation non-fatal. The PR list is appended after the
WhatsNew document, so losing it must not cost the release.

Revalidated: all five workflows parse under YamlDotNet, all 25 extracted
`run:` bodies pass `bash -n`.
The `tag` input was declared under `workflow_dispatch` but never mapped into the
environment, so the resolve step fell through to $GITHUB_REF. For a dispatch that
is the BRANCH, giving tag="main", which the `v*` guard rejects — the run died on
"Refusing to release a non-version tag: main".

That broke exactly the backfill path: creating the release page for the existing
v10.5.2 tag without re-cutting it. The workflow looked correct and the failure
mode was a message about a branch.

Also pins the checkout to the tag being released, so the version cross-check
reads the csproj at that tag rather than whatever is on the branch.

Verified by running the resolution logic against four scenarios:
  dispatch with tag input      -> OK, releasing v10.5.2 (Delibera.Core 10.5.2)
  tag push                     -> OK, releasing v10.5.2
  dispatch with blank input    -> refused: refs/heads/main
  dispatch naming v10.5.3      -> MISMATCH caught: tag says 10.5.3, csproj 10.5.2

Revalidated: all five workflows parse under YamlDotNet, all 25 `run:` bodies pass
`bash -n`.
The GitHub Pages build of the Web UI is a static export: no Nitro server, so
the same-origin /api/delibera/** BFF route does not exist there. The site
deployed and rendered, but every request 404ed — a "Pages" that shows nothing
is not Pages support. This closes that gap.

Delibera:Server:Cors:AllowedOrigins registers a named policy, and Program.cs
calls UseCors only when that list is non-empty.

Off by default, and the default is the load-bearing part. The Web UI in the
container talks to the API through its own BFF and never needs this. The
absent Access-Control-Allow-Origin is also what stops an arbitrary page from
reading a response: without CORS a cross-origin fetch still fires the request,
it just cannot see the answer. Wiring this on by default would have added a
browser-side CSRF vector to an API that is unauthenticated, unthrottled, and
spends real credits per debate.

Origins are matched exactly. There is deliberately no SetIsOriginAllowed(_ =>
true) path. AllowCredentials together with "*" is rejected at startup with a
message naming the config key, instead of by the framework with one that names
nothing.

Two naming details worth keeping: the option type is CorsGateOptions rather
than CorsOptions so it cannot shadow
Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions in any file importing both
namespaces, and a whitespace-only origin counts as unconfigured — otherwise an
unset compose variable would register a policy matching the literal string " "
while IsCorsEnabled reported true, and UseCors would run against a policy that
can never match.

8 new tests, including that the default path registers no CORS service at all
rather than an empty policy. Two of them failed first run because the assertion
resolving IOptions<CorsOptions> assumed AddCors had been called — the failure
was the point, since it shows nothing is registered, and the helper now reports
that instead.

Documented in both READMEs, appsettings.json, deploy/docker-compose.hub.yml
(DELIBERA_CORS_ORIGIN) and a new CHANGELOG [Unreleased] section.

Verified: build 0 errors / 0 warnings under -warnaserror; 628/628 tests
(493 Core + 125 Server + 10 gRPC).
The HTTP-level tests found a startup crash in the CORS wiring added earlier.

Program.cs decided whether to call UseCors by asking the configuration whether
origins were configured, while the policy itself was registered inside
AddDeliberaServer from that same configuration. Two reads of the same section
disagreed, and the host died before serving anything:

  System.InvalidOperationException : Unable to resolve service for type
  'Microsoft.AspNetCore.Cors.Infrastructure.ICorsService' while attempting to
  activate 'Microsoft.AspNetCore.Cors.Infrastructure.CorsMiddleware'

UseCors against a policy that was never registered is not a degraded mode, it
is a dead process. The gate now asks whether AddCors actually ran, by testing
for ICorsService — which is registered by AddCors and by nothing else, so the
answer cannot disagree with what is actually in the container. The
configuration-reading helper is gone rather than left as a second source of
truth waiting to drift.

Adds CorsPolicyBehaviourTests: five tests driving the real pipeline through
WebApplicationFactory. The DI tests said what was registered; these say what a
browser would see. Default host emits no Access-Control-Allow-Origin for a
cross-origin call, a configured origin gets its own origin echoed back rather
than a wildcard, an unlisted origin gets nothing, the preflight is answered
with Allow-Methods, and X-Correlation-Id is exposed so the id that finds a log
entry stays reachable from JavaScript.

Worth noting for anyone extending these: this host is built by
CreateSlimBuilder, which captures its configuration before
WebApplicationFactory's ConfigureAppConfiguration delegates run. Injecting
settings with ConfigureAppConfiguration leaves AddDeliberaServer blind to them
and the gate silently stays off. UseSetting reaches builder.Configuration.

Verified: build 0 errors / 0 warnings under -warnaserror; 633/633 tests
(493 Core + 130 Server + 10 gRPC).
The backfill sections told you to dispatch release and dockerhub-metadata
for an already-pushed tag. That advice cannot be followed while those
workflows only exist on a feature branch: workflow_dispatch is resolved
against the default branch, so the workflow has no Actions entry and the
button is not there at all.

This is not hypothetical. Right now the tags are on Docker Hub with real
pull counts, every pipeline is green, and both overviews are empty,
because nothing in the tag-push path writes metadata on its own.

Adds the one command that settles it before promising a backfill.

Verified: curl .../actions/workflows lists only publish-docker and
publish-nuget, which is the shape of a branch that has not merged yet.
Two things found by checking the release notes of the actions this
workflow depends on, not by reading the file.

deploy-pages was pinned at v4 while v5 has been out since September, and
v5 is the version GitHub's own Pages sample pairs with the v3 artifact
uploader. v5.0.1 only adds backoff and jitter to the deployment poll and
still reads upload-artifact@v4 containers, so v3 artifacts remain a
supported input and there is no reason to move the upload major as well
and re-test a tarball format that cannot be reproduced here.

setup-node keyed its cache on package-lock.json while the step above it
detected the package manager. Those agree today because the WebUI has
exactly one lockfile, so this is latent rather than broken. It is the
wrong kind of agreement though: add pnpm tomorrow and a pnpm cache keyed
on a file that does not exist never invalidates, restoring a stale
node_modules under a green build with no signal that anything is wrong.
The detector now emits the lockfile it actually found.

Verified: all five workflows parse; the five pages run-blocks pass
bash -n; the detect step was executed against the real tree and emits
manager=npm, lockfile=package-lock.json, command=ci.

Release notes checked: configure-pages v6.0.0 (node 24, no functional
change), upload-pages-artifact v5.0.0 (upload-artifact 7 plus
include-hidden-files), deploy-pages v5.0.1.
A dispatch checks out the TAG, not main. The release header is authored
on main, so it is absent from every tag cut before it landed, and the
assembly step copied it unconditionally:

  cp .github/release-notes/header.md "$body"

under set -euo pipefail. That is a hard stop at the last step before the
release is written, and the error it prints is "cp: cannot stat" — which
points at the filesystem, not at the fact that the backfill this dispatch
exists for can never work as written.

Confirmed by running it, not by reading it. A shallow clone at v10.5.2
reproduces the missing file exactly; git cat-file says header.md is
absent from that tag while docs/WhatsNew-v10.5.2.md is present, and the
version cross-check passes (Delibera.Core.csproj says 10.5.2 at the tag),
so the job would sail through every guard and then fail on the copy.

fetch_note now prefers the checked-out ref, falls back to origin/<default>
via a depth-1 fetch, and warns instead of failing when a document is
missing everywhere. A release page without the header still beats no
release page.

The fallback is only useful once the header reaches main, which is the
same reason this workflow cannot be dispatched at all before PR #27
merges — worth knowing before you spend an hour on a red run.

Verified by execution against a simulated post-merge remote: a clone at
v10.5.2 with main carrying the header produces an 11063-byte body, opens
with "# Delibera 10.5.2", zero unsubstituted {{VERSION}}, exit 0.
bash -n proves a run: block parses. It cannot prove the block finds the
files it reads, because on a dispatch the checkout is the TAG: anything
authored on main after that tag was cut is simply absent, and the block
still parses.

Clone shallow at the tag and execute the step against the clone. That is
what turned "cp of a missing file" from a claim into a reproduction.
NUXT_PUBLIC_DELIBERA_API_BASE never reached the bundle, so a GitHub
Pages build configured with it still called its own origin and every
request 404'd. Nothing would have said so: pages.yml was green from
start to finish.

The reason is that Nuxt substitutes an import.meta.env.NUXT_PUBLIC_*
read only when the name corresponds to a key of runtimeConfig.public.
deliberaApiBase was never declared, so the read was left alone and the
variable silently discarded - no warning, no non-zero exit. Measured
rather than assumed: a generate with the variable set produced zero
occurrences of it anywhere in .output/public.

Declaring the key fixes it and costs nothing in the container, where the
empty default keeps the client on the same-origin BFF exactly as before.
Verified in both directions - with the variable the origin appears in 4
places in the export, without it in none.

pages.yml now checks the artefact instead of trusting the exit code: if
an origin is configured and appears nowhere in the export, the deploy
fails naming the config key. Preview mode (nothing configured) is a
deliberate no-op. Tested all three ways: present passes, absent fails
with the key named, unset prints the preview notice and exits 0.

The reading of import.meta.env stays. useDebateStream calls this from a
start callback and a poll timer, long after setup has unwound, where a
useRuntimeConfig() lookup would depend on a Vue instance that is gone -
a note worth keeping next to the key it depends on.

Web UI tests 34/34 (npm run build first: the SSE suite loads
.output/server/index.mjs, which `nuxt generate` does not produce).
pages.yml runs `nuxt generate` and two assertions about the export.
None of that happens on a pull request, so a change that breaks the
static build cannot fail CI - it can only fail after the merge that
turns it into a deploy.

Writes the reproduction as plain shell rather than a script: the repo
has no shell scripts and no place to put one, and these are commands a
maintainer runs by hand anyway.

The last line is the one that earns the section. `grep -rqF` on the
export, not the exit code of the build - an undeclared NUXT_PUBLIC_*
name is dropped silently, so the build reports success while the
deployed site calls its own origin. That is exactly how the API-base
bug survived a green pipeline.

Also records why `npm run build` is needed before `npm test`: the SSE
suite loads .output/server/index.mjs, which a static export does not
produce, and reports a failed suite that reads like a broken test.

Verified by running the section top to bottom: 6 routes prerendered,
index.html and debates/new/index.html present, unset build 0 hits,
configured build bakes the origin in.
Two corrections, both found by checking the claim instead of
repeating it.

The order. pages.yml runs on every push to main, so merging it into a
repository with no Pages site puts a red run on main immediately:
"Get Pages site failed ... Error: Not Found". The site is not created by
the workflow and `enablement: true` cannot create it either — that is a
repository administration operation, and the automatic GITHUB_TOKEN can
never administer its own repository no matter what `permissions:` says.
So the Source dropdown is clicked BEFORE the merge, not after.

The 404. The runbook said "the API returns 404 until this is set", and
earlier notes here repeated it. It does not distinguish the two cases:
an unauthenticated GET answers 404 whether or not Pages is configured,
while the same repository at the same moment reports "has_pages": true
in its metadata. The earlier reading of that 404 as "Pages is off" was
wrong, and it is recorded here so nobody repeats it.

What actually settles it: the Source dropdown, and has_pages. What does
not: an anonymous /pages call.
Last correction on this, because the previous one was still wrong.
has_pages: true is not evidence that a Pages site exists. The account
has never published one: https://techbuzzz.github.io/Delibera/ and the
account root both answer 404 from GitHub.com's own servers, while the
repository metadata reads has_pages: true at the same moment.

Two probes that look authoritative and are not:
  GET /repos/OWNER/REPO/pages unauthenticated -> 404, ambiguous
  has_pages: true                              -> capability, not a site

Replaces both with the check GitHub itself serves: does the URL answer
200? A 404 there, straight from GitHub.com, is conclusive. Only the
Source dropdown says whether the site will accept deploys.
The goal asked whether publishing can ride the PR. Pages already does:
pages.yml runs on every push to main. The two workflows that still
required a manual dispatch are now on the same footing.

release.yml also runs on push to main and refreshes the page for the
newest v* tag reachable from HEAD (checkout is now depth 0, because the
default shallow clone has no tags and git describe finds nothing).
dockerhub-metadata.yml runs on push to main, filtered to the paths that
can change a card.

The part that needed care is what a mismatch means. Two triggers, two
meanings, deliberately not unified:

  v* tag pushed / dispatch naming a tag -> mismatch FAILS. That tag
    exists to publish a specific version; the csproj disagreeing with it
    is the exact bug that once shipped a nupkg whose metadata lied about
    itself.
  merge to main -> mismatch exits 0 with a notice. A version bump with
    no tag yet is an ordinary intermediate state, and failing there would
    put a red run on main for something nobody did wrong.

Verified by running the extracted step against a real clone, all six
modes: merge resolves v10.5.2 and releases it (exit 0); tag push and
dispatch both go explicit; a dispatch naming an unmerged tag fails loudly;
a main with the version bumped to 10.5.3 exits 0, writes no outputs and
says the release will happen at v10.5.3; the same tree on a tag push
still fails. The safety net from the original bug survives.

NuGet is untouched and stays that way: publish-nuget triggers on tag
pushes only, so merging a PR cannot republish anything.

Runbook updated to say dispatch is now a repair tool, not a step.
configure-pages with static_site_generator: nuxt cannot configure this
project. It rewrites the generator config file rather than exporting
anything, supports only .js/.cjs/.mjs, and our nuxt.config.ts is
TypeScript - so it warns and injects nothing. The keys it would inject
(router.base, target) are Nuxt 3 names that Nuxt 4 has moved or removed.

So the input is dropped and the base path comes from the step's own
base_path output, passed to the build as NUXT_APP_BASE_URL. Nuxt reads
that into app.baseURL: documents stay at the artifact root, asset URLs
gain the prefix, which is what Pages expects.

A third gate joins the two existing ones. A Pages project site is served
from /<repo>, so an export whose asset URLs are root-relative deploys
successfully and renders unstyled over a client bundle that 404s - no
failed step, no visible error, just a broken site. The gate reads the
base back out of the emitted HTML.

Recorded here because I got it wrong first: I "found" a serious bug
where setting a base produced redirect stubs instead of documents, and
started rewriting nuxt.config.ts around it. The bug was in my test
harness. Git Bash rewrites an environment value that looks like a POSIX
path when it exports it to a child process: NUXT_APP_BASE_URL=/Delibera
reached the build as "C:/Program Files/Git/Delibera". A base that
malformed produces exactly the stub layout I mistook for a Nuxt defect.

Re-measured from PowerShell, where the value passes through untouched,
on a pristine nuxt.config.ts: documents at the artifact root, payload
baseURL "/Delibera", assets under /Delibera/_nuxt/. nuxt.config.ts is
therefore unchanged by this commit.

Verified with the exact environment CI sets - both variables together:
export gate PASS, base gate PASS (/Delibera/_nuxt/StatusBadge.*.css),
API-base gate PASS, zero extension-less stub files.
Git Bash exports an environment value that looks like a POSIX path as a
Windows path: NUXT_APP_BASE_URL=/Delibera reaches the build as
"C:/Program Files/Git/Delibera".

A malformed base does not error. It produces .output/public/index and
debates/new containing "Redirecting..." instead of documents - no
index.html anywhere, green build, and every route 404s on deploy. That
is indistinguishable from a Nuxt bug by inspection, and I acted on it for
a cycle before checking the variable itself.

PowerShell passes the value through untouched; that is where the real
behaviour was measured.
@techbuzzz
techbuzzz merged commit f6c1892 into main Oct 10, 2026
5 checks passed
@techbuzzz
techbuzzz deleted the feat/v10.5.2c branch October 10, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant