-
-
Notifications
You must be signed in to change notification settings - Fork 20
feat(targets): add cloudflare deploy target #843
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | | ||
| | `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. | | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
| 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(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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'?