feat(ci): automate the learn-cli deploy pipeline — merge deploys prod, dispatch previews#14
Conversation
…spec-to-plan) Converged frame + buildable plan for merge-to-main deploys of the whole /learn stack (site + learn-api Worker + D1 schema) with a branch workflow_dispatch preview path (versions upload --env preview, separate preview D1, CI-synced secrets). 5 tasks / 3 waves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
…iew D1, no route) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
Exported spec/plan quote claim text verbatim, which can contain a bare URL (e.g. LIVE_ORIGIN=https://agentculture.org). Consistent with the existing MD033: false relaxation — don't force rewording generated text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
|
/agentic_review |
PR Summary by QodoAutomate learn-api Worker deploys: prod on main, preview on dispatch
AI Description
Diagram
High-Level Assessment
Files changed (14)
|
Code Review by Qodo
Context used✅ Compliance rules (platform):
9 rules 1.
|
| on: | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - 'workers/learn-api/**' | ||
| - '.github/workflows/deploy-worker.yml' | ||
| workflow_dispatch: {} | ||
|
|
||
| jobs: | ||
| deploy: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: read | ||
| concurrency: | ||
| group: worker-deploy-${{ github.ref }} | ||
| cancel-in-progress: true | ||
| # Mapped here so later steps can gate with `if: env.CLOUDFLARE_API_TOKEN | ||
| # != ''` — secrets.* isn't usable directly in an `if:` conditional. | ||
| env: | ||
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| steps: | ||
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | ||
|
|
||
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | ||
| with: | ||
| node-version: '20' | ||
|
|
||
| - name: Skip notice (Cloudflare secrets not configured) | ||
| if: env.CLOUDFLARE_API_TOKEN == '' || env.CLOUDFLARE_ACCOUNT_ID == '' | ||
| run: | | ||
| echo "::notice::CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_ACCOUNT_ID are not configured on this repo yet — skipping the learn-api Worker deploy (both the production and preview paths). This validates the workflow parses and runs cleanly up to that point." | ||
| { | ||
| echo '### Deploy skipped' | ||
| echo '' | ||
| echo 'The Cloudflare secrets (`CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`) are not both set on this repo yet, so the Worker deploy/upload steps were skipped.' | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
|
|
||
| # ----------------------------------------------------------------- | ||
| # PRODUCTION path — push to main, or a workflow_dispatch run whose | ||
| # ref is main. Never runs on any other branch. | ||
| # ----------------------------------------------------------------- | ||
|
|
||
| - name: "[prod] Apply D1 schema (learn-ledger)" | ||
| if: >- | ||
| env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' && | ||
| github.ref == 'refs/heads/main' | ||
| working-directory: workers/learn-api | ||
| run: npx wrangler@4 d1 execute learn-ledger --remote --file schema.sql | ||
|
|
||
| # Secrets are synced BEFORE the deploy below so the deployed Worker | ||
| # version has them from the moment it goes live. Each value is piped | ||
| # over stdin — never a CLI argument — so it never lands in a process | ||
| # list or shell history. | ||
| - name: "[prod] Sync Worker secrets" | ||
| if: >- | ||
| env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' && | ||
| github.ref == 'refs/heads/main' | ||
| working-directory: workers/learn-api | ||
| run: | | ||
| printf '%s' "${{ secrets.SESSION_SECRET }}" | npx wrangler@4 secret put SESSION_SECRET | ||
| printf '%s' "${{ secrets.GITHUB_CLIENT_ID }}" | npx wrangler@4 secret put GITHUB_CLIENT_ID | ||
| printf '%s' "${{ secrets.GITHUB_CLIENT_SECRET }}" | npx wrangler@4 secret put GITHUB_CLIENT_SECRET | ||
| printf '%s' "${{ secrets.INFERENCE_TOKEN }}" | npx wrangler@4 secret put INFERENCE_TOKEN | ||
| printf '%s' "${{ secrets.VOICE_TOKEN_SECRET }}" | npx wrangler@4 secret put VOICE_TOKEN_SECRET | ||
|
|
||
| # The only step in this workflow allowed to touch production: promotes | ||
| # the top-level (production) wrangler.toml config — never | ||
| # wrangler.signedout.toml, never an --env flag. | ||
| - name: "[prod] wrangler deploy" | ||
| if: >- | ||
| env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' && | ||
| github.ref == 'refs/heads/main' | ||
| working-directory: workers/learn-api | ||
| run: npx wrangler@4 deploy |
There was a problem hiding this comment.
2. Dispatch can deploy prod 🐞 Bug ☼ Reliability
Because workflow_dispatch is enabled and the production steps are gated only by `github.ref == 'refs/heads/main', manually running the workflow on main` will execute the production schema apply, secret sync, and wrangler deploy. This contradicts the “dispatch previews” safety expectation and makes accidental production mutation much easier than intended.
Agent Prompt
## Issue description
`workflow_dispatch` is enabled for the deploy-worker workflow, and production actions are guarded only by `github.ref == 'refs/heads/main'`. As a result, a workflow_dispatch run targeting the default branch (`main`) will execute production steps (remote schema apply + prod secret sync + promote deploy).
## Issue Context
The PR description and workflow comments emphasize that branch `workflow_dispatch` runs are meant to be preview verification and should not touch production. The current gating makes it easy to accidentally run a production deploy from the UI.
## Fix Focus Areas
- .github/workflows/deploy-worker.yml[42-151]
## What to change
- Restrict production steps to pushes to main by adding `github.event_name == 'push'` (in addition to `github.ref == 'refs/heads/main'`).
- Restrict preview steps to `github.event_name == 'workflow_dispatch'` (and optionally keep `github.ref != 'refs/heads/main'` if you want to forbid dispatch-on-main entirely).
- Alternatively (if you do want manual prod deploy), add an explicit workflow_dispatch input like `target: preview|prod` and require `target == 'prod'` plus ref==main for prod steps.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Each 'wrangler secret put' is now guarded by a non-empty check on its Actions secret. An unset/empty secret is skipped (leaving the deployed value unchanged) instead of overwriting a live prod secret with '' — so the first merge can't wipe pre-existing prod secrets, and the currently- unset optional secrets (INFERENCE_TOKEN, VOICE_TOKEN_SECRET, while tutoring/voice are off) don't clobber or fail. Adds a guard-assertion test to lock the invariant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
docs/hosting.md documented what's hosted but not how it deploys. Adds a durable, findable reference for the two deploy workflows (site + worker), the merge=prod / dispatch=preview split, and the load-bearing safety properties, cross-linking the README runbook (step-by-step) and the spec (rationale). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
|



Automate the learn-cli deploy pipeline
Turns the Worker go-live from a hand-run, non-reproducible
wranglerrunbook(that auto-mode blocks the agent from running) into a CI pipeline: merging to
main deploys the
learn-apiWorker + applies the D1 schema, and a branchworkflow_dispatchpreviews the same jobs against a non-production target sothe pipeline is proven green before the merge path is trusted.
Specced with devague
/think, planned with/spec-to-plan, built via/assign-to-workforce— 5 tasks, 3 waves, one worktree per task, every mergeTDD-gated (585 tests green before and after each). The spec + plan are committed
under
docs/specs/anddocs/plans/.What ships
The Astro site already auto-deploys on merge (
deploy-site.yml). This adds thesibling
deploy-worker.ymlso the Worker + schema stop being manual:workflow_dispatch(preview)wrangler.toml(KV+D1,/learn/*route)[env.preview]—learn-api-preview, no routed1 execute learn-ledger --remote --file schema.sqllearn-ledger-preview)wrangler deploy(promotes to prod)wrangler versions upload --env preview(no promote)Every production / promote / prod-D1 step is guarded by
github.ref == 'refs/heads/main'; the preview path is gated to the complement, so a branchdispatch can never hit prod.
Key decisions (from the converged spec)
[env.preview]— a plainversions uploadsharesthe top-level Worker's D1 binding, so a Cloudflare preview environment is how
"no-promote preview" and "separate preview D1" compose.
SESSION_SECRET,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,INFERENCE_TOKEN,VOICE_TOKEN_SECRETare applied viaprintf '%s' "${{ secrets.X }}" | wrangler secret put X(stdin — never a CLIarg, never logged; masked by GitHub). Credential-less forks skip with a notice.
infra/) stays a manualsam deploy;no D1 migrations framework (idempotent
schema.sqlonly); the tutoring flip(
INFERENCE_URL) stays committed config.Operator prerequisites (before the pipeline runs green)
This PR ships the mechanism; a branch dispatch will fail red until:
wrangler d1 create learn-ledger-preview, pasteits id into the
[env.preview]placeholder inwrangler.toml(and the previewKV). Production bindings are unchanged.
CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID, plus the five Worker secrets above.Both are documented in
workers/learn-api/README.md(deploy section rewritten:CI is the routine path; local
wrangler deployis a break-glass fallback). Thepreview-env OAuth-callback caveat and the post-merge verify (LIVE launch gate vs
BASELINE-2026-07-11.md, plus run-twice idempotency) are documented there too.Test evidence
tests/test_deploy_pipeline_invariants.py— 13 hermetic guard tests(proven non-vacuous by mutation): prod steps gated to
main, nosam/infrareference (robust word-boundary check), deploys
wrangler.tomlnotwrangler.signedout.toml, no secret as a CLI arg, all five secrets synced onboth paths,
schema.sqlfully idempotent, preview uses non-promotingversions upload.coverage 94.91% (floor 60%).
teken cli doctor --strictPASS. Version bumped0.6.0→0.7.0.Follow-ups (not this PR)
Operator provisions the preview D1 + sets the Actions secrets, then a branch
dispatch verifies the pipeline end-to-end.
Voice bridge (
infra/) SAM deploy remains manual.learn-cli (Claude)