Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 14 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,17 @@ jobs:
# above does NOT validate tag existence, so without this guard the
# failure would surface at deploy time rather than PR time.
#
# Pin alignment between env blocks is workflow-aware:
# Pin alignment between the two configs is workflow-aware:
# dev-targeting PRs: pins may diverge (staging-leads-prod
# during the soak-then-promote workflow;
# see RELEASES.md § Sandbox image releases).
# Each pin is verified independently against
# the registry.
# main-targeting PRs: pins MUST be equal (released state).
# The release PR is expected to promote the
# top-level pin to match env.staging before
# merging.
# production pin in wrangler.jsonc to match
# the staging pin in wrangler.staging.jsonc
# before merging.
#
# Skipped on forks (secrets are unavailable to PRs from forks by
# default). Same-repo branches always hit it.
Expand All @@ -122,9 +123,9 @@ jobs:
run: |
set -euo pipefail

prod_img=$(bun x wrangler deploy --dry-run 2>&1 \
prod_img=$(bun x wrangler deploy --dry-run --config wrangler.jsonc 2>&1 \
| grep -oE 'registry\.cloudflare\.com/[^ )]+' | head -1 || true)
stg_img=$(bun x wrangler deploy --dry-run --env staging 2>&1 \
stg_img=$(bun x wrangler deploy --dry-run --config wrangler.staging.jsonc 2>&1 \
| grep -oE 'registry\.cloudflare\.com/[^ )]+' | head -1 || true)

if [ -z "$prod_img" ] || [ -z "$stg_img" ]; then
Expand All @@ -141,19 +142,19 @@ jobs:
fail=0

if echo "$images" | grep -qE "anc-sandbox[[:space:]]+${prod_tag}\b"; then
echo "verified: top-level pin anc-sandbox:${prod_tag} is in the CF managed registry"
echo "verified: production pin anc-sandbox:${prod_tag} (wrangler.jsonc) is in the CF managed registry"
else
echo "::error::top-level pin anc-sandbox:${prod_tag} not found in CF managed registry"
echo "::error::production pin anc-sandbox:${prod_tag} (wrangler.jsonc) not found in CF managed registry"
echo "did you forget: bun x wrangler containers build -p -t anc-sandbox:${prod_tag} docker/sandbox/"
fail=1
fi

if [ "$prod_tag" = "$stg_tag" ]; then
echo "verified: env.staging pin equals top-level pin (lockstep)"
echo "verified: staging pin equals production pin (lockstep)"
elif echo "$images" | grep -qE "anc-sandbox[[:space:]]+${stg_tag}\b"; then
echo "verified: env.staging pin anc-sandbox:${stg_tag} is in the CF managed registry"
echo "verified: staging pin anc-sandbox:${stg_tag} (wrangler.staging.jsonc) is in the CF managed registry"
else
echo "::error::env.staging pin anc-sandbox:${stg_tag} not found in CF managed registry"
echo "::error::staging pin anc-sandbox:${stg_tag} (wrangler.staging.jsonc) not found in CF managed registry"
echo "did you forget: bun x wrangler containers build -p -t anc-sandbox:${stg_tag} docker/sandbox/"
fail=1
fi
Expand All @@ -163,9 +164,9 @@ jobs:
# leads prod during development).
if [ "${BASE_REF}" = "main" ] && [ "$prod_tag" != "$stg_tag" ]; then
echo "::error::release PR to main has divergent image pins"
echo " top-level: anc-sandbox:${prod_tag}"
echo " env.staging: anc-sandbox:${stg_tag}"
echo "Promote the top-level pin to match env.staging before merging."
echo " wrangler.jsonc (prod): anc-sandbox:${prod_tag}"
echo " wrangler.staging.jsonc (stg): anc-sandbox:${stg_tag}"
echo "Promote the wrangler.jsonc pin to match wrangler.staging.jsonc before merging."
echo "See RELEASES.md § Sandbox image releases for the soak-and-promote procedure."
fail=1
fi
Expand Down
16 changes: 10 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ jobs:
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: deploy --env staging
# Two separate wrangler configs (production vs staging) — see
# the header comment in wrangler.staging.jsonc for the rationale
# (workaround for cloudflare/workers-sdk#13925).
command: deploy --config wrangler.staging.jsonc

production:
name: build + deploy production
Expand Down Expand Up @@ -134,8 +137,9 @@ jobs:
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
# --env="" tells wrangler explicitly to use the top-level config
# (the production environment in this repo's multi-env shape).
# Without it, wrangler 4.x warns about ambiguity even though
# bare `deploy` does the right thing.
command: deploy --env=""
# wrangler.jsonc is the default config so no --config flag is
# required. The split-config layout (wrangler.jsonc for prod,
# wrangler.staging.jsonc for staging) replaces the previous
# multi-env block; see wrangler.jsonc's header comment for the
# rationale (workaround for cloudflare/workers-sdk#13925).
command: deploy
60 changes: 43 additions & 17 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,42 @@ and regenerate. Hand-editing the generated artifact directly produces drift the

## Deploy

`.github/workflows/deploy.yml` runs on pushes to `dev` or `main`, targeting separate Workers via wrangler environments:
`.github/workflows/deploy.yml` runs on pushes to `dev` or `main`, targeting separate Workers via two independent
wrangler configs:

| Branch | Worker | Domain | Wrangler command |
| ------ | -------------------------- | -------------------------------------------------- | ------------------------------- |
| `dev` | `agentnative-site-staging` | `agentnative-site-staging.<subdomain>.workers.dev` | `wrangler deploy --env staging` |
| `main` | `agentnative-site` | `anc.dev` (custom domain, `workers_dev: false`) | `wrangler deploy` |
| Branch | Worker | Domain | Wrangler command |
| ------ | -------------------------- | -------------------------------------------------- | ------------------------------------------------- |
| `dev` | `agentnative-site-staging` | `agentnative-site-staging.<subdomain>.workers.dev` | `wrangler deploy --config wrangler.staging.jsonc` |
| `main` | `agentnative-site` | `anc.dev` (custom domain, `workers_dev: false`) | `wrangler deploy` |

The staging-host guard in `src/worker/headers.ts` adds `X-Robots-Tag: noindex` on any response served from a
`.workers.dev` host. Production at `anc.dev` gets full indexing.

Every response also carries an `X-Build-Env` header (`production`, `staging`, or `development` when running tests),
which doubles as a quick "which Worker is this?" diagnostic and as the source-level mechanism that keeps the two
compiled Worker scripts at distinct etags — see the next section.

### Why two separate wrangler configs (not a single multi-env file)

Standard Workers Assets is keyed in a way that lets one Worker's asset uploads shadow another Worker's served content
when the two compiled Worker scripts have identical bytes. Reproduction and discussion at
[cloudflare/workers-sdk#13925](https://github.com/cloudflare/workers-sdk/issues/13925).

To preserve real isolation between staging and production assets, this repo deploys from **two top-level wrangler
configs** rather than the more common single-config multi-env shape:

- `wrangler.jsonc` deploys the `agentnative-site` (production) Worker.
- `wrangler.staging.jsonc` deploys the `agentnative-site-staging` Worker.

Each config carries its own `define` block that substitutes `__BUILD_ENV__` (referenced in `src/worker/index.ts`) with a
literal string at deploy time (`"production"` vs `"staging"`). That single-character divergence is enough to give the
two compiled scripts distinct etags, which keeps their asset namespaces physically separate per CF's deduplication
keying. Without the substitution, staging deploys could silently override production-served asset content.

Keep both configs in sync for anything that isn't a deliberate per-env divergence (compatibility flags, observability,
asset settings, etc.). The sandbox image pin is the one canonical exception: it's deliberately split between
soak-then-promote envs (see "Sandbox image releases" below).

Manual deploys use `workflow_dispatch` with an explicit environment picker:

```bash
Expand Down Expand Up @@ -285,15 +311,15 @@ The live-scoring path uses a Cloudflare Containers binding that pins an Alpine +
`registry.cloudflare.com/<account-id>/anc-sandbox:<git-sha>`. Build is decoupled from deploy: a Worker code-only deploy
never rebuilds the image, and an image-only release never reships Worker code unintentionally.

`wrangler.jsonc` holds TWO independent image pins:
The two pins live in separate wrangler configs:

- `containers[0].image` (top-level) is the PRODUCTION pin. The `agentnative-site` Worker on `anc.dev` deploys from this
tag. Advances only at release time.
- `env.staging.containers[0].image` is the STAGING pin. The `agentnative-site-staging` Worker on
- `wrangler.jsonc` → `containers[0].image` is the PRODUCTION pin. The `agentnative-site` Worker on `anc.dev` deploys
from this tag. Advances only at release time.
- `wrangler.staging.jsonc` → `containers[0].image` is the STAGING pin. The `agentnative-site-staging` Worker on
`agentnative-site-staging.brettdavies.workers.dev` deploys from this tag. Advances independently during development.

The two pins may legitimately differ. Each env block owns its own container application with its own version history.
The pins describe what staging and prod each run, not a shared constraint.
The two pins may legitimately differ. Each config owns its own container application with its own version history. The
pins describe what staging and prod each run, not a shared constraint.

#### Default workflow: staging soak then promote

Expand All @@ -311,8 +337,8 @@ bun x wrangler containers build -p -t "anc-sandbox:$GIT_SHA" docker/sandbox/
The command runs `docker build` locally and pushes to the CF registry, authenticated via `CLOUDFLARE_API_TOKEN`. Output
ends with a `<git-sha>: digest: sha256:... size: ...` line confirming the push.

Update **only `env.staging.containers[0].image`** in `wrangler.jsonc` with the new tag. Leave the top-level (prod) pin
alone. Commit the Dockerfile change + the staging-pin update together. PR to `dev`.
Update **only `containers[0].image` in `wrangler.staging.jsonc`** with the new tag. Leave the production pin (in
`wrangler.jsonc`) alone. Commit the Dockerfile change + the staging-pin update together. PR to `dev`.

CI on a dev-targeting PR verifies the new staging tag exists in the registry; the prod pin keeps pointing at the
last-released tag, which also still exists (image-retention discipline). The CI guard accepts the divergence.
Expand All @@ -323,8 +349,8 @@ traffic on the staging.workers.dev URL.
**Promotion (release PR to main):**

When the image is ready to ship, cut a release branch from `main` and cherry-pick the dev commits as usual. Add one
promotion commit that bumps the top-level `containers[0].image` to match `env.staging.containers[0].image`. Open the PR
to `main`. CI on a main-targeting PR enforces TWO invariants:
promotion commit that bumps `containers[0].image` in `wrangler.jsonc` to match `wrangler.staging.jsonc`'s pin. Open the
PR to `main`. CI on a main-targeting PR enforces TWO invariants:

- both pins exist in the CF managed registry, AND
- both pins point at the same tag (released state)
Expand All @@ -345,8 +371,8 @@ FROM line.

#### Deploy never rebuilds

`wrangler deploy --env staging` (and `wrangler deploy` on main) against the fully-qualified registry URI does NOT
trigger a rebuild. The image was already published during the local `wrangler containers build -p` step.
`wrangler deploy --config wrangler.staging.jsonc` (and `wrangler deploy` on main) against the fully-qualified registry
URI does NOT trigger a rebuild. The image was already published during the local `wrangler containers build -p` step.

#### Image-retention discipline

Expand Down
12 changes: 6 additions & 6 deletions docker/sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ resolves the account ID from auth at push time. Push output ends with a line lik
<git-sha>: digest: sha256:... size: ...
```

Pin the resulting tag in `wrangler.jsonc`. The file holds two independent pins, and the choice of which one(s) to update
depends on the change:
Pin the resulting tag in one of the two wrangler configs. Each config holds its own `containers[0].image`, and the
choice of which one(s) to update depends on the change:

- For a normal sandbox change (any commit past the base-image FROM line): update only `env.staging.containers[0].image`.
The image soaks on staging, then a separate release PR to main promotes the top-level (prod) pin to match. This is the
default and what the CI guard expects.
- For a normal sandbox change (any commit past the base-image FROM line): update only `containers[0].image` in
`wrangler.staging.jsonc`. The image soaks on staging, then a separate release PR to main promotes the production pin
in `wrangler.jsonc` to match. This is the default and what the CI guard expects.
- For a low-risk bump (base-image security patch, dependency-only update with no behavior delta): update both pins in
lockstep. The CI guard accepts equal pins too.

Expand All @@ -43,7 +43,7 @@ soak-then-promote flow and the release-time invariant the main-targeting CI chec
After pinning, deploy without rebuilding:

```sh
bun x wrangler deploy --env staging
bun x wrangler deploy --config wrangler.staging.jsonc
# verify staging is healthy, then promote via the dev → main release flow
```

Expand Down
8 changes: 4 additions & 4 deletions scripts/hooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ bun run build
bold '==> bun test'
bun test

bold '==> bun x wrangler deploy --dry-run (production env)'
bun x wrangler deploy --dry-run --env=""
bold '==> bun x wrangler deploy --dry-run (production config)'
bun x wrangler deploy --dry-run --config wrangler.jsonc

bold '==> bun x wrangler deploy --dry-run (staging env)'
bun x wrangler deploy --dry-run --env staging
bold '==> bun x wrangler deploy --dry-run (staging config)'
bun x wrangler deploy --dry-run --config wrangler.staging.jsonc

bold '==> Pack-README drift check'
bun scripts/generate-pack-readme.mjs site --check </dev/null
Expand Down
12 changes: 12 additions & 0 deletions src/worker/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ export interface ApplyHeadersOptions {
request: Request;
servedMarkdown: boolean;
pathname: string;
/** Build-time env identifier. Surfaces in the `X-Build-Env` response
* header so it's trivial to confirm which Worker is serving a given
* response. The value differs between configs (`production` from
* wrangler.jsonc, `staging` from wrangler.staging.jsonc), which also
* forces distinct compiled script etags. See `src/worker/index.ts`. */
buildEnv: 'production' | 'staging' | 'development';
}

/** `true` when the Host header ends with `.workers.dev` — the staging origin. */
Expand Down Expand Up @@ -109,6 +115,12 @@ export function applyHeaders(response: Response, opts: ApplyHeadersOptions): Res
headers.set('X-Robots-Tag', 'noindex');
}

// Surface the build-time env identifier on every response. Diagnostic
// header (useful for confirming "which Worker is this") AND the reason
// the two Workers' compiled scripts have distinct etags — see
// `src/worker/index.ts`'s `__BUILD_ENV__` comment.
headers.set('X-Build-Env', opts.buildEnv);

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
Expand Down
21 changes: 20 additions & 1 deletion src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ import { applyHeaders } from './headers';
// implementation.
export { Sandbox } from './score/do';

// Build-time env identifier. Wrangler substitutes `__BUILD_ENV__` at
// deploy via the `define` block in each Worker's config:
// wrangler.jsonc → "production"
// wrangler.staging.jsonc → "staging"
//
// Why this constant exists: forcing the two compiled Worker scripts to
// have distinct bytes (and therefore distinct script etags) is the
// workaround for the Workers Assets cross-env asset-sharing bug filed
// at https://github.com/cloudflare/workers-sdk/issues/13925. Without
// some bytes-level divergence, an asset upload from staging silently
// overrides what production serves at the same URL path.
//
// The `typeof` guard keeps tests working: `bun test` does not run
// through wrangler's bundler, so `__BUILD_ENV__` is undeclared at
// test time and the fallback to 'development' kicks in.
declare const __BUILD_ENV__: 'production' | 'staging';
const BUILD_ENV: 'production' | 'staging' | 'development' =
typeof __BUILD_ENV__ !== 'undefined' ? __BUILD_ENV__ : 'development';

export interface Env {
ASSETS: Fetcher;
}
Expand Down Expand Up @@ -56,6 +75,6 @@ export default {
}

const upstream = await env.ASSETS.fetch(assetRequest);
return applyHeaders(upstream, { request, servedMarkdown, pathname });
return applyHeaders(upstream, { request, servedMarkdown, pathname, buildEnv: BUILD_ENV });
},
} satisfies ExportedHandler<Env>;
7 changes: 7 additions & 0 deletions styles/config/vocabularies/site/accept.txt
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,10 @@ yazi
yq
zoomable
zoxide
configs
etag
etags
deduplication
namespaces
envs
CF's
Loading
Loading