From b917069591665d52efaba5412e44385a58773f61 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 19:38:28 +0300 Subject: [PATCH 01/10] spec+plan: automate the learn-cli deploy pipeline (devague /think + /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 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- .devague/current | 2 +- .devague/current_plan | 2 +- ...ps-a-one-command-free-deployment-pipe.json | 369 ++++++++++++++++++ ...ps-a-one-command-free-deployment-pipe.json | 254 ++++++++++++ ...hips-a-one-command-free-deployment-pipe.md | 52 +++ ...hips-a-one-command-free-deployment-pipe.md | 77 ++++ 6 files changed, 754 insertions(+), 2 deletions(-) create mode 100644 .devague/frames/learn-cli-ships-a-one-command-free-deployment-pipe.json create mode 100644 .devague/plans/learn-cli-ships-a-one-command-free-deployment-pipe.json create mode 100644 docs/plans/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md create mode 100644 docs/specs/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md diff --git a/.devague/current b/.devague/current index ed609e8..87e39ab 100644 --- a/.devague/current +++ b/.devague/current @@ -1 +1 @@ -agentculture-org-learn-is-now-a-consent-first-role +learn-cli-ships-a-one-command-free-deployment-pipe diff --git a/.devague/current_plan b/.devague/current_plan index ed609e8..87e39ab 100644 --- a/.devague/current_plan +++ b/.devague/current_plan @@ -1 +1 @@ -agentculture-org-learn-is-now-a-consent-first-role +learn-cli-ships-a-one-command-free-deployment-pipe diff --git a/.devague/frames/learn-cli-ships-a-one-command-free-deployment-pipe.json b/.devague/frames/learn-cli-ships-a-one-command-free-deployment-pipe.json new file mode 100644 index 0000000..2b34f14 --- /dev/null +++ b/.devague/frames/learn-cli-ships-a-one-command-free-deployment-pipe.json @@ -0,0 +1,369 @@ +{ + "slug": "learn-cli-ships-a-one-command-free-deployment-pipe", + "title": "learn-cli ships a one-command-free deployment pipeline: merging to main deploys the whole /learn stack \u2014 Astro site, the learn-api Cloudflare Worker, and the D1 schema \u2014 from CI with no local wrangler, and the same workflow runs on demand from any branch (workflow_dispatch) against a non-production preview target, so the pipeline is proven green before the merge path is trusted.", + "schema_version": 2, + "status": "exported", + "created": "2026-07-11T16:20:22Z", + "updated": "2026-07-11T16:32:31Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "learn-cli ships a one-command-free deployment pipeline: merging to main deploys the whole /learn stack \u2014 Astro site, the learn-api Cloudflare Worker, and the D1 schema \u2014 from CI with no local wrangler, and the same workflow runs on demand from any branch (workflow_dispatch) against a non-production preview target, so the pipeline is proven green before the merge path is trusted.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "The whole claim is provable by two runs: a green branch workflow_dispatch and a green merge-to-main deploy that together cover site + Worker + schema.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Land deploy-worker CI + branch-dispatch preview; demo one green dispatch and one green main deploy." + }, + { + "id": "c2", + "kind": "audience", + "text": "The learn-cli operator/maintainer who merges to main or needs to verify a deploy, and the agent driving go-live (which the auto-mode classifier currently forbids from running local wrangler).", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "After this ships, neither operator nor agent runs local wrangler for a normal deploy \u2014 CI holds the Cloudflare credentials and does it.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Verify the go-live runbook no longer contains a local 'wrangler deploy' step for the routine path." + }, + { + "id": "c3", + "kind": "before_state", + "text": "The Astro site auto-deploys on merge (deploy-site.yml), but the learn-api Worker, its D1 schema, and its secrets are a manual out-of-band wrangler runbook the auto-mode classifier blocks the agent from running \u2014 so go-live is a hand-run, non-reproducible checklist.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "Today no workflow references wrangler deploy / learn-api (grep-verified), so the manual Worker gap is real, not already-solved.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Diff against the current .github/workflows (only deploy-site/publish/tests exist)." + }, + { + "id": "c4", + "kind": "after_state", + "text": "Merge to main runs one CI pipeline that deploys the site, deploys the learn-api Worker, and idempotently applies the D1 schema \u2014 no local wrangler, no agent-run deploy; a branch workflow_dispatch runs the same jobs against a non-prod preview target and reports green/red without mutating production.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "One triggered CI run performs all three actions (site deploy, Worker deploy, remote schema apply) on main, and a branch run is provably non-prod.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Trace the workflow jobs: site, worker, schema; assert the non-main path skips prod promote + prod-D1 write." + }, + { + "id": "c5", + "kind": "why_it_matters", + "text": "The deploy becomes reproducible, auditable, and executable by CI's credentials rather than a human/agent laptop \u2014 which also resolves the auto-mode deploy blocker \u2014 and branch-dispatch lets the pipeline mechanics be verified before a real merge relies on them.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "The deploy is reproducible from committed git state + repo secrets alone, with no laptop-specific or agent-specific state.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Confirm every input (config, schema, secrets) comes from the repo or GitHub Actions secrets." + }, + { + "id": "c6", + "kind": "success_signal", + "text": "A branch workflow_dispatch run goes green end-to-end against the preview target while production stays byte-identical (route, Worker version, D1 rows unchanged).", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "A preview run leaves the production Worker version id, the /learn/* route binding, and D1 row counts unchanged \u2014 checkable before/after.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Capture 'wrangler deployments list' + a row count before/after a preview dispatch; assert identical." + }, + { + "id": "c7", + "kind": "success_signal", + "text": "After a merge to main, the LIVE_ORIGIN=https://agentculture.org launch gate (consent_walk.mjs) flips from the recorded pre-uplift baseline to green with no local wrangler invoked.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "Post-merge, LIVE_ORIGIN=https://agentculture.org consent_walk.mjs flips the recorded BASELINE-2026-07-11 failures to green.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Run the LIVE launch gate after the first main deploy and compare to the baseline artifact." + }, + { + "id": "c8", + "kind": "success_signal", + "text": "Re-running the pipeline is a no-op-safe idempotent operation: schema re-apply changes nothing, and a Worker redeploy preserves already-set secrets.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "A second identical pipeline run changes nothing: schema apply is a no-op and the Worker redeploy preserves secrets and behavior.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Run the main pipeline twice; assert the second run reports no schema change and OAuth still works." + }, + { + "id": "c9", + "kind": "boundary", + "text": "Not automating the SAM voice bridge deploy (infra/ \u2014 sam deploy stays a separate, later manual step); this phase is site + Worker + D1 schema only.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h14", + "text": "The workflows never invoke sam / touch infra/ \u2014 the voice bridge stays undeployed by this pipeline.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Grep the new workflow for 'sam'/'infra' \u2014 must be absent; voice remains a separate manual step." + }, + { + "id": "c10", + "kind": "non_goal", + "text": "Not building a D1 migrations framework \u2014 the pipeline applies the existing idempotent schema.sql (all CREATE ... IF NOT EXISTS); destructive/ALTER migrations are out of scope.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c11", + "kind": "non_goal", + "text": "Not changing the tutoring enablement model \u2014 INFERENCE_URL stays commented-in-wrangler.toml config, so enabling Nova Pro remains a deliberate commit, not a pipeline toggle.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c12", + "kind": "requirement", + "text": "The pipeline deploys the Worker from the committed workers/learn-api/wrangler.toml (the full signed-in config, not wrangler.signedout.toml) and applies workers/learn-api/schema.sql to the remote D1 before/with the Worker deploy.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h1", + "text": "schema.sql is fully idempotent (verified: every statement is CREATE TABLE/INDEX IF NOT EXISTS), so applying it on every prod deploy is a no-op once migrated.", + "status": "confirmed", + "instruction": "" + }, + { + "id": "h2", + "text": "wrangler.toml (not wrangler.signedout.toml) is the config CI deploys, and it carries the KV+D1 bindings and /learn/* route the running product needs.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Point the deploy step at workers/learn-api/wrangler.toml and run 'wrangler d1 execute learn-ledger --remote --file schema.sql' on the main path." + }, + { + "id": "c13", + "kind": "requirement", + "text": "A branch workflow_dispatch (preview) run must not deploy to the production Worker/route or write to production KV/D1 \u2014 production is left untouched by any non-main run.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "The mechanism chosen for preview (per the open question) provably cannot promote to the production Worker, bind the /learn/* route, or write prod D1/KV \u2014 a preview run leaves prod's deployed version and data unchanged.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Gate the promote/prod-D1 steps to ref==main (or environment:production); branch dispatch uses versions upload / preview only." + }, + { + "id": "c14", + "kind": "requirement", + "text": "Secrets are never printed to CI logs nor committed: CI reads them from GitHub Actions secrets (masked) and pipes them to wrangler; no secret value appears in the workflow file or logs.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "wrangler deploy does NOT reset or delete already-set Worker secrets, and 'wrangler secret put' (if ever used) reads the value from stdin/env so it never appears as a command-line arg in logs; GitHub masks secrets.* in logs.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Store each app secret as a masked GitHub Actions secret; pipe via stdin to 'wrangler secret put NAME' (value never on the command line); apply to both the prod and preview-env Workers." + }, + { + "id": "c15", + "kind": "requirement", + "text": "A deploy failure (bad wrangler.toml, missing binding, D1 error) fails the CI job visibly red and does not leave a partially-promoted production Worker.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h5", + "text": "A failing wrangler deploy / d1 execute returns a non-zero exit that fails the job, and 'wrangler deploy' is atomic (a bad upload does not partially replace the live version).", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Let non-zero wrangler exit fail the job; rely on wrangler deploy atomicity so no partial prod version is promoted." + }, + { + "id": "c16", + "kind": "decision", + "text": "A new sibling workflow .github/workflows/deploy-worker.yml (modeled on deploy-site.yml: same checkout, npx wrangler, secrets-gated skip-notice) rather than folding Worker steps into deploy-site.yml \u2014 keeps site and Worker deploys independently triggerable and path-filtered.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c17", + "kind": "decision", + "text": "Branch preview = 'wrangler versions upload' (uploads a Worker version + a preview URL, does NOT promote to production and does NOT bind the /learn/* route); merge-to-main = 'wrangler deploy' (promotes to production). Mirrors deploy-site.yml's --branch preview-vs-production split.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c18", + "kind": "decision", + "text": "Worker secrets stay set-once/manual (GITHUB_CLIENT_ID, SESSION_SECRET, INFERENCE_TOKEN, VOICE_TOKEN_SECRET): CI runs only 'wrangler deploy', which preserves existing secrets. CI does NOT store or push app secrets \u2014 only CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID (already GitHub secrets) are needed to deploy.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c19", + "kind": "decision", + "text": "The prod D1 schema apply ('wrangler d1 execute learn-ledger --remote --file schema.sql') runs only on the main (production) path; a branch preview run skips the remote prod-D1 apply (or targets a preview D1), so no branch run mutates production data \u2014 even though the apply is idempotent-additive.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c20", + "kind": "decision", + "text": "Worker deploy is path-filtered to workers/learn-api/** + the workflow file on push; workflow_dispatch ignores path filters so a branch preview can always be forced. The prod job is gated to github.ref == main (or a GitHub environment: production) so a dispatch from a branch can never hit the deploy/promote path.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c21", + "kind": "decision", + "text": "Worker secrets are CI-synced from GitHub Actions secrets every deploy: SESSION_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, INFERENCE_TOKEN, VOICE_TOKEN_SECRET are each stored as masked Actions secrets and applied via 'printf %s \"$X\" | wrangler secret put NAME' (stdin, never an arg). Deploys are fully hands-off; the tradeoff is these secrets now also live in GitHub's encrypted store.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c22", + "kind": "decision", + "text": "A separate preview D1 database backs branch runs: [env.preview] in wrangler.toml binds a preview D1 (its own database_id) and NO /learn/* route. Branch workflow_dispatch applies schema.sql to the preview D1 and uploads a preview version there; the main path applies schema.sql to the prod learn-ledger D1 and deploys the top-level (production) Worker. No branch run ever reads or writes prod D1.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c23", + "kind": "decision", + "text": "Branch preview is realized as a Cloudflare preview ENVIRONMENT: 'wrangler versions upload --env preview' uploads a non-promoted version of the learn-api-preview Worker (bound to the preview D1, no route, its own workers.dev preview URL). This composes Q1 (versions upload / no promote) with Q3 (separate preview D1), since a plain version of the top-level Worker cannot swap its D1 binding.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + } + ], + "open_vagueness": [ + { + "id": "v1", + "text": "Preview-env OAuth callback origin differs from prod (the *.workers.dev preview URL is not the registered GitHub OAuth callback), so full authed flows on the preview version may need a preview OAuth app or be limited to unauth probes \u2014 the preview target still validates the pipeline mechanics (build, upload, schema apply, bindings) regardless.", + "kind": "unknown_nonblocking", + "claim_id": null + } + ], + "scope_entries": [] +} diff --git a/.devague/plans/learn-cli-ships-a-one-command-free-deployment-pipe.json b/.devague/plans/learn-cli-ships-a-one-command-free-deployment-pipe.json new file mode 100644 index 0000000..9b5dfba --- /dev/null +++ b/.devague/plans/learn-cli-ships-a-one-command-free-deployment-pipe.json @@ -0,0 +1,254 @@ +{ + "slug": "learn-cli-ships-a-one-command-free-deployment-pipe", + "title": "learn-cli ships a one-command-free deployment pipeline: merging to main deploys the whole /learn stack \u2014 Astro site, the learn-api Cloudflare Worker, and the D1 schema \u2014 from CI with no local wrangler, and the same workflow runs on demand from any branch (workflow_dispatch) against a non-production preview target, so the pipeline is proven green before the merge path is trusted.", + "frame_slug": "learn-cli-ships-a-one-command-free-deployment-pipe", + "schema_version": 2, + "status": "exported", + "created": "2026-07-11T16:34:03Z", + "updated": "2026-07-11T16:38:18Z", + "targets": [ + { + "id": "c1", + "kind": "announcement", + "text": "learn-cli ships a one-command-free deployment pipeline: merging to main deploys the whole /learn stack \u2014 Astro site, the learn-api Cloudflare Worker, and the D1 schema \u2014 from CI with no local wrangler, and the same workflow runs on demand from any branch (workflow_dispatch) against a non-production preview target, so the pipeline is proven green before the merge path is trusted." + }, + { + "id": "h6", + "kind": "honesty", + "text": "The whole claim is provable by two runs: a green branch workflow_dispatch and a green merge-to-main deploy that together cover site + Worker + schema." + }, + { + "id": "c2", + "kind": "audience", + "text": "The learn-cli operator/maintainer who merges to main or needs to verify a deploy, and the agent driving go-live (which the auto-mode classifier currently forbids from running local wrangler)." + }, + { + "id": "h7", + "kind": "honesty", + "text": "After this ships, neither operator nor agent runs local wrangler for a normal deploy \u2014 CI holds the Cloudflare credentials and does it." + }, + { + "id": "c3", + "kind": "before_state", + "text": "The Astro site auto-deploys on merge (deploy-site.yml), but the learn-api Worker, its D1 schema, and its secrets are a manual out-of-band wrangler runbook the auto-mode classifier blocks the agent from running \u2014 so go-live is a hand-run, non-reproducible checklist." + }, + { + "id": "h8", + "kind": "honesty", + "text": "Today no workflow references wrangler deploy / learn-api (grep-verified), so the manual Worker gap is real, not already-solved." + }, + { + "id": "c4", + "kind": "after_state", + "text": "Merge to main runs one CI pipeline that deploys the site, deploys the learn-api Worker, and idempotently applies the D1 schema \u2014 no local wrangler, no agent-run deploy; a branch workflow_dispatch runs the same jobs against a non-prod preview target and reports green/red without mutating production." + }, + { + "id": "h9", + "kind": "honesty", + "text": "One triggered CI run performs all three actions (site deploy, Worker deploy, remote schema apply) on main, and a branch run is provably non-prod." + }, + { + "id": "c5", + "kind": "why_it_matters", + "text": "The deploy becomes reproducible, auditable, and executable by CI's credentials rather than a human/agent laptop \u2014 which also resolves the auto-mode deploy blocker \u2014 and branch-dispatch lets the pipeline mechanics be verified before a real merge relies on them." + }, + { + "id": "h10", + "kind": "honesty", + "text": "The deploy is reproducible from committed git state + repo secrets alone, with no laptop-specific or agent-specific state." + }, + { + "id": "c6", + "kind": "success_signal", + "text": "A branch workflow_dispatch run goes green end-to-end against the preview target while production stays byte-identical (route, Worker version, D1 rows unchanged)." + }, + { + "id": "h11", + "kind": "honesty", + "text": "A preview run leaves the production Worker version id, the /learn/* route binding, and D1 row counts unchanged \u2014 checkable before/after." + }, + { + "id": "c7", + "kind": "success_signal", + "text": "After a merge to main, the LIVE_ORIGIN=https://agentculture.org launch gate (consent_walk.mjs) flips from the recorded pre-uplift baseline to green with no local wrangler invoked." + }, + { + "id": "h12", + "kind": "honesty", + "text": "Post-merge, LIVE_ORIGIN=https://agentculture.org consent_walk.mjs flips the recorded BASELINE-2026-07-11 failures to green." + }, + { + "id": "c8", + "kind": "success_signal", + "text": "Re-running the pipeline is a no-op-safe idempotent operation: schema re-apply changes nothing, and a Worker redeploy preserves already-set secrets." + }, + { + "id": "h13", + "kind": "honesty", + "text": "A second identical pipeline run changes nothing: schema apply is a no-op and the Worker redeploy preserves secrets and behavior." + }, + { + "id": "c9", + "kind": "boundary", + "text": "Not automating the SAM voice bridge deploy (infra/ \u2014 sam deploy stays a separate, later manual step); this phase is site + Worker + D1 schema only." + }, + { + "id": "h14", + "kind": "honesty", + "text": "The workflows never invoke sam / touch infra/ \u2014 the voice bridge stays undeployed by this pipeline." + }, + { + "id": "c12", + "kind": "requirement", + "text": "The pipeline deploys the Worker from the committed workers/learn-api/wrangler.toml (the full signed-in config, not wrangler.signedout.toml) and applies workers/learn-api/schema.sql to the remote D1 before/with the Worker deploy." + }, + { + "id": "h1", + "kind": "honesty", + "text": "schema.sql is fully idempotent (verified: every statement is CREATE TABLE/INDEX IF NOT EXISTS), so applying it on every prod deploy is a no-op once migrated." + }, + { + "id": "h2", + "kind": "honesty", + "text": "wrangler.toml (not wrangler.signedout.toml) is the config CI deploys, and it carries the KV+D1 bindings and /learn/* route the running product needs." + }, + { + "id": "c13", + "kind": "requirement", + "text": "A branch workflow_dispatch (preview) run must not deploy to the production Worker/route or write to production KV/D1 \u2014 production is left untouched by any non-main run." + }, + { + "id": "h3", + "kind": "honesty", + "text": "The mechanism chosen for preview (per the open question) provably cannot promote to the production Worker, bind the /learn/* route, or write prod D1/KV \u2014 a preview run leaves prod's deployed version and data unchanged." + }, + { + "id": "c14", + "kind": "requirement", + "text": "Secrets are never printed to CI logs nor committed: CI reads them from GitHub Actions secrets (masked) and pipes them to wrangler; no secret value appears in the workflow file or logs." + }, + { + "id": "h4", + "kind": "honesty", + "text": "wrangler deploy does NOT reset or delete already-set Worker secrets, and 'wrangler secret put' (if ever used) reads the value from stdin/env so it never appears as a command-line arg in logs; GitHub masks secrets.* in logs." + }, + { + "id": "c15", + "kind": "requirement", + "text": "A deploy failure (bad wrangler.toml, missing binding, D1 error) fails the CI job visibly red and does not leave a partially-promoted production Worker." + }, + { + "id": "h5", + "kind": "honesty", + "text": "A failing wrangler deploy / d1 execute returns a non-zero exit that fails the job, and 'wrangler deploy' is atomic (a bad upload does not partially replace the live version)." + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Add an [env.preview] environment to workers/learn-api/wrangler.toml: name learn-api-preview, a preview D1 binding (its own database_id, operator-provisioned), NO routes key; the top-level config stays production (learn-ledger D1 + /learn/* route).", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "wrangler.toml has an [env.preview] table whose worker name is learn-api-preview, binds a preview D1, and declares no route/routes key", + "the top-level (production) config still binds learn-ledger D1 and the /learn/* zone route, unchanged" + ], + "deps": [], + "covers": [ + "c13", + "h3" + ], + "instruction": "Model [env.preview] on the top-level config: copy the [[kv_namespaces]]/[[d1_databases]] blocks under [env.preview], set name='learn-api-preview', point d1 at the preview DB (placeholder database_id, operator fills like the prod one), and OMIT the routes key. Do not touch the top-level production config." + }, + { + "id": "t2", + "summary": "Add .github/workflows/deploy-worker.yml: push-to-main (path-filtered workers/learn-api/**) runs schema.sql against learn-ledger --remote, syncs secrets, and 'wrangler deploy' (production); workflow_dispatch from any branch runs schema.sql against the preview D1 and 'wrangler versions upload --env preview' (no promote); prod/promote steps gated to ref==main; secrets piped via stdin; secrets-gated skip-notice like deploy-site.yml.", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "on push to main the job applies schema.sql to learn-ledger --remote and runs 'wrangler deploy' (top-level wrangler.toml)", + "a workflow_dispatch run off a non-main branch runs 'wrangler versions upload --env preview' and applies schema to the preview D1, and NEVER runs 'wrangler deploy' or promotes to prod", + "every production/promote/prod-D1 step is guarded by an if: condition requiring ref == refs/heads/main", + "each secret is applied via stdin (printf %s $X | wrangler secret put NAME) so no secret value appears as a CLI argument; the deploy step is skipped with a notice when CLOUDFLARE_API_TOKEN is unset" + ], + "deps": [ + "t1" + ], + "covers": [ + "c1", + "c4", + "c5", + "c12", + "c14", + "c15", + "h1", + "h4", + "h5", + "h6", + "h8", + "h9", + "h10" + ], + "instruction": "Mirror .github/workflows/deploy-site.yml structure (checkout, env-mapped CLOUDFLARE_* secrets, 'if: env.CLOUDFLARE_API_TOKEN != \\'\\'' skip-notice, npx wrangler@4, working-directory workers/learn-api). Use two guarded step groups: prod (if github.ref=='refs/heads/main') and preview (else / workflow_dispatch). Secrets loop: for each of SESSION_SECRET GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET INFERENCE_TOKEN VOICE_TOKEN_SECRET, 'printf %s \"${{ secrets.X }}\" | npx wrangler@4 secret put X [--env preview]'." + }, + { + "id": "t3", + "summary": "Add tests/test_deploy_pipeline_invariants.py: parse deploy-worker.yml + wrangler.toml and assert the mechanical boundaries \u2014 prod steps gated to ref==main, no 'sam'/'infra' reference, deploys wrangler.toml (never wrangler.signedout.toml), no secret passed as a CLI arg, schema.sql statements all idempotent.", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "test asserts the workflow guards prod deploy + prod-D1 apply behind ref==refs/heads/main (or environment: production)", + "test asserts the workflow never references 'sam' or 'infra/', and deploys workers/learn-api/wrangler.toml and never wrangler.signedout.toml", + "test asserts every statement in schema.sql is CREATE ... IF NOT EXISTS (idempotent) and no secret value is a literal CLI argument in the workflow" + ], + "deps": [ + "t2" + ], + "covers": [ + "c6", + "c9", + "h2", + "h11", + "h14" + ], + "instruction": "Follow the existing tests/test_launch_gate_invariants.py style (read the workflow YAML + wrangler.toml as text/ast, assert mechanical boundaries). Keep it hermetic \u2014 no network, no wrangler invocation." + }, + { + "id": "t4", + "summary": "Update workers/learn-api/README.md deploy section: CI now deploys (merge=prod, branch dispatch=preview verify); document the one-time operator prerequisites (create the preview D1, set the 5 GitHub Actions secrets), the pre/post-deploy verify (LIVE launch gate + run-twice idempotency), and the preview-env OAuth-callback caveat; remove local 'wrangler deploy' as the routine path.", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "README documents the CI pipeline (merge-to-main production vs branch-dispatch preview) and no longer presents a local 'wrangler deploy' as the routine go-live path", + "README lists the one-time operator setup (preview D1 creation + the named GitHub Actions secrets) and the preview OAuth-callback caveat", + "README documents the post-merge verify (LIVE_ORIGIN launch gate vs baseline) and the run-twice idempotency check" + ], + "deps": [ + "t2" + ], + "covers": [ + "c2", + "c3", + "c7", + "c8", + "h7", + "h12", + "h13" + ], + "instruction": "Edit the deploy/runbook section of workers/learn-api/README.md. Cross-reference tools/launch-gate/BASELINE-2026-07-11.md for the post-merge verify. State the operator prereqs as a numbered list." + }, + { + "id": "t5", + "summary": "Bump pyproject.toml version and prepend a CHANGELOG entry describing the deploy-worker pipeline (required by version-check CI on every PR).", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "pyproject.toml version is greater than main's and CHANGELOG.md has a dated entry describing the deploy pipeline automation" + ], + "deps": [], + "covers": [], + "instruction": "Use the version-bump skill: echo JSON | python3 .claude/skills/version-bump/scripts/bump.py minor (this is a new feature). Summarize the deploy pipeline in the Added section." + } + ], + "risks": [] +} diff --git a/docs/plans/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md b/docs/plans/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md new file mode 100644 index 0000000..01b276f --- /dev/null +++ b/docs/plans/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md @@ -0,0 +1,52 @@ +# Build Plan — learn-cli ships a one-command-free deployment pipeline: merging to main deploys the whole /learn stack — Astro site, the learn-api Cloudflare Worker, and the D1 schema — from CI with no local wrangler, and the same workflow runs on demand from any branch (workflow_dispatch) against a non-production preview target, so the pipeline is proven green before the merge path is trusted. + +slug: `learn-cli-ships-a-one-command-free-deployment-pipe` · status: `exported` · from frame: `learn-cli-ships-a-one-command-free-deployment-pipe` + +> learn-cli ships a one-command-free deployment pipeline: merging to main deploys the whole /learn stack — Astro site, the learn-api Cloudflare Worker, and the D1 schema — from CI with no local wrangler, and the same workflow runs on demand from any branch (workflow_dispatch) against a non-production preview target, so the pipeline is proven green before the merge path is trusted. + +## Tasks + +### t1 — Add an [env.preview] environment to workers/learn-api/wrangler.toml: name learn-api-preview, a preview D1 binding (its own database_id, operator-provisioned), NO routes key; the top-level config stays production (learn-ledger D1 + /learn/* route). + +- instruction: Model [env.preview] on the top-level config: copy the [[kv_namespaces]]/[[d1_databases]] blocks under [env.preview], set name='learn-api-preview', point d1 at the preview DB (placeholder database_id, operator fills like the prod one), and OMIT the routes key. Do not touch the top-level production config. +- covers: c13, h3 +- acceptance: + - wrangler.toml has an [env.preview] table whose worker name is learn-api-preview, binds a preview D1, and declares no route/routes key + - the top-level (production) config still binds learn-ledger D1 and the /learn/* zone route, unchanged + +### t2 — Add .github/workflows/deploy-worker.yml: push-to-main (path-filtered workers/learn-api/**) runs schema.sql against learn-ledger --remote, syncs secrets, and 'wrangler deploy' (production); workflow_dispatch from any branch runs schema.sql against the preview D1 and 'wrangler versions upload --env preview' (no promote); prod/promote steps gated to ref==main; secrets piped via stdin; secrets-gated skip-notice like deploy-site.yml. + +- instruction: Mirror .github/workflows/deploy-site.yml structure (checkout, env-mapped CLOUDFLARE_* secrets, 'if: env.CLOUDFLARE_API_TOKEN != \'\'' skip-notice, npx wrangler@4, working-directory workers/learn-api). Use two guarded step groups: prod (if github.ref=='refs/heads/main') and preview (else / workflow_dispatch). Secrets loop: for each of SESSION_SECRET GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET INFERENCE_TOKEN VOICE_TOKEN_SECRET, 'printf %s "${{ secrets.X }}" | npx wrangler@4 secret put X [--env preview]'. +- depends on: t1 +- covers: c1, c4, c5, c12, c14, c15, h1, h4, h5, h6, h8, h9, h10 +- acceptance: + - on push to main the job applies schema.sql to learn-ledger --remote and runs 'wrangler deploy' (top-level wrangler.toml) + - a workflow_dispatch run off a non-main branch runs 'wrangler versions upload --env preview' and applies schema to the preview D1, and NEVER runs 'wrangler deploy' or promotes to prod + - every production/promote/prod-D1 step is guarded by an if: condition requiring ref == refs/heads/main + - each secret is applied via stdin (printf %s $X | wrangler secret put NAME) so no secret value appears as a CLI argument; the deploy step is skipped with a notice when CLOUDFLARE_API_TOKEN is unset + +### t3 — Add tests/test_deploy_pipeline_invariants.py: parse deploy-worker.yml + wrangler.toml and assert the mechanical boundaries — prod steps gated to ref==main, no 'sam'/'infra' reference, deploys wrangler.toml (never wrangler.signedout.toml), no secret passed as a CLI arg, schema.sql statements all idempotent. + +- instruction: Follow the existing tests/test_launch_gate_invariants.py style (read the workflow YAML + wrangler.toml as text/ast, assert mechanical boundaries). Keep it hermetic — no network, no wrangler invocation. +- depends on: t2 +- covers: c6, c9, h2, h11, h14 +- acceptance: + - test asserts the workflow guards prod deploy + prod-D1 apply behind ref==refs/heads/main (or environment: production) + - test asserts the workflow never references 'sam' or 'infra/', and deploys workers/learn-api/wrangler.toml and never wrangler.signedout.toml + - test asserts every statement in schema.sql is CREATE ... IF NOT EXISTS (idempotent) and no secret value is a literal CLI argument in the workflow + +### t4 — Update workers/learn-api/README.md deploy section: CI now deploys (merge=prod, branch dispatch=preview verify); document the one-time operator prerequisites (create the preview D1, set the 5 GitHub Actions secrets), the pre/post-deploy verify (LIVE launch gate + run-twice idempotency), and the preview-env OAuth-callback caveat; remove local 'wrangler deploy' as the routine path. + +- instruction: Edit the deploy/runbook section of workers/learn-api/README.md. Cross-reference tools/launch-gate/BASELINE-2026-07-11.md for the post-merge verify. State the operator prereqs as a numbered list. +- depends on: t2 +- covers: c2, c3, c7, c8, h7, h12, h13 +- acceptance: + - README documents the CI pipeline (merge-to-main production vs branch-dispatch preview) and no longer presents a local 'wrangler deploy' as the routine go-live path + - README lists the one-time operator setup (preview D1 creation + the named GitHub Actions secrets) and the preview OAuth-callback caveat + - README documents the post-merge verify (LIVE_ORIGIN launch gate vs baseline) and the run-twice idempotency check + +### t5 — Bump pyproject.toml version and prepend a CHANGELOG entry describing the deploy-worker pipeline (required by version-check CI on every PR). + +- instruction: Use the version-bump skill: echo JSON | python3 .claude/skills/version-bump/scripts/bump.py minor (this is a new feature). Summarize the deploy pipeline in the Added section. +- acceptance: + - pyproject.toml version is greater than main's and CHANGELOG.md has a dated entry describing the deploy pipeline automation diff --git a/docs/specs/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md b/docs/specs/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md new file mode 100644 index 0000000..948ce16 --- /dev/null +++ b/docs/specs/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md @@ -0,0 +1,77 @@ +# learn-cli ships a one-command-free deployment pipeline: merging to main deploys the whole /learn stack — Astro site, the learn-api Cloudflare Worker, and the D1 schema — from CI with no local wrangler, and the same workflow runs on demand from any branch (workflow_dispatch) against a non-production preview target, so the pipeline is proven green before the merge path is trusted. + +> learn-cli ships a one-command-free deployment pipeline: merging to main deploys the whole /learn stack — Astro site, the learn-api Cloudflare Worker, and the D1 schema — from CI with no local wrangler, and the same workflow runs on demand from any branch (workflow_dispatch) against a non-production preview target, so the pipeline is proven green before the merge path is trusted. +> instruction: Land deploy-worker CI + branch-dispatch preview; demo one green dispatch and one green main deploy. + +## Audience + +- The learn-cli operator/maintainer who merges to main or needs to verify a deploy, and the agent driving go-live (which the auto-mode classifier currently forbids from running local wrangler). + - instruction: Verify the go-live runbook no longer contains a local 'wrangler deploy' step for the routine path. + +## Before → After + +- Before: The Astro site auto-deploys on merge (deploy-site.yml), but the learn-api Worker, its D1 schema, and its secrets are a manual out-of-band wrangler runbook the auto-mode classifier blocks the agent from running — so go-live is a hand-run, non-reproducible checklist. + - instruction: Diff against the current .github/workflows (only deploy-site/publish/tests exist). +- After: Merge to main runs one CI pipeline that deploys the site, deploys the learn-api Worker, and idempotently applies the D1 schema — no local wrangler, no agent-run deploy; a branch workflow_dispatch runs the same jobs against a non-prod preview target and reports green/red without mutating production. + - instruction: Trace the workflow jobs: site, worker, schema; assert the non-main path skips prod promote + prod-D1 write. + +## Why it matters + +- The deploy becomes reproducible, auditable, and executable by CI's credentials rather than a human/agent laptop — which also resolves the auto-mode deploy blocker — and branch-dispatch lets the pipeline mechanics be verified before a real merge relies on them. + - instruction: Confirm every input (config, schema, secrets) comes from the repo or GitHub Actions secrets. + +## Requirements + +- The pipeline deploys the Worker from the committed workers/learn-api/wrangler.toml (the full signed-in config, not wrangler.signedout.toml) and applies workers/learn-api/schema.sql to the remote D1 before/with the Worker deploy. + - instruction: Point the deploy step at workers/learn-api/wrangler.toml and run 'wrangler d1 execute learn-ledger --remote --file schema.sql' on the main path. + - honesty: schema.sql is fully idempotent (verified: every statement is CREATE TABLE/INDEX IF NOT EXISTS), so applying it on every prod deploy is a no-op once migrated. + - honesty: wrangler.toml (not wrangler.signedout.toml) is the config CI deploys, and it carries the KV+D1 bindings and /learn/* route the running product needs. +- A branch workflow_dispatch (preview) run must not deploy to the production Worker/route or write to production KV/D1 — production is left untouched by any non-main run. + - instruction: Gate the promote/prod-D1 steps to ref==main (or environment:production); branch dispatch uses versions upload / preview only. + - honesty: The mechanism chosen for preview (per the open question) provably cannot promote to the production Worker, bind the /learn/* route, or write prod D1/KV — a preview run leaves prod's deployed version and data unchanged. +- Secrets are never printed to CI logs nor committed: CI reads them from GitHub Actions secrets (masked) and pipes them to wrangler; no secret value appears in the workflow file or logs. + - instruction: Store each app secret as a masked GitHub Actions secret; pipe via stdin to 'wrangler secret put NAME' (value never on the command line); apply to both the prod and preview-env Workers. + - honesty: wrangler deploy does NOT reset or delete already-set Worker secrets, and 'wrangler secret put' (if ever used) reads the value from stdin/env so it never appears as a command-line arg in logs; GitHub masks secrets.* in logs. +- A deploy failure (bad wrangler.toml, missing binding, D1 error) fails the CI job visibly red and does not leave a partially-promoted production Worker. + - instruction: Let non-zero wrangler exit fail the job; rely on wrangler deploy atomicity so no partial prod version is promoted. + - honesty: A failing wrangler deploy / d1 execute returns a non-zero exit that fails the job, and 'wrangler deploy' is atomic (a bad upload does not partially replace the live version). + +## Honesty conditions + +- The whole claim is provable by two runs: a green branch workflow_dispatch and a green merge-to-main deploy that together cover site + Worker + schema. +- After this ships, neither operator nor agent runs local wrangler for a normal deploy — CI holds the Cloudflare credentials and does it. +- Today no workflow references wrangler deploy / learn-api (grep-verified), so the manual Worker gap is real, not already-solved. +- One triggered CI run performs all three actions (site deploy, Worker deploy, remote schema apply) on main, and a branch run is provably non-prod. +- The deploy is reproducible from committed git state + repo secrets alone, with no laptop-specific or agent-specific state. +- A preview run leaves the production Worker version id, the /learn/* route binding, and D1 row counts unchanged — checkable before/after. +- Post-merge, LIVE_ORIGIN=https://agentculture.org consent_walk.mjs flips the recorded BASELINE-2026-07-11 failures to green. +- A second identical pipeline run changes nothing: schema apply is a no-op and the Worker redeploy preserves secrets and behavior. +- The workflows never invoke sam / touch infra/ — the voice bridge stays undeployed by this pipeline. + +## Success signals + +- A branch workflow_dispatch run goes green end-to-end against the preview target while production stays byte-identical (route, Worker version, D1 rows unchanged). + - instruction: Capture 'wrangler deployments list' + a row count before/after a preview dispatch; assert identical. +- After a merge to main, the LIVE_ORIGIN=https://agentculture.org launch gate (consent_walk.mjs) flips from the recorded pre-uplift baseline to green with no local wrangler invoked. + - instruction: Run the LIVE launch gate after the first main deploy and compare to the baseline artifact. +- Re-running the pipeline is a no-op-safe idempotent operation: schema re-apply changes nothing, and a Worker redeploy preserves already-set secrets. + - instruction: Run the main pipeline twice; assert the second run reports no schema change and OAuth still works. + +## Scope / boundaries + +- Not automating the SAM voice bridge deploy (infra/ — sam deploy stays a separate, later manual step); this phase is site + Worker + D1 schema only. + - instruction: Grep the new workflow for 'sam'/'infra' — must be absent; voice remains a separate manual step. + +## Non-goals + +- Not building a D1 migrations framework — the pipeline applies the existing idempotent schema.sql (all CREATE ... IF NOT EXISTS); destructive/ALTER migrations are out of scope. +- Not changing the tutoring enablement model — INFERENCE_URL stays commented-in-wrangler.toml config, so enabling Nova Pro remains a deliberate commit, not a pipeline toggle. + +## Decisions + +- A new sibling workflow .github/workflows/deploy-worker.yml (modeled on deploy-site.yml: same checkout, npx wrangler, secrets-gated skip-notice) rather than folding Worker steps into deploy-site.yml — keeps site and Worker deploys independently triggerable and path-filtered. +- Branch preview = 'wrangler versions upload' (uploads a Worker version + a preview URL, does NOT promote to production and does NOT bind the /learn/* route); merge-to-main = 'wrangler deploy' (promotes to production). Mirrors deploy-site.yml's --branch preview-vs-production split. +- Worker deploy is path-filtered to workers/learn-api/** + the workflow file on push; workflow_dispatch ignores path filters so a branch preview can always be forced. The prod job is gated to github.ref == main (or a GitHub environment: production) so a dispatch from a branch can never hit the deploy/promote path. +- Worker secrets are CI-synced from GitHub Actions secrets every deploy: SESSION_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, INFERENCE_TOKEN, VOICE_TOKEN_SECRET are each stored as masked Actions secrets and applied via 'printf %s "$X" | wrangler secret put NAME' (stdin, never an arg). Deploys are fully hands-off; the tradeoff is these secrets now also live in GitHub's encrypted store. +- A separate preview D1 database backs branch runs: [env.preview] in wrangler.toml binds a preview D1 (its own database_id) and NO /learn/* route. Branch workflow_dispatch applies schema.sql to the preview D1 and uploads a preview version there; the main path applies schema.sql to the prod learn-ledger D1 and deploys the top-level (production) Worker. No branch run ever reads or writes prod D1. +- Branch preview is realized as a Cloudflare preview ENVIRONMENT: 'wrangler versions upload --env preview' uploads a non-promoted version of the learn-api-preview Worker (bound to the preview D1, no route, its own workers.dev preview URL). This composes Q1 (versions upload / no promote) with Q3 (separate preview D1), since a plain version of the top-level Worker cannot swap its D1 binding. From 93dc862bcb4e3ae285084256b21903f637281f5e Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 19:42:56 +0300 Subject: [PATCH 02/10] chore(t5): bump version to 0.7.0 + changelog for the deploy pipeline Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3f8f2..ccde65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-07-11 + +### Added + +- Deploy-worker CI: merges to main now deploy the learn-api Cloudflare Worker and apply the D1 schema automatically via .github/workflows/deploy-worker.yml. +- Branch-preview verification: a workflow_dispatch path runs wrangler versions upload --env preview against a separate preview D1, so the pipeline can be exercised without touching production. +- CI-synced Worker secrets: production and preview secrets are pushed from CI on every relevant run, keeping the deployed Worker config in lockstep with the repo. + ## [0.6.0] - 2026-07-11 ### Added diff --git a/pyproject.toml b/pyproject.toml index f7f120f..9829d50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "learn-cli" -version = "0.6.0" +version = "0.7.0" description = "A CLI, MCP server, and web front for learning — a hosted site where humans and agents learn a subject step by step. Fronts the french-cli and spanish-cli language tutors, and generalizes to other learnable domains such as leading teams of agents. The learn command is the local operator surface and how agents drive it." readme = "README.md" license = "Apache-2.0" From 54ddd8632fcdb9e399d3de5a96c7657ae8203e90 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 19:43:14 +0300 Subject: [PATCH 03/10] feat(t1): add [env.preview] to wrangler.toml (learn-api-preview, preview D1, no route) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- workers/learn-api/wrangler.toml | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/workers/learn-api/wrangler.toml b/workers/learn-api/wrangler.toml index 484082a..836f690 100644 --- a/workers/learn-api/wrangler.toml +++ b/workers/learn-api/wrangler.toml @@ -119,3 +119,49 @@ database_id = "c32b7bf6-b0a8-4c22-86f7-b50b3aa22a2e" # --- Observability ---------------------------------------------------------- [observability] enabled = true + +# --- Preview environment (task t1) ------------------------------------------ +# A separate, non-production Worker + D1 so a `workflow_dispatch` deploy from +# ANY branch can prove the deploy pipeline green without ever touching prod. +# Cloudflare env bindings do NOT inherit from the top-level config, so every +# binding the preview Worker needs is repeated below. Deliberately NO +# `routes`/`route` key here: the production `/learn/*` zone route stays owned +# by the top-level Worker above, and a preview env must never bind it. Deploy +# with `wrangler versions upload --env preview` (a preview-only, non-promoting +# upload) — never `wrangler deploy --env preview`. +[env.preview] +name = "learn-api-preview" + +# --- Preview vars ------------------------------------------------------------ +# Mirrors the top-level [vars] block so the preview Worker is functional. +# INFERENCE_URL stays commented, exactly like top-level, until tutoring is +# wired up for this environment too. +[env.preview.vars] +PUBLIC_URL = "https://agentculture.org" # origin used to build the OAuth callback +APP_URL = "https://agentculture.org/learn/" # where the web callback redirects post-login +CORS_ORIGIN = "https://agentculture.org" # allow the /learn site to call the API with creds +PAGES_ORIGIN = "https://agentculture-learn.pages.dev" # static-site origin the /learn zone mount proxies to +# INFERENCE_URL = "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-pro-v1:0/converse" +ADMIN_GITHUB_IDS = "20955789" + +# --- Preview KV: sessions namespace ------------------------------------------ +# A dedicated preview KV namespace (not the prod one) for cleaner isolation — +# a preview run's device-flow bookkeeping and revocation tombstones never mix +# with production session state. Create with: +# wrangler kv namespace create SESSIONS_PREVIEW +# then replace the placeholder id below with the id that command prints. +[[env.preview.kv_namespaces]] +binding = "SESSIONS" +id = "PLACEHOLDER_PREVIEW_KV_ID" + +# --- Preview D1: learner ledger ---------------------------------------------- +# Own preview database, provisioned once by the operator: +# wrangler d1 create learn-ledger-preview +# wrangler d1 execute learn-ledger-preview --file schema.sql +# then replace the placeholder database_id below with the id that command +# prints (mirrors how the production database_id above is a real, filled-in +# value). +[[env.preview.d1_databases]] +binding = "DB" +database_name = "learn-ledger-preview" +database_id = "PLACEHOLDER_PREVIEW_D1_ID" From 95e4e6c8916b141e61ba4be06388a5d1881d5659 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 19:48:16 +0300 Subject: [PATCH 04/10] =?UTF-8?q?feat(t2):=20add=20deploy-worker.yml=20?= =?UTF-8?q?=E2=80=94=20merge=20deploys=20prod,=20dispatch=20previews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- .github/workflows/deploy-worker.yml | 172 ++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 .github/workflows/deploy-worker.yml diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml new file mode 100644 index 0000000..ec7d711 --- /dev/null +++ b/.github/workflows/deploy-worker.yml @@ -0,0 +1,172 @@ +name: Deploy Worker + +# learn-cli's dynamic face: the learn-api Cloudflare Worker (GitHub OAuth +# sessions, the learner ledger, the tutoring broker) plus its D1 schema +# (learn-ledger). This workflow owns ONLY the Worker + D1 — NOT the Astro +# site (deploy-site.yml owns site-astro/ and Cloudflare Pages); do not add +# site steps here. +# +# Two paths, one workflow, gated purely by github.ref: +# +# - PUSH TO MAIN (or a workflow_dispatch run whose ref IS main): the +# PRODUCTION path. Applies workers/learn-api/schema.sql to the live +# `learn-ledger` D1 with --remote, syncs the 5 Worker secrets to +# production, then `wrangler deploy` promotes the top-level (production) +# wrangler.toml config. This is the ONLY path that ever touches +# production. +# - WORKFLOW_DISPATCH from any OTHER branch: the PREVIEW path. Applies +# schema.sql to the `learn-ledger-preview` D1, syncs the 5 Worker secrets +# to the `preview` environment, then `wrangler versions upload --env +# preview` — a non-promoting upload that proves the deploy pipeline green +# without ever touching production. It NEVER runs `wrangler deploy` and +# never promotes anything to prod. +# +# Every production/promote/prod-D1 step below is guarded by +# `github.ref == 'refs/heads/main'`; every preview step is guarded by the +# complementary `github.ref != 'refs/heads/main'`, so the two paths can never +# both fire in one run. workflow_dispatch deliberately ignores the `paths:` +# filter below (GitHub's own behavior) — that's intentional, so a branch +# preview run can always be forced regardless of what changed. +# +# Operator prerequisites (one-time, before the preview path can go green): +# 1. `wrangler d1 create learn-ledger-preview`, then paste the printed +# database_id into workers/learn-api/wrangler.toml's +# [env.preview.d1_databases] block (replacing PLACEHOLDER_PREVIEW_D1_ID). +# 2. The 5 Worker secrets referenced below — SESSION_SECRET, +# GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, INFERENCE_TOKEN, +# VOICE_TOKEN_SECRET — must exist as GitHub Actions repository secrets, +# alongside CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. +# Until both are done, a branch workflow_dispatch run fails red — expected, +# not a bug in this workflow. + +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 + + # ----------------------------------------------------------------- + # PREVIEW path — workflow_dispatch from any branch OTHER than main. + # Targets the [env.preview] config only; never deploys/promotes to + # production. + # ----------------------------------------------------------------- + + - name: "[preview] Apply D1 schema (learn-ledger-preview)" + 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-preview --remote --file schema.sql + + - name: "[preview] 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 --env preview + printf '%s' "${{ secrets.GITHUB_CLIENT_ID }}" | npx wrangler@4 secret put GITHUB_CLIENT_ID --env preview + printf '%s' "${{ secrets.GITHUB_CLIENT_SECRET }}" | npx wrangler@4 secret put GITHUB_CLIENT_SECRET --env preview + printf '%s' "${{ secrets.INFERENCE_TOKEN }}" | npx wrangler@4 secret put INFERENCE_TOKEN --env preview + printf '%s' "${{ secrets.VOICE_TOKEN_SECRET }}" | npx wrangler@4 secret put VOICE_TOKEN_SECRET --env preview + + # Non-promoting: uploads a preview version of the Worker without ever + # making it the production deployment. This is the ONLY upload command + # the preview path is allowed to run. + - name: "[preview] wrangler versions upload" + if: >- + env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' && + github.ref != 'refs/heads/main' + working-directory: workers/learn-api + run: npx wrangler@4 versions upload --env preview + + - name: Deployment summary + if: env.CLOUDFLARE_API_TOKEN != '' && env.CLOUDFLARE_ACCOUNT_ID != '' + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + { + echo '### Deployed learn-api Worker (production)' + echo '' + echo '- D1 schema applied to `learn-ledger` (--remote)' + echo '- Worker secrets synced to production' + echo '- `wrangler deploy` promoted the top-level (production) config' + } >> "$GITHUB_STEP_SUMMARY" + else + { + echo '### Uploaded learn-api Worker (preview)' + echo '' + echo '- D1 schema applied to `learn-ledger-preview` (--remote)' + echo '- Worker secrets synced to the `preview` environment' + echo '- `wrangler versions upload --env preview` — non-promoting; production untouched' + } >> "$GITHUB_STEP_SUMMARY" + fi From 1709827292bfde665f5a4aaa4ea710a533c5555c Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 19:54:31 +0300 Subject: [PATCH 05/10] =?UTF-8?q?docs(t4):=20README=20deploy=20section=20?= =?UTF-8?q?=E2=80=94=20CI=20pipeline,=20operator=20prereqs,=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- workers/learn-api/README.md | 159 ++++++++++++++++++++++++++++++------ 1 file changed, 132 insertions(+), 27 deletions(-) diff --git a/workers/learn-api/README.md b/workers/learn-api/README.md index ed62a72..ec4aa1b 100644 --- a/workers/learn-api/README.md +++ b/workers/learn-api/README.md @@ -248,33 +248,141 @@ secrets in a git-ignored `.dev.vars` file (see below). ## Deploy status: two phases -The go-live is staged. **Phase 1 (signed-out) is LIVE** at - (deployed 2026-07-11); **Phase 2 (signed-in) -is pending** two operator-held credentials. - -| | Phase 1 — signed-out (LIVE) | Phase 2 — signed-in (pending) | +The go-live was staged. **Phase 1 (signed-out) went LIVE** at + on 2026-07-11, and **Phase 2 (signed-in)** — +sessions, ledger, tutoring broker — was provisioned and deployed the same day +(see `CHANGELOG.md` 0.5.3). Both phases are live today; `wrangler.signedout.toml` +is kept only as a historical record of the Phase-1 config and is **never** +deployed by CI (`tests/test_deploy_pipeline_invariants.py` asserts this). + +| | Phase 1 — signed-out (historical) | Phase 2 — signed-in (LIVE) | | --- | --- | --- | | Config | [`wrangler.signedout.toml`](wrangler.signedout.toml) | [`wrangler.toml`](wrangler.toml) | | Serves | Static `/learn/*` proxied to Pages; `/api/*` returns 401 | + sessions, ledger, tutoring | | KV / D1 | none | KV `SESSIONS` + D1 `learn-ledger` | -| Secrets | none | `SESSION_SECRET`, `GITHUB_CLIENT_SECRET`, `INFERENCE_TOKEN` | +| Secrets | none | `SESSION_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `INFERENCE_TOKEN`, `VOICE_TOKEN_SECRET` | | Token perms | Cloudflare Pages: Edit **+** Workers Scripts: Edit | **+** Workers KV Storage: Edit **+** D1: Edit | -| Deploy | `wrangler deploy -c wrangler.signedout.toml` | `wrangler deploy` | +| Deploy | (superseded — see "CI deploy pipeline" below) | `.github/workflows/deploy-worker.yml`, merge to `main` | -The signed-out tier touches no storage and spends no inference — `/api/me` and +The signed-out tier touched no storage and spent no inference — `/api/me` and `POST /api/tutor` return `401` *before* any KV/D1 access (proven live by the -launch gate's "signed-out fires ONLY GET /api/me" walk). That is why Phase 1 -deploys with a Pages+Workers token and nothing else. +launch gate's "signed-out fires ONLY GET /api/me" walk); that invariant still +holds under the full Phase-2 config, just gated one level deeper (consent, +then approval — see the invariants documented above). + +## CI deploy pipeline -**Phase 1 was deployed with the repo's `.env` token, which carries Pages: Edit -and Workers Scripts: Edit but NOT KV/D1.** To do Phase 2 self-serve, that token -needs **Workers KV Storage: Edit** and **D1: Edit** added (or swap in a token -that has them). +`.github/workflows/deploy-worker.yml` is the routine way this Worker ships — +**merging to `main` is the deploy**; nobody runs `wrangler deploy` from a +laptop for the normal case anymore (a manual fallback still exists for when +CI itself is unavailable — see "Manual fallback / break-glass" below). One +workflow, two paths, gated purely by `github.ref`: -## Provisioning Phase 2 (signed-in) +| | Push to `main` (production) | `workflow_dispatch` off any other branch (preview) | +| --- | --- | --- | +| D1 schema | `wrangler d1 execute learn-ledger --remote --file schema.sql` | `wrangler d1 execute learn-ledger-preview --remote --file schema.sql` | +| Secrets | synced to production | synced to the `preview` environment (`--env preview`) | +| Deploy step | `wrangler deploy` — promotes the top-level (production) `wrangler.toml` | `wrangler versions upload --env preview` — uploads a non-promoted preview version | +| Route touched | `/learn/*` (live) | none — `[env.preview]` declares no route | + +`workflow_dispatch` deliberately ignores the `push` path filter +(`workers/learn-api/**`), so a preview run can always be forced regardless of +what changed. Every production/promote/prod-D1 step is guarded by +`github.ref == 'refs/heads/main'`; every preview step is guarded by the +complementary `github.ref != 'refs/heads/main'`, so the two paths can never +both fire in the same run. Every secret is piped over stdin +(`printf '%s' "$VALUE" | wrangler secret put NAME`), never a CLI argument, so +no value can appear in a process list or a CI log. + +If `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` are not both set on the +repo, the deploy/upload steps are skipped with a `::notice::` and a +job-summary note — the rest of the workflow still runs, which validates the +pipeline's shape even before Cloudflare credentials exist. + +### One-time operator setup + +Before the preview path (and, for the Cloudflare credentials, the production +path too) can go green, an operator does the following once: + +1. **Provision the preview D1**: run `wrangler d1 create learn-ledger-preview`, + then paste the printed `database_id` into the `[env.preview.d1_databases]` + block in `wrangler.toml`, replacing the `PLACEHOLDER_PREVIEW_D1_ID` + placeholder. Do the same for the preview KV namespace — run + `wrangler kv namespace create SESSIONS_PREVIEW` and paste its id into + `[env.preview.kv_namespaces]`, replacing `PLACEHOLDER_PREVIEW_KV_ID`. +2. **Create the GitHub Actions repository secrets** the pipeline reads: + - `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` — the deploy + credentials (Pages: Edit + Workers Scripts: Edit + Workers KV Storage: + Edit + D1: Edit). + - `SESSION_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, + `INFERENCE_TOKEN`, `VOICE_TOKEN_SECRET` — the five Worker secrets, synced + by CI to both the production and preview environments on every relevant + run. + +Until both steps are done, a branch `workflow_dispatch` run fails red (the +preview D1/KV ids are still placeholders) — expected, not a bug in the +workflow. + +### Preview-env OAuth-callback caveat + +The preview Worker (`learn-api-preview`) has no `routes` binding, so it is +only reachable at its own `*.workers.dev` origin — which is **not** the +registered GitHub OAuth app callback +(`https://agentculture.org/learn/api/auth/callback`, see step 1 below). A full +authed sign-in against preview will fail GitHub's `redirect_uri` check unless +a second, preview-scoped OAuth app is registered with a matching +`*.workers.dev` callback. Absent that, the preview target verifies +**unauthenticated** probes only — `/api/health`, the `401` shape of +session-gated routes, and so on — which is still enough to prove the pipeline +mechanics that matter: the build succeeds, the version uploads, the preview D1 +schema applies cleanly, and the bindings resolve. The full +consented/approved/tutoring flows stay proven against the real topology by +`consent_walk.mjs`'s LOCAL mode and the Worker's own unit suite (see +"Testing" below), and against production by the post-merge verify next. + +### Post-merge verify + +After a push to `main` deploys, confirm the live surface actually flipped: -These steps are done once, by the operator, before the signed-in deploy. -Nothing here is committed. +```bash +LIVE_ORIGIN=https://agentculture.org node tools/launch-gate/consent_walk.mjs +``` + +Compare the result to +[`tools/launch-gate/BASELINE-2026-07-11.md`](../../tools/launch-gate/BASELINE-2026-07-11.md): +every check recorded there as failing pre-uplift (`404` routes/pages) must now +read `PASS` (routes exist and reject unauth with `401`; pages serve `200`), +per that file's "Post-deploy: re-run to green" section. + +**Idempotency (run-twice) check.** `schema.sql` is entirely +`CREATE ... IF NOT EXISTS` statements, so re-running the pipeline against +unchanged source must be a no-op: the schema-apply step changes nothing on +the second run, and `wrangler deploy` (which does not reset or delete +already-set Worker secrets) redeploys behavior-identically with secrets +intact. Verify by triggering the workflow twice in a row (a no-op commit, or +re-running the job) and confirming the second run's job summary reports the +same deploy, the `wrangler d1 execute` step exits clean, and a live +`GET /learn/api/me` with an existing session cookie still `200`s afterward +(secrets were not rotated out from under it). + +### Manual fallback / break-glass + +Local `wrangler deploy` is no longer the routine path, but it still works as +an escape hatch if CI itself is unavailable: authenticate with +`wrangler login` (or a Cloudflare API token with the same +Pages/Workers Scripts/KV/D1 Edit scopes CI uses), then from +`workers/learn-api/` run the same two commands the workflow runs — +`wrangler d1 execute learn-ledger --remote --file schema.sql` and +`wrangler deploy`. Prefer fixing the pipeline over reaching for this; a manual +deploy bypasses the CI audit trail the pipeline exists to provide. + +## Initial provisioning reference (historical) + +These steps were performed once, by the operator, to stand up the signed-in +tier originally (2026-07-11 — see `CHANGELOG.md` 0.5.3/0.5.4/0.7.0). They are +kept here as a disaster-recovery reference (e.g. re-provisioning production +KV/D1 from scratch) — **not** as the routine deploy path, which is now the CI +pipeline above. Nothing here is committed. ### 1. Create the GitHub OAuth app @@ -347,17 +455,14 @@ GITHUB_CLIENT_SECRET = "..." INFERENCE_TOKEN = "..." ``` -### 4. Deploy - -```bash -wrangler deploy # uses wrangler.toml — full stack, same /learn/* route -``` +### 4. Deploy (historical — now done by CI) -The `agentculture.org/learn/*` zone route is already live (Phase 1) and is -declared in `wrangler.toml`, so this deploy upgrades the *same* worker in place -— the route does not move. Confirm the signed-in path end-to-end afterward -(`GET /learn/api/me` with a real session cookie should `200`), then merge org's -Learn-nav link to open public discovery. +The original Phase-2 deploy ran `wrangler deploy` directly (recorded in +`CHANGELOG.md` 0.5.3) against the `agentculture.org/learn/*` zone route +already live from Phase 1. That command still works as the manual fallback +(see "Manual fallback / break-glass" above), but every deploy since has +gone through `.github/workflows/deploy-worker.yml` — see "CI deploy +pipeline" above for the routine path. ## Endpoint shapes for downstream waves From aaea8d14f5356d2bcd779e9139b008817fd94577 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 19:59:11 +0300 Subject: [PATCH 06/10] test(t3): static invariant guard for the deploy-worker pipeline Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- tests/test_deploy_pipeline_invariants.py | 343 +++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 tests/test_deploy_pipeline_invariants.py diff --git a/tests/test_deploy_pipeline_invariants.py b/tests/test_deploy_pipeline_invariants.py new file mode 100644 index 0000000..18217eb --- /dev/null +++ b/tests/test_deploy_pipeline_invariants.py @@ -0,0 +1,343 @@ +"""Mechanical boundary invariants of the deploy-worker CI pipeline. + +Task t3 of the 2026-07-11 "learn-cli ships a one-command-free deployment +pipeline" plan (``docs/plans/2026-07-11-learn-cli-ships-a-one-command-free- +deployment-pipe.md``). ``.github/workflows/deploy-worker.yml`` (t2) is the +*only* thing that ever runs ``wrangler deploy`` — a mistake there silently +promotes an untested Worker to production, or leaks a secret into a process +list, or drifts the schema out of idempotency and breaks a re-run. None of +that shows up as a Python test failure on its own, so this file locks the +workflow's and ``schema.sql``'s *shape* directly: + +1. The production ``wrangler deploy`` step and the production D1 apply step + (``d1 execute learn-ledger --remote``) are both gated to + ``github.ref == 'refs/heads/main'`` (or a job-level + ``environment: production``) — the boundary that keeps a branch + ``workflow_dispatch`` run from ever touching prod. +2. The workflow never shells out to the SAM CLI and never references + ``infra/`` — the voice-bridge stack (task t16) is out of scope for this + pipeline entirely. +3. The workflow deploys the top-level ``workers/learn-api/wrangler.toml`` + and never ``wrangler.signedout.toml``. +4. Every ``wrangler secret put`` line pipes its value over stdin + (``printf '%s' "$VALUE" | wrangler secret put NAME``) — a secret value + must never appear as a literal CLI argument, where it would land in a + process listing. All five Worker secrets are synced on both the prod and + the preview path. +5. ``schema.sql`` is idempotent: every ``CREATE TABLE``/``CREATE INDEX`` + statement uses ``IF NOT EXISTS``, so re-applying it against a live D1 is a + safe no-op. +6. The preview path only ever runs the non-promoting + ``wrangler versions upload --env preview``; the promoting + ``wrangler deploy`` appears exactly once and only under the main-ref + guard — the two paths can never both fire in one run (h3/h11). + +Deliberately pure file reads (no subprocess, no network, no wrangler/gh +invocation) — same rationale as ``test_launch_gate_invariants.py``: these +lock *source shape* and stay hermetic so the default suite is fast and +offline. The workflow is parsed as text/regex rather than through a YAML +library (PyYAML is a project dependency and available, but a plain-text, +step-block split avoids YAML's own gotchas here — e.g. a bare ``on:`` key +being cast to the boolean ``True`` under the YAML 1.1 rules PyYAML's +``safe_load`` still applies — and keeps this file dependency-free, matching +``test_launch_gate_invariants.py``'s own style). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +WORKFLOW_YML = ROOT / ".github" / "workflows" / "deploy-worker.yml" +SCHEMA_SQL = ROOT / "workers" / "learn-api" / "schema.sql" + +_SECRET_NAMES = ( + "SESSION_SECRET", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "INFERENCE_TOKEN", + "VOICE_TOKEN_SECRET", +) + + +def _read(path: Path) -> str: + assert path.is_file(), f"expected {path} to exist" + return path.read_text(encoding="utf-8") + + +# --- text-only step splitting ------------------------------------------------- +# Each GitHub Actions step starts with a `- name:` or `- uses:` marker at the +# step-list indentation. Slicing the raw text at those markers keeps a step's +# `if:`/`working-directory:`/`run:` lines together for block-local regex +# checks, without needing a YAML parser. + +_STEP_START_RE = re.compile(r"^( *)- (?:name|uses):", re.M) + + +def _step_blocks(text: str) -> list[str]: + starts = [m.start() for m in _STEP_START_RE.finditer(text)] + assert starts, "expected at least one `- name:`/`- uses:` step marker in the workflow" + starts.append(len(text)) + return [text[starts[i] : starts[i + 1]] for i in range(len(starts) - 1)] + + +def _step_matching(blocks: list[str], run_re: re.Pattern[str]) -> str: + hits = [b for b in blocks if run_re.search(b)] + assert len(hits) == 1, ( + f"expected exactly one step whose `run:` matches {run_re.pattern!r}, found {len(hits)}: " + f"{hits!r}" + ) + return hits[0] + + +def _strip_comment_lines(text: str) -> str: + """Drop every full-line ``#`` comment. + + deploy-worker.yml's own header/step comments narrate the same invariants + this file checks mechanically — a comment literally says "never + wrangler.signedout.toml" (documenting the boundary, not violating it), so + a naive substring search over the raw text would trip on the prose + *asserting* the invariant. Mirrors test_launch_gate_invariants.py's SQL + comment-stripping (``line.split("--", 1)[0]``) for the same reason: check + the code, not the commentary about the code. + """ + return "\n".join(line for line in text.splitlines() if not line.strip().startswith("#")) + + +# --- ref-gating helpers (acceptance 1 & 6) ------------------------------------ + +_MAIN_REF_RE = re.compile(r"github\.ref\s*==\s*['\"]refs/heads/main['\"]") +_NOT_MAIN_REF_RE = re.compile(r"github\.ref\s*!=\s*['\"]refs/heads/main['\"]") +_PROD_ENVIRONMENT_RE = re.compile(r"^\s*environment:\s*['\"]?production['\"]?\s*$", re.M) + + +def _job_declares_production_environment(text: str) -> bool: + # Job-level keys (env:, permissions:, environment:, ...) sit before the + # first step marker, so restrict the search to that header region. + first_step = _STEP_START_RE.search(text) + header = text[: first_step.start()] if first_step else text + return bool(_PROD_ENVIRONMENT_RE.search(header)) + + +def _requires_main_ref(step_block: str) -> bool: + return bool(_MAIN_REF_RE.search(step_block)) + + +def _requires_non_main_ref(step_block: str) -> bool: + return bool(_NOT_MAIN_REF_RE.search(step_block)) + + +def _is_prod_gated(step_block: str, text: str) -> bool: + return _requires_main_ref(step_block) or _job_declares_production_environment(text) + + +# --- command-matching regexes -------------------------------------------------- +# All four require the `npx wrangler@` prefix so a match only fires against +# an actual invocation line, never against the "Deployment summary" step's +# narrative `echo '... `wrangler deploy` ...'` text, which names the same +# commands in prose without the `npx` prefix. + +_PROD_D1_RUN_RE = re.compile(r"npx\s+wrangler@?\d*\s+d1\s+execute\s+learn-ledger\s+--remote\b") +_PREVIEW_D1_RUN_RE = re.compile( + r"npx\s+wrangler@?\d*\s+d1\s+execute\s+learn-ledger-preview\s+--remote\b" +) +_PROD_DEPLOY_RUN_RE = re.compile(r"npx\s+wrangler@?\d*\s+deploy\b") +_PREVIEW_UPLOAD_RUN_RE = re.compile(r"npx\s+wrangler@?\d*\s+versions\s+upload\s+--env\s+preview\b") + +# A real `sam` CLI invocation, not a false positive on "same"/"same-origin". +_SAM_CLI_RE = re.compile(r"\bsam\s+(?:deploy|build|package|sync|validate)\b") + +_SECRET_PUT_LINE_RE = re.compile( + r"printf\s+'%s'\s+\"\$\{\{\s*secrets\.(\w+)\s*\}\}\"\s*\|\s*" + r"npx\s+wrangler@?\d*\s+secret\s+put\s+(\w+)(?:\s+--env\s+\S+)?" +) + + +# --- 1. prod deploy + prod D1 apply are gated to the main ref ----------------- + + +def test_prod_d1_apply_is_gated_to_main_ref() -> None: + text = _read(WORKFLOW_YML) + step = _step_matching(_step_blocks(text), _PROD_D1_RUN_RE) + assert _is_prod_gated(step, text), ( + "the `d1 execute learn-ledger --remote` (production) step must require " + "github.ref == 'refs/heads/main' (or a job-level environment: production); " + f"step was:\n{step}" + ) + + +def test_prod_wrangler_deploy_is_gated_to_main_ref() -> None: + text = _read(WORKFLOW_YML) + step = _step_matching(_step_blocks(text), _PROD_DEPLOY_RUN_RE) + assert _is_prod_gated(step, text), ( + "the `wrangler deploy` (production promote) step must require " + "github.ref == 'refs/heads/main' (or a job-level environment: production); " + f"step was:\n{step}" + ) + + +# --- 2. no sam / infra reference (voice bridge is out of scope) --------------- + + +def test_workflow_never_invokes_the_sam_cli() -> None: + code = _strip_comment_lines(_read(WORKFLOW_YML)) + match = _SAM_CLI_RE.search(code) + assert match is None, ( + "deploy-worker.yml must never shell out to the SAM CLI (voice bridge / " + f"infra/ is out of scope for this workflow); found {match!r}" + ) + + +def test_workflow_never_references_the_infra_directory() -> None: + code = _strip_comment_lines(_read(WORKFLOW_YML)) + assert "infra/" not in code, ( + "deploy-worker.yml must never reference infra/ (the SAM voice-bridge " + "stack is out of scope for this workflow)" + ) + + +# --- 3. deploys the top-level wrangler.toml, never wrangler.signedout.toml ---- + + +def test_workflow_never_deploys_the_signed_out_toml_variant() -> None: + # The string "wrangler.signedout.toml" appears once in the raw file today, + # but only inside a comment documenting the invariant ("never + # wrangler.signedout.toml") — strip comments first so this targets actual + # deploy commands, not prose that names the forbidden file while asserting + # it's avoided. + code = _strip_comment_lines(_read(WORKFLOW_YML)) + assert "wrangler.signedout.toml" not in code, ( + "the workflow must deploy the top-level workers/learn-api/wrangler.toml, " + "never wrangler.signedout.toml" + ) + + +def test_workflow_never_redirects_wrangler_at_a_different_config_file() -> None: + text = _read(WORKFLOW_YML) + assert "--config" not in text, ( + "no step may pass wrangler a --config flag pointing away from the " + "top-level workers/learn-api/wrangler.toml" + ) + + +# --- 4. secrets never appear as a CLI argument; all five are synced ----------- + + +def test_every_secret_put_line_pipes_the_value_over_stdin() -> None: + text = _read(WORKFLOW_YML) + lines = [ln.strip() for ln in text.splitlines() if "secret put" in ln] + assert lines, "expected at least one `wrangler secret put` invocation" + for line in lines: + match = _SECRET_PUT_LINE_RE.fullmatch(line) + assert match, ( + "every `secret put` must receive its value over stdin " + "(printf '%s' \"${{ secrets.X }}\" | wrangler secret put X [--env ...]), " + f"never as a literal CLI argument; offending line: {line!r}" + ) + piped_secret, put_name = match.group(1), match.group(2) + assert piped_secret == put_name, ( + f"piped secret {piped_secret!r} must match the `secret put` target name " + f"{put_name!r} on the same line: {line!r}" + ) + + +def _secret_put_lines(step_block: str) -> list[str]: + # Line-scoped on purpose: a step's trailing comment (e.g. "never an --env + # flag.", which sits between this step and the next marker and so is part + # of this text block) must not be mistaken for step content like a real + # `secret put ... --env preview` invocation. + return [ln.strip() for ln in step_block.splitlines() if "secret put" in ln] + + +def test_all_five_worker_secrets_are_synced_on_the_prod_path() -> None: + text = _read(WORKFLOW_YML) + blocks = _step_blocks(text) + prod_secret_blocks = [b for b in blocks if _secret_put_lines(b) and _requires_main_ref(b)] + assert prod_secret_blocks, "expected a main-ref-gated step that syncs Worker secrets" + lines = [ln for b in prod_secret_blocks for ln in _secret_put_lines(b) if "--env" not in ln] + missing = [ + name for name in _SECRET_NAMES if not any(f"secret put {name}" in ln for ln in lines) + ] + assert not missing, f"prod secret sync (no --env, i.e. production) is missing: {missing}" + + +def test_all_five_worker_secrets_are_synced_on_the_preview_path() -> None: + text = _read(WORKFLOW_YML) + blocks = _step_blocks(text) + preview_secret_blocks = [ + b for b in blocks if _secret_put_lines(b) and _requires_non_main_ref(b) + ] + assert preview_secret_blocks, "expected a non-main-ref-gated step that syncs Worker secrets" + lines = [ln for b in preview_secret_blocks for ln in _secret_put_lines(b) if "--env" in ln] + missing = [ + name for name in _SECRET_NAMES if not any(f"secret put {name}" in ln for ln in lines) + ] + assert not missing, f"preview secret sync (--env preview) is missing: {missing}" + + +# --- 5. schema.sql is idempotent ----------------------------------------------- + +_CREATE_ANY_RE = re.compile(r"\bcreate\s+(?:unique\s+)?(?:table|index)\b", re.I) +_CREATE_IDEMPOTENT_RE = re.compile( + r"\bcreate\s+(?:unique\s+)?(?:table|index)\s+if\s+not\s+exists\b", re.I +) + + +def test_schema_statements_are_all_created_idempotently() -> None: + sql = _read(SCHEMA_SQL) + code = "\n".join(line.split("--", 1)[0] for line in sql.splitlines()) + all_creates = _CREATE_ANY_RE.findall(code) + idempotent_creates = _CREATE_IDEMPOTENT_RE.findall(code) + assert all_creates, "expected at least one CREATE TABLE/INDEX statement in schema.sql" + assert len(idempotent_creates) == len(all_creates), ( + "every CREATE TABLE/INDEX in schema.sql must use IF NOT EXISTS so a " + f"re-run is a safe no-op (found {len(all_creates)} CREATE statement(s), " + f"{len(idempotent_creates)} idempotent)" + ) + + +# --- 6. preview safety: non-promoting upload, promote only under main -------- + + +def test_preview_path_uses_non_promoting_versions_upload() -> None: + text = _read(WORKFLOW_YML) + step = _step_matching(_step_blocks(text), _PREVIEW_UPLOAD_RUN_RE) + assert _requires_non_main_ref(step), ( + "`wrangler versions upload --env preview` must run only off a non-main " + f"ref (branch dispatch), never on push-to-main; step was:\n{step}" + ) + assert not _PROD_DEPLOY_RUN_RE.search( + step + ), "the preview upload step must never also run `wrangler deploy` (promote)" + + +def test_wrangler_deploy_promote_appears_only_once_and_only_under_main_guard() -> None: + text = _read(WORKFLOW_YML) + blocks = _step_blocks(text) + deploy_blocks = [b for b in blocks if _PROD_DEPLOY_RUN_RE.search(b)] + assert len(deploy_blocks) == 1, ( + "expected exactly one step that runs `wrangler deploy` (the sole " + f"promote path), found {len(deploy_blocks)}" + ) + step = deploy_blocks[0] + assert _is_prod_gated( + step, text + ), "the `wrangler deploy` step must require github.ref == 'refs/heads/main'" + assert not _requires_non_main_ref(step), ( + "the `wrangler deploy` step must never also be reachable on the " + "non-main (preview) condition" + ) + + +def test_preview_and_prod_paths_are_mutually_exclusive_by_construction() -> None: + # Belt-and-suspenders on h3/h11: the prod D1/deploy steps require + # ref == main, the preview D1/upload steps require ref != main, so no + # single workflow run can ever satisfy both — encoded here so a future + # edit that relaxes either guard to an unconditional step trips a test. + text = _read(WORKFLOW_YML) + blocks = _step_blocks(text) + prod_step = _step_matching(blocks, _PROD_DEPLOY_RUN_RE) + preview_step = _step_matching(blocks, _PREVIEW_UPLOAD_RUN_RE) + assert _requires_main_ref(prod_step) and not _requires_non_main_ref(prod_step) + assert _requires_non_main_ref(preview_step) and not _requires_main_ref(preview_step) From 3f48845b93cf178b6a2433feabb4ffff9a3f9481 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 20:02:12 +0300 Subject: [PATCH 07/10] fix(lint): relax MD034 for devague-exported specs/plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- docs/plans/.markdownlint-cli2.yaml | 3 +++ docs/specs/.markdownlint-cli2.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docs/plans/.markdownlint-cli2.yaml b/docs/plans/.markdownlint-cli2.yaml index fed4a5c..1d7b523 100644 --- a/docs/plans/.markdownlint-cli2.yaml +++ b/docs/plans/.markdownlint-cli2.yaml @@ -5,6 +5,8 @@ # MD026: the H1 is the announcement sentence and ends with punctuation. # MD033: claim/instruction text may contain tokens # (e.g. "learn subject doctor ") that read as inline HTML. +# MD034: claim/instruction text may quote a bare URL verbatim +# (e.g. "LIVE_ORIGIN=https://agentculture.org launch gate"). config: default: true MD013: false @@ -13,3 +15,4 @@ config: siblings_only: true MD026: false MD033: false + MD034: false diff --git a/docs/specs/.markdownlint-cli2.yaml b/docs/specs/.markdownlint-cli2.yaml index 0ce2492..5760fce 100644 --- a/docs/specs/.markdownlint-cli2.yaml +++ b/docs/specs/.markdownlint-cli2.yaml @@ -5,6 +5,8 @@ # MD026: the H1 is the announcement sentence and ends with punctuation. # MD033: claim/instruction text may contain tokens # (e.g. "learn subject doctor ") that read as inline HTML. +# MD034: claim/instruction text may quote a bare URL verbatim +# (e.g. "LIVE_ORIGIN=https://agentculture.org launch gate"). config: default: true MD013: false @@ -13,3 +15,4 @@ config: siblings_only: true MD026: false MD033: false + MD034: false From 04c996ed03c5850f128e4b1b69a9097e7dc60eca Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 20:03:56 +0300 Subject: [PATCH 08/10] chore: sync uv.lock to 0.7.0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 4730450..7eebdb9 100644 --- a/uv.lock +++ b/uv.lock @@ -474,7 +474,7 @@ wheels = [ [[package]] name = "learn-cli" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "agentfront", extra = ["mcp"] }, From de0e542584f379680db781cf6b1438424ae5ed5f Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 20:10:37 +0300 Subject: [PATCH 09/10] fix(ci): skip empty secrets in deploy-worker sync (never clobber prod) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- .github/workflows/deploy-worker.yml | 49 ++++++++++++++++++------ CHANGELOG.md | 2 +- tests/test_deploy_pipeline_invariants.py | 17 ++++++++ 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml index ec7d711..20729dc 100644 --- a/.github/workflows/deploy-worker.yml +++ b/.github/workflows/deploy-worker.yml @@ -92,18 +92,33 @@ jobs: # 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. + # list or shell history. Each put is guarded by a non-empty check: an + # unset/empty Actions secret is SKIPPED, leaving the deployed value + # unchanged, so we never clobber a live prod secret with "". This lets + # optional secrets (INFERENCE_TOKEN, VOICE_TOKEN_SECRET) stay unset while + # tutoring/voice are off, and protects pre-existing prod secrets on the + # first run before they are mirrored into GitHub Actions. - 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 + if [ -n "${{ secrets.SESSION_SECRET }}" ]; then + printf '%s' "${{ secrets.SESSION_SECRET }}" | npx wrangler@4 secret put SESSION_SECRET + else echo "::notice::SESSION_SECRET unset in Actions — leaving deployed value unchanged"; fi + if [ -n "${{ secrets.GITHUB_CLIENT_ID }}" ]; then + printf '%s' "${{ secrets.GITHUB_CLIENT_ID }}" | npx wrangler@4 secret put GITHUB_CLIENT_ID + else echo "::notice::GITHUB_CLIENT_ID unset in Actions — leaving deployed value unchanged"; fi + if [ -n "${{ secrets.GITHUB_CLIENT_SECRET }}" ]; then + printf '%s' "${{ secrets.GITHUB_CLIENT_SECRET }}" | npx wrangler@4 secret put GITHUB_CLIENT_SECRET + else echo "::notice::GITHUB_CLIENT_SECRET unset in Actions — leaving deployed value unchanged"; fi + if [ -n "${{ secrets.INFERENCE_TOKEN }}" ]; then + printf '%s' "${{ secrets.INFERENCE_TOKEN }}" | npx wrangler@4 secret put INFERENCE_TOKEN + else echo "::notice::INFERENCE_TOKEN unset in Actions — leaving deployed value unchanged"; fi + if [ -n "${{ secrets.VOICE_TOKEN_SECRET }}" ]; then + printf '%s' "${{ secrets.VOICE_TOKEN_SECRET }}" | npx wrangler@4 secret put VOICE_TOKEN_SECRET + else echo "::notice::VOICE_TOKEN_SECRET unset in Actions — leaving deployed value unchanged"; fi # The only step in this workflow allowed to touch production: promotes # the top-level (production) wrangler.toml config — never @@ -128,17 +143,29 @@ jobs: working-directory: workers/learn-api run: npx wrangler@4 d1 execute learn-ledger-preview --remote --file schema.sql + # Same non-empty guard as the prod step: an unset Actions secret is + # skipped, leaving the preview Worker's value unchanged. - name: "[preview] 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 --env preview - printf '%s' "${{ secrets.GITHUB_CLIENT_ID }}" | npx wrangler@4 secret put GITHUB_CLIENT_ID --env preview - printf '%s' "${{ secrets.GITHUB_CLIENT_SECRET }}" | npx wrangler@4 secret put GITHUB_CLIENT_SECRET --env preview - printf '%s' "${{ secrets.INFERENCE_TOKEN }}" | npx wrangler@4 secret put INFERENCE_TOKEN --env preview - printf '%s' "${{ secrets.VOICE_TOKEN_SECRET }}" | npx wrangler@4 secret put VOICE_TOKEN_SECRET --env preview + if [ -n "${{ secrets.SESSION_SECRET }}" ]; then + printf '%s' "${{ secrets.SESSION_SECRET }}" | npx wrangler@4 secret put SESSION_SECRET --env preview + else echo "::notice::SESSION_SECRET unset in Actions — leaving preview value unchanged"; fi + if [ -n "${{ secrets.GITHUB_CLIENT_ID }}" ]; then + printf '%s' "${{ secrets.GITHUB_CLIENT_ID }}" | npx wrangler@4 secret put GITHUB_CLIENT_ID --env preview + else echo "::notice::GITHUB_CLIENT_ID unset in Actions — leaving preview value unchanged"; fi + if [ -n "${{ secrets.GITHUB_CLIENT_SECRET }}" ]; then + printf '%s' "${{ secrets.GITHUB_CLIENT_SECRET }}" | npx wrangler@4 secret put GITHUB_CLIENT_SECRET --env preview + else echo "::notice::GITHUB_CLIENT_SECRET unset in Actions — leaving preview value unchanged"; fi + if [ -n "${{ secrets.INFERENCE_TOKEN }}" ]; then + printf '%s' "${{ secrets.INFERENCE_TOKEN }}" | npx wrangler@4 secret put INFERENCE_TOKEN --env preview + else echo "::notice::INFERENCE_TOKEN unset in Actions — leaving preview value unchanged"; fi + if [ -n "${{ secrets.VOICE_TOKEN_SECRET }}" ]; then + printf '%s' "${{ secrets.VOICE_TOKEN_SECRET }}" | npx wrangler@4 secret put VOICE_TOKEN_SECRET --env preview + else echo "::notice::VOICE_TOKEN_SECRET unset in Actions — leaving preview value unchanged"; fi # Non-promoting: uploads a preview version of the Worker without ever # making it the production deployment. This is the ONLY upload command diff --git a/CHANGELOG.md b/CHANGELOG.md index ccde65e..0d3395e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Deploy-worker CI: merges to main now deploy the learn-api Cloudflare Worker and apply the D1 schema automatically via .github/workflows/deploy-worker.yml. - Branch-preview verification: a workflow_dispatch path runs wrangler versions upload --env preview against a separate preview D1, so the pipeline can be exercised without touching production. -- CI-synced Worker secrets: production and preview secrets are pushed from CI on every relevant run, keeping the deployed Worker config in lockstep with the repo. +- CI-synced Worker secrets: production and preview secrets are pushed from CI on every relevant run, keeping the deployed Worker config in lockstep with the repo. Each sync is guarded by a non-empty check, so an unset Actions secret is skipped rather than clobbering a live deployed secret with an empty value. ## [0.6.0] - 2026-07-11 diff --git a/tests/test_deploy_pipeline_invariants.py b/tests/test_deploy_pipeline_invariants.py index 18217eb..a86faa7 100644 --- a/tests/test_deploy_pipeline_invariants.py +++ b/tests/test_deploy_pipeline_invariants.py @@ -276,6 +276,23 @@ def test_all_five_worker_secrets_are_synced_on_the_preview_path() -> None: assert not missing, f"preview secret sync (--env preview) is missing: {missing}" +def test_secret_sync_skips_unset_values_to_avoid_clobbering_deployed_secrets() -> None: + # Each `secret put` must be guarded by a non-empty check on the same + # Actions secret, so an unset secret is SKIPPED (leaving the deployed + # value unchanged) rather than overwriting a live prod secret with "". + # Without this, the first merge would clobber pre-existing prod secrets + # and the always-unset optional secrets (INFERENCE_TOKEN, VOICE_TOKEN_SECRET) + # would wipe or fail on every run. + text = _read(WORKFLOW_YML) + for name in _SECRET_NAMES: + guard = '-n "${{ secrets.' + name + ' }}"' + assert guard in text, ( + f"{name} secret-put must be guarded by an emptiness check " + f"({guard!r}); an unset Actions secret must be skipped, never piped " + "as an empty value that clobbers the deployed secret" + ) + + # --- 5. schema.sql is idempotent ----------------------------------------------- _CREATE_ANY_RE = re.compile(r"\bcreate\s+(?:unique\s+)?(?:table|index)\b", re.I) From 6e2fdc107cedef68da41dfe6fde39bc6ef320597 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sat, 11 Jul 2026 21:00:22 +0300 Subject: [PATCH 10/10] =?UTF-8?q?docs(hosting):=20add=20'How=20it=20deploy?= =?UTF-8?q?s'=20=E2=80=94=20the=20CI=20pipeline=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --- docs/hosting.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/hosting.md b/docs/hosting.md index a01cc8d..c9efb6f 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -25,6 +25,55 @@ never signs in costs nothing beyond static bandwidth (free) and cannot trigger a model call. This is enforced in code and proven by test (`workers/learn-api/test/worker.test.js`). +## How it deploys (CI pipeline) + +Both surfaces deploy from CI on merge to `main` — **no local `wrangler`**. Two +sibling GitHub Actions workflows, each path-filtered so it only runs when its +surface changes: + +| Workflow | Deploys | Triggers | +| --- | --- | --- | +| `.github/workflows/deploy-site.yml` | the static shell + tour (Cloudflare **Pages**, project `agentculture-learn`) | push to `main` on `site-astro/**`; PR = preview alias; `workflow_dispatch` | +| `.github/workflows/deploy-worker.yml` | the `learn-api` **Worker** + the **D1 schema** | push to `main` on `workers/learn-api/**`; `workflow_dispatch` = preview | + +The Worker workflow has two paths (added in `learn-cli 0.7.0`, PR #14): + +- **Merge to main → production.** Applies `schema.sql` to the `learn-ledger` D1 + `--remote` (idempotent — every statement is `CREATE … IF NOT EXISTS`, so + re-applying is a no-op), syncs the Worker secrets, and runs `wrangler deploy` + on the top-level `wrangler.toml` (the config with the `/learn/*` route). + **Consequence: merging any PR that touches `workers/learn-api/**` performs a + real production Worker deploy.** +- **Branch `workflow_dispatch` → preview.** Runs `wrangler versions upload + --env preview` (uploads a non-promoted version with its own `*.workers.dev` + URL) against a **separate preview D1** declared in the `[env.preview]` block + (`learn-api-preview`, `learn-ledger-preview`, **no route**). Production — + deployed version, route, and D1 — is never touched by a branch run. The + dispatch button only appears once the workflow is on the default branch, so + the branch-verify path is available **after** the first merge. + +Load-bearing safety properties (guarded by +`tests/test_deploy_pipeline_invariants.py`): + +- Every production / promote / prod-D1 step is gated to + `github.ref == 'refs/heads/main'`; a branch run can never reach them. +- Secret sync pipes each value over **stdin** (never a CLI argument) and is + **guarded by a non-empty check** — an unset GitHub Actions secret is *skipped*, + leaving the deployed value unchanged, so a deploy never clobbers a live prod + secret with `""` (and the intentionally-unset optional secrets + `INFERENCE_TOKEN` / `VOICE_TOKEN_SECRET`, while tutoring/voice are off, don't + wipe or fail). +- The workflows never touch `infra/` — the SAM voice bridge stays a separate + manual `sam deploy`. + +**Operator setup and the step-by-step runbook** (provisioning the preview D1, +the GitHub Actions secrets to create, the post-merge verify via the LIVE launch +gate, and the OAuth-callback caveat for preview) live in +[`workers/learn-api/README.md`](../workers/learn-api/README.md) — the single +source of truth. The design rationale (why a preview *environment* + separate +D1, why CI-synced secrets) is in the converged spec, +[`docs/specs/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md`](specs/2026-07-11-learn-cli-ships-a-one-command-free-deployment-pipe.md). + ## Cost-when-busy Rough order-of-magnitude figures (Cloudflare pricing as of 2026; confirm