Skip to content

cf deploy aborts in CI on a Worker last uploaded via the script API, with no flag to override #69

Open

Description

@kenwongkaien

Version: cf 1.0.0-beta.5 (with @cloudflare/vite-plugin 2.0.0-beta.sha-ad79608dd)

What happens

We moved three existing Workers from another deploy tool, which uploads through the Workers script API, to cf. The first cf deploy --prebuilt in GitHub Actions for each Worker aborts:

▲  You are about to upload a Worker that was last updated via the script API.
│  Edits that have been made via the script API will be overridden by your local code and config.
■  Aborting the upload operation because of conflicts. To override and upload anyway, remove the `--strict` flag

We never passed --strict, and cf deploy has no such flag to remove. The deploy input for deploy (and versions upload) hard-codes strict: true, so in a non-interactive environment the last_deployed_from === "api" confirmation always resolves to abort. The same applies to the last_deployed_from === "dash" config-diff prompt.

--dry-run skips these pre-upload checks, so a dry run passes and the abort only shows up on the real deploy.

Repro

  1. Upload a Worker via the script API (PUT /accounts/:id/workers/scripts/:name), e.g. with any non-Wrangler tool.
  2. From a cf project with the same Worker name: CI=1 cf deploy </dev/null.
  3. It exits 1 with the message above.

A Worker first uploaded by cf redeploys fine non-interactively, so this only blocks the first cf deploy of a Worker that another tool manages today. That is exactly the migration path, and it has to happen in CI when deploys are CI-only.

Ask

A way to confirm the override non-interactively, e.g. cf deploy --force / --no-strict (or an env var). Also, the message should not tell users to remove a flag that cf doesn't expose.

Activity

  1. matthewbjones commented on Sep 29, 2026

    @matthewbjones

    Standalone reproduction on cf 1.0.0-beta.5, Node 24.4.1, Bun 1.4.2, macOS. This uses a local mock API, dummy credentials, and a tiny prebuilt Worker. No Cloudflare account, existing Worker, or application repository is required. Telemetry is disabled.

    Install in a fresh directory:

    mkdir cf-69-repro
    cd cf-69-repro
    printf '{"private":true}\n' > package.json
    bun add --exact cf@1.0.0-beta.5

    Save the following as ci-repro.mjs and run node ci-repro.mjs. The script asserts the observed beta.5 behavior and removes its temporary Worker output afterward.

    Complete reproduction script
    import assert from 'node:assert/strict';
    import { spawn } from 'node:child_process';
    import { createServer } from 'node:http';
    import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
    import { tmpdir } from 'node:os';
    import { join, resolve } from 'node:path';
    
    const cli = resolve('node_modules/.bin/cf');
    const cwd = mkdtempSync(join(tmpdir(), 'cf-ci-repro-'));
    const output = join(cwd, '.cloudflare/output/v0');
    const worker = join(output, 'workers/default');
    mkdirSync(join(worker, 'bundle'), { recursive: true });
    // Minimal prebuilt Worker: no framework or repository dependencies.
    writeFileSync(join(output, 'config.json'), JSON.stringify({ buildContext: { isPreview: false } }));
    writeFileSync(join(worker, 'worker.config.json'), JSON.stringify({
      name: 'fixture', compatibilityDate: '2026-09-29', env: {},
      manifest: { type: 'complete', mainModule: 'worker.js', modules: { 'worker.js': { type: 'esm' } } },
    }));
    writeFileSync(join(worker, 'bundle/worker.js'), 'export default { fetch() { return new Response("ok"); } };');
    const requests = [];
    const server = createServer((req, res) => {
      requests.push(`${req.method} ${req.url}`);
      const matches = req.method === 'GET' && req.url === '/client/v4/accounts/fixture-only/workers/services/fixture';
      res.writeHead(matches ? 200 : 500, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ success: matches, errors: [], messages: [], result: {
        id: 'fixture', default_environment: { environment: 'production', script: {
          id: 'fixture', tag: 'fixture', last_deployed_from: 'api', compatibility_date: '2026-09-29',
        } },
      } }));
    });
    await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
    try {
      const child = spawn(cli, ['deploy', '--prebuilt'], { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: {
        ...process.env, CI: 'true', CLOUDFLARE_API_TOKEN: 'fixture-only', CLOUDFLARE_ACCOUNT_ID: 'fixture-only',
        CLOUDFLARE_API_BASE_URL: `http://127.0.0.1:${server.address().port}/client/v4`,
        CF_SEND_TELEMETRY: 'false', WRANGLER_SEND_METRICS: 'false', DO_NOT_TRACK: '1',
      } });
      let output = '';
      for (const stream of [child.stdout, child.stderr]) stream.on('data', data => { output += data; });
      const code = await new Promise((resolve, reject) => { child.on('error', reject); child.on('close', resolve); });
      console.log(output.trim());
      console.log(JSON.stringify({ exit: code, requests }));
      assert.equal(code, 1);
      assert.match(output, /remove the `--strict` flag/);
      assert.equal(requests.length, 1);
      assert(requests[0].startsWith('GET '));
    } finally { server.close(); rmSync(cwd, { recursive: true, force: true }); }

    Observed output:

    ├  Deploy
    ▲  You are about to upload a Worker that was last updated via the script API.
    │  Edits that have been made via the script API will be overridden by your local code and config.
    ■  Aborting the upload operation because of conflicts. To override and upload anyway, remove the `--strict` flag
    │
    ◆  Deploy complete
    {"exit":1,"requests":["GET /client/v4/accounts/fixture-only/workers/services/fixture"]}
    

    The mock returns last_deployed_from: "api" on the service lookup. The CLI exits 1 before any upload. No --strict flag was supplied, and the output nevertheless tells the caller to remove it. It also prints Deploy complete after the abort.

    Expected: expose an explicit way to authorize the migration in CI, and make the abort guidance refer to options actually supported by cf deploy. This reproduction exercises the script-API origin case; it does not test the dashboard-origin case.

  2. added theissue type on Sep 29, 2026
  3. treeder commented on Oct 12, 2026

    @treeder

    What's the workaround when this happens?

  4. jswny commented on Oct 21, 2026

    @jswny

    I am hitting this as well, have to switch off cf in the meantime as it breaks my deploys every time I add a secret in the cloudflare dashboard :(

  5. ferdousbhai commented on Oct 25, 2026

    @ferdousbhai

    Adding the dashboard-origin case from a production Worker deployed by Workers Builds, and a workaround (for @treeder's question). Still present in cf 1.0.0-beta.20.

    What happened

    1. Updated one secret on the Worker in the Cloudflare dashboard. The script's last_deployed_from became dash.
    2. Every Workers Builds deploy after that (deploy command runs cf deploy --prebuilt) failed with:
      Aborting the upload operation because of conflicts. To override and upload anyway, remove the `--strict` flag
      
      Production stayed on the old version, which no longer matched the secret we had just changed. That's how a routine secret rotation became an outage.

    What does not clear the dash marker

    • cf workers secrets update <NAME> --worker <worker>
    • cf deployments create (promoting an existing version)

    Workaround that worked

    Run one cf deploy --prebuilt interactively (in a real TTY) from a clean checkout of the same commit and answer yes at the conflict prompt. That upload resets last_deployed_from, and Workers Builds deploys succeed again afterwards. Workers Builds itself can't do this, because it has no TTY.

    Where it comes from (beta.20 dist)

    • deploy-input-*.mjs: the deploy and versions upload inputs set strict: !0 unconditionally.
    • containers-*.mjs (getDeployConfirmFunction): when non-interactive and strictMode, it logs the abort and sets process.exitCode = 1.
    • cf deploy --help lists no --strict, --force, or --yes option, so the message's advice can't be followed.

    Suggestions

    • Add an explicit override for non-interactive runs (e.g. --force / --no-strict, or an env var Workers Builds can set).
    • If only secrets changed in the dashboard, consider not treating it as a conflict. Secrets aren't in the local config, so a deploy can't overwrite them.
    • Make the abort message name an option that exists. (As noted above, it also prints Deploy complete after aborting.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions