Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
77abb66
Release assets: Pages, GitHub Release, Docker Hub cards, README refresh
techbuzzz Oct 9, 2026
f516b57
Pages: deploy the real Nuxt app instead of a hand-written landing
techbuzzz Oct 9, 2026
c89df7e
Workflows: fix an invalid gh JSON field, make category failures actio…
techbuzzz Oct 9, 2026
7a7eeba
docs: add a release runbook
techbuzzz Oct 9, 2026
2a0f368
release.yml: GH_TOKEN was missing from the step that calls gh
techbuzzz Oct 9, 2026
312f34b
release.yml: workflow_dispatch tag input was never read
techbuzzz Oct 9, 2026
dcac624
Add opt-in CORS so the static Pages build can reach the API
techbuzzz Oct 9, 2026
069c1b2
CORS: gate UseCors on the container, not on configuration
techbuzzz Oct 9, 2026
06b57e9
ο»Ώdocs: say that a backfill needs its workflow on main first
techbuzzz Oct 9, 2026
56f1ce5
pages: align deploy action with the sample, unpin the cache to npm
techbuzzz Oct 9, 2026
e0724b2
release: do not die when the notes do not exist at the tag
techbuzzz Oct 9, 2026
99b512e
docs: record how to test a workflow that checks out a tag
techbuzzz Oct 9, 2026
2186b2d
webui: the static build was ignoring its configured API origin
techbuzzz Oct 10, 2026
3909c99
docs: how to reproduce the Pages build, which CI does not run
techbuzzz Oct 10, 2026
b20be01
docs: get the Pages setup order right, and stop trusting the 404
techbuzzz Oct 10, 2026
006d4b3
docs: neither API probe answers the Pages question, including has_pages
techbuzzz Oct 10, 2026
ebce771
release + dockerhub: let the merge do the work, not a human
techbuzzz Oct 10, 2026
c49c35c
pages: set the base path ourselves, and assert the export three ways
techbuzzz Oct 10, 2026
060a278
docs: verify base-path builds from PowerShell, and say why
techbuzzz Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/release-notes/header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Delibera {{VERSION}}

> **⚠️ Before you expose this anywhere:** the API is unauthenticated and unthrottled, and a debate
> spends real LLM credits. Published ports bind to `127.0.0.1`. Put an authenticating reverse proxy
> in front before putting this stack on a network you do not control.

## Get it

| | |
| --- | --- |
| NuGet | [`Delibera.Core`](https://www.nuget.org/packages/Delibera.Core) Β· [`Delibera.Server`](https://www.nuget.org/packages/Delibera.Server) Β· [`Delibera.Redis`](https://www.nuget.org/packages/Delibera.Redis) |
| Docker | [`techbuzzz/delibera-server`](https://hub.docker.com/r/techbuzzz/delibera-server) Β· [`techbuzzz/delibera-webui`](https://hub.docker.com/r/techbuzzz/delibera-webui) β€” `linux/amd64` + `linux/arm64` |
| Docs | [techbuzzz.github.io/Delibera](https://techbuzzz.github.io/Delibera/) |

```bash
export DELIBERA_VERSION={{VERSION}}
docker compose -f docker-compose.hub.yml up -d
```

```bash
dotnet add package Delibera.Core # {{VERSION}}
```

---
117 changes: 117 additions & 0 deletions .github/workflows/dockerhub-metadata.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: dockerhub-metadata

# Syncs the Docker Hub repository cards (short description, long overview, categories) from the
# markdown files in dockerhub/.
#
# Deliberately a SEPARATE workflow from publish-docker.yml: the images are already pushed by the
# time this runs, so a rejected description or an expired token must never turn a successful image
# publish into a red run. It re-runs on every v* tag and can be re-triggered by hand.
#
# The cards are empty by default when a repository is created through the API β€” `docker push` does
# not write metadata. That is why the text lives in the repository rather than only in someone's
# browser session.

on:
workflow_dispatch:
push:
tags: [ 'v*' ]
branches: [ main ]
paths:
- 'dockerhub/**'
- '.github/workflows/dockerhub-metadata.yml'

permissions:
contents: read

jobs:
sync:
name: Sync Docker Hub metadata
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Sign in to the Docker Hub API
id: auth
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
set -euo pipefail
if [ -z "${DOCKERHUB_USERNAME}" ] || [ -z "${DOCKERHUB_TOKEN}" ]; then
echo "DOCKERHUB_USERNAME / DOCKERHUB_TOKEN are not set on this repository." >&2
echo "Set them under Settings -> Secrets and variables -> Actions." >&2
exit 1
fi
# hub.docker.com issues a short-lived JWT for metadata edits; the push token itself is
# scoped to the registry and cannot change repository settings.
token="$(curl -fsS -X POST https://hub.docker.com/v2/users/login/ \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg u "$DOCKERHUB_USERNAME" --arg p "$DOCKERHUB_TOKEN" '{username:$u,password:$p}')" \
| jq -r '.token // empty')"
if [ -z "$token" ]; then
echo "Docker Hub did not return a token β€” the username or token is wrong." >&2
exit 1
fi
echo "token=$token" >> "$GITHUB_OUTPUT"

- name: Push descriptions and categories
env:
JWT: ${{ steps.auth.outputs.token }}
run: |
set -euo pipefail

# repo | short description (the card headline, 100 chars) | categories
while IFS='|' read -r repo short categories; do
[ -z "$repo" ] && continue
echo "── techbuzzz/$repo"

full="$(jq -Rs . < "dockerhub/$repo.md")"
jq -n --arg d "$short" --arg f "$full" \
'{description:$d, full_description:$f}' \
| curl -fsS -X PATCH \
-H "Authorization: JWT ${JWT}" \
-H 'Content-Type: application/json' \
--data-binary @- \
"https://hub.docker.com/v2/repositories/techbuzzz/$repo/" > /dev/null
echo " description + overview synced"

# Categories come from a fixed Docker Hub taxonomy keyed by slug, not by the
# display name shown in the UI, so the slugs below are the part most likely to
# need fixing. A rejected value must not cost us the description above, but it
# must not be silent either β€” Docker Hub echoes the accepted values in the error
# body, so print it instead of discarding it.
cats_resp="$(jq -nc --argjson c "$categories" '{categories:$c}' \
| curl -sS -w $'\n%{http_code}' -X PATCH \
-H "Authorization: JWT ${JWT}" \
-H 'Content-Type: application/json' \
--data-binary @- \
"https://hub.docker.com/v2/repositories/techbuzzz/$repo/")"
cats_code="${cats_resp##*$'\n'}"
if [ "$cats_code" = "200" ] || [ "$cats_code" = "201" ]; then
echo " categories synced: $categories"
else
echo " ::warning::categories rejected for $repo (HTTP $cats_code): $categories"
echo " Docker Hub said: $(printf '%s' "${cats_resp%$'\n'*}" | head -c 600)"
fi
done <<'REPOS'
delibera-server|ASP.NET Core host for multi-model AI council debates: REST, SSE streaming, MCP|["web","messaging","ai-machine-learning","network"]
delibera-webui|Nuxt 4 browser front end for Delibera AI council debates|["web","ai-machine-learning"]
REPOS

- name: Verify the cards are populated
env:
JWT: ${{ steps.auth.outputs.token }}
run: |
set -euo pipefail
for repo in delibera-server delibera-webui; do
body="$(curl -fsS -H "Authorization: JWT ${JWT}" \
"https://hub.docker.com/v2/repositories/techbuzzz/$repo/")"
desc="$(jq -r '.description // ""' <<< "$body")"
len="$(jq -r '(.full_description // "") | length' <<< "$body")"
cats="$(jq -r '(.categories // []) | join(", ")' <<< "$body")"
printf '%-18s %-72s overview=%s chars categories=[%s]\n' "$repo" "$desc" "$len" "$cats"
if [ "$len" -lt 200 ]; then
echo "::error::overview for $repo is only $len chars β€” did the PATCH land?" >&2
exit 1
fi
done
182 changes: 182 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
name: Deploy Nuxt site to Pages

# Publishes the real Delibera Web UI to GitHub Pages as a static build.
#
# This is a STATIC export of the same app that ships in the container. The important
# consequence, which is not obvious from the workflow: `nuxt generate` produces no Nitro server,
# so the BFF route `/api/delibera/**` does not exist in the deployed site. The browser then needs
# an absolute API origin, supplied at build time by NUXT_PUBLIC_DELIBERA_API_BASE and resolved in
# app/composables/useDebates.ts. That server must allow this origin via CORS β€” Delibera.Server
# registers no CORS policy by default, because in its normal container deployment the browser
# only ever talks to the same-origin proxy.
#
# Without that env var the site still deploys and still renders, but every request 404s. That is
# a legitimate state for a UI preview; it is not a working install.

on:
push:
branches: [ main ]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# Allow only one concurrent deployment, skipping runs queued between the in-progress and the
# latest queued. Do NOT cancel in-progress runs β€” let a production deploy finish.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Detect package manager
id: detect-package-manager
run: |
set -euo pipefail
if [ -f "${{ github.workspace }}/src/Delibera.WebUI/pnpm-lock.yaml" ]; then
echo "manager=pnpm" >> "$GITHUB_OUTPUT"
echo "lockfile=pnpm-lock.yaml" >> "$GITHUB_OUTPUT"
echo "command=install" >> "$GITHUB_OUTPUT"
elif [ -f "${{ github.workspace }}/src/Delibera.WebUI/yarn.lock" ]; then
echo "manager=yarn" >> "$GITHUB_OUTPUT"
echo "lockfile=yarn.lock" >> "$GITHUB_OUTPUT"
echo "command=install" >> "$GITHUB_OUTPUT"
elif [ -f "${{ github.workspace }}/src/Delibera.WebUI/package-lock.json" ]; then
echo "manager=npm" >> "$GITHUB_OUTPUT"
echo "lockfile=package-lock.json" >> "$GITHUB_OUTPUT"
echo "command=ci" >> "$GITHUB_OUTPUT"
else
echo "::error::no lockfile found in src/Delibera.WebUI" >&2
exit 1
fi

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: ${{ steps.detect-package-manager.outputs.manager }}
# Must follow the manager the detect step chose. Hardcoding the npm lockfile works
# today because package-lock.json is the only one in the tree β€” but if this repo ever
# adopts pnpm, a pnpm cache keyed on a file that does not exist never invalidates, and
# a stale node_modules is restored forever under a green build.
cache-dependency-path: src/Delibera.WebUI/${{ steps.detect-package-manager.outputs.lockfile }}

- name: Setup Pages
id: pages
uses: actions/configure-pages@v5
# No `static_site_generator: nuxt` here, and that is deliberate. That input makes the
# action REWRITE the generator config file instead of exporting anything, and it only
# understands .js/.cjs/.mjs β€” our nuxt.config.ts is TypeScript, so it emits a warning and
# injects nothing. The keys it would inject (`router.base`, `target`) are Nuxt 3 names
# that Nuxt 4 has moved or removed anyway. The base path is taken from this step's
# `base_path` output and passed to the build as NUXT_APP_BASE_URL instead.

- name: Install dependencies
working-directory: src/Delibera.WebUI
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}

- name: Typecheck
working-directory: src/Delibera.WebUI
run: ${{ steps.detect-package-manager.outputs.manager }} run typecheck

- name: Static HTML export with Nuxt
working-directory: src/Delibera.WebUI
env:
# Empty by default: the site deploys as a UI preview whose API calls 404.
# Set it as a repository variable to point the static build at a live server.
NUXT_PUBLIC_DELIBERA_API_BASE: ${{ vars.NUXT_PUBLIC_DELIBERA_API_BASE }}
# The site's own base path (e.g. /Delibera). Nuxt reads NUXT_APP_BASE_URL into
# app.baseURL, so the export keeps its documents at the artifact root while asset
# URLs are prefixed with the base β€” which is exactly what Pages expects.
NUXT_APP_BASE_URL: ${{ steps.pages.outputs.base_path }}
run: ${{ steps.detect-package-manager.outputs.manager }} run generate

- name: Fail if the export produced a real site
run: |
set -euo pipefail
# `.output/public`, NOT `dist` β€” Nuxt 4 moved the static output there. GitHub's own
# sample workflow still uploads ./dist because it predates Nuxt 4, and copying it
# verbatim uploads nothing at all.
#
# The second assertion matters more: without an explicit nitro.prerender.routes list
# the export contains only 200.html + 404.html and NO document for the real pages,
# which deploys as a blank site that still reports a green build.
out=src/Delibera.WebUI/.output/public
if [ ! -f "$out/index.html" ]; then
echo "::error::$out/index.html is missing β€” the site would deploy blank" >&2
ls -la "$out" || true
exit 1
fi
echo "prerendered: $(cd "$out" && find . -name '*.html' | sort | tr '\n' ' ')"
echo "total files: $(find "$out" -type f | wc -l)"

- name: Fail if the configured API base did not reach the bundle
env:
API_BASE: ${{ vars.NUXT_PUBLIC_DELIBERA_API_BASE }}
run: |
set -euo pipefail
# Nuxt substitutes `import.meta.env.NUXT_PUBLIC_*` only for keys declared under
# runtimeConfig.public. Undeclare or rename `deliberaApiBase` and nothing fails: the
# build goes green and the deployed site keeps calling its own origin, which on
# Pages is a 404. A green pipeline is exactly the signal that cannot catch this,
# so check the artefact instead of the exit code.
if [ -z "$API_BASE" ]; then
echo "no API base configured β€” deploying a UI preview whose requests 404"
exit 0
fi
out=src/Delibera.WebUI/.output/public
if ! grep -rqF "$API_BASE" "$out"; then
echo "::error::$API_BASE is configured but appears nowhere in the export" >&2
echo "Check that deliberaApiBase is still declared under runtimeConfig.public." >&2
exit 1
fi
echo "API base $API_BASE is baked into the export"

- name: Fail if the Pages base path did not reach the export
run: |
set -euo pipefail
# configure-pages injects the site's base path into nuxt.config before the build, so
# a project site (techbuzzz.github.io/Delibera) emits /Delibera/_nuxt/... asset URLs
# while the files themselves stay at the root of the artifact. If the injection ever
# stops happening, the HTML keeps root-relative URLs: the deploy succeeds, and the
# site renders unstyled over a client bundle that 404s.
base="${{ steps.pages.outputs.base_path }}"
if [ -z "$base" ]; then
echo "no base path reported β€” an owner/org Pages site, nothing to check"
exit 0
fi
html=src/Delibera.WebUI/.output/public/index.html
if grep -q "href=\"$base/_nuxt/" "$html"; then
echo "base path $base is applied to the export's asset URLs"
exit 0
fi
echo "::error::expected asset URLs under $base/_nuxt/ in $html" >&2
grep -o 'href="[^"]*_nuxt/[^"]*"' "$html" | head -3 >&2 || true
exit 1

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: src/Delibera.WebUI/.output/public

deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
# v5 is current (5.0.1, Sep 2026) and is what GitHub's own sample pairs with
# upload-pages-artifact@v3. It stays on upload-artifact@v4 internals, so v3 artifacts
# are still the supported input β€” no reason to move the upload major and re-test a
# tarball format nobody here can reproduce locally.
uses: actions/deploy-pages@v5
42 changes: 35 additions & 7 deletions .github/workflows/publish-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ jobs:
needs: [ build, webui-tests ]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
# Two multi-arch builds plus the smoke checks finish in ~15 min. 45 leaves room for a
# slow runner without letting a stuck step burn the default 6 hours.
timeout-minutes: 45
permissions:
contents: read
steps:
Expand Down Expand Up @@ -161,12 +164,37 @@ jobs:
- name: Runtime smoke (amd64)
run: |
set -euo pipefail
# Runs the actual entrypoint binary from each published image. The WebUI
# container would exit immediately, so --version is the cheapest proof that
# the runtime stage has a working interpreter and the right file layout.
docker run --rm --platform linux/amd64 \
# Proves the runtime stage has a working interpreter and the right file layout.
#
# --entrypoint is REQUIRED, not decorative: both images declare ENTRYPOINT
# (dotnet Delibera.Server.dll / node .output/server/index.mjs), and `docker run
# IMAGE <args>` APPENDS <args> to an ENTRYPOINT instead of replacing it. Without
# the override this runs `dotnet Delibera.Server.dll dotnet --version` β€” which
# boots the web host and never exits, so the step hangs silently instead of
# failing loudly.
#
# --list-runtimes, NOT --version: the server image ships the ASP.NET Core
# RUNTIME, not the SDK, so `dotnet --version` exits 155 with "No .NET SDKs were
# found" and would fail a perfectly good image. Listing runtimes proves the
# interpreter works AND asserts the AspNetCore runtime the host actually needs.
#
# `timeout` bounds it so a future regression costs minutes, not the job timeout.
timeout --kill-after=10 180 docker run --rm --platform linux/amd64 \
--entrypoint dotnet \
"${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.SERVER_IMG }}:${{ steps.meta.outputs.full }}" \
dotnet --version
docker run --rm --platform linux/amd64 \
--list-runtimes | tee /tmp/runtimes
grep -q 'Microsoft.AspNetCore.App' /tmp/runtimes
timeout --kill-after=10 180 docker run --rm --platform linux/amd64 \
--entrypoint node \
"${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.WEBUI_IMG }}:${{ steps.meta.outputs.full }}" \
node --version
--version
# The interpreter can be fine while the published payload is missing β€” a build
# that silently drops /app would sail past the two checks above.
timeout --kill-after=10 180 docker run --rm --platform linux/amd64 \
--entrypoint ls \
"${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.SERVER_IMG }}:${{ steps.meta.outputs.full }}" \
-la /app/Delibera.Server.dll
timeout --kill-after=10 180 docker run --rm --platform linux/amd64 \
--entrypoint ls \
"${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.WEBUI_IMG }}:${{ steps.meta.outputs.full }}" \
-la /app/.output/server/index.mjs
Loading
Loading