From a84fdf2b97419679b36cd7a70cafb14db1370af8 Mon Sep 17 00:00:00 2001 From: ponharu <128346975+ponharu@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:07:43 +0900 Subject: [PATCH 1/2] fix(wb): build Durable Object-managed images from committed sources only Co-authored-by: WillBooster (Claude Code) --- packages/wb/src/utils/containerImages.ts | 131 ++++++++++-------- packages/wb/test/unit/containerImages.test.ts | 20 ++- 2 files changed, 92 insertions(+), 59 deletions(-) diff --git a/packages/wb/src/utils/containerImages.ts b/packages/wb/src/utils/containerImages.ts index 32088a047..e52017215 100644 --- a/packages/wb/src/utils/containerImages.ts +++ b/packages/wb/src/utils/containerImages.ts @@ -33,6 +33,7 @@ const configSchema = z .passthrough(); type Container = z.infer; +type NamedImage = z.infer; type LocalContainer = Container & { image: string }; interface ContainerImageDeployment { @@ -66,28 +67,23 @@ export async function withReusableContainerImages({ const result = await execute(command, args, { cwd, env, signal: cancellation.signal, maxBuffer: 64 * 1024 * 1024 }); return result.stdout.trim(); }; - // Wrangler builds these itself from the working tree, so only committed sources may be there. - for (const container of containers) { - for (const image of Object.values(container.images ?? {})) { - if (image.dockerfile) - await assertCommittedNamedImageSource(configDir, image.dockerfile, image.build_context, run); - } - } - if (localContainers.length === 0) return deploy(configPath); - const registry = z - .array(z.object({ name: z.string(), tags: z.array(z.string()) })) - .parse(JSON.parse(await run('wrangler', ['containers', 'images', 'list', '--json', ...wranglerArgs]))); + const namedImages = containers + .flatMap((container) => Object.values(container.images ?? {})) + .filter((image): image is NamedImage & { dockerfile: string } => image.dockerfile !== undefined); + if (localContainers.length === 0 && namedImages.length === 0) return deploy(configPath); const { temporaryDir, temporaryConfig, dispose } = createTemporaryImageFiles(absoluteConfigPath, cancellation); try { - for (const container of localContainers) { - await reuseContainerImage(container, { - configDir, - workerName, - temporaryDir, - registry, - wranglerArgs, - run, - }); + // Wrangler builds these itself, so it must read an extracted copy of the committed sources, not the working tree. + for (const [index, image] of namedImages.entries()) { + await stageNamedImage(image, path.join(temporaryDir, `named-${index}`), configDir, run); + } + if (localContainers.length > 0) { + const registry = z + .array(z.object({ name: z.string(), tags: z.array(z.string()) })) + .parse(JSON.parse(await run('wrangler', ['containers', 'images', 'list', '--json', ...wranglerArgs]))); + for (const container of localContainers) { + await reuseContainerImage(container, { configDir, workerName, temporaryDir, registry, wranglerArgs, run }); + } } fs.writeFileSync(temporaryConfig, JSON.stringify(config)); await deploy(temporaryConfig); @@ -142,26 +138,28 @@ interface ImageContext { run: (command: string, args: string[], cwd?: string) => Promise; } -interface CommittedImageSource { - key: string; - treeish: string; +interface CommittedTree { repository: string; + treeish: string; dockerfilePath: string; +} + +interface CommittedImageSource extends CommittedTree { + key: string; buildVars: (readonly [string, string | undefined])[]; } -async function assertCommittedNamedImageSource( +async function stageNamedImage( + image: NamedImage & { dockerfile: string }, + directory: string, configDir: string, - dockerfile: string, - buildContext: string | undefined, run: ImageContext['run'] ): Promise { - const contextDir = path.resolve(configDir, buildContext ?? path.dirname(dockerfile)); - const repository = await run('git', ['rev-parse', '--show-toplevel'], contextDir); - const contextPath = path.relative(repository, contextDir).split(path.sep).join('/') || '.'; - if (await run('git', ['status', '--porcelain', '--untracked-files=all', '--', contextPath], repository)) { - throw new Error(`Commit container build context changes before deploying: ${contextDir}`); - } + const dockerfile = resolveDockerfile(path.resolve(configDir, image.dockerfile)); + const buildContext = path.resolve(configDir, image.build_context ?? path.dirname(dockerfile)); + const tree = await resolveCommittedTree(dockerfile, buildContext, run); + image.dockerfile = await extractCommittedTree(tree, directory, run); + image.build_context = directory; } async function reuseContainerImage(container: LocalContainer, context: ImageContext): Promise { @@ -188,41 +186,66 @@ async function resolveCommittedImageSource( context: ImageContext ): Promise { const { configDir, workerName, run } = context; - let dockerfile = path.resolve(configDir, container.image); - if (fs.statSync(dockerfile).isDirectory()) dockerfile = path.join(dockerfile, 'Dockerfile'); + const dockerfile = resolveDockerfile(path.resolve(configDir, container.image)); const buildContext = path.resolve(configDir, container.image_build_context ?? path.dirname(dockerfile)); + const tree = await resolveCommittedTree(dockerfile, buildContext, run); + const treeHash = await run('git', ['rev-parse', tree.treeish], tree.repository); + const vars = container.image_vars ?? {}; + const buildVars = Object.keys(vars) + .toSorted() + .map((name) => [name, vars[name]] as const); + const key = createHash('sha256') + .update( + JSON.stringify({ + tree: treeHash, + workerName, + dockerfilePath: tree.dockerfilePath, + buildVars, + platform: 'linux/amd64', + }) + ) + .digest('hex'); + return { ...tree, key, buildVars }; +} + +function resolveDockerfile(dockerfile: string): string { + return fs.statSync(dockerfile).isDirectory() ? path.join(dockerfile, 'Dockerfile') : dockerfile; +} + +async function resolveCommittedTree( + dockerfile: string, + buildContext: string, + run: ImageContext['run'] +): Promise { const repository = await run('git', ['rev-parse', '--show-toplevel'], buildContext); const contextPath = path.relative(repository, buildContext).split(path.sep).join('/'); const dockerfilePath = path.relative(buildContext, dockerfile); if (dockerfilePath.startsWith('..') || path.isAbsolute(dockerfilePath)) { - throw new Error('Container image reuse requires the Dockerfile inside its committed build context.'); + throw new Error('Container images require the Dockerfile inside their committed build context.'); } const revision = await run('git', ['rev-parse', 'HEAD'], repository); - const treeish = `${revision}:${contextPath}`; - const tree = await run('git', ['rev-parse', treeish], repository); if (await run('git', ['diff', revision, '--', contextPath || '.'], repository)) { throw new Error(`Commit container build context changes before deploying: ${buildContext}`); } - const vars = container.image_vars ?? {}; - const buildVars = Object.keys(vars) - .toSorted() - .map((name) => [name, vars[name]] as const); - const key = createHash('sha256') - .update(JSON.stringify({ tree, workerName, dockerfilePath, buildVars, platform: 'linux/amd64' })) - .digest('hex'); - return { key, treeish, repository, dockerfilePath, buildVars }; + return { repository, treeish: `${revision}:${contextPath}`, dockerfilePath }; } -async function buildAndPushImage(image: string, source: CommittedImageSource, context: ImageContext): Promise { - const { key, treeish, repository, dockerfilePath, buildVars } = source; - const { temporaryDir, run } = context; - const archive = path.join(temporaryDir, `${key}.tar`); - const directory = path.join(temporaryDir, key); +/** Extracts the committed build context into `directory` and returns the path of its Dockerfile there. */ +async function extractCommittedTree(tree: CommittedTree, directory: string, run: ImageContext['run']): Promise { + const archive = `${directory}.tar`; fs.mkdirSync(directory, { recursive: true }); - await run('git', ['archive', '--format=tar', `--output=${archive}`, treeish], repository); + await run('git', ['archive', '--format=tar', `--output=${archive}`, tree.treeish], tree.repository); await run('tar', ['-xf', archive, '-C', directory]); - const stagedDockerfile = path.join(directory, dockerfilePath); - if (!fs.existsSync(stagedDockerfile)) throw new Error(`Container Dockerfile must be committed: ${dockerfilePath}`); + const stagedDockerfile = path.join(directory, tree.dockerfilePath); + if (!fs.existsSync(stagedDockerfile)) + throw new Error(`Container Dockerfile must be committed: ${tree.dockerfilePath}`); + return stagedDockerfile; +} + +async function buildAndPushImage(image: string, source: CommittedImageSource, context: ImageContext): Promise { + const { temporaryDir, run } = context; + const directory = path.join(temporaryDir, source.key); + const stagedDockerfile = await extractCommittedTree(source, directory, run); console.info(`Building container image ${image} from committed context.`); await run('docker', [ 'build', @@ -234,7 +257,7 @@ async function buildAndPushImage(image: string, source: CommittedImageSource, co stagedDockerfile, '-t', image, - ...buildVars.flatMap(([name, value]) => ['--build-arg', `${name}=${value}`]), + ...source.buildVars.flatMap(([name, value]) => ['--build-arg', `${name}=${value}`]), directory, ]); await run('wrangler', ['containers', 'push', image, ...context.wranglerArgs]); diff --git a/packages/wb/test/unit/containerImages.test.ts b/packages/wb/test/unit/containerImages.test.ts index 42e770833..c3cc52b8a 100644 --- a/packages/wb/test/unit/containerImages.test.ts +++ b/packages/wb/test/unit/containerImages.test.ts @@ -73,7 +73,7 @@ if (path.basename(process.argv[1]) === 'docker') { const image = imageHost.includes('.') || imageHost.includes(':') ? rawImage : registryHost() + '/' + account() + '/' + rawImage; const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); if (rollout !== 'none' && !fs.existsSync(path.resolve(path.dirname(configPath), selected.containers[0].image)) && !registry.some((entry) => entry.account === account() && entry.host === registryHost() && entry.tags.some((tag) => image === entry.host + '/' + entry.account + '/' + entry.name + ':' + tag))) throw new Error('Image is unavailable in deployment account'); - deploys.push({ image, rollout, configPath, main: path.resolve(path.dirname(configPath), config.main), named: selected.containers.filter((container) => container.images) }); + deploys.push({ image, rollout, configPath, main: path.resolve(path.dirname(configPath), config.main), named: selected.containers.filter((container) => container.images).map((container) => ({ ...container, contexts: Object.values(container.images).map((image) => { const context = path.resolve(path.dirname(configPath), image.build_context ?? path.dirname(image.dockerfile)); return { files: fs.readdirSync(context).toSorted(), dockerfile: fs.readFileSync(path.resolve(path.dirname(configPath), image.dockerfile), 'utf8') }; }) })) }); fs.writeFileSync(path.join(root, 'deployed.json'), JSON.stringify(deploys)); if (process.env.FAIL_DEPLOY) process.exit(9); if (process.env.HOLD_DEPLOY) { @@ -170,19 +170,29 @@ it.each([false, true])('rejects tracked context edits (staged: %s)', async (stag expect(readDeployments()).toEqual([]); }); -it('passes Durable Object-managed containers with committed sources through to Wrangler', async () => { +it('lets Wrangler build Durable Object-managed images from committed sources only', async () => { const project = createProject('durable-object'); const runner = addNamedImageContainer(project); + fs.writeFileSync(path.join(project, '.gitignore'), 'runner/.env\n'); + fs.writeFileSync(path.join(project, 'runner', '.env'), 'SECRET=local'); + fs.writeFileSync(path.join(project, 'runner', 'untracked.txt'), 'local generated output'); await deploy(project, {}, true, ['--containers-rollout=gradual']); const [deployment] = readDeployments(); expect(deployment?.image).toContain('registry.cloudflare.com/account/'); - expect(deployment?.named).toEqual([runner]); + expect(deployment?.named).toMatchObject([ + { + class_name: runner.class_name, + scheduling_policy: runner.scheduling_policy, + contexts: [{ files: ['Dockerfile'], dockerfile: 'FROM scratch\n' }], + }, + ]); + expect(fs.readFileSync(path.join(project, 'wrangler.json'), 'utf8')).toContain('./runner/Dockerfile'); }); -it('rejects untracked sources of a Durable Object-managed image', async () => { +it('rejects tracked edits to the sources of a Durable Object-managed image', async () => { const project = createProject('durable-object-dirty'); addNamedImageContainer(project); - fs.writeFileSync(path.join(project, 'runner', 'untracked.txt'), 'local generated output'); + fs.writeFileSync(path.join(project, 'runner', 'Dockerfile'), 'FROM busybox\n'); const result = await deploy(project, {}, false, ['--containers-rollout=gradual']); expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain('Commit container build context changes'); From add57380ff65a32a1a060c8e9848c31b45940cf3 Mon Sep 17 00:00:00 2001 From: ponharu <128346975+ponharu@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:12:40 +0900 Subject: [PATCH 2/2] refactor(wb): drop path separator conversion for the committed build context Co-authored-by: WillBooster (Claude Code) --- packages/wb/src/utils/containerImages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wb/src/utils/containerImages.ts b/packages/wb/src/utils/containerImages.ts index e52017215..093b041bf 100644 --- a/packages/wb/src/utils/containerImages.ts +++ b/packages/wb/src/utils/containerImages.ts @@ -218,7 +218,7 @@ async function resolveCommittedTree( run: ImageContext['run'] ): Promise { const repository = await run('git', ['rev-parse', '--show-toplevel'], buildContext); - const contextPath = path.relative(repository, buildContext).split(path.sep).join('/'); + const contextPath = path.relative(repository, buildContext); const dockerfilePath = path.relative(buildContext, dockerfile); if (dockerfilePath.startsWith('..') || path.isAbsolute(dockerfilePath)) { throw new Error('Container images require the Dockerfile inside their committed build context.');