diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c9d2994..23d32ba8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,28 +4,184 @@ on: push: tags: - "v[0-9]*" + # Manual trigger for running the dist-smoke test against a wheel built + # from the current branch WITHOUT publishing anything (#1611). Dispatch + # runs build-wheel → dist-smoke-test; publish-and-release is gated on + # `github.event_name == 'push'` so a manual run stops at the smoke gate. + # Use this to validate a release candidate before tagging — `gh workflow + # run release.yml` from the branch, or the Actions UI. + workflow_dispatch: concurrency: group: release cancel-in-progress: false jobs: - build-and-release: + # Build the klangk wheel (#1656). No publish here — the wheel is uploaded + # as a workflow artifact for dist-smoke-test to install + exercise, and + # PyPI publish is gated on the smoke test passing (publish-and-release + # below). Pre-#1705 the wheel built + published in one job in parallel + # with the image build; the smoke gate serializes that and holds PyPI + # until the smoke passes so a broken wheel never reaches the index — + # no yank ever needed. + # + # The distribution name is ``klangk`` (one unified wheel ships the klangkd + # server + klangk CLI, #1606); the tag is ``vX.Y.Z``; hatch-vcs derives + # the version from it. + build-wheel: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write # unused here; kept for symmetry with publish-and-release + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # hatch-vcs needs full history to derive the version + + - uses: ./.github/actions/devenv-setup + with: + # Build the frontend with the default plugin set (checked-in + # plugins.yaml, #1660) so the wheel's klangk/frontend/ carries + # the compiled UI + features.json. The hatch build hook + # (hatch_build_frontend.py) force-includes it and *requires* it + # for non-editable wheel builds (#1600). + build-tasks: klangk:flutter-build + + - name: Build wheel + run: | + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + # scripts/build_wheel.sh installs python-build transiently (uv-sync + # at shell entry would wipe it; the script installs + builds in one + # process). Requires the frontend artifact from klangk:flutter-build + # above — the hatch build hook force-includes it and requires it for + # non-editable wheel builds (#1600). + devenv shell -- bash scripts/build_wheel.sh + + - name: Upload wheel artifact + uses: actions/upload-artifact@v7 + with: + name: klangk-wheel + path: src/klangk/dist/*.whl + retention-days: 30 + + # Smoke test the installed wheel OUTSIDE devenv (#1611). The whole point + # is to prove a bare `pip install klangk && klangkd` works on a stock + # Ubuntu runner with no devenv/nix in the loop — that's the audience + # the #1607 / #1645 first-run story targets. Runs apt-get install for + # caddy (klangkd forks it as a child via shutil.which) and the Playwright + # Chromium browser; pip-installs the wheel into an isolated venv; starts + # real klangkd (caddy engine, UDS upstream, Flutter Web frontend served + # from the wheel's bundled klangk/frontend/); runs the one-file dist-smoke + # Playwright spec against it. + # + # What this catches before anything publishes: + # - Frontend not included in wheel → 404 / blank page + # - _DEFAULT_FRONTEND_DIR resolves wrong post-install + # - Flutter build broken / incomplete → flutter-view never attaches + # - main.dart.js missing or corrupt → engine doesn't boot + # - klangkd entrypoint registration broken → server never starts + # - caddy binary not found → engine refuses to start + # - Caddyfile render broken → caddy rejects the config + # - UDS upstream misconfigured → caddy 502s + # - Missing transitive dep in pyproject.toml → import fails fast + dist-smoke-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: build-wheel + steps: + - uses: actions/checkout@v7 + + - name: Install caddy + Playwright deps via apt + # Stock Ubuntu packages — no devenv, no nix. Caddy is klangkd's + # default proxy engine child (KLANGK_PROXY_BIN overrides if needed, + # but the default shutil.which("caddy") → /usr/bin/caddy path is + # exactly what apt installs). The Playwright libs are what + # `npx playwright install-deps` would install; we install them + # directly so the spec run doesn't need to. + run: | + sudo apt-get update + sudo apt-get install -y caddy curl python3 python3-venv \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \ + libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 + + - name: Download wheel artifact + uses: actions/download-artifact@v7 + with: + name: klangk-wheel + path: wheel-cache + + - name: Run dist-smoke test + run: bash scripts/dist-smoke-test.sh "$(echo wheel-cache/*.whl)" + + - name: Upload smoke logs on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: dist-smoke-logs + retention-days: 7 + path: | + /tmp/klangk-smoke-state/ + /tmp/klangk-smoke-data/ + src/frontend/e2e-tests/test-results/ + src/frontend/e2e-tests/logs/ + + # Publish + release. Runs only after dist-smoke-test passes: PyPI publish, + # GHCR image push, and the GitHub Release all happen here. Gating PyPI on + # the smoke (vs. publishing in parallel and yanking on failure) means a + # broken wheel never reaches the index — there is no yank path and no need + # for one. The host image build (the slow step, ~25-30 min) runs in this + # same job, before the pushes; it can't easily be split into a parallel + # job because GitHub Actions jobs run on separate runners and the built + # images can't transfer without an explicit save/load step that costs + # roughly the time the parallelism would save (the host image embeds the + # workspace tarball, multi-GB). Tag-push-only frequency makes the serial + # SLO acceptable. + publish-and-release: runs-on: ubuntu-latest timeout-minutes: 45 + needs: dist-smoke-test + # Skip on workflow_dispatch — a manual run is for validating the smoke + # gate, not for actually publishing. Only a v* tag push runs the full + # release. Lets a maintainer run `gh workflow run release.yml` from a + # release-candidate branch to verify build-wheel + dist-smoke pass + # before tagging. + if: github.event_name == 'push' + environment: pypi # PyPI trusted publisher binds to this environment name permissions: packages: write contents: write + id-token: write # OIDC attestation for trusted PyPI publishing steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 # hatch-vcs / changelog extraction both need history - uses: ./.github/actions/devenv-setup + - name: Download wheel artifact + uses: actions/download-artifact@v7 + with: + name: klangk-wheel + path: wheel-dist + - name: Build host image run: | . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh devenv shell -- build-host-image + - name: Publish wheel to PyPI + # Trusted publishing (OIDC) — no API token. pypa/gh-action-pypi-publish + # negotiates an OIDC token from GitHub Actions and presents it to + # PyPI, which validates it against the trusted-publisher config on + # the `klangk` project (environment: pypi, workflow: release.yml). + # Runs after the smoke test has passed, so a broken wheel never + # reaches PyPI — no yank path exists. + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: wheel-dist + skip-existing: true # idempotent on re-runs of the same tag + - name: Push images to GHCR run: | echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin @@ -76,64 +232,3 @@ jobs: fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Build + publish the klangk wheel to PyPI via trusted publishing (OIDC, - # no API token — same shape as the deleted cli-publish.yml pre-#1606). - # Runs in parallel with the image build — the wheel doesn't need - # podman/docker, and pip/uv users (the #1607 / #1645 first-run audience) - # get an installable artifact via `pip install klangk==`. The - # distribution name is ``klangk`` (one unified wheel ships the klangkd - # server + klangk CLI, #1606); the tag is ``vX.Y.Z``; hatch-vcs derives - # the version from it. - # - # Trusted-publishing prereq (PyPI side, one-time): the `klangk` PyPI - # project must have a trusted publisher configured for this repo + - # workflow + environment (mcdonc/klangk, .github/workflows/release.yml, - # environment name: pypi). With that in place PyPI validates the OIDC - # token from GitHub Actions and publishes with no secret on our side. - build-wheel: - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: pypi # PyPI trusted publisher binds to this environment name - permissions: - id-token: write # OIDC attestation — the only permission trusted publishing needs - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 # hatch-vcs needs full history to derive the version - - - uses: ./.github/actions/devenv-setup - with: - # Build the frontend with the default plugin set (checked-in - # plugins.yaml, #1660) so the wheel's klangk/frontend/ carries - # the compiled UI + features.json. The hatch build hook - # (hatch_build_frontend.py) force-includes it and *requires* it - # for non-editable wheel builds (#1600). - build-tasks: klangk:flutter-build - - - name: Build wheel - run: | - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - # scripts/build_wheel.sh installs python-build transiently (uv-sync - # at shell entry would wipe it; the script installs + builds in one - # process). Requires the frontend artifact from klangk:flutter-build - # above — the hatch build hook force-includes it and requires it for - # non-editable wheel builds (#1600). - devenv shell -- bash scripts/build_wheel.sh - - - name: Upload wheel artifact - uses: actions/upload-artifact@v7 - with: - name: klangk-wheel - path: src/klangk/dist/*.whl - retention-days: 30 - - - name: Publish to PyPI - # Trusted publishing (OIDC) — no API token. pypa/gh-action-pypi-publish - # negotiates an OIDC token from GitHub Actions and presents it to - # PyPI, which validates it against the trusted-publisher config on - # the `klangk` project (environment: pypi, workflow: release.yml). - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: src/klangk/dist - skip-existing: true # idempotent on re-runs of the same tag diff --git a/scripts/dist-smoke-test.sh b/scripts/dist-smoke-test.sh new file mode 100755 index 00000000..58b546f0 --- /dev/null +++ b/scripts/dist-smoke-test.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Run the dist-smoke Playwright spec against a klangkd started from an +# installed wheel — OUTSIDE any devenv / nix shell (#1611). +# +# The whole point: prove a bare `pip install klangk && klangkd` works on a +# stock Ubuntu runner. caddy is apt-installed (klangkd forks it as a child +# via shutil.which); python3 + venv come from the runner; npm + Playwright +# install their own Chromium. Nothing from devenv. This is the audience the +# #1607 / #1645 first-run story targets — if the wheel doesn't serve a +# working login page in this environment, the release is broken. +# +# The wheel is built separately (release.yml's build-wheel job). This script: +# 1. Installs the wheel into an isolated venv (not editable — catches +# packaging bugs an editable install would hide). +# 2. Imports klangk.main.build_app as a sanity check — surfaces a +# ModuleNotFoundError fast if a transitive dep is missing from +# pyproject.toml (klangkd --help is too shallow; Typer exits before +# main() runs and build_app is imported lazily inside it). +# 3. Starts the real klangkd entrypoint (caddy engine on UDS upstream + +# Flutter Web frontend served from the wheel's bundled klangk/frontend/ +# — KLANGK_FRONTEND_DIR is unset so settings falls back to +# _DEFAULT_FRONTEND_DIR, NOT inherited from any dev shell). +# 4. Polls /health until caddy + uvicorn are ready. +# 5. Installs the e2e npm deps + Playwright Chromium, then runs +# `npx playwright test --project=dist-smoke` against that URL. +# 6. Tears klangkd down on exit (trap). +# +# What this catches (before anything publishes): +# - Frontend not included in wheel → 404 / blank page +# - _DEFAULT_FRONTEND_DIR resolves wrong post-install → same +# - Flutter build broken / incomplete → flutter-view never attaches +# - main.dart.js missing or corrupt → engine doesn't boot +# - klangkd entrypoint registration broken → server never starts +# - caddy binary not found → engine refuses to start +# - Caddyfile render broken → caddy rejects the config +# - UDS upstream misconfigured → caddy 502s +# - Missing transitive dep in pyproject.toml → import fails fast +# +# Usage: +# scripts/dist-smoke-test.sh +# +# Requires on PATH (apt-installable on Ubuntu): caddy, curl, python3, +# python3-venv, npm, node. release.yml's dist-smoke-test job apt-installs +# the first set; npm/node come from the GitHub Actions runner image. +# +# Running locally without cutting a release: build the wheel from your +# current branch, then run the script directly (the script itself makes +# zero release.yml assumptions). On a stock NixOS/devenv host caddy is +# already on PATH: +# devenv shell -- bash scripts/flutterbuildweb.sh # produce the frontend +# devenv shell -- bash scripts/build_wheel.sh # produce the wheel +# bash scripts/dist-smoke-test.sh src/klangk/dist/klangk-*.whl +# Or via CI without tagging: `gh workflow run release.yml` from your branch +# (release.yml's workflow_dispatch runs build-wheel + dist-smoke-test only; +# publish-and-release is gated on `github.event_name == 'push'`). +set -euo pipefail + +PORT="${KLANGK_PORT:-18997}" +EGRESS_PORT="${KLANGK_EGRESS_PORT:-18995}" +VENV_DIR="${SMOKE_VENV:-/tmp/klangk-smoke-venv}" +DATA_DIR="${KLANGK_DATA_DIR:-/tmp/klangk-smoke-data}" +STATE_DIR="${KLANGK_STATE_DIR:-/tmp/klangk-smoke-state}" + +WHEEL="${1:-}" +if [ -z "$WHEEL" ]; then + echo "usage: $0 " >&2 + exit 2 +fi +if [ ! -f "$WHEEL" ]; then + echo "error: wheel not found at $WHEEL" >&2 + exit 2 +fi + +# Locate the repo root from the script's location (the wheel path may be +# relative to the caller's CWD, which we resolve before cd-ing anywhere). +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WHEEL="$(cd "$(dirname "$WHEEL")" && pwd)/$(basename "$WHEEL")" + +echo "=== dist-smoke-test (no devenv) ===" +echo " wheel: $WHEEL" +echo " port: $PORT" +echo " venv: $VENV_DIR" +echo " data_dir: $DATA_DIR" +echo " state_dir: $STATE_DIR" +echo " caddy: $(command -v caddy || echo '(NOT FOUND — apt install caddy')" +echo + +# 1. Fresh isolated venv + install the wheel (with deps — this is the real +# "pip install klangk" exercise; if a transitive dep is missing from +# pyproject.toml the install fails here, not at first import). +echo "=== creating isolated venv ===" +rm -rf "$VENV_DIR" "$DATA_DIR" "$STATE_DIR" +mkdir -p "$DATA_DIR" "$STATE_DIR" +python3 -m venv "$VENV_DIR" +"$VENV_DIR/bin/pip" install --quiet --upgrade pip +echo "=== installing wheel into venv (this is the 'pip install klangk' test) ===" +"$VENV_DIR/bin/pip" install --quiet "$WHEEL" + +# Sanity: klangk imports end-to-end. `klangkd --help` is NOT enough — Typer +# handles --help and exits before main() runs, and build_app is imported +# lazily inside main(), so --help proves only the entry-point registration, +# not that the wheel's deps are complete. Importing build_app directly +# surfaces a ModuleNotFoundError with a real traceback if a transitive dep +# is missing from pyproject.toml. (#1705 review, I4.) +if ! "$VENV_DIR/bin/python" -c 'from klangk.main import build_app' >/dev/null 2>&1; then + echo "error: klangk imports fail from the installed wheel — missing dep?" >&2 + "$VENV_DIR/bin/python" -c 'from klangk.main import build_app' + exit 1 +fi + +# 2. Start klangkd from the venv. trap ensures cleanup on any exit path. +KLANGKD_PID="" +cleanup() { + if [ -n "$KLANGKD_PID" ] && kill -0 "$KLANGKD_PID" 2>/dev/null; then + echo "=== shutting down klangkd (pid $KLANGKD_PID) ===" + kill "$KLANGKD_PID" 2>/dev/null || true + wait "$KLANGKD_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +echo "=== starting klangkd from installed wheel ===" +# Min env: password auth (so the login form is the rendered route), test +# mode (skips the workspace-image presence check), no logfire, no banner. +# --config=none opts out of the config file (post-#1607 / #1645 first-run +# generation) so the server runs from env + defaults alone. +# +# UNSET KLANGK_FRONTEND_DIR — if a parent shell (e.g. a dev's devenv shell) +# exports it pointing at src/frontend/build/web, klangkd would serve the dev +# tree instead of the wheel-bundled frontend, defeating the smoke. Empty +# string does NOT work — pydantic-settings accepts the empty string literally +# and Path("").exists() is False, so the UI mount is skipped with a warning. +# `env -u` actually removes the var so settings falls back to +# _DEFAULT_FRONTEND_DIR = /klangk/frontend — the wheel's copy. +# (#1705 review, B1.) +# +# Redirect stdio to $STATE_DIR/server.log so a failure artifact contains +# klangkd's output (a backgrounded process's inherited stdio would otherwise +# land only in the GH Actions step log). Mirrors the e2e harness's +# global-setup.ts openSync(logFd) pattern. (#1705 review, I3.) +LOG_PATH="$STATE_DIR/server.log" +env -u KLANGK_FRONTEND_DIR \ + KLANGK_PORT="$PORT" \ + KLANGK_EGRESS_PORT="$EGRESS_PORT" \ + KLANGK_DATA_DIR="$DATA_DIR" \ + KLANGK_STATE_DIR="$STATE_DIR" \ + KLANGK_AUTH_MODES=password \ + KLANGK_DEFAULT_USER=admin@example.com \ + KLANGK_DEFAULT_PASSWORD=admin \ + KLANGK_JWT_SECRET=smoke-test-secret \ + KLANGK_TEST_MODE=1 \ + LOGFIRE_TOKEN='' \ + "$VENV_DIR/bin/klangkd" --config=none >"$LOG_PATH" 2>&1 & +KLANGKD_PID=$! +echo "klangkd pid=$KLANGKD_PID, log=$LOG_PATH" + +# 3. Poll /health. Bail early if klangkd dies during startup, and dump the +# log tail so the actual error reaches the step output (not just the +# uploaded artifact). +echo "=== polling http://127.0.0.1:$PORT/health ===" +READY=0 +for i in $(seq 1 120); do + if ! kill -0 "$KLANGKD_PID" 2>/dev/null; then + echo "error: klangkd exited during startup (after ${i}s)" >&2 + echo "--- last 50 lines of $LOG_PATH ---" >&2 + tail -n 50 "$LOG_PATH" >&2 2>/dev/null || true + exit 1 + fi + if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then + echo "klangkd ready after ${i}s" + READY=1 + break + fi + sleep 1 +done +if [ "$READY" -ne 1 ]; then + echo "error: klangkd not ready at http://127.0.0.1:$PORT/health after 120s" >&2 + echo "--- last 50 lines of $LOG_PATH ---" >&2 + tail -n 50 "$LOG_PATH" >&2 2>/dev/null || true + exit 1 +fi + +# 4. Install the e2e deps + Playwright's own Chromium (no devenv-bundled +# browser — `npx playwright install chromium` fetches the one pinned by +# @playwright/test 1.59.1 in package.json). KLANGK_TEST_URL makes +# global-setup short-circuit its own server startup (it just polls +# /health on this URL and returns). +echo "=== installing playwright deps + chromium ===" +cd "$REPO_ROOT/src/frontend/e2e-tests" +npm install --silent +npx playwright install --with-deps chromium + +echo "=== running dist-smoke spec ===" +KLANGK_TEST_URL="http://127.0.0.1:$PORT" \ + npx playwright test --project=dist-smoke --reporter=list + +# trap handles klangkd teardown. diff --git a/src/frontend/e2e-tests/e2e/dist-smoke.spec.ts b/src/frontend/e2e-tests/e2e/dist-smoke.spec.ts new file mode 100644 index 00000000..a526ee7d --- /dev/null +++ b/src/frontend/e2e-tests/e2e/dist-smoke.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "@playwright/test"; + +// Dist smoke test (#1611) — runs only in release.yml's `dist-smoke-test` +// job against a klangkd started from an installed wheel (not editable, not +// the devenv source tree). Proves exactly one thing: the frontend assets +// shipped in the wheel are complete enough for the Flutter app to boot and +// render the login page through the real nginx → UDS → uvicorn path. +// +// Catches, before images are pushed + the GitHub release is created: +// - Frontend not included in wheel (404 / blank page) +// - _DEFAULT_FRONTEND_DIR resolves wrong post-install (same) +// - Flutter build broken / incomplete (flutter-view never attaches) +// - main.dart.js missing or corrupt (engine doesn't boot) +// - klangkd entrypoint registration broken (server never starts — caught +// by the workflow's /health poll before Playwright runs) +// - nginx not found or config render broken (nginx refuses to start) +// - UDS proxy_pass misconfigured (nginx 502s) +// - location / missing in full template (static files not served) +// +// Deliberately minimal: one test, one browser (chromium), no app-level +// interactions. The full e2e suite (frontend-e2e-tests.yml) covers +// cross-browser + workspace lifecycle; this is the release-gate smoke. + +test.describe("Dist smoke (installed wheel)", () => { + test("login page renders through nginx → UDS → uvicorn", async ({ page }) => { + await page.goto("/"); + + // Flutter Web shows "Loading, please wait" until main.dart.js has + // booted the engine. Wait for that text to clear. + await page.waitForFunction( + () => !document.body.textContent?.includes("Loading, please wait"), + { timeout: 90_000 }, + ); + + // The engine attaches once the first frame is ready. + // Mirrors the e2e suite's waitForFlutter() helper. + await page.waitForSelector("flutter-view", { timeout: 10_000 }); + + // The page title is "Login" only when the auth form is the rendered + // route — so a non-Login title here means either the Flutter app + // didn't route correctly or the auth config endpoint is broken. + await expect(page).toHaveTitle(/Login/i); + }); +}); diff --git a/src/frontend/e2e-tests/playwright.config.ts b/src/frontend/e2e-tests/playwright.config.ts index d523e329..bf8ea218 100644 --- a/src/frontend/e2e-tests/playwright.config.ts +++ b/src/frontend/e2e-tests/playwright.config.ts @@ -133,5 +133,16 @@ export default defineConfig({ ], use: chromiumUse, }, + { + // Dist smoke test (#1611) — release.yml's dist-smoke-test job runs + // ONLY this project, against a klangkd started from an installed + // wheel (KLANGK_TEST_URL points Playwright at it; global-setup + // short-circuits its own server startup in that mode). One test, + // one browser: proves the frontend shipped in the wheel boots and + // renders the login page through nginx → UDS → uvicorn. + name: "dist-smoke", + testMatch: ["dist-smoke.spec.ts"], + use: chromiumUse, + }, ], });