diff --git a/.github/release-notes/header.md b/.github/release-notes/header.md new file mode 100644 index 0000000..56a1fee --- /dev/null +++ b/.github/release-notes/header.md @@ -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}} +``` + +--- \ No newline at end of file diff --git a/.github/workflows/dockerhub-metadata.yml b/.github/workflows/dockerhub-metadata.yml new file mode 100644 index 0000000..b315a2f --- /dev/null +++ b/.github/workflows/dockerhub-metadata.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..dbcf5ea --- /dev/null +++ b/.github/workflows/pages.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index e830f17..c6c0d1f 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -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: @@ -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 ` APPENDS 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 \ No newline at end of file + --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 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1dc786b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,205 @@ +name: release + +# Publishes a GitHub Release for every v* tag. +# +# The body is assembled from three parts, in this order: +# 1. .github/release-notes/header.md — orientation, registry links, the security warning +# 2. docs/WhatsNew-v.md — the hand-written account of what changed and why +# 3. auto-generated notes — every merged PR, straight from GitHub +# +# (2) is why this is not just `--generate-notes`: an auto-notes-only release lists "updated README" +# and "bump version to 10.5.2" as if they were the release. Part (2) is written by the person who +# knows which of those commits mattered. +# +# Kept separate from publish-nuget.yml so a release-page failure cannot fail a package publish. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag to release (defaults to the tag this workflow was dispatched on)' + required: false + type: string + push: + tags: [ 'v*' ] + branches: [ main ] + +permissions: + contents: write + +jobs: + release: + name: Publish GitHub Release + runs-on: ubuntu-latest + # Job-level, not step-level: `Generate PR notes` also calls `gh`, and a runner has no + # ambient credentials. With `set -e` an unauthenticated `gh api` aborts the job before the + # release is ever created — the failure looks like "workflow broke", not "auth missing". + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + # On a dispatch, GITHUB_REF is the BRANCH. Without this the version cross-check below + # reads whatever is on the branch rather than at the tag being released. + ref: ${{ inputs.tag || github.ref }} + # Tags are not fetched by the default shallow checkout, and the main-sync path below + # has to ask which version tag HEAD can reach. Depth 0 costs one clone of a repo this + # size and removes an entire class of "git describe found nothing" confusion. + fetch-depth: 0 + + - name: Resolve tag and cross-check the version + id: meta + env: + # workflow_dispatch carries the requested tag as an INPUT, not as an environment + # variable. Reading only $TAG ignores it, falls through to GITHUB_REF (a branch, + # e.g. "main"), and dies on "Refusing to release a non-version tag" — which is + # precisely the backfill case this input exists for. + REQUESTED_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + + # Two different jobs share this workflow, and conflating them is how a routine merge + # ends up either skipping a real release or failing on one that is not wanted: + # + # explicit — a v* tag was pushed, or a dispatch named a tag. That tag is what the + # release is FOR, so a version mismatch is a genuine error. + # sync — a merge to main. There is no tag to create anything for; the newest + # reachable v* tag is refreshed instead, and the version is expected to + # have moved on without a tag yet. Failing there would redden main on an + # ordinary version bump. + explicit="${REQUESTED_TAG:-}" + if [ -n "$explicit" ]; then + tag="$explicit"; mode=explicit + elif [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then + tag="${GITHUB_REF#refs/tags/}"; mode=explicit + else + tag="$(git describe --tags --abbrev=0 --match 'v*' 2> /dev/null || true)" + [ -n "$tag" ] || { echo "No v* tag reachable from $GITHUB_REF - nothing to release."; exit 0; } + mode=sync + fi + + case "$tag" in + v*) version="${tag#v}" ;; + *) echo "Refusing to release a non-version tag: $tag" >&2; exit 1 ;; + esac + + # A tag that disagrees with ships a package whose metadata lies about + # itself — publish-nuget packs from the csproj, not the tag, so the Docker tag would + # read 10.5.2 while the nupkg on nuget.org is 10.5.1. This is the exact mismatch that + # shipped a silent no-op once; fail here instead. + csproj="$(sed -n 's/.*\([^<]*\)<\/Version>.*/\1/p' src/Delibera.Core/Delibera.Core.csproj | head -1)" + if [ "$csproj" != "$version" ]; then + if [ "$mode" = sync ]; then + echo "Newest tag reachable from main is $tag, but the tree is now $csproj." + echo "Nothing to release yet - that will happen when tag v$csproj is pushed." + exit 0 + fi + echo "::error::tag $tag says $version but Delibera.Core.csproj says $csproj" >&2 + exit 1 + fi + + echo "Releasing $tag (Delibera.Core $csproj, mode=$mode)" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Generate PR notes + env: + TAG: ${{ steps.meta.outputs.tag }} + run: | + set -euo pipefail + # Explicit JSON through --input rather than -f: `gh api -f` sends parameters as a + # form-encoded body, and this endpoint wants application/json. The mismatch comes + # back as a 415 that reads like a permissions problem. + # + # Non-fatal on purpose. Auto notes are a nice-to-have appended after the WhatsNew + # document — losing them must not cost us the release itself. + if ! printf '{"tag_name":"%s"}' "$TAG" \ + | gh api --method POST \ + -H 'Accept: application/vnd.github+json' \ + "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + --input - > /tmp/auto-notes.md; then + echo "::warning::could not generate PR notes for $TAG; continuing without them" + : > /tmp/auto-notes.md + fi + if [ ! -s /tmp/auto-notes.md ]; then + echo "::warning::GitHub returned no auto notes for $TAG" + fi + + - name: Assemble the release body + env: + TAG: ${{ steps.meta.outputs.tag }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + default_branch="${{ github.event.repository.default_branch }}" + + # A dispatch checks out the TAG. Editorial files that live on main — the release + # header above all — do not exist at an older tag, and a bare `cp` under `set -e` + # kills the job at this step, after the version cross-check has already passed and + # with an error ("cp: cannot stat") that says nothing about releases. + # + # Prefer whatever is checked out, fall back to the default branch, and never let a + # missing document cost us the release page itself. + fetch_note() { + path="$1"; dest="$2" + if [ -f "$path" ]; then + cp "$path" "$dest" + echo " $path (from the checked-out ref)" + return 0 + fi + echo " $path is not at this ref — trying origin/$default_branch" + if git fetch --depth=1 origin "$default_branch" > /dev/null 2>&1 \ + && git show "FETCH_HEAD:$path" > "$dest" 2> /dev/null \ + && [ -s "$dest" ]; then + echo " $path (from origin/$default_branch)" + return 0 + fi + rm -f "$dest" + echo "::warning::$path is absent both here and on $default_branch — omitted from the body" + return 1 + } + + body=/tmp/release-body.md + : > "$body" + if fetch_note .github/release-notes/header.md "$body"; then + sed -i "s/{{VERSION}}/${VERSION}/g" "$body" + fi + + whatsnew="docs/WhatsNew-v${VERSION}.md" + if fetch_note "$whatsnew" /tmp/whatsnew.md; then + printf '\n## What is new in %s\n\n' "$VERSION" >> "$body" + cat /tmp/whatsnew.md >> "$body" + fi + + if [ -s /tmp/auto-notes.md ]; then + printf '\n---\n\n## Merged pull requests\n\n' >> "$body" + cat /tmp/auto-notes.md >> "$body" + fi + + if grep -q '{{VERSION}}' "$body"; then + echo "::error::unsubstituted {{VERSION}} left in the release body" >&2 + exit 1 + fi + echo "--- body: $(wc -c < "$body") bytes, $(wc -l < "$body") lines ---" + + - name: Create or update the release + env: + TAG: ${{ steps.meta.outputs.tag }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + title="Delibera $VERSION" + if gh release view "$TAG" > /dev/null 2>&1; then + echo "Release for $TAG already exists — updating it." + gh release edit "$TAG" --title "$title" --notes-file /tmp/release-body.md + else + # --verify-tag refuses to create a release whose tag is not on the remote, so a typo + # cannot manufacture a release pointing at a tag that does not exist. + gh release create "$TAG" --title "$title" --notes-file /tmp/release-body.md --verify-tag + fi + # Only fields `gh release view --json` actually exposes. `isLatest` is NOT one of + # them (it belongs to `gh release list`), and asking for it fails with + # "Unknown JSON field" — AFTER the release was created, turning a successful + # publish into a red run. + gh release view "$TAG" --json url,tagName,isDraft,isPrerelease \ + --jq '" -> \(.tagName) draft=\(.isDraft) prerelease=\(.isPrerelease) \(.url)"' \ No newline at end of file diff --git a/.gitignore b/.gitignore index cd9559e..91267ea 100644 --- a/.gitignore +++ b/.gitignore @@ -470,3 +470,10 @@ FodyWeavers.xsd **/.output/ **/.data/ **/dist/ + + +# Social / newsletter drafts (never committed) +# LinkedIn articles and release announcements are written here, reviewed on the +# desktop, and pasted into the platform. Same rationale as .bench/ — personal +# working material, not repository documentation. +.articles/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0d37f..6c7ec3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ All notable changes to **Delibera** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added — opt-in CORS for static browser clients + +`Delibera:Server:Cors:AllowedOrigins` registers a named CORS policy listing the browser origins +permitted to call the API cross-origin, and `UseCors` is wired only when that list is non-empty. + +**Off by default, and the default is the point.** The Web UI reaches the API through its own +same-origin BFF route and never needs CORS. The absent header is also what stops an arbitrary web +page from *reading* a response — a cross-origin `fetch` still fires the request without it, it just +cannot see the answer. Turning this on globally would have added a browser-side CSRF vector to an +API that is unauthenticated, unthrottled, and spends real credits per debate. + +Set it only for a deliberately static client — the GitHub Pages build of the Web UI, which has no +Nitro server and therefore no BFF route. In compose: + +```yaml +environment: + DELIBERA_CORS_ORIGIN: https://techbuzzz.github.io +``` + +Origins are matched exactly; there is no wildcard switch. `AllowCredentials` together with `*` +is rejected at startup with a message naming the config key, rather than by the framework with one +that names nothing. 13 new tests: 8 asserting what gets registered, and 5 driving the real +pipeline over HTTP — that a default host emits no `Access-Control-Allow-Origin`, that a +configured origin gets its own origin echoed back rather than a wildcard, that an unlisted origin +gets nothing, that the preflight is answered, and that `X-Correlation-Id` is exposed. + +### Fixed — the static Web UI build ignored its configured API origin + +`NUXT_PUBLIC_DELIBERA_API_BASE` did not reach the bundle, so a GitHub Pages build configured with +it still called its own origin and every request 404'd. `pages.yml` was green throughout: Nuxt +substitutes `import.meta.env.NUXT_PUBLIC_*` only for names declared under `runtimeConfig.public`, +and an undeclared one is silently left as-is rather than rejected. `deliberaApiBase` is now +declared (empty, so the container's same-origin behaviour is unchanged), and `pages.yml` asserts +that a configured origin actually appears in the export — a dead site is no longer able to report +a successful deployment. + +Verified: a build with the variable set carries it into 4 places in the export; a build without it +carries it into none; the new gate passes when the origin is present, fails naming the missing +config key when it is not, and is a no-op in preview mode. Web UI tests 34/34. + ## [10.5.2] - 2026-10-09 **Contains behaviour changes.** Two are breaking for existing deployments: published ports now diff --git a/README-RU.md b/README-RU.md index c3d685a..57b96e4 100644 --- a/README-RU.md +++ b/README-RU.md @@ -9,6 +9,15 @@ **Коллективное принятие решений через структурированное обсуждение ИИ — с RAG, pgvector, Knowledge Keeper, 🛠️ Operator (MCP-инструменты), Chairman, 🔥 сжатием контекста, ✂️ AutoChunking, 💉 Dependency Injection и 📋 журналированием выполнения** [![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) +[![NuGet: Server](https://img.shields.io/nuget/v/Delibera.Server.svg)](https://www.nuget.org/packages/Delibera.Server) +[![NuGet: Redis](https://img.shields.io/nuget/v/Delibera.Redis.svg)](https://www.nuget.org/packages/Delibera.Redis) +[![Release](https://img.shields.io/github/v/release/techbuzzz/Delibera?label=Release)](https://github.com/techbuzzz/Delibera/releases/latest) +[![Docker Hub](https://img.shields.io/badge/Docker%20Hub-2496ED?logo=docker&logoColor=fff)](https://hub.docker.com/r/techbuzzz/delibera-server) +[![Pulls: server](https://img.shields.io/docker/pulls/techbuzzz/delibera-server?logo=docker&logoColor=2496ED)](https://hub.docker.com/r/techbuzzz/delibera-server) +[![Pulls: webui](https://img.shields.io/docker/pulls/techbuzzz/delibera-webui?logo=docker&logoColor=2496ED)](https://hub.docker.com/r/techbuzzz/delibera-webui) +[![Web UI](https://img.shields.io/badge/Web%20UI-GitHub%20Pages-3E8AFF?logo=githubpages)](https://techbuzzz.github.io/Delibera/) +[![CI](https://github.com/techbuzzz/Delibera/actions/workflows/publish-nuget.yml/badge.svg)](https://github.com/techbuzzz/Delibera/actions/workflows/publish-nuget.yml) +[![Docker CI](https://github.com/techbuzzz/Delibera/actions/workflows/publish-docker.yml/badge.svg)](https://github.com/techbuzzz/Delibera/actions/workflows/publish-docker.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](LICENSE) [![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) [![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) @@ -227,10 +236,72 @@ Breaking changes нет — всё опционально, ни один пуб ### Установка +Delibera публикуется как три пакета NuGet и два образа Docker. Выберите подходящий путь. + +#### 📦 NuGet + +```bash +dotnet add package Delibera.Core # сам фреймворк — начните отсюда +dotnet add package Delibera.Server # хост ASP.NET Core (Minimal API, SSE, MCP) +dotnet add package Delibera.Redis # распределённые дебаты + общий кэш результатов +``` + +Все три выходят в каждом релизе с одной версией. `Delibera.Server` подтягивает `Delibera.Redis` +транзитивно, поэтому явная ссылка нужна только если вы используете Redis-интеграцию напрямую. +Redis остаётся **выключенным**, пока не задан `Delibera:Redis:Enabled`. + +#### 🐳 Docker Hub — без сборки + +Образы публикуются на каждом теге `v*` для `linux/amd64` **и** `linux/arm64`: + +| Образ | Что это | +| --- | --- | +| `techbuzzz/delibera-server` | API, SSE-стриминг дебат, MCP-эндпоинты | +| `techbuzzz/delibera-webui` | браузерный интерфейс на Nuxt 4 | + ```bash -dotnet add package Delibera.Core +curl -O https://raw.githubusercontent.com/techbuzzz/Delibera/main/deploy/docker-compose.hub.yml +export DELIBERA_VERSION=10.5.2 # зафиксировать релиз; без переменной тянется :latest +docker compose -f docker-compose.hub.yml up -d ``` +> ⚠️ **API не аутентифицирован и не ограничен по частоте, а дебата тратит реальные кредиты LLM.** +> Все публикуемые порты слушают `127.0.0.1`. Поставьте аутентифицирующий обратный прокси, прежде чем +> открывать этот стек в сеть, которой не управляете. + +#### 🌐 Веб-интерфейс на GitHub Pages + +Тот же интерфейс статически публикуется на при каждом +мердже в `main`. + +У статической сборки **нет сервера**, поэтому same-origin BFF-маршрута в ней не существует — нужен +абсолютный origin API, и тот сервер должен разрешать origin Pages через CORS: + +```yaml +environment: + DELIBERA_CORS_ORIGIN: https://techbuzzz.github.io +``` + +CORS **выключен по умолчанию** и остаётся выключенным, пока не перечислены origins. Это намеренно: +именно отсутствие заголовка не даёт случайной веб-странице прочитать ответ этого API, а контейнерному +Web UI он не нужен вовсе. Комбинация «wildcard + AllowCredentials» отклоняется на старте с сообщением, +называющим конкретный ключ конфигурации. + +#### 📥 Из исходников + +```bash +git clone https://github.com/techbuzzz/Delibera.git +cd Delibera +dotnet build Delibera.slnx -c Release +``` + +Из клона `docker compose up -d` поднимает весь стек — сервер, веб-интерфейс, Ollama, Qdrant, +pgvector и Redis. Пошаговое руководство: [docs/QuickStart-RU.md](docs/QuickStart-RU.md). + +> 🧑‍🔧 **Публикуете релиз или обновляетесь со старой версии?** Смотрите +> [docs/ReleaseProcess.md](docs/ReleaseProcess.md) — там описан порядок, который важен, и два +> шага, которые падают молча, а не с ошибкой. + ### Минимальный пример ```csharp diff --git a/README.md b/README.md index 2ec7cfd..5f72b1f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,13 @@ [![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) [![NuGet: Server](https://img.shields.io/nuget/v/Delibera.Server.svg)](https://www.nuget.org/packages/Delibera.Server) [![NuGet: Redis](https://img.shields.io/nuget/v/Delibera.Redis.svg)](https://www.nuget.org/packages/Delibera.Redis) +[![Release](https://img.shields.io/github/v/release/techbuzzz/Delibera?label=Release)](https://github.com/techbuzzz/Delibera/releases/latest) +[![Docker Hub](https://img.shields.io/badge/Docker%20Hub-2496ED?logo=docker&logoColor=fff)](https://hub.docker.com/r/techbuzzz/delibera-server) +[![Pulls: server](https://img.shields.io/docker/pulls/techbuzzz/delibera-server?logo=docker&logoColor=2496ED)](https://hub.docker.com/r/techbuzzz/delibera-server) +[![Pulls: webui](https://img.shields.io/docker/pulls/techbuzzz/delibera-webui?logo=docker&logoColor=2496ED)](https://hub.docker.com/r/techbuzzz/delibera-webui) +[![Web UI](https://img.shields.io/badge/Web%20UI-GitHub%20Pages-3E8AFF?logo=githubpages)](https://techbuzzz.github.io/Delibera/) [![CI](https://github.com/techbuzzz/Delibera/actions/workflows/publish-nuget.yml/badge.svg)](https://github.com/techbuzzz/Delibera/actions/workflows/publish-nuget.yml) +[![Docker CI](https://github.com/techbuzzz/Delibera/actions/workflows/publish-docker.yml/badge.svg)](https://github.com/techbuzzz/Delibera/actions/workflows/publish-docker.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](LICENSE) [![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) [![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) @@ -229,10 +235,71 @@ Heavier local models, recommended on GPUs with ≥ 24 GB VRAM or on Ollama Cloud ### Installation +Delibera ships as three NuGet packages and two Docker images. Pick the path that fits. + +#### 📦 NuGet + +```bash +dotnet add package Delibera.Core # the framework — start here +dotnet add package Delibera.Server # ASP.NET Core host (Minimal API, SSE, MCP) +dotnet add package Delibera.Redis # distributed debates + shared result cache +``` + +All three publish on every release at the same version. `Delibera.Server` pulls `Delibera.Redis` +transitively, so you only need to reference it explicitly if you use the Redis integration directly. +Redis stays **off** unless you set `Delibera:Redis:Enabled`. + +#### 🐳 Docker Hub — no build required + +Images are published on every `v*` tag for `linux/amd64` **and** `linux/arm64`: + +| Image | What it is | +| --- | --- | +| `techbuzzz/delibera-server` | API, SSE debate streaming, MCP endpoints | +| `techbuzzz/delibera-webui` | Nuxt 4 browser front end for the debate workflow | + ```bash -dotnet add package Delibera.Core +curl -O https://raw.githubusercontent.com/techbuzzz/Delibera/main/deploy/docker-compose.hub.yml +export DELIBERA_VERSION=10.5.2 # pin a release; omit to track :latest +docker compose -f docker-compose.hub.yml up -d ``` +> ⚠️ **The API is unauthenticated and unthrottled, and a debate spends real LLM credits.** Every +> published port binds to `127.0.0.1`. Put an authenticating reverse proxy in front before exposing +> this stack to any network you do not control. + +#### 🌐 Web UI on GitHub Pages + +The same UI is deployed statically to on every merge to +`main`. + +A static export has **no server**, so the UI's same-origin BFF route does not exist there — it needs +an absolute API origin, and that server must allow the Pages origin via CORS: + +```yaml +environment: + DELIBERA_CORS_ORIGIN: https://techbuzzz.github.io +``` + +CORS is **off by default** and stays off unless you list origins. That is deliberate: the missing +header is what stops a random web page from reading this API, and the Web UI in the container does +not need it. `Wildcard + AllowCredentials` is rejected at startup with a message naming the key. + +#### 📥 From source + +```bash +git clone https://github.com/techbuzzz/Delibera.git +cd Delibera +dotnet build Delibera.slnx -c Release +``` + +From a clone, `docker compose up -d` starts the whole stack — server, Web UI, Ollama, Qdrant, +pgvector and Redis. See [docs/QuickStart.md](docs/QuickStart.md) for the walkthrough. + +> 🧑‍🔧 **Publishing a release, or upgrading from an older one?** See +> [docs/ReleaseProcess.md](docs/ReleaseProcess.md) — it documents the order that matters, and the +> two steps that fail silently rather than loudly. + ### Minimal Example ```csharp diff --git a/deploy/docker-compose.hub.yml b/deploy/docker-compose.hub.yml index 608e0cf..57b4846 100644 --- a/deploy/docker-compose.hub.yml +++ b/deploy/docker-compose.hub.yml @@ -64,6 +64,11 @@ services: Delibera__Redis__ConnectionString: ${DELIBERA_REDIS_CONNECTION:-redis:6379,abortConnect=false} Delibera__Redis__StreamMaxLength: ${DELIBERA_REDIS_STREAM_MAX_LENGTH:-10000} Delibera__Redis__StateKeyTtl: ${DELIBERA_REDIS_STATE_TTL:-24:00:00} + # CORS is OFF unless you list origins here. The bundled Web UI talks to this API through + # its own same-origin BFF route and never needs it; set it only when a *static* client + # (e.g. the GitHub Pages build) calls this API straight from the browser. List origins + # explicitly — a wildcard would let any page on the internet spend credits here. + Delibera__Server__Cors__AllowedOrigins__0: ${DELIBERA_CORS_ORIGIN:-} depends_on: qdrant: condition: service_healthy diff --git a/dockerhub/delibera-server.md b/dockerhub/delibera-server.md new file mode 100644 index 0000000..ce43bce --- /dev/null +++ b/dockerhub/delibera-server.md @@ -0,0 +1,106 @@ +# delibera-server + +ASP.NET Core host for **Delibera** — a framework for collective decision making through structured +AI deliberation. Multiple models reason through a question across rounds, critique each other, and a +**Chairman** weighs the arguments into a final verdict. + +This image is the API surface only. For a browser front end, pair it with +[`delibera-webui`](https://hub.docker.com/r/techbuzzz/delibera-webui). + +--- + +## Quick start + +```bash +docker run -d --name delibera-server \ + -p 127.0.0.1:5200:8080 \ + -e Delibera__Providers__DefaultEndpoint=http://host.docker.internal:11434 \ + -e Delibera__Models__Fast=llama3.2:3b \ + -e Delibera__Models__Strong=qwen2.5:7b \ + techbuzzz/delibera-server:latest +``` + +Or bring up the full stack — server, Web UI, Ollama, Qdrant and Redis: + +```bash +curl -O https://raw.githubusercontent.com/techbuzzz/Delibera/main/deploy/docker-compose.hub.yml +export DELIBERA_VERSION=10.5.2 +docker compose -f docker-compose.hub.yml up -d +``` + +Pin a release with `DELIBERA_VERSION`; omit it to track `:latest`. + +--- + +## ⚠️ Security + +**The API is unauthenticated and unthrottled, and running a debate spends real LLM credits.** +Every published port in the supplied compose file binds to `127.0.0.1` for that reason. + +Put an authenticating reverse proxy in front before exposing this stack to any network you do not +control. Do not widen the port bind back to `0.0.0.0` on the assumption that it is "just local". + +--- + +## What it exposes + +| Endpoint | Purpose | +| --- | --- | +| `/api/v1/debates` | Create, list, inspect and cancel debates | +| `/api/v1/debates/{id}/stream` | Server-Sent Events, one event per round | +| `/api/v1/scenarios`, `/api/v1/corpus` | Templates and the RAG corpus | +| `/api/v1/health` | Liveness and readiness | + +Debates run for 150–200 s and an SSE stream stays open far longer, so read the debate result from +the stream or the record — not from a short client timeout. + +--- + +## Configuration + +Every setting is bound from `Delibera__
__`. + +| Variable | Default | Meaning | +| --- | --- | --- | +| `Delibera__Providers__DefaultType` | `Ollama` | Provider family: `Ollama`, `OpenAI`, … | +| `Delibera__Providers__DefaultEndpoint` | `http://host.docker.internal:11434` | Provider base URL | +| `Delibera__Providers__DefaultModel` | `qwen2.5:14b` | Model used when none is specified | +| `Delibera__Providers__ApiKey` | *(empty)* | Required for hosted providers | +| `Delibera__Providers__EmbeddingModel` | `nomic-embed-text` | Embedding model for RAG | +| `Delibera__Models__Fast` / `__Strong` | `llama3.2:3b` / `qwen2.5:7b` | Council sizing tiers | +| `Delibera__MaxRounds` | `4` | Debate rounds | +| `Delibera__Temperature` | `0.7` | Sampling temperature | +| `Delibera__Strategy` | `Standard` | Debate strategy | +| `Delibera__Rag__Enabled` | `false` | Enable RAG | +| `Delibera__Rag__ProviderType` | `Qdrant` | `Qdrant` or `PgVector` | +| `Delibera__Redis__Enabled` | `false` | Enable the Redis orchestrator | +| `Delibera__Redis__CacheEnabled` | `false` | Enable the Redis result cache | +| `Delibera__Output__Directory` | `/app/debate_results` | Where results are written | + +### Redis + +Off by default — the default path resolves the in-process orchestrator, exactly as before. + +Enabling it publishes round events to a Redis Stream so any API instance can stream them over SSE, +and shares debate state across instances. It does **not** provide distributed turn execution: each +debate still runs in the process that accepted it. A bad connection string fails at startup rather +than silently degrading, because a deployment that quietly fell back would look distributed and not be. + +--- + +## Architectures + +`linux/amd64` and `linux/arm64`, published together under one manifest per tag. + +## Tags + +- `10.5.2`, `10.5` — pinned releases +- `latest` — newest release + +## Links + +- Source: https://github.com/techbuzzz/Delibera +- NuGet: https://www.nuget.org/packages/Delibera.Server +- Release notes: https://github.com/techbuzzz/Delibera/releases + +MIT licensed. \ No newline at end of file diff --git a/dockerhub/delibera-webui.md b/dockerhub/delibera-webui.md new file mode 100644 index 0000000..8ef70e7 --- /dev/null +++ b/dockerhub/delibera-webui.md @@ -0,0 +1,102 @@ +# delibera-webui + +Browser front end for **Delibera** — a Nuxt 4 application covering the debate workflow: list debates, +create one from a registered template, watch rounds arrive live, read the verdict with token stats, +export Markdown, cancel a running debate. + +This image is UI only. It talks to the API through +[`delibera-server`](https://hub.docker.com/r/techbuzzz/delibera-server) and holds no state. + +--- + +## Quick start + +```bash +docker run -d --name delibera-webui \ + -p 127.0.0.1:3000:3000 \ + -e NUXT_DELIBERA_API_BASE=http://delibera-server:8080 \ + techbuzzz/delibera-webui:latest +``` + +The API base URL has to be reachable **from inside the container**, so it is the service name on the +Docker network — not `localhost`, and not a host-side address. + +Or bring up the full stack: + +```bash +curl -O https://raw.githubusercontent.com/techbuzzz/Delibera/main/deploy/docker-compose.hub.yml +export DELIBERA_VERSION=10.5.2 +docker compose -f docker-compose.hub.yml up -d +``` + +Then open . + +--- + +## ⚠️ Security + +This UI has **no authentication of its own**. It exposes everything the API exposes, including the +ability to spend LLM credits. + +Every published port in the supplied compose file binds to `127.0.0.1` for that reason. Put an +authenticating reverse proxy in front before exposing this stack to any network you do not control. + +--- + +## Static build (GitHub Pages) + +The same app is published as a static site at + on every merge to `main`. + +A static export has **no Nitro server**, so the `/api/delibera/**` BFF route does not exist there. +Set `NUXT_PUBLIC_DELIBERA_API_BASE` to a reachable server origin to give the static build an API — +and that server must allow the Pages origin via CORS, which `Delibera.Server` does not do by +default. Without it the page deploys and renders, but its requests 404; that is a UI preview, not +an install. + +Two build details worth knowing if you fork it: Nuxt 4 emits to `.output/public`, not `dist`, and +the prerender routes must be listed explicitly in `nuxt.config.ts` — otherwise the export contains +only the `200.html`/`404.html` SPA fallbacks and deploys as a blank page. + +--- + +## How it reaches the API + +Through a Nitro server-side proxy at `/api/delibera/**`, never directly from the browser. Two defects +in the API force this: + +1. The server registers no CORS policy, so a browser calling the API origin directly is blocked on + every request, GETs included. +2. Absolute `streamUrl` / `resultUrl` values are built from `Scheme://Host` with no + forwarded-header handling, so they point at the wrong host behind a proxy or an ingress. + +SSE is forwarded unbuffered, so a round appears the moment it is produced rather than at the end of +the debate. + +--- + +## Configuration + +| Variable | Default | Meaning | +| --- | --- | --- | +| `NUXT_DELIBERA_API_BASE` | `http://delibera-server:8080` | API base URL, resolved inside the container network | +| `NUXT_PUBLIC_TENANT_ID` | `default` | Tenant forwarded to the API | +| `NODE_ENV` | `production` | Standard Node environment | + +--- + +## Architectures + +`linux/amd64` and `linux/arm64`, published together under one manifest per tag. + +## Tags + +- `10.5.2`, `10.5` — pinned releases +- `latest` — newest release + +## Links + +- Source: https://github.com/techbuzzz/Delibera +- Release notes: https://github.com/techbuzzz/Delibera/releases + +MIT licensed. \ No newline at end of file diff --git a/docs/ReleaseProcess.md b/docs/ReleaseProcess.md new file mode 100644 index 0000000..0ca06fa --- /dev/null +++ b/docs/ReleaseProcess.md @@ -0,0 +1,260 @@ +# 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: `` 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 + +```bash +# All five packaged projects, plus the Web UI manifest. +grep -rl '10\.5\.2' 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 `## [] - ` +- `docs/WhatsNew-v.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. Squash-merge — the repo squash-merges, so a feature branch's SHAs never become ancestors +of `main`. + +### 3. Verify before tagging + +```bash +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 .nupkg — not the previous one +``` + +--- + +## Cut the tag + +```bash +git tag -a v -m "v" +git push origin v +``` + +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 + +### Check what actually landed + +Green pipelines are not proof that something was published: + +```bash +curl -s https://api.nuget.org/v3-flatcontainer/delibera.core/index.json | grep +curl -s https://hub.docker.com/v2/repositories/techbuzzz/delibera-server/tags | grep +``` + +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 + +Normally nothing to do: a merge to `main` refreshes the release page for the newest `v*` tag +reachable from `main`, so the page picks up the header and the WhatsNew document without a +handful of clicks. + +Run **Actions → release → Run workflow** only to repair a *specific* tag — for instance to rebuild +the body of a release whose page was created by hand. It creates or updates; it never duplicates. + +That workflow distinguishes two situations on purpose, and the difference is what keeps a routine +merge from reddening `main`: + +| Trigger | Behaviour on a tag/`` mismatch | +|---|---| +| `v*` tag pushed, or dispatch naming a tag | **fails** — that tag exists to publish something specific | +| merge to `main` | **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: + +```bash +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? + +```bash +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` deploys the Web UI as a **static** export on every merge to `main`. Two consequences: + +- A static export has **no Nitro server**, so the `/api/delibera/**` BFF route does not exist + there. The browser needs an absolute origin via `NUXT_PUBLIC_DELIBERA_API_BASE`, and that + origin must allow the Pages origin via CORS — which `Delibera.Server` does not do by default. + Unset, the site deploys and its requests 404: a UI preview, not an install. +- Nuxt 4 emits to `.output/public`, **not** `dist`. GitHub's own sample workflow still uploads + `./dist` because it predates Nuxt 4. + +### Reproduce the Pages build locally + +`pages.yml` runs steps that PR CI does not: `nuxt generate`, and two assertions about the export. +That gap is worth closing by hand before every Pages-related change, because none of it can fail +a pull request: + +```bash +cd src/Delibera.WebUI +npm ci +npm run generate # 6 routes -> .output/public +test -f .output/public/index.html # the gate in pages.yml +test -f .output/public/debates/new/index.html # prerender.routes actually took + +# the origin must be BAKED IN, not merely configured +NUXT_PUBLIC_DELIBERA_API_BASE=https://example.test npm run generate +grep -rqF 'https://example.test' .output/public # 0 hits = the whole feature is dead +``` + +That last `grep` is the check that matters. `NUXT_PUBLIC_*` is substituted only for names declared +under `runtimeConfig.public`; an undeclared one is dropped silently, the build stays green, and the +deployed site quietly calls its own origin. It has already happened once here. + +Note `npm run build` (not `generate`) before `npm test` — the SSE suite loads +`.output/server/index.mjs`, which a static export does not produce, and reports a suite failure +that looks like a broken test rather than a missing artefact. + +### Verify base-path runs from PowerShell, not Git Bash + +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 npm run generate` reaches the build as +`C:/Program Files/Git/Delibera`. + +That malformed base produces exactly the output a real Nuxt bug would: `.output/public/index` +and `.output/public/debates/new` containing `Redirecting...` instead of documents, no `index.html` +anywhere, and a green build. I spent a cycle "fixing" nuxt.config.ts on the strength of that. + +Set these variables from PowerShell, where the value passes through untouched, before believing +anything about a base-path build: + +```powershell +$env:NUXT_APP_BASE_URL = '/Delibera' +npm run generate +``` + +--- + +## 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: + +```bash +# structure: tabs, block scalars, `on:` key +# bash syntax of every extracted run: body (Git Bash or any Linux) +bash -n