From c68a5b2a2e8c15f9ca6f8cc65e823dbf5a65f637 Mon Sep 17 00:00:00 2001 From: Oluwaseun Ismaila Date: Mon, 11 May 2026 14:28:59 +0100 Subject: [PATCH] QVAC-18608 fix(label-gate): preserve hyphens in input env-var names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QVAC-18612 canary (PR #1971, run id 25672483584) hard-failed with "required input 'github-token' is missing" even though the workflow clearly passed `github-token: ${{ secrets.GITHUB_TOKEN }}`. Root cause: `getInput` in src/index.mjs was uppercasing the input name AND replacing hyphens with underscores, looking up `INPUT_GITHUB_TOKEN`. The GitHub Actions runner (and @actions/core) preserve hyphens — only spaces are replaced — so the runner sets `INPUT_GITHUB-TOKEN`. The action never found the token and threw a missing-input error. The local smoke test that "passed" before merge set `INPUT_GITHUB_TOKEN=...` (matching the buggy lookup) so both sides were wrong in the same direction. This is exactly the failure mode the canary was meant to surface; without it, the gate would have failed across all 75 secret-bearing workflows on first PR after the QVAC-18612 fan-out. Fix: - getInput now uses `name.replace(/ /g, '_').toUpperCase()` — matching the runner / @actions/core convention exactly. - getInput is exported from src/index.mjs (with an injectable env arg) so the convention can be unit-tested. - Top-level main() is gated on `import.meta.url === argv[1]` so importing index.mjs from tests no longer triggers a real run. Tests: - 9 new tests in test/index.test.mjs pin the env-var-name resolution: * INPUT_GITHUB-TOKEN (hyphen preserved) -> resolves * INPUT_GITHUB_TOKEN (hyphen replaced) -> does NOT resolve (locks the contract against accidental "helpful" rewrite) * spaces are still replaced with underscores * trim, missing-required, defaults-to-process.env - Total: 53/53 pass via `node --test`. - End-to-end smoke against the runner-correct env-var name (INPUT_GITHUB-TOKEN=...) confirms exit 0 and authorised=false on the no-label deny path. Refs: https://app.asana.com/1/45238840754660/project/1214153063536860/task/1214612672233087 Related: https://github.com/tetherto/qvac/pull/1971 Co-authored-by: Cursor --- .github/actions/label-gate/src/index.mjs | 46 ++++++++---- .../actions/label-gate/test/index.test.mjs | 74 +++++++++++++++++++ 2 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 .github/actions/label-gate/test/index.test.mjs diff --git a/.github/actions/label-gate/src/index.mjs b/.github/actions/label-gate/src/index.mjs index 1fbae2e793..2c79eca9fb 100644 --- a/.github/actions/label-gate/src/index.mjs +++ b/.github/actions/label-gate/src/index.mjs @@ -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'; @@ -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_ - // 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_ where 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`); @@ -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; + }); +} diff --git a/.github/actions/label-gate/test/index.test.mjs b/.github/actions/label-gate/test/index.test.mjs new file mode 100644 index 0000000000..9a2b32919c --- /dev/null +++ b/.github/actions/label-gate/test/index.test.mjs @@ -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; + } +});