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/README.md b/.github/actions/locked-tools/README.md new file mode 100644 index 0000000..630a8a0 --- /dev/null +++ b/.github/actions/locked-tools/README.md @@ -0,0 +1,70 @@ +# 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. 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..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 1723932..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:-}" @@ -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/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 e5d0026..6a1bb04 100755 --- a/.github/actions/locked-tools/verify-locked-go.sh +++ b/.github/actions/locked-tools/verify-locked-go.sh @@ -1,53 +1,73 @@ #!/usr/bin/env bash -# Separate runtime/version and go.mod checks; never permit toolchain downloads. +# 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)" -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=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' + +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 -if [ "${GOTOOLCHAIN:-}" != local ]; then - echo '::error title=GF13::GOTOOLCHAIN must be local; automatic toolchain downloads are forbidden' >&2 - exit 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' + 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 [...]' + 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/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 1b1183c..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,28 +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`. 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. - -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. - -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). +`.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. +The release workflow does not cache Go modules. + +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 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 @@ -293,22 +271,20 @@ 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. 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 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 | | --- | --- | --- | --- | @@ -326,17 +302,14 @@ files. The old `tools/tool-locks.txt` is removed, not copied or mirrored. | 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 @@ -395,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 @@ -486,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. @@ -618,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 40f757e..dbd0fdc 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" @@ -51,6 +52,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 +85,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) @@ -149,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()) @@ -162,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()) @@ -174,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()) @@ -183,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()) @@ -215,11 +220,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 +258,78 @@ 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) + 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) - 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.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), + ("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 git(self, *args: str) -> str: return subprocess.check_output(["git", "-C", str(self.repo), *args], text=True, stderr=subprocess.DEVNULL).strip() @@ -273,10 +365,11 @@ 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") - 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) @@ -315,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()) @@ -332,11 +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") - 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) @@ -346,6 +434,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 +475,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 +486,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 +575,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") @@ -462,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()) @@ -496,8 +622,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 deleted file mode 100755 index 3f473af..0000000 --- a/scripts/verify-locked-go.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -# Transitional entry point; implementation and lock live with the action. -set -euo pipefail -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec bash "$script_dir/../.github/actions/locked-tools/verify-locked-go.sh" "$@" 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