diff --git a/.changeset/five-falcons-destroy.md b/.changeset/five-falcons-destroy.md new file mode 100644 index 0000000000..481c2a38ea --- /dev/null +++ b/.changeset/five-falcons-destroy.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add `copyWithoutTemplating` to the fetch template action input. `copyWithoutTemplating` also accepts an array of glob patterns. Contents of matched files or directories are copied without being processed, but paths are subject to rendering. + +Deprecate `copyWithoutRender` in favor of `copyWithoutTemplating`. diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 4e09c66755..bcdcfd7546 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -106,6 +106,7 @@ export function createFetchTemplateAction(options: { values: any; templateFileExtension?: string | boolean | undefined; copyWithoutRender?: string[] | undefined; + copyWithoutTemplating?: string[] | undefined; cookiecutterCompat?: boolean | undefined; }>; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 3e4ad61260..af353bef70 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -127,7 +127,22 @@ describe('fetch:template', () => { action.handler( mockContext({ copyWithoutRender: 'abc' as unknown as string[] }), ), - ).rejects.toThrowError(/copyWithoutRender must be an array/i); + ).rejects.toThrowError( + /copyWithoutRender\/copyWithoutTemplating must be an array/i, + ); + }); + + it('throws if both copyWithoutRender and copyWithoutTemplating are used', async () => { + await expect(() => + action.handler( + mockContext({ + copyWithoutRender: 'abc' as unknown as string[], + copyWithoutTemplating: 'def' as unknown as string[], + }), + ), + ).rejects.toThrowError( + /copyWithoutRender and copyWithoutTemplating can not be used at the same time/i, + ); }); it('throws if copyWithoutRender is used with extension', async () => { @@ -139,7 +154,7 @@ describe('fetch:template', () => { }), ), ).rejects.toThrowError( - /input extension incompatible with copyWithoutRender and cookiecutterCompat/, + /input extension incompatible with copyWithoutRender\/copyWithoutTemplating and cookiecutterCompat/, ); }); @@ -152,7 +167,7 @@ describe('fetch:template', () => { }), ), ).rejects.toThrowError( - /input extension incompatible with copyWithoutRender and cookiecutterCompat/, + /input extension incompatible with copyWithoutRender\/copyWithoutTemplating and cookiecutterCompat/, ); }); @@ -357,57 +372,105 @@ describe('fetch:template', () => { ).resolves.toBe(joinPath(workspacePath, 'target', 'a-binary-file.png')); }); }); + }); - describe('copyWithoutRender', () => { - let context: ActionContext; + describe('copyWithoutRender', () => { + let context: ActionContext; - beforeEach(async () => { - context = mockContext({ - values: { - name: 'test-project', - count: 1234, - }, - copyWithoutRender: ['.unprocessed'], - }); + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + }, + copyWithoutRender: ['.unprocessed'], + }); - mockFetchContents.mockImplementation(({ outputPath }) => { - mockFs({ - ...realFiles, - [outputPath]: { - processed: { - 'templated-content-${{ values.name }}.txt': - '${{ values.count }}', - }, - '.unprocessed': { - 'templated-content-${{ values.name }}.txt': - '${{ values.count }}', - }, + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + ...realFiles, + [outputPath]: { + processed: { + 'templated-content-${{ values.name }}.txt': '${{ values.count }}', }, - }); - - return Promise.resolve(); + '.unprocessed': { + 'templated-content-${{ values.name }}.txt': '${{ values.count }}', + }, + }, }); - await action.handler(context); + return Promise.resolve(); }); - it('ignores template syntax in files matched in copyWithoutRender', async () => { - await expect( - fs.readFile( - `${workspacePath}/target/.unprocessed/templated-content-\${{ values.name }}.txt`, - 'utf-8', - ), - ).resolves.toEqual('${{ values.count }}'); + await action.handler(context); + }); + + it('ignores template syntax in files matched in copyWithoutRender', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/.unprocessed/templated-content-\${{ values.name }}.txt`, + 'utf-8', + ), + ).resolves.toEqual('${{ values.count }}'); + }); + + it('processes files not matched in copyWithoutRender', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/processed/templated-content-test-project.txt`, + 'utf-8', + ), + ).resolves.toEqual('1234'); + }); + }); + + describe('copyWithoutTemplating', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + }, + copyWithoutTemplating: ['.unprocessed'], }); - it('processes files not matched in copyWithoutRender', async () => { - await expect( - fs.readFile( - `${workspacePath}/target/processed/templated-content-test-project.txt`, - 'utf-8', - ), - ).resolves.toEqual('1234'); + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + ...realFiles, + [outputPath]: { + processed: { + 'templated-content-${{ values.name }}.txt': '${{ values.count }}', + }, + '.unprocessed': { + 'templated-content-${{ values.name }}.txt': '${{ values.count }}', + }, + }, + }); + + return Promise.resolve(); }); + + await action.handler(context); + }); + + it('renders path template and ignores content template in files matched in copyWithoutTemplating', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/.unprocessed/templated-content-test-project.txt`, + 'utf-8', + ), + ).resolves.toEqual('${{ values.count }}'); + }); + + it('processes files not matched in copyWithoutTemplating', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/processed/templated-content-test-project.txt`, + 'utf-8', + ), + ).resolves.toEqual('1234'); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 25457f4775..5e5b7052ed 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -50,7 +50,11 @@ export function createFetchTemplateAction(options: { templateFileExtension?: string | boolean; // Cookiecutter compat options + /** + * @deprecated This field is deprecated in favor of copyWithoutTemplating. + */ copyWithoutRender?: string[]; + copyWithoutTemplating?: string[]; cookiecutterCompat?: boolean; }>({ id: 'fetch:template', @@ -79,7 +83,7 @@ export function createFetchTemplateAction(options: { type: 'object', }, copyWithoutRender: { - title: 'Copy Without Render', + title: '[Deprecated] Copy Without Render', description: 'An array of glob patterns. Any files or directories which match are copied without being processed as templates.', type: 'array', @@ -87,6 +91,15 @@ export function createFetchTemplateAction(options: { type: 'string', }, }, + copyWithoutTemplating: { + title: 'Copy Without Templating', + description: + 'An array of glob patterns. Contents of matched files or directories are copied without being processed, but paths are subject to rendering.', + type: 'array', + items: { + type: 'string', + }, + }, cookiecutterCompat: { title: 'Cookiecutter compatibility mode', description: @@ -111,22 +124,37 @@ export function createFetchTemplateAction(options: { const targetPath = ctx.input.targetPath ?? './'; const outputDir = resolveSafeChildPath(ctx.workspacePath, targetPath); - - if ( - ctx.input.copyWithoutRender && - !Array.isArray(ctx.input.copyWithoutRender) - ) { + if (ctx.input.copyWithoutRender && ctx.input.copyWithoutTemplating) { throw new InputError( - 'Fetch action input copyWithoutRender must be an Array', + 'Fetch action input copyWithoutRender and copyWithoutTemplating can not be used at the same time', + ); + } + + let copyOnlyPatterns: string[] | undefined; + let renderFilename: boolean; + if (ctx.input.copyWithoutRender) { + ctx.logger.warn( + '[Deprecated] Please use copyWithoutTemplating instead.', + ); + copyOnlyPatterns = ctx.input.copyWithoutRender; + renderFilename = false; + } else { + copyOnlyPatterns = ctx.input.copyWithoutTemplating; + renderFilename = true; + } + + if (copyOnlyPatterns && !Array.isArray(copyOnlyPatterns)) { + throw new InputError( + 'Fetch action input copyWithoutRender/copyWithoutTemplating must be an Array', ); } if ( ctx.input.templateFileExtension && - (ctx.input.copyWithoutRender || ctx.input.cookiecutterCompat) + (copyOnlyPatterns || ctx.input.cookiecutterCompat) ) { throw new InputError( - 'Fetch action input extension incompatible with copyWithoutRender and cookiecutterCompat', + 'Fetch action input extension incompatible with copyWithoutRender/copyWithoutTemplating and cookiecutterCompat', ); } @@ -161,7 +189,7 @@ export function createFetchTemplateAction(options: { const nonTemplatedEntries = new Set( ( await Promise.all( - (ctx.input.copyWithoutRender || []).map(pattern => + (copyOnlyPatterns || []).map(pattern => globby(pattern, { cwd: templateDir, dot: true, @@ -194,22 +222,31 @@ export function createFetchTemplateAction(options: { }); for (const location of allEntriesInTemplate) { - let renderFilename: boolean; let renderContents: boolean; let localOutputPath = location; if (extension) { - renderFilename = true; renderContents = extname(localOutputPath) === extension; if (renderContents) { localOutputPath = localOutputPath.slice(0, -extension.length); } - } else { - renderFilename = renderContents = !nonTemplatedEntries.has(location); - } - - if (renderFilename) { + // extension is mutual exclusive with copyWithoutRender/copyWithoutTemplating, + // therefore the output path is always rendered. localOutputPath = renderTemplate(localOutputPath, context); + } else { + renderContents = !nonTemplatedEntries.has(location); + // The logic here is a bit tangled because it depends on two variables. + // If renderFilename is true, which means copyWithoutTemplating is used, + // then the path is always rendered. + // If renderFilename is false, which means copyWithoutRender is used, + // then matched file/directory won't be processed, same as before. + if (renderFilename) { + localOutputPath = renderTemplate(localOutputPath, context); + } else { + localOutputPath = renderContents + ? renderTemplate(localOutputPath, context) + : localOutputPath; + } } if (containsSkippedContent(localOutputPath)) {