Testing, CI and DevOps

This content was generated with the assistance of AI and should be verified against the official Webflow documentation before being relied on in production.

This sub-section’s Webflow bibliography lists the reference material consulted while preparing these pages.

This page describes how to test and operate the developer side of a Webflow project as it was on 2026-10-28, the date on which its facts were checked against the CLI, Webflow Cloud, Data API rate-limit and webhook pages of the documentation on developers.webflow.com and against the public status page. Everything on it can be tried on a Developer workspace site without a paid plan, with two exceptions: sites on the free tier have the lowest API rate limit, and the number of backups kept and the availability of branches and Releases depend on the plan. Plans are named by tier only, never by price; see Plans, Workspaces, Seats and Roles for the tiers. The pinned tool versions are webflow-api 3.3.4 (Node SDK), webflow 2.0.0 (Python SDK) and @webflow/webflow-cli 2.15.0, which requires Node.js 22.13.0 or later.

Webflow is a hosted product, so there is no server of your own to patch, and the visual output of the Designer cannot be unit-tested in the usual sense. What can be tested and automated is everything around it: the published pages, the custom code and scripts loaded into them, the apps and Cloud deployments, the scripts that write to the CMS and the components exported to React. The running example is Fernbrook Gardens, a fictitious botanical garden whose developer maintains fernbrook-sync (a nightly plant import), a Webflow Cloud app called Visit Planner, the PlantCard component exported with DevLink into a Next.js kiosk, and the BloomCalendar code component.

The delivery model in one picture

flowchart LR GIT["Git repository: scripts, kiosk app, components"] --> CI["GitHub Actions"] CI --> EXPORT["devlink export: components to React"] CI --> IMPORT["devlink import: code components to workspace"] CI --> CLOUD["Webflow Cloud deploy per environment"] CI --> SYNC["fernbrook-sync: CMS writes via Data API"] SYNC --> STAGE["Staging site on webflow.io"] STAGE --> E2E["Playwright checks"] E2E -->|pass| PUBLISH["Publish to production domain"] PUBLISH --> MON["Monitoring: status page, webhooks, uptime checks"] BK["Backup or CSV export"] --> SYNC

The picture contains two ideas that the rest of the page expands. First, Webflow has no staging server that you deploy to with a command: the Designer is the editing environment and publishing is the deployment, so the staging layer is a second publish target (the webflow.io domain) plus, on plans that have them, branches and Releases. Second, the developer artefacts that live outside Webflow (scripts, React apps, Cloud apps) are ordinary code and follow ordinary CI/CD, with a few Webflow-specific rules about tokens, rate limits and idempotency.

Environments

Developer workspace

A Developer workspace is the free sandbox Webflow offers for building and testing integrations. It is the right place for everything on this page that writes data: create a throwaway copy of the Fernbrook Gardens site there, point CI at its site ID and token, and never let a test run use the production site’s token. Keep two sets of secrets in the repository settings, one per environment, with names that make mixing them up hard (WEBFLOW_API_TOKEN_STAGING and WEBFLOW_API_TOKEN_PRODUCTION).

The webflow.io staging domain

Every Webflow site can be published to its free webflow.io subdomain independently of its custom domains, and the publish call in the Data API accepts a list of domain IDs, so a pipeline can publish to staging only. Because the staging URL serves the same generated HTML, CSS and JavaScript as production, it is the target for browser tests. The URL used below, https://fernbrook-gardens.webflow.io, is a placeholder for the real subdomain. Domain choices, custom domains and publish options are described in Domains, Hosting and Publishing Options and Publishing and Scheduling.

A rule that matters for automation: the Data API allows one successful site publish per minute, so a pipeline that publishes after every commit must serialise its publishes (see the concurrency group in the sync workflow below).

Branches and Releases

Page branching and Releases let designers work on a page without touching what is live and promote changes after review; they are covered in Page Branching, Releases and Approvals. For a developer the useful consequence is that a branch gives a preview URL where custom code changes can be tested by people, while automated tests still run against the staging domain.

Webflow Cloud environments

Webflow Cloud apps have their own environment model, described in Webflow Cloud: Deployments and Environments. In summary, each environment is an isolated container tracking one GitHub branch, and every push to that branch triggers a deployment. Each environment has a unique mount path under the Webflow domain, so a staging environment of Visit Planner can live at /planner-staging while production lives at /planner. A failed deployment leaves the last successful deployment serving, so a broken push does not cause downtime. Environment variables are set per environment (up to 110 per environment), can be marked secret, and take effect only after a new deployment.

Testing the published site and custom code

What to test

Webflow itself is not what you test; the things you control are:

The general method of browser testing, including page objects, traces, retries and CI, is covered in the end-to-end testing section: End-to-End Testing, Running E2E Tests in CI/CD and the Playwright pages from Playwright: Getting Started. This page only adds the Webflow-specific parts.

A Playwright project for the staging site

The configuration reads the base URL from an environment variable so the same tests run against staging, a branch preview or the production domain.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  retries: process.env.CI ? 2 : 0,
  reporter: [['html', { open: 'never' }], ['list']],
  use: {
    baseURL: process.env.SITE_URL ?? 'https://fernbrook-gardens.webflow.io',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'desktop', use: { ...devices['Desktop Chrome'] } },
    { name: 'mobile', use: { ...devices['Pixel 7'] } },
  ],
});

Two projects, desktop and mobile, matter for Webflow because the published CSS is organised by breakpoints; a rule that works at the desktop breakpoint can overflow at the mobile one (see Responsive Design and Breakpoints).

Smoke tests for custom code and forms

// e2e/smoke.spec.ts
import { test, expect } from '@playwright/test';

test('home page loads without console errors', async ({ page }) => {
  const errors: string[] = [];
  page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
  page.on('pageerror', (err) => errors.push(err.message));

  await page.goto('/');
  await expect(page).toHaveTitle(/Fernbrook Gardens/);
  await expect(page.locator('html')).toHaveAttribute('lang', /^en/);
  expect(errors).toEqual([]);
});

test('bloom reveal runs on scroll', async ({ page }) => {
  await page.goto('/plants');
  const card = page.locator('[data-bloom-reveal]').first();
  await card.scrollIntoViewIfNeeded();
  await expect(card).toHaveCSS('opacity', '1');
});

test('group visit form shows the success state', async ({ page }) => {
  await page.goto('/visit');
  await page.getByLabel('Name').fill('Test Visitor');
  await page.getByLabel('Email').fill('test+ci@example.com');
  await page.getByRole('button', { name: 'Send enquiry' }).click();
  await expect(page.locator('.w-form-done')).toBeVisible();
});

The .w-form-done and .w-form-fail classes are the success and error blocks that Webflow generates for every form (see Forms). A test that submits a real form creates a real submission and, if notifications are configured, sends an email, so run form tests only against the staging site and point its notification address at a mailbox for tests.

Redirect and locale checks

Redirects are data, and data deserves a table-driven test. The test below checks a few of the 301 redirects configured for the old site and the Spanish locale route.

// e2e/routes.spec.ts
import { test, expect } from '@playwright/test';

const redirects: Array<[string, string]> = [
  ['/old-site/orchids', '/plants/orchids'],
  ['/tickets.html', '/visit'],
];

for (const [from, to] of redirects) {
  test(`301 from ${from}`, async ({ request, baseURL }) => {
    const res = await request.get(from, { maxRedirects: 0 });
    expect(res.status()).toBe(301);
    expect(new URL(res.headers()['location'], baseURL).pathname).toBe(to);
  });
}

test('Spanish locale is served under /es', async ({ page }) => {
  await page.goto('/es');
  await expect(page.locator('html')).toHaveAttribute('lang', /^es/);
});

Testing apps and Cloud routes

A Webflow Cloud app is reached through its mount path, and the Cloud app takes precedence over a Webflow page that has the same path. A route test therefore asserts both the app route and that a Webflow page next to it still renders. For a Designer Extension or a hybrid app (see Designer Extensions and the Designer API and Hybrid Apps), the Designer itself cannot be driven reliably from CI, so test the extension’s logic as plain TypeScript with the Designer API mocked, and test its backend through HTTP.

Contract tests for the CMS sync

The fernbrook-sync script reads a nursery inventory feed and writes Plants items through the Data API. Two contracts can break without any code change on your side: the feed (the nursery adds, renames or drops a field) and the CMS schema (an editor deletes or renames a field in the Designer). A contract test checks each boundary without touching production.

// test/contracts.test.ts
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { WebflowClient } from 'webflow-api';

const FeedItem = z.object({
  sku: z.string(),
  commonName: z.string().min(1),
  latinName: z.string().min(1),
  zone: z.number().int().min(1).max(13),
  endangered: z.boolean(),
});

describe('nursery feed', () => {
  it('matches the shape the importer expects', async () => {
    const res = await fetch(process.env.FEED_URL!);
    const items = z.array(FeedItem).parse(await res.json());
    expect(items.length).toBeGreaterThan(0);
  });
});

describe('Plants collection', () => {
  it('still has the fields the importer writes', async () => {
    const client = new WebflowClient({ accessToken: process.env.WEBFLOW_API_TOKEN! });
    const collection = await client.collections.get(process.env.PLANTS_COLLECTION_ID!);
    const slugs = collection.fields?.map((f) => f.slug) ?? [];
    for (const required of ['name', 'latin-name', 'hardiness-zone', 'is-endangered']) {
      expect(slugs).toContain(required);
    }
  });
});

Run this suite on a schedule, not only on pushes, because the contracts break on events outside the repository. A failing feed test tells the team to talk to the nursery; a failing schema test tells them an editor changed a field. For the CMS-side rules that make schema changes safe, see CMS Concepts and Content Modeling and the SDK pages Data API: SDKs, Limits and Errors and Data API: CMS. The same checks in Python use the webflow 2.0.0 package and pydantic.

# tests/test_contracts.py
import os
from pydantic import BaseModel, Field
from webflow.client import Webflow


class FeedItem(BaseModel):
    sku: str
    commonName: str = Field(min_length=1)
    latinName: str = Field(min_length=1)
    zone: int = Field(ge=1, le=13)
    endangered: bool


def test_plants_collection_has_required_fields():
    client = Webflow(access_token=os.environ["WEBFLOW_API_TOKEN"])
    collection = client.collections.get(os.environ["PLANTS_COLLECTION_ID"])
    slugs = {field.slug for field in collection.fields}
    assert {"name", "latin-name", "hardiness-zone", "is-endangered"} <= slugs

GitHub Actions workflows

All workflows below follow the same security rules, which the CLI documentation also gives for CI: tokens come from repository or environment secrets and are passed through the WEBFLOW_API_TOKEN environment variable, never as a --api-token flag that a shell might echo; the CLI is pinned to an exact version because tags such as latest and next move; and --no-input is passed so a missing value fails fast instead of hanging on a prompt (the CLI also turns prompts off by itself when CI=true or when there is no terminal).

The PlantCard component is exported into the kiosk repository. The workflow regenerates the webflow/ folder on demand and on a schedule, and opens a change for review instead of committing straight to the main branch. Exporting needs a token with read access to components and the site ID, and the devlink-export block of webflow.json configures the output (see DevLink Export and Webflow CLI).

name: Export DevLink components
on:
  workflow_dispatch:
  schedule:
    - cron: '0 5 * * 1'
permissions:
  contents: write
  pull-requests: write
jobs:
  export:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm install -g @webflow/webflow-cli@2.15.0
      - name: Export components
        run: webflow devlink export --site "$WEBFLOW_SITE_ID" --no-input
        env:
          WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN_STAGING }}
          WEBFLOW_SITE_ID: ${{ secrets.WEBFLOW_SITE_ID_STAGING }}
      - name: Type-check the kiosk against the new components
        run: npx tsc --noEmit
      - name: Open a pull request when the export changed
        run: |
          if [ -n "$(git status --porcelain webflow)" ]; then
            git config user.name "fernbrook-ci"
            git config user.email "ci@example.com"
            git checkout -b "devlink/export-${GITHUB_RUN_ID}"
            git add webflow
            git commit -m "Update exported DevLink components"
            git push origin HEAD
            gh pr create --fill
          fi
        env:
          GH_TOKEN: ${{ github.token }}

The type-check step is the cheap test that catches the usual breakage: a designer renamed a component property in Webflow, the export changed the React prop name and the kiosk no longer compiles. Reviewing the export as a pull request makes that change visible to the developers before it reaches the kiosk’s main branch.

Code component import

Importing bundles the React components of the BloomCalendar library and uploads them to the workspace as a shared library. Two documented points shape the workflow: --no-input skips the prompts for CI/CD, and a pipeline should detect changes so that components are not removed by accident; the bundle is limited to 50 MB, which includes dependencies and assets such as images and fonts, and both import and bundle exit with 0 on success, so any non-zero code is a failure. See Code Components.

name: Import code components
on:
  push:
    branches: [main]
    paths:
      - 'components/**'
      - 'webflow.json'
jobs:
  import:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm install -g @webflow/webflow-cli@2.15.0
      - name: Check the bundle size locally
        run: |
          webflow devlink bundle --public-path http://localhost:4000/
          du -sm dist
      - name: Share the library with the workspace
        run: webflow devlink import --no-input
        env:
          WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_WORKSPACE_API_TOKEN }}

The paths filter is the simple form of change detection: the import runs only when component sources or the manifest changed. The secret holds a Workspace API token, which the CLI reads from WEBFLOW_API_TOKEN in non-interactive runs (see Webflow CLI for the authentication modes).

Webflow Cloud deploys

The recommended route for continuous deployment of a Cloud app is the GitHub connection in the dashboard: every push to the branch an environment tracks produces a deployment, and a failed deployment keeps the previous one live. A workflow is needed only for custom build steps, per-environment secrets or deploy gates. The CLI command is webflow cloud deploy on the stable channel; the apps family, which gives deployment history, waiting and idempotency, exists on the pre-release channel (see Webflow CLI for the channels).

name: Deploy Visit Planner
on:
  push:
    branches: [main]
concurrency:
  group: deploy-visit-planner
  cancel-in-progress: false
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci && npm test
      - name: Install Webflow CLI
        run: npm install -g @webflow/webflow-cli@next   # pin an exact pre-release version in a real pipeline
      - name: Deploy
        run: |
          webflow apps deploy --no-input \
            --site-id "$WEBFLOW_SITE_ID" \
            --mount /planner \
            --environment production \
            --skip-mount-path-check
        env:
          WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }}
          WEBFLOW_SITE_ID: ${{ secrets.WEBFLOW_SITE_ID }}
      - name: Wait for the deployment to finish
        run: |
          DEP_ID=$(webflow apps deployments list --limit 1 --json | jq -r '.deployments[0].id')
          webflow apps deployments get "$DEP_ID" --wait --timeout 300
        env:
          WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }}

Four details make this workflow safe. --mount and --environment are passed together because without both the deploy still prompts and would hang. The concurrency group serialises deploys so two pushes cannot race. The wait step turns the asynchronous deployment into a pass or fail result (exit code 0 means success; 1 covers any other outcome and a timeout, so read the reported status to tell them apart). Finally, retries should not queue duplicate builds:

webflow apps deployments trigger --idempotency-key "$GITHUB_RUN_ID"
webflow apps deployments redeploy REPLACE_WITH_DEPLOYMENT_ID --idempotency-key "$GITHUB_RUN_ID"

Both commands require a GitHub-connected app. Secrets for the app itself, such as the key that Visit Planner uses for a map service, are set per environment, and the documentation shows piping the value through standard input so it does not land in shell history or the process list:

printf '%s' "$MAP_KEY" | webflow apps env-vars set MAP_KEY --secret \
  --app-id REPLACE_WITH_APP_ID --environment-id REPLACE_WITH_ENVIRONMENT_ID

Environment-variable changes take effect only after the next deployment, and the variable page says secret values are masked in the dashboard and redacted from build logs. Do not print them anyway.

Rollbacks. Webflow Cloud has no one-click restore of a past build in the way a static host does. The documented rollback is to revert the tracked branch to the wanted commit and push, which triggers a new deployment of the earlier code; the documentation notes that this does not restore the exact state of the previous deployment, so anything the app stored in its databases is untouched. With the apps family, redeploy reruns a deployment at the same commit.

Logs for diagnosing a failed deployment are in the environment’s Deployment Dashboard: build logs per deployment ID (the commit SHA) and a Runtime Logs tab with server-side output and errors. See Webflow Cloud: Deployments and Environments for the full model.

CMS sync with secrets

The nightly import runs fernbrook-sync. Its design rules are those of any job that writes to a shared system: a dry-run mode, a concurrency group so two runs never overlap, a backup before writing, and idempotency so a retry cannot create duplicates (the script matches nursery SKUs to existing items by a SKU field and updates rather than creates).

name: Nightly plant sync
on:
  schedule:
    - cron: '30 2 * * *'
  workflow_dispatch:
    inputs:
      dry_run:
        description: 'Report changes without writing'
        type: boolean
        default: true
concurrency:
  group: fernbrook-sync
  cancel-in-progress: false
jobs:
  sync:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - name: Contract tests
        run: npm run test:contracts
        env:
          WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }}
          PLANTS_COLLECTION_ID: ${{ vars.PLANTS_COLLECTION_ID }}
          FEED_URL: ${{ vars.FEED_URL }}
      - name: Export a backup of the Plants items
        run: node scripts/backup-plants.mjs "backup/plants-${GITHUB_RUN_ID}.json"
        env:
          WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }}
          PLANTS_COLLECTION_ID: ${{ vars.PLANTS_COLLECTION_ID }}
      - uses: actions/upload-artifact@v4
        with:
          name: plants-backup
          path: backup/
          retention-days: 30
      - name: Sync
        run: node scripts/sync.mjs ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '--dry-run' || '' }}
        env:
          WEBFLOW_API_TOKEN: ${{ secrets.WEBFLOW_API_TOKEN }}
          PLANTS_COLLECTION_ID: ${{ vars.PLANTS_COLLECTION_ID }}
          FEED_URL: ${{ vars.FEED_URL }}

The token is a site token (or a workspace token for several sites) with only the scopes the job needs: CMS read and write for the Plants collection and nothing else. Scopes and token types are explained in Data API: Authentication and Scopes. Using a GitHub environment named production lets the repository require a manual approval before the job can read the production secrets.

Rate limits, retries and idempotency

The Data API limits requests per minute per API key by plan: 60 for the Starter and Basic site plans, 120 for CMS, Ecommerce and Business, and a custom limit for Enterprise. Responses carry X-RateLimit-Limit and X-RateLimit-Remaining; exceeding the limit returns HTTP 429 with the error code too_many_requests and a Retry-After header, typically 60 seconds. The official SDK retries with exponential backoff by itself; a hand-written client must wait for the Retry-After value. A site publish is limited to one successful publish per minute, and the documentation recommends webhooks over polling to stay under the limit.

A sync that writes hundreds of plants should therefore batch (the bulk CMS endpoints accept many items per call), throttle itself below the limit and treat a 429 as a signal to pause. A wrapper such as the following makes the pause explicit when a raw fetch is used instead of the SDK.

async function withRetry<T>(call: () => Promise<Response>, parse: (r: Response) => Promise<T>): Promise<T> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await call();
    if (res.status !== 429) {
      if (!res.ok) throw new Error(`Webflow API error ${res.status}`);
      return parse(res);
    }
    const waitSeconds = Number(res.headers.get('retry-after') ?? '60');
    await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
  }
  throw new Error('Rate limit retries exhausted');
}

Idempotency is the second half of safe retries. A retried create writes a second item; a retried update does not. Prefer matching on a stable key (the SKU stored in a field) and updating, and, for deployments, pass the CI run ID as the idempotency key as shown above. Details of the SDK retry options and the error catalogue are in Data API: SDKs, Limits and Errors.

Monitoring

The status page

The public status page at https://status.webflow.com lists components grouped by area, including Hosted Websites, Site Pages, Ecommerce, Forms, Webflow Canvas, Dashboard, Publishing, the API and MCP server, Analyze and Email Notifications. It offers subscriptions by email, SMS and Slack, an Atom and an RSS feed of incident history, and webhook notifications that call a URL you provide on incident creation, update and resolution and on component status changes. Subscribe the team’s incident channel with a webhook so that a failed nightly sync can be compared with a Webflow incident before anyone debugs the script.

Source: Webflow status

Webhooks from the site

Site webhooks turn publishes and submissions into events your monitoring can use. The event types include form_submission, site_publish, collection_item_created, collection_item_changed, collection_item_published and collection_item_unpublished among others. Webflow expects a 200 response; any other result, including redirects, TLS errors and timeouts, counts as a failure and is retried up to three more times, ten minutes apart. Repeated failures deactivate the webhook and send an email, so a receiver that goes down silently loses events: alert on the receiver’s own health. There is a limit of 75 webhooks per trigger type. Receivers must verify the x-webflow-signature header, an HMAC SHA-256 of the timestamp, a colon and the raw body, and should reject requests older than five minutes.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebflow(rawBody: string, timestamp: string, signature: string, secret: string): boolean {
  const sent = Number(timestamp);
  const sentMs = sent > 1e12 ? sent : sent * 1000; // Unix epoch time: accept seconds or milliseconds
  const ageMs = Date.now() - sentMs;
  if (!timestamp || !Number.isFinite(ageMs) || Math.abs(ageMs) > 5 * 60 * 1000) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}:${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

The documentation’s example timestamp (1722370035277) is in milliseconds, which is why the function compares against Date.now() directly. The full receiver for Fernbrook Gardens, with form submissions and item publishes, is on Webhooks.

Synthetic checks and metrics

Add a scheduled run of the smoke tests against the production domain (every hour is enough for most sites) and alert on failure; it is the only check that covers DNS, the CDN, custom code and forms together. Page speed and Core Web Vitals belong to Performance Optimization and Analyze and Optimize. Also record the deployment history of Cloud apps and the X-RateLimit-Remaining header of a sync run, so that a growing sync that approaches its limit is noticed before it fails.

Backups before bulk API writes

Webflow’s own backups (see Backups and Recovery) are Designer-level restore points; they are not an API and restoring one rolls the whole site back, which is a poor tool for undoing a bad import of two hundred plants. Before any bulk write, take a data-level backup that can restore individual items:

  • export the collection to CSV from the CMS panel (see CSV Import and Export), or list items through the API and store the JSON, as scripts/backup-plants.mjs does in the workflow above

  • keep the backup as a CI artifact with a retention period, and keep the IDs, because restoring means updating items by ID

  • create a manual Webflow backup of the site before an unusually large change

  • run the first execution of any new script with --dry-run, and on a Developer workspace copy of the site, before it touches production

  • for a script that deletes, require an explicit flag and print the number of items affected before acting

A backup is only useful if restore has been rehearsed, so include a restore script that reads the JSON and updates items by ID, and run it once against the test site.

Common pitfalls

  • Production token in CI for tests. A leaked or misused token writes to the live site. Use separate tokens, scopes and GitHub environments per target.

  • Tests that depend on editor content. Staging content changes; assert on structure and attributes you control, not on copy an editor may rewrite.

  • Unpinned CLI. latest and next move, and so do command names between channels. Pin an exact version.

  • Prompts in CI. A missing flag hangs a job unless --no-input is set; --mount and --environment must be given together for apps deploy.

  • Publishing on every push. One successful publish per minute is allowed; serialise publishes with a concurrency group.

  • Webhook receiver without a health check. Repeated failures deactivate the webhook; monitor the receiver.

  • Secrets in logs. Pass secrets by environment variable or standard input, never as arguments or echoed values.

  • Assuming a rollback restores data. Redeploying earlier code does not restore database content written by the app.