Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 29 additions & 7 deletions .github/workflows/dockerhub-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ jobs:
fi
echo "token=$token" >> "$GITHUB_OUTPUT"

# Decode the JWT and refuse to run with an expired/stale token: a 403 five minutes
# later is much harder to diagnose than a clear failure here. Hub JWTs live ~5 min.
claims="$(jq -rn --arg t "$token" '
($t | split(".")[1] | gsub("-";"+") | gsub("_";"/")
| . + ("=" * ((4 - length % 4) % 4))) as $p
| ($p | @base64d | fromjson)')"
exp="$(jq -r '.exp // 0' <<< "$claims")"
now="$(date +%s)"
if [ "$exp" -le "$now" ]; then
echo "::error::Docker Hub token is already expired (exp=$exp, now=$now) β€” re-run the job." >&2
exit 1
fi
echo " token valid for $(( exp - now )) more seconds"

- name: Push descriptions and categories
env:
JWT: ${{ steps.auth.outputs.token }}
Expand All @@ -66,14 +80,22 @@ jobs:
echo "── techbuzzz/$repo"

full="$(jq -Rs . < "dockerhub/$repo.md")"
jq -n --arg d "$short" --arg f "$full" \
desc_resp="$(jq -n --arg d "$short" --arg f "$full" \
'{description:$d, full_description:$f}' \
| curl -fsS -X PATCH \
-H "Authorization: JWT ${JWT}" \
| curl -sS -w $'\n%{http_code}' -X PATCH \
-H "Authorization: Bearer ${JWT}" \
-H 'Content-Type: application/json' \
--data-binary @- \
"https://hub.docker.com/v2/repositories/techbuzzz/$repo/" > /dev/null
echo " description + overview synced"
"https://hub.docker.com/v2/repositories/techbuzzz/$repo/")"
desc_code="${desc_resp##*$'\n'}"
if [ "$desc_code" = "200" ] || [ "$desc_code" = "201" ]; then
echo " description + overview synced"
else
echo "::error::description PATCH rejected for $repo (HTTP $desc_code)" >&2
echo " Docker Hub said: $(printf '%s' "${desc_resp%$'\n'*}" | head -c 600)" >&2
echo " If this is a 403, DOCKERHUB_TOKEN needs Read/Write/Delete scope on the techbuzzz namespace." >&2
exit 1
fi

# 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
Expand All @@ -82,7 +104,7 @@ jobs:
# 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 "Authorization: Bearer ${JWT}" \
-H 'Content-Type: application/json' \
--data-binary @- \
"https://hub.docker.com/v2/repositories/techbuzzz/$repo/")"
Expand All @@ -104,7 +126,7 @@ jobs:
run: |
set -euo pipefail
for repo in delibera-server delibera-webui; do
body="$(curl -fsS -H "Authorization: JWT ${JWT}" \
body="$(curl -fsS -H "Authorization: Bearer ${JWT}" \
"https://hub.docker.com/v2/repositories/techbuzzz/$repo/")"
desc="$(jq -r '.description // ""' <<< "$body")"
len="$(jq -r '(.full_description // "") | length' <<< "$body")"
Expand Down
178 changes: 39 additions & 139 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
name: Deploy Nuxt site to Pages
name: pages

# Publishes the real Delibera Web UI to GitHub Pages as a static build.
# Builds the Next.js site in site/ and publishes it to GitHub Pages on every merge to main.
#
# 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.
# The site is a *view* over files that already live in this repository: the guides and
# release notes in docs/, the changelog, the version in Delibera.Core.csproj, 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.
#
# 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.
# The Nuxt Web UI is NOT deployed here. It ships in the container image
# (techbuzzz/delibera-webui) 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 this workflow should configure silently.

on:
push:
Expand All @@ -23,160 +23,60 @@ permissions:
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.
# A new push supersedes an in-flight deploy; without this the second run would fail on the
# concurrency lock and leave the site one deploy behind.
concurrency:
group: pages
cancel-in-progress: false
cancel-in-progress: true

jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- 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
- 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 }}
node-version: 22
cache: npm
cache-dependency-path: site/package-lock.json

- name: Install dependencies
run: npm ci
working-directory: site

- 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
- name: Build
run: npm run build
working-directory: site
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
# configure-pages computes the prefix for this repo's Pages URL (/Delibera) and
# Next.js needs it at build time β€” it is baked into every emitted href.
PAGES_BASE_PATH: ${{ steps.pages.outputs.base_path }}

- 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
- name: Verify the export
run: node scripts/verify-export.mjs
working-directory: site
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
PAGES_BASE_PATH: ${{ steps.pages.outputs.base_path }}

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

deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
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
uses: actions/deploy-pages@v4
13 changes: 13 additions & 0 deletions .github/workflows/publish-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ jobs:
timeout-minutes: 45
permissions:
contents: read
# Same protection layer NuGet has, and the reason it is not enough to rely on the tag
# gate above.
#
# The `if:` already stops a merge to `main` from publishing β€” but it does nothing about a
# mistyped tag, or about anyone able to push a `v*` tag. That path overwrites a published
# `latest` on a registry consumers pull from, which is not undoable by re-tagging.
#
# The environment is where `Required reviewers` lives. With it enabled on
# `dockerhub-prod`, the run pauses at this job and a human approves the push; without it
# the block is only scoping for secrets, the URL below, and the deployment history.
environment:
name: dockerhub-prod
url: https://hub.docker.com/r/techbuzzz/delibera-server
steps:
- uses: actions/checkout@v4

Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ name: release
# knows which of those commits mattered.
#
# Kept separate from publish-nuget.yml so a release-page failure cannot fail a package publish.
#
# Tag-only since the merge-squash cleanup. It used to also run on every push to `main`, where it
# resolved the newest reachable v* tag and rewrote that release's body. That cost a full
# fetch-depth-0 clone, a PR-notes generation and a release API call on every merge, to produce
# a body that usually had not changed β€” and "usually" is the problem: a merge that happened to
# touch the header or the WhatsNew doc rewrote a *published* release's notes after the fact.
# The repair path is still one click: Actions -> release -> Run workflow.

on:
workflow_dispatch:
Expand All @@ -22,7 +29,6 @@ on:
type: string
push:
tags: [ 'v*' ]
branches: [ main ]

permissions:
contents: write
Expand Down
Loading
Loading