Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 31 additions & 15 deletions .github/actions/label-gate/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// exit 0 with `authorised=false`. Downstream jobs gate on the output.

import { appendFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { GitHubClient, GitHubApiError } from './github-client.mjs';
import { gate, parseList, loadEventPayload } from './gate.mjs';

Expand All @@ -18,11 +19,19 @@ const notice = (m) => annotate('notice', m);
const warning = (m) => annotate('warning', m);
const error = (m) => annotate('error', m);

function getInput(name, { required = false } = {}) {
// Composite/JS actions both expose inputs as INPUT_<NAME_UPPERCASED>
// with hyphens replaced by underscores.
const key = `INPUT_${name.toUpperCase().replace(/-/g, '_')}`;
const raw = process.env[key];
// Exported for unit tests; index.mjs is the only production caller.
export function getInput(name, { required = false, env = process.env } = {}) {
// Match the GitHub Actions runner / @actions/core convention exactly:
// INPUT_<NAME> where <NAME> uppercases the input and replaces spaces
// (NOT hyphens) with underscores. Hyphens are preserved verbatim, so
// `github-token` becomes `INPUT_GITHUB-TOKEN`. Hyphens in env-var
// names are technically non-POSIX but Node.js exposes them via
// process.env regardless. An earlier impl replaced hyphens with
// underscores too, which silently lost any hyphenated input β€” caught
// by the QVAC-18612 canary; the regression test below pins this for
// good.
const key = `INPUT_${name.replace(/ /g, '_').toUpperCase()}`;
const raw = env[key];
const value = raw == null ? '' : raw.trim();
if (required && !value) {
throw new Error(`required input '${name}' is missing`);
Expand Down Expand Up @@ -83,13 +92,20 @@ async function main() {
await setOutput('authorised', decision.authorised ? 'true' : 'false');
}

main().catch((e) => {
if (e instanceof GitHubApiError) {
error(
`GitHub API error: ${e.message} (status=${e.status} method=${e.method} path=${e.path})`
);
} else {
error(`unexpected failure: ${e.message ?? e}`);
}
process.exitCode = 1;
});
// Only execute when invoked as the action entrypoint (`node src/index.mjs`).
// When imported by the test suite the top-level main() must not run.
const invokedDirectly =
process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url);

if (invokedDirectly) {
main().catch((e) => {
if (e instanceof GitHubApiError) {
error(
`GitHub API error: ${e.message} (status=${e.status} method=${e.method} path=${e.path})`
);
} else {
error(`unexpected failure: ${e.message ?? e}`);
}
process.exitCode = 1;
});
}
74 changes: 74 additions & 0 deletions .github/actions/label-gate/test/index.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Tests for the entrypoint's input plumbing. Pins the env-var-name
// resolution against the GitHub Actions runner's actual contract,
// which is what failed live in the QVAC-18612 canary on 2026-05-11
// (run id 25672483584): the runner exposes `github-token` as
// INPUT_GITHUB-TOKEN (hyphen preserved); an earlier impl looked up
// INPUT_GITHUB_TOKEN (hyphen-to-underscore) and silently lost the
// token, hard-failing every PR-event run.

import test from 'node:test';
import assert from 'node:assert/strict';

import { getInput } from '../src/index.mjs';

test('getInput: simple uppercase name (no transform)', () => {
const env = { INPUT_LABEL: 'verified' };
assert.equal(getInput('label', { env }), 'verified');
});

test('getInput: hyphenated input -> hyphen PRESERVED in env-var name', () => {
// This is the QVAC-18612 regression: runner sets INPUT_GITHUB-TOKEN,
// not INPUT_GITHUB_TOKEN. getInput must match what the runner sets.
const env = { 'INPUT_GITHUB-TOKEN': 'pat_xyz' };
assert.equal(getInput('github-token', { env }), 'pat_xyz');
});

test('getInput: hyphen-replaced lookup must NOT find the value (lock the contract)', () => {
// Belt-and-braces: assert the OPPOSITE form is silently empty so we
// notice if anyone "helpfully" reintroduces the hyphen-to-underscore
// substitution.
const env = { INPUT_GITHUB_TOKEN: 'wrong-key' };
assert.equal(getInput('github-token', { env }), '');
});

test('getInput: spaces are replaced with underscores (per @actions/core)', () => {
const env = { INPUT_MY_INPUT: 'value' };
assert.equal(getInput('my input', { env }), 'value');
});

test('getInput: missing optional input -> empty string', () => {
assert.equal(getInput('label', { env: {} }), '');
});

test('getInput: missing required input -> throws with the original name', () => {
assert.throws(
() => getInput('github-token', { required: true, env: {} }),
/required input 'github-token' is missing/
);
});

test('getInput: empty/whitespace value treated as missing for required', () => {
assert.throws(
() => getInput('github-token', {
required: true,
env: { 'INPUT_GITHUB-TOKEN': ' ' },
}),
/required input 'github-token' is missing/
);
});

test('getInput: trims surrounding whitespace from supplied value', () => {
const env = { 'INPUT_GITHUB-TOKEN': ' pat_xyz ' };
assert.equal(getInput('github-token', { env }), 'pat_xyz');
});

test('getInput: defaults env to process.env when not supplied', () => {
const original = process.env.INPUT_LABEL;
process.env.INPUT_LABEL = 'from-process-env';
try {
assert.equal(getInput('label'), 'from-process-env');
} finally {
if (original === undefined) delete process.env.INPUT_LABEL;
else process.env.INPUT_LABEL = original;
}
});