Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
name: 🚀 Deploy to production
timeout-minutes: 8
timeout-minutes: 12
needs: sha-guard
if: needs.sha-guard.outputs.should_deploy == 'true'
environment:
Expand Down Expand Up @@ -153,6 +153,20 @@ jobs:
set -euo pipefail
node tools/ci/production-resources.ts ensure --out-config packages/worker/wrangler-production.generated.json | tee -a "$GITHUB_OUTPUT"

# Package runtime worker (ADR 0016). Shares the main worker's data
# plane, so it derives resource ids from the generated main config and
# patches the main config's cross-script references in place.
- name: 🧩 Generate runtime worker config
id: runtime_config
run: |
set -euo pipefail
node tools/ci/runtime-worker-config.ts generate \
--env production \
--main-config packages/worker/wrangler-production.generated.json \
--worker-name kody-runtime \
--main-worker-name kody \
--out-config packages/runtime-worker/wrangler-production.generated.json | tee -a "$GITHUB_OUTPUT"

- name: 🔐 Sync Cloudflare Secrets (bulk)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand Down Expand Up @@ -185,6 +199,39 @@ jobs:
--set-from-env-optional STRIPE_SECRET_KEY --set-from-env-optional
STRIPE_WEBHOOK_SECRET

- name: 🔐 Sync Cloudflare Secrets to runtime worker (bulk)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
WRANGLER_CONFIG:
${{ steps.runtime_config.outputs.runtime_wrangler_config }}
COOKIE_SECRET: ${{ secrets.COOKIE_SECRET }}
SECRET_STORE_KEY: ${{ secrets.SECRET_STORE_KEY }}
AI_GATEWAY_ID: ${{ secrets.AI_GATEWAY_ID }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
CAPABILITY_REINDEX_SECRET: ${{ secrets.CAPABILITY_REINDEX_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.OAUTH_GITHUB_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.OAUTH_GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }}
X_CLIENT_ID: ${{ secrets.OAUTH_X_CLIENT_ID }}
X_CLIENT_SECRET: ${{ secrets.OAUTH_X_CLIENT_SECRET }}
KIT_API_KEY: ${{ secrets.KIT_API_KEY }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
run: >
node tools/ci/sync-worker-secrets.ts --env production --config
"$WRANGLER_CONFIG" --set-from-env COOKIE_SECRET
--set-from-env-optional SECRET_STORE_KEY --set-from-env-optional
AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN
--set-from-env-optional CLOUDFLARE_API_TOKEN --set-from-env-optional
CAPABILITY_REINDEX_SECRET --set-from-env-optional GITHUB_CLIENT_ID
--set-from-env-optional GITHUB_CLIENT_SECRET --set-from-env-optional
GOOGLE_CLIENT_ID --set-from-env-optional GOOGLE_CLIENT_SECRET
--set-from-env-optional X_CLIENT_ID --set-from-env-optional
X_CLIENT_SECRET --set-from-env-optional KIT_API_KEY
--set-from-env-optional STRIPE_SECRET_KEY --set-from-env-optional
STRIPE_WEBHOOK_SECRET

Comment on lines +346 to +361

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

The runtime Worker receives the main Worker's full secret set in both workflows. Both secret-sync paths copy every application secret to kody-runtime and to ${APP_WORKER_NAME}-runtime, although the runtime handler in packages/worker/src/runtime-worker.ts only serves package apps, package invocation, and the health path. Billing, waiting-list, and OAuth flows stay on the main Worker, so the extra secrets widen the blast radius of a runtime-Worker compromise without a functional need. Define one shared allowlist of runtime-lane secrets and use it in both places.

  • .github/workflows/deploy.yml#L202-L234: drop STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, KIT_API_KEY, and the OAuth client id/secret pairs from the runtime sync command and its env block; keep only the secrets that the runtime lane reads.
  • .github/workflows/preview.yml#L344-L354: replace the second --from-dotenv "$ENV_FILE" --from-dotenv "$OVERRIDES_FILE" sync for $RUNTIME_WORKER_NAME with an explicit --set/--set-from-env-optional list limited to the same runtime-lane secrets.
📍 Affects 2 files
  • .github/workflows/deploy.yml#L202-L234 (this comment)
  • .github/workflows/preview.yml#L344-L354
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 202 - 234, Define and apply one
shared allowlist of runtime-lane secrets across both sync paths. In
.github/workflows/deploy.yml:202-234, update the runtime sync env block and
command to remove STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, KIT_API_KEY, and all
OAuth client ID/secret pairs, retaining only secrets consumed by the runtime
lane. In .github/workflows/preview.yml:344-354, replace the dotenv-based runtime
sync with explicit --set or --set-from-env-optional entries for exactly the same
allowlisted secrets.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 290f2d0. The production runtime sync now uses a strict runtime-lane allowlist: COOKIE_SECRET, SECRET_STORE_KEY, AI_GATEWAY_ID, SENTRY_DSN, CLOUDFLARE_API_TOKEN — Stripe, Kit, OAuth client pairs, and CAPABILITY_REINDEX_SECRET no longer reach kody-runtime (removed from both the env block and the command). Preview intentionally keeps the dotenv-based sync (see the new comment in preview.yml): those files contain only .env.example placeholders plus per-PR mock-endpoint overrides that runtime capability execution needs, and no production secret is present in either file.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

- name: 🗄️ Apply D1 Migrations
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand All @@ -193,6 +240,27 @@ jobs:
node ./wrangler-env.ts d1 migrations apply APP_DB --remote --config "$WRANGLER_CONFIG"
node ./wrangler-env.ts d1 migrations apply AUDIT_DB --remote --config "$WRANGLER_CONFIG"

# Deploys before the main worker: the main worker's cross-script
# Durable Object / service bindings require the kody-runtime script to
# exist. On the first production deploy this also runs the
# transferred_classes migration; see
# docs/contributing/architecture/runtime-worker-migration-runbook.md.
- name: ☁️ Deploy runtime worker to Cloudflare Workers
id: deploy_runtime
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
DEPLOY_COMMIT_SHA: ${{ needs.sha-guard.outputs.deploy_sha }}
WRANGLER_CONFIG:
${{ steps.runtime_config.outputs.runtime_wrangler_config }}
run: |
set -euo pipefail
npm run deploy -- --config "$WRANGLER_CONFIG" --var "APP_COMMIT_SHA:${DEPLOY_COMMIT_SHA}" 2>&1 | tee deploy-runtime.log

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for custom-domain handover steps in the runtime worker migration runbook.
fd -t f 'runtime-worker-migration-runbook.md' --exec rg -n -C 4 -i 'custom domain|kodyapps|detach|route'

Repository: kentcdodds/kody

Length of output: 2483


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- workflow deployment context ---'
sed -n '200,290p' .github/workflows/deploy.yml

echo '--- migration runbook ---'
fd -t f 'runtime-worker-migration-runbook.md' --exec sed -n '1,180p' {}

echo '--- custom-domain configuration and migration references ---'
rg -n -C 4 -i 'kodyapps\.dev|custom[_ -]?domain|customDomain|detach|transfer|runtime-worker' \
  .github tools docs wrangler.toml '*.json' '*.yml' '*.yaml' 2>/dev/null || true

Repository: kentcdodds/kody

Length of output: 50374


🌐 Web query:

Cloudflare Workers custom domain already assigned to another Worker deploy transfer ownership existing custom domain Wrangler

💡 Result:

When you encounter the error stating that a custom domain is already assigned to another Cloudflare Worker, you cannot automatically transfer ownership via a single Wrangler command [1][2]. Cloudflare prevents multiple workers from being assigned to the same custom domain or route to ensure routing stability [1][2]. To resolve this and move the domain to your desired Worker, you must manually unassign the custom domain from the existing Worker first [1][2]. Steps to transfer the domain: 1. Identify the worker currently assigned to the domain: The Wrangler error message typically identifies the worker name and provides a link to the Cloudflare dashboard [1][3]. 2. Unassign the domain: Log in to the Cloudflare dashboard, navigate to the Workers overview page or the specific worker currently using the domain, and remove the custom domain or route association [1][2]. 3. Deploy your new worker: Once the domain is unassigned, run your wrangler deploy command again [1][2]. The new worker will now be able to claim the custom domain successfully [1]. There is currently no native "override" or "force transfer" flag in Wrangler to automate this process because the Cloudflare API requires explicit unassignment to prevent configuration conflicts [4][2][5]. If you are managing many workers, you may need to design your workflow to explicitly unassign routes as part of your CI/CD process before deploying the new version [2]. For advanced scenarios where you need to route many domains, consider Cloudflare for Platforms (formerly Cloudflare for SaaS), which allows for more flexible programmatic hostname management [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

workflow = Path(".github/workflows/deploy.yml").read_text()
runbook = Path("docs/contributing/architecture/runtime-worker-migration-runbook.md").read_text()
runtime_config = Path("tools/ci/runtime-worker-config.ts").read_text()
resource_utils = Path("tools/ci/resource-utils.ts").read_text()

checks = {
    "runtime deploy precedes main deploy": workflow.index(
        "Deploy runtime worker to Cloudflare Workers"
    ) < workflow.index("Deploy to Cloudflare Workers"),
    "runbook has no custom-domain handover command": not any(
        term in runbook.lower()
        for term in ("detach", "unassign", "custom domain handover", "remove custom domain")
    ),
    "runtime config attaches package-app host": "runtimeEnv.routes = [{ pattern: hostname, custom_domain: true }]" in runtime_config,
    "main config excludes package-app host": "package-app host itself is attached to the runtime Worker" in resource_utils,
    "workflow has no explicit custom-domain removal": not any(
        term in workflow.lower()
        for term in ("delete custom domain", "remove custom domain", "unassign", "workers/domains")
    ),
}

for name, result in checks.items():
    print(f"{name}: {'yes' if result else 'no'}")
PY

Repository: kentcdodds/kody

Length of output: 386


Add an explicit kodyapps.dev handover before deploying kody-runtime.

The runbook and workflow do not remove the domain from kody. Cloudflare rejects assigning a custom domain that another Worker owns, so the first production deploy fails unless the workflow unassigns kodyapps.dev before the runtime deploy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 243 - 257, Add a handover step
before the “Deploy runtime worker to Cloudflare Workers” step that explicitly
removes the kodyapps.dev custom domain from the kody Worker using the existing
Cloudflare credentials and API conventions. Ensure the handover completes before
the runtime deployment runs, while preserving the current deployment command and
configuration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — fixed in the runbook (290f2d0). Cloudflare refuses to attach a custom domain that another Worker still owns, so the first production kody-runtime deploy would fail while kody holds kodyapps.dev. The runbook now has a dedicated step: detach kodyapps.dev from kody in the dashboard immediately before merging, with the explicit note that package-app traffic on that domain is unserved between the detach and the runtime deploy's attach (one-time only; later deploys find the domain already on kody-runtime).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.


RUNTIME_URL="$(node -e "const fs = require('node:fs'); const t = fs.readFileSync('deploy-runtime.log','utf8'); const m = t.match(/https:\\/\\/[a-zA-Z0-9._-]+\\.workers\\.dev/g); process.stdout.write(m?.at(-1) ?? '')")"
if [ -n "$RUNTIME_URL" ]; then
echo "url=$RUNTIME_URL" >> "$GITHUB_OUTPUT"
fi

Comment on lines +370 to +432

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

The runtime deploy lacks the retry and ANSI handling that the main deploy has.

Three defects in this step:

  1. No retry. The main deploy at lines 275-313 retries workflows.api.error.internal_server and Cloudflare code: 10001. The runtime Worker also creates a Workflow and Durable Object namespaces, so it faces the same transient failures. A single transient error fails the production deploy before the main Worker is deployed.
  2. The URL extraction at line 259 does not strip ANSI escape codes. .github/workflows/preview.yml line 284 strips them before matching. If wrangler colorizes the URL, the character class [a-zA-Z0-9._-]+ stops at the escape sequence and the match is empty or truncated. RUNTIME_URL is then unset, and the runtime healthcheck at lines 388-391 exits 1 with Missing runtime worker deploy URL output.
  3. The extraction takes the last *.workers.dev match in the log without anchoring to the runtime Worker name. The preview step anchors on $RUNTIME_WORKER_NAME. An unrelated URL printed by wrangler produces a healthcheck against the wrong host.
🐛 Proposed fix for the URL extraction
-          RUNTIME_URL="$(node -e "const fs = require('node:fs'); const t = fs.readFileSync('deploy-runtime.log','utf8'); const m = t.match(/https:\\/\\/[a-zA-Z0-9._-]+\\.workers\\.dev/g); process.stdout.write(m?.at(-1) ?? '')")"
+          RUNTIME_URL="$(node -e 'const fs = require("node:fs"); const text = fs.readFileSync("deploy-runtime.log", "utf8").replace(/\u001b\[[0-9;]*m/g, ""); const worker = process.argv[1]; const escaped = worker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const workerUrlRegex = new RegExp(`https://(?:[a-zA-Z0-9-]+\\.)?${escaped}\\.[a-zA-Z0-9._-]+\\.workers\\.dev`, "g"); const matches = text.match(workerUrlRegex); process.stdout.write(matches?.at(-1) ?? "")' kody-runtime)"

For the retry, extract the is_retryable_deploy_failure helper and the attempt loop into a shared script and call it from both deploy steps.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 243 - 263, Update the runtime
deploy step identified by deploy_runtime to reuse the shared
is_retryable_deploy_failure helper and deployment-attempt loop used by the main
deploy, including retries for workflows.api.error.internal_server and Cloudflare
code 10001. Before extracting the URL, remove ANSI escape sequences from
deploy-runtime.log, then match the URL specifically on RUNTIME_WORKER_NAME
rather than selecting the last workers.dev URL; preserve the existing GitHub
output behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three fixed in 290f2d0: (1) the runtime deploy now retries up to 3 attempts with exponential backoff on the same retryable signatures as the main deploy (workflows.api.error.internal_server, Cloudflare code: 10001), checked against an ANSI-stripped log; (2) URL extraction strips ANSI escapes before matching; (3) the workers.dev URL regex is anchored on the kody-runtime worker name (passed as an argument), matching the preview step's approach.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

- name: ☁️ Deploy to Cloudflare Workers
id: deploy
env:
Expand Down Expand Up @@ -309,6 +377,43 @@ jobs:
fi
exit 1

- name: 🩺 Healthcheck runtime worker (production)
shell: bash
env:
RUNTIME_URL: ${{ steps.deploy_runtime.outputs.url }}
EXPECTED_COMMIT_SHA: ${{ needs.sha-guard.outputs.deploy_sha }}
run: |
set -euo pipefail

if [ -z "${RUNTIME_URL:-}" ]; then
echo "Missing runtime worker deploy URL output; cannot run healthcheck." >&2
exit 1
fi

HEALTHCHECK_URL="${RUNTIME_URL%/}/__runtime/health"
echo "Healthcheck URL: $HEALTHCHECK_URL"

attempts=20
delay_seconds=3

for i in $(seq 1 "$attempts"); do
echo "Attempt $i/$attempts"
if curl --fail --silent --show-error --location --max-time 10 \
--header "Accept: application/json" \
"$HEALTHCHECK_URL" > runtime-health.json && \
node -e "const fs = require('node:fs'); const json = JSON.parse(fs.readFileSync('runtime-health.json','utf8')); const expected = process.env.EXPECTED_COMMIT_SHA; if (json?.status !== 'ok') { console.error('runtime-healthcheck-unexpected-response', json); process.exit(1); } if (json?.commitSha !== expected) { console.error('runtime-healthcheck-unexpected-commit-sha', { expected, actual: json?.commitSha }); process.exit(1); } console.log('runtime-healthcheck-ok', json);"; then
exit 0
fi
sleep "$delay_seconds"
done

echo "Runtime worker healthcheck failed after ${attempts} attempts: $HEALTHCHECK_URL" >&2
if [ -f runtime-health.json ]; then
echo "Last response body:" >&2
cat runtime-health.json >&2
fi
exit 1

Comment on lines +549 to +590

@coderabbitai coderabbitai Bot Aug 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the shared runtime worker health payload contract.
fd -t f 'runtime-worker.ts' -p packages/shared --exec rg -n -C 8 'buildRuntimeWorkerHealth|runtimeWorkerHealthPath|status|commitSha'

Repository: kentcdodds/kody

Length of output: 986


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for file in .github/workflows/deploy.yml .github/workflows/preview.yml; do
  echo "===== $file: healthcheck context ====="
  if [ "$file" = ".github/workflows/deploy.yml" ]; then
    sed -n '330,420p' "$file"
    echo "===== $file: SHA and runtime deployment references ====="
    rg -n -C 5 'deploy_sha|EXPECTED_COMMIT_SHA|deploy_runtime|commitSha|sha-guard' "$file"
  else
    sed -n '400,465p' "$file"
    echo "===== $file: SHA and runtime deployment references ====="
    rg -n -C 5 'EXPECTED_COMMIT_SHA|deploy_runtime|commitSha|github\.sha|sha-guard' "$file"
  fi
done

Repository: kentcdodds/kody

Length of output: 25344


Add fail-fast guards for EXPECTED_COMMIT_SHA. buildRuntimeWorkerHealth emits status and commitSha, so both field checks are correct. Add the guard used by the main healthchecks before polling in both runtime healthchecks. An empty expected SHA currently consumes the full retry budget before failing.

📍 Affects 2 files
  • .github/workflows/deploy.yml#L380-L416 (this comment)
  • .github/workflows/preview.yml#L423-L458
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 380 - 416, In both runtime
healthcheck blocks, add the existing fail-fast guard for EXPECTED_COMMIT_SHA
before polling begins: validate that the environment value is non-empty, print
an error to stderr, and exit 1 when missing. Apply this to
.github/workflows/deploy.yml lines 380-416 and .github/workflows/preview.yml
lines 423-458; leave the status and commitSha response checks unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 290f2d0: both runtime healthchecks (deploy.yml production and preview.yml) now fail fast with Missing expected commit SHA; cannot verify runtime deployment version. when EXPECTED_COMMIT_SHA is empty, before entering the polling loop — same guard the main healthchecks use.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

- name: 🧪 Execute smoke check (production)
shell: bash
env:
Expand Down
121 changes: 119 additions & 2 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
name: 🔎 Deploy Preview Resources
timeout-minutes: 4
timeout-minutes: 8
environment:
name:
preview-${{ github.event.pull_request.number || inputs.pr_number ||
Expand Down Expand Up @@ -138,6 +138,27 @@ jobs:
set -euo pipefail
node tools/ci/preview-resources.ts ensure --worker-name "$APP_WORKER_NAME" --out-config packages/worker/wrangler-preview.generated.json | tee -a "$GITHUB_OUTPUT"

# Package runtime worker (ADR 0016). Shares the preview data plane, so
# its config derives resource ids from the main generated config. The
# bootstrap variant of the main config breaks the fresh-pair circular
# reference (main binds runtime's Durable Objects, runtime binds
# main's): deploy order is main (bootstrap) → runtime → main (full).
- name: 🧩 Generate runtime worker preview config
id: runtime_config
env:
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }}
run: |
set -euo pipefail
node tools/ci/runtime-worker-config.ts generate \
--env preview \
--main-config "$WRANGLER_CONFIG" \
--worker-name "${APP_WORKER_NAME}-runtime" \
--main-worker-name "$APP_WORKER_NAME" \
--out-config packages/runtime-worker/wrangler-preview.generated.json \
--out-main-bootstrap-config packages/worker/wrangler-preview-bootstrap.generated.json | tee -a "$GITHUB_OUTPUT"
echo "runtime_worker_name=${APP_WORKER_NAME}-runtime" >> "$GITHUB_OUTPUT"

- name: 🗄️ Apply D1 Migrations (preview)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand Down Expand Up @@ -230,6 +251,41 @@ jobs:
echo "overrides_file=$OVERRIDES_FILE"
} >> "$GITHUB_OUTPUT"

- name: ☁️ Bootstrap preview app Worker (no runtime references)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_ENV: preview
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
WRANGLER_CONFIG:
${{ steps.runtime_config.outputs.main_bootstrap_wrangler_config }}
DEPLOY_COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
npm run deploy -- --name "$APP_WORKER_NAME" --config "$WRANGLER_CONFIG" --var "APP_COMMIT_SHA:${DEPLOY_COMMIT_SHA}"

- name: ☁️ Deploy preview runtime Worker
id: deploy_runtime
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_ENV: preview
RUNTIME_WORKER_NAME:
${{ steps.runtime_config.outputs.runtime_worker_name }}
WRANGLER_CONFIG:
${{ steps.runtime_config.outputs.runtime_wrangler_config }}
DEPLOY_COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
echo "worker_name=$RUNTIME_WORKER_NAME" >> "$GITHUB_OUTPUT"

npm run deploy -- --name "$RUNTIME_WORKER_NAME" --config "$WRANGLER_CONFIG" --var "APP_COMMIT_SHA:${DEPLOY_COMMIT_SHA}" 2>&1 | tee deploy-runtime.log

RUNTIME_URL="$(node -e 'const fs = require("node:fs"); const text = fs.readFileSync(process.argv[1], "utf8").replace(/\u001b\[[0-9;]*m/g, ""); const worker = process.argv[2]; const escaped = worker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const workerUrlRegex = new RegExp(`https://(?:[a-zA-Z0-9-]+\\.)?${escaped}\\.[a-zA-Z0-9._-]+\\.workers\\.dev`, "g"); const matches = text.match(workerUrlRegex); process.stdout.write(matches?.at(-1) ?? "")' deploy-runtime.log "$RUNTIME_WORKER_NAME")"
if [ -n "$RUNTIME_URL" ]; then
echo "url=$RUNTIME_URL" >> "$GITHUB_OUTPUT"
fi

- name: ☁️ Deploy preview app Worker
id: deploy_preview
env:
Expand Down Expand Up @@ -261,6 +317,10 @@ jobs:
APP_WORKER_NAME: ${{ steps.names.outputs.worker_name }}
OVERRIDES_FILE: ${{ steps.deploy_mocks.outputs.overrides_file }}
WRANGLER_CONFIG: ${{ steps.resources.outputs.wrangler_config }}
RUNTIME_WORKER_NAME:
${{ steps.runtime_config.outputs.runtime_worker_name }}
RUNTIME_WRANGLER_CONFIG:
${{ steps.runtime_config.outputs.runtime_wrangler_config }}
PREVIEW_URL: ${{ steps.deploy_preview.outputs.url }}
AI_GATEWAY_ID: ${{ secrets.AI_GATEWAY_ID_PREVIEW }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
Expand All @@ -281,9 +341,17 @@ jobs:
extra_args+=(--set "APP_BASE_URL=$PREVIEW_URL")
fi

# Both workers must share the session-signing and secret-store keys:
# the runtime worker reads app-origin session cookies for inline
# package apps and package-app handoffs.
PREVIEW_COOKIE_SECRET="$(openssl rand -hex 32)"
echo "::add-mask::$PREVIEW_COOKIE_SECRET"
extra_args+=(--set "COOKIE_SECRET=$PREVIEW_COOKIE_SECRET")

# Intentionally omit KIT_API_KEY so preview/E2E waiting-list joins use
# the non-production no-op path instead of writing to production Kit.
node tools/ci/sync-worker-secrets.ts --env "" --name "$APP_WORKER_NAME" --config "$WRANGLER_CONFIG" --from-dotenv "$ENV_FILE" --from-dotenv "$OVERRIDES_FILE" --set-from-env-optional AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN --generate-cookie-secret --include-empty "${extra_args[@]}"
node tools/ci/sync-worker-secrets.ts --env "" --name "$APP_WORKER_NAME" --config "$WRANGLER_CONFIG" --from-dotenv "$ENV_FILE" --from-dotenv "$OVERRIDES_FILE" --set-from-env-optional AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN --include-empty "${extra_args[@]}"
node tools/ci/sync-worker-secrets.ts --env "" --name "$RUNTIME_WORKER_NAME" --config "$RUNTIME_WRANGLER_CONFIG" --from-dotenv "$ENV_FILE" --from-dotenv "$OVERRIDES_FILE" --set-from-env-optional AI_GATEWAY_ID --set-from-env-optional SENTRY_DSN --include-empty "${extra_args[@]}"

- name: 📤 Upload Worker source maps to Sentry
env:
Expand Down Expand Up @@ -352,13 +420,52 @@ jobs:
fi
exit 1

- name: 🩺 Healthcheck runtime worker (preview)
shell: bash
env:
RUNTIME_URL: ${{ steps.deploy_runtime.outputs.url }}
EXPECTED_COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail

if [ -z "${RUNTIME_URL:-}" ]; then
echo "Missing runtime worker preview URL output; cannot run healthcheck." >&2
exit 1
fi

HEALTHCHECK_URL="${RUNTIME_URL%/}/__runtime/health"
echo "Healthcheck URL: $HEALTHCHECK_URL"

attempts=30
delay_seconds=3

for i in $(seq 1 "$attempts"); do
echo "Attempt $i/$attempts"
if curl --fail --silent --show-error --location --max-time 10 \
--header "Accept: application/json" \
"$HEALTHCHECK_URL" > runtime-health.json && \
node -e "const fs = require('node:fs'); const json = JSON.parse(fs.readFileSync('runtime-health.json','utf8')); const expected = process.env.EXPECTED_COMMIT_SHA; if (json?.status !== 'ok') { console.error('runtime-healthcheck-unexpected-response', json); process.exit(1); } if (json?.commitSha !== expected) { console.error('runtime-healthcheck-unexpected-commit-sha', { expected, actual: json?.commitSha }); process.exit(1); } console.log('runtime-healthcheck-ok', json);"; then
exit 0
fi
sleep "$delay_seconds"
done

echo "Runtime worker healthcheck failed after ${attempts} attempts: $HEALTHCHECK_URL" >&2
if [ -f runtime-health.json ]; then
echo "Last response body:" >&2
cat runtime-health.json >&2
fi
exit 1

- name: 🧾 Write preview link summary
if: steps.deploy_preview.outputs.url != ''
env:
PREVIEW_URL: ${{ steps.deploy_preview.outputs.url }}
PREVIEW_VERSION_URL:
'${{ steps.deploy_preview.outputs.version_preview_url }}'
WORKER_NAME: ${{ steps.deploy_preview.outputs.worker_name }}
RUNTIME_WORKER_NAME: ${{ steps.deploy_runtime.outputs.worker_name }}
RUNTIME_URL: ${{ steps.deploy_runtime.outputs.url }}
MOCK_SUMMARY: ${{ steps.deploy_mocks.outputs.mock_summary }}
D1_DATABASE_NAME: ${{ steps.resources.outputs.d1_database_name }}
OAUTH_KV_TITLE: ${{ steps.resources.outputs.oauth_kv_title }}
Expand All @@ -369,6 +476,9 @@ jobs:
echo "$PREVIEW_URL"
echo ""
printf 'Worker: `%s`\n' "$WORKER_NAME"
if [ -n "${RUNTIME_URL:-}" ]; then
printf 'Runtime worker: `%s` (%s)\n' "$RUNTIME_WORKER_NAME" "$RUNTIME_URL"
fi
if [ -n "${PREVIEW_VERSION_URL:-}" ]; then
printf 'Version preview: %s\n' "$PREVIEW_VERSION_URL"
fi
Expand All @@ -395,6 +505,8 @@ jobs:
PREVIEW_VERSION_URL:
'${{ steps.deploy_preview.outputs.version_preview_url }}'
WORKER_NAME: ${{ steps.deploy_preview.outputs.worker_name }}
RUNTIME_WORKER_NAME: ${{ steps.deploy_runtime.outputs.worker_name }}
RUNTIME_URL: ${{ steps.deploy_runtime.outputs.url }}
MOCK_SUMMARY: ${{ steps.deploy_mocks.outputs.mock_comment_summary }}
D1_DATABASE_NAME: ${{ steps.resources.outputs.d1_database_name }}
OAUTH_KV_TITLE: ${{ steps.resources.outputs.oauth_kv_title }}
Expand All @@ -407,6 +519,11 @@ jobs:
"",
`Worker: \`${process.env.WORKER_NAME}\``,
];
if (process.env.RUNTIME_URL?.trim()) {
bodyLines.push(
`Runtime worker: \`${process.env.RUNTIME_WORKER_NAME}\` (${process.env.RUNTIME_URL.trim()})`,
);
}
if (process.env.PREVIEW_VERSION_URL?.trim()) {
bodyLines.push(
`Version preview: ${process.env.PREVIEW_VERSION_URL.trim()}`,
Expand Down
4 changes: 4 additions & 0 deletions docs/contributing/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ to become.
- [Run records](./run-records.md): per-user execution history and logs across
every runtime surface (`RunLog` Durable Object, `runs` MCP domain,
`/account/activity`).
- [Runtime worker migration runbook](./runtime-worker-migration-runbook.md): the
coordinated deploy order and Durable Object script migration for extracting
the package runtime lane into the `kody-runtime` Worker
([ADR 0016](../decisions/0016-mono-worker-extraction.md)).
- [Primitives map](./primitives.yaml): stable taxonomy of system primitives and
invariants for the visual-recap skill
(`.agents/skills/visual-recap/SKILL.md`). It is not a living feature changelog
Expand Down
Loading
Loading