Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/js/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ function lspFor(patterns, archetype, engine, extras) {
return Object.keys(lsp).length ? lsp : null;
}

// Safe outputs that create a brand-new GitHub item (issue/PR) on every run. When a
// workflow is schedule-triggered and uses one of these, the generated file must ship
// with skip-if-match + max/expires by default — otherwise every scheduled run creates
// another duplicate issue/PR forever, since the prompt body alone cannot be relied on
// to add this guard (see generateAgentPrompt's separate duplicatePreventionTips path,
// which only reaches prompt-mode output, not the directly generated workflow file).
const DEDUPE_SAFE_OUTPUTS = ['create-issue', 'create-pull-request'];

export function generateWorkflowFile(answers, patterns) {
generationModel(patterns);
const definition = workflowDefinition(patterns, answers.archetype);
Expand All @@ -218,10 +226,16 @@ export function generateWorkflowFile(answers, patterns) {
});
}

const needsDedupeGuard = answers.triggers.indexOf('schedule') !== -1 &&
safeOutputs.some((output) => DEDUPE_SAFE_OUTPUTS.indexOf(output) !== -1);

let frontmatter = '---\n';
frontmatter += `name: ${ name }\n`;
frontmatter += `description: ${ description }\n`;
frontmatter += `on:\n${ buildTriggerYaml(answers.triggers, name, answers.archetype, patterns)}`;
if (needsDedupeGuard) {
frontmatter += ` skip-if-match: 'is:issue is:open "gh-aw-workflow-id: ${ name }" in:body'\n`;
}
frontmatter += 'permissions:\n';
permissionsFor(patterns, answers.archetype, inferred).forEach((permission) => {
frontmatter += ` ${ permission }: read\n`;
Expand Down Expand Up @@ -270,7 +284,12 @@ export function generateWorkflowFile(answers, patterns) {
}
if (safeOutputs.length) {
frontmatter += 'safe-outputs:\n';
safeOutputs.forEach((safeOutput) => { frontmatter += ` ${ safeOutput }:\n`; });
safeOutputs.forEach((safeOutput) => {
frontmatter += ` ${ safeOutput }:\n`;
if (needsDedupeGuard && DEDUPE_SAFE_OUTPUTS.indexOf(safeOutput) !== -1) {
frontmatter += ' max: 1\n expires: 7\n';
}
});
}
frontmatter += `timeout-minutes: ${ timeout }\n`;
frontmatter += '---\n\n';
Expand Down
35 changes: 34 additions & 1 deletion test/workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ describe('generateWorkflowFile', () => {
expect(md).toContain(' github:\n');
expect(md).toContain(' cache-memory:\n');
expect(md).toContain(' playwright:\n mode: cli\n');
expect(md).toContain('safe-outputs:\n create-issue:\n upload-assets:\n');
expect(md).toContain('safe-outputs:\n create-issue:\n max: 1\n expires: 7\n upload-assets:\n');
expect(md).toContain('## Pre-steps');
});

Expand Down Expand Up @@ -397,6 +397,39 @@ describe('generateWorkflowFile', () => {
expect(piMd).toContain('engine: pi\n');
});

it('adds skip-if-match and max/expires by default for scheduled issue/PR-creating workflows', () => {
// Without this default, a schedule-triggered workflow that creates issues or PRs would
// ship as a plain generated file with no deduplication guard, so every scheduled run
// creates another duplicate item — this must not depend on the downstream agent
// remembering the separate prompt-mode guidance (generateAgentPrompt's dedupe tips).
const md = generateWorkflowFile(
answers({ archetype: 'status-report', triggers: ['schedule'], outputs: ['create-issue'] }),
patterns
);
expect(md).toContain("skip-if-match: 'is:issue is:open \"gh-aw-workflow-id: status-report\" in:body'");
expect(md).toContain('safe-outputs:\n create-issue:\n max: 1\n expires: 7\n');
});

it('adds the dedupe guard for every dedupe-relevant safe output the workflow uses', () => {
const md = generateWorkflowFile(
answers({
archetype: 'dependency-monitor',
triggers: ['schedule'],
outputs: ['create-issue', 'create-pull-request']
}),
patterns
);
expect(md).toContain('create-issue:\n max: 1\n expires: 7\n');
expect(md).toContain('create-pull-request:\n max: 1\n expires: 7\n');
});

it('does not add a dedupe guard for non-scheduled triggers or non-dedupe safe outputs', () => {
const md = generateWorkflowFile(answers(), patterns);
expect(md).not.toContain('skip-if-match');
expect(md).not.toContain('max: 1');
expect(md).not.toContain('expires: 7');
});

it('deduplicates safe outputs', () => {
const md = generateWorkflowFile(answers({ outputs: ['add-comment', 'add-labels'] }), patterns);
const commentsCount = md.split('\n').filter((line) => line === ' add-comment:').length;
Expand Down
Loading