test(release): cover workflow injection safety and prerelease note check

The AUR test pinned the literal ${{ steps.version.outputs.VERSION }}
interpolation that moved into an env block, so it failed once the tag
value started reaching the shell as $RELEASE_VERSION.

Update that assertion and add guards for the invariant behind the move:
no tag-derived value may be interpolated into a run body, where GitHub
substitutes it before the shell parses the line. Also assert the
prerelease workflow runs the committed-notes check before it creates or
edits the release.

Document that the fragment delta resolves git paths against cwd, which
is both the project root and the repository root.
This commit is contained in:
sudacode
2026-08-23 03:17:06 -07:00
parent 1cbc5f3853
commit 7b403bf8ad
3 changed files with 62 additions and 1 deletions
+5
View File
@@ -233,6 +233,11 @@ function defaultListPrereleaseTags(cwd: string, baseVersion: string): string[] {
// Diffs changes/*.md between the previous prerelease tag and the working tree. // Diffs changes/*.md between the previous prerelease tag and the working tree.
// Renamed fragments are treated as modifications of the new path. // Renamed fragments are treated as modifications of the new path.
//
// Like every other path in this script, git paths are resolved against `cwd`,
// which is the project root and also the repository root. Callers that point
// `cwd` elsewhere already fail earlier and loudly, when package.json and
// changes/ come back missing.
function defaultResolveFragmentDelta(cwd: string, previousTag: string): FragmentDeltaEntry[] { function defaultResolveFragmentDelta(cwd: string, previousTag: string): FragmentDeltaEntry[] {
const output = execFileSync( const output = execFileSync(
'git', 'git',
+32
View File
@@ -122,3 +122,35 @@ test('prerelease workflow does not publish to AUR', () => {
assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/); assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/); assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/);
}); });
test('prerelease workflow rejects committed notes generated for a different beta or rc', () => {
assert.equal(
packageJson.scripts['changelog:check-prerelease-notes'],
'bun run scripts/build-changelog.ts check-prerelease-notes',
);
assert.match(
prereleaseWorkflow,
/bun run changelog:check-prerelease-notes --version "\$RELEASE_VERSION"/,
);
// The staleness check has to run before the release is created or edited,
// otherwise stale notes are already published by the time it fails.
const checkIndex = prereleaseWorkflow.indexOf('changelog:check-prerelease-notes');
const createIndex = prereleaseWorkflow.indexOf('gh release create');
const editIndex = prereleaseWorkflow.indexOf('gh release edit');
assert.notEqual(checkIndex, -1);
assert.ok(checkIndex < createIndex);
assert.ok(checkIndex < editIndex);
});
// GitHub substitutes ${{ }} into the run script before the shell parses it, so a
// tag-derived value used that way is executed as script rather than read as data.
test('tag-derived values reach shell bodies through env, not template interpolation', () => {
const rawVersionUses = prereleaseWorkflow
.split('\n')
.filter((line) => line.includes('steps.version.outputs.VERSION'))
.filter(
(line) => !/^\s*RELEASE_VERSION: \$\{\{ steps\.version\.outputs\.VERSION \}\}$/.test(line),
);
assert.deepEqual(rawVersionUses, []);
});
+25 -1
View File
@@ -249,7 +249,7 @@ test('release workflow publishes subminer-bin to AUR from tagged release artifac
releaseWorkflow, releaseWorkflow,
/cp packaging\/aur\/subminer-bin\/\.SRCINFO aur-subminer-bin\/\.SRCINFO/, /cp packaging\/aur\/subminer-bin\/\.SRCINFO aur-subminer-bin\/\.SRCINFO/,
); );
assert.match(releaseWorkflow, /version_no_v="\$\{\{ steps\.version\.outputs\.VERSION \}\}"/); assert.match(releaseWorkflow, /version_no_v="\$RELEASE_VERSION"/);
assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/); assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/);
assert.doesNotMatch( assert.doesNotMatch(
releaseWorkflow, releaseWorkflow,
@@ -278,3 +278,27 @@ test('Makefile uninstall targets remove bundled runtime plugin app-data copies',
assert.match(makefile, /Removed:[\s\S]*\$\(LINUX_DATA_DIR\)\/plugin\/subminer/); assert.match(makefile, /Removed:[\s\S]*\$\(LINUX_DATA_DIR\)\/plugin\/subminer/);
assert.match(makefile, /Removed:[\s\S]*\$\(MACOS_DATA_DIR\)\/plugin\/subminer/); assert.match(makefile, /Removed:[\s\S]*\$\(MACOS_DATA_DIR\)\/plugin\/subminer/);
}); });
// GitHub substitutes ${{ }} into the run script before the shell parses it, so a
// tag-derived value used that way is executed as script rather than read as data.
// The release and docs workflows must route tag values through env and
// reference them as shell variables.
test('tag-derived values reach shell bodies through env, not template interpolation', () => {
const rawVersionUses = releaseWorkflow
.split('\n')
.filter((line) => line.includes('steps.version.outputs.VERSION'))
.filter(
(line) => !/^\s*RELEASE_VERSION: \$\{\{ steps\.version\.outputs\.VERSION \}\}$/.test(line),
);
assert.deepEqual(rawVersionUses, []);
const rawRefNameUses = docsPagesWorkflow
.split('\n')
.filter((line) => line.includes('github.ref_name'))
.filter((line) => !/^\s*TAG_NAME: \$\{\{ github\.ref_name \}\}$/.test(line))
// `if:` conditions are evaluated by Actions itself, never handed to a shell.
.filter((line) => !/^\s*if:/.test(line));
assert.deepEqual(rawRefNameUses, []);
assert.match(docsPagesWorkflow, /if \[\[ ! "\$TAG_NAME" =~/);
});