test(publish): probe for a nocasematch-capable bash instead of assuming one

The rc-guard suite spawned bare 'bash', which on an ordinary Windows PATH
resolves to the WSL launcher and fails all six cases when no distribution
is installed. Resolve candidates with a real nocasematch capability probe
(GITNEXUS_TEST_BASH override, then Git for Windows bash.exe on win32, then
PATH bash), skip the suite with an explicit reason when none passes, keep
an always-on probe self-test, and document the prerequisite in
CONTRIBUTING.md.
This commit is contained in:
auyua9 2026-09-19 01:21:57 +08:00
parent 9a5607a906
commit d564e1bbf2
2 changed files with 100 additions and 23 deletions

View file

@ -15,6 +15,15 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro
**Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version.
One unit test (`gitnexus/test/unit/publish-rc-guard.test.ts`) also spawns a
real `bash` to exercise the release-subject regex under `nocasematch`
semantics. On Linux and macOS the PATH `bash` is used as-is. On Windows the
test probes Git for Windows' `bash.exe` (both the Program Files and the
per-user install) before falling back to the PATH entry, because the PATH
`bash` is frequently the WSL launcher. The suite skips with an explicit
message when no suitable bash is found; point `GITNEXUS_TEST_BASH` at a
specific executable to override discovery.
1. Clone the repository.
2. **Shared package:** `cd gitnexus-shared && npm install && npm run build`
3. **CLI / MCP package:** `cd ../gitnexus && npm install && npm run build`

View file

@ -35,37 +35,105 @@ function subjectMatches(subject: string, regex: string): boolean {
'[[ "$SUBJECT" =~ $REGEX ]] && echo MATCH || echo NO_MATCH',
'shopt -u nocasematch',
].join('\n');
const out = execFileSync('bash', ['-c', script], { encoding: 'utf8' }).trim();
const out = execFileSync(BASH, ['-c', script], { encoding: 'utf8' }).trim();
return out === 'MATCH';
}
describe('rc-guard release-subject regex (publish.yml)', () => {
const regex = releaseSubjectRegex();
// Probe script: only a real bash with nocasematch semantics (the behavior
// this suite pins) prints BASH_OK. A Windows PATH `bash` that is actually
// the WSL launcher exits with an error when no distribution is installed,
// so it fails this probe instead of failing six unit tests.
const BASH_PROBE = 'shopt -s nocasematch; [[ "Chore" =~ ^chore$ ]] && echo BASH_OK';
it('matches canonical release subjects', () => {
expect(subjectMatches('chore: release v1.6.4', regex)).toBe(true);
expect(subjectMatches('chore: release v10.20.30', regex)).toBe(true);
});
function probeBash(candidate: string): boolean {
try {
const out = execFileSync(candidate, ['-c', BASH_PROBE], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return out === 'BASH_OK';
} catch {
return false;
}
}
it('matches squash-merge subjects with the (#NNNN) suffix', () => {
expect(subjectMatches('chore: release v1.6.4 (#1474)', regex)).toBe(true);
});
// Resolve a bash executable that can actually run the nocasematch ERE
// semantics this suite extracts from publish.yml. Candidates are probed in
// order; the first one that passes the nocasematch probe wins:
// 1. GITNEXUS_TEST_BASH (explicit override for unusual installs)
// 2. on win32, Git for Windows' bash.exe — probed before the PATH entry
// because the PATH `bash` on Windows is frequently the System32 WSL
// launcher (both the Program Files and the per-user install)
// 3. plain `bash` from PATH (the POSIX default)
// When no candidate passes, the suite below skips with an explicit reason
// instead of failing: CONTRIBUTING lists Node.js as the prerequisite, so a
// contributor without any suitable bash is supported, not broken.
function resolveBash(): string | null {
const candidates: string[] = [];
const override = process.env['GITNEXUS_TEST_BASH'];
if (override) {
candidates.push(override);
}
if (process.platform === 'win32') {
const programFiles = process.env['ProgramFiles'];
const localAppData = process.env['LocalAppData'];
if (programFiles) {
candidates.push(path.join(programFiles, 'Git', 'bin', 'bash.exe'));
}
if (localAppData) {
candidates.push(path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'));
}
}
candidates.push('bash');
for (const candidate of candidates) {
if (probeBash(candidate)) {
return candidate;
}
}
return null;
}
it('stays case-insensitive for IDE auto-capitalization', () => {
expect(subjectMatches('Chore: Release v1.2.3', regex)).toBe(true);
});
const BASH = resolveBash();
it('does not match ordinary chore commits', () => {
expect(subjectMatches('chore: bump deps (#1500)', regex)).toBe(false);
});
describe.skipIf(!BASH)(
`rc-guard release-subject regex (publish.yml)${BASH ? '' : ' — skipped: no bash with nocasematch semantics found (install Git for Windows or point GITNEXUS_TEST_BASH at one)'}`,
() => {
const regex = releaseSubjectRegex();
it('does not match release-like subjects with extra suffixes or prefixes', () => {
expect(subjectMatches('chore: release v1.6.4 hotfix', regex)).toBe(false);
expect(subjectMatches('revert: chore: release v1.6.4', regex)).toBe(false);
});
it('matches canonical release subjects', () => {
expect(subjectMatches('chore: release v1.6.4', regex)).toBe(true);
expect(subjectMatches('chore: release v10.20.30', regex)).toBe(true);
});
it('requires a full semver', () => {
expect(subjectMatches('chore: release v1.6', regex)).toBe(false);
expect(subjectMatches('chore: release v1.6.x', regex)).toBe(false);
it('matches squash-merge subjects with the (#NNNN) suffix', () => {
expect(subjectMatches('chore: release v1.6.4 (#1474)', regex)).toBe(true);
});
it('stays case-insensitive for IDE auto-capitalization', () => {
expect(subjectMatches('Chore: Release v1.2.3', regex)).toBe(true);
});
it('does not match ordinary chore commits', () => {
expect(subjectMatches('chore: bump deps (#1500)', regex)).toBe(false);
});
it('does not match release-like subjects with extra suffixes or prefixes', () => {
expect(subjectMatches('chore: release v1.6.4 hotfix', regex)).toBe(false);
expect(subjectMatches('revert: chore: release v1.6.4', regex)).toBe(false);
});
it('requires a full semver', () => {
expect(subjectMatches('chore: release v1.6', regex)).toBe(false);
expect(subjectMatches('chore: release v1.6.x', regex)).toBe(false);
});
},
);
// Runs on every platform, including machines where the suite above skips:
// proves the capability probe actually rejects an unusable candidate instead
// of silently treating every spawn failure as "bash found".
describe('rc-guard bash resolution probe', () => {
it('rejects a candidate that cannot run the nocasematch probe', () => {
expect(probeBash('gitnexus-definitely-not-a-shell')).toBe(false);
});
});