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
6 changes: 6 additions & 0 deletions .github/actions/locked-tools/install-tool.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions .github/actions/locked-tools/tool-locks.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ 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
go go1.27.3 linux/arm64 a06e99309dbcbdca1a8ccd2282c3ee146e55930886e26b4f1193269587165f49 https://go.dev/dl/go1.27.3.linux-arm64.tar.gz

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: This go go1.27.3 linux/arm64 lock goes beyond the PR's stated scope (Windows Node), and the PR body gives no provenance for this hash. The Windows Node row does have provenance. The installer, URL canonicalization, and tests look correct, but this repo can't verify the hash itself. Please add a note on where it came from (e.g. the go.dev/dl JSON manifest), or split it into its own PR. (confidence: medium)

19 changes: 19 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions docs/release-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -317,10 +320,12 @@ 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) |
| 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
Expand Down Expand Up @@ -514,6 +519,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
Expand Down
13 changes: 12 additions & 1 deletion scripts/test-ci-workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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"})
Expand Down
110 changes: 108 additions & 2 deletions scripts/test-tool-integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -122,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):
Expand Down Expand Up @@ -339,6 +372,79 @@ 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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (medium confidence): No workflow in .github/workflows/ runs this suite on a Windows runner, so this test always skips in CI. That means the new node branch in install-tool.ps1 (canonical-URL pin, hash before extract, nested node.exe leaf, PATH publication) never actually runs. The dispatch test above stubs pwsh. If a later edit breaks the Windows node path, it would first show up in a consumer's release-windows job. Consider adding a small windows-latest job that runs these negative controls.

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())
# 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)
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),
Expand Down
Loading