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.
This commit is contained in:
sudacode
2026-08-23 04:09:08 -07:00
parent 94643ffceb
commit 2bfb72a09e
3 changed files with 113 additions and 6 deletions
+9 -4
View File
@@ -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);
+77
View File
@@ -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']);
});
+27 -2
View File
@@ -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(/;|&&|\|\||(?<![0-9<>])\|(?!\|)/)
.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