diff --git a/.gitattributes b/.gitattributes index 54249ed..bc6adac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,3 @@ scripts/test-deny-os.sh text eol=lf scripts/test-ca-trust-guard.sh text eol=lf # The canonical lock hash and action scripts must survive Windows checkout unchanged. .github/actions/locked-tools/** text eol=lf -scripts/install-tool.sh text eol=lf -scripts/install-tool.ps1 text eol=lf -scripts/gon-source.sh text eol=lf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a937870..6219d88 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,6 +8,7 @@ on: paths: - .github/workflows/ci.yaml - .github/workflows/verify.yaml + - .github/workflows/release.yaml - .github/actions/** - .gitattributes - actions/** @@ -23,6 +24,7 @@ on: paths: - .github/workflows/ci.yaml - .github/workflows/verify.yaml + - .github/workflows/release.yaml - .github/actions/** - .gitattributes - actions/** diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7cd61d2..729dd7f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -118,8 +118,21 @@ concurrency: jobs: validate-inputs: runs-on: ubuntu-latest + permissions: + contents: read steps: + - name: Checkout connector workflows + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 + with: + path: _workflows + repository: ConductorOne/github-workflows + ref: ${{ job.workflow_sha }} + persist-credentials: false + + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + - name: Validate tag format env: TAG: ${{ inputs.tag }} @@ -177,13 +190,16 @@ jobs: brew_tap: ${{ steps.release-options.outputs.brew_tap }} steps: - name: Checkout pinned connector workflows - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 with: path: _workflows repository: ConductorOne/github-workflows ref: ${{ job.workflow_sha }} persist-credentials: false + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + - name: Validate dockerfile_template has safe path if: inputs.dockerfile_template != '' working-directory: _workflows @@ -257,6 +273,24 @@ jobs: outputs: github_prerelease: ${{ steps.github-release.outputs.prerelease }} steps: + - name: Checkout connector workflows + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 + with: + path: _workflows + repository: ConductorOne/github-workflows + ref: ${{ job.workflow_sha }} + persist-credentials: false + + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + + - name: Install locked release tools + id: tools + if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true + uses: ./_workflows/.github/actions/locked-tools + with: + tools: go + - name: Checkout caller repo if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 @@ -267,15 +301,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Checkout connector workflows - if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 - with: - path: _workflows - repository: ConductorOne/github-workflows - ref: ${{ needs.determine-workflows-ref.outputs.ref }} - persist-credentials: false - - name: Verify caller checkout matches release tag if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true shell: bash @@ -284,17 +309,13 @@ jobs: EXPECTED_COMMIT: ${{ github.sha }} run: bash _workflows/scripts/verify-release-checkout.sh _caller - - name: Install locked Go - if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true - run: bash _workflows/scripts/install-tool.sh go "$RUNNER_TEMP/locked-go" - - - name: Verify locked Go version - if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true - run: bash _workflows/scripts/verify-locked-go.sh version - - name: Check caller Go requirement if: inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true - run: bash _workflows/scripts/verify-locked-go.sh module _caller/go.mod _workflows/go.mod + env: + EXPECTED_GO_VERSION: ${{ fromJSON(steps.tools.outputs.versions || '{}').go }} + EXPECTED_LOCK_SHA256: ${{ steps.tools.outputs.lock_sha256 }} + LOCK_PATH: ${{ steps.tools.outputs.lock_path }} + run: bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module _caller/go.mod _workflows/go.mod - name: Capture GitHub Release pre-release setting id: github-release @@ -383,6 +404,23 @@ jobs: binaries_checksums: ${{ steps.output-checksums.outputs.checksums }} released_at: ${{ steps.release-meta.outputs.released_at }} steps: + - name: Checkout connector workflows + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 + with: + path: _workflows + repository: ConductorOne/github-workflows + ref: ${{ job.workflow_sha }} + persist-credentials: false + + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + + - name: Install locked release tools + id: tools + uses: ./_workflows/.github/actions/locked-tools + with: + tools: go,gon,syft,goreleaser + - name: Checkout caller repo uses: actions/checkout@v5 with: @@ -392,14 +430,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Checkout connector workflows - uses: actions/checkout@v5 - with: - path: _workflows - repository: ConductorOne/github-workflows - ref: ${{ needs.determine-workflows-ref.outputs.ref }} - persist-credentials: false - - name: Verify caller checkout matches release tag shell: bash env: @@ -420,35 +450,12 @@ jobs: --suffix "${REPO_OWNER}-${REPO_NAME}" \ --output-name gha_artifacts_role_name >> "$GITHUB_OUTPUT" - - name: Read locked gon source commit - id: gon-source - run: bash _workflows/scripts/gon-source.sh ref - - - name: Checkout locked gon source - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 - with: - repository: ConductorOne/gon - ref: ${{ steps.gon-source.outputs.ref }} - path: _gon - persist-credentials: false - - - name: Install locked Go - run: bash _workflows/scripts/install-tool.sh go "$RUNNER_TEMP/locked-go" - - - name: Verify locked Go version - run: bash _workflows/scripts/verify-locked-go.sh version - - name: Check caller Go requirement - run: bash _workflows/scripts/verify-locked-go.sh module _caller/go.mod _workflows/go.mod - - - name: Build native gon from locked source - run: bash _workflows/scripts/gon-source.sh build _gon "$RUNNER_TEMP/locked-gon" - - - name: Install locked syft - run: bash _workflows/scripts/install-tool.sh syft "$RUNNER_TEMP/locked-syft" - - - name: Install locked GoReleaser - run: bash _workflows/scripts/install-tool.sh goreleaser "$RUNNER_TEMP/locked-goreleaser" + env: + EXPECTED_GO_VERSION: ${{ fromJSON(steps.tools.outputs.versions || '{}').go }} + EXPECTED_LOCK_SHA256: ${{ steps.tools.outputs.lock_sha256 }} + LOCK_PATH: ${{ steps.tools.outputs.lock_path }} + run: bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module _caller/go.mod _workflows/go.mod - name: Install cosign uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 @@ -716,6 +723,23 @@ jobs: outputs: windows_manifest: ${{ steps.generate-windows-manifest.outputs.windows_manifest }} steps: + - name: Checkout connector workflows + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 + with: + path: _workflows + repository: ConductorOne/github-workflows + ref: ${{ job.workflow_sha }} + persist-credentials: false + + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + + - name: Install locked release tools + id: tools + uses: ./_workflows/.github/actions/locked-tools + with: + tools: go,syft,goreleaser-pro + - name: Enable Git long paths shell: pwsh run: git config --system core.longpaths true @@ -729,14 +753,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Checkout connector workflows - uses: actions/checkout@v5 - with: - path: _workflows - repository: ConductorOne/github-workflows - ref: ${{ needs.determine-workflows-ref.outputs.ref }} - persist-credentials: false - - name: Verify caller checkout matches release tag shell: bash env: @@ -757,25 +773,13 @@ jobs: --suffix "${REPO_OWNER}-${REPO_NAME}" \ --output-name gha_artifacts_role_name >> "$GITHUB_OUTPUT" - - name: Install locked Go - shell: pwsh - run: ./_workflows/scripts/install-tool.ps1 go "$env:RUNNER_TEMP/locked-go" - - - name: Verify locked Go version - shell: bash - run: bash _workflows/scripts/verify-locked-go.sh version - - name: Check caller Go requirement shell: bash - run: bash _workflows/scripts/verify-locked-go.sh module _caller/go.mod _workflows/go.mod - - - name: Install locked syft - shell: pwsh - run: ./_workflows/scripts/install-tool.ps1 syft "$env:RUNNER_TEMP/locked-syft" - - - name: Install locked GoReleaser Pro - shell: pwsh - run: ./_workflows/scripts/install-tool.ps1 goreleaser-pro "$env:RUNNER_TEMP/locked-goreleaser" + env: + EXPECTED_GO_VERSION: ${{ fromJSON(steps.tools.outputs.versions || '{}').go }} + EXPECTED_LOCK_SHA256: ${{ steps.tools.outputs.lock_sha256 }} + LOCK_PATH: ${{ steps.tools.outputs.lock_path }} + run: bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module _caller/go.mod _workflows/go.mod - name: Generate or locate WXS file id: wxs @@ -1002,6 +1006,23 @@ jobs: env: GOTOOLCHAIN: local steps: + - name: Checkout connector workflows + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 + with: + path: _workflows + repository: ConductorOne/github-workflows + ref: ${{ job.workflow_sha }} + persist-credentials: false + + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + + - name: Install locked release tools + id: tools + uses: ./_workflows/.github/actions/locked-tools + with: + tools: go,goreleaser + - name: Checkout caller repo uses: actions/checkout@v5 with: @@ -1011,15 +1032,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Checkout connector workflows - if: inputs.docker == true || inputs.lambda == true - uses: actions/checkout@v5 - with: - path: _workflows - repository: ConductorOne/github-workflows - ref: ${{ needs.determine-workflows-ref.outputs.ref }} - persist-credentials: false - - name: Verify caller checkout matches release tag shell: bash env: @@ -1045,17 +1057,12 @@ jobs: --suffix "${REPO_NAME}" \ --output-name ecr_push_role_name >> "$GITHUB_OUTPUT" - - name: Install locked Go - run: bash _workflows/scripts/install-tool.sh go "$RUNNER_TEMP/locked-go" - - - name: Verify locked Go version - run: bash _workflows/scripts/verify-locked-go.sh version - - name: Check caller Go requirement - run: bash _workflows/scripts/verify-locked-go.sh module _caller/go.mod _workflows/go.mod - - - name: Install locked GoReleaser - run: bash _workflows/scripts/install-tool.sh goreleaser "$RUNNER_TEMP/locked-goreleaser" + env: + EXPECTED_GO_VERSION: ${{ fromJSON(steps.tools.outputs.versions || '{}').go }} + EXPECTED_LOCK_SHA256: ${{ steps.tools.outputs.lock_sha256 }} + LOCK_PATH: ${{ steps.tools.outputs.lock_path }} + run: bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module _caller/go.mod _workflows/go.mod - name: Install cosign if: inputs.docker == true @@ -1296,13 +1303,22 @@ jobs: GOTOOLCHAIN: local steps: - name: Checkout connector workflows - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 with: path: _workflows repository: ConductorOne/github-workflows - ref: ${{ needs.determine-workflows-ref.outputs.ref }} + ref: ${{ job.workflow_sha }} persist-credentials: false + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + + - name: Install locked release tools + id: tools + uses: ./_workflows/.github/actions/locked-tools + with: + tools: go + - name: Derive AWS role names id: role-names working-directory: _workflows @@ -1316,14 +1332,12 @@ jobs: --suffix "${REPO_OWNER}-${REPO_NAME}" \ --output-name gha_artifacts_role_name >> "$GITHUB_OUTPUT" - - name: Install locked Go - run: bash _workflows/scripts/install-tool.sh go "$RUNNER_TEMP/locked-go" - - - name: Verify locked Go version - run: bash _workflows/scripts/verify-locked-go.sh version - - name: Check workflows Go requirement - run: bash _workflows/scripts/verify-locked-go.sh module _workflows/go.mod + env: + EXPECTED_GO_VERSION: ${{ fromJSON(steps.tools.outputs.versions || '{}').go }} + EXPECTED_LOCK_SHA256: ${{ steps.tools.outputs.lock_sha256 }} + LOCK_PATH: ${{ steps.tools.outputs.lock_path }} + run: bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module _workflows/go.mod - name: Merge binaries, Windows, and images manifests working-directory: _workflows @@ -1551,13 +1565,22 @@ jobs: GOTOOLCHAIN: local steps: - name: Checkout connector workflows - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 with: path: _workflows repository: ConductorOne/github-workflows - ref: ${{ needs.determine-workflows-ref.outputs.ref }} + ref: ${{ job.workflow_sha }} persist-credentials: false + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + + - name: Install locked release tools + id: tools + uses: ./_workflows/.github/actions/locked-tools + with: + tools: go + - name: Checkout connector repo uses: actions/checkout@v5 with: @@ -1575,14 +1598,12 @@ jobs: EXPECTED_COMMIT: ${{ github.sha }} run: bash _workflows/scripts/verify-release-checkout.sh _connector - - name: Install locked Go - run: bash _workflows/scripts/install-tool.sh go "$RUNNER_TEMP/locked-go" - - - name: Verify locked Go version - run: bash _workflows/scripts/verify-locked-go.sh version - - name: Check workflows Go requirement - run: bash _workflows/scripts/verify-locked-go.sh module _workflows/go.mod + env: + EXPECTED_GO_VERSION: ${{ fromJSON(steps.tools.outputs.versions || '{}').go }} + EXPECTED_LOCK_SHA256: ${{ steps.tools.outputs.lock_sha256 }} + LOCK_PATH: ${{ steps.tools.outputs.lock_path }} + run: bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module _workflows/go.mod - name: Read connector documentation id: read-docs @@ -1712,13 +1733,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout shared workflows - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # actions/checkout@v5 with: repository: ConductorOne/github-workflows - ref: ${{ needs.determine-workflows-ref.outputs.ref }} + ref: ${{ job.workflow_sha }} path: _workflows persist-credentials: false + - name: Enforce runner CA trust + uses: ./_workflows/.github/actions/ca-trust-guard + - name: Install cosign uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 with: diff --git a/docs/release-workflow.md b/docs/release-workflow.md index eeb70be..2d60455 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -233,6 +233,49 @@ The same event commit remains in the provenance's resolved Git dependency. Every GoReleaser invocation receives `GORELEASER_CURRENT_TAG` from the workflow input. This pins archive names, release metadata, and image digest files to the requested tag when multiple tags identify the same commit. +### Release Job Bootstrap + +Nine release jobs first check out `_workflows` at `${{ job.workflow_sha }}` +with a SHA-pinned checkout action and `persist-credentials: false`. This fetch +necessarily precedes the guard and, like the runner's own action downloads, +authenticates to github.com with the job's read-scoped `GITHUB_TOKEN` +(actions/checkout requires a token); the token is not persisted, the host is not +behind the incident intermediates, and content integrity is bound to +`job.workflow_sha`. This read-only infrastructure checkout provides the +workflow's own action implementations and canonical lock; it does not execute +caller code. +The job then runs `./_workflows/.github/actions/ca-trust-guard` with its default +`enforce`/`true`/`true`/`fail` inputs. Six tool-using jobs next run +`./_workflows/.github/actions/locked-tools` with `id: tools`, before caller +checkout, release-commit verification, module requirements, and credentials. +The workflow uses no remote self-action SHA references: its workflow, local +actions, and lock come from the same checkout commit. The guard's measurements +and residual-client limits are documented in +[CA Trust Guard](../.github/actions/ca-trust-guard/README.md). + +`notify-release-failure` is deliberately exempt: guard errors must not prevent +failure reporting. The job remains failure-only and does not install build +tools. `validate-inputs`, `determine-workflows-ref`, and `verify-release` need +the guard but no locked-tools installation. + +| Tool-using job | `locked-tools` input | +| --- | --- | +| release-preflight | `go` | +| goreleaser-binaries | `go,gon,syft,goreleaser` | +| goreleaser-windows | `go,syft,goreleaser-pro` | +| goreleaser-docker | `go,goreleaser` | +| publish-release-manifest | `go` | +| record-registry-api | `go` | + +The preflight `_workflows` checkout and local guard always run. Its +`go_source_hygiene || go_vulnerability_scan` condition applies to locked-tools, +caller checkout, source verification, and the module gate, so disabling both +optional checks does not download a Go toolchain. +Tool-using jobs set `GOTOOLCHAIN=local` at job scope. The binaries job also sets +`GOFLAGS=-mod=readonly`, `GOPROXY=https://proxy.golang.org`, and +`GOSUMDB=sum.golang.org` at job scope. Cosign and bundled-buildx/QEMU setup run +after trust/tool bootstrap and before their signing or AWS credentials. + ### Locked Release Tools `.github/actions/locked-tools/tool-locks.txt` identifies tool versions, artifact @@ -243,13 +286,22 @@ 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. +Go is installed once per tool-using job, with `GOTOOLCHAIN=local`. Each of the +six module gates invokes the checked-out action-local `verify-locked-go.sh` +after source verification and receives the installed action's identity: + +- `EXPECTED_GO_VERSION`: `${{ fromJSON(steps.tools.outputs.versions || '{}').go }}` +- `EXPECTED_LOCK_SHA256`: `${{ steps.tools.outputs.lock_sha256 }}` +- `LOCK_PATH`: `${{ steps.tools.outputs.lock_path }}` + +The empty-JSON expression is safe when optional tools are skipped: step +environments are evaluated before step conditions. It supplies no version +fallback; an executing gate with incomplete outputs fails closed. +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 action asserts the installed Go version itself; the workflow +does not duplicate standalone version gates or 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 @@ -258,7 +310,7 @@ 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@` +The local `./_workflows/.github/actions/locked-tools` composite action accepts a comma-separated `tools` list, an optional reviewed `locks` file (default: the action's canonical lock), and `dest` (default: `${{ runner.temp }}/locked-tools`). Invoke it before configuring release @@ -283,8 +335,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). -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. +The action-local Go gate is environment-only. It consumes the installed +action's version and lock-identity outputs, not a version re-read from a +checkout lock or positional lockfile arguments. Canonical implementations +and the single lock reside under `.github/actions/locked-tools/`; forwarding +wrappers under `scripts/` are not supported. | Tool / version | Lock platform | SHA256 or source commit | Committed artifact/source | | --- | --- | --- | --- | diff --git a/scripts/gon-source.sh b/scripts/gon-source.sh deleted file mode 100755 index c48a511..0000000 --- a/scripts/gon-source.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/gon-source.sh" "$@" diff --git a/scripts/install-tool.ps1 b/scripts/install-tool.ps1 deleted file mode 100644 index 90fa78d..0000000 --- a/scripts/install-tool.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -# Transitional entry point; implementation and lock live with the action. -param( - [Parameter(Mandatory = $true)][string]$Tool, - [Parameter(Mandatory = $true)][string]$DestDir -) -$ErrorActionPreference = 'Stop' -& (Join-Path $PSScriptRoot '../.github/actions/locked-tools/install-tool.ps1') -Tool $Tool -DestDir $DestDir diff --git a/scripts/install-tool.sh b/scripts/install-tool.sh deleted file mode 100755 index 6fc1b17..0000000 --- a/scripts/install-tool.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/install-tool.sh" "$@" diff --git a/scripts/test-ci-workflow.py b/scripts/test-ci-workflow.py index ae6048b..4a6e1e3 100755 --- a/scripts/test-ci-workflow.py +++ b/scripts/test-ci-workflow.py @@ -21,7 +21,7 @@ def test_repository_events_jobs_and_read_only_permissions(self): self.assertEqual(workflow["on"]["push"]["branches"], ["main"]) expected_paths = {"cmd/**", ".github/actions/**", "actions/**", "scripts/**", "tools/**", "go.*", "renovate.json", "templates/**", ".github/workflows/ci.yaml", - ".github/workflows/verify.yaml", "Makefile", ".gitattributes"} + ".github/workflows/verify.yaml", ".github/workflows/release.yaml", "Makefile", ".gitattributes"} pull_paths = set(workflow["on"]["pull_request"]["paths"]) self.assertTrue(expected_paths <= pull_paths) self.assertEqual(pull_paths, set(workflow["on"]["push"]["paths"])) diff --git a/scripts/test-tool-integrity.py b/scripts/test-tool-integrity.py index dbd0fdc..271adf3 100755 --- a/scripts/test-tool-integrity.py +++ b/scripts/test-tool-integrity.py @@ -17,6 +17,13 @@ ROOT = Path(__file__).resolve().parent.parent ACTION = ROOT / ".github/actions/locked-tools" +GUARD_ACTION = "./_workflows/.github/actions/ca-trust-guard" +TOOLS_ACTION = "./_workflows/.github/actions/locked-tools" +GO_GATE_ENV = { + "EXPECTED_GO_VERSION": "${{ fromJSON(steps.tools.outputs.versions || '{}').go }}", + "EXPECTED_LOCK_SHA256": "${{ steps.tools.outputs.lock_sha256 }}", + "LOCK_PATH": "${{ steps.tools.outputs.lock_path }}", +} class IntegrityTests(unittest.TestCase): @@ -32,8 +39,6 @@ def setUp(self) -> None: shutil.copyfile(ACTION / name, self.action_dir / 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" @@ -412,24 +417,22 @@ def test_minisign_zip_verified_before_extraction(self) -> None: self.assertFalse(self.path_file.exists()) self.assertFalse((self.root / "bad-minisign").exists()) - def test_custom_lock_and_legacy_forwarders(self) -> None: + def test_custom_lock_overrides_action_defaults(self) -> None: self.fixture_download("node", "linux/amd64") expected_member = self.archive_member("node", "linux/amd64") custom = self.root / "custom locks.txt" shutil.copyfile(self.lock, custom) self.lock.write_text("# The canonical fixture lock is deliberately unusable.\n") self.env["TOOL_LOCK_FILE"] = str(custom) - result = subprocess.run(["bash", str(self.scripts / "install-tool.sh"), "node", str(self.root / "legacy")], - env=self.env, text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertTrue((self.root / "legacy" / expected_member).is_file()) + self.run_script("install-tool.sh", "node", self.root / "custom-install") + self.assertTrue((self.root / "custom-install" / expected_member).is_file()) self.run_script("gon-source.sh", "ref") self.assertIn("ref=", self.output_file.read_text()) - self.output_file.unlink() - result = subprocess.run(["bash", str(self.scripts / "gon-source.sh"), "ref"], - env=self.env, text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stderr) - 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.go_contract(lock=custom) + self.mock_go_gate() + self.run_script("verify-locked-go.sh", "module", module) def phase_outputs(self) -> dict[str, str]: return dict(line.split("=", 1) for line in self.output_file.read_text().splitlines()) @@ -455,7 +458,7 @@ def test_action_go_outputs_feed_environment_gate(self) -> None: 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.env["EXPECTED_GO_VERSION"] = json.loads(outputs["versions"])["go"] self.run_script("verify-locked-go.sh", "version") def test_action_prepare_owns_single_go_version_sanity(self) -> None: @@ -631,27 +634,131 @@ def test_composite_metadata_and_single_canonical_lock(self) -> None: 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_release_workflow_has_no_remote_self_actions_or_duplicate_bootstrap(self) -> None: + text = (ROOT / ".github/workflows/release.yaml").read_text() + self.assertNotIn("ConductorOne/github-workflows/.github/actions/", text) + self.assertNotIn("verify-locked-go.sh version", text) + self.assertNotIn("gon-source.sh", text) + for legacy in ("_workflows/scripts/install-tool", "_workflows/scripts/gon-source", "_workflows/scripts/verify-locked-go"): + self.assertNotIn(legacy, text) + def test_workflow_security_wiring(self) -> None: workflow = json.loads(subprocess.check_output(["yq", ".", str(ROOT / ".github/workflows/release.yaml")], text=True)) jobs = workflow["jobs"] self.assertEqual(jobs["determine-workflows-ref"]["outputs"]["ref"], "${{ job.workflow_sha }}") + self.assertEqual(jobs["validate-inputs"]["permissions"], {"contents": "read"}) + expected_tools = { + "validate-inputs": "", + "determine-workflows-ref": "", + "release-preflight": "go", + "goreleaser-binaries": "go,gon,syft,goreleaser", + "goreleaser-windows": "go,syft,goreleaser-pro", + "goreleaser-docker": "go,goreleaser", + "publish-release-manifest": "go", + "record-registry-api": "go", + "verify-release": "", + "notify-release-failure": "", + } + self.assertEqual(set(jobs), set(expected_tools)) + checkout_count = guard_count = tool_count = gate_count = 0 for job_name, job in jobs.items(): - steps = job["steps"] - installs = [i for i, step in enumerate(steps) if any(token in step.get("run", "") for token in ("install-tool.", "gon-source.sh build", "setup-release-buildx.sh")) or "cosign-installer@" in step.get("uses", "") or "setup-qemu-action@" in step.get("uses", "")] - credentials = [i for i, step in enumerate(steps) if "secrets." in str(step) or "configure-aws-credentials@" in step.get("uses", "")] - if installs and credentials: - self.assertLess(max(installs), min(credentials), job_name) - for step in steps: - if "install-tool." in step.get("run", ""): - self.assertNotIn("\n", step["run"], job_name) + with self.subTest(job=job_name): + steps = job["steps"] + guards = [index for index, step in enumerate(steps) if step.get("uses") == GUARD_ACTION] + tools = [index for index, step in enumerate(steps) if step.get("uses") == TOOLS_ACTION] + workflow_checkouts = [index for index, step in enumerate(steps) + if step.get("with", {}).get("path") == "_workflows"] + if job_name == "notify-release-failure": + self.assertEqual(workflow_checkouts, []) + self.assertEqual(guards, [], "guard failures must not suppress failure reporting") + self.assertEqual(tools, []) + self.assertEqual(job["if"], "failure()") + continue + self.assertEqual(workflow_checkouts, [0]) + bootstrap = steps[0] + self.assertRegex(bootstrap["uses"], r"^actions/checkout@[0-9a-f]{40}$") + self.assertEqual(bootstrap["with"], { + "path": "_workflows", + "repository": "ConductorOne/github-workflows", + "ref": "${{ job.workflow_sha }}", + "persist-credentials": False, + }) + self.assertNotIn("if", bootstrap) + self.assertNotIn("continue-on-error", bootstrap) + self.assertEqual(job["permissions"]["contents"], "read") + checkout_count += 1 + self.assertEqual(guards, [1]) + self.assertEqual(steps[1].get("with", {}), {}, "use enforce/true/true/fail defaults") + self.assertNotIn("if", steps[1]) + self.assertNotIn("continue-on-error", steps[1]) + guard_count += 1 + prefix_length = 2 + if expected_tools[job_name]: + self.assertEqual(tools, [2]) + self.assertEqual(steps[2]["id"], "tools") + self.assertEqual(steps[2]["with"], {"tools": expected_tools[job_name]}) + self.assertNotIn("continue-on-error", steps[2]) self.assertEqual(job["env"]["GOTOOLCHAIN"], "local") - if step.get("with", {}).get("path") in ("_caller", "_connector"): - self.assertEqual(step["with"]["ref"], "${{ github.sha }}") - if "verify-release-checkout.sh" in step.get("run", ""): - self.assertEqual(step["env"]["EXPECTED_COMMIT"], "${{ github.sha }}") - if "cosign-installer@" in step.get("uses", ""): - self.assertEqual(step["with"]["cosign-release"], "v2.5.2") - self.assertRegex(step["uses"], r"@[0-9a-f]{40}$") + if job_name == "release-preflight": + self.assertEqual(steps[2]["if"], "inputs.go_source_hygiene == true || inputs.go_vulnerability_scan == true") + else: + self.assertNotIn("if", steps[2]) + prefix_length = 3 + tool_count += 1 + else: + self.assertEqual(tools, []) + caller_checkouts = [index for index, step in enumerate(steps) + if step.get("with", {}).get("path") in ("_caller", "_connector")] + checkouts = workflow_checkouts + caller_checkouts + verifications = [index for index, step in enumerate(steps) + if "verify-release-checkout.sh" in step.get("run", "")] + credentials = [index for index, step in enumerate(steps) + if "secrets." in str(step) or "github.token" in str(step) or "core.getIDToken" in str(step) + or "configure-aws-credentials@" in step.get("uses", "")] + self.assertTrue(all(index >= prefix_length for index in caller_checkouts)) + if verifications: + self.assertLess(max(checkouts), min(verifications)) + if credentials: + self.assertLess(prefix_length - 1, min(credentials)) + if verifications: + self.assertLess(max(verifications), min(credentials)) + installs = [index for index, step in enumerate(steps) + if "cosign-installer@" in step.get("uses", "") + or "setup-qemu-action@" in step.get("uses", "") + or "setup-release-buildx.sh" in step.get("run", "")] + if installs and credentials: + self.assertLess(max(installs), min(credentials)) + module_gates = [index for index, step in enumerate(steps) + if "/locked-tools/verify-locked-go.sh module " in step.get("run", "")] + if expected_tools[job_name]: + self.assertEqual(len(module_gates), 1) + gate = steps[module_gates[0]] + self.assertEqual(gate["env"], GO_GATE_ENV) + self.assertLess(max(checkouts), module_gates[0]) + if verifications: + self.assertLess(max(verifications), module_gates[0]) + if credentials: + self.assertLess(module_gates[0], min(credentials)) + modules = "_workflows/go.mod" if job_name in ("publish-release-manifest", "record-registry-api") else "_caller/go.mod _workflows/go.mod" + self.assertEqual(gate["run"], + "bash _workflows/.github/actions/locked-tools/verify-locked-go.sh module " + modules) + if job_name == "release-preflight": + self.assertEqual(gate["if"], steps[2]["if"]) + gate_count += 1 + for step in steps: + self.assertNotEqual(step.get("with", {}).get("repository"), "ConductorOne/gon") + if step.get("with", {}).get("path") in ("_caller", "_connector"): + self.assertEqual(step["with"]["ref"], "${{ github.sha }}") + if "verify-release-checkout.sh" in step.get("run", ""): + self.assertEqual(step["env"]["EXPECTED_COMMIT"], "${{ github.sha }}") + if "cosign-installer@" in step.get("uses", ""): + self.assertEqual(step["with"]["cosign-release"], "v2.5.2") + self.assertRegex(step["uses"], r"@[0-9a-f]{40}$") + ids = [step["id"] for step in steps if "id" in step] + self.assertEqual(len(ids), len(set(ids))) + self.assertEqual((checkout_count, guard_count, tool_count, gate_count), (9, 9, 6, 6)) + for wrapper in ("install-tool.sh", "install-tool.ps1", "gon-source.sh", "verify-locked-go.sh"): + self.assertFalse((ROOT / "scripts" / wrapper).exists()) if __name__ == "__main__":