From 2bfb72a09e35be77947d0802c988077f010d44ce Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 23 Aug 2026 04:09:08 -0700 Subject: [PATCH] test(release): match workflow commands at command positions only An unanchored pattern matched the command text anywhere on an executable line, so wrapping it in echo or printf satisfied the ordering assertion while the workflow ran no validation at all. Split each line on shell separators, strip control-flow prefixes, and match anchored patterns against those command positions. Cover the helper directly with cases for echo, printf, comments, and quoted mentions. --- src/prerelease-workflow.test.ts | 13 ++++-- src/workflow-test-helpers.test.ts | 77 +++++++++++++++++++++++++++++++ src/workflow-test-helpers.ts | 29 +++++++++++- 3 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 src/workflow-test-helpers.test.ts 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