Overview

Status Note — 26 July 2026

This article documents the June 2026 CI/CD rollout across twelve consumer repositories, with atlas-infra acting as the control plane rather than another consumer. The estate has since added immutable workflow pins, README and runtime contract checks, action pinning, Worker /_meta validation, optional OpenAPI validation, and ADR-backed policy. The rollout incidents and design decisions below remain accurate to the period described.

The Atlas Systems estate grew quickly. Five Cloudflare Workers behind the api subdomain, three static sites on Cloudflare Pages, two Python libraries, and two private publishing-pipeline repos made twelve consumer repositories, with atlas-infra holding the shared workflow definitions. Every consumer was deployed by hand or by a Cloudflare integration that ran without any pre-deploy checks. A broken Worker shipped the moment I ran npx wrangler deploy. A malformed HTML tag shipped the moment Cloudflare's native Git integration saw a push to main. There were no gates anywhere.

This project replaced all of that with a consistent, observable pipeline shape per kind of repo, defined once in atlas-infra and replicated across the rollout scope by short caller files. Every deploy now passes a real gate before it reaches production. Every outcome posts to a dedicated Discord channel. Where the live site is concerned, broken HTML or a dead internal link cannot ship at all.

Background

The estate this project started from was the one built the previous month in Atlas Pipeline: ten repos with a working event bus and a first pass at automated deploys, but every Worker still running its own per-repo deploy.yml. That pattern was already uneven a few weeks in. atlas-notify had Vitest tests (12, covering all three of its auth dialects) but the test suite did not gate the deploy; tests and deploys ran in separate flows. The other four Workers had no tests and no lint. The three static sites had no validation. atlas-kit-python-rag had a good Python CI suite (ruff, mypy, pytest, 62% coverage with a live badge) but no Discord notification. atlas-infra had a dormant workflow_dispatch workflow and a Docker hello-world. The intent for atlas-infra to host canonical patterns was there in the docs; the patterns themselves were not.

The bar I set was specific. Would a senior engineer at a remote-first company look at the resulting estate and find it impressive or sloppy. Sloppy means ad-hoc deploys, no test gates, inconsistent config between repos, secrets named differently across projects, Discord channels that sometimes lie about what they contain. Impressive means consistent pipeline shape across every repo, automated gates that actually catch regressions before production, observable deploy history, infrastructure that documents itself.

Reusable Workflows as the Architecture

The decision that shaped everything else: every deployable repo would use one of two reusable workflows defined in atlas-infra. deploy-worker.yml for Cloudflare Workers and validate-static.yml for Cloudflare Pages sites. Each caller would be a thin file naming the reusable workflow, passing a label and a couple of flags, and forwarding secrets with secrets: inherit.

The alternative was keeping five separate deploy.yml files, the pattern from the previous month. They had already drifted in weeks, not months; one repo's wrangler.toml used zone_name on a route while the others used zone_id, the exact gap this piece resolves properly later on. The rollout established one source definition for each pipeline shape. The current estate pins each caller to an immutable atlas-infra commit, so workflow changes do not float into consumers at queue time. A deploy-shape change is authored once in atlas-infra, then propagated through explicit pin-update pull requests, keeping adoption auditable and reviewable.

Design Decision

Every deployable repo calls a reusable workflow defined in atlas-infra. New repos start from a short caller file, and production callers pin an immutable control-plane revision rather than a moving branch.

Worker workflow shape

The Worker workflow runs validate then deploy then notify. Validate runs npx wrangler deploy --dry-run --outdir dist unconditionally; this bundles the Worker without publishing and exits non-zero on import errors, missing bindings the bundler can see, or syntax issues. Optional lint and test steps run after the dry-run if the caller sets run_lint: true or run_tests: true. Deploy uses wrangler directly with the production token. Notify posts a Discord embed via curl and jq, green or red, with the commit short SHA, the actor, and a link to the run logs.

The later control-plane revision expanded that validation surface with README-contract enforcement, immutable action pins, optional /_meta checks, and optional OpenAPI validation. Those additions are subsequent evolution, not claims about the first rollout recorded here.

Static-site workflow shape

The static-site workflow has the same shape but with html-validate plus an offline lychee link check in place of wrangler dry-run, and wrangler pages deploy in place of wrangler deploy. Both workflows use if: always() on the notify job so failures get reported with the same fidelity as successes.

Gating atlas-notify Through workflow_run

atlas-notify is the only Worker with a real test suite. Wiring its deploy to the same push trigger as the other four would mean running Vitest twice on every push to main: once in its existing ci.yml (the established source of truth for test results, posted to the ci-cd channel), and once again inside the deploy workflow's validate job. Two runs, two Discord posts, two places to trip when one of them gives a different answer.

I used GitHub's workflow_run trigger instead. The deploy workflow waits for the CI workflow to complete on main, and only proceeds when CI's conclusion was success.

```yaml on: workflow_run: workflows: ["CI"] types: [completed] branches: [main]

jobs: deploy: if: ${{ github.event.workflow_run.conclusion == 'success' }} ```

The behaviour is what you would want. A failing CI prevents the deploy from ever starting, the ci-cd channel stays the source of truth for test results, the api-deploys channel stays the source of truth for deploys, and Vitest runs exactly once per push.

Design Decision

CI gates the deploy through workflow_run, not by running tests twice. The deploy is the consequence of CI passing, not a parallel attempt at the same thing.

Hard Gate or Soft Gate

Cloudflare Pages native Git integration deploys on every push to main with no pre-deploy hook on the free tier. A validation workflow running in parallel can report on a broken build after it lands, but it cannot prevent that broken build from going live. This is a soft gate. It makes the GitHub status check go red, but the user sees the broken page anyway.

A hard gate means disconnecting native Git integration entirely and deploying with wrangler pages deploy from inside the validated Actions workflow. The validation step runs before the deploy step, and the deploy step only runs if validation passed. A broken HTML tag or a dead internal link literally cannot reach production because nothing publishes when validation fails.

The cost is two to three minutes per deploy (validation takes time that native integration does not) and a one-time dashboard action per project to disconnect the integration. The benefit is the gate being real. I picked the hard gate on all three sites.

Safe cutover

The cutover was sequenced to be safe. For each site: add the workflow, let it run alongside native integration for two or three pushes, watch every run go green, then disconnect native integration in the Cloudflare dashboard. The custom domain stays attached because it is a DNS setting, not a Git setting. Until the disconnect, native integration is still the real deploy path so the live site cannot break; after the disconnect, the Actions run is the only path.

Why This Matters

The gate must prevent harm, not report on it after it lands. Disconnecting native Git integration is the only way to make the validation step actually authoritative.

The Token Model

Cloudflare's permission system distinguishes between Workers and Pages. A token with Workers Scripts: Edit cannot deploy a Pages project; a token with Pages: Edit cannot edit a Worker. Using one account-wide token everywhere would have been simpler in the short term and worse in every other way: a leaked token would have access to everything in the account, not just the class of resource the leaking workflow needed to touch.

The estate now uses two narrowly scoped tokens. CF_WORKERS_DEPLOY_TOKEN carries Workers Scripts: Edit and Workers Routes: Edit, and is set on the five Worker repos. CF_PAGES_DEPLOY_TOKEN carries Pages: Edit only, and is set on the three static-site repos. A token leak is bounded by the token's permission class, not by the breadth of the account.

Deploy-time vs runtime credentials

Runtime secrets (the tokens Workers themselves call out to other services with, such as deploy-watch's Pages: Read token or site-pulse's Zone Analytics: Read token) are set with wrangler secret put and never appear in GitHub Actions. The deploy-time and runtime credential paths share no values.

Design Decision

Two narrowly scoped deploy tokens, never one account-wide token. Runtime secrets stay separate from deploy secrets.

Phase I — The zone_name Trap

Phase IThe zone_name Trap

The Workers token used in CI has Workers Scripts: Edit and Workers Routes: Edit, deliberately scoped narrowly. It does not have Account: Read. Local wrangler deploy runs with a broader interactive token and resolves zone names fine. CI runs with the scoped token and fails silently with "Could not find zone." atlas-notify's wildcard route used zone_name = "atlas-systems.uk", and every CI deploy failed on every run while the route worked perfectly when deployed locally. This was the same gap the previous month's pipeline had already hit once, in a narrower form, and patched without fully understanding why.

Root Cause

Resolving a Cloudflare zone name to its zone ID requires Account: Read on the calling token because the lookup happens against the account's zone list. The scoped CI token cannot do the membership lookup. The narrower the token, the less it can introspect.

The fix was to use the literal zone ID in every route in every wrangler.toml on the estate, so nothing has to introspect at deploy time:

``toml routes = [ { pattern = "api.atlas-systems.uk/notify*", zone_id = "8ba6350a78c988447c111048b125f917" } ] ``

This became a hard estate rule. Its current authority lives in atlas-infra/docs/adrs/ and the executable policy and validation code; the older docs/decisions.md record remains useful historical context but is not the current architecture authority.

Resolution

zone_id everywhere, no exceptions. The deploy works identically from any environment because nothing has to introspect.

Named Environments Inherit Nothing

The pipeline supports a dev environment per Worker out of the box. Push to a dev branch and the workflow deploys to a separate Worker instance on a workers.dev URL; push to main and it deploys to the production custom route. The mechanism is wrangler's named environments.

The trap is what wrangler does and does not inherit. A [env.dev] block does not inherit vars, kv_namespaces, routes, or triggers from the top level of the config. A dev environment that does not redeclare its bindings deploys with them empty: KV namespaces become undefined, environment variables go missing, and the Worker runs with a broken runtime state that only shows up at request time.

Caught Late

The empty-binding failure mode does not surface at deploy time. The deploy succeeds, wrangler reports green, and the Worker only breaks when a real request arrives expecting a KV namespace that does not exist. Easy to miss until a dev URL is actually hit.

Every [env.dev] block in the estate now redeclares everything the Worker reads from. They also deliberately omit routes (so dev never claims api.atlas-systems.uk/*) and triggers (so dev never runs the production cron). workers_dev = true exposes the dev Worker on a workers.dev URL, separate from production.

Design Decision

Every named environment redeclares all bindings. Dev environments deliberately omit routes and triggers so dev traffic cannot reach production paths and dev crons cannot fire against production state.

Phase II — What the Gates Caught on Day One

Phase IIWhat the Gates Caught

The rollout itself was a stress test of the gates. ESLint caught an unused ctx parameter on site-pulse's scheduled() cron handler that had been quietly wrong for months. The parameter was declared but the function never called ctx.waitUntil(). The fix was a one-character rename to _ctx (the config allows underscore-prefixed unused arguments by convention) which made it explicit that the parameter is required by the platform's function signature but deliberately unused by this Worker.

html-validate caught roughly 60 violations across the seven HTML files on the first run. The signal-to-noise ratio was the problem worth solving.

Root Cause

The default html-validate ruleset enforces a mix of genuine accessibility rules and stylistic preferences. Treating both as equal severity buried the real findings under noise: button-type defaults that already match the standard, trailing whitespace, raw & characters in URLs that every browser handles correctly.

I disabled no-implicit-button-type, no-trailing-whitespace, and demoted no-raw-characters to a warning. The ruleset's job is to catch things users notice, not to enforce style preferences. The real findings remained, and they were the ones worth keeping. Bare <nav> landmarks across every page needed aria-label attributes because each page also has a labelled mobile nav, and the WCAG rule that landmarks of the same type need distinct accessible names was correct. Fourteen gallery-dot buttons on the work page had no inner text, so screen readers had nothing to announce; each got an aria-label="View image N" derived from its onclick handler. An <img> in the lightbox had src="" (it gets set by JavaScript when the lightbox opens, but html-validate flagged the empty attribute correctly); replacing the empty value with a 1×1 transparent GIF as a data URI satisfied the validator without changing any visible behaviour.

Resolution

Three cosmetic rules turned off, one demoted to warning, every genuine landmark and label issue fixed in code. These were not problems the rollout introduced; they were pre-existing issues the new gates surfaced for the first time. That is what the gates are for.

atlas-infra as the Reference

A reusable workflow that nobody can read is half useful. During this rollout, atlas-infra gained the workflows themselves plus docs/decisions.md covering the token model, Discord routing, the zone_id rule, named-environment inheritance, hard versus soft gates, html-validate choices, and runtime secret boundaries. The current estate has since promoted accepted architecture into docs/adrs/ and executable policy, while keeping historical decision notes as context rather than authority.

Why This Matters

Tribal knowledge dies; infrastructure that documents itself survives. atlas-infra is the canonical control-plane repository, with accepted decisions and executable policy taking precedence over historical notes.

Outcomes

Rollout scope
12 consumer repos on consistent pipelines, defined once in atlas-infra
Caller file size
~12 lines per repo, three pipeline shapes (Worker, static site, library)
Deploy times
38s average for Workers, 45s average for static sites
Cost
£0 for the bounded rollout described here

The rollout replaced ad-hoc deploys with a consistent, gated, observable estate. Every Worker passed wrangler dry-run and ESLint before production. atlas-notify additionally passed Vitest, with the deploy gated on a successful CI run rather than rerunning the tests. Every static site passed html-validate and a link check before wrangler pages deploy ran; the alternative deploy path (Cloudflare native Git integration) was disconnected on all three so Actions became the only path to production. Every outcome posted to one of three Discord channels: api-deploys for Workers, deploy-log for static sites, ci-cd for test results. The signal was narrow, so silence in any channel was meaningful.

The deeper insight is that pipeline code rewards centralisation more than almost any other code in a codebase, because every repo using the pattern multiplies the value of each improvement. Maintaining five deploy.yml files is five times the work of maintaining one. Defining the pipeline once gave the estate a common contract; immutable pins and explicit pin-update pull requests later made changes reviewable without surrendering that common source. The reusable-workflow pattern is not specific to Cloudflare or even to GitHub Actions; it is what infrastructure code looks like when configuration is treated as versioned software. The notify job built here, posting a coloured outcome to Discord on every run, later became the second source feeding the Lab dashboard directly; the events were always there, the dashboard just had to learn to read them too.