From 23cb8aec6ecd09cde297185caa6a0615053dbbd1 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 01:06:51 +0000 Subject: [PATCH 1/3] fix: bind Go gates to locked-tools output identity Expose the verified absolute lock path alongside versions and SHA256, keep single-version parsing in prepare, and require Go gates to consume expected version/path/hash through the environment. Retain deprecated prepare-backed adapters for current main workflow calls. Co-authored-by: c1-squire-dev[bot] --- .github/actions/locked-tools/README.md | 96 +++++++++++ .github/actions/locked-tools/action.yml | 9 + .github/actions/locked-tools/gon-source.sh | 12 +- .github/actions/locked-tools/locked-tools.sh | 21 ++- .../actions/locked-tools/verify-locked-go.sh | 127 +++++++++----- docs/release-workflow.md | 27 ++- scripts/test-tool-integrity.py | 158 +++++++++++++++++- scripts/verify-locked-go.sh | 7 +- 8 files changed, 395 insertions(+), 62 deletions(-) create mode 100644 .github/actions/locked-tools/README.md diff --git a/.github/actions/locked-tools/README.md b/.github/actions/locked-tools/README.md new file mode 100644 index 0000000..22af220 --- /dev/null +++ b/.github/actions/locked-tools/README.md @@ -0,0 +1,96 @@ +# Locked release tools + +Install checksum-verified release tools before configuring release credentials: + +```yaml +- uses: ConductorOne/github-workflows/.github/actions/locked-tools@ + id: tools + with: + tools: go,node +``` + +The action owns the version/URL/checksum reader. It resolves its canonical +`tool-locks.txt` from the action directory, not from a caller checkout, verifies +artifacts before extraction, and checks each expected executable leaf before +publishing PATH. Requesting `gon` also installs Go first. + +## Inputs and outputs + +- `tools`: required comma-separated tool names. Unknown/duplicate tools and + missing platform locks fail closed. +- `locks`: optional reviewed lock override. Empty selects the action-local + canonical file; an override is not copied over that canonical file. +- `dest`: installation root, default `${{ runner.temp }}/locked-tools`. + +Outputs: + +| Output | Contract | +| --- | --- | +| `go`, `node`, `syft`, `goreleaser`, `goreleaser-pro`, `gon`, `minisign` | Installed tool version, or source commit for gon; empty when not requested. Go includes its `go` prefix. | +| `versions` | JSON per-tool map of the installed versions/source commits. | +| `lock_sha256` | SHA256 of the exact reviewed lock used throughout preparation, installation and output publication. | +| `lock_path` | Absolute path of that same existing lock file. Action-local by default; the resolved reviewed override when explicitly supplied. Native Windows form on Windows. | + +Prepare exports the resolved path, hash and expected Go version to the later +phases. The action checks the lock hash before each later phase and rejects +changes. When Go is requested, **prepare alone** performs the `sort -u` +single-stable-Go-version sanity across all Go platform rows. The Go gate does +not rediscover versions from a checkout's lock. + +## Go gate environment contract + +After checking out caller/source helpers, wire the installed action's outputs +into the gate; do not derive its expectations from `_workflows/tool-locks.txt` +or any other checkout copy: + +```yaml +- name: Check Go module requirements + shell: bash + env: + EXPECTED_GO_VERSION: ${{ steps.tools.outputs.go }} + EXPECTED_LOCK_SHA256: ${{ steps.tools.outputs.lock_sha256 }} + LOCK_PATH: ${{ steps.tools.outputs.lock_path }} + GOTOOLCHAIN: local + run: bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module _caller/go.mod _workflows/go.mod +``` + +The same environment applies to `verify-locked-go.sh version`. Request `go` +(or `gon`, which adds it) to obtain a nonempty expected Go version. + +All three expectation variables are required. The gate validates their shape, +requires an absolute readable `LOCK_PATH`, and hashes its bytes to assert the +provided identity. It never parses lock versions. Missing/partial expectations, +a changed lock, nonlocal toolchain mode or a runtime Go-version mismatch fail +closed. + +Module mode first rejects missing, duplicate, malformed or too-new `go` +directives. It then checks runtime version equality and runs +`GOTOOLCHAIN=local go list -m` with an explicit module file, readonly/offline +module settings and workspace discovery disabled. The reported GoVersion must +agree with the directive. Neither gate mode can download a Go toolchain. + +## Deprecated compatibility + +**DEPRECATED:** the action-local gate also accepts an explicit positional lock +for existing callers: + +```text +verify-locked-go.sh version +verify-locked-go.sh module [...] +``` + +It delegates version/identity lookup to the action's prepare reader and emits a +deprecation warning; it does not add a second lock parser. Without either a +complete environment contract or that explicit argument, it fails closed. +Partial environment contracts never fall back to positional lookup. + +The transitional `scripts/verify-locked-go.sh` wrapper injects the canonical +lock argument for main's existing no-environment workflow calls (or the reviewed +`TOOL_LOCK_FILE` override). Legacy gon source-build calls use the same adapter. +These adapters intentionally preserve current main until the caller migration +lands; the new action's internal phases already use the environment contract. + +Removal follow-up: once the six release jobs' version/module gates and other +consumers have migrated to action outputs, remove the deprecated wrapper and +positional adapter in a separate PR. No workflow should retain a second +checkout-based Go-version reader. diff --git a/.github/actions/locked-tools/action.yml b/.github/actions/locked-tools/action.yml index 8ed7a84..c1ca867 100644 --- a/.github/actions/locked-tools/action.yml +++ b/.github/actions/locked-tools/action.yml @@ -38,6 +38,9 @@ outputs: lock_sha256: description: SHA256 of the exact lock file used by every phase, for provenance. value: ${{ steps.result.outputs.lock_sha256 }} + lock_path: + description: Absolute path of the verified lock used by this action (action-local by default). + value: ${{ steps.result.outputs.lock_path }} runs: using: composite steps: @@ -65,6 +68,8 @@ runs: env: LOCKED_TOOLS: ${{ steps.prepare.outputs.tools }} TOOL_LOCK_FILE: ${{ steps.prepare.outputs.lock_file }} + LOCK_PATH: ${{ steps.prepare.outputs.lock_path }} + EXPECTED_GO_VERSION: ${{ steps.prepare.outputs.go_version }} LOCKED_TOOLS_DEST: ${{ steps.prepare.outputs.dest }} EXPECTED_LOCK_SHA256: ${{ steps.prepare.outputs.lock_sha256 }} run: bash "$GITHUB_ACTION_PATH/locked-tools.sh" install @@ -75,6 +80,8 @@ runs: env: LOCKED_TOOLS: ${{ steps.prepare.outputs.tools }} TOOL_LOCK_FILE: ${{ steps.prepare.outputs.lock_file }} + LOCK_PATH: ${{ steps.prepare.outputs.lock_path }} + EXPECTED_GO_VERSION: ${{ steps.prepare.outputs.go_version }} LOCKED_TOOLS_DEST: ${{ steps.prepare.outputs.dest }} EXPECTED_LOCK_SHA256: ${{ steps.prepare.outputs.lock_sha256 }} LOCKED_GON_PATH: ${{ steps.prepare.outputs.gon_path }} @@ -90,6 +97,8 @@ runs: env: LOCKED_TOOLS: ${{ steps.prepare.outputs.tools }} TOOL_LOCK_FILE: ${{ steps.prepare.outputs.lock_file }} + LOCK_PATH: ${{ steps.prepare.outputs.lock_path }} + EXPECTED_GO_VERSION: ${{ steps.prepare.outputs.go_version }} LOCKED_TOOLS_DEST: ${{ steps.prepare.outputs.dest }} EXPECTED_LOCK_SHA256: ${{ steps.prepare.outputs.lock_sha256 }} run: bash "$GITHUB_ACTION_PATH/locked-tools.sh" outputs diff --git a/.github/actions/locked-tools/gon-source.sh b/.github/actions/locked-tools/gon-source.sh index 91f6977..145d4b3 100755 --- a/.github/actions/locked-tools/gon-source.sh +++ b/.github/actions/locked-tools/gon-source.sh @@ -8,6 +8,14 @@ if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then echo '::error title=GF13::Expected a single immutable gon source commit lock' >&2 exit 1 fi +verify_go() { + if [ -z "${EXPECTED_GO_VERSION:-}${EXPECTED_LOCK_SHA256:-}${LOCK_PATH:-}" ]; then + # DEPRECATED legacy source-build callers; metadata still comes from prepare. + bash "$script_dir/verify-locked-go.sh" "$1" "$lock" "${@:2}" + else + bash "$script_dir/verify-locked-go.sh" "$@" + fi +} case "${1:-}" in ref) printf 'ref=%s\n' "$commit" >> "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" @@ -28,8 +36,8 @@ case "${1:-}" in echo '::error title=GF13::gon requires readonly modules, proxy.golang.org, and sum.golang.org' >&2 exit 1 fi - bash "$script_dir/verify-locked-go.sh" version - bash "$script_dir/verify-locked-go.sh" module "$source_dir/go.mod" + verify_go version + verify_go module "$source_dir/go.mod" mkdir -p "$dest" dest="$(cd "$dest" && pwd)" # Match the fork's release build directory (the module root has no Go files). diff --git a/.github/actions/locked-tools/locked-tools.sh b/.github/actions/locked-tools/locked-tools.sh index 1723932..e568644 100755 --- a/.github/actions/locked-tools/locked-tools.sh +++ b/.github/actions/locked-tools/locked-tools.sh @@ -50,7 +50,7 @@ esac if [ "$phase" = prepare ]; then lock="${LOCKS_INPUT:-${ACTION_DIR:-$script_dir}/tool-locks.txt}" else - lock="${TOOL_LOCK_FILE:-$script_dir/tool-locks.txt}" + lock="${LOCK_PATH:-${TOOL_LOCK_FILE:-$script_dir/tool-locks.txt}}" fi dest="${LOCKED_TOOLS_DEST:?LOCKED_TOOLS_DEST must be set}" if [[ "$lock" == *$'\n'* || "$lock" == *$'\r'* || "$dest" == *$'\n'* || "$dest" == *$'\r'* ]]; then @@ -85,6 +85,19 @@ for tool in "${tools[@]}"; do [[ "$checksum" =~ ^[0-9a-f]{64}$ && -n "$url" && -z "$extra" ]] || fail "Invalid artifact lock for $tool" fi done +if [ "$phase" = prepare ] && [ "$needs_go" = true ]; then + expected_go_version="$(awk '$1 == "go" { print $2 }' "$lock" | sort -u)" + [[ "$expected_go_version" =~ ^go[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail 'Expected one consistent, stable Go version in the lock' +else + expected_go_version="${EXPECTED_GO_VERSION:-}" +fi +if [ "$phase" != prepare ] && [ "$needs_go" = true ]; then + [[ "$expected_go_version" =~ ^go[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail 'EXPECTED_GO_VERSION must come from preparation' + row="$(lock_row go)" + read -r _ locked_go_version _ <<< "$row" + [ "$locked_go_version" = "$expected_go_version" ] || fail 'Prepared Go version does not match the verified lock' +fi +export EXPECTED_GO_VERSION="$expected_go_version" if command -v sha256sum >/dev/null 2>&1; then lock_sha256="$(sha256sum "$lock" | awk '{print $1}')" else @@ -93,6 +106,9 @@ fi if [ "$phase" != prepare ] && [ "$lock_sha256" != "${EXPECTED_LOCK_SHA256:-}" ]; then fail 'Lock file changed after preparation' fi +lock_path="$lock" +if [ "$os" = windows ]; then lock_path="$(cygpath -w "$lock" | tr -d '\r')"; fi +export LOCK_PATH="$lock_path" mkdir -p "$dest" dest="$(cd "$dest" && pwd)" case "$phase" in @@ -105,6 +121,7 @@ case "$phase" in fi { printf 'tools=%s\nlock_file=%s\ndest=%s\nlock_sha256=%s\n' "$normalized" "$lock" "$dest" "$lock_sha256" + printf 'lock_path=%s\ngo_version=%s\n' "$lock_path" "$expected_go_version" printf 'gon_ref=%s\ngon_path=%s\n' "$gon_ref" "$gon_path" } >> "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" ;; @@ -151,6 +168,6 @@ case "$phase" in separator=, done versions+='}' - printf 'versions=%s\nlock_sha256=%s\n' "$versions" "$lock_sha256" >> "$GITHUB_OUTPUT" + printf 'versions=%s\nlock_sha256=%s\nlock_path=%s\n' "$versions" "$lock_sha256" "$lock_path" >> "$GITHUB_OUTPUT" ;; esac diff --git a/.github/actions/locked-tools/verify-locked-go.sh b/.github/actions/locked-tools/verify-locked-go.sh index e5d0026..2c5b090 100755 --- a/.github/actions/locked-tools/verify-locked-go.sh +++ b/.github/actions/locked-tools/verify-locked-go.sh @@ -1,53 +1,94 @@ #!/usr/bin/env bash -# Separate runtime/version and go.mod checks; never permit toolchain downloads. +# Environment contract: expectations come from locked-tools outputs, never by +# parsing a checkout's lock. DEPRECATED positional lockfile mode delegates to +# the action's prepare reader; remove after all legacy workflow callers migrate. set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -lock="${TOOL_LOCK_FILE:-$script_dir/tool-locks.txt}" -expected="$(awk '$1 == "go" { print $2 }' "$lock" | sort -u)" -if [[ ! "$expected" =~ ^go[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo '::error title=GF13::Expected one consistent, stable Go version in the lock' >&2 - exit 1 +fail() { echo "::error title=GF13::$*" >&2; exit 1; } +mode="${1:-}" +case "$mode" in version|module) shift;; *) fail 'Usage: verify-locked-go.sh version | module [...]';; esac +[ "${GOTOOLCHAIN:-}" = local ] || fail 'GOTOOLCHAIN must be local; automatic toolchain downloads are forbidden' + +if [ -z "${EXPECTED_GO_VERSION:-}${EXPECTED_LOCK_SHA256:-}${LOCK_PATH:-}" ]; then + [ "$#" -gt 0 ] || fail 'EXPECTED_GO_VERSION, EXPECTED_LOCK_SHA256 and LOCK_PATH must be supplied' + legacy_lock="$1"; shift + echo '::warning title=GF13::Positional Go lockfile mode is DEPRECATED; wire locked-tools outputs through the environment' >&2 + legacy_work="$(mktemp -d)" + trap 'rm -rf "$legacy_work"' EXIT + # Preparation is the only owner of Go-version parsing/single-version sanity. + # This compatibility path downloads/installs nothing and publishes no caller outputs. + LOCKED_TOOLS=go LOCKS_INPUT="$legacy_lock" ACTION_DIR="$script_dir" \ + LOCKED_TOOLS_DEST="$legacy_work/unused-install-dir" GITHUB_OUTPUT="$legacy_work/plan" \ + bash "$script_dir/locked-tools.sh" prepare + while IFS='=' read -r name value; do + case "$name" in + go_version) EXPECTED_GO_VERSION="$value" ;; + lock_sha256) EXPECTED_LOCK_SHA256="$value" ;; + lock_path) LOCK_PATH="$value" ;; + esac + done < "$legacy_work/plan" fi -if [ "${GOTOOLCHAIN:-}" != local ]; then - echo '::error title=GF13::GOTOOLCHAIN must be local; automatic toolchain downloads are forbidden' >&2 - exit 1 +expected="${EXPECTED_GO_VERSION:-}" +lock_hash="${EXPECTED_LOCK_SHA256:-}" +lock_path="${LOCK_PATH:-}" +[[ "$expected" =~ ^go[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail 'EXPECTED_GO_VERSION must be one stable goX.Y.Z version from locked-tools' +[[ "$lock_hash" =~ ^[0-9a-f]{64}$ ]] || fail 'EXPECTED_LOCK_SHA256 must be the locked-tools SHA256 output' +[ -n "$lock_path" ] || fail 'LOCK_PATH must be the absolute locked-tools lock_path output' +if [[ "$lock_path" == *$'\n'* || "$lock_path" == *$'\r'* ]]; then fail 'LOCK_PATH must not contain line breaks'; fi +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) lock_path="$(cygpath -u "$lock_path")";; esac +[[ "$lock_path" = /* ]] || fail 'LOCK_PATH must be absolute' +if [ ! -f "$lock_path" ] || [ ! -r "$lock_path" ]; then fail "LOCK_PATH missing or unreadable: $lock_path"; fi +if command -v sha256sum >/dev/null 2>&1; then + actual_hash="$(sha256sum "$lock_path" | awk '{print $1}')" +else + actual_hash="$(shasum -a 256 "$lock_path" | awk '{print $1}')" fi -case "${1:-}" in +[ "$actual_hash" = "$lock_hash" ] || fail 'Lock identity mismatch: LOCK_PATH no longer matches EXPECTED_LOCK_SHA256' +echo "Verified Go lock identity: $lock_hash $lock_path" + +check_version() { + local actual + actual="$(go version)" + echo "$actual" + [ "$(awk '{print $3}' <<< "$actual")" = "$expected" ] || fail "Expected locked Go $expected, got $actual" +} +version_le() { + awk -v required="$1" -v locked="$2" 'BEGIN { + split(required, r, "."); split(locked, l, "."); + for (i = 1; i <= 3; i++) { + if (r[i] + 0 < l[i] + 0) exit 0; + if (r[i] + 0 > l[i] + 0) exit 1; + } + exit 0; + }' +} +case "$mode" in version) - actual="$(go version)" - echo "$actual" - if [ "$(awk '{print $3}' <<< "$actual")" != "$expected" ]; then - echo "::error title=GF13::Expected locked Go $expected, got $actual" >&2 - exit 1 - fi - ;; + [ "$#" -eq 0 ] || fail 'Usage: verify-locked-go.sh version [deprecated-lockfile]' + check_version ;; module) - shift - if [ "$#" -eq 0 ]; then - echo 'Usage: verify-locked-go.sh module [...]' >&2 - exit 1 - fi + [ "$#" -gt 0 ] || fail 'Usage: verify-locked-go.sh module [deprecated-lockfile] [...]' + requirements=() for module in "$@"; do - required="$(awk '{ sub(/\r$/, "") } $1 == "go" { print $2; count++ } END { if (count != 1) exit 1 }' "$module")" || { - echo "::error title=GF13::Cannot read exactly one go directive from $module" >&2 - exit 1 - } - if [[ ! "$required" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then - echo "::error title=GF13::Invalid go directive in $module: $required" >&2 - exit 1 - fi - if ! awk -v required="$required" -v locked="${expected#go}" 'BEGIN { - split(required, r, "."); split(locked, l, "."); - for (i = 1; i <= 3; i++) { - if (r[i] + 0 < l[i] + 0) exit 0; - if (r[i] + 0 > l[i] + 0) exit 1; - } - exit 0; - }'; then - echo "::error title=GF13::$module requires Go $required, newer than locked $expected; update the reviewed lock instead of downloading a toolchain" >&2 - exit 1 - fi + required="$(awk '{ sub(/\r$/, "") } $1 == "go" { print $2; count++ } END { if (count != 1) exit 1 }' "$module")" || \ + fail "Cannot read exactly one go directive from $module" + [[ "$required" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail "Invalid go directive in $module: $required" + version_le "$required" "${expected#go}" || \ + fail "$module requires Go $required, newer than locked $expected; update the reviewed lock instead of downloading a toolchain" + requirements+=("$required") done - ;; - *) echo 'Usage: verify-locked-go.sh version | module [...]' >&2; exit 1 ;; + check_version + index=0 + for module in "$@"; do + listed="$(cd "$(dirname "$module")" && GOTOOLCHAIN=local GOWORK=off GOPROXY=off GOSUMDB=off \ + go list -m -mod=readonly -modfile="$(basename "$module")" -f '{{.GoVersion}}')" || \ + fail "GOTOOLCHAIN=local go list -m failed for $module" + listed="${listed%$'\r'}" + [[ "$listed" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || fail "Invalid GoVersion from go list -m for $module: $listed" + required="${requirements[$index]}" + if ! version_le "$listed" "$required" || ! version_le "$required" "$listed"; then + fail "go list -m version disagrees with the go directive in $module" + fi + index=$((index+1)) + done ;; esac diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 1b1183c..670f009 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -267,10 +267,13 @@ Both installers require the expected binary layout before publishing PATH; missi The workflow checks out its own source using GitHub's `job.workflow_sha` context, eliminating the previous Python package bootstrap. -Go is installed once per job, with `GOTOOLCHAIN=local`. Separate assertions -check `go version` against the lock and reject any caller/workflow `go.mod` -directive newer than the locked toolchain. Module caching from the former -setup action is intentionally dropped. +Go is installed once per job, with `GOTOOLCHAIN=local`. Preferred gate callers +take `EXPECTED_GO_VERSION`, `EXPECTED_LOCK_SHA256`, and `LOCK_PATH` from the +locked-tools action's `go`, `lock_sha256`, and `lock_path` outputs. The gate +hashes the referenced lock to check identity but never parses its versions; +prepare owns the single-version sanity. Version equality and readonly/offline +`GOTOOLCHAIN=local go list -m` checks reject incompatible module requirements. +Module caching from the former setup action is intentionally dropped. The versions below were observed in [release run 38979848074](https://github.com/ConductorOne/baton-github-test/actions/runs/38979848074). Artifact hashes were fetched once from the upstream release checksums and @@ -299,16 +302,24 @@ action directory is available, rather than in the caller-context input default. the caller on Linux, macOS, and Windows. Outputs include each installed tool's version (`gon` reports its source commit), -a `versions` JSON object, and `lock_sha256` for provenance. The action verifies -that the lock bytes have not changed between preparation, installation, and -output publication. Each phase is a one-line script call in the composite. -Standalone helpers honor `TOOL_LOCK_FILE` as the same reviewed-lock override. +a `versions` JSON object, `lock_sha256`, and absolute `lock_path` for provenance. +The path identifies the action-local canonical lock by default, or the actual +reviewed override used. The action verifies that lock bytes have not changed +between preparation, installation, and output publication; internal Go gates +receive the same expected version/path/hash. Each phase is a one-line script +call in the composite. See the [action contract](../.github/actions/locked-tools/README.md). During the consumer transition, `scripts/install-tool.sh`, `install-tool.ps1`, `gon-source.sh`, and `verify-locked-go.sh` are thin forwarding wrappers to the canonical action-local implementations. This keeps this branch's release workflow unchanged and executable without duplicating implementation or lock files. The old `tools/tool-locks.txt` is removed, not copied or mirrored. +The Go gate's positional lockfile mode and the no-environment wrapper are +**DEPRECATED** compatibility adapters: they delegate metadata lookup to action +prepare, keeping current main's workflow calls intact. Remove them in a +follow-up after all six release jobs and other consumers migrate to the +environment contract; missing both environment and an explicit lock argument +fails closed in the action-local gate. | Tool / version | Lock platform | SHA256 or source commit | Committed artifact/source | | --- | --- | --- | --- | diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index 40f757e..edd8380 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -51,6 +51,8 @@ def setUp(self) -> None: TEST_OS="Linux", TEST_ARCH="x86_64", CALLS=str(self.calls), GITHUB_WORKSPACE=str(self.workspace), LOCKED_TOOLS_DEST=str(self.root / "dest")) self.env.pop("TOOL_LOCK_FILE", None) + for name in ("EXPECTED_GO_VERSION", "EXPECTED_LOCK_SHA256", "LOCK_PATH"): + self.env.pop(name, None) def executable(self, name: str, content: str) -> None: path = self.bin / name @@ -82,11 +84,13 @@ def archive_member(self, tool: str, platform: str) -> str: 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: + def fixture_download(self, tool: str, platform: str, member: str | None = None, + mode: int = 0o755, data: bytes | None = None) -> Path: 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 data is None: + data = b"#!/bin/sh\necho 'binary must not be invoked' >&2\nexit 77\n" if is_zip: with zipfile.ZipFile(archive, "w") as zipped: zipped.writestr(member, data) @@ -215,11 +219,37 @@ def test_node_stale_url_and_duplicate_locks_fail_before_network(self) -> None: self.run_script("install-tool.sh", "node", self.root / "dest", ok=False) self.assertFalse(self.calls.exists()) + def go_contract(self, version: str = "go1.27.3", lock: Path | None = None) -> None: + lock = lock or self.lock + self.env.update(EXPECTED_GO_VERSION=version, LOCK_PATH=str(lock.resolve()), + EXPECTED_LOCK_SHA256=hashlib.sha256(lock.read_bytes()).hexdigest()) + + def mock_go_gate(self) -> None: + self.executable("go", r''' +printf 'go %s\n' "$*" >> "$CALLS" +case "$1" in + version) echo "go version ${TEST_GO_VERSION:-go1.27.3} linux/arm64" ;; + list) + [ "$2" = -m ] && [ "$GOTOOLCHAIN" = local ] && [ "$GOWORK" = off ] + [ "$GOPROXY" = off ] && [ "$GOSUMDB" = off ] + [ "${TEST_GO_LIST_FAIL:-false}" != true ] || exit 43 + module= + for argument in "$@"; do + case "$argument" in -modfile=*) module="${argument#*=}";; esac + done + if [ -n "${TEST_GO_LIST_VERSION:-}" ]; then echo "$TEST_GO_LIST_VERSION" + else awk '{ sub(/\r$/, "") } $1 == "go" { print $2 }' "$module"; fi ;; + *) exit 99 ;; +esac +''') + def test_go_version_and_module_gates(self) -> None: - self.executable("go", 'echo "go $*" >> "$CALLS"\necho "go version ${TEST_GO_VERSION:-go1.27.3} linux/arm64"\n') + self.go_contract() + self.mock_go_gate() self.run_script("verify-locked-go.sh", "version") self.env["TEST_GO_VERSION"] = "go1.27.4" self.run_script("verify-locked-go.sh", "version", ok=False) + self.env.pop("TEST_GO_VERSION") self.calls.unlink() module = self.root / "go.mod" for version in ("1.13", "1.27", "1.27.3"): @@ -227,17 +257,88 @@ def test_go_version_and_module_gates(self) -> None: self.run_script("verify-locked-go.sh", "module", module) module.write_bytes(b"module example.com/fixture\r\n\r\ngo 1.27.3\r\n") self.run_script("verify-locked-go.sh", "module", module) + self.assertEqual(self.calls.read_text().count("go list -m"), 4) + self.calls.unlink() module.write_bytes(b"module example.com/fixture\r\n\r\ngo 1.27.4\r\n") result = self.run_script("verify-locked-go.sh", "module", module, ok=False) self.assertIn(f"::error title=GF13::{module} requires Go 1.27.4, newer than locked go1.27.3", result.stderr) for directive in ("go 1.27.4", "go 1.28", "go 2.0", "go 1.28rc1", "", "go 1.27\ngo 1.26"): module.write_text(f"module example.com/fixture\n\n{directive}\n") self.run_script("verify-locked-go.sh", "module", module, ok=False) - self.assertFalse(self.calls.exists(), "module gating must not invoke Go or download a toolchain") + self.assertFalse(self.calls.exists(), "invalid directives must fail before invoking Go") + module.write_text("module fixture\ngo 1.27.3\n") + self.env["TEST_GO_VERSION"] = "go1.27.4" + self.run_script("verify-locked-go.sh", "module", module, ok=False) + self.assertNotIn("go list", self.calls.read_text()) + self.env.pop("TEST_GO_VERSION") + self.calls.unlink() self.env["GOTOOLCHAIN"] = "auto" self.run_script("verify-locked-go.sh", "version", ok=False) self.assertFalse(self.calls.exists()) + def test_go_gate_requires_complete_env_and_matching_lock_identity(self) -> None: + self.go_contract() + self.mock_go_gate() + complete = dict(self.env) + module = self.root / "go.mod" + module.write_text("module fixture\ngo 1.27.3\n") + for name in ("EXPECTED_GO_VERSION", "EXPECTED_LOCK_SHA256", "LOCK_PATH"): + with self.subTest(missing=name): + self.env = dict(complete) + self.env.pop(name) + self.run_script("verify-locked-go.sh", "version", ok=False) + self.run_script("verify-locked-go.sh", "module", module, ok=False) + self.assertFalse(self.calls.exists()) + self.env = dict(complete) + for name in ("EXPECTED_GO_VERSION", "EXPECTED_LOCK_SHA256", "LOCK_PATH"): + self.env.pop(name) + self.run_script("verify-locked-go.sh", "version", ok=False) + self.run_script("verify-locked-go.sh", "module", module, ok=False) + self.assertFalse(self.calls.exists()) + for name, value in (("EXPECTED_GO_VERSION", "1.27.3"), + ("EXPECTED_LOCK_SHA256", "0" * 64), + ("LOCK_PATH", "relative/tool-locks.txt"), + ("LOCK_PATH", str(self.root / "missing-lock"))): + with self.subTest(name=name, value=value): + self.env = dict(complete) + self.env[name] = value + self.run_script("verify-locked-go.sh", "version", ok=False) + self.assertFalse(self.calls.exists()) + self.env = dict(complete) + self.lock.write_text(self.lock.read_text() + "# drift\n") + self.run_script("verify-locked-go.sh", "version", ok=False) + self.assertFalse(self.calls.exists()) + + def test_go_gate_env_does_not_parse_versions_from_lock(self) -> None: + self.lock.write_text("opaque identity bytes: not a version manifest\n") + self.go_contract() + self.mock_go_gate() + self.run_script("verify-locked-go.sh", "version") + self.assertIn("go version", self.calls.read_text()) + + def test_go_list_failure_or_disagreement_fails_closed(self) -> None: + self.go_contract() + self.mock_go_gate() + module = self.root / "go.mod" + module.write_text("module fixture\ngo 1.27.3\n") + self.env["TEST_GO_LIST_FAIL"] = "true" + self.run_script("verify-locked-go.sh", "module", module, ok=False) + self.env.pop("TEST_GO_LIST_FAIL") + self.env["TEST_GO_LIST_VERSION"] = "1.27.2" + self.run_script("verify-locked-go.sh", "module", module, ok=False) + + def test_deprecated_positional_go_lock_uses_prepare_reader(self) -> None: + self.mock_go_gate() + result = self.run_script("verify-locked-go.sh", "version", self.lock) + self.assertIn("DEPRECATED", result.stderr) + module = self.root / "go.mod" + module.write_text("module fixture\ngo 1.27.3\n") + self.run_script("verify-locked-go.sh", "module", self.lock, module) + self.lock.write_text(self.lock.read_text().replace("go go1.27.3 darwin/arm64", "go go1.27.4 darwin/arm64")) + self.calls.unlink() + self.run_script("verify-locked-go.sh", "version", self.lock, ok=False) + self.assertFalse(self.calls.exists(), "prepare must reject inconsistent versions before Go runs") + def git(self, *args: str) -> str: return subprocess.check_output(["git", "-C", str(self.repo), *args], text=True, stderr=subprocess.DEVNULL).strip() @@ -276,7 +377,7 @@ def test_gon_source_gates_and_build(self) -> None: self.run_script("gon-source.sh", "ref") self.assertEqual(self.output_file.read_text(), f"ref={commit}\n") self.env.update(GOFLAGS="-mod=readonly", GOPROXY="https://proxy.golang.org", GOSUMDB="sum.golang.org") - self.executable("go", 'echo "go $*" >> "$CALLS"\nif [ "$1" = version ]; then echo "go version go1.27.3 linux/arm64"; else [[ "$PWD" == */cmd/gon ]]; [ "$1 $2 $3" = "build -trimpath -o" ]; printf fixture > "$4"; fi\n') + self.executable("go", 'echo "go $*" >> "$CALLS"\ncase "$1" in version) echo "go version go1.27.3 linux/arm64";; list) awk \'$1 == "go" { print $2 }\' go.mod;; build) [[ "$PWD" == */cmd/gon ]]; [ "$1 $2 $3" = "build -trimpath -o" ]; printf fixture > "$4";; *) exit 99;; esac\n') module = self.repo / "go.mod" module.write_text("module fixture\ngo 1.13\n") (self.repo / "cmd/gon").mkdir(parents=True) @@ -334,6 +435,7 @@ def test_custom_lock_and_legacy_forwarders(self) -> None: self.assertIn("ref=", self.output_file.read_text()) module = self.root / "go.mod" module.write_bytes(b"module fixture\r\ngo 1.27.3\r\n") + self.mock_go_gate() result = subprocess.run(["bash", str(self.scripts / "verify-locked-go.sh"), "module", str(module)], env=self.env, text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) @@ -346,6 +448,38 @@ def test_custom_lock_and_legacy_forwarders(self) -> None: def phase_outputs(self) -> dict[str, str]: return dict(line.split("=", 1) for line in self.output_file.read_text().splitlines()) + def test_action_go_outputs_feed_environment_gate(self) -> None: + version = next(line.split()[1] for line in self.lock.read_text().splitlines() + if line.startswith("go ")) + binary = f'#!/bin/sh\n[ "$1" = version ] || exit 77\nprintf "go version {version} linux/amd64\\n"\n'.encode() + self.fixture_download("go", "linux/amd64", data=binary) + self.env.update(LOCKED_TOOLS="go", LOCKS_INPUT="", ACTION_DIR=str(self.action_dir)) + self.run_script("locked-tools.sh", "prepare") + plan = self.phase_outputs() + self.assertEqual(plan["go_version"], version) + self.assertTrue(Path(plan["lock_path"]).is_absolute()) + self.assertTrue(Path(plan["lock_path"]).is_file()) + self.env.update(LOCKED_TOOLS=plan["tools"], LOCK_PATH=plan["lock_path"], + EXPECTED_GO_VERSION=plan["go_version"], EXPECTED_LOCK_SHA256=plan["lock_sha256"]) + self.run_script("locked-tools.sh", "install") + self.run_script("locked-tools.sh", "outputs") + outputs = self.phase_outputs() + self.assertEqual(json.loads(outputs["versions"]), {"go": version}) + self.assertEqual(outputs["go"], version) + self.assertEqual(outputs["lock_path"], plan["lock_path"]) + self.assertEqual(outputs["lock_sha256"], plan["lock_sha256"]) + self.env["PATH"] = f"{self.root / 'dest/go/bin'}:{self.env['PATH']}" + self.env["EXPECTED_GO_VERSION"] = outputs["go"] + self.run_script("verify-locked-go.sh", "version") + + def test_action_prepare_owns_single_go_version_sanity(self) -> None: + self.fixture_download("go", "linux/amd64") + self.env.update(LOCKED_TOOLS="go", LOCKS_INPUT="", ACTION_DIR=str(self.action_dir)) + self.lock.write_text(self.lock.read_text().replace("go go1.27.3 darwin/arm64", "go go1.27.4 darwin/arm64")) + self.run_script("locked-tools.sh", "prepare", ok=False) + self.assertFalse(self.calls.exists()) + self.assertFalse(self.path_file.exists()) + def test_action_node_phases_and_provenance_outputs(self) -> None: self.fixture_download("node", "linux/amd64") custom = self.root / "custom locks.txt" @@ -355,6 +489,8 @@ def test_action_node_phases_and_provenance_outputs(self) -> None: plan = self.phase_outputs() expected_hash = hashlib.sha256(custom.read_bytes()).hexdigest() self.assertEqual(plan["lock_sha256"], expected_hash) + self.assertEqual(plan["lock_path"], str(custom.resolve())) + self.assertTrue(Path(plan["lock_path"]).is_file()) self.env.update(LOCKED_TOOLS=plan["tools"], TOOL_LOCK_FILE=plan["lock_file"], LOCKED_TOOLS_DEST=plan["dest"], EXPECTED_LOCK_SHA256=plan["lock_sha256"]) self.run_script("locked-tools.sh", "install") @@ -364,6 +500,7 @@ def test_action_node_phases_and_provenance_outputs(self) -> None: self.assertEqual(outputs["node"], expected_version) self.assertEqual(json.loads(outputs["versions"]), {"node": expected_version}) self.assertEqual(outputs["lock_sha256"], expected_hash) + self.assertEqual(outputs["lock_path"], str(custom.resolve())) self.assertTrue(self.path_file.exists()) custom.write_text(custom.read_text() + "# changed after preparation\n") self.calls.unlink() @@ -452,6 +589,9 @@ def test_action_empty_lock_input_resolves_in_action_context(self) -> None: self.run_script("locked-tools.sh", "prepare") plan = self.phase_outputs() self.assertEqual(plan["lock_file"], str(self.lock)) + self.assertEqual(plan["lock_path"], str(self.lock.resolve())) + self.assertTrue(Path(plan["lock_path"]).is_absolute()) + self.assertTrue(Path(plan["lock_path"]).is_file()) self.env.update(TOOL_LOCK_FILE=plan["lock_file"], EXPECTED_LOCK_SHA256=plan["lock_sha256"]) self.run_script("locked-tools.sh", "install") self.run_script("locked-tools.sh", "outputs") @@ -496,8 +636,14 @@ def test_composite_metadata_and_single_canonical_lock(self) -> None: self.assertRegex(step["uses"], r"@[0-9a-f]{40}$") self.assertEqual(step["with"]["ref"], "${{ steps.prepare.outputs.gon_ref }}") self.assertFalse(step["with"]["persist-credentials"]) - for tool in ("go", "node", "syft", "goreleaser", "goreleaser-pro", "gon", "minisign", "versions", "lock_sha256"): + for tool in ("go", "node", "syft", "goreleaser", "goreleaser-pro", "gon", "minisign", "versions", "lock_sha256", "lock_path"): self.assertIn(tool, action["outputs"]) + self.assertEqual(action["outputs"]["lock_path"]["value"], "${{ steps.result.outputs.lock_path }}") + for step in action["runs"]["steps"]: + if step.get("name") in ("Install verified artifacts", "Build locked gon source", + "Report installed tool versions and lock hash"): + self.assertEqual(step["env"]["LOCK_PATH"], "${{ steps.prepare.outputs.lock_path }}") + self.assertEqual(step["env"]["EXPECTED_GO_VERSION"], "${{ steps.prepare.outputs.go_version }}") def test_workflow_security_wiring(self) -> None: workflow = json.loads(subprocess.check_output(["yq", ".", str(ROOT / ".github/workflows/release.yaml")], text=True)) diff --git a/scripts/verify-locked-go.sh b/scripts/verify-locked-go.sh index 3f473af..b757a71 100755 --- a/scripts/verify-locked-go.sh +++ b/scripts/verify-locked-go.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash -# Transitional entry point; implementation and lock live with the action. +# DEPRECATED compatibility entry point; remove after workflow gates use outputs/env. set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -z "${EXPECTED_GO_VERSION:-}${EXPECTED_LOCK_SHA256:-}${LOCK_PATH:-}" ] && [ "$#" -gt 0 ]; then + mode="$1"; shift + lock="${TOOL_LOCK_FILE:-$script_dir/../.github/actions/locked-tools/tool-locks.txt}" + exec bash "$script_dir/../.github/actions/locked-tools/verify-locked-go.sh" "$mode" "$lock" "$@" +fi exec bash "$script_dir/../.github/actions/locked-tools/verify-locked-go.sh" "$@" From 3d1670e36178450022cc41297b3716b9e5929006 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 01:17:06 +0000 Subject: [PATCH 2/3] refactor: remove deprecated Go gate compatibility Require output-derived version and lock identity in every Go gate. Remove the repository wrapper, positional adapter, gon adapter and their fixtures; retain the strict environment contract and existing install checks. Co-authored-by: c1-squire-dev[bot] --- .github/actions/locked-tools/README.md | 26 ----------------- .github/actions/locked-tools/gon-source.sh | 12 ++------ .../actions/locked-tools/verify-locked-go.sh | 29 +++---------------- docs/release-workflow.md | 16 ++++------ scripts/test-tool-integrity.py | 24 ++++----------- scripts/verify-locked-go.sh | 10 ------- 6 files changed, 16 insertions(+), 101 deletions(-) delete mode 100755 scripts/verify-locked-go.sh diff --git a/.github/actions/locked-tools/README.md b/.github/actions/locked-tools/README.md index 22af220..630a8a0 100644 --- a/.github/actions/locked-tools/README.md +++ b/.github/actions/locked-tools/README.md @@ -68,29 +68,3 @@ directives. It then checks runtime version equality and runs `GOTOOLCHAIN=local go list -m` with an explicit module file, readonly/offline module settings and workspace discovery disabled. The reported GoVersion must agree with the directive. Neither gate mode can download a Go toolchain. - -## Deprecated compatibility - -**DEPRECATED:** the action-local gate also accepts an explicit positional lock -for existing callers: - -```text -verify-locked-go.sh version -verify-locked-go.sh module [...] -``` - -It delegates version/identity lookup to the action's prepare reader and emits a -deprecation warning; it does not add a second lock parser. Without either a -complete environment contract or that explicit argument, it fails closed. -Partial environment contracts never fall back to positional lookup. - -The transitional `scripts/verify-locked-go.sh` wrapper injects the canonical -lock argument for main's existing no-environment workflow calls (or the reviewed -`TOOL_LOCK_FILE` override). Legacy gon source-build calls use the same adapter. -These adapters intentionally preserve current main until the caller migration -lands; the new action's internal phases already use the environment contract. - -Removal follow-up: once the six release jobs' version/module gates and other -consumers have migrated to action outputs, remove the deprecated wrapper and -positional adapter in a separate PR. No workflow should retain a second -checkout-based Go-version reader. diff --git a/.github/actions/locked-tools/gon-source.sh b/.github/actions/locked-tools/gon-source.sh index 145d4b3..91f6977 100755 --- a/.github/actions/locked-tools/gon-source.sh +++ b/.github/actions/locked-tools/gon-source.sh @@ -8,14 +8,6 @@ if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then echo '::error title=GF13::Expected a single immutable gon source commit lock' >&2 exit 1 fi -verify_go() { - if [ -z "${EXPECTED_GO_VERSION:-}${EXPECTED_LOCK_SHA256:-}${LOCK_PATH:-}" ]; then - # DEPRECATED legacy source-build callers; metadata still comes from prepare. - bash "$script_dir/verify-locked-go.sh" "$1" "$lock" "${@:2}" - else - bash "$script_dir/verify-locked-go.sh" "$@" - fi -} case "${1:-}" in ref) printf 'ref=%s\n' "$commit" >> "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" @@ -36,8 +28,8 @@ case "${1:-}" in echo '::error title=GF13::gon requires readonly modules, proxy.golang.org, and sum.golang.org' >&2 exit 1 fi - verify_go version - verify_go module "$source_dir/go.mod" + bash "$script_dir/verify-locked-go.sh" version + bash "$script_dir/verify-locked-go.sh" module "$source_dir/go.mod" mkdir -p "$dest" dest="$(cd "$dest" && pwd)" # Match the fork's release build directory (the module root has no Go files). diff --git a/.github/actions/locked-tools/verify-locked-go.sh b/.github/actions/locked-tools/verify-locked-go.sh index 2c5b090..77b01c6 100755 --- a/.github/actions/locked-tools/verify-locked-go.sh +++ b/.github/actions/locked-tools/verify-locked-go.sh @@ -1,33 +1,12 @@ #!/usr/bin/env bash -# Environment contract: expectations come from locked-tools outputs, never by -# parsing a checkout's lock. DEPRECATED positional lockfile mode delegates to -# the action's prepare reader; remove after all legacy workflow callers migrate. +# Expectations come only from locked-tools outputs through the environment. +# Never parse a checkout's lock or fall back to positional lock arguments. set -euo pipefail -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fail() { echo "::error title=GF13::$*" >&2; exit 1; } mode="${1:-}" case "$mode" in version|module) shift;; *) fail 'Usage: verify-locked-go.sh version | module [...]';; esac [ "${GOTOOLCHAIN:-}" = local ] || fail 'GOTOOLCHAIN must be local; automatic toolchain downloads are forbidden' -if [ -z "${EXPECTED_GO_VERSION:-}${EXPECTED_LOCK_SHA256:-}${LOCK_PATH:-}" ]; then - [ "$#" -gt 0 ] || fail 'EXPECTED_GO_VERSION, EXPECTED_LOCK_SHA256 and LOCK_PATH must be supplied' - legacy_lock="$1"; shift - echo '::warning title=GF13::Positional Go lockfile mode is DEPRECATED; wire locked-tools outputs through the environment' >&2 - legacy_work="$(mktemp -d)" - trap 'rm -rf "$legacy_work"' EXIT - # Preparation is the only owner of Go-version parsing/single-version sanity. - # This compatibility path downloads/installs nothing and publishes no caller outputs. - LOCKED_TOOLS=go LOCKS_INPUT="$legacy_lock" ACTION_DIR="$script_dir" \ - LOCKED_TOOLS_DEST="$legacy_work/unused-install-dir" GITHUB_OUTPUT="$legacy_work/plan" \ - bash "$script_dir/locked-tools.sh" prepare - while IFS='=' read -r name value; do - case "$name" in - go_version) EXPECTED_GO_VERSION="$value" ;; - lock_sha256) EXPECTED_LOCK_SHA256="$value" ;; - lock_path) LOCK_PATH="$value" ;; - esac - done < "$legacy_work/plan" -fi expected="${EXPECTED_GO_VERSION:-}" lock_hash="${EXPECTED_LOCK_SHA256:-}" lock_path="${LOCK_PATH:-}" @@ -64,10 +43,10 @@ version_le() { } case "$mode" in version) - [ "$#" -eq 0 ] || fail 'Usage: verify-locked-go.sh version [deprecated-lockfile]' + [ "$#" -eq 0 ] || fail 'Usage: verify-locked-go.sh version' check_version ;; module) - [ "$#" -gt 0 ] || fail 'Usage: verify-locked-go.sh module [deprecated-lockfile] [...]' + [ "$#" -gt 0 ] || fail 'Usage: verify-locked-go.sh module [...]' requirements=() for module in "$@"; do required="$(awk '{ sub(/\r$/, "") } $1 == "go" { print $2; count++ } END { if (count != 1) exit 1 }' "$module")" || \ diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 670f009..16000a2 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -309,17 +309,11 @@ between preparation, installation, and output publication; internal Go gates receive the same expected version/path/hash. Each phase is a one-line script call in the composite. See the [action contract](../.github/actions/locked-tools/README.md). -During the consumer transition, `scripts/install-tool.sh`, `install-tool.ps1`, -`gon-source.sh`, and `verify-locked-go.sh` are thin forwarding wrappers to the -canonical action-local implementations. This keeps this branch's release -workflow unchanged and executable without duplicating implementation or lock -files. The old `tools/tool-locks.txt` is removed, not copied or mirrored. -The Go gate's positional lockfile mode and the no-environment wrapper are -**DEPRECATED** compatibility adapters: they delegate metadata lookup to action -prepare, keeping current main's workflow calls intact. Remove them in a -follow-up after all six release jobs and other consumers migrate to the -environment contract; missing both environment and an explicit lock argument -fails closed in the action-local gate. +The Go gate is action-local and environment-only; there is no repository-level +wrapper or positional lockfile fallback. Callers must wire the installed action's +version and lock-identity outputs. The main-tip workflow's old wrapper calls are +not executable until the coordinated caller amendment lands; moving-tag +promotion remains blocked until that migration is complete. | Tool / version | Lock platform | SHA256 or source commit | Committed artifact/source | | --- | --- | --- | --- | diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index edd8380..da39f88 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -30,9 +30,10 @@ def setUp(self) -> None: self.action_dir.mkdir(parents=True) for name in ("install-tool.sh", "verify-locked-go.sh", "gon-source.sh"): 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 ("install-tool.sh", "gon-source.sh"): + shutil.copyfile(ROOT / "scripts" / name, self.scripts / name) 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" @@ -294,6 +295,8 @@ def test_go_gate_requires_complete_env_and_matching_lock_identity(self) -> None: self.env.pop(name) self.run_script("verify-locked-go.sh", "version", ok=False) self.run_script("verify-locked-go.sh", "module", module, ok=False) + self.run_script("verify-locked-go.sh", "version", self.lock, ok=False) + self.run_script("verify-locked-go.sh", "module", self.lock, module, ok=False) self.assertFalse(self.calls.exists()) for name, value in (("EXPECTED_GO_VERSION", "1.27.3"), ("EXPECTED_LOCK_SHA256", "0" * 64), @@ -327,18 +330,6 @@ def test_go_list_failure_or_disagreement_fails_closed(self) -> None: self.env["TEST_GO_LIST_VERSION"] = "1.27.2" self.run_script("verify-locked-go.sh", "module", module, ok=False) - def test_deprecated_positional_go_lock_uses_prepare_reader(self) -> None: - self.mock_go_gate() - result = self.run_script("verify-locked-go.sh", "version", self.lock) - self.assertIn("DEPRECATED", result.stderr) - module = self.root / "go.mod" - module.write_text("module fixture\ngo 1.27.3\n") - self.run_script("verify-locked-go.sh", "module", self.lock, module) - self.lock.write_text(self.lock.read_text().replace("go go1.27.3 darwin/arm64", "go go1.27.4 darwin/arm64")) - self.calls.unlink() - self.run_script("verify-locked-go.sh", "version", self.lock, ok=False) - self.assertFalse(self.calls.exists(), "prepare must reject inconsistent versions before Go runs") - def git(self, *args: str) -> str: return subprocess.check_output(["git", "-C", str(self.repo), *args], text=True, stderr=subprocess.DEVNULL).strip() @@ -374,6 +365,7 @@ def test_gon_source_gates_and_build(self) -> None: text = self.lock.read_text() old_commit = next(line.split()[1] for line in text.splitlines() if line.startswith("gon ")) self.lock.write_text(text.replace(old_commit, commit)) + self.go_contract() self.run_script("gon-source.sh", "ref") self.assertEqual(self.output_file.read_text(), f"ref={commit}\n") self.env.update(GOFLAGS="-mod=readonly", GOPROXY="https://proxy.golang.org", GOSUMDB="sum.golang.org") @@ -433,12 +425,6 @@ def test_custom_lock_and_legacy_forwarders(self) -> None: self.assertTrue((self.root / "legacy" / expected_member).is_file()) self.run_script("gon-source.sh", "ref") self.assertIn("ref=", self.output_file.read_text()) - module = self.root / "go.mod" - module.write_bytes(b"module fixture\r\ngo 1.27.3\r\n") - self.mock_go_gate() - result = subprocess.run(["bash", str(self.scripts / "verify-locked-go.sh"), "module", str(module)], - env=self.env, text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stderr) self.output_file.unlink() result = subprocess.run(["bash", str(self.scripts / "gon-source.sh"), "ref"], env=self.env, text=True, capture_output=True) diff --git a/scripts/verify-locked-go.sh b/scripts/verify-locked-go.sh deleted file mode 100755 index b757a71..0000000 --- a/scripts/verify-locked-go.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -# DEPRECATED compatibility entry point; remove after workflow gates use outputs/env. -set -euo pipefail -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [ -z "${EXPECTED_GO_VERSION:-}${EXPECTED_LOCK_SHA256:-}${LOCK_PATH:-}" ] && [ "$#" -gt 0 ]; then - mode="$1"; shift - lock="${TOOL_LOCK_FILE:-$script_dir/../.github/actions/locked-tools/tool-locks.txt}" - exec bash "$script_dir/../.github/actions/locked-tools/verify-locked-go.sh" "$mode" "$lock" "$@" -fi -exec bash "$script_dir/../.github/actions/locked-tools/verify-locked-go.sh" "$@" From 25572259eb0d4f8fe29f5a492c5bd4591e090cea Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Thu, 22 Oct 2026 01:36:29 +0000 Subject: [PATCH 3/3] docs: present release safeguards as public contracts Use neutral fixture and tool provenance, remove process history and stale helper references, and replace opaque diagnostic labels with descriptive names. Refresh the pinned fixture metadata checksum; artifact locks and certificate identities remain unchanged. Co-authored-by: c1-squire-dev[bot] --- .gitattributes | 1 - .github/actions/ca-trust-guard/README.md | 70 +++----- .../ca-trust-guard/fixtures/SHA256SUMS | 2 +- .../fixtures/incident-certs.json | 8 +- .github/actions/locked-tools/gon-source.sh | 6 +- .github/actions/locked-tools/install-tool.ps1 | 14 +- .github/actions/locked-tools/install-tool.sh | 22 +-- .github/actions/locked-tools/locked-tools.sh | 2 +- .github/actions/locked-tools/tool-locks.txt | 8 +- .../actions/locked-tools/verify-locked-go.sh | 2 +- cmd/generate-windows-manifest/main.go | 2 +- docs/release-workflow.md | 149 ++++++------------ scripts/deny-os.ps1 | 2 +- scripts/deny-os.sh | 2 +- scripts/test-tool-integrity.py | 14 +- scripts/verify-release-checkout.sh | 6 +- 16 files changed, 118 insertions(+), 192 deletions(-) diff --git a/.gitattributes b/.gitattributes index 15a2e41..54249ed 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,4 +9,3 @@ scripts/test-ca-trust-guard.sh text eol=lf scripts/install-tool.sh text eol=lf scripts/install-tool.ps1 text eol=lf scripts/gon-source.sh text eol=lf -scripts/verify-locked-go.sh text eol=lf diff --git a/.github/actions/ca-trust-guard/README.md b/.github/actions/ca-trust-guard/README.md index 3ca364c..16c4f91 100644 --- a/.github/actions/ca-trust-guard/README.md +++ b/.github/actions/ca-trust-guard/README.md @@ -2,7 +2,7 @@ ## What it checks -CA Trust Guard packages the gated SH10/v3.11 offline incident-chain probe. +CA Trust Guard measures offline incident chains against runner trust stores. Use it as a job's first step, before checkout or credential setup: ```yaml @@ -49,7 +49,7 @@ only failure of every candidate emits a probe ERROR. The selected instrument emits an `isolation-self-check` / `VERIFIED-DISTRUST` row before the two positive controls. This is instrument-control evidence, not physical-store retirement evidence. Its path and `openssl version` propagate to probe, OS-deny/env records -and summary rows. Action inputs and outputs are unchanged. +and summary rows. **Outputs: none.** Fixture bytes remain SHA-256-pinned with checkout text conversion disabled. Generated native-tool text is a different boundary: Windows `jq.exe` @@ -57,14 +57,11 @@ Generated native-tool text is a different boundary: Windows `jq.exe` The deny leg normalizes generated TSV/fingerprint output and strips trailing CR at path consumption; it does not normalize or waive checksums on fixture bytes. -**Why OpenSSL remains the measurement instrument:** Go 1.27.3 `crypto/x509` -applies the incident SPKI blacklist unconditionally through -`containsDistrustedSPKIHash`, even with explicit roots and historical capture -time. A native `x509.Verify` would reject even `vulnerable-roots.pem`, fail the -positive control, and erase BEFORE/residual distinctions and the R3 retirement -signal (the rejection is `CertificateInvalidError/NoValidChains`, not a missing -issuer). Do not replace the independent instrument with `x509.Verify` as a -cleanup. A regression test preserves this constraint as living documentation. +File-store measurement uses OpenSSL because Go 1.27.3 `crypto/x509` applies the +incident SPKI blacklist unconditionally through `containsDistrustedSPKIHash`, +even with explicit roots and historical capture time. Native `x509.Verify` +rejects `vulnerable-roots.pem` with `CertificateInvalidError/NoValidChains`, +preventing the positive control and BEFORE/AFTER store comparisons. The stdlib-only `cmd/ca-trust-guard` is built using the runner-image Go toolchain with `GOTOOLCHAIN=local`, `GOPROXY=off` and `GOSUMDB=off`. **Only the bootstrap @@ -88,7 +85,7 @@ summarize --mode report|enforce --apply-distrust=true|false --os-deny=true|false summarize also accepts --os-deny-elevation fail|warn (default fail) ``` -`--stores auto` discovers the gated platform stores. Python certifi, Homebrew, +`--stores auto` discovers the platform stores. Python certifi, Homebrew, and Windows Git-Bash OpenSSL discovery subprocesses return **paths only**; these paths do not decide trust. Go drives the same explicit-store OpenSSL measurement for every bundle. SecTrust (`security verify-cert`), Node's effective @@ -102,32 +99,23 @@ same common name but different cross-signs: | Certificate | SHA-1 | Source | | --- | --- | --- | -| emSign ECC Root CA - G3 | `3043FA4FF257DCA0C380EE2E58EA78B23FE6BBC1` | multipass #951 | -| emSign Root TLS CA - G3 | `39D3236E4A609AB6190CBFA1AD02B3CC61DFC2C3` | multipass #951 | -| emSign Root CS CA - G3 | `1F74B6010A219FD9CA058D07AA053D15EFD9300D` | multipass #951 | +| emSign ECC Root CA - G3 | `3043FA4FF257DCA0C380EE2E58EA78B23FE6BBC1` | pinned `vulnerable-roots.pem` | +| emSign Root TLS CA - G3 | `39D3236E4A609AB6190CBFA1AD02B3CC61DFC2C3` | pinned `vulnerable-roots.pem` | +| emSign Root CS CA - G3 | `1F74B6010A219FD9CA058D07AA053D15EFD9300D` | pinned `vulnerable-roots.pem` | | AAA Certificate Services | `D1EB23A46D17D68FD92564C2F1F1601764D8E349` | pinned incident `vulnerable-roots.pem` | | EM DV TLS CA - G3B (emSign cross-sign) | `A99B289464F3B44343270A7A1E910F02370483FE` | pinned `intermediates-1.pem`, first certificate | | EM DV TLS CA - G3B (AAA cross-sign) | `1396BE1372BC93DFA2A433CA964DBA91548A6E57` | pinned `intermediates-2.pem`, first certificate | -AAA was explicitly excluded from #951; it is fixture-derived, not a fingerprint -claimed from that diff. The original incident `roots.json` citation resolves to -this pinned fixture. The four roots are scoped to the incident, not #951's -wider emSign inventory. The two fixture intermediates are denied directly on -macOS and Windows because their keys were compromised; Linux ca-certificates -cannot blacklist intermediates, so root removal is its evidence. +The deny list contains the four roots and two intermediates in the pinned +fixtures. macOS and Windows deny the intermediate certificates directly; +Linux ca-certificates blacklists the roots and verifies their absence from +the compiled bundle. Both fingerprints are checked for every candidate before the first mutation. -Unexpected same-name certificates are refused; authorized cross-signed variants -are distinguished by fingerprint. Linux retains #951's compiled-bundle -verification loop. No package upgrade or online fetch is performed. +Unexpected same-name certificates are refused; cross-signed variants are +distinguished by fingerprint. No package upgrade or online fetch is performed. `bash scripts/deny-os.sh --dry-run` validates candidates and prints mutation commands without executing them or claiming successful distrust. -Credit to [gsilvestrin](https://github.com/gsilvestrin) and -[ductone/multipass#951](https://github.com/ductone/multipass/pull/951) for the -adopted OS-level deny design: per-OS deny mechanics, fingerprint-keyed -refusal-before-mutation, and the Linux verification loop; also for the enforce -post-condition design point of verifying each mitigation where it was applied. - ## Verdict table | Verdict | Meaning | @@ -145,7 +133,7 @@ Known SecTrust explicit-deny outcomes include `kSecTrustResultDeny`, `SecTrustEvaluate result: kSecTrustResultDeny` and other `result: ...Deny...` variants. `CSSMERR_TP_NOT_TRUSTED` remains verified distrust; `CSSMERR_TP_VERIFY_ACTION_FAILED` remains unknown-cause rejection; other failures -remain errors. This preserves the canary-gated explicit-deny extension. +remain errors. ## Inputs @@ -210,24 +198,10 @@ BEFORE baseline; AFTER rows are the actual per-store retirement observations. remove only the incident thumbprints from `Cert:\LocalMachine\Disallowed`. Clean-bundle application also sets global Git CA config and job environment. -Low-priority follow-up: the incident `impacted-intermediates.json` inventory -contains 16 compromised intermediates, but only two PEMs are available in these -pinned fixtures. Extend the deny set to all 16 after obtaining the remaining -PEMs from CT/crt.sh and verifying each inventory SHA-256. This is not silently -claimed as complete intermediate coverage and does not block this guard. - ## Retirement rule -AFTER **per-store** `VERIFIED-DISTRUST` rows are the R3 retirement signal, not -an assertion that every client has been repaired. SecTrust denial and the +AFTER **per-store** `VERIFIED-DISTRUST` rows identify repaired physical stores; +they do not assert that every client is repaired. SecTrust denial and the clean-bundle environment cover different clients. Untouched physical bundles -remain residual until the runner image changes or their specific store is -repaired; environment coverage alone is not physical-store retirement proof. - -Scoped verification: `go vet ./cmd/ca-trust-guard/...`, -`go test ./cmd/ca-trust-guard/...`, `bash scripts/test-deny-os.sh`, -`bash scripts/test-ca-trust-guard.sh`, and shellcheck on the new Bash scripts. -Repository-owned `.github/workflows/ci.yaml` runs Go vet/tests, the script suites, -and ShellCheck on relevant pull requests and pushes to main. The reusable -`verify.yaml` remains caller-scoped; it does not run workflow-repository checks -for connector callers. +remain residual until the runner image or that specific store changes; +environment coverage alone does not establish physical-store distrust. diff --git a/.github/actions/ca-trust-guard/fixtures/SHA256SUMS b/.github/actions/ca-trust-guard/fixtures/SHA256SUMS index c4ef5d0..4b8435b 100644 --- a/.github/actions/ca-trust-guard/fixtures/SHA256SUMS +++ b/.github/actions/ca-trust-guard/fixtures/SHA256SUMS @@ -7,5 +7,5 @@ a32e9d07c2c6a7c8c82002cbe124f192c59a7af517caec209c58e967fa182f78 leaf-2.pem 7fec291ee3087852310d8dc78c7ab7f21220873b17c671edeeb1a68e0a347844 expected-1.json 598e42aa0b94e05ed1a4292f15e51be2017c491ea26d8efb84c8a7c628b45f8c expected-2.json 9c279a9a98d48f92ec67a0380ddaffc7af6014fc32df2703be77e1052e8e454a chains.json -54f1ab7a002b9f410a057ef9b5df82c5db309bcb720d919ed5d42e26e1346a4a incident-certs.json +f8e43fbfc1985bee58002c2865fed7c796b948b27a390fba7e172a8aeff821ba incident-certs.json 3e15c4c950355f53d04624fd6adf0264e0f3e814ce7ce684e652c76d4e5eaa51 isolation-ca.pem diff --git a/.github/actions/ca-trust-guard/fixtures/incident-certs.json b/.github/actions/ca-trust-guard/fixtures/incident-certs.json index dbf0da2..e6531cf 100644 --- a/.github/actions/ca-trust-guard/fixtures/incident-certs.json +++ b/.github/actions/ca-trust-guard/fixtures/incident-certs.json @@ -4,7 +4,7 @@ "name": "emSign ECC Root CA - G3", "sha1": "3043FA4FF257DCA0C380EE2E58EA78B23FE6BBC1", "sha256": "86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B", - "source": "multipass #951; cross-checked against incident fixture vulnerable-roots.pem", + "source": "SHA256SUMS-pinned vulnerable-roots.pem", "kind": "root", "pem": "vulnerable-roots.pem" }, @@ -12,7 +12,7 @@ "name": "emSign Root TLS CA - G3", "sha1": "39D3236E4A609AB6190CBFA1AD02B3CC61DFC2C3", "sha256": "7DD78D5F4F13459A83DFF9ABBBA62EDBAF6F2D102BF257FD712F4D9F2746ED8D", - "source": "multipass #951; cross-checked against incident fixture vulnerable-roots.pem", + "source": "SHA256SUMS-pinned vulnerable-roots.pem", "kind": "root", "pem": "vulnerable-roots.pem" }, @@ -20,7 +20,7 @@ "name": "emSign Root CS CA - G3", "sha1": "1F74B6010A219FD9CA058D07AA053D15EFD9300D", "sha256": "F2EDAAFF3D9B701FB4D14C9F285F8C0BAD90FC3BCC1FA07D47F4718EDABA34A5", - "source": "multipass #951; cross-checked against incident fixture vulnerable-roots.pem", + "source": "SHA256SUMS-pinned vulnerable-roots.pem", "kind": "root", "pem": "vulnerable-roots.pem" }, @@ -28,7 +28,7 @@ "name": "AAA Certificate Services", "sha1": "D1EB23A46D17D68FD92564C2F1F1601764D8E349", "sha256": "D7A7A0FB5D7E2731D771E9484EBCDEF71D5F0C3E0A2948782BC83EE0EA699EF4", - "source": "incident fixture vulnerable-roots.pem (AAA excluded from multipass #951)", + "source": "SHA256SUMS-pinned vulnerable-roots.pem", "kind": "root", "pem": "vulnerable-roots.pem" }, diff --git a/.github/actions/locked-tools/gon-source.sh b/.github/actions/locked-tools/gon-source.sh index 91f6977..90360fe 100755 --- a/.github/actions/locked-tools/gon-source.sh +++ b/.github/actions/locked-tools/gon-source.sh @@ -5,7 +5,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" lock="${TOOL_LOCK_FILE:-$script_dir/tool-locks.txt}" commit="$(awk '$1 == "gon" && $3 == "darwin/*" && $4 == "source" && NF == 4 { print $2; count++ } END { if (count != 1) exit 1 }' "$lock")" if [[ ! "$commit" =~ ^[0-9a-f]{40}$ ]]; then - echo '::error title=GF13::Expected a single immutable gon source commit lock' >&2 + echo '::error title=locked-tools::Expected a single immutable gon source commit lock' >&2 exit 1 fi case "${1:-}" in @@ -20,12 +20,12 @@ case "${1:-}" in source_dir="$2" dest="$3" if [ "$(git -C "$source_dir" rev-parse HEAD)" != "$commit" ]; then - echo '::error title=GF13::gon checkout does not match locked source commit' >&2 + echo '::error title=locked-tools::gon checkout does not match locked source commit' >&2 exit 1 fi # Require the verified Go binary and checksum-authenticated module downloads. if [ "${GOFLAGS:-}" != -mod=readonly ] || [ "${GOPROXY:-}" != https://proxy.golang.org ] || [ "${GOSUMDB:-}" != sum.golang.org ]; then - echo '::error title=GF13::gon requires readonly modules, proxy.golang.org, and sum.golang.org' >&2 + echo '::error title=locked-tools::gon requires readonly modules, proxy.golang.org, and sum.golang.org' >&2 exit 1 fi bash "$script_dir/verify-locked-go.sh" version diff --git a/.github/actions/locked-tools/install-tool.ps1 b/.github/actions/locked-tools/install-tool.ps1 index f95cb40..cab9006 100644 --- a/.github/actions/locked-tools/install-tool.ps1 +++ b/.github/actions/locked-tools/install-tool.ps1 @@ -7,7 +7,7 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest if (-not $IsWindows -or [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'X64') { - throw '::error title=GF13::Unsupported platform (expected windows/amd64)' + throw '::error title=locked-tools::Unsupported platform (expected windows/amd64)' } $lock = if ($env:TOOL_LOCK_FILE) { $env:TOOL_LOCK_FILE } else { Join-Path $PSScriptRoot 'tool-locks.txt' } $rows = @(Get-Content $lock | Where-Object { $_ -notmatch '^\s*(#|$)' } | ForEach-Object { @@ -15,13 +15,13 @@ $rows = @(Get-Content $lock | Where-Object { $_ -notmatch '^\s*(#|$)' } | ForEac if ($fields[0] -eq $Tool -and $fields[2] -eq 'windows/amd64') { ,$fields } }) if ($rows.Count -ne 1 -or $rows[0].Count -ne 5) { - throw "::error title=GF13::Expected exactly one artifact lock for $Tool windows/amd64" + throw "::error title=locked-tools::Expected exactly one artifact lock for $Tool windows/amd64" } $version = $rows[0][1] $expected = $rows[0][3] $url = $rows[0][4] if ($expected -cnotmatch '^[0-9a-f]{64}$') { - throw "::error title=GF13::Invalid sha256 lock for $Tool" + throw "::error title=locked-tools::Invalid sha256 lock for $Tool" } switch ($Tool) { 'go' { $canonical = "https://go.dev/dl/$version.windows-amd64.zip" } @@ -31,10 +31,10 @@ switch ($Tool) { $package = "node-$version-win-x64" $canonical = "https://nodejs.org/dist/$version/$package.zip" } - default { throw "::error title=GF13::Unsupported artifact tool: $Tool" } + default { throw "::error title=locked-tools::Unsupported artifact tool: $Tool" } } if ($url -cne $canonical) { - throw "::error title=GF13::$Tool lock version/platform does not match its URL" + throw "::error title=locked-tools::$Tool lock version/platform does not match its URL" } $workdir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()) New-Item -ItemType Directory -Path $workdir | Out-Null @@ -43,7 +43,7 @@ try { Invoke-WebRequest -Uri $url -OutFile $archive -MaximumRetryCount 3 $actual = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant() if ($actual -cne $expected) { - Write-Host "::error title=GF13::$Tool sha256 mismatch" + Write-Host "::error title=locked-tools::$Tool sha256 mismatch" throw "Expected $expected, got $actual" } # Do not extract anything until the committed hash has matched. @@ -60,7 +60,7 @@ try { $binaryName = if ($Tool -eq 'goreleaser-pro') { 'goreleaser.exe' } else { "$Tool.exe" } $binaryPath = Join-Path $bin $binaryName if (-not (Test-Path -LiteralPath $binaryPath -PathType Leaf)) { - Write-Host "::error title=GF13::$Tool package layout mismatch" + Write-Host "::error title=locked-tools::$Tool package layout mismatch" throw "Expected tool binary not found at $binaryPath" } if ($Tool -eq 'go') { diff --git a/.github/actions/locked-tools/install-tool.sh b/.github/actions/locked-tools/install-tool.sh index 00cd6fb..a32bee2 100755 --- a/.github/actions/locked-tools/install-tool.sh +++ b/.github/actions/locked-tools/install-tool.sh @@ -13,20 +13,20 @@ lock="${TOOL_LOCK_FILE:-$script_dir/tool-locks.txt}" case "$(uname -s)" in Linux) os=linux ;; Darwin) os=darwin ;; - *) echo '::error title=GF13::Unsupported operating system' >&2; exit 1 ;; + *) echo '::error title=locked-tools::Unsupported operating system' >&2; exit 1 ;; esac case "$(uname -m)" in x86_64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; - *) echo '::error title=GF13::Unsupported architecture' >&2; exit 1 ;; + *) echo '::error title=locked-tools::Unsupported architecture' >&2; exit 1 ;; esac row="$(awk -v tool="$tool" -v platform="$os/$arch" '$1 == tool && $3 == platform { print; count++ } END { if (count != 1) exit 1 }' "$lock")" || { - echo "::error title=GF13::Expected exactly one $tool lock for $os/$arch" >&2 + echo "::error title=locked-tools::Expected exactly one $tool lock for $os/$arch" >&2 exit 1 } read -r _ version _ expected url extra <<< "$row" if [[ ! "$expected" =~ ^[0-9a-f]{64}$ || -z "$url" || -n "$extra" ]]; then - echo "::error title=GF13::Invalid artifact lock for $tool" >&2 + echo "::error title=locked-tools::Invalid artifact lock for $tool" >&2 exit 1 fi # A version-only updater must not leave an old URL silently installable. @@ -43,20 +43,20 @@ case "$tool" in ;; minisign) canonical="https://github.com/jedisct1/minisign/releases/download/${version}/minisign-${version}-macos.zip" - [ "$os" = darwin ] || { echo "::error title=GF13::Unsupported $tool platform" >&2; exit 1; } + [ "$os" = darwin ] || { echo "::error title=locked-tools::Unsupported $tool platform" >&2; exit 1; } ;; goreleaser) case "$os/$arch" in linux/amd64) asset=goreleaser_Linux_x86_64.tar.gz ;; darwin/arm64) asset=goreleaser_Darwin_all.tar.gz ;; - *) echo "::error title=GF13::Unsupported $tool platform" >&2; exit 1 ;; + *) echo "::error title=locked-tools::Unsupported $tool platform" >&2; exit 1 ;; esac canonical="https://github.com/goreleaser/goreleaser/releases/download/${version}/${asset}" ;; - *) echo "::error title=GF13::Unsupported artifact tool: $tool" >&2; exit 1 ;; + *) echo "::error title=locked-tools::Unsupported artifact tool: $tool" >&2; exit 1 ;; esac if [ "$url" != "$canonical" ]; then - echo "::error title=GF13::$tool lock version/platform does not match its URL" >&2 + echo "::error title=locked-tools::$tool lock version/platform does not match its URL" >&2 exit 1 fi workdir="$(mktemp -d)" @@ -65,11 +65,11 @@ archive="$workdir/${url##*/}" curl -sSfL --retry 3 -o "$archive" "$url" if [ "$os" = darwin ]; then if ! printf '%s %s\n' "$expected" "$archive" | shasum -a 256 -c -; then - echo "::error title=GF13::$tool sha256 mismatch" >&2 + echo "::error title=locked-tools::$tool sha256 mismatch" >&2 exit 1 fi elif ! printf '%s %s\n' "$expected" "$archive" | sha256sum -c -; then - echo "::error title=GF13::$tool sha256 mismatch" >&2 + echo "::error title=locked-tools::$tool sha256 mismatch" >&2 exit 1 fi # No extraction, installation, or invocation is allowed before verification. @@ -91,7 +91,7 @@ else bin="$dest" fi if [ ! -f "$bin/$tool" ] || [ ! -x "$bin/$tool" ]; then - echo "::error title=GF13::$tool package layout mismatch" >&2 + echo "::error title=locked-tools::$tool package layout mismatch" >&2 exit 1 fi if [ "$tool" = go ]; then diff --git a/.github/actions/locked-tools/locked-tools.sh b/.github/actions/locked-tools/locked-tools.sh index e568644..1162721 100755 --- a/.github/actions/locked-tools/locked-tools.sh +++ b/.github/actions/locked-tools/locked-tools.sh @@ -2,7 +2,7 @@ # Composite-action glue. Artifact verification remains in the gated installers. set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -fail() { echo "::error title=GF13::$*" >&2; exit 1; } +fail() { echo "::error title=locked-tools::$*" >&2; exit 1; } phase="${1:-}" case "$phase" in prepare|install|gon|outputs) ;; *) fail 'Unknown locked-tools phase' ;; esac request="${LOCKED_TOOLS:-}" diff --git a/.github/actions/locked-tools/tool-locks.txt b/.github/actions/locked-tools/tool-locks.txt index f7470ef..0e364da 100644 --- a/.github/actions/locked-tools/tool-locks.txt +++ b/.github/actions/locked-tools/tool-locks.txt @@ -1,7 +1,7 @@ # Artifact: / # gon source: darwin/* source (checkout by object ID, build with locked Go). -# Versions observed in baton-github-test release run 38979848074; Go hashes from go.dev/dl/?mode=json. -# Artifact hashes fetched once from each release's checksums.txt (Syft: syft_1.42.3_checksums.txt). +# Go toolchain checksums: https://go.dev/dl/?mode=json +# Artifact URLs and SHA256 values identify upstream release archives. goreleaser v2.13.3 linux/amd64 4b66f2f78f78561330350651ade557b70328664718490f37834749073af21d20 https://github.com/goreleaser/goreleaser/releases/download/v2.13.3/goreleaser_Linux_x86_64.tar.gz goreleaser v2.13.3 darwin/arm64 5516c37779efb3935d5b213cda3b0b9025ae94ddbcb51df6919acbcdef4194b0 https://github.com/goreleaser/goreleaser/releases/download/v2.13.3/goreleaser_Darwin_all.tar.gz goreleaser-pro v2.13.3 windows/amd64 8f65fca431ae6aa083805c9287d4b98b7937d54649712f1185b0590ae02001de https://github.com/goreleaser/goreleaser-pro/releases/download/v2.13.3/goreleaser-pro_Windows_x86_64.zip @@ -13,10 +13,10 @@ syft v1.42.3 windows/amd64 e1b9f4945aa64c2b34970bec617623d7f803d0661b48a50b96676 go go1.27.3 linux/amd64 65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40 https://go.dev/dl/go1.27.3.linux-amd64.tar.gz go go1.27.3 darwin/arm64 dd3908cf20959d798c1e06ebe5b72213f0505ce01373b40ce3110975a6cd99c2 https://go.dev/dl/go1.27.3.darwin-arm64.tar.gz go go1.27.3 windows/amd64 f26081f438319757f8a1b46d7d0b2c5ec7583d4038e485e0aa725cfde7885fff https://go.dev/dl/go1.27.3.windows-amd64.zip -# Node is the approved uniform patched line; hashes from nodejs.org/dist/v26.12.1/SHASUMS256.txt. +# Node checksums: https://nodejs.org/dist/v26.12.1/SHASUMS256.txt node v26.12.1 linux/amd64 0fd1eb7770598f52a21995edf6ed35fc690400fa437c2386af329519e9143132 https://nodejs.org/dist/v26.12.1/node-v26.12.1-linux-x64.tar.gz 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 macOS ZIP uses the upstream release URL and SHA256 below. 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/.github/actions/locked-tools/verify-locked-go.sh b/.github/actions/locked-tools/verify-locked-go.sh index 77b01c6..6a1bb04 100755 --- a/.github/actions/locked-tools/verify-locked-go.sh +++ b/.github/actions/locked-tools/verify-locked-go.sh @@ -2,7 +2,7 @@ # Expectations come only from locked-tools outputs through the environment. # Never parse a checkout's lock or fall back to positional lock arguments. set -euo pipefail -fail() { echo "::error title=GF13::$*" >&2; exit 1; } +fail() { echo "::error title=locked-tools::$*" >&2; exit 1; } mode="${1:-}" case "$mode" in version|module) shift;; *) fail 'Usage: verify-locked-go.sh version | module [...]';; esac [ "${GOTOOLCHAIN:-}" = local ] || fail 'GOTOOLCHAIN must be local; automatic toolchain downloads are forbidden' diff --git a/cmd/generate-windows-manifest/main.go b/cmd/generate-windows-manifest/main.go index d17457c..784cf4b 100644 --- a/cmd/generate-windows-manifest/main.go +++ b/cmd/generate-windows-manifest/main.go @@ -93,7 +93,7 @@ func main() { } // MSI uses key "windows-amd64-msi" - // MSI has cosign signatures and attestations; Azure Trusted Signing (Windows code signing) planned for Stage 2 + // MSI assets have cosign signatures and attestations, not Authenticode signatures. assets["windows-amd64-msi"] = asset fmt.Fprintf(os.Stderr, "✅ Added MSI asset: windows-amd64-msi -> %s\n", filename) } diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 16000a2..eeb70be 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -151,14 +151,11 @@ Post-release validation (non-blocking): The default Dockerfile templates select immutable multi-architecture base-image indexes. Buildx selects the target platform from each index; the Lambda build continues to select Linux arm64. `tools/image-locks.txt` records each image -reference, index digest, and UTC resolution date. The helper-image locks are -consumed by the companion release-workflow change that pins QEMU and the -docker-container BuildKit driver. -The companion release-workflow change is now merged, so the helper-image locks -and release-workflow/setup-script Renovate managers are active. Tool-version -proposal managers read `.github/actions/locked-tools/tool-locks.txt`. +reference, index digest, and UTC resolution date. QEMU and the docker-container +BuildKit driver use pinned helper-image locks. Renovate managers track these +image references and `.github/actions/locked-tools/tool-locks.txt`. -Resolved on **2026-10-21** with `crane digest `: +Committed image references and digests: | Image reference / lock line or distroless platform | Index / platform manifest digest | | --- | --- | @@ -173,21 +170,11 @@ Resolved on **2026-10-21** with `crane digest `: | `tonistiigi/binfmt:latest` | `sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0` | | `moby/buildkit:buildx-stable-1` | `sha256:cec9f139f45e93c5c69c60f8b07cfad9f43f4ef6b6a6cd917527fea5ff2e3dea` | -Distroless intentionally sets image-config creation times to the Unix epoch. -Its registry's [manifest metadata](https://gcr.io/v2/distroless/static-debian13/tags/list) -records this index's `timeUploadedMs` as `1792595040699` -(**2026-10-21 15:04:00.699 UTC**), after the required -**2026-10-20 06:04 UTC** cutoff for the patched CA bundle -([distroless #2215](https://github.com/GoogleContainerTools/distroless/pull/2215)). -Do not use the reproducible config timestamp to infer publication time. - -The Lambda arm64 config was created on 2026-10-19 at 23:50:49 UTC, before that -cutoff. AL2023's unpatched `ca-certificates` bundle -([Amazon Linux 2023 #1147](https://github.com/amazonlinux/amazon-linux-2023/issues/1147)) -does not block Go **1.27.3 or newer** binaries; it is not a claim that the base -image's bundle is patched. Binfmt's index creation annotation is -2026-06-08 09:32:02 UTC, and BuildKit's is 2026-09-30 15:17:55 UTC; both predate -the cutoff, which applies to the distroless runtime base, not these helpers. +Distroless image-config creation times use the Unix epoch for reproducibility; +registry manifest metadata identifies publication time. The runtime base uses +the pinned Distroless index. Lambda uses the pinned AL2023 base; its certificate +bundle is independent of the Go 1.27.3-or-newer runtime's incident-key distrust. +Helper-image digest pins identify the exact QEMU and BuildKit code used by builds. `scripts/check-image-locks.sh` checks repository Dockerfile `FROM` declarations (including documented examples), helper driver image assignments, and YAML image @@ -208,25 +195,16 @@ Tagless QEMU and BuildKit execution references are tracked against explicit `latest` and `buildx-stable-1` update channels, respectively; refreshes replace their digests without adding mutable tags to the execution references. -Neither Dependabot nor Renovate natively refreshes this repository's arbitrary -artifact SHA-256 lock format. Regex managers instead open **draft version-proposal -PRs** for GoReleaser OSS/Pro, Syft, and Go. Gon is built from a locked source -commit: the `# gon-release ` marker and its following commit lock are tracked -against `ConductorOne/gon` tags. These proposals are not ready-to-merge locks. - -Before merging an update: - -1. For tools, refresh every affected platform's artifact URL and SHA-256 from the - reviewed upstream release assets. Keep the version, URL, and checksum together; - installers reject a version/URL mismatch. For gon, review the source commit - against its release tag and exercise the source build. -2. For images, resolve the multi-arch index, inspect every architecture with - `crane manifest`, and record the resolution date and build/publication evidence. - Keep templates, helper inputs, lock entries, and this table in sync. Distroless - must remain newer than the CA-bundle cutoff above. -3. Run the image-lock check and relevant installer negative controls, then a - canary release and artifact/base-layer verification. A bot PR or green fixture - test is not evidence of a successful release. +Neither Dependabot nor Renovate refreshes this repository's artifact SHA256 +values automatically. Regex managers open draft version proposals for the +locked tools; each artifact entry still requires a matching URL and checksum. +Gon's `# gon-release ` marker and source commit are tracked together +against `ConductorOne/gon` tags. + +Tool lock entries keep each platform's version, artifact URL and SHA256 together; +installers reject a version/URL mismatch. Gon identifies source by immutable +commit. Image lock entries, templates and helper inputs use the same immutable +index digest, and the lock table records each selected reference and digest. ### Immutable S3 Release Objects @@ -257,31 +235,28 @@ Every GoReleaser invocation receives `GORELEASER_CURRENT_TAG` from the workflow ### Locked Release Tools -`.github/actions/locked-tools/tool-locks.txt` is the single source of truth for tool versions, artifact -URLs, and committed SHA256 values. The Bash and PowerShell `install-tool` -helpers fetch only that URL, verify the hash **before extraction**, and add the -verified directory to the next step's PATH. They never run the downloaded -binary. All tool installations, including cosign and the native gon build, -finish before Apple certificates, release secrets, or AWS credentials are read. -Both installers require the expected binary layout before publishing PATH; missing tools never silently fall back to runner-bundled binaries. -The workflow checks out its own source using GitHub's `job.workflow_sha` -context, eliminating the previous Python package bootstrap. - -Go is installed once per job, with `GOTOOLCHAIN=local`. Preferred gate callers -take `EXPECTED_GO_VERSION`, `EXPECTED_LOCK_SHA256`, and `LOCK_PATH` from the -locked-tools action's `go`, `lock_sha256`, and `lock_path` outputs. The gate +`.github/actions/locked-tools/tool-locks.txt` identifies tool versions, artifact +URLs and committed SHA256 values. The action-local Bash and PowerShell installers +fetch the selected URL, verify its hash **before extraction**, and check the +expected executable leaf before publishing PATH. They do not execute downloaded +binaries during extraction. The action's Go assertion and native gon build run +after verified installation. Release credentials are configured only after +tool installation and checks. Helper code checkouts use `job.workflow_sha`. + +Go is installed once per job, with `GOTOOLCHAIN=local`. Go gates take +`EXPECTED_GO_VERSION`, `EXPECTED_LOCK_SHA256` and `LOCK_PATH` from the +locked-tools action's `go`, `lock_sha256` and `lock_path` outputs. The gate hashes the referenced lock to check identity but never parses its versions; prepare owns the single-version sanity. Version equality and readonly/offline `GOTOOLCHAIN=local go list -m` checks reject incompatible module requirements. -Module caching from the former setup action is intentionally dropped. +The release workflow does not cache Go modules. -The versions below were observed in [release run 38979848074](https://github.com/ConductorOne/baton-github-test/actions/runs/38979848074). -Artifact hashes were fetched once from the upstream release checksums and -[Go's release metadata](https://go.dev/dl/?mode=json), then checked against real -downloads before committing. Checksums and artifacts share upstream TLS origins; -the integrity boundary during a release is the reviewed, committed lock. +Artifact hashes identify the archives at the upstream release URLs. +[Go release metadata](https://go.dev/dl/?mode=json) provides the toolchain +checksums. Checksums and artifacts share upstream TLS origins; release +integrity uses the committed lock rather than a checksum fetched during the job. -Node.js 26.12.1, consumed by axiomatic, is the approved single tool-version change in this series: the uniform patched Node line, superseding the earlier 22.23.4 choice and locked using [SHASUMS256.txt](https://nodejs.org/dist/v26.12.1/SHASUMS256.txt). +Node.js 26.12.1 uses platform-specific archive locks with checksums from [SHASUMS256.txt](https://nodejs.org/dist/v26.12.1/SHASUMS256.txt). The `ConductorOne/github-workflows/.github/actions/locked-tools@` composite action accepts a comma-separated `tools` list, an optional reviewed @@ -296,8 +271,7 @@ 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. +An empty `locks` input selects the action-local canonical lock inside prepare. `dest` keeps its `runner.temp` metadata default: that context is available in the caller on Linux, macOS, and Windows. @@ -309,11 +283,8 @@ between preparation, installation, and output publication; internal Go gates receive the same expected version/path/hash. Each phase is a one-line script call in the composite. See the [action contract](../.github/actions/locked-tools/README.md). -The Go gate is action-local and environment-only; there is no repository-level -wrapper or positional lockfile fallback. Callers must wire the installed action's -version and lock-identity outputs. The main-tip workflow's old wrapper calls are -not executable until the coordinated caller amendment lands; moving-tag -promotion remains blocked until that migration is complete. +The action-local Go gate is environment-only. Callers supply the installed +action's version and lock-identity outputs; positional lockfile lookup is not supported. | Tool / version | Lock platform | SHA256 or source commit | Committed artifact/source | | --- | --- | --- | --- | @@ -331,17 +302,14 @@ promotion remains blocked until that migration is complete. | 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) | +| minisign 0.12 | darwin/arm64 | `89000b19535765f9cffc65a65d64a820f433ef6db8020667f7570e06bf6aac63` | [macOS ZIP](https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-macos.zip) | -The gon fork publishes only an amd64 asset for v0.2.10. Rather than using -Rosetta, `actions/checkout` fetches the locked commit by object ID and -`scripts/gon-source.sh` builds it natively with the already-verified Go. -It checks gon's Go requirement, requires `GOFLAGS=-mod=readonly`, -`GOPROXY=https://proxy.golang.org`, and `GOSUMDB=sum.golang.org`, and logs the -built binary's SHA256 for cross-run comparison. A native darwin-arm64 release -asset is a follow-up improvement, not a prerequisite for this source-locked build. +Gon builds natively from a pinned source commit with the verified Go toolchain. +The action checks out the exact source object, checks its Go requirement, and +requires `GOFLAGS=-mod=readonly`, `GOPROXY=https://proxy.golang.org` and +`GOSUMDB=sum.golang.org`. It records the resulting binary's SHA256. -Cosign remains on its observed v2.5.2, installed by +Cosign v2.5.2 is installed by `sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac`. That action verifies its bootstrap binary against hashes embedded in the pinned action and verifies other requested versions by signature. The @@ -400,7 +368,7 @@ Both Windows zip and MSI have: - SLSA provenance attestations - SBOM attestations -**Note:** Windows code signing via Azure Trusted Signing is planned for Stage 2. +Windows artifacts have Sigstore signatures, not Windows Authenticode signatures. ### Verification @@ -491,7 +459,7 @@ to connector callers. Real image-pin enforcement stays in the existing CI installs ShellCheck and script-test dependencies through Ubuntu 24.04 apt. The tests select apt's Python/jq `yq` ahead of the runner's different `yq`. `bash scripts/lint-shell.sh` checks every repository Bash source, including -action implementations, guard scripts, tests, and compatibility wrappers. +action implementations, guard scripts, tests and repository helper entrypoints. Local lifecycle fixtures also verify connector failure propagation without calling a live connector. @@ -623,23 +591,8 @@ Test the MSI installer on an actual Windows machine: - **Cosign version:** Ensure using latest cosign (`cosign version`) - **MSI UpgradeCode:** If upgrades don't work, verify UpgradeCode is consistent across versions -## Future Work - -### Stage 2: Windows Code Signing - -Currently MSI installers have Sigstore signatures (cosign) but not Windows Authenticode signatures. Stage 2 will add: - -- **Azure Trusted Signing** integration for Authenticode signatures -- MSI files will be signed with Microsoft-trusted certificate -- Windows SmartScreen warnings will be eliminated -- Users can verify publisher identity in Windows UAC prompts - -This requires: -- Azure Trusted Signing account setup -- GitHub Actions OIDC integration with Azure -- Workflow updates to sign MSI after build - -### Other Potential Improvements +## Platform Limitations -- Support for Windows ARM64 builds -- Windows Service installer templates (for connectors that run as services) +- Windows artifacts use Sigstore signatures and do not include Windows Authenticode signatures. +- Windows build and toolchain targets use amd64; Windows ARM64 is not supported. +- Windows Service installers require a caller-provided custom WXS template. diff --git a/scripts/deny-os.ps1 b/scripts/deny-os.ps1 index d20ccd2..4921f74 100644 --- a/scripts/deny-os.ps1 +++ b/scripts/deny-os.ps1 @@ -49,7 +49,7 @@ foreach ($entry in $denyList) { if (-not $found) { throw "fixture fingerprint mismatch for $($entry.name)" } $pending += @{ Cert = $found; Entry = $entry } } -# Retain #951's four-store search and fingerprint/name collision refusal. Both +# Search all four stores and refuse fingerprint/name collisions. Both # compromised intermediates share a CN; accept either AUTHORIZED thumbprint, # never an unrelated same-name certificate. Original source certs are retained. $stores = @('Cert:\LocalMachine\Root', 'Cert:\LocalMachine\CA', 'Cert:\CurrentUser\Root', 'Cert:\CurrentUser\CA') diff --git a/scripts/deny-os.sh b/scripts/deny-os.sh index 09674b1..fe98bd3 100755 --- a/scripts/deny-os.sh +++ b/scripts/deny-os.sh @@ -98,7 +98,7 @@ case "$os" in if [ "$dry_run" = true ]; then exit 0; fi BUNDLE=/etc/ssl/certs/ca-certificates.crt [ -s "$BUNDLE" ] || { echo 'compiled system bundle missing/empty' >&2; exit 1; } - # Retain #951's compiled-bundle verification, scoped to incident roots. + # Verify the compiled bundle contains none of the incident roots. awk -v dir="$work" '/-----BEGIN CERTIFICATE-----/{n++} n {print > sprintf("%s/dca-%03d.pem", dir, n)}' "$BUNDLE" for cert in "$work"/dca-*.pem; do [ -s "$cert" ] || continue diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index da39f88..dbd0fdc 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -154,7 +154,7 @@ def test_go_linux_arm64_hash_mismatch_precedes_extraction(self) -> None: 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.assertIn("::error title=locked-tools::go sha256 mismatch", result.stderr) self.assertNotIn("tar\n", self.calls.read_text()) self.assertFalse(dest.exists()) self.assertFalse(self.path_file.exists()) @@ -167,7 +167,7 @@ def test_mismatch_fails_before_extraction_or_path_update(self) -> None: archive.write_bytes(archive.read_bytes() + b"tampered") self.calls.unlink(missing_ok=True) result = self.run_script("install-tool.sh", tool, self.root / "dest", ok=False) - self.assertIn(f"::error title=GF13::{tool} sha256 mismatch", result.stderr) + self.assertIn(f"::error title=locked-tools::{tool} sha256 mismatch", result.stderr) self.assertNotIn("tar\n", self.calls.read_text()) self.assertFalse((self.root / "dest").exists()) self.assertFalse(self.path_file.exists()) @@ -179,7 +179,7 @@ def test_node_layout_mismatch_never_publishes_path(self) -> None: self.env.update(TEST_OS=os_name, TEST_ARCH="arm64" if os_name == "Darwin" else "x86_64") dest = self.root / f"wrong-layout-{os_name}" result = self.run_script("install-tool.sh", "node", dest, ok=False) - self.assertIn("::error title=GF13::node package layout mismatch", result.stderr) + self.assertIn("::error title=locked-tools::node package layout mismatch", result.stderr) self.assertTrue((dest / "unexpected-node/bin/node").is_file(), "fixture must reach extraction") self.assertFalse(self.path_file.exists()) @@ -188,7 +188,7 @@ def test_nonexecutable_binary_never_publishes_environment(self) -> None: with self.subTest(tool=tool): self.fixture_download(tool, "linux/amd64", mode=0o644) result = self.run_script("install-tool.sh", tool, self.root / tool, ok=False) - self.assertIn(f"::error title=GF13::{tool} package layout mismatch", result.stderr) + self.assertIn(f"::error title=locked-tools::{tool} package layout mismatch", result.stderr) self.assertFalse(self.path_file.exists()) self.assertFalse(self.env_file.exists()) @@ -262,7 +262,7 @@ def test_go_version_and_module_gates(self) -> None: self.calls.unlink() module.write_bytes(b"module example.com/fixture\r\n\r\ngo 1.27.4\r\n") result = self.run_script("verify-locked-go.sh", "module", module, ok=False) - self.assertIn(f"::error title=GF13::{module} requires Go 1.27.4, newer than locked go1.27.3", result.stderr) + self.assertIn(f"::error title=locked-tools::{module} requires Go 1.27.4, newer than locked go1.27.3", result.stderr) for directive in ("go 1.27.4", "go 1.28", "go 2.0", "go 1.28rc1", "", "go 1.27\ngo 1.26"): module.write_text(f"module example.com/fixture\n\n{directive}\n") self.run_script("verify-locked-go.sh", "module", module, ok=False) @@ -408,7 +408,7 @@ def test_minisign_zip_verified_before_extraction(self) -> None: self.path_file.unlink() archive.write_bytes(archive.read_bytes() + b"tampered") result = self.run_script("install-tool.sh", "minisign", self.root / "bad-minisign", ok=False) - self.assertIn("::error title=GF13::minisign sha256 mismatch", result.stderr) + self.assertIn("::error title=locked-tools::minisign sha256 mismatch", result.stderr) self.assertFalse(self.path_file.exists()) self.assertFalse((self.root / "bad-minisign").exists()) @@ -588,7 +588,7 @@ def test_action_explicit_missing_lock_fails_without_fallback(self) -> None: self.env.update(LOCKED_TOOLS="node", LOCKS_INPUT=str(self.root / "missing-lock.txt"), ACTION_DIR=str(self.action_dir)) result = self.run_script("locked-tools.sh", "prepare", ok=False) - self.assertIn("::error title=GF13::Lock file missing or not readable:", result.stderr) + self.assertIn("::error title=locked-tools::Lock file missing or not readable:", result.stderr) self.assertFalse(self.calls.exists()) self.assertFalse(self.path_file.exists()) diff --git a/scripts/verify-release-checkout.sh b/scripts/verify-release-checkout.sh index 235b298..c6e4d07 100755 --- a/scripts/verify-release-checkout.sh +++ b/scripts/verify-release-checkout.sh @@ -1,18 +1,18 @@ #!/usr/bin/env bash set -euo pipefail if [ "$#" -ne 1 ] || [[ ! "${EXPECTED_COMMIT:-}" =~ ^[0-9a-f]{40}$ ]] || [ -z "${RELEASE_TAG:-}" ]; then - echo '::error title=GF12::Require a checkout directory, event commit SHA, and release tag' >&2 + echo '::error title=release-source::Require a checkout directory, event commit SHA, and release tag' >&2 exit 1 fi repo="$1" git check-ref-format "refs/tags/$RELEASE_TAG" head_commit="$(git -C "$repo" rev-parse --verify HEAD)" if ! tag_commit="$(git -C "$repo" rev-parse --verify "refs/tags/$RELEASE_TAG^{commit}")"; then - echo "::error title=GF12::Cannot peel refs/tags/$RELEASE_TAG to a commit (lightweight and annotated tags must resolve to the event SHA)" >&2 + echo "::error title=release-source::Cannot peel refs/tags/$RELEASE_TAG to a commit (lightweight and annotated tags must resolve to the event SHA)" >&2 exit 1 fi if [ "$head_commit" != "$EXPECTED_COMMIT" ] || [ "$tag_commit" != "$EXPECTED_COMMIT" ]; then - echo "::error title=GF12::Release source mismatch: HEAD=$head_commit, peeled refs/tags/$RELEASE_TAG=$tag_commit, event SHA=$EXPECTED_COMMIT; both lightweight and annotated tags must resolve to the event commit" >&2 + echo "::error title=release-source::Release source mismatch: HEAD=$head_commit, peeled refs/tags/$RELEASE_TAG=$tag_commit, event SHA=$EXPECTED_COMMIT; both lightweight and annotated tags must resolve to the event commit" >&2 exit 1 fi if [ -n "${GITHUB_OUTPUT:-}" ]; then