From 53801ceb0afed8fac4b15fd8b678fabd1b75696d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 15 Jul 2026 15:02:19 +0000 Subject: [PATCH] feat(targets): add cloudflare deploy target Add a `cloudflare` release target that deploys a release artifact to Cloudflare via the `wrangler` CLI. Supports two config-selectable modes: - `deployType: pages` (default): `wrangler pages deploy` to a Pages project. Always deploys to production by passing the configured `productionBranch` (default `main`) as `--branch`, plus commit provenance flags so wrangler does not infer git state from the extracted-artifact temp dir. - `deployType: worker`: `wrangler deploy` using a `wrangler.toml` in the artifact. Details: - Secrets CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID are passed via env, never argv. Config values that would expand to `${VAR}` are rejected so they can't be interpolated into secrets. - The deploy is a remote, irreversible operation, so it is explicitly skipped in dry-run mode (including worktree mode). - wrangler resolved via WRANGLER_BIN override; presence checked in the constructor. Bundled in the Docker image (pinned wrangler@4.111.0). - Refactor ghPages' single-top-level-dir flatten logic into a shared `extractZipArchiveWithFlattening` util reused by both targets. --- Dockerfile | 4 + docs/src/content/docs/targets/cloudflare.md | 62 ++++ docs/src/content/docs/targets/index.md | 1 + src/targets/__tests__/cloudflare.test.ts | 293 +++++++++++++++++++ src/targets/cloudflare.ts | 304 ++++++++++++++++++++ src/targets/ghPages.ts | 25 +- src/targets/index.ts | 2 + src/utils/system.ts | 36 +++ 8 files changed, 705 insertions(+), 22 deletions(-) create mode 100644 docs/src/content/docs/targets/cloudflare.md create mode 100644 src/targets/__tests__/cloudflare.test.ts create mode 100644 src/targets/cloudflare.ts diff --git a/Dockerfile b/Dockerfile index 2422595c3..c957ed926 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 '*' diff --git a/docs/src/content/docs/targets/cloudflare.md b/docs/src/content/docs/targets/cloudflare.md new file mode 100644 index 000000000..369d9d845 --- /dev/null +++ b/docs/src/content/docs/targets/cloudflare.md @@ -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. | + +## Environment Variables + +| Name | Description | +|------|-------------| +| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with permission to deploy Pages/Workers. | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID. | + +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 + 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. diff --git a/docs/src/content/docs/targets/index.md b/docs/src/content/docs/targets/index.md index 13758a21f..8218e9c34 100644 --- a/docs/src/content/docs/targets/index.md +++ b/docs/src/content/docs/targets/index.md @@ -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 | diff --git a/src/targets/__tests__/cloudflare.test.ts b/src/targets/__tests__/cloudflare.test.ts new file mode 100644 index 000000000..941c656b6 --- /dev/null +++ b/src/targets/__tests__/cloudflare.test.ts @@ -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(); + 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(); + return { + ...actual, + withTempDir: async (cb: (dir: string) => Promise) => 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, +): 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(); + }); +}); diff --git a/src/targets/cloudflare.ts b/src/targets/cloudflare.ts new file mode 100644 index 000000000..e21a5d4ad --- /dev/null +++ b/src/targets/cloudflare.ts @@ -0,0 +1,304 @@ +import { join } from 'path'; + +import { + GitHubGlobalConfig, + TargetConfig, + TypedTargetConfig, +} from '../schemas/project_config'; +import { checkEnvForPrerequisite } from '../utils/env'; +import { ConfigurationError, reportError } from '../utils/errors'; +import { withTempDir } from '../utils/files'; +import { isDryRun } from '../utils/helpers'; +import { logDryRun } from '../utils/dryRun'; +import { + checkExecutableIsPresent, + extractZipArchiveWithFlattening, + resolveExecutable, + spawnProcess, +} from '../utils/system'; +import { BaseTarget } from './base'; +import { BaseArtifactProvider } from '../artifact_providers/base'; + +/** + * Secrets required to authenticate with the Cloudflare API. + * + * Exported so tests (and documentation tooling) can reference the canonical + * list of environment variables this target consumes. + */ +export const targetSecrets = [ + 'CLOUDFLARE_API_TOKEN', + 'CLOUDFLARE_ACCOUNT_ID', +] as const; +type SecretsType = (typeof targetSecrets)[number]; + +/** Wrangler executable configuration */ +const WRANGLER_CONFIG = { + name: 'wrangler', + envVar: 'WRANGLER_BIN', + errorHint: + 'Install wrangler (npm install -g wrangler) or set WRANGLER_BIN to its path', +} as const; + +/** How the artifact should be deployed to Cloudflare */ +type CloudflareDeployType = 'pages' | 'worker'; + +/** Valid deploy types */ +const DEPLOY_TYPES: readonly CloudflareDeployType[] = ['pages', 'worker']; + +/** Default deploy type when none is configured */ +const DEFAULT_DEPLOY_TYPE: CloudflareDeployType = 'pages'; + +/** Default production branch passed to `wrangler pages deploy --branch` */ +const DEFAULT_PRODUCTION_BRANCH = 'main'; + +/** + * Regex for the Cloudflare deploy archive. + * + * Matches e.g. `cloudflare.zip` or `my-site-cloudflare.zip`. Can be overridden + * via the `includeNames` target option. + */ +const DEFAULT_DEPLOY_ARCHIVE_REGEX = /^(?:.+-)?cloudflare\.zip$/; + +/** Fields on the cloudflare target config accessed at runtime */ +interface CloudflareConfigFields extends Record { + deployType?: string; + projectName?: string; + productionBranch?: string; + wranglerCliPath?: string; + workingDir?: string; +} + +/** Target options for "cloudflare" */ +export interface CloudflareTargetConfig { + /** How to deploy: "pages" (default) or "worker" */ + deployType: CloudflareDeployType; + /** Cloudflare Pages project name (required for `deployType: pages`) */ + projectName?: string; + /** + * The Cloudflare Pages project's production branch name. Passed to + * `wrangler pages deploy --branch` so a release publish always targets the + * production environment. This is the Cloudflare environment selector, NOT + * craft's git release branch. + */ + productionBranch: string; + /** Resolved path/name of the wrangler binary */ + wranglerCliPath: string; + /** Subdirectory within the extracted artifact to deploy from */ + workingDir?: string; +} + +/** + * Full config for the "cloudflare" target, including secrets. + */ +export type CloudflareTargetFullConfig = CloudflareTargetConfig & + Record; + +/** + * Target responsible for deploying static assets or Workers to Cloudflare. + * + * Shells out to the `wrangler` CLI (the reference implementation of the + * Cloudflare deploy protocols). Two deploy modes are supported via the + * `deployType` option: + * - "pages" → `wrangler pages deploy --project-name ...` + * - "worker" → `wrangler deploy` (using a `wrangler.toml` in the artifact) + */ +export class CloudflareTarget extends BaseTarget { + /** Target name */ + public readonly name: string = 'cloudflare'; + /** Target options */ + public readonly cloudflareConfig: CloudflareTargetFullConfig; + /** GitHub repo configuration */ + public readonly githubRepo: GitHubGlobalConfig; + + public constructor( + config: TargetConfig, + artifactProvider: BaseArtifactProvider, + githubRepo: GitHubGlobalConfig, + ) { + super(config, artifactProvider, githubRepo); + this.githubRepo = githubRepo; + this.cloudflareConfig = this.getCloudflareConfig(); + checkExecutableIsPresent(this.cloudflareConfig.wranglerCliPath); + } + + /** + * Extracts, validates and returns the "cloudflare" target options. + * + * @returns the cloudflare config for this target. + */ + public getCloudflareConfig(): CloudflareTargetFullConfig { + const config = this.config as TypedTargetConfig; + + const deployType = (config.deployType ?? + DEFAULT_DEPLOY_TYPE) as CloudflareDeployType; + if (!DEPLOY_TYPES.includes(deployType)) { + throw new ConfigurationError( + `[cloudflare] Invalid deployType "${config.deployType}": ` + + `must be one of ${DEPLOY_TYPES.map(t => `"${t}"`).join(', ')}`, + ); + } + + if (deployType === 'pages' && !config.projectName) { + throw new ConfigurationError( + '[cloudflare] "projectName" is required when deployType is "pages"', + ); + } + + // These config values are passed to wrangler as command-line arguments. + // spawnProcess() expands args of the exact form "${VAR}" using the + // environment -- which includes CLOUDFLARE_API_TOKEN/ACCOUNT_ID. Reject + // such values so a config string can never be expanded into a secret. + for (const [key, value] of Object.entries({ + projectName: config.projectName, + productionBranch: config.productionBranch, + workingDir: config.workingDir, + })) { + if (typeof value === 'string' && /^\$\{.*\}$/.test(value)) { + throw new ConfigurationError( + `[cloudflare] "${key}" must not be an environment-variable ` + + `expansion (got "${value}")`, + ); + } + } + + const wranglerCliPath = config.wranglerCliPath + ? config.wranglerCliPath + : resolveExecutable(WRANGLER_CONFIG); + + return { + deployType, + projectName: config.projectName, + productionBranch: config.productionBranch || DEFAULT_PRODUCTION_BRANCH, + wranglerCliPath, + workingDir: config.workingDir, + ...this.getTargetSecrets(), + }; + } + + private getTargetSecrets(): Record { + return targetSecrets + .map(name => { + checkEnvForPrerequisite({ name }); + return { + name, + value: process.env[name] as string, + }; + }) + .reduce( + (prev, current) => ({ + ...prev, + [current.name]: current.value, + }), + {}, + ) as Record; + } + + /** + * Builds the wrangler argument list for the configured deploy type. + * + * @param deployDir The directory to deploy from + * @param version The version being released + * @param revision The git commit SHA being published + */ + private getWranglerArgs( + deployDir: string, + version: string, + revision: string, + ): string[] { + if (this.cloudflareConfig.deployType === 'worker') { + // Worker deploys use the local wrangler.toml; run with cwd=deployDir. + return ['deploy']; + } + + // Pages deploy. `--branch ` forces a production + // deployment; the commit-* flags attach release provenance and prevent + // wrangler from inferring (wrong) git state from the temp directory. + return [ + 'pages', + 'deploy', + deployDir, + '--project-name', + this.cloudflareConfig.projectName as string, + '--branch', + this.cloudflareConfig.productionBranch, + '--commit-hash', + revision, + '--commit-message', + `Release ${version}`, + '--commit-dirty', + 'false', + ]; + } + + /** + * Deploys the release artifact to Cloudflare via wrangler. + * + * @param version New version to be released + * @param revision Git commit SHA to be published + */ + public async publish(version: string, revision: string): Promise { + this.logger.debug('Fetching artifact list...'); + const packageFiles = await this.getArtifactsForRevision(revision, { + includeNames: DEFAULT_DEPLOY_ARCHIVE_REGEX, + }); + if (!packageFiles.length) { + reportError('Cannot deploy to Cloudflare: no artifacts found'); + return; + } else if (packageFiles.length > 1) { + reportError( + `Not implemented: more than one Cloudflare archive found\nDetails: ${JSON.stringify( + packageFiles, + )}`, + ); + return; + } + + const archivePath = await this.artifactProvider.downloadArtifact( + packageFiles[0], + ); + + await withTempDir( + async directory => { + this.logger.info(`Extracting "${archivePath}" to "${directory}"...`); + await extractZipArchiveWithFlattening(archivePath, directory); + + const deployDir = this.cloudflareConfig.workingDir + ? join(directory, this.cloudflareConfig.workingDir) + : directory; + + const args = this.getWranglerArgs(deployDir, version, revision); + + // A Cloudflare deploy is a remote, irreversible operation with no local + // isolation. Unlike git/fs operations, it must NEVER run in dry-run + // mode -- including worktree mode, where spawnProcess would otherwise + // execute the command for real. Guard explicitly here. + if (isDryRun()) { + logDryRun( + `${this.cloudflareConfig.wranglerCliPath} ${args.join(' ')}`, + ); + return; + } + + const env = { + ...process.env, + CLOUDFLARE_API_TOKEN: this.cloudflareConfig.CLOUDFLARE_API_TOKEN, + CLOUDFLARE_ACCOUNT_ID: this.cloudflareConfig.CLOUDFLARE_ACCOUNT_ID, + }; + + this.logger.info( + `Deploying to Cloudflare (${this.cloudflareConfig.deployType})...`, + ); + await spawnProcess( + this.cloudflareConfig.wranglerCliPath, + args, + { cwd: deployDir, env }, + { showStdout: true }, + ); + }, + true, + 'craft-cloudflare-', + ); + + this.logger.info('Cloudflare deploy complete'); + } +} diff --git a/src/targets/ghPages.ts b/src/targets/ghPages.ts index 2c5f791e8..cd4938adf 100644 --- a/src/targets/ghPages.ts +++ b/src/targets/ghPages.ts @@ -1,5 +1,4 @@ import * as fs from 'fs'; -import * as path from 'path'; import { Octokit } from '@octokit/rest'; @@ -16,7 +15,7 @@ import { GitHubRemote, } from '../utils/githubApi'; import { cloneRepo } from '../utils/git'; -import { extractZipArchive } from '../utils/system'; +import { extractZipArchiveWithFlattening } from '../utils/system'; import { BaseTarget } from './base'; import { BaseArtifactProvider } from '../artifact_providers/base'; @@ -119,27 +118,9 @@ export class GhPagesTarget extends BaseTarget { ); } - // Extract the archive + // Extract the archive (flattening a single top-level directory if present) this.logger.info(`Extracting "${archivePath}" to "${directory}"...`); - await extractZipArchive(archivePath, directory); - - // If there's a single top-level directory -- move its contents to the git root - const newDirContents = fs.readdirSync(directory).filter(f => f !== '.git'); - if ( - newDirContents.length === 1 && - fs.statSync(path.join(directory, newDirContents[0])).isDirectory() - ) { - this.logger.debug( - 'Single top-level directory found, moving files from it...', - ); - const innerDirPath = path.join(directory, newDirContents[0]); - fs.readdirSync(innerDirPath).forEach(item => { - const srcPath = path.join(innerDirPath, item); - const destPath = path.join(directory, item); - fs.renameSync(srcPath, destPath); - }); - fs.rmdirSync(innerDirPath); - } + await extractZipArchiveWithFlattening(archivePath, directory); } /** diff --git a/src/targets/index.ts b/src/targets/index.ts index 802c22525..ff51af271 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -1,5 +1,6 @@ import { BaseTarget } from './base'; import { BrewTarget } from './brew'; +import { CloudflareTarget } from './cloudflare'; import { CocoapodsTarget } from './cocoapods'; import { CratesTarget } from './crates'; import { DockerTarget } from './docker'; @@ -23,6 +24,7 @@ import { PowerShellTarget } from './powershell'; export const TARGET_MAP: { [key: string]: typeof BaseTarget } = { brew: BrewTarget, + cloudflare: CloudflareTarget, cocoapods: CocoapodsTarget, crates: CratesTarget, docker: DockerTarget, diff --git a/src/utils/system.ts b/src/utils/system.ts index ca6a172f6..38d9dd19c 100644 --- a/src/utils/system.ts +++ b/src/utils/system.ts @@ -545,6 +545,42 @@ export async function extractZipArchive( } } +/** + * Extracts a ZIP archive into the specified directory and, if the archive + * contains a single top-level directory, moves its contents up into `dir`. + * + * This "flatten" behavior is useful for static-site archives that wrap all + * their files in a single parent folder (e.g. `my-site/index.html`), so that + * the caller ends up with the files directly under `dir`. + * + * @param filePath Path to the ZIP archive + * @param dir Path to the directory to extract into + * @returns A promise that resolves when extraction (and flattening) completes + * @async + */ +export async function extractZipArchiveWithFlattening( + filePath: string, + dir: string, +): Promise { + await extractZipArchive(filePath, dir); + + // If there's a single top-level directory, move its contents to `dir` + const dirContents = fs.readdirSync(dir).filter(f => f !== '.git'); + if ( + dirContents.length === 1 && + fs.statSync(path.join(dir, dirContents[0])).isDirectory() + ) { + logger.debug('Single top-level directory found, moving files from it...'); + const innerDirPath = path.join(dir, dirContents[0]); + fs.readdirSync(innerDirPath).forEach(item => { + const srcPath = path.join(innerDirPath, item); + const destPath = path.join(dir, item); + fs.renameSync(srcPath, destPath); + }); + fs.rmdirSync(innerDirPath); + } +} + /** * Sets SIGINT handler to avoid accidental process termination via Ctrl-C *