Skip to content

Add CI + production deploy + BYOK embedder - #18

Merged
thorwhalen merged 4 commits into
mainfrom
claude/ci-deploy-byok
Jul 30, 2026
Merged

Add CI + production deploy + BYOK embedder#18
thorwhalen merged 4 commits into
mainfrom
claude/ci-deploy-byok

Conversation

@thorwhalen

@thorwhalen thorwhalen commented May 23, 2026

Copy link
Copy Markdown
Owner

Bundled deliverable: app_ef gets a CI pipeline, a production deploy to
apps.thorwhalen.com/app_ef/ via the tw_platform / enlace stack, and a
bring-your-own-key embedder so the deployed backend holds no OpenAI key.

Per the plan agreed with the user. Independent of #17 (orthogonal; both branch from main).

What ships

BYOK embedder

  • EfService.create_corpus's new embedder_api_key (in ef@0.1.17, already merged + published) is mapped onto the X-OpenAI-Key request header via qh's param_overrides. Header-mapped params are excluded from the OpenAPI request body — the TS body type does not carry the key.
  • frontend/src/api/openaiKey.ts is the SSOT for the localStorage key + header name; client.ts sends the header on createCorpus when a key is stored. search/retrieve/explore reuse the corpus's bound embedder and need no header.
  • Degrades cleanly: no header + no server env → hashing fallback (lexical search), works offline. Whole BYOK contract documented in the backend module docstring + README.

CI pipeline (.github/workflows/ci.yml)

  • frontend — pnpm install, typecheck (app + e2e), Playwright e2e, production build with VITE_PUBLIC_BASE=/app_ef/ + VITE_API_BASE=/api/app_ef.
  • backendpip install -e ./backend[test] (pulls ef[openai]>=0.1.17 + qh>=0.0.14 from PyPI), pytest.
  • deploy — on push to main, triggers tw_platform's deploy.yml (gh workflow run -f app=app_ef). deploy.py stays the single source of deploy truth.

Deployment wiring

  • New root server.py — thin enlace entry shim (puts backend/ on sys.path, re-exports app.main:app).
  • New app.tomlaccess="protected:shared" + shared_password_env="APP_EF_SHARED_PASSWORD", [build] runs pnpm in frontend/, frontend_dir=frontend/dist, [python].packages = [ef[openai]>=0.1.17, qh>=0.0.14].
  • vite.config.tsbase: VITE_PUBLIC_BASE ?? '/', explicit outDir: 'dist'.
  • @zodal/* deps move from local link: to npm version pins (^0.1.2 / ^0.1.0 — published versions are current vs local source). Build is now self-contained: no sibling checkouts in CI or on the deploy runner. Mirrors how acture is already consumed. Dropped deps:build/prebuild/predev scripts + the vite server.fs.allow widening.
  • Renamed VITE_API_BASE_URLVITE_API_BASE (the tw_platform convention).

Fixes since first CI run

  • .gitignore had unscoped lib/ and lib64/ (Python build-artifact ignores from the wads template) which silently swallowed frontend/src/lib/. As a result frontend/src/lib/utils.ts (the standard shadcn cn() helper) was never tracked, and the first CI run failed with TS2307: Cannot find module '@/lib/utils' across nine components. Scoped both ignores to the repo root (/lib/, /lib64/) and tracked the file. Local typecheck passed before this fix because the file was on disk.

Tests

  • New backend tests: BYOK header threading, keyless fallback, embedder_api_key absent from the request schema.
  • All existing tests stay green (verified locally: backend 14/14, frontend typecheck + typecheck:e2e + Playwright 8/8 + production build; CI now green on this PR too).

Prerequisites — must be in place before merging

  1. tw_platform PR #20 (your draft 13-ci-deploy-workflow) — the workflow_dispatch deploy workflow. Must merge to tw_platform main first.
  2. tw_platform PR #21 (registering app_ef) — stacked on #20; auto-retargets to main once #20 merges, then merge it.
  3. TW_PLATFORM_DEPLOY_TOKEN secret in this repo — fine-grained PAT (or GitHub App token) with Actions: write on thorwhalen/tw_platform.
  4. Deploy GitHub App granted contents: read on thorwhalen/app_ef (so tw_platform's deploy.yml can check it out).
  5. APP_EF_SHARED_PASSWORD server env var — set the argon2id hash of the chosen shared password before the first real deploy. Generate with python -m enlace_auth hash-password on any machine with enlace_auth installed; copy the printed hash (starts with $argon2id$...) into the server's environment (systemd Environment= line or wherever tw_platform sources app secrets). Without this, the login form fails closed.

Done already (no action needed)

  • ef@0.1.17 — published to PyPI, carries embedder_api_key.
  • qh@0.0.14 — already on PyPI with the header param_override support.
  • Access mode decided: protected:shared (no per-user emails committed to this public repo).

Gives app_ef a CI pipeline that validates every change, ships the app to
apps.thorwhalen.com via the tw_platform / enlace stack on each push to
main, and threads a per-request OpenAI key through to ef's embedder so
the deployed backend holds no key of its own.

# BYOK embedder
* backend/app/main.py: map create_corpus's embedder_api_key parameter to
  the X-OpenAI-Key request header via qh's param_overrides. Header-mapped
  params are excluded from the OpenAPI request body, so the generated TS
  body type does not carry the key.
* frontend/src/api/openaiKey.ts: SSOT for the localStorage key + the
  request-header name; assistant/engine.ts and api/client.ts both import it.
* frontend/src/api/client.ts: createCorpus sends X-OpenAI-Key when a key
  is stored in localStorage; search/retrieve/explore reuse the corpus's
  bound embedder and need no header.
* The flow degrades cleanly: no key + no server env → hashing fallback
  (lexical search), still works offline.
* Pins ef[openai]>=0.1.17 (which carries embedder_api_key) and qh>=0.0.14
  (which carries the HEADER param_override) in backend/pyproject.toml.
* openapi.json + openapi.d.ts regenerated; embedder_api_key is absent
  from the request schema (only present in the human description).

# CI pipeline (.github/workflows/ci.yml)
* frontend job — checkout, pnpm install, typecheck (app + e2e), Playwright
  e2e, production build with VITE_PUBLIC_BASE/VITE_API_BASE matching
  production.
* backend job — pip install -e ./backend[test] (pulls ef + qh from PyPI),
  pytest.
* deploy job — on push to main, triggers tw_platform's deploy.yml via
  `gh workflow run -f app=app_ef`. deploy.py stays the single source of
  deploy truth; this job only triggers it. Needs TW_PLATFORM_DEPLOY_TOKEN
  (Actions: write on tw_platform).

# Deployment wiring
* server.py — thin enlace entry shim at the repo root, puts backend/ on
  sys.path and re-exports app.main:app.
* app.toml — enlace app config: access=protected:user (allowed_users TBD;
  empty by default so the app fails closed until populated), build runs
  pnpm in frontend/, frontend_dir=frontend/dist, [python].packages lists
  ef[openai] + qh.
* vite.config.ts — `base: VITE_PUBLIC_BASE ?? '/'`, explicit outDir 'dist'.
* @zodal/* deps move from `link:` to npm version pins (^0.1.2 / ^0.1.0,
  current vs local source), so the build is self-contained — no sibling
  checkouts in CI or on the deploy runner; mirrors how acture is already
  consumed. Removes the deps:build / prebuild / predev scripts and the
  vite server.fs.allow widening.
* Rename VITE_API_BASE_URL → VITE_API_BASE (the tw_platform convention).
  In production deploy.py injects VITE_API_BASE=/api/app_ef and
  VITE_PUBLIC_BASE=/app_ef/.
* README.md + frontend/README.md updated for the new env var, npm-pinned
  zodal, BYOK, and CI/deploy.

# Tests
* backend/tests/test_app.py: BYOK threading (header → embedder_api_key),
  keyless fallback, embedder_api_key absent from the request schema.
* All existing backend + frontend tests stay green.

# Prerequisites (manual)
* Set the TW_PLATFORM_DEPLOY_TOKEN repo secret (Actions: write on
  tw_platform).
* Grant the deploy GitHub App contents:read on app_ef.
* Add the user email(s) to app.toml's allowed_users (or switch access to
  protected:shared) before merging.
* tw_platform PR #21 (registering app_ef) must merge — itself stacked on
  the tw_platform deploy-workflow PR (#13/#20).

Refs the deploy-design discussion (no single issue — multi-repo work).
… root

The wads template's Python .gitignore had unscoped 'lib/' and 'lib64/'
entries, which silently matched any 'lib/' directory in the tree —
including frontend/src/lib/. As a result frontend/src/lib/utils.ts
(the standard shadcn 'cn()' helper) was never tracked: local typecheck
passed because the file was on disk, but the first CI run failed with
TS2307 'Cannot find module @/lib/utils' across nine components.

- Scope the Python ignores to the repo root: lib/ → /lib/, lib64/ → /lib64/.
  The Python build artifacts only ever appear at the top level; nested
  lib/ directories (frontend, future packages) must not be dropped.
- Track frontend/src/lib/utils.ts.

Refs #18.
app_ef is a public repo, so committing per-user email allow-lists is
undesirable. Switch to a shared-password gate:

  access = "protected:shared"
  shared_password_env = "APP_EF_SHARED_PASSWORD"

The env var must hold the argon2id HASH of the password (not plaintext),
generated with 'python -m enlace_auth hash-password'. Set on the deploy
server before the first real deploy; otherwise the login form fails
closed.

Refs #18.
@thorwhalen

Copy link
Copy Markdown
Owner Author

Manual prereqs (GitHub App + repo secrets) tracked in thorwhalen/tw_platform#22. Once the App exists, the agent will set DEPLOY_APP_ID / DEPLOY_APP_PRIVATE_KEY here too (and either edit this PR's ci.yml to use App-minted tokens, or set TW_PLATFORM_DEPLOY_TOKEN from a PAT — see the issue for the recommended path).

@thorwhalen

Copy link
Copy Markdown
Owner Author

Inheriting from tw_platform#22 — remaining app_ef onboarding checklist

tw_platform#22 is being closed (its core deliverable — the GitHub App + DEPLOY_APP_* secrets on tw_platform — is done and verified green in production). The remaining app_ef-specific work is consolidated here.

Outstanding for this PR to be mergeable + functional

  • Resolve conflicts with main — current state is CONFLICTING/DIRTY. Rebase or merge.
  • Register app_ef as a deployable enlace app. PR tw_platform#21 (which would have done this) is closed; needs to be re-done. Either:
    • Add an entry to tw_platform/platform.toml (apps_dirs or app_dirs), or
    • Add it to tw_platform/panapy_platform.toml if it belongs on panapy.com
  • Add app_ef to tw_platform/.github/workflows/deploy.yml's repositories: list (currently tw_platform,papp,reelee-web,elumel — needs ,app_ef)
  • Decide Option A vs B for the deploy-trigger token:
    • Option A (recommended): edit this PR's ci.yml to mint the dispatch token via actions/create-github-app-token@v1 (like deploy.yml does), and set DEPLOY_APP_ID + DEPLOY_APP_PRIVATE_KEY on thorwhalen/app_ef. Requires the GitHub App to be installed on thorwhalen/app_ef (single short-lived token; no PAT to manage).
    • Option B (current PR coding): keep the TW_PLATFORM_DEPLOY_TOKEN approach, create a fine-grained PAT with Actions: write scoped to thorwhalen/tw_platform only, set it as TW_PLATFORM_DEPLOY_TOKEN on thorwhalen/app_ef. Faster, but a long-lived PAT to rotate.
  • Set APP_EF_SHARED_PASSWORD on the deploy server. Agent has ssh+root and can install the argon2id hash via python -m enlace_auth hash-password — just needs either the plaintext (chat) or pre-hashed $argon2id$... string from you.

Verification once the above is in place

  1. Re-run gh workflow run deploy.yml --repo thorwhalen/tw_platform -f platform=thorwhalen -f dry_run=true and confirm it now checks out app_ef alongside the other 4 repos.
  2. Push to app_ef's main after merge to confirm the cross-repo trigger fires.

Refs tw_platform#22 (closed), tw_platform#21 (closed — needs reprise), tw_platform#13 (parent).

Conflict: frontend/src/assistant/engine.ts import block.

main renamed the store helper `appContext` -> `assistantContext` (it marks
the dispatch `origin: 'assistant'`) and kept the OpenAI key location as an
inline `KEY_STORAGE` const. This branch had extracted that constant into
`@/api/openaiKey` as `OPENAI_KEY_STORAGE`, so the assistant engine and the
API client share one source of truth without importing each other.

Resolved by taking both: main's `assistantContext` import and this branch's
`OPENAI_KEY_STORAGE` import. The inline `KEY_STORAGE` is dropped -- the
file body already calls `assistantContext()` and `OPENAI_KEY_STORAGE`, so
this is what the merged code actually references. tsc --noEmit passes.

Claude-Session: https://claude.ai/code/session_01Dq9GZDVCd3oGmEeehN3Cgm
@thorwhalen
thorwhalen merged commit 531ff63 into main Jul 30, 2026
2 checks passed
@thorwhalen
thorwhalen deleted the claude/ci-deploy-byok branch July 30, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant