From a45fe8fd7b2a897fb8b6dd224a95600d383ed529 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Wed, 21 Oct 2026 22:38:34 +0000 Subject: [PATCH 1/4] fix: support verified Windows Node in locked-tools action Port the gated Windows Node ZIP installer and add the canonical v26.12.1 windows/amd64 artifact lock, fixture coverage, and platform documentation. Co-authored-by: c1-squire-dev[bot] --- .github/actions/locked-tools/install-tool.ps1 | 6 ++ .github/actions/locked-tools/tool-locks.txt | 1 + docs/release-workflow.md | 4 + scripts/test-tool-integrity.py | 79 ++++++++++++++++++- 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/.github/actions/locked-tools/install-tool.ps1 b/.github/actions/locked-tools/install-tool.ps1 index b6ad103..f95cb40 100644 --- a/.github/actions/locked-tools/install-tool.ps1 +++ b/.github/actions/locked-tools/install-tool.ps1 @@ -27,6 +27,10 @@ switch ($Tool) { 'go' { $canonical = "https://go.dev/dl/$version.windows-amd64.zip" } 'syft' { $canonical = "https://github.com/anchore/syft/releases/download/$version/syft_$($version.TrimStart('v'))_windows_amd64.zip" } 'goreleaser-pro' { $canonical = "https://github.com/goreleaser/goreleaser-pro/releases/download/$version/goreleaser-pro_Windows_x86_64.zip" } + 'node' { + $package = "node-$version-win-x64" + $canonical = "https://nodejs.org/dist/$version/$package.zip" + } default { throw "::error title=GF13::Unsupported artifact tool: $Tool" } } if ($url -cne $canonical) { @@ -48,6 +52,8 @@ try { Expand-Archive -Path $archive -DestinationPath $DestDir -Force if ($Tool -eq 'go') { $bin = Join-Path $DestDir 'go/bin' + } elseif ($Tool -eq 'node') { + $bin = Join-Path $DestDir $package } else { $bin = $DestDir } diff --git a/.github/actions/locked-tools/tool-locks.txt b/.github/actions/locked-tools/tool-locks.txt index 59aa5ef..df429fe 100644 --- a/.github/actions/locked-tools/tool-locks.txt +++ b/.github/actions/locked-tools/tool-locks.txt @@ -18,3 +18,4 @@ node v26.12.1 linux/amd64 0fd1eb7770598f52a21995edf6ed35fc690400fa437c2386af3295 node v26.12.1 darwin/arm64 1901d539912c02afa10d1cc126943a8855a7eedebe830119463470db5e6c59e8 https://nodejs.org/dist/v26.12.1/node-v26.12.1-darwin-arm64.tar.gz # Minisign lock ported verbatim from ductone/multipass PR #953 (release-sign-split); producer/reviewer verified it independently. minisign 0.12 darwin/arm64 89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63 https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip +node v26.12.1 windows/amd64 b3efd5c70cba3308ed46e7e699a9b8aac075bbc6b027fcb39c84c31acc25e2b4 https://nodejs.org/dist/v26.12.1/node-v26.12.1-win-x64.zip diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 248fffc..70f3014 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -289,6 +289,9 @@ credentials. Supported tools are `go`, `node`, `syft`, `goreleaser`, duplicate tool names fail closed. Requesting `gon` automatically adds Go, installs/verifies it first, then builds the SHA-checked-out gon source with the existing readonly-module/proxy/checksum-database constraints. +Node is supported on Linux amd64, macOS arm64, and Windows amd64. The Windows +installer verifies the ZIP hash before extraction, checks the nested +`node-v26.12.1-win-x64/node.exe` leaf, and publishes that package directory to PATH. An empty `locks` input is resolved inside the action's prepare step, where the action directory is available, rather than in the caller-context input default. @@ -321,6 +324,7 @@ files. The old `tools/tool-locks.txt` is removed, not copied or mirrored. | go go1.27.3 | windows/amd64 | `f26081f438319757f8a1b46d7d0b2c5ec7583d4038e485e0aa725cfde7885fff` | [Windows toolchain](https://go.dev/dl/go1.27.3.windows-amd64.zip) | | node v26.12.1 | linux/amd64 | `0fd1eb7770598f52a21995edf6ed35fc690400fa437c2386af329519e9143132` | [Linux package](https://nodejs.org/dist/v26.12.1/node-v26.12.1-linux-x64.tar.gz) | | node v26.12.1 | darwin/arm64 | `1901d539912c02afa10d1cc126943a8855a7eedebe830119463470db5e6c59e8` | [Darwin package](https://nodejs.org/dist/v26.12.1/node-v26.12.1-darwin-arm64.tar.gz) | +| node v26.12.1 | windows/amd64 | `b3efd5c70cba3308ed46e7e699a9b8aac075bbc6b027fcb39c84c31acc25e2b4` | [Windows package](https://nodejs.org/dist/v26.12.1/node-v26.12.1-win-x64.zip) | | minisign 0.12 | darwin/arm64 | `89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63` | [macOS ZIP](https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip), ported from [multipass #953](https://github.com/ductone/multipass/pull/953) | The gon fork publishes only an amd64 asset for v0.2.10. Rather than using diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index df1ec13..adfc285 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -32,6 +32,7 @@ def setUp(self) -> None: shutil.copyfile(ACTION / name, self.action_dir / name) shutil.copyfile(ROOT / "scripts" / name, self.scripts / name) shutil.copyfile(ACTION / "locked-tools.sh", self.action_dir / "locked-tools.sh") + shutil.copyfile(ACTION / "install-tool.ps1", self.action_dir / "install-tool.ps1") for name in ("verify-release-checkout.sh", "setup-release-buildx.sh"): shutil.copyfile(ROOT / "scripts" / name, self.scripts / name) self.lock = self.action_dir / "tool-locks.txt" @@ -76,14 +77,17 @@ def archive_member(self, tool: str, platform: str) -> str: if line.startswith("node ") and line.split()[2] == platform) os_name, arch = platform.split("/") node_arch = "x64" if arch == "amd64" else arch + if os_name == "windows": + return f"node-{version}-win-{node_arch}/node.exe" return f"node-{version}-{os_name}-{node_arch}/bin/node" return tool def fixture_download(self, tool: str, platform: str, member: str | None = None, mode: int = 0o755) -> Path: - archive = self.root / ("fixture.zip" if tool == "minisign" else "fixture.tar.gz") + is_zip = tool == "minisign" or (tool == "node" and platform == "windows/amd64") + archive = self.root / ("fixture.zip" if is_zip else "fixture.tar.gz") member = member or self.archive_member(tool, platform) data = b"#!/bin/sh\necho 'binary must not be invoked' >&2\nexit 77\n" - if tool == "minisign": + if is_zip: with zipfile.ZipFile(archive, "w") as zipped: zipped.writestr(member, data) else: @@ -339,6 +343,77 @@ def test_action_node_phases_and_provenance_outputs(self) -> None: self.assertFalse(self.calls.exists()) self.run_script("locked-tools.sh", "outputs", ok=False) + def test_windows_node_zip_and_action_dispatch(self) -> None: + row = next(line.split() for line in self.lock.read_text().splitlines() + if line.startswith("node ") and line.split()[2] == "windows/amd64") + version = row[1] + self.assertEqual(row[4], f"https://nodejs.org/dist/{version}/node-{version}-win-x64.zip") + archive = self.fixture_download("node", "windows/amd64") + member = f"node-{version}-win-x64/node.exe" + with zipfile.ZipFile(archive) as zipped: + self.assertEqual(zipped.namelist(), [member]) + self.assertFalse(zipped.getinfo(member).is_dir()) + self.env.update(TEST_OS="MINGW64_NT", TEST_ARCH="x86_64", LOCKED_TOOLS="node", + LOCKS_INPUT="", ACTION_DIR=str(self.action_dir)) + self.executable("cygpath", '[[ "$1" = -u || "$1" = -w ]]; printf "%s\\n" "$2"\n') + self.executable("pwsh", 'printf "pwsh %s\\n" "$*" >> "$CALLS"\n') + self.run_script("locked-tools.sh", "prepare") + plan = self.phase_outputs() + self.assertEqual(plan["tools"], "node") + self.env.update(TOOL_LOCK_FILE=plan["lock_file"], EXPECTED_LOCK_SHA256=plan["lock_sha256"]) + self.run_script("locked-tools.sh", "install") + self.assertEqual(self.calls.read_text().splitlines(), [ + f"pwsh -NoProfile -File {self.action_dir}/install-tool.ps1 -Tool node -DestDir {self.root / 'dest/node'}" + ]) + self.run_script("locked-tools.sh", "outputs") + self.assertEqual(self.phase_outputs()["node"], version) + self.assertEqual(json.loads(self.phase_outputs()["versions"]), {"node": version}) + + @unittest.skipUnless(os.name == "nt" and shutil.which("pwsh"), "requires native Windows PowerShell") + def test_windows_node_installer_fixture(self) -> None: + original = (ACTION / "tool-locks.txt").read_text() + version = next(line.split()[1] for line in original.splitlines() + if line.startswith("node ") and line.split()[2] == "windows/amd64") + harness = """ +$ErrorActionPreference = 'Stop' +function Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [int]$MaximumRetryCount) + Add-Content -LiteralPath $env:CALLS -Value "download $Uri" + Copy-Item -LiteralPath $env:ARCHIVE -Destination $OutFile +} +& $env:INSTALLER -Tool node -DestDir $env:INSTALL_DEST +""" + for case in ("valid", "bad-hash", "bad-layout", "bad-url"): + with self.subTest(case=case): + self.lock.write_text(original) + member = "unexpected-node/node.exe" if case == "bad-layout" else None + archive = self.fixture_download("node", "windows/amd64", member=member) + if case == "bad-hash": + archive.write_bytes(archive.read_bytes() + b"tampered") + if case == "bad-url": + self.lock.write_text(self.lock.read_text().replace( + f"node-{version}-win-x64.zip", "unexpected-node.zip")) + for path in (self.path_file, self.env_file, self.calls): + path.unlink(missing_ok=True) + dest = self.root / case + env = dict(self.env, TOOL_LOCK_FILE=str(self.lock), + INSTALLER=str(self.action_dir / "install-tool.ps1"), INSTALL_DEST=str(dest)) + result = subprocess.run([shutil.which("pwsh"), "-NoProfile", "-NonInteractive", "-Command", harness], + env=env, text=True, capture_output=True) + if case == "valid": + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + package = dest / f"node-{version}-win-x64" + self.assertTrue((package / "node.exe").is_file()) + self.assertIn(str(package), self.path_file.read_text(encoding="utf-8-sig").splitlines()) + self.assertFalse(self.env_file.exists(), "Node must not publish GOROOT") + else: + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(self.path_file.exists()) + if case in ("bad-hash", "bad-url"): + self.assertFalse(dest.exists(), "must reject before extraction") + if case == "bad-url": + self.assertFalse(self.calls.exists(), "must reject before downloading") + def test_action_empty_lock_input_resolves_in_action_context(self) -> None: self.fixture_download("node", "linux/amd64") self.env.update(LOCKED_TOOLS="node", LOCKS_INPUT="", ACTION_DIR=str(self.action_dir), From 5d95b3e0ed152bfe6df932deed0fabad421eb8d3 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Wed, 21 Oct 2026 23:01:38 +0000 Subject: [PATCH 2/4] ci: exercise Windows Node installer fixture on native runner Run only the self-contained installer fixture on windows-latest with read-only permissions, pinned checkout, and no persisted credentials or release secrets. Co-authored-by: c1-squire-dev[bot] --- .github/workflows/ci.yaml | 19 +++++++++++++++++++ docs/release-workflow.md | 4 ++++ scripts/test-ci-workflow.py | 13 ++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2c99f7e..a937870 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,3 +88,22 @@ jobs: env: SHELLCHECK: /usr/bin/shellcheck run: bash scripts/lint-shell.sh + + windows-installer-tests: + runs-on: windows-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 + with: + persist-credentials: false + - name: Check native test runtimes + shell: pwsh + run: | + python --version + Get-Command pwsh -CommandType Application -ErrorAction Stop + - name: Test verified Windows Node installer + shell: pwsh + run: python scripts/test-tool-integrity.py IntegrityTests.test_windows_node_installer_fixture diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 70f3014..b04d787 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -518,6 +518,10 @@ duplicate locks, newer or malformed Go requirements, the source-locked gon build gates, bundled-buildx failure, and event-SHA checks with both lightweight and annotated tags. It also checks workflow credential ordering. These are deterministic local fixtures, not deployed macOS or Windows proof. +Repository CI additionally runs the single Windows Node installer fixture on +`windows-latest`, using real ZIP/hash/extraction/leaf checks and a shadowed +download command, without release secrets. This is native fixture proof, not a +deployed Windows release or signing test. Before release adoption, run canaries on `ConductorOne/baton-github-test`: mutate one artifact hash at a time for GoReleaser and Syft and require the diff --git a/scripts/test-ci-workflow.py b/scripts/test-ci-workflow.py index 414b5d0..ae6048b 100755 --- a/scripts/test-ci-workflow.py +++ b/scripts/test-ci-workflow.py @@ -27,7 +27,7 @@ def test_repository_events_jobs_and_read_only_permissions(self): self.assertEqual(pull_paths, set(workflow["on"]["push"]["paths"])) self.assertEqual(workflow["permissions"], {"contents": "read"}) self.assertNotIn("secrets.", json.dumps(workflow)) - self.assertEqual(set(workflow["jobs"]), {"go-tests", "shell-tests", "shellcheck"}) + self.assertEqual(set(workflow["jobs"]), {"go-tests", "shell-tests", "shellcheck", "windows-installer-tests"}) go_job = workflow["jobs"]["go-tests"] self.assertEqual(go_job["env"]["GOTOOLCHAIN"], "local") self.assertEqual([step["run"] for step in go_job["steps"] if "run" in step], @@ -47,6 +47,17 @@ def test_repository_events_jobs_and_read_only_permissions(self): self.assertIn("image-locks", review["jobs"]) self.assertIn("pull_request", review["on"]) + def test_windows_installer_fixture_runs_natively(self): + job = load_yaml(".github/workflows/ci.yaml")["jobs"]["windows-installer-tests"] + self.assertEqual(job["runs-on"], "windows-latest") + self.assertEqual(job["permissions"], {"contents": "read"}) + fixture_steps = [step for step in job["steps"] + if "scripts/test-tool-integrity.py" in step.get("run", "")] + self.assertEqual(len(fixture_steps), 1) + self.assertEqual(fixture_steps[0]["shell"], "pwsh") + self.assertEqual(fixture_steps[0]["run"], + "python scripts/test-tool-integrity.py IntegrityTests.test_windows_node_installer_fixture") + def test_reusable_verify_contains_only_caller_jobs(self): workflow = load_yaml(".github/workflows/verify.yaml") self.assertEqual(set(workflow["on"]), {"workflow_call"}) From 12103a792b50f5bdd44b32b3d5fcae336a9455f2 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Wed, 21 Oct 2026 23:04:05 +0000 Subject: [PATCH 3/4] test: compare Windows installer PATH entries by directory identity Accept equivalent short and long Windows temp paths without weakening archive hash, extraction, leaf, or negative-control checks. Co-authored-by: c1-squire-dev[bot] --- scripts/test-tool-integrity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index adfc285..86fe01f 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -404,7 +404,9 @@ def test_windows_node_installer_fixture(self) -> None: self.assertEqual(result.returncode, 0, result.stdout + result.stderr) package = dest / f"node-{version}-win-x64" self.assertTrue((package / "node.exe").is_file()) - self.assertIn(str(package), self.path_file.read_text(encoding="utf-8-sig").splitlines()) + # PowerShell may expand the Windows 8.3 temporary-directory alias. + published = self.path_file.read_text(encoding="utf-8-sig").splitlines() + self.assertTrue(any(Path(path).samefile(package) for path in published)) self.assertFalse(self.env_file.exists(), "Node must not publish GOROOT") else: self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) From a75968fb7b12fd28c0835c2de80bf60cb6a39e65 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 00:24:23 +0000 Subject: [PATCH 4/4] fix: add verified linux arm64 Go toolchain lock Pin Go 1.27.3 linux/arm64 from official release metadata and a real archive download. Exercise both uname aliases and hash-before-extraction failure using the existing architecture-generic installer. Co-authored-by: c1-squire-dev[bot] --- .github/actions/locked-tools/tool-locks.txt | 1 + docs/release-workflow.md | 1 + scripts/test-tool-integrity.py | 29 +++++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/.github/actions/locked-tools/tool-locks.txt b/.github/actions/locked-tools/tool-locks.txt index df429fe..f7470ef 100644 --- a/.github/actions/locked-tools/tool-locks.txt +++ b/.github/actions/locked-tools/tool-locks.txt @@ -19,3 +19,4 @@ node v26.12.1 darwin/arm64 1901d539912c02afa10d1cc126943a8855a7eedebe83011946347 # Minisign lock ported verbatim from ductone/multipass PR #953 (release-sign-split); producer/reviewer verified it independently. minisign 0.12 darwin/arm64 89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63 https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip node v26.12.1 windows/amd64 b3efd5c70cba3308ed46e7e699a9b8aac075bbc6b027fcb39c84c31acc25e2b4 https://nodejs.org/dist/v26.12.1/node-v26.12.1-win-x64.zip +go go1.27.3 linux/arm64 a06e99309dbcbdca1a8ccd2282c3ee146e55930886e26b4f1193269587165f49 https://go.dev/dl/go1.27.3.linux-arm64.tar.gz diff --git a/docs/release-workflow.md b/docs/release-workflow.md index b04d787..1b1183c 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -320,6 +320,7 @@ files. The old `tools/tool-locks.txt` is removed, not copied or mirrored. | syft v1.42.3 | darwin/arm64 | `d71ee7db2be0fe2e96f679fd9d69ef04274cc86c8604707797080a21070b3f32` | [Darwin archive](https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_darwin_arm64.tar.gz) | | syft v1.42.3 | windows/amd64 | `e1b9f4945aa64c2b34970bec617623d7f803d0661b48a50b966768b363322e2d` | [Windows archive](https://github.com/anchore/syft/releases/download/v1.42.3/syft_1.42.3_windows_amd64.zip) | | go go1.27.3 | linux/amd64 | `65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40` | [Linux toolchain](https://go.dev/dl/go1.27.3.linux-amd64.tar.gz) | +| go go1.27.3 | linux/arm64 | `a06e99309dbcbdca1a8ccd2282c3ee146e55930886e26b4f1193269587165f49` | [Linux arm64 toolchain](https://go.dev/dl/go1.27.3.linux-arm64.tar.gz) | | go go1.27.3 | darwin/arm64 | `dd3908cf20959d798c1e06ebe5b72213f0505ce01373b40ce3110975a6cd99c2` | [Darwin toolchain](https://go.dev/dl/go1.27.3.darwin-arm64.tar.gz) | | go go1.27.3 | windows/amd64 | `f26081f438319757f8a1b46d7d0b2c5ec7583d4038e485e0aa725cfde7885fff` | [Windows toolchain](https://go.dev/dl/go1.27.3.windows-amd64.zip) | | node v26.12.1 | linux/amd64 | `0fd1eb7770598f52a21995edf6ed35fc690400fa437c2386af329519e9143132` | [Linux package](https://nodejs.org/dist/v26.12.1/node-v26.12.1-linux-x64.tar.gz) | diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index 86fe01f..40f757e 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -126,6 +126,35 @@ def test_verified_install_never_invokes_binary(self) -> None: if tool == "go": self.assertIn(f"GOROOT={dest}/go", self.env_file.read_text()) + def test_go_linux_arm64_verified_install(self) -> None: + for machine in ("aarch64", "arm64"): + with self.subTest(machine=machine): + self.fixture_download("go", "linux/arm64") + self.env.update(TEST_OS="Linux", TEST_ARCH=machine) + dest = self.root / f"go-linux-{machine}" + self.run_script("install-tool.sh", "go", dest) + binary = dest / "go/bin/go" + self.assertTrue(binary.is_file()) + self.assertTrue(os.access(binary, os.X_OK)) + self.assertIn(str(binary.parent), self.path_file.read_text().splitlines()) + self.assertIn(f"GOROOT={dest}/go", self.env_file.read_text()) + row = next(line.split() for line in self.lock.read_text().splitlines() + if line.startswith("go ") and line.split()[2] == "linux/arm64") + self.assertEqual(row[4], f"https://go.dev/dl/{row[1]}.linux-arm64.tar.gz") + self.assertIn(row[4], self.calls.read_text()) + + def test_go_linux_arm64_hash_mismatch_precedes_extraction(self) -> None: + archive = self.fixture_download("go", "linux/arm64") + archive.write_bytes(archive.read_bytes() + b"tampered") + self.env.update(TEST_OS="Linux", TEST_ARCH="aarch64") + dest = self.root / "bad-go-arm64" + result = self.run_script("install-tool.sh", "go", dest, ok=False) + self.assertIn("::error title=GF13::go sha256 mismatch", result.stderr) + self.assertNotIn("tar\n", self.calls.read_text()) + self.assertFalse(dest.exists()) + self.assertFalse(self.path_file.exists()) + self.assertFalse(self.env_file.exists()) + def test_mismatch_fails_before_extraction_or_path_update(self) -> None: for tool in ("syft", "goreleaser", "go", "node"): with self.subTest(tool=tool):