diff --git a/src/prerelease-workflow.test.ts b/src/prerelease-workflow.test.ts index ce162f11..ba1c819b 100644 --- a/src/prerelease-workflow.test.ts +++ b/src/prerelease-workflow.test.ts @@ -137,13 +137,18 @@ test('prerelease workflow rejects committed notes generated for a different beta 'bun run scripts/build-changelog.ts check-prerelease-notes', ); - // Matched against executable lines only, so commenting the check out fails the - // test rather than silently satisfying it. + // Matched at command positions only, so commenting the check out or quoting it + // inside an echo fails the test rather than silently satisfying it. const steps = jobSteps(parsedPrereleaseWorkflow, 'release'); const checkIndex = steps.findIndex((step) => - stepRunsCommand(step, /changelog:check-prerelease-notes --version "\$RELEASE_VERSION"/), + stepRunsCommand( + step, + /^bun run changelog:check-prerelease-notes --version "\$RELEASE_VERSION"/, + ), + ); + const publishIndex = steps.findIndex((step) => + stepRunsCommand(step, /^gh release (create|edit)\b/), ); - const publishIndex = steps.findIndex((step) => stepRunsCommand(step, /gh release (create|edit)/)); assert.notEqual(checkIndex, -1); assert.notEqual(publishIndex, -1); diff --git a/src/workflow-test-helpers.test.ts b/src/workflow-test-helpers.test.ts new file mode 100644 index 00000000..905e29e5 --- /dev/null +++ b/src/workflow-test-helpers.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + commandPositions, + executableRunLines, + stepRunsCommand, + stepsMissingEnvDeclaration, + templateExpressionsInRunBodies, +} from './workflow-test-helpers'; + +const runs = (run: string): boolean => stepRunsCommand({ run }, /^bun run verify --flag "\$VALUE"/); + +test('stepRunsCommand matches a command that actually executes', () => { + assert.equal(runs('bun run verify --flag "$VALUE"'), true); + assert.equal(runs('if ! bun run verify --flag "$VALUE"; then\nexit 1\nfi'), true); + assert.equal(runs('set -e && bun run verify --flag "$VALUE"'), true); + assert.equal(runs(' bun run verify --flag "$VALUE" || exit 1'), true); +}); + +test('stepRunsCommand rejects commands that are only mentioned, not run', () => { + assert.equal(runs('# bun run verify --flag "$VALUE"'), false); + assert.equal(runs('echo \'bun run verify --flag "$VALUE"\''), false); + assert.equal(runs("printf '%s\\n' 'bun run verify --flag \"$VALUE\"'"), false); + assert.equal(runs('echo "run: bun run verify --flag \\"$VALUE\\"" >> notes.txt'), false); + // A different argument list is a different command. + assert.equal(runs('bun run verify'), false); +}); + +test('commandPositions splits on separators and strips control-flow prefixes', () => { + assert.deepEqual( + commandPositions({ run: 'if gh release view "$V"; then\ngh release edit "$V"\nfi' }), + ['gh release view "$V"', 'then', 'gh release edit "$V"', 'fi'], + ); +}); + +test('executableRunLines drops blank and comment-only lines', () => { + assert.deepEqual(executableRunLines({ run: '\n# a comment\n \nreal command\n' }), [ + 'real command', + ]); +}); + +test('templateExpressionsInRunBodies reports every expression spelling in a run body', () => { + const workflow = { + jobs: { + release: { + steps: [ + { name: 'Safe', env: { V: '${{ steps.version.outputs.VERSION }}' }, run: 'echo "$V"' }, + { name: 'Dotted', run: 'echo "${{ steps.version.outputs.VERSION }}"' }, + { name: 'Bracketed', run: 'echo "${{ steps.version.outputs[\'VERSION\'] }}"' }, + { name: 'Github', run: 'echo "${{ github[\'ref_name\'] }}"' }, + ], + }, + }, + }; + + assert.deepEqual(templateExpressionsInRunBodies(workflow), [ + 'release/Dotted: ${{ steps.version.outputs.VERSION }}', + "release/Bracketed: ${{ steps.version.outputs['VERSION'] }}", + "release/Github: ${{ github['ref_name'] }}", + ]); +}); + +test('stepsMissingEnvDeclaration finds shell reads with no matching env entry', () => { + const workflow = { + jobs: { + release: { + steps: [ + { name: 'Declared', env: { TAG: 'x' }, run: 'echo "$TAG"' }, + { name: 'Undeclared', run: 'echo "${TAG}"' }, + { name: 'Unrelated', run: 'echo "$TAGGED"' }, + ], + }, + }, + }; + + assert.deepEqual(stepsMissingEnvDeclaration(workflow, 'TAG'), ['release/Undeclared']); +}); diff --git a/src/workflow-test-helpers.ts b/src/workflow-test-helpers.ts index 7d93b196..95a2fa59 100644 --- a/src/workflow-test-helpers.ts +++ b/src/workflow-test-helpers.ts @@ -50,9 +50,34 @@ export function executableRunLines(step: WorkflowStep): string[] { .filter((line) => line.length > 0 && !line.startsWith('#')); } -// Whether a step actually executes a command matching the pattern. +// Leading shell keywords and operators that can precede a real command. +const COMMAND_PREFIX = /^(?:if|elif|while|until|then|else|do|!|&&|\|\||\(|\{)\s+/; + +// Command positions within a step's shell body: each line split on separators, +// with control-flow prefixes stripped. A pattern anchored with ^ therefore +// matches only where a command actually starts, so text quoted inside an +// `echo`/`printf` argument is not mistaken for the command running. +export function commandPositions(step: WorkflowStep): string[] { + return executableRunLines(step).flatMap((line) => + line + .split(/;|&&|\|\||(?])\|(?!\|)/) + .map((segment) => { + let candidate = segment.trim(); + let stripped = candidate.replace(COMMAND_PREFIX, ''); + while (stripped !== candidate) { + candidate = stripped; + stripped = candidate.replace(COMMAND_PREFIX, ''); + } + return candidate; + }) + .filter(Boolean), + ); +} + +// Whether a step actually executes a command matching the pattern. Anchor the +// pattern with ^ so it has to match at a command position. export function stepRunsCommand(step: WorkflowStep, pattern: RegExp): boolean { - return executableRunLines(step).some((line) => pattern.test(line)); + return commandPositions(step).some((position) => pattern.test(position)); } // GitHub substitutes ${{ }} into a run script before the shell parses it, so any