{"slug": "frontend-ci-cd-in-the-age-of-ai-part-2-deployments", "title": "Frontend CI/CD in the age of AI - Part 2: Deployments", "summary": "Neciu Dan, a technical co-founder and Staff Software Engineer, outlines a deployment pipeline for frontend CI/CD in the age of AI, emphasizing canary deployments and rapid rollbacks to counter the increased delivery instability reported in DORA's 2025 report, which links higher AI adoption to more production failures. The proposed pipeline uses a merge queue, a single build artifact, and staged traffic shifts with smoke tests and automated rollbacks, aiming to reduce the hours-long rebuilds typical of all-or-nothing deployments.", "body_md": "· [ devops ](/category/devops) · 37 min read\n\n# Frontend CI/CD in the age of AI - Part 2: Deployments\n\nAgents write code that passes every test and still breaks in production. Here is how to ship it to a slice of your users first, and how to take it back in seconds when it goes wrong.\n\n## Neciu Dan\n\nHi there, it's Dan, a technical co-founder of an ed-tech startup, host of Señors at Scale - a podcast for Senior Engineers, Organizer of ReactJS Barcelona meetup, international speaker and Staff Software Engineer, I'm here to share insights on combining\ntechnology and education to solve real problems.\n\nI write about startup challenges, tech innovations, and the Frontend Development.\nSubscribe to join me on this journey of transforming education through technology. Want to discuss\nTech, Frontend or Startup life? [Let's connect.](https://www.linkedin.com/in/neciudan/)\n\n[Part 1](https://neciudan.dev/ci-cd-in-the-age-of-ai-part-1) covered the Continuous Integration part, where the goal was to run the pipeline less (cheaper) and run it faster, because agents produce more pull requests than a normal pipeline can absorb.\n\nOn the other hand, while AI has gotten better and better at coding and reviewing code, it still is missing context and product sense, and as a result, it makes mistakes.\n\nA lot of mistakes.\n\nAnd this is an industry problem. If you look carefully, reliability is down across the board.\n\nDORA’s 2025 report says that teams with higher AI adoption show both higher delivery throughput and higher delivery instability, resulting in more changes failing in production and more unplanned work to fix them.\n\nThis makes our deployment part that much more important.\n\nWe need to either deploy to a subset of our users to ensure it doesn’t cause outages, and revert quickly if something goes wrong.\n\nUnfortunately, that is not how we are doing things.\n\nMost of the companies I worked with typically use an all-or-nothing approach to deployments, and reverting often requires rebuilding the entire CI pipeline, which can take hours.\n\nHere is how our ideal pipeline looks like 👇\n\nA pull request merges into main only through a merge queue, where the full integration suite runs against the latest code. We do this because in the Part 1 article, we only checked what was touched or impacted by the PR code.\n\nThe merge produces a single build artifact, which we use for deployment and later serve in production without being rebuilt, so promoting and rolling back both come down to where the traffic is pointing.\n\nThat artifact goes out to a piece of your traffic, with a smoke test hitting it the moment it lands. Five minutes later, something looks for catastrophic movement, and an hour after that, a slower comparison against the previous release decides whether the artifact is ready for 100% traffic.\n\nA bad result rolls it back, posts the offending pull request to Slack, opens a revert branch, and freezes merging until somebody closes the incident.\n\nIn this article, we are building this pipeline (or parts of it).\n\nLet’s go.\n\n## Canaries\n\nBritish coal mines carried canaries underground until 1986.\n\nA canary breathes through a one-way system with air sacs feeding the lungs, and it burns oxygen fast enough that carbon monoxide fills it well before anyone holding the cage feels a thing.\n\nA bird that stopped singing and swayed on its perch bought the crew a few minutes to climb out. Later cages came with a small oxygen bottle attached, so you could seal the bird in and revive it on the way up.\n\nDeployments applied the same concept. A small, known group goes into the unverified place ahead of everybody else, and you watch them while the rest of your traffic carries on where it was.\n\nYour five percent of traffic is the canary, and when you have a broken release, they are paying the price.\n\n(To mitigate for randomness and not affect important users, some companies apply this pattern to geo-location traffic, testing in low-impact countries)\n\n## The workflow we start from\n\nMost teams have something close to this, which builds the app and ships it in one go.\n\n```\n# .github/workflows/deploy.yml\nname: Deploy\non:\n  push:\n    branches: [main]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n      - run: npm ci\n      - run: npm run build\n      - run: npx vercel deploy --prod --token=\"$VERCEL_TOKEN\"\n        env:\n          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}\n```\n\nThat last step is the only line that changes from platform to platform, and the moment it finishes, every user gets the new code.\n\nOur pipeline, regardless of the platform we are deploying to, will use these concepts and I will refrence them all through the article:\n\n**deploy** ships the build and gives it a small slice of traffic**smoke** hits the new version before anybody else does with some tests**soak** waits, then reads your error rate split by version**promote** gives the build all the traffic**abort** removes the traffic from the build and gives it to the previous version**hold** posts to Slack when the numbers are too thin to call**watch-15** and**watch-60** keep checking after promotion**rollback** puts the previous build back when they find something\n\n### The common setup\n\nEvery job below starts the same way:\n\n```\n# .github/actions/setup/action.yml\nname: Setup\ndescription: Node and dependencies\nruns:\n  using: composite\n  steps:\n    - uses: actions/setup-node@v4\n      with:\n        node-version: 22\n        cache: 'npm'\n    - run: npm ci\n      shell: bash\n```\n\nComposite actions need `shell:`\n\non every `run`\n\nstep, and the action lives in your repository, so `actions/checkout`\n\nhas to run before you can call it.\n\n### The environments\n\nThe soak-and-watch jobs need to pause.\n\nIf you use a step that does `sleep 1800`\n\nit will work but it will also occupy a runner for half an hour, billed to you on every merge.\n\nGitHub environments do it for free. A job targeting an environment with a wait timer sits waiting before it starts, outside a runner.\n\nIn the Settings dashboard, then Environments, then New environment, tick Wait timer, and enter the minutes, with 43200 as the ceiling. All four at once from the CLI:\n\n```\nREPO=my-org/my-app\n\ngh api -X PUT \"repos/$REPO/environments/canary-soak\" -F wait_timer=60\ngh api -X PUT \"repos/$REPO/environments/watch-15min\" -F wait_timer=15\ngh api -X PUT \"repos/$REPO/environments/watch-45min\" -F wait_timer=45\ngh api -X PUT \"repos/$REPO/environments/geo-soak\"    -F wait_timer=60\n```\n\n### Telling Sentry which release is which\n\nThe soak job compares two releases (for bugs and issues), which only works if your app reports which one it is.\n\n``` js\n// vite.config.ts\nconst release =\n  process.env.VERCEL_GIT_COMMIT_SHA ??  // Vercel builds on its own machines\n  process.env.COMMIT_REF ??             // so does Netlify\n  process.env.GITHUB_SHA ??             // Cloudflare, Cloud Run, and ECS build in CI\n  'local';\n\nexport default defineConfig({\n  define: { 'import.meta.env.RELEASE': JSON.stringify(release) },\n});\n```\n\nVercel and Netlify build remotely, where `GITHUB_SHA`\n\ndoes not exist, so a config that reads only that variable tags every release as `local`\n\n, and the soak job compares two things that are not there.\n\nAll three variables hold the full commit SHA, which is what `github.sha`\n\ngives the workflow.\n\n```\nSentry.init({\n  dsn: import.meta.env.VITE_SENTRY_DSN,\n  release: import.meta.env.RELEASE,\n});\n```\n\nWithout this, every query returns the same blended number for both versions, and the check can never tell them apart.\n\n### The script that decides\n\nWe neeed to compare releases and for that we need to run a script.\n\nThe script can return `hold`\n\n, `promote`\n\n, and `abort`\n\n. At five percent traffic on Tuesday early afternoon, half an hour might produce forty sessions, and forty sessions say nothing about whether your crash rate moved.\n\nPromoting on numbers that thin is guessing, and aborting throws away a deploy that was probably fine.\n\n``` js\n// scripts/canary-health.mjs\nimport { appendFileSync } from 'node:fs';\n\nconst { SENTRY_TOKEN, SENTRY_ORG, SENTRY_PROJECT_ID,\n        CANARY_RELEASE, BASELINE_RELEASE,\n        MIN_SESSIONS = 200 } = process.env;\n\nconst MAX_DROP_POINTS = 0.5;   // crash-free rate, in percentage points\n\n// Sentry declares this metric as `crash_free_rate@ratio`, so the API hands\n// back 0..1 even though the UI shows you 99.98%. Converting on the value\n// rather than assuming the scale costs nothing and survives Sentry changing\n// its mind.\nconst points = (rate) =>\n  rate === null || rate === undefined || Number.isNaN(rate)\n    ? null\n    : rate <= 1 ? rate * 100 : rate;\n\nasync function health(release) {\n  if (!release) return { rate: null, sessions: 0 };\n\n  const url = new URL(`https://sentry.io/api/0/organizations/${SENTRY_ORG}/sessions/`);\n  url.searchParams.append('field', 'crash_free_rate(session)');\n  url.searchParams.append('field', 'sum(session)');\n  url.searchParams.set('project', SENTRY_PROJECT_ID);\n  url.searchParams.set('statsPeriod', '1h');\n  // query= rather than groupBy=release, which has a habit of\n  // returning zeroed buckets for individual releases\n  url.searchParams.set('query', `release:\"${release}\"`);\n\n  const response = await fetch(url, {\n    headers: { Authorization: `Bearer ${SENTRY_TOKEN}` },\n    // Without this, a hung Sentry holds the runner to the six-hour job ceiling\n    signal: AbortSignal.timeout(20_000),\n  });\n  if (!response.ok) throw new Error(`Sentry returned ${response.status}`);\n\n  const totals = (await response.json()).groups?.[0]?.totals;\n  if (!totals) return { rate: null, sessions: 0 };\n\n  return {\n    rate: totals['crash_free_rate(session)'] ?? null,\n    sessions: totals['sum(session)'] ?? 0,\n  };\n}\n\nfunction decide(decision, why) {\n  console.log(`${decision}: ${why}`);\n  // Running this by hand to debug a stuck rollout must not crash\n  if (process.env.GITHUB_OUTPUT) {\n    appendFileSync(process.env.GITHUB_OUTPUT, `decision=${decision}\\n`);\n  }\n  process.exit(0);\n}\n\ntry {\n  const canary = await health(CANARY_RELEASE);\n  const baseline = await health(BASELINE_RELEASE);\n\n  if (canary.sessions < Number(MIN_SESSIONS)) {\n    decide('hold', `only ${canary.sessions} sessions on the canary so far`);\n  }\n\n  const canaryRate = points(canary.rate);\n  const baselineRate = points(baseline.rate);\n\n  // `undefined` slips past a `=== null` check, makes the subtraction NaN, and\n  // NaN fails every comparison — which reads as \"no drop\" and promotes a\n  // release nobody measured. A baseline with no sessions is not a baseline.\n  if (canaryRate === null || baselineRate === null || baseline.sessions === 0) {\n    decide('hold', 'no session data for one of the two releases');\n  }\n\n  // Round before comparing, so the number in the log is the number\n  // the decision was made on\n  const drop = Number((baselineRate - canaryRate).toFixed(2));\n  if (drop > MAX_DROP_POINTS) {\n    decide('abort', `crash-free rate down ${drop.toFixed(2)} points`);\n  }\n\n  decide('promote', `crash-free rate within ${MAX_DROP_POINTS} points`);\n} catch (error) {\n  // Sentry being unreachable is not evidence that your deploy is bad\n  decide('hold', `health check failed: ${error.message}`);\n}\n```\n\nA hold parks the canary where it is, with production still mostly on the old build, which is a safe place to leave something while you look at it yourself.\n\nBut be aware that the endpoint buckets by the hour and refuses a window shorter than one, so a 30-minute soak reads 60 minutes of data, including traffic from before your deploy existed.\n\nLet it soak for an hour, or swap the query for a count of issues first seen since the deployment timestamp.\n\nUntil this point everything can be reused regardless of deployment platform. Let’s go into specifics. You can skip to your platform of choice or the end where we build something independend.\n\n## Vercel\n\nWe need to change two settings to get canary releases to work on Vercel.\n\n**Skew Protection.**\nGo to Settings, then Advanced, then switch it on and set Maximum Age. Having two versions live at once means that one browser can load HTML from the old version and JavaScript from the new, resulting in a blank screen.\n\nThis pins each session to whichever deployment it first loaded.\n\n**The stages.** Rolling Releases needs a Pro or Enterprise plan, and the percentages are fixed before the rollout as configuration.\n\n```\nvercel rolling-release configure --cfg '{\n  \"enabled\": true,\n  \"advancementType\": \"manual-approval\",\n  \"canaryResponseHeader\": true,\n  \"stages\": [\n    { \"targetPercentage\": 5 },\n    { \"targetPercentage\": 25 },\n    { \"targetPercentage\": 60 },\n    { \"targetPercentage\": 100 }\n  ]\n}'\n```\n\n### Deploying to five percent instead of everyone\n\nThe baseline workflow ended at `vercel deploy --prod`\n\n. Two lines change that into a canary.\n\n```\n# .github/workflows/deploy.yml\nname: Deploy\non:\n  push:\n    branches: [main]\n\n# One rollout at a time. Two mergers of racing produce two canaries.\n# GitHub keeps only one *pending* run per group, so a third merge arriving\n# mid-rollout cancels the second. One at a time is guaranteed; every merge\n# reaching production is not.\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: false\n\npermissions:\n  contents: read\n\nenv:\n  VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    outputs:\n      url: ${{ steps.deploy.outputs.url }}\n      baseline: ${{ steps.baseline.outputs.id }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n\n      # What production is running right now, so the soak job has\n      # something to compare against. The whole rollingRelease object is\n      # null until a deployment has happened, so your first ever run gets\n      # \"null\" here and the health check holds rather than promoting.\n      # There is a sibling canaryDeployment.id if you would rather name\n      # the canary that way than by commit SHA.\n      - id: baseline\n        run: |\n          ID=$(npx vercel rolling-release fetch --token=\"$VERCEL_TOKEN\" \\\n            | jq -r '.rollingRelease.currentDeployment.id')\n          echo \"id=$ID\" >> \"$GITHUB_OUTPUT\"\n\n      - id: deploy\n        run: |\n          URL=$(npx vercel deploy --prod --token=\"$VERCEL_TOKEN\")\n          echo \"url=$URL\" >> \"$GITHUB_OUTPUT\"\n          # A start that fails leaves the deployment live on all the traffic,\n          # with no smoke test run and no abort job watching it\n          if ! npx vercel rolling-release start --dpl=\"$URL\" --token=\"$VERCEL_TOKEN\" --yes; then\n            npx vercel rolling-release abort --dpl=\"$URL\" --token=\"$VERCEL_TOKEN\" --yes || true\n            exit 1\n          fi\n```\n\n`rolling-release start`\n\nis doing all the work in that step. It leaves the deployment sitting at stage zero on five percent, where, without it, the deployment would have taken everything.\n\nThat job doesn’t include `npm run build`\n\n, because Vercel builds on its own infrastructure when you run `vercel deploy`\n\n.\n\n### Hitting it before your users do\n\n```\n  smoke:\n    needs: deploy\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx playwright install --with-deps chromium\n      - run: npx playwright test e2e/smoke --project=chromium\n        env:\n          PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.url }}\n```\n\n### Waiting, then deciding\n\n```\n  soak:\n    needs: [deploy, smoke]\n    runs-on: ubuntu-latest\n    environment: canary-soak         # the 60-minute wait lives here\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}\n          SENTRY_ORG: my-org\n          SENTRY_PROJECT_ID: '4504000000000000'\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n```\n\n### Taking the rest, or giving it back\n\n```\n  promote:\n    needs: [deploy, soak]\n    if: needs.soak.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      # complete skips the remaining stages and takes 100%\n      - run: npx vercel rolling-release complete --dpl='${{ needs.deploy.outputs.url }}' --token=\"$VERCEL_TOKEN\" --yes\n\n  abort:\n    needs: [deploy, smoke, soak]\n    if: >\n      always() && (\n        needs.smoke.result == 'failure' ||\n        needs.soak.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx vercel rolling-release abort --dpl='${{ needs.deploy.outputs.url }}' --token=\"$VERCEL_TOKEN\" --yes\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary aborted. Production is back on the previous build.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n```\n\nA `hold`\n\ndecision matches neither condition, so the canary stays at five percent and nothing else runs.\n\nThe full file below adds a third job on `decision == 'hold'`\n\nthat posts to Slack, because otherwise you find rollouts parked there days later.\n\n### Rolling back after promotion\n\nA release that behaves at 5% for an hour can still fail at full traffic.\n\nWe want to make sure we can always do a fast and safe rollback.\n\n```\n  watch-15:\n    needs: [deploy, promote]\n    runs-on: ubuntu-latest\n    environment: watch-15min\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}\n          SENTRY_ORG: my-org\n          SENTRY_PROJECT_ID: '4504000000000000'\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  watch-60:\n    needs: [deploy, promote, watch-15]\n    if: needs.watch-15.outputs.decision != 'abort'\n    runs-on: ubuntu-latest\n    environment: watch-45min    # runs after watch-15, so 15 + 45 = an hour in\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}\n          SENTRY_ORG: my-org\n          SENTRY_PROJECT_ID: '4504000000000000'\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  rollback:\n    needs: [deploy, watch-15, watch-60]\n    if: >\n      always() && (\n        needs.watch-15.outputs.decision == 'abort' ||\n        needs.watch-60.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx vercel rollback '${{ needs.deploy.outputs.baseline }}' --token=\"$VERCEL_TOKEN\"\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Rolled back after promotion.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n```\n\nThe previous build never went anywhere, so `rollback`\n\npoints the domain at something that already exists and finishes in seconds.\n\nSessions you pinned stay where they are, though.\n\nSkew Protection keeps them loading assets from the deployment they landed on until Maximum Age expires, so a rollback catches everybody arriving fresh while open tabs sit put.\n\n### The whole file\n\nEvery fragment above, plus the `hold`\n\njob the soak decision needs, in one file.\n\n```\n# .github/workflows/deploy.yml\nname: Deploy\non:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: false\n\npermissions:\n  contents: read\n\nenv:\n  VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}\n  SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}\n  SENTRY_ORG: my-org\n  SENTRY_PROJECT_ID: '4504000000000000'\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    outputs:\n      url: ${{ steps.deploy.outputs.url }}\n      baseline: ${{ steps.baseline.outputs.id }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: baseline\n        run: |\n          ID=$(npx vercel rolling-release fetch --token=\"$VERCEL_TOKEN\" \\\n            | jq -r '.rollingRelease.currentDeployment.id')\n          echo \"id=$ID\" >> \"$GITHUB_OUTPUT\"\n      - id: deploy\n        run: |\n          URL=$(npx vercel deploy --prod --token=\"$VERCEL_TOKEN\")\n          echo \"url=$URL\" >> \"$GITHUB_OUTPUT\"\n          # A start that fails leaves the deployment live on all the traffic,\n          # with no smoke test run and no abort job watching it\n          if ! npx vercel rolling-release start --dpl=\"$URL\" --token=\"$VERCEL_TOKEN\" --yes; then\n            npx vercel rolling-release abort --dpl=\"$URL\" --token=\"$VERCEL_TOKEN\" --yes || true\n            exit 1\n          fi\n\n  smoke:\n    needs: deploy\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx playwright install --with-deps chromium\n      - run: npx playwright test e2e/smoke --project=chromium\n        env:\n          PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.url }}\n\n  soak:\n    needs: [deploy, smoke]\n    runs-on: ubuntu-latest\n    environment: canary-soak\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  promote:\n    needs: [deploy, soak]\n    if: needs.soak.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx vercel rolling-release complete --dpl='${{ needs.deploy.outputs.url }}' --token=\"$VERCEL_TOKEN\" --yes\n\n  hold:\n    needs: soak\n    if: needs.soak.outputs.decision == 'hold'\n    runs-on: ubuntu-latest\n    steps:\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary parked at 5%. Not enough data to decide.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n\n  abort:\n    needs: [deploy, smoke, soak]\n    if: >\n      always() && (\n        needs.smoke.result == 'failure' ||\n        needs.soak.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx vercel rolling-release abort --dpl='${{ needs.deploy.outputs.url }}' --token=\"$VERCEL_TOKEN\" --yes\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary aborted. Production is back on the previous build.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n\n  watch-15:\n    needs: [deploy, promote]\n    runs-on: ubuntu-latest\n    environment: watch-15min\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  watch-60:\n    needs: [deploy, promote, watch-15]\n    if: needs.watch-15.outputs.decision != 'abort'\n    runs-on: ubuntu-latest\n    environment: watch-45min    # runs after watch-15, so 15 + 45 = an hour in\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  rollback:\n    needs: [deploy, watch-15, watch-60]\n    if: >\n      always() && (\n        needs.watch-15.outputs.decision == 'abort' ||\n        needs.watch-60.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx vercel rollback '${{ needs.deploy.outputs.baseline }}' --token=\"$VERCEL_TOKEN\"\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Rolled back after promotion.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n```\n\n### Rolling out to one country\n\nRolling Releases takes a percentage and nothing else, so a country-scoped rollout means routing in middleware and a config value your workflow can move.\n\nCreate the store once from the dashboard, under Storage, then Edge Config, then Create, and connect it to your project on the same screen. Vercel injects an `EDGE_CONFIG`\n\nenvironment variable for you.\n\n``` js\n// middleware.ts\nimport { NextResponse, type NextRequest } from 'next/server';\nimport { geolocation } from '@vercel/functions';\nimport { get } from '@vercel/edge-config';\n\nexport const config = {\n  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],\n};\n\nexport async function middleware(request: NextRequest) {\n  const rollout = await get<{\n    countries: string[];\n    percentage: number;\n    canaryHost: string;\n  }>('canaryRollout');\n\n  const { country } = geolocation(request);\n  if (!rollout?.countries?.includes(country ?? '')) {\n    return NextResponse.next();\n  }\n\n  // Returning visitors keep whatever they were given\n  const existing = request.cookies.get('canary')?.value;\n  const assigned =\n    existing ?? (Math.random() * 100 < rollout.percentage ? 'on' : 'off');\n\n  const url = new URL(request.url);\n  url.host = rollout.canaryHost;\n\n  const response =\n    assigned === 'on' ? NextResponse.rewrite(url) : NextResponse.next();\n\n  response.cookies.set('canary', assigned, { maxAge: 86400, path: '/' });\n  return response;\n}\n```\n\nThe rollout then gets its own workflow, triggered by hand, with the same soak-and-decide loop pointing to the config value rather than a platform percentage.\n\n```\n# .github/workflows/geo-rollout.yml\nname: Geo rollout\non:\n  workflow_dispatch:\n    inputs:\n      countries:\n        description: 'Comma-separated ISO codes, for example PT, IE'\n        required: true\n\nconcurrency:\n  group: geo-rollout\n  cancel-in-progress: false\n\njobs:\n  start:\n    runs-on: ubuntu-latest\n    outputs:\n      baseline: ${{ steps.baseline.outputs.id }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n\n      # Without this, the health check compares against nothing, the\n      # arithmetic gives NaN, and every rollout promotes itself\n      - id: baseline\n        run: |\n          ID=$(npx vercel rolling-release fetch --token=\"$VERCEL_TOKEN\" \\\n            | jq -r '.rollingRelease.currentDeployment.id')\n          echo \"id=$ID\" >> \"$GITHUB_OUTPUT\"\n\n      - name: 10% of the named countries\n        run: |\n          COUNTRIES=$(echo \"${{ inputs.countries }}\" | jq -R 'split(\",\")')\n          npx vercel edge-config items add canaryRollout --value \\\n            \"$(jq -nc --argjson c \"$COUNTRIES\" \\\n               '{countries:$c,percentage:10,canaryHost:\"canary.example.com\"}')\"\n\n  soak-10:\n    needs: start\n    runs-on: ubuntu-latest\n    environment: geo-soak\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}\n          SENTRY_ORG: my-org\n          SENTRY_PROJECT_ID: '4504000000000000'\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.start.outputs.baseline }}\n          MIN_SESSIONS: 40      # ten percent of Portugal is a small number\n\n  widen:\n    needs: soak-10\n    if: needs.soak-10.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: |\n          COUNTRIES=$(echo \"${{ inputs.countries }}\" | jq -R 'split(\",\")')\n          npx vercel edge-config items add canaryRollout --value \\\n            \"$(jq -nc --argjson c \"$COUNTRIES\" \\\n               '{countries:$c,percentage:50,canaryHost:\"canary.example.com\"}')\"\n\n  stop:\n    needs: soak-10\n    if: always() && needs.soak-10.outputs.decision == 'abort'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx vercel edge-config items add canaryRollout --value '{\"percentage\":0}'\n```\n\nKeep that `MIN_SESSIONS`\n\noverride. Ten percent of Portugal produces far fewer sessions than ten percent of everything, and the default threshold would return `hold`\n\non every run.\n\n## Netlify\n\nNetlify has a built-in canary called Split Testing, and you can only use it from the Netlify Dashboard.\n\nTurn on branch deploys under Site configuration > Build and deploy > Branches and deploy contexts, and set Branch deploys to “All” or name your release branch.\n\nPush a candidate there, and Netlify builds it alongside main.\n\nSplit Testing then lives under Site configuration > Split Testing, where you pick two branches and assign percentages.\n\nThe percentages sit behind a form with no CLI and no documentation in the API, so a workflow has no way to touch a test once someone has started one.\n\n**So the pipeline does the split itself**, in an edge function reading numbers the CLI can set.\n\nThe obvious place to put those numbers is an environment variable, and it does not work. Netlify is explicit that “changes to environment variables for edge functions require a build and deploy to take effect” — each deploy captures the values as they were at deploy time. A workflow that sets `CANARY_PERCENT`\n\nwould never move the running function, and, much worse, the abort job setting it back to zero would not either.\n\nSo the numbers live in [Netlify Blobs](https://docs.netlify.com/build/data-and-storage/netlify-blobs/) instead, which is Netlify’s runtime store: readable from an edge function, writable from CI, and no deploy in between. It is the same shape as Vercel’s Edge Config and Cloudflare’s KV.\n\n``` js\n// netlify/edge-functions/canary.js\nimport { getStore } from '@netlify/blobs';\n\nexport default async (request, context) => {\n  // Blobs default to eventual consistency, which propagates within 60\n  // seconds. An abort cannot wait 60 seconds, so ask for strong.\n  const store = getStore({ name: 'canary', consistency: 'strong' });\n\n  // A missing or malformed key sends everyone to the stable version\n  const rollout = await store.get('rollout', { type: 'json' });\n  if (!rollout?.percentage || !rollout.origin) return;\n\n  // Countries stay empty for a plain percentage rollout\n  const countries = rollout.countries ?? [];\n  const country = context.geo?.country?.code;\n  if (countries.length && !countries.includes(country)) return;\n\n  // Returning visitors keep whatever they were given\n  const existing = context.cookies.get('canary');\n  const assigned =\n    existing ?? (Math.random() * 100 < rollout.percentage ? 'on' : 'off');\n\n  context.cookies.set({ name: 'canary', value: assigned, path: '/' });\n\n  if (assigned === 'off') return;\n  return context.rewrite(rollout.origin + new URL(request.url).pathname);\n};\n\nexport const config = { path: '/*' };\n```\n\nThat one file covers both the percentage rollout and the country rollout, since leaving `countries`\n\nempty skips the geo check.\n\nThis file lives in `netlify/edge-functions/`\n\n.\n\nEvery command below writes that one key with `netlify blobs:set`\n\n, which takes effect on the next request.\n\n### Deploying to five percent instead of everyone\n\n```\n# .github/workflows/deploy.yml\nname: Deploy\non:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: false\n\npermissions:\n  contents: read\n\nenv:\n  NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}\n  NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    outputs:\n      url: ${{ steps.deploy.outputs.url }}\n      id: ${{ steps.deploy.outputs.id }}\n      previous: ${{ steps.previous.outputs.id }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n\n      - id: previous\n        run: |\n          PREV=$(netlify api listSiteDeploys --data '{\"site_id\":\"'\"$NETLIFY_SITE_ID\"'\"}' \\\n            | jq -r '[.[] | select(.state==\"ready\" and .context==\"production\")][0].id')\n          echo \"id=$PREV\" >> \"$GITHUB_OUTPUT\"\n\n      # Netlify deploys what you hand it, so the build happens here\n      - run: npm run build\n\n      - id: deploy\n        run: |\n          OUT=$(netlify deploy --dir=dist --json)\n          echo \"url=$(echo \"$OUT\" | jq -r '.deploy_url')\" >> \"$GITHUB_OUTPUT\"\n          echo \"id=$(echo \"$OUT\" | jq -r '.deploy_id')\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Send it 5% of traffic\n        run: |\n          netlify blobs:set canary rollout \\\n            \"$(jq -nc --arg o '${{ steps.deploy.outputs.url }}' \\\n               '{origin:$o,percentage:5}')\"\n```\n\nThe `smoke`\n\nand `soak`\n\njobs are the ones from the Vercel section, with `PLAYWRIGHT_BASE_URL`\n\nset to the draft URL and `BASELINE_RELEASE`\n\nset to `needs.deploy.outputs.previous`\n\n.\n\n### Taking the rest, or giving it back\n\n```\n  promote:\n    needs: [deploy, soak]\n    if: needs.soak.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      # Publish the draft that is already up there. Rebuilding here\n      # would ship different bytes from the ones you just tested.\n      - run: |\n          netlify api restoreSiteDeploy --data '{\n            \"site_id\": \"'\"$NETLIFY_SITE_ID\"'\",\n            \"deploy_id\": \"${{ needs.deploy.outputs.id }}\"\n          }'\n          netlify blobs:set canary rollout '{\"percentage\":0}'\n\n  abort:\n    needs: [deploy, smoke, soak]\n    if: >\n      always() && (\n        needs.smoke.result == 'failure' ||\n        needs.soak.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: netlify blobs:set canary rollout '{\"percentage\":0}'\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary aborted. Split closed, production unchanged.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n```\n\n### Rolling back after promotion\n\nSame two watch jobs as Vercel, with one step swapped. Deployments on Netlify are immutable, so the previous one remains available for republishing.\n\n```\n  rollback:\n    needs: [deploy, watch-15, watch-60]\n    if: >\n      always() && (\n        needs.watch-15.outputs.decision == 'abort' ||\n        needs.watch-60.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: |\n          netlify api restoreSiteDeploy --data '{\n            \"site_id\": \"'\"$NETLIFY_SITE_ID\"'\",\n            \"deploy_id\": \"${{ needs.deploy.outputs.previous }}\"\n          }'\n```\n\nEverybody moves at once here. Your edge function returns early when `CANARY_PERCENT`\n\nis zero, so a visitor holding a `canary=on`\n\ncookie stops being routed anywhere the moment the abort or promote step runs.\n\nWhile you investigate, “Lock to stop auto publishing” on that deploy in the dashboard stops the next merge, which would put the bad version straight back.\n\n### The whole file\n\n```\n# .github/workflows/deploy.yml\nname: Deploy\non:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: false\n\npermissions:\n  contents: read\n\nenv:\n  NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}\n  NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}\n  SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}\n  SENTRY_ORG: my-org\n  SENTRY_PROJECT_ID: '4504000000000000'\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    outputs:\n      url: ${{ steps.deploy.outputs.url }}\n      id: ${{ steps.deploy.outputs.id }}\n      baseline: ${{ steps.baseline.outputs.id }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npm run build\n      - id: baseline\n        run: |\n          ID=$(netlify api listSiteDeploys --data '{\"site_id\":\"'\"$NETLIFY_SITE_ID\"'\"}' \\\n            | jq -r '[.[] | select(.state==\"ready\" and .context==\"production\")][0].id')\n          echo \"id=$ID\" >> \"$GITHUB_OUTPUT\"\n      - id: deploy\n        run: |\n          OUT=$(netlify deploy --dir=dist --json)\n          echo \"url=$(echo \"$OUT\" | jq -r '.deploy_url')\" >> \"$GITHUB_OUTPUT\"\n          echo \"id=$(echo \"$OUT\" | jq -r '.deploy_id')\" >> \"$GITHUB_OUTPUT\"\n      - name: Send it 5% of traffic\n        run: |\n          netlify blobs:set canary rollout \\\n            \"$(jq -nc --arg o '${{ steps.deploy.outputs.url }}' \\\n               '{origin:$o,percentage:5}')\"\n\n  smoke:\n    needs: deploy\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx playwright install --with-deps chromium\n      - run: npx playwright test e2e/smoke --project=chromium\n        env:\n          PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.url }}\n\n  soak:\n    needs: [deploy, smoke]\n    runs-on: ubuntu-latest\n    environment: canary-soak\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  promote:\n    needs: [deploy, soak]\n    if: needs.soak.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: |\n          netlify api restoreSiteDeploy --data '{\n            \"site_id\": \"'\"$NETLIFY_SITE_ID\"'\",\n            \"deploy_id\": \"${{ needs.deploy.outputs.id }}\"\n          }'\n          netlify blobs:set canary rollout '{\"percentage\":0}'\n\n  hold:\n    needs: soak\n    if: needs.soak.outputs.decision == 'hold'\n    runs-on: ubuntu-latest\n    steps:\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary parked at 5%. Not enough data to decide.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n\n  abort:\n    needs: [deploy, smoke, soak]\n    if: >\n      always() && (\n        needs.smoke.result == 'failure' ||\n        needs.soak.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: netlify blobs:set canary rollout '{\"percentage\":0}'\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary aborted. Production is back on the previous build.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n\n  watch-15:\n    needs: [deploy, promote]\n    runs-on: ubuntu-latest\n    environment: watch-15min\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  watch-60:\n    needs: [deploy, promote, watch-15]\n    if: needs.watch-15.outputs.decision != 'abort'\n    runs-on: ubuntu-latest\n    environment: watch-45min    # runs after watch-15, so 15 + 45 = an hour in\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  rollback:\n    needs: [deploy, watch-15, watch-60]\n    if: >\n      always() && (\n        needs.watch-15.outputs.decision == 'abort' ||\n        needs.watch-60.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: |\n          netlify api restoreSiteDeploy --data '{\n            \"site_id\": \"'\"$NETLIFY_SITE_ID\"'\",\n            \"deploy_id\": \"${{ needs.deploy.outputs.baseline }}\"\n          }'\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Rolled back after promotion.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n```\n\n### Rolling out to one country\n\nThe edge function above already reads `CANARY_COUNTRIES`\n\n, so the geo workflow is would be:\n\n```\n  start:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: |\n          COUNTRIES=$(echo \"${{ inputs.countries }}\" | jq -R 'split(\",\")')\n          netlify blobs:set canary rollout \\\n            \"$(jq -nc --argjson c \"$COUNTRIES\" --arg o \"$CANARY_ORIGIN\" \\\n               '{countries:$c,percentage:10,origin:$o}')\"\n\n  widen:\n    needs: soak-10\n    if: needs.soak-10.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      # Widen without touching the other keys\n      - run: |\n          CURRENT=$(netlify blobs:get canary rollout)\n          netlify blobs:set canary rollout \\\n            \"$(jq -c '.percentage = 50' <<<\"$CURRENT\")\"\n\n  stop:\n    needs: soak-10\n    if: always() && needs.soak-10.outputs.decision == 'abort'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: netlify blobs:set canary rollout '{\"percentage\":0}'\n```\n\n## Cloudflare\n\nIn the words of Tejas Kumar: “I goon to Cloudflare”\n\nCloudflare splits traffic between Worker versions, and it keeps uploading a version separate from deploying one, which makes it pleasant to automate.\n\n```\nWRANGLER_OUTPUT_FILE_PATH=out.ndjson npx wrangler versions upload\n```\n\nThat returns an ID and a preview URL, and the version carries zero production traffic until you say otherwise. Your smoke tests then run against the preview before any split exists.\n\nTo have it working properly you need a header `Cloudflare-Workers-Version-Key`\n\n, so that every request with the same value goes to the same version.\n\nIn the dashboard, open your zone, then Rules, then Transform Rules, then Modify Request Header.\n\n```\nWhen incoming requests match:   (http.host eq \"app.example.com\")\n\nThen:  Set dynamic  →  Header name:  Cloudflare-Workers-Version-Key\n                       Value:        http.request.cookies[\"sid\"][0]\n```\n\nSwap `sid`\n\nfor your session cookie and set that cookie in the first response, since visitors arriving without one fall back to per-request splitting.\n\nThe same rule as Terraform, if your zone lives in code:\n\n```\nresource \"cloudflare_ruleset\" \"version_affinity\" {\n  zone_id = var.zone_id\n  kind    = \"zone\"\n  phase   = \"http_request_late_transform\"\n\n  rules {\n    action     = \"rewrite\"\n    expression = \"(http.host eq \\\"app.example.com\\\")\"\n    action_parameters {\n      headers {\n        name       = \"Cloudflare-Workers-Version-Key\"\n        operation  = \"set\"\n        expression = \"http.request.cookies[\\\"sid\\\"][0]\"\n      }\n    }\n  }\n}\n```\n\nOne more binding, so your metrics have a version to group by. The runtime hands you the ID, which means Cloudflare skips the build-time Sentry release step from earlier.\n\n```\n// wrangler.jsonc\n{\n  \"version_metadata\": { \"binding\": \"CF_VERSION_METADATA\" }\n}\n// run `npx wrangler types` after adding the binding so env is typed\nexport default {\n  async fetch(request: Request, env: Env) {\n    const { id: versionId } = env.CF_VERSION_METADATA;\n    // attach versionId to your Sentry scope and your analytics events\n  },\n};\n```\n\n### Deploying to five percent instead of everyone\n\n```\n# .github/workflows/deploy.yml\nname: Deploy\non:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: false\n\npermissions:\n  contents: read\n\nenv:\n  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n  CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    outputs:\n      version: ${{ steps.upload.outputs.id }}\n      preview: ${{ steps.upload.outputs.preview }}\n      previous: ${{ steps.previous.outputs.id }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npm run build\n\n      - id: previous\n        run: |\n          PREV=$(npx wrangler deployments list --json \\\n            | jq -r '.[0].versions[0].version_id')\n          echo \"id=$PREV\" >> \"$GITHUB_OUTPUT\"\n\n      # Run this once locally and read the JSON before trusting either\n      # path. An empty preview URL sends Playwright to localhost and\n      # passes for the wrong reason.\n      - id: upload\n        env:\n          WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler.ndjson\n        run: |\n          npx wrangler versions upload\n          # ND-JSON, one object per line. The version-upload entry has both.\n          ENTRY=$(jq -sc '[.[] | select(.type==\"version-upload\")] | last' \\\n            \"$WRANGLER_OUTPUT_FILE_PATH\")\n          echo \"id=$(jq -r '.version_id' <<<\"$ENTRY\")\" >> \"$GITHUB_OUTPUT\"\n          echo \"preview=$(jq -r '.preview_url' <<<\"$ENTRY\")\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Send it 5% of traffic\n        run: |\n          npx wrangler versions deploy \\\n            \"${{ steps.upload.outputs.id }}@5%\" \\\n            \"${{ steps.previous.outputs.id }}@95%\" --yes\n```\n\nPoint `PLAYWRIGHT_BASE_URL`\n\nin the smoke job at `needs.deploy.outputs.preview`\n\n, and set `CANARY_RELEASE`\n\nin the soak job to `needs.deploy.outputs.version`\n\n.\n\n### Taking the rest, or giving it back\n\n```\n  promote:\n    needs: [deploy, soak]\n    if: needs.soak.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      # A single version at 100% ends the split\n      - run: npx wrangler versions deploy \"${{ needs.deploy.outputs.version }}@100%\" --yes\n\n  abort:\n    needs: [deploy, smoke, soak]\n    if: >\n      always() && (\n        needs.smoke.result == 'failure' ||\n        needs.soak.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx wrangler versions deploy \"${{ needs.deploy.outputs.previous }}@100%\" --yes\n```\n\n### Rolling back after promotion\n\n```\n  rollback:\n    needs: [deploy, watch-15, watch-60]\n    if: >\n      always() && (\n        needs.watch-15.outputs.decision == 'abort' ||\n        needs.watch-60.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx wrangler rollback --message \"post-promotion health check\" --yes\n```\n\nIt reaches the edge in seconds, and a split deployment collapses onto the version you picked.\n\nVersion affinity stops applying at that point, since one version is now serving everything, so the sessions you pinned move along with everybody else.\n\n### The whole file\n\n```\n# .github/workflows/deploy.yml\nname: Deploy\non:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: false\n\npermissions:\n  contents: read\n\nenv:\n  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n  CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n  SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}\n  SENTRY_ORG: my-org\n  SENTRY_PROJECT_ID: '4504000000000000'\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    outputs:\n      url: ${{ steps.upload.outputs.preview }}\n      version: ${{ steps.upload.outputs.id }}\n      baseline: ${{ steps.baseline.outputs.id }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npm run build\n      - id: baseline\n        run: |\n          ID=$(npx wrangler deployments list --json \\\n            | jq -r '.[0].versions[0].version_id')\n          echo \"id=$ID\" >> \"$GITHUB_OUTPUT\"\n      - id: upload\n        env:\n          WRANGLER_OUTPUT_FILE_PATH: ${{ runner.temp }}/wrangler.ndjson\n        run: |\n          npx wrangler versions upload\n          # ND-JSON, one object per line. The version-upload entry has both.\n          ENTRY=$(jq -sc '[.[] | select(.type==\"version-upload\")] | last' \\\n            \"$WRANGLER_OUTPUT_FILE_PATH\")\n          echo \"id=$(jq -r '.version_id' <<<\"$ENTRY\")\" >> \"$GITHUB_OUTPUT\"\n          echo \"preview=$(jq -r '.preview_url' <<<\"$ENTRY\")\" >> \"$GITHUB_OUTPUT\"\n      - name: Send it 5% of traffic\n        run: |\n          npx wrangler versions deploy \\\n            \"${{ steps.upload.outputs.id }}@5%\" \\\n            \"${{ steps.baseline.outputs.id }}@95%\" --yes\n\n  smoke:\n    needs: deploy\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx playwright install --with-deps chromium\n      - run: npx playwright test e2e/smoke --project=chromium\n        env:\n          PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.url }}\n\n  soak:\n    needs: [deploy, smoke]\n    runs-on: ubuntu-latest\n    environment: canary-soak\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ needs.deploy.outputs.version }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  promote:\n    needs: [deploy, soak]\n    if: needs.soak.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx wrangler versions deploy \"${{ needs.deploy.outputs.version }}@100%\" --yes\n\n  hold:\n    needs: soak\n    if: needs.soak.outputs.decision == 'hold'\n    runs-on: ubuntu-latest\n    steps:\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary parked at 5%. Not enough data to decide.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n\n  abort:\n    needs: [deploy, smoke, soak]\n    if: >\n      always() && (\n        needs.smoke.result == 'failure' ||\n        needs.soak.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx wrangler versions deploy \"${{ needs.deploy.outputs.baseline }}@100%\" --yes\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary aborted. Production is back on the previous build.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n\n  watch-15:\n    needs: [deploy, promote]\n    runs-on: ubuntu-latest\n    environment: watch-15min\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ needs.deploy.outputs.version }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  watch-60:\n    needs: [deploy, promote, watch-15]\n    if: needs.watch-15.outputs.decision != 'abort'\n    runs-on: ubuntu-latest\n    environment: watch-45min    # runs after watch-15, so 15 + 45 = an hour in\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ needs.deploy.outputs.version }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  rollback:\n    needs: [deploy, watch-15, watch-60]\n    if: >\n      always() && (\n        needs.watch-15.outputs.decision == 'abort' ||\n        needs.watch-60.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx wrangler rollback --message \"post-promotion health check\" --yes\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Rolled back after promotion.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n```\n\n### Rolling out to one country\n\nThe Worker reads its numbers from a KV namespace, so create one and bind it first.\n\n```\nnpx wrangler kv namespace create CONFIG\n// wrangler.jsonc\n{\n  \"kv_namespaces\": [\n    { \"binding\": \"CONFIG\", \"id\": \"0f2ac74b498b48028cb68387c421e279\" }\n  ]\n}\nexport default {\n  async fetch(request, env) {\n    // cacheTtl keeps this off the hot path; 60s is fast enough to abort\n    const rollout = await env.CONFIG.get('canary', {\n      type: 'json',\n      cacheTtl: 60,\n    });\n\n    // A missing or malformed key sends everyone to the stable version\n    if (!rollout?.countries?.length) return fetch(request);\n\n    const country = request.cf?.country;\n    if (!country || !rollout.countries.includes(country)) {\n      return fetch(request);\n    }\n\n    const cookie = request.headers.get('cookie') ?? '';\n    const existing = cookie.match(/canary=(on|off)/)?.[1];\n    const assigned =\n      existing ?? (Math.random() * 100 < rollout.percentage ? 'on' : 'off');\n\n    let upstream;\n    if (assigned === 'on') {\n      const url = new URL(request.url);\n      url.hostname = rollout.canaryHost;\n      upstream = await fetch(new Request(url, request));\n    } else {\n      upstream = await fetch(request);\n    }\n\n    const response = new Response(upstream.body, upstream);\n    response.headers.append(\n      'set-cookie',\n      `canary=${assigned}; Path=/; Max-Age=86400; SameSite=Lax`\n    );\n    return response;\n  },\n};\n```\n\nKeep the guard on a missing key. A Worker that throws when somebody deletes that KV entry takes the whole site down with it.\n\n```\n      - run: |\n          COUNTRIES=$(echo \"${{ inputs.countries }}\" | jq -R 'split(\",\")')\n          npx wrangler kv key put --binding CONFIG --remote canary \\\n            \"$(jq -nc --argjson c \"$COUNTRIES\" \\\n               '{countries:$c,percentage:10,canaryHost:\"canary.example.com\"}')\"\n```\n\n## Deploying it yourself on GCP or AWS\n\nPutting the app in a container means nobody hands you a traffic splitter anymore, so you configure the one your platform already has.\n\n### Cloud Run\n\nA Cloud Run service keeps every revision you have ever deployed, and each deployment is split across them. Deploying with `--no-traffic --tag canary`\n\nships a revision that serves nobody and gets its own URL, which is the same preview step Cloudflare gives you.\n\n```\ngcloud run deploy my-app \\\n  --image gcr.io/my-project/my-app:$SHA \\\n  --region us-central1 --no-traffic --tag canary\n# reachable at https://canary---my-app-xxxxx.a.run.app\n```\n\nTraffic then moves by tag.\n\n```\ngcloud run services update-traffic my-app --region us-central1 --to-tags canary=5\ngcloud run services update-traffic my-app --region us-central1 --to-tags canary=50\ngcloud run services update-traffic my-app --region us-central1 --to-tags canary=100\n```\n\nRolling back names a revision instead of a tag, which is why the workflow captures the current one before deploying anything.\n\n```\ngcloud run services update-traffic my-app --region us-central1 \\\n  --to-revisions my-app-00042-abc=100\ngcloud run services update my-app --region us-central1 --session-affinity\n```\n\nUse Workload Identity Federation for the credentials. It lets the workflow assume a service account with no JSON key sitting in GitHub, which is why every job below asks for `id-token: write`\n\n.\n\n```\n# .github/workflows/deploy.yml\nname: Deploy\non:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: false\n\nenv:\n  SERVICE: my-app\n  REGION: us-central1\n  SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }}\n  SENTRY_ORG: my-org\n  SENTRY_PROJECT_ID: '4504000000000000'\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write        # Workload Identity Federation, no JSON keys\n    outputs:\n      preview: ${{ steps.deploy.outputs.preview }}\n      revision: ${{ steps.deploy.outputs.revision }}\n      baseline: ${{ steps.baseline.outputs.revision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: google-github-actions/auth@v2\n        with:\n          workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}\n          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}\n      - uses: google-github-actions/setup-gcloud@v2\n\n      # The entry carrying the most traffic, rather than whichever one\n      # happens to sit first in the list\n      - id: baseline\n        run: |\n          REV=$(gcloud run services describe \"$SERVICE\" --region=\"$REGION\" --format=json \\\n            | jq -r '[.status.traffic[] | select(.percent > 0)] | max_by(.percent) | .revisionName')\n          echo \"revision=$REV\" >> \"$GITHUB_OUTPUT\"\n\n      - run: |\n          gcloud builds submit --tag \"gcr.io/$GCP_PROJECT/$SERVICE:${{ github.sha }}\"\n        env:\n          GCP_PROJECT: ${{ secrets.GCP_PROJECT }}\n\n      # --no-traffic ships it with zero users. --tag gives it a URL.\n      - id: deploy\n        run: |\n          gcloud run deploy \"$SERVICE\" \\\n            --image \"gcr.io/${{ secrets.GCP_PROJECT }}/$SERVICE:${{ github.sha }}\" \\\n            --region \"$REGION\" --no-traffic --tag canary\n          URL=$(gcloud run services describe \"$SERVICE\" --region=\"$REGION\" --format=json \\\n            | jq -r '.status.traffic[] | select(.tag==\"canary\") | .url')\n          REV=$(gcloud run services describe \"$SERVICE\" --region=\"$REGION\" \\\n            --format='value(status.latestCreatedRevisionName)')\n          echo \"preview=$URL\" >> \"$GITHUB_OUTPUT\"\n          echo \"revision=$REV\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Send it 5% of traffic\n        run: gcloud run services update-traffic \"$SERVICE\" --region=\"$REGION\" --to-tags canary=5\n\n  smoke:\n    needs: deploy\n    runs-on: ubuntu-latest\n    timeout-minutes: 5\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - run: npx playwright install --with-deps chromium\n      - run: npx playwright test e2e/smoke --project=chromium\n        env:\n          PLAYWRIGHT_BASE_URL: ${{ needs.deploy.outputs.preview }}\n\n  soak:\n    needs: [deploy, smoke]\n    runs-on: ubuntu-latest\n    environment: canary-soak\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  promote:\n    needs: [deploy, soak]\n    if: needs.soak.outputs.decision == 'promote'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - uses: google-github-actions/auth@v2\n        with:\n          workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}\n          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}\n      - uses: google-github-actions/setup-gcloud@v2\n      - run: gcloud run services update-traffic \"$SERVICE\" --region=\"$REGION\" --to-tags canary=100\n\n  hold:\n    needs: soak\n    if: needs.soak.outputs.decision == 'hold'\n    runs-on: ubuntu-latest\n    steps:\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary parked at 5%. Not enough data to decide.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n\n  abort:\n    needs: [deploy, smoke, soak]\n    if: >\n      always() && (\n        needs.smoke.result == 'failure' ||\n        needs.soak.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - uses: google-github-actions/auth@v2\n        with:\n          workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}\n          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}\n      - uses: google-github-actions/setup-gcloud@v2\n      - run: |\n          gcloud run services update-traffic \"$SERVICE\" --region=\"$REGION\" \\\n            --to-revisions=\"${{ needs.deploy.outputs.baseline }}=100\"\n      - run: |\n          curl -sS -X POST -H 'Content-type: application/json' \\\n            --data \"{\\\"text\\\": \\\"Canary aborted. All traffic back on the previous revision.\\\"}\" \\\n            \"${{ secrets.SLACK_WEBHOOK }}\"\n\n  watch-15:\n    needs: [deploy, promote]\n    runs-on: ubuntu-latest\n    environment: watch-15min\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  watch-60:\n    needs: [deploy, promote, watch-15]\n    if: needs.watch-15.outputs.decision != 'abort'\n    runs-on: ubuntu-latest\n    environment: watch-45min    # runs after watch-15, so 15 + 45 = an hour in\n    outputs:\n      decision: ${{ steps.check.outputs.decision }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - id: check\n        run: node scripts/canary-health.mjs\n        env:\n          CANARY_RELEASE: ${{ github.sha }}\n          BASELINE_RELEASE: ${{ needs.deploy.outputs.baseline }}\n\n  rollback:\n    needs: [deploy, watch-15, watch-60]\n    if: >\n      always() && (\n        needs.watch-15.outputs.decision == 'abort' ||\n        needs.watch-60.outputs.decision == 'abort'\n      )\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - uses: google-github-actions/auth@v2\n        with:\n          workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }}\n          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}\n      - uses: google-github-actions/setup-gcloud@v2\n      - run: |\n          gcloud run services update-traffic \"$SERVICE\" --region=\"$REGION\" \\\n            --to-revisions=\"${{ needs.deploy.outputs.baseline }}=100\"\n```\n\n### ECS behind an Application Load Balancer\n\nIn ECS, containers use the load balancer split, where a listener rule forwards traffic to two target groups with weights.\n\n```\naws elbv2 modify-listener --listener-arn \"$LISTENER\" --default-actions '[{\n  \"Type\": \"forward\",\n  \"ForwardConfig\": {\n    \"TargetGroups\": [\n      {\"TargetGroupArn\": \"'\"$BLUE\"'\",  \"Weight\": 95},\n      {\"TargetGroupArn\": \"'\"$GREEN\"'\", \"Weight\": 5}\n    ],\n    \"TargetGroupStickinessConfig\": {\"Enabled\": true, \"DurationSeconds\": 3600}\n  }\n}]'\n```\n\nDeploy registers a task definition and updates the green service. The ramp is that `modify-listener`\n\ncall with new weights, promoting set green to 100, and both abort and rollback set blue back to 100.\n\n```\n      - name: Send it 5% of traffic\n        run: |\n          aws ecs update-service --cluster \"$CLUSTER\" --service \"$SERVICE_GREEN\" \\\n            --task-definition \"${{ steps.taskdef.outputs.arn }}\" --force-new-deployment\n          aws ecs wait services-stable --cluster \"$CLUSTER\" --services \"$SERVICE_GREEN\"\n          ./scripts/set-weights.sh 95 5\n\n      # promote\n      - run: ./scripts/set-weights.sh 0 100\n\n      # abort and rollback are the same call, the other way round\n      - run: ./scripts/set-weights.sh 100 0\nbash\n#!/usr/bin/env bash\n# scripts/set-weights.sh <blue-weight> <green-weight>\nset -euo pipefail\n\naws elbv2 modify-listener --listener-arn \"$LISTENER\" --default-actions \"$(cat <<JSON\n[{\n  \"Type\": \"forward\",\n  \"ForwardConfig\": {\n    \"TargetGroups\": [\n      {\"TargetGroupArn\": \"$BLUE\",  \"Weight\": $1},\n      {\"TargetGroupArn\": \"$GREEN\", \"Weight\": $2}\n    ],\n    \"TargetGroupStickinessConfig\": {\"Enabled\": true, \"DurationSeconds\": 3600}\n  }\n}]\nJSON\n)\"\n```\n\n### Country rollouts on either\n\nNeither platform reads geography on its own, so the country check goes in the CDN sitting in front.\n\nOn AWS, that is a CloudFront Function reading `CloudFront-Viewer-Country`\n\n, which CloudFront adds when you enable it in the cache policy. The function is the one from the Cloudflare section with the header swapped for `request.headers['cloudfront-viewer-country'].value`\n\n.\n\nOn GCP, the external Application Load Balancer can add a custom request header populated from `{client_region}`\n\n, which your container then reads like any other header. Configure it under the backend service, then route to it in the app or in a small proxy.\n\n## All the code\n\nEverything above lives in [github.com/Cst2989/canary-deploys](https://github.com/Cst2989/canary-deploys), one folder per platform.\n\n```\nscripts/canary-health.mjs     the decision: promote | abort | hold\nscripts/__tests__/            28 tests over every branch of it\nsetup/environments.sh         creates the four wait-timer environments\nplatforms/vercel/             deploy.yml, geo-rollout.yml, middleware.ts\nplatforms/netlify/            deploy.yml, edge-functions/canary.js\nplatforms/cloudflare/         deploy.yml, worker-geo.js, version-affinity.tf\nplatforms/cloud-run/          deploy.yml\nplatforms/ecs/                set-weights.sh and the job fragments\n```\n\nCopy `scripts/`\n\n, `setup/`\n\nand the one `platforms/<yours>/`\n\nfolder you need. The `deploy.yml`\n\nfiles go in `.github/workflows/`\n\n.\n\nThe change detection from [part 1](https://neciudan.dev/ci-cd-in-the-age-of-ai-part-1) is in [change-detection](https://github.com/Cst2989/change-detection), packaged as [affected-ci](https://github.com/Cst2989/affected-ci).\n\n### Discover more from The Neciu Dan Newsletter\n\nA weekly column on Tech & Education, startup building and occasional hot takes.\n\nOver 1,000 subscribers", "url": "https://wpnews.pro/news/frontend-ci-cd-in-the-age-of-ai-part-2-deployments", "canonical_source": "https://neciudan.dev/ci-cd-in-the-age-of-ai-part-2-deployments", "published_at": "2026-08-05 00:00:00+00:00", "updated_at": "2026-08-05 07:52:17.266068+00:00", "lang": "en", "topics": ["ai-agents", "ai-policy"], "entities": ["Neciu Dan", "DORA"], "alternates": {"html": "https://wpnews.pro/news/frontend-ci-cd-in-the-age-of-ai-part-2-deployments", "markdown": "https://wpnews.pro/news/frontend-ci-cd-in-the-age-of-ai-part-2-deployments.md", "text": "https://wpnews.pro/news/frontend-ci-cd-in-the-age-of-ai-part-2-deployments.txt", "jsonld": "https://wpnews.pro/news/frontend-ci-cd-in-the-age-of-ai-part-2-deployments.jsonld"}}