diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index a04ff4da33..4fca768883 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -3992,7 +3992,12 @@ Every `actions/setup-node` release, governance, security, integration, and native-agent job, the release-preflight worker, the developer container, and the release frontend stages must stay on Node.js `24`; local integration setup must reject a different major instead of treating newer non-LTS majors as -equivalent. Shipped and test Dockerfiles must pin every external base to a full +equivalent. Workflow selectors may use the governed major or an exact stable +`major.minor.patch` pin within it; ranges, previews, other majors and unresolved +selectors are not parity evidence. `TestNodeToolchainParity` checks the actual +workflows, and `TestNodeToolchainSelectors` checks both allowed forms and those +rejections without removing an exact runtime pin from a qualification job. +Shipped and test Dockerfiles must pin every external base to a full manifest-list digest, and checked-in integration Compose images must do the same. Node.js `24` is normally supported through `2028-04-30` and Alpine `3.24` through `2028-06-01`; a weekly repository check must fail once either @@ -5003,7 +5008,13 @@ advisory fails whenever the graph moves or the mode value is not exactly `false`, that an inherited advisory warns and is still named, that an unparseable or unrecognised report is never read as clean, and that neither a hung attempt nor an exhausted budget can outlive -its bound. `frontend-modern/src/security/__tests__/dependencySecurity.test.ts` +its bound. Both fake-provider suites own their default timing controls rather +than inheriting a caller's shortened or exhausted budget. Python's hostile-env +controls exercise both exact single-request advisory verdicts and the real +shell fixture, including its explicit hung-attempt and total-deadline cases. +This fixture isolation does not change the production runner, retry budget or +audit verdict, and cannot establish the cause of an earlier extra invocation. +`frontend-modern/src/security/__tests__/dependencySecurity.test.ts` pins the known safe floors for advisories remediated by commit `6ba85a185`, including DOMPurify `GHSA-55q2-fjhq-7xh7`, brace-expansion `GHSA-mh99-v99m-4gvg` and `GHSA-rgw5-rvv9-x895`, and nanoid diff --git a/scripts/installtests/container_dependency_contract_test.go b/scripts/installtests/container_dependency_contract_test.go index 9645a3396e..6c0a17c2ad 100644 --- a/scripts/installtests/container_dependency_contract_test.go +++ b/scripts/installtests/container_dependency_contract_test.go @@ -84,7 +84,7 @@ func TestNodeToolchainParity(t *testing.T) { setupCount += strings.Count(content, "actions/setup-node@") for _, match := range versionPattern.FindAllStringSubmatch(content, -1) { versionCount++ - if match[1] != nodeToolchainLine { + if !usesNodeToolchainLine(match[1], nodeToolchainLine) { t.Fatalf("%s selects Node.js %s; all workflows must use governed line %s", path, match[1], nodeToolchainLine) } } @@ -111,3 +111,27 @@ func TestNodeToolchainParity(t *testing.T) { t.Fatalf("devcontainer must use Node.js %s from a digest-pinned feature", nodeToolchainLine) } } + +// A reproducible job may pin an exact stable version within the governed +// major. Reject ranges, previews and unresolved selectors rather than treating +// a literal same-major pin as a different toolchain line. +func usesNodeToolchainLine(selector, line string) bool { + return regexp.MustCompile(`^` + regexp.QuoteMeta(line) + `(\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))?$`).MatchString(selector) +} + +func TestNodeToolchainSelectors(t *testing.T) { + for _, selector := range []string{"24", "24.0.0", "24.20.0", "24.123.456"} { + if !usesNodeToolchainLine(selector, "24") { + t.Errorf("governed stable selector %q rejected", selector) + } + } + for _, selector := range []string{ + "23", "25", "23.20.0", "25.20.0", "240.20.0", "24.20", "24.x", "24.*", + "^24.20.0", ">=24", "latest", "node", "lts/*", "${{ matrix.node }}", + "24.20.0-rc.1", "24.20.0+build", "24.020.0", "24.20.00", "24.20.0 ", "", + } { + if usesNodeToolchainLine(selector, "24") { + t.Errorf("ungoverned or unresolved selector %q accepted", selector) + } + } +} diff --git a/scripts/tests/test-npm-audit-retry.sh b/scripts/tests/test-npm-audit-retry.sh index 165af753a7..dc8706ed0e 100755 --- a/scripts/tests/test-npm-audit-retry.sh +++ b/scripts/tests/test-npm-audit-retry.sh @@ -9,6 +9,13 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SCRIPT="${ROOT_DIR}/scripts/npm-audit-retry.sh" +# Own the fake-provider baseline. Individual deadline controls below still +# override these values at the function call, but a CI caller's audit budget +# must not silently alter all the other canned-verdict tests. +NPM_AUDIT_ATTEMPTS=3 +NPM_AUDIT_ATTEMPT_TIMEOUT=60 +NPM_AUDIT_MAX_SECONDS=240 + WORK_DIR="$(mktemp -d)" trap 'rm -rf "${WORK_DIR}"' EXIT diff --git a/scripts/tests/test_npm_audit_retry.py b/scripts/tests/test_npm_audit_retry.py index 3bcb17c7cb..ea17e7306d 100755 --- a/scripts/tests/test_npm_audit_retry.py +++ b/scripts/tests/test_npm_audit_retry.py @@ -9,6 +9,7 @@ import subprocess import tempfile import textwrap import unittest +from unittest.mock import patch import yaml @@ -99,6 +100,8 @@ class NpmAuditRetryTest(unittest.TestCase): "FAKE_NPM_MODE": mode, "FAKE_NPM_REPORT": str(report_path), "NPM_AUDIT_ATTEMPTS": "3", + "NPM_AUDIT_ATTEMPT_TIMEOUT": "60", + "NPM_AUDIT_MAX_SECONDS": "240", "NPM_AUDIT_CMD": str(fake_npm), "NPM_AUDIT_REQUIRE_RESULT": require, "NPM_AUDIT_RETRY_DELAY": "0", @@ -119,6 +122,44 @@ class NpmAuditRetryTest(unittest.TestCase): ) return result, recorded_calls + def test_fake_audit_budget_is_not_inherited_from_the_caller(self) -> None: + # These are fixture controls, not a real advisory query. A caller's + # exhausted/short budget must not prevent the canned verdict from + # being read or change the exact one-request assertion. + with patch.dict(os.environ, { + "NPM_AUDIT_ATTEMPTS": "0", + "NPM_AUDIT_ATTEMPT_TIMEOUT": "0", + "NPM_AUDIT_MAX_SECONDS": "0", + "NPM_AUDIT_RETRY_DELAY": "10000", + "NPM_AUDIT_CMD": "/must-not-be-executed", + "NPM_AUDIT_REQUIRE_RESULT": "false", + }): + for require, status, annotation in [ + ("true", 1, "::error::"), ("false", 0, "::warning::") + ]: + with self.subTest(require=require): + result, calls = self.run_check("vulnerability", "all", require=require) + self.assertEqual(result.returncode, status, result.stdout) + self.assertEqual(calls, ["audit --json"]) + self.assertIn(annotation, result.stdout) + self.assertIn("vulnerabilities present", result.stdout) + self.assertNotIn("retrying", result.stdout) + + def test_shell_fixture_owns_its_default_and_explicit_timing_controls(self) -> None: + env = os.environ.copy() + env.update(NPM_AUDIT_ATTEMPTS="0", NPM_AUDIT_ATTEMPT_TIMEOUT="0", + NPM_AUDIT_MAX_SECONDS="0", NPM_AUDIT_RETRY_DELAY="10000", + NPM_AUDIT_CMD="/must-not-be-executed") + result = subprocess.run( + ["bash", str(ROOT / "scripts/tests/test-npm-audit-retry.sh")], + cwd=ROOT, env=env, text=True, capture_output=True, check=False, + timeout=90, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("all npm-audit-retry tests passed", result.stdout) + self.assertIn("a hung audit is stopped at the per-attempt limit", result.stdout) + self.assertIn("the wall-clock budget ends the retry sequence", result.stdout) + def test_passes_a_clean_production_audit_and_forwards_arguments(self) -> None: result, calls = self.run_check( "success", "production", "--package-lock-only"