From 7f59f98b8dd9d730b80622e5430a615ca8bf0159 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 20 Jul 2026 19:51:28 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(1611):=20release=20smoke=20test=20?= =?UTF-8?q?=E2=80=94=20Playwright=20verifies=20login=20page=20from=20insta?= =?UTF-8?q?lled=20wheel=20(#1611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dist-smoke-test job to release.yml that gates build-and-release (image push + GH release creation). Downloads the wheel built by build-wheel, installs it into an isolated venv (not editable — catches packaging bugs an editable install would hide), starts the real klangkd entrypoint (uvicorn on UDS + nginx child), polls /health, and runs one minimal Playwright spec against it. If this fails, the release stops before images are pushed. What this catches before users see a broken release: - 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 - 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 Pieces: - scripts/dist-smoke-test.sh — the runner. Installs the wheel into an isolated venv, starts klangkd --config=none with password-mode env vars (so the login form is the rendered route), polls /health, runs `npx playwright test --project=dist-smoke`, traps klangkd teardown on exit. Shellcheck clean. - src/frontend/e2e-tests/e2e/dist-smoke.spec.ts — the one-test spec. Navigates /, waits for the "Loading, please wait" text to clear (Flutter boot), waits for (engine hydrated), asserts page title is /Login/i. Mirrors the existing e2e helpers' canonical login-page check. - src/frontend/e2e-tests/playwright.config.ts — new `dist-smoke` project matching only dist-smoke.spec.ts on chromium. Other projects untouched. - .github/workflows/release.yml — new dist-smoke-test job (needs: build-wheel) that downloads the wheel artifact and runs scripts/dist-smoke-test.sh inside devenv shell (devenv provides nginx on PATH + the nix-bundled Playwright Chromium via PLAYWRIGHT_BROWSERS_PATH). build-and-release now needs: dist-smoke-test. Dependency chain: build-wheel → dist-smoke-test → build-and-release PyPI publish (inside build-wheel) stays parallel with the smoke test (pip/uv users get the wheel immediately; if the smoke fails we yank). The smoke job reuses the existing global-setup/global-teardown KLANGK_TEST_URL short-circuit (they were already designed for "external server" mode) — no harness changes needed. Verified locally: YAML parses; devenv-setup action correctly skips the Build step for build-tasks: ''; `npx playwright test --project=dist-smoke --list` matches exactly the one new test (no other projects disturbed); shellcheck clean on the runner script. --- .github/workflows/release.yml | 48 ++++++ scripts/dist-smoke-test.sh | 139 ++++++++++++++++++ src/frontend/e2e-tests/e2e/dist-smoke.spec.ts | 44 ++++++ src/frontend/e2e-tests/playwright.config.ts | 11 ++ 4 files changed, 242 insertions(+) create mode 100755 scripts/dist-smoke-test.sh create mode 100644 src/frontend/e2e-tests/e2e/dist-smoke.spec.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c9d2994..33a4157f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,57 @@ concurrency: cancel-in-progress: false jobs: + # Smoke test the installed wheel before pushing images or cutting the + # GitHub Release (#1611). Builds nothing of its own — downloads the wheel + # artifact from build-wheel, installs it into an isolated venv (not + # editable — catches packaging bugs an editable install would hide), + # starts the real klangkd entrypoint (uvicorn on UDS + nginx child), and + # runs the one-file dist-smoke Playwright spec against it. If this fails, + # build-and-release (image push + GH release) is skipped — broken + # frontend packaging / nginx config / UDS proxy is caught before users do. + dist-smoke-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: build-wheel + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/devenv-setup + with: + # No build-tasks — the wheel is pre-built by build-wheel and + # downloaded below. We only need devenv for: nginx on PATH + # (klangkd forks it as a child), python3 (for the venv), and + # the nix-bundled Playwright Chromium (PLAYWRIGHT_BROWSERS_PATH + # is set by devenv.nix). + build-tasks: "" + + - name: Download wheel artifact + uses: actions/download-artifact@v7 + with: + name: klangk-wheel + path: wheel-cache + + - name: Run dist-smoke test + run: | + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + devenv shell -- bash scripts/dist-smoke-test.sh "$(echo wheel-cache/*.whl)" + + - name: Upload server 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/ + build-and-release: runs-on: ubuntu-latest timeout-minutes: 45 + needs: dist-smoke-test permissions: packages: write contents: write diff --git a/scripts/dist-smoke-test.sh b/scripts/dist-smoke-test.sh new file mode 100755 index 00000000..f577e047 --- /dev/null +++ b/scripts/dist-smoke-test.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Run the dist-smoke Playwright spec against a klangkd started from an +# installed wheel (#1611). +# +# 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. Starts the real klangkd entrypoint (uvicorn on UDS + nginx child) +# with KLANGK_PORT=18997 so nginx renders the full browser listener. +# 3. Polls /health on http://127.0.0.1:18997 until nginx + uvicorn are +# ready. +# 4. Runs `npx playwright test --project=dist-smoke` against that URL. +# 5. Tears klangkd down on exit (trap). +# +# What this catches (before images are pushed + the GitHub release is cut): +# - 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 +# - 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 +# +# Usage: +# scripts/dist-smoke-test.sh +# +# Requires: nginx on PATH (klangkd renders the config + forks nginx as a +# child). In CI this comes from devenv; locally, run inside `devenv shell`. +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 ===" +echo " wheel: $WHEEL" +echo " port: $PORT" +echo " venv: $VENV_DIR" +echo " data_dir: $DATA_DIR" +echo " state_dir: $STATE_DIR" +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: the entry point shipped in the wheel is on the venv's PATH. +if ! "$VENV_DIR/bin/klangkd" --help >/dev/null 2>&1; then + echo "error: klangkd entry point missing or broken in the wheel" >&2 + 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. +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 & +KLANGKD_PID=$! + +# 3. Poll /health. Bail early if klangkd dies during startup. +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 + 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 + exit 1 +fi + +# 4. Install the e2e deps + run the smoke spec. 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 ===" +cd "$REPO_ROOT/src/frontend/e2e-tests" +npm install --silent + +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, + }, ], }); From dbce6a85c7560b089c1b26cdfb304c343effa5cc Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 21 Jul 2026 11:25:51 -0400 Subject: [PATCH 2/4] =?UTF-8?q?test(1611):=20apply=20#1705=20review=20?= =?UTF-8?q?=E2=80=94=20wheel-bundled=20frontend,=20serial=20release=20topo?= =?UTF-8?q?logy=20(#1705=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from the adversarial review of #1705: B1 (blocker) — KLANGK_FRONTEND_DIR was leaking from devenv shell into the klangkd child, defeating the gate: in CI (no Flutter build) it would make the smoke fail on first run; locally (with a dev Flutter build) it would make the smoke pass while testing the dev tree, not the wheel. Use `env -u KLANGK_FRONTEND_DIR` to strip the inherited var so settings.py falls back to _DEFAULT_FRONTEND_DIR = /klangk/frontend (the wheel-bundled copy). Empty string does NOT work — pydantic-settings accepts it literally and Path("").exists() is False, so the UI mount is skipped with a warning; unsetting is what triggers the fall-back. I1 — Topology changed: make PyPI publish wait on the smoke test. Pre-PR build-wheel published in parallel with the image build; that put a broken wheel on PyPI before the smoke caught it and required a yank. Restructured to build-wheel (no publish) → dist-smoke-test → publish-and-release, where publish-and-release publishes PyPI + pushes images + cuts the GH release, all gated by the smoke. No yank path exists, by design — broken wheels never reach the index. I2 — Acknowledged in publish-and-release's job comment: the host-image build (slow step, ~25-30 min) runs in publish-and-release, not parallel with wheel+smoke. Splitting it into a separate job would require docker save/load of the multi-GB host image (which embeds the workspace tarball) between runners — roughly the time the parallelism would save. Tag-push-only frequency makes the serial SLO acceptable. I3 — Redirect klangkd's stdio to $STATE_DIR/server.log (was inherited stdio → only landed in the GH Actions step log, not the failure artifact). Mirrors global-setup.ts's openSync(logFd) pattern. Poll-loop failure branches now `tail -n 50` the log before exiting so the actual startup error is in the step output, not just the artifact. I4 — Replaced `klangkd --help` sanity check with `python -c 'from klangk.main import build_app'`. Typer handles --help and exits before main(), and build_app is imported lazily inside main(), so --help proved only the entry-point registration. The import check surfaces a ModuleNotFoundError with a real traceback if a transitive dep is missing from pyproject.toml. Verified: YAML valid; topology build-wheel → dist-smoke-test → publish-and-release; dist-smoke project still matches exactly one test; shellcheck clean. --- .github/workflows/release.yml | 164 +++++++++++++++++++--------------- scripts/dist-smoke-test.sh | 41 +++++++-- 2 files changed, 129 insertions(+), 76 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33a4157f..7dab16a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,14 +10,64 @@ concurrency: cancel-in-progress: false jobs: - # Smoke test the installed wheel before pushing images or cutting the - # GitHub Release (#1611). Builds nothing of its own — downloads the wheel - # artifact from build-wheel, installs it into an isolated venv (not - # editable — catches packaging bugs an editable install would hide), - # starts the real klangkd entrypoint (uvicorn on UDS + nginx child), and - # runs the one-file dist-smoke Playwright spec against it. If this fails, - # build-and-release (image push + GH release) is skipped — broken - # frontend packaging / nginx config / UDS proxy is caught before users do. + # 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 before anything publishes (#1611). + # Downloads the wheel artifact from build-wheel, installs it into an + # isolated venv (not editable — catches packaging bugs an editable install + # would hide), starts the real klangkd entrypoint (uvicorn on UDS + nginx + # child) with KLANGK_FRONTEND_DIR cleared so it serves the wheel-bundled + # frontend (not the dev tree — without this the gate would be defeated; + # see scripts/dist-smoke-test.sh), and runs the one-file dist-smoke + # Playwright spec against it. If this fails, publish-and-release (PyPI + + # images + GH release) is skipped — broken frontend packaging / nginx + # config / UDS proxy / missing transitive dep is caught before any + # artifact reaches a user. No yank ever needed. dist-smoke-test: runs-on: ubuntu-latest timeout-minutes: 15 @@ -45,7 +95,7 @@ jobs: . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh devenv shell -- bash scripts/dist-smoke-test.sh "$(echo wheel-cache/*.whl)" - - name: Upload server logs on failure + - name: Upload smoke logs on failure if: failure() uses: actions/upload-artifact@v7 with: @@ -57,23 +107,56 @@ jobs: src/frontend/e2e-tests/test-results/ src/frontend/e2e-tests/logs/ - build-and-release: + # 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 + 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 @@ -124,64 +207,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 index f577e047..34f2a448 100755 --- a/scripts/dist-smoke-test.sh +++ b/scripts/dist-smoke-test.sh @@ -70,9 +70,16 @@ python3 -m venv "$VENV_DIR" echo "=== installing wheel into venv (this is the 'pip install klangk' test) ===" "$VENV_DIR/bin/pip" install --quiet "$WHEEL" -# Sanity: the entry point shipped in the wheel is on the venv's PATH. -if ! "$VENV_DIR/bin/klangkd" --help >/dev/null 2>&1; then - echo "error: klangkd entry point missing or broken in the wheel" >&2 +# Sanity: the entry point shipped in the wheel is importable 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 @@ -92,7 +99,26 @@ echo "=== starting klangkd from installed wheel ===" # 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. -KLANGK_PORT="$PORT" \ +# +# UNSET KLANGK_FRONTEND_DIR — devenv.nix exports it pointing at the repo's +# src/frontend/build/web (the dev tree), which would leak through +# `devenv shell` and make klangkd serve the dev frontend instead of the +# one bundled in the installed wheel. That defeats the entire smoke test: +# a wheel with no frontend would pass as long as the dev tree has a build. +# 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. Unsetting the variable makes pydantic fall back to +# _DEFAULT_FRONTEND_DIR = /klangk/frontend — the wheel's +# bundled copy. (#1705 review, B1.) +# +# Redirect stdio to $STATE_DIR/server.log so the GH Actions failure artifact +# (release.yml uploads /tmp/klangk-smoke-state/) captures klangkd's output — +# a backgrounded process's inherited stdio would otherwise land only in the +# step log, not the uploaded artifact. 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" \ @@ -102,8 +128,9 @@ KLANGK_PORT="$PORT" \ KLANGK_JWT_SECRET=smoke-test-secret \ KLANGK_TEST_MODE=1 \ LOGFIRE_TOKEN='' \ - "$VENV_DIR/bin/klangkd" --config=none & + "$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. echo "=== polling http://127.0.0.1:$PORT/health ===" @@ -111,6 +138,8 @@ 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 @@ -122,6 +151,8 @@ for i in $(seq 1 120); do 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 From cfd54c2d4a3b0175c3a9af727406e94082a7df65 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 21 Jul 2026 11:33:48 -0400 Subject: [PATCH 3/4] feat(1611): run dist-smoke against stock Ubuntu, not devenv (#1611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole point of the dist-smoke test is to prove a bare `pip install klangk && klangkd` works in the environment the #1607 / #1645 first-run audience actually uses — a stock OS with caddy + Python, no devenv/nix. The previous version ran the smoke inside `devenv shell`, which: - bundled caddy, Python, AND Playwright Chromium from nix (defeating the "stock Ubuntu" claim); - leaked KLANGK_FRONTEND_DIR from devenv.nix, which the runner had to strip with `env -u` to avoid serving the dev tree's frontend instead of the wheel's. Restructured: - `.github/workflows/release.yml` — dist-smoke-test drops its `.github/actions/devenv-setup` step. apt-get installs caddy + the Playwright runtime libs + python3-venv directly from the Ubuntu archive; npm/node come from the GitHub Actions runner image; nothing from nix. publish-and-release still uses devenv (for build-host-image + podman); build-wheel still uses devenv (for flutterbuildweb via the build hook). Only the smoke step is devenv-free. - `scripts/dist-smoke-test.sh` — no longer assumes it runs inside `devenv shell`. Adds `npx playwright install --with-deps chromium` so Playwright fetches its own pinned Chromium (was relying on PLAYWRIGHT_BROWSERS_PATH from devenv.nix). Documents the stock-Ubuntu PATH requirements (caddy, curl, python3, python3-venv, npm, node) in the script header. Keeps the prior review fixes (#1705 review): - env -u KLANGK_FRONTEND_DIR (B1) — still needed even outside devenv in case a dev runs the script locally from a devenv shell. - python -c 'from klangk.main import build_app' sanity check (I4). - $STATE_DIR/server.log stdio redirect + log-tail on poll failure (I3). - Serial topology build-wheel → dist-smoke-test → publish-and-release (I1 + I2): PyPI publish, image push, and GH release all gate on the smoke passing — no yank path exists by design. Verified: shellcheck clean; YAML valid; dist-smoke-test job has zero devenv/nix references in its steps; dist-smoke project still matches exactly one test. --- .github/workflows/release.yml | 56 ++++++++++++--------- scripts/dist-smoke-test.sh | 93 +++++++++++++++++++++-------------- 2 files changed, 89 insertions(+), 60 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dab16a9..aaea7ef1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,17 +57,26 @@ jobs: path: src/klangk/dist/*.whl retention-days: 30 - # Smoke test the installed wheel before anything publishes (#1611). - # Downloads the wheel artifact from build-wheel, installs it into an - # isolated venv (not editable — catches packaging bugs an editable install - # would hide), starts the real klangkd entrypoint (uvicorn on UDS + nginx - # child) with KLANGK_FRONTEND_DIR cleared so it serves the wheel-bundled - # frontend (not the dev tree — without this the gate would be defeated; - # see scripts/dist-smoke-test.sh), and runs the one-file dist-smoke - # Playwright spec against it. If this fails, publish-and-release (PyPI + - # images + GH release) is skipped — broken frontend packaging / nginx - # config / UDS proxy / missing transitive dep is caught before any - # artifact reaches a user. No yank ever needed. + # 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 @@ -75,14 +84,19 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: ./.github/actions/devenv-setup - with: - # No build-tasks — the wheel is pre-built by build-wheel and - # downloaded below. We only need devenv for: nginx on PATH - # (klangkd forks it as a child), python3 (for the venv), and - # the nix-bundled Playwright Chromium (PLAYWRIGHT_BROWSERS_PATH - # is set by devenv.nix). - build-tasks: "" + - 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 @@ -91,9 +105,7 @@ jobs: path: wheel-cache - name: Run dist-smoke test - run: | - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - devenv shell -- bash scripts/dist-smoke-test.sh "$(echo wheel-cache/*.whl)" + run: bash scripts/dist-smoke-test.sh "$(echo wheel-cache/*.whl)" - name: Upload smoke logs on failure if: failure() diff --git a/scripts/dist-smoke-test.sh b/scripts/dist-smoke-test.sh index 34f2a448..d215b7d5 100755 --- a/scripts/dist-smoke-test.sh +++ b/scripts/dist-smoke-test.sh @@ -1,32 +1,47 @@ #!/usr/bin/env bash # Run the dist-smoke Playwright spec against a klangkd started from an -# installed wheel (#1611). +# 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. Starts the real klangkd entrypoint (uvicorn on UDS + nginx child) -# with KLANGK_PORT=18997 so nginx renders the full browser listener. -# 3. Polls /health on http://127.0.0.1:18997 until nginx + uvicorn are -# ready. -# 4. Runs `npx playwright test --project=dist-smoke` against that URL. -# 5. Tears klangkd down on exit (trap). +# 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 images are pushed + the GitHub release is cut): +# 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 -# - 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 +# - 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: nginx on PATH (klangkd renders the config + forks nginx as a -# child). In CI this comes from devenv; locally, run inside `devenv shell`. +# 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. set -euo pipefail PORT="${KLANGK_PORT:-18997}" @@ -51,12 +66,13 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" WHEEL="$(cd "$(dirname "$WHEEL")" && pwd)/$(basename "$WHEEL")" -echo "=== dist-smoke-test ===" +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 @@ -70,13 +86,12 @@ python3 -m venv "$VENV_DIR" echo "=== installing wheel into venv (this is the 'pip install klangk' test) ===" "$VENV_DIR/bin/pip" install --quiet "$WHEEL" -# Sanity: the entry point shipped in the wheel is importable 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.) +# 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' @@ -100,21 +115,18 @@ echo "=== starting klangkd from installed wheel ===" # --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 — devenv.nix exports it pointing at the repo's -# src/frontend/build/web (the dev tree), which would leak through -# `devenv shell` and make klangkd serve the dev frontend instead of the -# one bundled in the installed wheel. That defeats the entire smoke test: -# a wheel with no frontend would pass as long as the dev tree has a build. -# 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. Unsetting the variable makes pydantic fall back to -# _DEFAULT_FRONTEND_DIR = /klangk/frontend — the wheel's -# bundled copy. (#1705 review, B1.) +# 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 the GH Actions failure artifact -# (release.yml uploads /tmp/klangk-smoke-state/) captures klangkd's output — -# a backgrounded process's inherited stdio would otherwise land only in the -# step log, not the uploaded artifact. Mirrors the e2e harness's +# 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 \ @@ -132,7 +144,9 @@ env -u KLANGK_FRONTEND_DIR \ KLANGKD_PID=$! echo "klangkd pid=$KLANGKD_PID, log=$LOG_PATH" -# 3. Poll /health. Bail early if klangkd dies during startup. +# 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 @@ -156,12 +170,15 @@ if [ "$READY" -ne 1 ]; then exit 1 fi -# 4. Install the e2e deps + run the smoke spec. KLANGK_TEST_URL makes +# 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 ===" +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" \ From 0561e85ad59c309c69f9dfb8fa9065c2b089fc02 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Tue, 21 Jul 2026 11:41:00 -0400 Subject: [PATCH 4/4] feat(1611): workflow_dispatch runs dist-smoke without publishing (#1611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add workflow_dispatch to release.yml so a maintainer can run the dist-smoke gate against the current branch without cutting a release. The chain build-wheel → dist-smoke-test runs on any dispatch; publish-and-release is gated on `github.event_name == 'push'` so a manual run stops at the smoke gate and never touches PyPI / GHCR / GitHub Release. Use case: validate a release candidate before tagging. Run `gh workflow run release.yml` from the release branch, watch the smoke pass, then tag. Also documents the fully-local option in scripts/dist-smoke-test.sh's header — build the wheel with scripts/build_wheel.sh + run the script directly. No CI round-trip needed. --- .github/workflows/release.yml | 13 +++++++++++++ scripts/dist-smoke-test.sh | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aaea7ef1..23d32ba8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,13 @@ 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 @@ -134,6 +141,12 @@ jobs: 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 diff --git a/scripts/dist-smoke-test.sh b/scripts/dist-smoke-test.sh index d215b7d5..58b546f0 100755 --- a/scripts/dist-smoke-test.sh +++ b/scripts/dist-smoke-test.sh @@ -42,6 +42,17 @@ # 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}"