From 62784ecc9e9ed24c7e276db71c4ae0c42b6f638b Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 9 Apr 2026 22:49:46 -0700 Subject: [PATCH 1/3] Write colocated gitignore for custom manifests --- .changeset/ignore-generated-manifest.md | 5 ++ packages/builders/src/base-builder.ts | 18 ++++- packages/builders/src/gitignore.test.ts | 101 ++++++++++++++++++++++++ packages/builders/src/gitignore.ts | 61 ++++++++++++++ workbench/example/.gitignore | 1 - workbench/example/package.json | 2 +- workbench/example/turbo.json | 2 +- 7 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 .changeset/ignore-generated-manifest.md create mode 100644 packages/builders/src/gitignore.test.ts create mode 100644 packages/builders/src/gitignore.ts delete mode 100644 workbench/example/.gitignore diff --git a/.changeset/ignore-generated-manifest.md b/.changeset/ignore-generated-manifest.md new file mode 100644 index 0000000000..931956c45e --- /dev/null +++ b/.changeset/ignore-generated-manifest.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": patch +--- + +Write colocated `.gitignore` files for generated custom workflow manifests diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 25762fa47a..cc80c3ae00 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -14,6 +14,7 @@ import { } from './apply-swc-transform.js'; import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js'; import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js'; +import { ensureGeneratedFileGitignore } from './gitignore.js'; import { getImportPath } from './module-specifier.js'; import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js'; @@ -826,10 +827,7 @@ export abstract class BaseBuilder { ); if (this.config.workflowManifestPath) { - const resolvedPath = resolve( - process.cwd(), - this.config.workflowManifestPath - ); + const resolvedPath = this.resolvePath(this.config.workflowManifestPath); let prefix = ''; if (resolvedPath.endsWith('.cjs')) { @@ -846,6 +844,18 @@ export abstract class BaseBuilder { resolvedPath, prefix + JSON.stringify(workflowManifest, null, 2) ); + + try { + await ensureGeneratedFileGitignore({ + workingDir: this.config.workingDir, + filePath: resolvedPath, + }); + } catch (error) { + console.warn( + `Failed to write colocated .gitignore for generated manifest ${resolvedPath}:`, + error + ); + } } // Create .gitignore in .swc directory diff --git a/packages/builders/src/gitignore.test.ts b/packages/builders/src/gitignore.test.ts new file mode 100644 index 0000000000..9b27993eb7 --- /dev/null +++ b/packages/builders/src/gitignore.test.ts @@ -0,0 +1,101 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ensureGeneratedFileGitignore } from './gitignore.js'; + +const realTmpdir = realpathSync(tmpdir()); + +describe('ensureGeneratedFileGitignore', () => { + let testRoot: string; + + beforeEach(() => { + testRoot = mkdtempSync(join(realTmpdir, 'gitignore-manifest-')); + }); + + afterEach(() => { + rmSync(testRoot, { recursive: true, force: true }); + }); + + it('creates a colocated .gitignore entry for a generated manifest file', async () => { + const changed = await ensureGeneratedFileGitignore({ + workingDir: testRoot, + filePath: join(testRoot, '.well-known', 'workflow', 'manifest.js'), + }); + + expect(changed).toBe(true); + expect( + readFileSync( + join(testRoot, '.well-known', 'workflow', '.gitignore'), + 'utf8' + ) + ).toBe( + 'manifest.js\n' + ); + }); + + it('appends the manifest entry without clobbering existing content', async () => { + mkdirSync(join(testRoot, 'generated'), { recursive: true }); + writeFileSync( + join(testRoot, 'generated', '.gitignore'), + 'other-generated-file.js\n', + 'utf8' + ); + + const changed = await ensureGeneratedFileGitignore({ + workingDir: testRoot, + filePath: join(testRoot, 'generated', 'manifest.json'), + }); + + expect(changed).toBe(true); + expect(readFileSync(join(testRoot, 'generated', '.gitignore'), 'utf8')).toBe( + 'other-generated-file.js\nmanifest.json\n' + ); + }); + + it('does not duplicate an existing ignore entry', async () => { + mkdirSync(join(testRoot, 'generated'), { recursive: true }); + writeFileSync( + join(testRoot, 'generated', '.gitignore'), + 'manifest.js\n', + 'utf8' + ); + + const changed = await ensureGeneratedFileGitignore({ + workingDir: testRoot, + filePath: join(testRoot, 'generated', 'manifest.js'), + }); + + expect(changed).toBe(false); + expect(readFileSync(join(testRoot, 'generated', '.gitignore'), 'utf8')).toBe( + 'manifest.js\n' + ); + }); + + it('does not create a project root .gitignore for root-level outputs', async () => { + const changed = await ensureGeneratedFileGitignore({ + workingDir: testRoot, + filePath: join(testRoot, 'manifest.js'), + }); + + expect(changed).toBe(false); + expect(() => readFileSync(join(testRoot, '.gitignore'), 'utf8')).toThrow(); + }); + + it('does not modify .gitignore for files outside the working directory', async () => { + const changed = await ensureGeneratedFileGitignore({ + workingDir: testRoot, + filePath: join(testRoot, '..', 'manifest.js'), + }); + + expect(changed).toBe(false); + expect(() => readFileSync(join(testRoot, '.gitignore'), 'utf8')).toThrow(); + }); +}); diff --git a/packages/builders/src/gitignore.ts b/packages/builders/src/gitignore.ts new file mode 100644 index 0000000000..b48cb07570 --- /dev/null +++ b/packages/builders/src/gitignore.ts @@ -0,0 +1,61 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; + +function normalizeRelativePath(path: string): string { + return path.split(sep).join('/'); +} + +function isOutsideRoot(path: string): boolean { + return path === '..' || path.startsWith('../'); +} + +/** + * Ensures the generated file's output directory contains a colocated + * `.gitignore` entry for that file. + * + * Returns true when a new ignore entry was written. + */ +export async function ensureGeneratedFileGitignore({ + workingDir, + filePath, +}: { + workingDir: string; + filePath: string; +}): Promise { + const resolvedWorkingDir = resolve(workingDir); + const resolvedFilePath = resolve(resolvedWorkingDir, filePath); + const relativePath = normalizeRelativePath( + relative(resolvedWorkingDir, resolvedFilePath) + ); + + if (!relativePath || isOutsideRoot(relativePath)) { + return false; + } + + const outputDir = dirname(resolvedFilePath); + if (outputDir === resolvedWorkingDir) { + return false; + } + + const gitignorePath = join(outputDir, '.gitignore'); + const entry = basename(resolvedFilePath); + + let existing = ''; + try { + existing = await readFile(gitignorePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const lines = existing.split(/\r?\n/).map((line) => line.trim()); + if (lines.includes('*') || lines.includes(entry) || lines.includes(`/${entry}`)) { + return false; + } + + await mkdir(outputDir, { recursive: true }); + const separator = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'; + await writeFile(gitignorePath, `${existing}${separator}${entry}\n`); + return true; +} diff --git a/workbench/example/.gitignore b/workbench/example/.gitignore deleted file mode 100644 index 821a5ee075..0000000000 --- a/workbench/example/.gitignore +++ /dev/null @@ -1 +0,0 @@ -manifest.js \ No newline at end of file diff --git a/workbench/example/package.json b/workbench/example/package.json index 47dfbed3c7..604eb6e76e 100644 --- a/workbench/example/package.json +++ b/workbench/example/package.json @@ -7,7 +7,7 @@ "scripts": { "workflow": "workflow", "wf": "wf", - "build": "workflow build --target vercel-build-output-api --workflow-manifest manifest.js" + "build": "workflow build --target vercel-build-output-api --workflow-manifest .well-known/workflow/manifest.js" }, "devDependencies": { "@workflow/world-postgres": "workspace:*", diff --git a/workbench/example/turbo.json b/workbench/example/turbo.json index 9821853e22..47eb4de9c3 100644 --- a/workbench/example/turbo.json +++ b/workbench/example/turbo.json @@ -3,7 +3,7 @@ "extends": ["//"], "tasks": { "build": { - "outputs": [".well-known/workflow/**", ".vercel/output/**", "manifest.js"] + "outputs": [".well-known/workflow/**", ".vercel/output/**"] } } } From 2ac6942fa00496d6459e91867ad9d453113391f4 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 9 Apr 2026 22:56:47 -0700 Subject: [PATCH 2/3] Gitignore public workflow manifests --- .changeset/ignore-generated-manifest.md | 4 +- packages/builders/src/base-builder.ts | 13 --- packages/builders/src/gitignore.test.ts | 101 ------------------ packages/builders/src/gitignore.ts | 61 ----------- .../builders/src/vercel-build-output-api.ts | 1 + packages/next/src/builder-deferred.ts | 4 + packages/next/src/builder-eager.ts | 1 + packages/sveltekit/src/builder.ts | 1 + workbench/example/.gitignore | 1 + workbench/example/package.json | 2 +- workbench/example/turbo.json | 2 +- 11 files changed, 13 insertions(+), 178 deletions(-) delete mode 100644 packages/builders/src/gitignore.test.ts delete mode 100644 packages/builders/src/gitignore.ts create mode 100644 workbench/example/.gitignore diff --git a/.changeset/ignore-generated-manifest.md b/.changeset/ignore-generated-manifest.md index 931956c45e..d00263a47d 100644 --- a/.changeset/ignore-generated-manifest.md +++ b/.changeset/ignore-generated-manifest.md @@ -1,5 +1,7 @@ --- "@workflow/builders": patch +"@workflow/next": patch +"@workflow/sveltekit": patch --- -Write colocated `.gitignore` files for generated custom workflow manifests +Write colocated `.gitignore` files for public workflow manifests generated by `WORKFLOW_PUBLIC_MANIFEST=1` diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index cc80c3ae00..9c0cce388a 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -14,7 +14,6 @@ import { } from './apply-swc-transform.js'; import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js'; import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js'; -import { ensureGeneratedFileGitignore } from './gitignore.js'; import { getImportPath } from './module-specifier.js'; import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js'; @@ -844,18 +843,6 @@ export abstract class BaseBuilder { resolvedPath, prefix + JSON.stringify(workflowManifest, null, 2) ); - - try { - await ensureGeneratedFileGitignore({ - workingDir: this.config.workingDir, - filePath: resolvedPath, - }); - } catch (error) { - console.warn( - `Failed to write colocated .gitignore for generated manifest ${resolvedPath}:`, - error - ); - } } // Create .gitignore in .swc directory diff --git a/packages/builders/src/gitignore.test.ts b/packages/builders/src/gitignore.test.ts deleted file mode 100644 index 9b27993eb7..0000000000 --- a/packages/builders/src/gitignore.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ensureGeneratedFileGitignore } from './gitignore.js'; - -const realTmpdir = realpathSync(tmpdir()); - -describe('ensureGeneratedFileGitignore', () => { - let testRoot: string; - - beforeEach(() => { - testRoot = mkdtempSync(join(realTmpdir, 'gitignore-manifest-')); - }); - - afterEach(() => { - rmSync(testRoot, { recursive: true, force: true }); - }); - - it('creates a colocated .gitignore entry for a generated manifest file', async () => { - const changed = await ensureGeneratedFileGitignore({ - workingDir: testRoot, - filePath: join(testRoot, '.well-known', 'workflow', 'manifest.js'), - }); - - expect(changed).toBe(true); - expect( - readFileSync( - join(testRoot, '.well-known', 'workflow', '.gitignore'), - 'utf8' - ) - ).toBe( - 'manifest.js\n' - ); - }); - - it('appends the manifest entry without clobbering existing content', async () => { - mkdirSync(join(testRoot, 'generated'), { recursive: true }); - writeFileSync( - join(testRoot, 'generated', '.gitignore'), - 'other-generated-file.js\n', - 'utf8' - ); - - const changed = await ensureGeneratedFileGitignore({ - workingDir: testRoot, - filePath: join(testRoot, 'generated', 'manifest.json'), - }); - - expect(changed).toBe(true); - expect(readFileSync(join(testRoot, 'generated', '.gitignore'), 'utf8')).toBe( - 'other-generated-file.js\nmanifest.json\n' - ); - }); - - it('does not duplicate an existing ignore entry', async () => { - mkdirSync(join(testRoot, 'generated'), { recursive: true }); - writeFileSync( - join(testRoot, 'generated', '.gitignore'), - 'manifest.js\n', - 'utf8' - ); - - const changed = await ensureGeneratedFileGitignore({ - workingDir: testRoot, - filePath: join(testRoot, 'generated', 'manifest.js'), - }); - - expect(changed).toBe(false); - expect(readFileSync(join(testRoot, 'generated', '.gitignore'), 'utf8')).toBe( - 'manifest.js\n' - ); - }); - - it('does not create a project root .gitignore for root-level outputs', async () => { - const changed = await ensureGeneratedFileGitignore({ - workingDir: testRoot, - filePath: join(testRoot, 'manifest.js'), - }); - - expect(changed).toBe(false); - expect(() => readFileSync(join(testRoot, '.gitignore'), 'utf8')).toThrow(); - }); - - it('does not modify .gitignore for files outside the working directory', async () => { - const changed = await ensureGeneratedFileGitignore({ - workingDir: testRoot, - filePath: join(testRoot, '..', 'manifest.js'), - }); - - expect(changed).toBe(false); - expect(() => readFileSync(join(testRoot, '.gitignore'), 'utf8')).toThrow(); - }); -}); diff --git a/packages/builders/src/gitignore.ts b/packages/builders/src/gitignore.ts deleted file mode 100644 index b48cb07570..0000000000 --- a/packages/builders/src/gitignore.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { basename, dirname, join, relative, resolve, sep } from 'node:path'; - -function normalizeRelativePath(path: string): string { - return path.split(sep).join('/'); -} - -function isOutsideRoot(path: string): boolean { - return path === '..' || path.startsWith('../'); -} - -/** - * Ensures the generated file's output directory contains a colocated - * `.gitignore` entry for that file. - * - * Returns true when a new ignore entry was written. - */ -export async function ensureGeneratedFileGitignore({ - workingDir, - filePath, -}: { - workingDir: string; - filePath: string; -}): Promise { - const resolvedWorkingDir = resolve(workingDir); - const resolvedFilePath = resolve(resolvedWorkingDir, filePath); - const relativePath = normalizeRelativePath( - relative(resolvedWorkingDir, resolvedFilePath) - ); - - if (!relativePath || isOutsideRoot(relativePath)) { - return false; - } - - const outputDir = dirname(resolvedFilePath); - if (outputDir === resolvedWorkingDir) { - return false; - } - - const gitignorePath = join(outputDir, '.gitignore'); - const entry = basename(resolvedFilePath); - - let existing = ''; - try { - existing = await readFile(gitignorePath, 'utf8'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - } - - const lines = existing.split(/\r?\n/).map((line) => line.trim()); - if (lines.includes('*') || lines.includes(entry) || lines.includes(`/${entry}`)) { - return false; - } - - await mkdir(outputDir, { recursive: true }); - const separator = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'; - await writeFile(gitignorePath, `${existing}${separator}${entry}\n`); - return true; -} diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index 81ab83342f..f5638881fc 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -50,6 +50,7 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { 'static/.well-known/workflow/v1' ); await mkdir(staticManifestDir, { recursive: true }); + await writeFile(join(staticManifestDir, '.gitignore'), '*'); await copyFile( join(workflowGeneratedDir, 'manifest.json'), join(staticManifestDir, 'manifest.json') diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index cb918cf0c2..ee4ee5bed1 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -640,6 +640,10 @@ export async function getNextBuilderDeferred() { 'public/.well-known/workflow/v1' ); await mkdir(publicManifestDir, { recursive: true }); + await this.writeFileIfChanged( + join(publicManifestDir, '.gitignore'), + '*' + ); await this.copyFileIfChanged( manifestFilePath, join(publicManifestDir, 'manifest.json') diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index c6d6fac0e2..944ac84739 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -76,6 +76,7 @@ export async function getNextBuilderEager() { 'public/.well-known/workflow/v1' ); await mkdir(publicManifestDir, { recursive: true }); + await writeFile(join(publicManifestDir, '.gitignore'), '*'); await copyFile( join(workflowGeneratedDir, 'manifest.json'), join(publicManifestDir, 'manifest.json') diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 3a0a988af6..0a10277c74 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -88,6 +88,7 @@ export class SvelteKitBuilder extends BaseBuilder { 'static/.well-known/workflow/v1' ); await mkdir(staticManifestDir, { recursive: true }); + await writeFile(join(staticManifestDir, '.gitignore'), '*'); await copyFile( join(workflowGeneratedDir, 'manifest.json'), join(staticManifestDir, 'manifest.json') diff --git a/workbench/example/.gitignore b/workbench/example/.gitignore new file mode 100644 index 0000000000..2cc3c7bc8d --- /dev/null +++ b/workbench/example/.gitignore @@ -0,0 +1 @@ +manifest.js diff --git a/workbench/example/package.json b/workbench/example/package.json index 604eb6e76e..47dfbed3c7 100644 --- a/workbench/example/package.json +++ b/workbench/example/package.json @@ -7,7 +7,7 @@ "scripts": { "workflow": "workflow", "wf": "wf", - "build": "workflow build --target vercel-build-output-api --workflow-manifest .well-known/workflow/manifest.js" + "build": "workflow build --target vercel-build-output-api --workflow-manifest manifest.js" }, "devDependencies": { "@workflow/world-postgres": "workspace:*", diff --git a/workbench/example/turbo.json b/workbench/example/turbo.json index 47eb4de9c3..9821853e22 100644 --- a/workbench/example/turbo.json +++ b/workbench/example/turbo.json @@ -3,7 +3,7 @@ "extends": ["//"], "tasks": { "build": { - "outputs": [".well-known/workflow/**", ".vercel/output/**"] + "outputs": [".well-known/workflow/**", ".vercel/output/**", "manifest.js"] } } } From e5763f1db046ed6e7d87d4471c8c907b02d843d6 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Thu, 9 Apr 2026 22:57:18 -0700 Subject: [PATCH 3/3] Scope manifest gitignore fix to public outputs --- packages/builders/src/base-builder.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 9c0cce388a..25762fa47a 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -826,7 +826,10 @@ export abstract class BaseBuilder { ); if (this.config.workflowManifestPath) { - const resolvedPath = this.resolvePath(this.config.workflowManifestPath); + const resolvedPath = resolve( + process.cwd(), + this.config.workflowManifestPath + ); let prefix = ''; if (resolvedPath.endsWith('.cjs')) {