diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c986df911..f81d7f6d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,31 @@ jobs: # surfaces missing-file / bundler-config regressions before the # (slower) full Vitest suite runs. - run: npm run build + # Guard against committed-bundle drift: GitHub's Actions runner + # executes whatever is *checked in* under `lib/`, not whatever CI + # rebuilds. Without this check a PR could change `src/` + rebuild + # locally, commit only the `src/` change, and still pass CI while + # shipping a stale bundle to every consumer. + # + # Two complementary checks: + # 1. `git diff --exit-code -- lib/` catches modifications to + # tracked files (today: `lib/main.js`). + # 2. `git ls-files --others --exclude-standard -- lib/` catches + # brand-new untracked files the bundler might start emitting + # (e.g. sourcemaps, additional chunks) that a future `src/` + # change could introduce without the author noticing. + - name: Ensure lib/ is up to date with src/ + run: | + git diff --stat -- lib/ || true + if ! git diff --exit-code -- lib/; then + echo "::error::Files under \`lib/\` are stale. Run \`npm run build\` locally and commit the updated bundle." + exit 1 + fi + untracked=$(git ls-files --others --exclude-standard -- lib/) + if [ -n "$untracked" ]; then + echo "::error::The build produced new files under \`lib/\` that are not committed:" + printf ' %s\n' $untracked + echo "::error::Commit the new artefact(s) or add them to \`.gitignore\`." + exit 1 + fi - run: npm test diff --git a/src/action.ts b/src/action.ts index 6ee3fadfa..ceba0365d 100644 --- a/src/action.ts +++ b/src/action.ts @@ -216,6 +216,14 @@ export default async function main() { // `preset: 'conventionalcommits'` would trigger a dynamic // `import-from-esm` lookup that fails on the Actions runner // where no `node_modules` directory exists. + // + // Note: upstream `load-changelog-config.js` only reads + // `loadedConfig.commits` (never merging a plugin-supplied `commits` + // field), so when no `preset` / `config` is passed `commitOpts` + // always comes from the angular default preset. That is harmless + // today — angular and conventionalcommits both default to + // `{ ignore: undefined, merges: false }` — but is worth knowing if + // those defaults ever diverge upstream. const resolvedPreset = conventionalCommitsPreset({ types: mergeWithDefaultChangelogRules(mappedReleaseRules), }); diff --git a/tests/bundle.smoke.test.ts b/tests/bundle.smoke.test.ts index d8903b34f..d2b6dc679 100644 --- a/tests/bundle.smoke.test.ts +++ b/tests/bundle.smoke.test.ts @@ -54,7 +54,8 @@ interface ChildResult { * Drain stdout/stderr asynchronously while the child runs so large * bursts of startup output can't deadlock the parent. Always resolves * (never rejects) so the caller can assert on the captured streams even - * if the child crashed, exited non-zero, or had to be SIGKILLed. + * if the child crashed, exited non-zero, had to be SIGKILLed, or failed + * to spawn in the first place. */ function runChildAndCapture( child: ChildProcess, @@ -63,21 +64,39 @@ function runChildAndCapture( return new Promise((resolve) => { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; + let settled = false; + + const finish = (overrides: Partial): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stderr: Buffer.concat(stderrChunks).toString('utf8'), + status: null, + signal: null, + ...overrides, + }); + }; + child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + // If spawn itself fails (e.g. ENOENT for the node binary), Node emits + // `error` and never a `close`. Fold the error into stderr so the + // caller still sees a useful message instead of hanging until the + // SIGKILL timer fires. + child.on('error', (err) => { + stderrChunks.push(Buffer.from(`\n${String(err)}\n`)); + finish({}); + }); + const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeoutMs); child.on('close', (status, signal) => { - clearTimeout(timer); - resolve({ - stdout: Buffer.concat(stdoutChunks).toString('utf8'), - stderr: Buffer.concat(stderrChunks).toString('utf8'), - status, - signal, - }); + finish({ status, signal }); }); }); } @@ -209,8 +228,18 @@ describe('bundled action artefact', () => { }; const server = createServer(handleRequest); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', resolve); + await new Promise((resolve, reject) => { + // Without an `error` listener, a failed `listen` (e.g. EADDRINUSE + // on a constrained CI host) would never reject and the test would + // silently hang until vitest's test timeout. + const onError = (err: Error): void => { + reject(err); + }; + server.once('error', onError); + server.listen(0, '127.0.0.1', () => { + server.off('error', onError); + resolve(); + }); }); try {