Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ RUN curl -fsSL https://github.com/PowerShell/PowerShell/releases/download/v7.4.1
&& apt-get clean \
&& rm /opt/powershell.deb

# Wrangler CLI for the "cloudflare" target (pinned; pulls the workerd binary)
RUN npm install -g wrangler@4.111.0 \
&& wrangler --version

# craft does `git` things against mounted directories as root
RUN git config --global --add safe.directory '*'

Expand Down
62 changes: 62 additions & 0 deletions docs/src/content/docs/targets/cloudflare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
title: Cloudflare
description: Deploy static sites or Workers to Cloudflare
---

Deploys a release artifact to Cloudflare, either as a [Cloudflare Pages](https://developers.cloudflare.com/pages/) site or as a [Cloudflare Worker](https://developers.cloudflare.com/workers/) with static assets.

The target extracts a ZIP artifact and shells out to the [`wrangler`](https://developers.cloudflare.com/workers/wrangler/) CLI to perform the deployment. `wrangler` is bundled in the Craft Docker image.

## Configuration

| Option | Description |
|--------|-------------|
| `deployType` | `pages` (default) or `worker`. |
| `projectName` | Cloudflare Pages project name. **Required** when `deployType` is `pages`. |
| `productionBranch` | The Pages project's production branch name. Passed to `wrangler pages deploy --branch` so a release always targets the **production** environment. Default: `main`. This is the Cloudflare environment selector, not your git release branch. |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is not a git branch, then why is thwe default main instead of 'production'?

| `wranglerCliPath` | Path to the `wrangler` binary. Default: `wrangler` (or the `WRANGLER_BIN` env var). |
| `workingDir` | Subdirectory within the extracted artifact to deploy from. For `worker` deploys this is where the `wrangler.toml` lives. |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have auto discovery for this? If yes, we should document, if not we should.


## Environment Variables

| Name | Description |
|------|-------------|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with permission to deploy Pages/Workers. |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID. |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Account id doesn't seem like a secret and maybe we can infer it from a config file or somkething?


Both are required. They are passed to `wrangler` via the environment, never on the command line.

## Default Behavior

By default, this target:

1. Looks for a single artifact matching `cloudflare.zip` (or `*-cloudflare.zip`). Override with `includeNames`.
2. Extracts its contents (flattening a single top-level directory if present).
3. Deploys via `wrangler`.

## Example

Cloudflare Pages (static site):

```yaml
targets:
- name: cloudflare
deployType: pages

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also infer this automatically?

projectName: my-docs-site
productionBranch: main
```

Cloudflare Worker (with a `wrangler.toml` in the artifact):

```yaml
targets:
- name: cloudflare
deployType: worker
workingDir: worker
```

## Workflow

1. Create a `cloudflare.zip` artifact in your CI workflow (e.g. the built static site, or your Worker plus `wrangler.toml`).
2. Configure the target in `.craft.yml`.
3. Set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` in your environment.
1 change: 1 addition & 0 deletions docs/src/content/docs/targets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Targets define where Craft publishes your release artifacts. Configure them in `
| [Homebrew](./brew/) | Update Homebrew formulas |
| [GCS](./gcs/) | Upload to Google Cloud Storage |
| [GitHub Pages](./gh-pages/) | Deploy static sites |
| [Cloudflare](./cloudflare/) | Deploy static sites or Workers to Cloudflare |
| [CocoaPods](./cocoapods/) | Publish iOS/macOS pods |
| [Ruby Gems](./gem/) | Publish Ruby gems |
| [Maven](./maven/) | Publish to Maven Central |
Expand Down
293 changes: 293 additions & 0 deletions src/targets/__tests__/cloudflare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import { vi } from 'vitest';

import { CloudflareTarget, targetSecrets } from '../cloudflare';
import { NoneArtifactProvider } from '../../artifact_providers/none';
import * as system from '../../utils/system';
import { isDryRun } from '../../utils/helpers';

const TMP_DIR = '/tmp/craft-cloudflare-test';
const DEFAULT_SECRET_VALUE = 'secret_value';

vi.mock('../../utils/helpers');

vi.mock('../../utils/system', async importOriginal => {
const actual = await importOriginal<typeof import('../../utils/system')>();
return {
...actual,
checkExecutableIsPresent: vi.fn(),
spawnProcess: vi.fn(async () => undefined),
extractZipArchiveWithFlattening: vi.fn(async () => undefined),
};
});

vi.mock('../../utils/files', async importOriginal => {
const actual = await importOriginal<typeof import('../../utils/files')>();
return {
...actual,
withTempDir: async (cb: (dir: string) => Promise<void>) => cb(TMP_DIR),
};
});

function setTargetSecretsInEnv(): void {
for (const secret of targetSecrets) {
process.env[secret] = DEFAULT_SECRET_VALUE;
}
}

function removeTargetSecretsFromEnv(): void {
for (const secret of targetSecrets) {
delete process.env[secret];
}
}

function createCloudflareTarget(
targetConfig?: Record<string, unknown>,
): CloudflareTarget {
return new CloudflareTarget(
{
name: 'cloudflare',
...targetConfig,
},
new NoneArtifactProvider(),
{ owner: 'testOwner', repo: 'testRepo' },
);
}

beforeEach(() => {
setTargetSecretsInEnv();
delete process.env.WRANGLER_BIN;
(isDryRun as any).mockReturnValue(false);
});

afterEach(() => {
removeTargetSecretsFromEnv();
vi.clearAllMocks();
});

describe('cloudflare target configuration', () => {
test('exports the expected secrets', () => {
expect(targetSecrets).toContain('CLOUDFLARE_API_TOKEN');
expect(targetSecrets).toContain('CLOUDFLARE_ACCOUNT_ID');
});

test('enforces required secrets', () => {
removeTargetSecretsFromEnv();

expect(() =>
createCloudflareTarget({ projectName: 'my-project' }),
).toThrowErrorMatchingInlineSnapshot(
`[Error: Required value(s) CLOUDFLARE_API_TOKEN not found in configuration files or the environment. See the documentation for more details.]`,
);

process.env.CLOUDFLARE_API_TOKEN = DEFAULT_SECRET_VALUE;
expect(() =>
createCloudflareTarget({ projectName: 'my-project' }),
).toThrowErrorMatchingInlineSnapshot(
`[Error: Required value(s) CLOUDFLARE_ACCOUNT_ID not found in configuration files or the environment. See the documentation for more details.]`,
);
});

test('applies default options (pages)', () => {
const target = createCloudflareTarget({ projectName: 'my-project' });

expect(target.cloudflareConfig).toStrictEqual({
CLOUDFLARE_API_TOKEN: DEFAULT_SECRET_VALUE,
CLOUDFLARE_ACCOUNT_ID: DEFAULT_SECRET_VALUE,
deployType: 'pages',
projectName: 'my-project',
productionBranch: 'main',
wranglerCliPath: 'wrangler',
workingDir: undefined,
});
});

test('allows overriding default options', () => {
const target = createCloudflareTarget({
deployType: 'worker',
projectName: 'my-project',
productionBranch: 'production',
wranglerCliPath: '/custom/wrangler',
workingDir: 'subdir',
});

expect(target.cloudflareConfig).toStrictEqual({
CLOUDFLARE_API_TOKEN: DEFAULT_SECRET_VALUE,
CLOUDFLARE_ACCOUNT_ID: DEFAULT_SECRET_VALUE,
deployType: 'worker',
projectName: 'my-project',
productionBranch: 'production',
wranglerCliPath: '/custom/wrangler',
workingDir: 'subdir',
});
});

test('resolves wrangler path from WRANGLER_BIN env', () => {
process.env.WRANGLER_BIN = '/env/wrangler';
const target = createCloudflareTarget({ projectName: 'my-project' });
expect(target.cloudflareConfig.wranglerCliPath).toBe('/env/wrangler');
});

test('throws on invalid deployType', () => {
expect(() =>
createCloudflareTarget({ deployType: 'bogus', projectName: 'p' }),
).toThrowErrorMatchingInlineSnapshot(
`[Error: [cloudflare] Invalid deployType "bogus": must be one of "pages", "worker"]`,
);
});

test('requires projectName for pages deployType', () => {
expect(() =>
createCloudflareTarget({ deployType: 'pages' }),
).toThrowErrorMatchingInlineSnapshot(
`[Error: [cloudflare] "projectName" is required when deployType is "pages"]`,
);
});

test('does not require projectName for worker deployType', () => {
expect(() =>
createCloudflareTarget({ deployType: 'worker' }),
).not.toThrow();
});

test('checks wrangler is present in the constructor', () => {
createCloudflareTarget({ projectName: 'my-project' });
expect(system.checkExecutableIsPresent).toHaveBeenCalledWith('wrangler');
});

test('rejects config values that look like env-var expansions', () => {
expect(() =>
createCloudflareTarget({ projectName: '${CLOUDFLARE_API_TOKEN}' }),
).toThrowErrorMatchingInlineSnapshot(
`[Error: [cloudflare] "projectName" must not be an environment-variable expansion (got "\${CLOUDFLARE_API_TOKEN}")]`,
);

expect(() =>
createCloudflareTarget({
projectName: 'ok',
workingDir: '${CLOUDFLARE_ACCOUNT_ID}',
}),
).toThrow(/workingDir.*must not be an environment-variable expansion/);
});
});

describe('publish', () => {
const revision = 'deadbeef';
const version = '1.2.3';
const artifact = { filename: 'cloudflare.zip' } as any;

function stubArtifacts(target: CloudflareTarget, artifacts: any[]): void {
target.getArtifactsForRevision = vi.fn(async () => artifacts);
target.artifactProvider.downloadArtifact = vi.fn(
async () => '/downloads/cloudflare.zip',
);
}

test('deploys a pages project with production branch and provenance', async () => {
const target = createCloudflareTarget({ projectName: 'my-project' });
stubArtifacts(target, [artifact]);

await target.publish(version, revision);

expect(system.extractZipArchiveWithFlattening).toHaveBeenCalledWith(
'/downloads/cloudflare.zip',
TMP_DIR,
);

expect(system.spawnProcess).toHaveBeenCalledTimes(1);
const [bin, args, options] = (system.spawnProcess as any).mock.calls[0];
expect(bin).toBe('wrangler');
expect(args).toEqual([
'pages',
'deploy',
TMP_DIR,
'--project-name',
'my-project',
'--branch',
'main',
'--commit-hash',
revision,
'--commit-message',
`Release ${version}`,
'--commit-dirty',
'false',
]);
// Secrets must be in env, not argv
expect(options.env.CLOUDFLARE_API_TOKEN).toBe(DEFAULT_SECRET_VALUE);
expect(options.env.CLOUDFLARE_ACCOUNT_ID).toBe(DEFAULT_SECRET_VALUE);
expect(args).not.toContain(DEFAULT_SECRET_VALUE);
});

test('uses the configured production branch', async () => {
const target = createCloudflareTarget({
projectName: 'my-project',
productionBranch: 'release',
});
stubArtifacts(target, [artifact]);

await target.publish(version, revision);

const [, args] = (system.spawnProcess as any).mock.calls[0];
expect(args).toContain('--branch');
expect(args[args.indexOf('--branch') + 1]).toBe('release');
});

test('deploys a worker using the local wrangler.toml', async () => {
const target = createCloudflareTarget({ deployType: 'worker' });
stubArtifacts(target, [artifact]);

await target.publish(version, revision);

const [bin, args, options] = (system.spawnProcess as any).mock.calls[0];
expect(bin).toBe('wrangler');
expect(args).toEqual(['deploy']);
expect(options.cwd).toBe(TMP_DIR);
expect(options.env.CLOUDFLARE_API_TOKEN).toBe(DEFAULT_SECRET_VALUE);
});

test('deploys from workingDir subdirectory when configured', async () => {
const target = createCloudflareTarget({
projectName: 'my-project',
workingDir: 'dist',
});
stubArtifacts(target, [artifact]);

await target.publish(version, revision);

const [, args, options] = (system.spawnProcess as any).mock.calls[0];
// pages deploy directory arg should point at the subdir
expect(args[2]).toBe(`${TMP_DIR}/dist`);
expect(options.cwd).toBe(`${TMP_DIR}/dist`);
});

test('reports an error and does not deploy when no artifacts found', async () => {
const target = createCloudflareTarget({ projectName: 'my-project' });
stubArtifacts(target, []);

await expect(target.publish(version, revision)).rejects.toThrow(
/no artifacts found/,
);
expect(system.spawnProcess).not.toHaveBeenCalled();
});

test('reports an error when more than one artifact found', async () => {
const target = createCloudflareTarget({ projectName: 'my-project' });
stubArtifacts(target, [artifact, artifact]);

await expect(target.publish(version, revision)).rejects.toThrow(
/more than one Cloudflare archive/,
);
expect(system.spawnProcess).not.toHaveBeenCalled();
});

test('does not deploy in dry-run mode (including worktree mode)', async () => {
(isDryRun as any).mockReturnValue(true);
const target = createCloudflareTarget({ projectName: 'my-project' });
stubArtifacts(target, [artifact]);

await target.publish(version, revision);

// Artifact is still extracted (local, safe), but the remote deploy is skipped
expect(system.extractZipArchiveWithFlattening).toHaveBeenCalled();
expect(system.spawnProcess).not.toHaveBeenCalled();
});
});
Loading
Loading