From 2ee5b914496cf1bbbc1d0720e4b26b5dffc722ae Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:39:41 -0400 Subject: [PATCH 1/2] ci(workflows): add reusable CI foundation --- .github/tests/reusable_ci_contract_test.py | 429 +++++++++++++++++++ .github/workflows/go-ci.yml | 460 +++++++++++++++++++++ .github/workflows/node-ci.yml | 186 +++++++++ .github/workflows/release-gate.yml | 138 +++++++ .github/workflows/standards-validation.yml | 1 + 5 files changed, 1214 insertions(+) create mode 100644 .github/tests/reusable_ci_contract_test.py create mode 100644 .github/workflows/go-ci.yml create mode 100644 .github/workflows/node-ci.yml create mode 100644 .github/workflows/release-gate.yml diff --git a/.github/tests/reusable_ci_contract_test.py b/.github/tests/reusable_ci_contract_test.py new file mode 100644 index 0000000..8bc5b35 --- /dev/null +++ b/.github/tests/reusable_ci_contract_test.py @@ -0,0 +1,429 @@ +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = { + "go": ROOT / ".github/workflows/go-ci.yml", + "node": ROOT / ".github/workflows/node-ci.yml", + "release": ROOT / ".github/workflows/release-gate.yml", +} +TARGET_SHA = "a" * 40 + + +class ReusableCIContractTest(unittest.TestCase): + def test_workflow_call_interfaces_are_typed_and_have_no_outputs_or_secrets(self): + go_inputs = [ + "module-directory", + "go-version-file", + "go-cache-dependency-path", + "lint-check-name", + "test-check-name", + "fuzzers-json", + "run-govulncheck", + "run-qlty", + "run-goreleaser", + "run-workflow-security", + "run-commit-message", + "run-codeql", + ] + self.egress_inputs( + ( + "test", + "lint", + "govulncheck", + "workflow-security", + "commit-message", + "goreleaser", + "codeql", + "qlty", + "fuzz", + ) + ) + node_inputs = [ + "node-version", + "lockfile-path", + "lint-check-name", + "test-check-name", + "build-check-name", + "run-lint", + "run-test", + "run-build", + ] + self.egress_inputs(("lint", "test", "build")) + release_inputs = [ + "target-sha", + "workflow-files-json", + "max-attempts", + "sleep-seconds", + ] + + expected_inputs = { + "go": go_inputs, + "node": node_inputs, + "release": release_inputs, + } + expected_jobs = { + "go": { + "test", + "lint", + "govulncheck", + "workflow-security", + "commit-message", + "goreleaser", + "codeql", + "qlty", + "fuzz", + }, + "node": {"lint", "test", "build"}, + "release": {"gate"}, + } + + for name, path in WORKFLOWS.items(): + workflow = self.read_workflow(path) + header = workflow.split("\npermissions:", 1)[0] + inputs = re.findall(r"^ ([a-z][a-z0-9-]+):$", header, re.MULTILINE) + self.assertEqual(expected_inputs[name], inputs) + self.assertRegex(workflow, r"(?m)^on:\n workflow_call:\s+inputs:") + self.assertNotRegex( + workflow, + r"(?m)^ (push|pull_request|schedule|workflow_dispatch):", + ) + self.assertNotIn("concurrency:", workflow) + self.assertNotIn("outputs:", header) + self.assertNotIn("secrets:", header) + self.assertNotIn("secrets: inherit", workflow) + self.assertIn("permissions: {}", workflow) + + jobs = set( + re.findall( + r"^ ([a-z][a-z0-9-]+):$", + workflow.split("\njobs:\n", 1)[1], + re.MULTILINE, + ) + ) + self.assertEqual(expected_jobs[name], jobs) + + self.assert_input("go", "module-directory", "string", default=".") + self.assert_input("go", "go-version-file", "string", default="go.mod") + self.assert_input("go", "go-cache-dependency-path", "string", default="go.sum") + self.assert_input("go", "lint-check-name", "string", default="Go Lint") + self.assert_input("go", "test-check-name", "string", default="Go Test") + self.assert_input("go", "fuzzers-json", "string", default="[]") + for input_name in ( + "run-govulncheck", + "run-qlty", + "run-goreleaser", + "run-workflow-security", + "run-commit-message", + "run-codeql", + ): + self.assert_input("go", input_name, "boolean", default="false") + + self.assert_input("node", "node-version", "string", default="24") + self.assert_input("node", "lockfile-path", "string", default="package-lock.json") + self.assert_input("node", "lint-check-name", "string", default="Node Lint") + self.assert_input("node", "test-check-name", "string", default="Node Test") + self.assert_input("node", "build-check-name", "string", default="Node Build") + for input_name in ("run-lint", "run-test", "run-build"): + self.assert_input("node", input_name, "boolean", default="false") + + for workflow_name, job_names in ( + ("go", ("test", "lint", "govulncheck", "workflow-security", "commit-message", "goreleaser", "codeql", "qlty", "fuzz")), + ("node", ("lint", "test", "build")), + ): + for job_name in job_names: + self.assert_input(workflow_name, f"{job_name}-egress-policy", "string", default="audit") + self.assert_input(workflow_name, f"{job_name}-allowed-endpoints", "string", default="") + + self.assert_input("release", "target-sha", "string", required=True) + self.assert_input("release", "workflow-files-json", "string", required=True) + self.assert_input("release", "max-attempts", "number", default="12") + self.assert_input("release", "sleep-seconds", "number", default="300") + + def test_central_jobs_have_exact_names_fixed_commands_and_per_job_egress(self): + go = self.read_workflow(WORKFLOWS["go"]) + expected_go_names = { + "test": "${{ inputs.test-check-name }}", + "lint": "${{ inputs.lint-check-name }}", + "govulncheck": "Govulncheck", + "workflow-security": "Workflow Security", + "commit-message": "Commit Message", + "goreleaser": "GoReleaser Config", + "codeql": "CodeQL Analysis", + "qlty": "Qlty Check", + "fuzz": 'Go Fuzz (${{ matrix.fuzzer.name }})', + } + for job, display_name in expected_go_names.items(): + self.assertEqual(display_name, self.job_name(go, job)) + self.assert_job_egress(go, job) + + expected_go_scripts = [ + "./scripts/ci/go-test.sh", + "./scripts/ci/go-lint.sh", + "./scripts/ci/go-govulncheck.sh", + "./scripts/ci/commit-message.sh", + "./scripts/ci/go-release-check.sh", + "./scripts/ci/go-codeql-build.sh", + "./scripts/ci/go-qlty.sh", + "./scripts/ci/go-fuzz.sh", + ] + self.assertEqual(expected_go_scripts, self.fixed_scripts(go)) + self.assertIn("fuzzer: ${{ fromJSON(inputs.fuzzers-json) }}", go) + self.assertIn("go-version-file: ${{ inputs.go-version-file }}", go) + self.assertIn("cache-dependency-path: ${{ inputs.go-cache-dependency-path }}", go) + self.assertIn("MODULE_DIRECTORY: ${{ inputs.module-directory }}", go) + + node = self.read_workflow(WORKFLOWS["node"]) + expected_node_names = { + "lint": "${{ inputs.lint-check-name }}", + "test": "${{ inputs.test-check-name }}", + "build": "${{ inputs.build-check-name }}", + } + for job, display_name in expected_node_names.items(): + self.assertEqual(display_name, self.job_name(node, job)) + self.assert_job_egress(node, job) + self.assertEqual( + [ + "./scripts/ci/node-lint.sh", + "./scripts/ci/node-test.sh", + "./scripts/ci/node-build.sh", + ], + self.fixed_scripts(node), + ) + self.assertIn("node-version: ${{ inputs.node-version }}", node) + self.assertIn("cache-dependency-path: ${{ inputs.lockfile-path }}", node) + + for workflow in (go, node, self.read_workflow(WORKFLOWS["release"])): + self.assertIn("runs-on: ubuntu-24.04", workflow) + for action in re.findall(r"^\s+uses: ([^\s#]+)", workflow, re.MULTILINE): + self.assertRegex(action, r"^[^@]+@[0-9a-f]{40}$") + for run_block in re.findall(r"run: \|\n((?: {10}.*\n|\n)+)", workflow): + self.assertNotIn("${{ inputs.", run_block) + + def test_artifact_uploads_are_central_and_fixed(self): + go = self.read_workflow(WORKFLOWS["go"]) + node = self.read_workflow(WORKFLOWS["node"]) + + self.assertEqual( + ["artifacts/go-test/", "artifacts/go-fuzz/"], + re.findall(r"^\s+path: (artifacts/[^\s]+)$", go, re.MULTILINE), + ) + self.assertEqual( + ["artifacts/node-test/", "artifacts/node-build/"], + re.findall(r"^\s+path: (artifacts/[^\s]+)$", node, re.MULTILINE), + ) + for workflow in (go, node): + upload_count = workflow.count("actions/upload-artifact@") + self.assertEqual(upload_count, workflow.count("if-no-files-found: ignore")) + self.assertEqual(upload_count, workflow.count("retention-days: 14")) + self.assertEqual(upload_count, workflow.count("if: always()")) + + def test_release_gate_checks_every_workflow_by_exact_sha_push_and_nonempty_branch(self): + exact_success = { + "head_sha": TARGET_SHA, + "event": "push", + "head_branch": "dev/v1.7", + "status": "completed", + "conclusion": "success", + } + wrong_runs = [ + {**exact_success, "event": "pull_request"}, + {**exact_success, "head_branch": ""}, + {**exact_success, "head_sha": "b" * 40}, + ] + result = self.run_release_gate( + { + "ci-verify.yml": wrong_runs + [exact_success], + "e2e-playwright.yml": [ + {**exact_success, "head_branch": "main"}, + ], + } + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn(f"Found successful CI Verify push run for {TARGET_SHA}.", result.stdout) + self.assertIn(f"Found successful E2E Playwright push run for {TARGET_SHA}.", result.stdout) + + def test_release_gate_fails_closed_when_any_workflow_lacks_exact_success(self): + result = self.run_release_gate( + { + "ci-verify.yml": [ + { + "head_sha": TARGET_SHA, + "event": "push", + "head_branch": "main", + "status": "completed", + "conclusion": "success", + } + ], + "e2e-playwright.yml": [ + { + "head_sha": "b" * 40, + "event": "push", + "head_branch": "main", + "status": "completed", + "conclusion": "success", + } + ], + } + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn( + f"Timed out waiting for successful E2E Playwright push run for {TARGET_SHA}.", + result.stdout, + ) + + def test_release_gate_rejects_unsafe_inputs_before_api_calls(self): + bad_sha = self.run_release_gate({"ci-verify.yml": []}, target_sha="main") + bad_files = self.run_release_gate( + {"ci-verify.yml": []}, + workflow_files_json='["../../release.yml"]', + ) + bad_attempts = self.run_release_gate( + {"ci-verify.yml": []}, + max_attempts="0", + ) + + self.assertNotEqual(0, bad_sha.returncode) + self.assertIn("target-sha must be a full lowercase commit SHA", bad_sha.stdout) + self.assertNotEqual(0, bad_files.returncode) + self.assertIn("workflow-files-json must be a nonempty JSON array", bad_files.stdout) + self.assertNotEqual(0, bad_attempts.returncode) + self.assertIn("max-attempts must be a positive integer", bad_attempts.stdout) + + def test_standards_validation_runs_this_contract(self): + workflow = self.read_workflow(ROOT / ".github/workflows/standards-validation.yml") + self.assertEqual(1, workflow.count("python3 .github/tests/reusable_ci_contract_test.py")) + + def egress_inputs(self, job_names): + return [item for job in job_names for item in (f"{job}-egress-policy", f"{job}-allowed-endpoints")] + + def read_workflow(self, path): + self.assertTrue(path.is_file(), f"missing reusable workflow: {path.name}") + return path.read_text() + + def input_header(self, workflow_name): + workflow = self.read_workflow(WORKFLOWS[workflow_name]) + return workflow.split("\npermissions:", 1)[0] + + def assert_input(self, workflow_name, input_name, input_type, default=None, required=False): + header = self.input_header(workflow_name) + block = header.split(f" {input_name}:\n", 1)[1] + next_input = re.search(r"^ [a-z][a-z0-9-]+:$", block, re.MULTILINE) + if next_input: + block = block[: next_input.start()] + self.assertIn(f" type: {input_type}\n", block) + if default is not None: + expected = ( + f' default: "{default}"\n' + if default in {"", "[]"} + else f" default: {default}\n" + ) + self.assertIn(expected, block) + if required: + self.assertIn(" required: true\n", block) + + def job_section(self, workflow, job_name): + jobs = workflow.split("\njobs:\n", 1)[1] + section = jobs.split(f" {job_name}:\n", 1)[1] + next_job = re.search(r"^ [a-z][a-z0-9-]+:$", section, re.MULTILINE) + return section[: next_job.start()] if next_job else section + + def job_name(self, workflow, job_name): + section = self.job_section(workflow, job_name) + return re.search(r"^ name: (.+)$", section, re.MULTILINE).group(1).strip('"') + + def assert_job_egress(self, workflow, job_name): + section = self.job_section(workflow, job_name) + self.assertIn(f"egress-policy: ${{{{ inputs.{job_name}-egress-policy }}}}", section) + self.assertIn(f"allowed-endpoints: ${{{{ inputs.{job_name}-allowed-endpoints }}}}", section) + + def fixed_scripts(self, workflow): + return re.findall(r"^\s+run: (\./scripts/ci/[^\s]+)$", workflow, re.MULTILINE) + + def release_gate_script(self): + workflow = self.read_workflow(WORKFLOWS["release"]) + marker = " - name: Verify required CI workflows\n" + self.assertIn(marker, workflow) + step = workflow.split(marker, 1)[1] + block = step.split(" run: |\n", 1)[1] + lines = [] + for line in block.splitlines(): + if line.startswith(" "): + lines.append(line[10:]) + elif not line: + lines.append("") + else: + break + self.assertTrue(lines, "release gate shell step is empty") + return "\n".join(lines) + + def run_release_gate( + self, + runs_by_workflow, + target_sha=TARGET_SHA, + workflow_files_json=None, + max_attempts="1", + ): + with tempfile.TemporaryDirectory() as temp_dir: + temp = Path(temp_dir) + fake_bin = temp / "bin" + fake_bin.mkdir() + fixtures = temp / "fixtures" + fixtures.mkdir() + + metadata = { + "ci-verify.yml": (123, "CI Verify"), + "e2e-playwright.yml": (456, "E2E Playwright"), + } + for workflow_file, runs in runs_by_workflow.items(): + workflow_id = metadata[workflow_file][0] + (fixtures / f"{workflow_id}.json").write_text(json.dumps({"workflow_runs": runs})) + + curl = fake_bin / "curl" + curl.write_text( + "#!/usr/bin/env bash\n" + "for argument in \"$@\"; do url=\"$argument\"; done\n" + "case \"$url\" in\n" + " */actions/workflows/ci-verify.yml) printf '%s' '{\"id\":123,\"name\":\"CI Verify\",\"path\":\".github/workflows/ci-verify.yml\"}' ;;\n" + " */actions/workflows/e2e-playwright.yml) printf '%s' '{\"id\":456,\"name\":\"E2E Playwright\",\"path\":\".github/workflows/e2e-playwright.yml\"}' ;;\n" + " */actions/workflows/123/runs?per_page=100) cat \"$RUNS_FIXTURES/123.json\" ;;\n" + " */actions/workflows/456/runs?per_page=100) cat \"$RUNS_FIXTURES/456.json\" ;;\n" + " *) printf 'unexpected URL: %s\\n' \"$url\" >&2; exit 22 ;;\n" + "esac\n" + ) + curl.chmod(0o755) + + if workflow_files_json is None: + workflow_files_json = json.dumps(list(runs_by_workflow)) + env = os.environ.copy() + env.update( + { + "GH_TOKEN": "test-token", + "GITHUB_API_URL": "https://api.github.test", + "GITHUB_REPOSITORY": "CodesWhat/example", + "MAX_ATTEMPTS": max_attempts, + "PATH": f"{fake_bin}:{env['PATH']}", + "RUNS_FIXTURES": str(fixtures), + "SLEEP_SECONDS": "0", + "TARGET_SHA": target_sha, + "WORKFLOW_FILES_JSON": workflow_files_json, + } + ) + return subprocess.run( + ["bash", "-c", self.release_gate_script()], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000..a1ce024 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,460 @@ +name: Reusable Go CI + +on: + workflow_call: + inputs: + module-directory: + description: Repository-relative Go module directory + required: false + type: string + default: . + go-version-file: + description: Repository-relative Go version file + required: false + type: string + default: go.mod + go-cache-dependency-path: + description: Repository-relative Go cache dependency path + required: false + type: string + default: go.sum + lint-check-name: + description: Exact display name for the Go lint job + required: false + type: string + default: Go Lint + test-check-name: + description: Exact display name for the Go test job + required: false + type: string + default: Go Test + fuzzers-json: + description: JSON array of caller-owned fuzzer name and pkg objects + required: false + type: string + default: "[]" + run-govulncheck: + description: Run the fixed repository govulncheck gate + required: false + type: boolean + default: false + run-qlty: + description: Run the fixed repository Qlty gate + required: false + type: boolean + default: false + run-goreleaser: + description: Run the fixed repository GoReleaser gate + required: false + type: boolean + default: false + run-workflow-security: + description: Run the central workflow security gate + required: false + type: boolean + default: false + run-commit-message: + description: Run the fixed repository commit-message gate on pull requests + required: false + type: boolean + default: false + run-codeql: + description: Run CodeQL for Actions and Go + required: false + type: boolean + default: false + test-egress-policy: + description: Harden Runner egress policy for Go test + required: false + type: string + default: audit + test-allowed-endpoints: + description: Harden Runner allowlist for Go test + required: false + type: string + default: "" + lint-egress-policy: + description: Harden Runner egress policy for Go lint + required: false + type: string + default: audit + lint-allowed-endpoints: + description: Harden Runner allowlist for Go lint + required: false + type: string + default: "" + govulncheck-egress-policy: + description: Harden Runner egress policy for govulncheck + required: false + type: string + default: audit + govulncheck-allowed-endpoints: + description: Harden Runner allowlist for govulncheck + required: false + type: string + default: "" + workflow-security-egress-policy: + description: Harden Runner egress policy for workflow security + required: false + type: string + default: audit + workflow-security-allowed-endpoints: + description: Harden Runner allowlist for workflow security + required: false + type: string + default: "" + commit-message-egress-policy: + description: Harden Runner egress policy for commit-message validation + required: false + type: string + default: audit + commit-message-allowed-endpoints: + description: Harden Runner allowlist for commit-message validation + required: false + type: string + default: "" + goreleaser-egress-policy: + description: Harden Runner egress policy for GoReleaser + required: false + type: string + default: audit + goreleaser-allowed-endpoints: + description: Harden Runner allowlist for GoReleaser + required: false + type: string + default: "" + codeql-egress-policy: + description: Harden Runner egress policy for CodeQL + required: false + type: string + default: audit + codeql-allowed-endpoints: + description: Harden Runner allowlist for CodeQL + required: false + type: string + default: "" + qlty-egress-policy: + description: Harden Runner egress policy for Qlty + required: false + type: string + default: audit + qlty-allowed-endpoints: + description: Harden Runner allowlist for Qlty + required: false + type: string + default: "" + fuzz-egress-policy: + description: Harden Runner egress policy for Go fuzzing + required: false + type: string + default: audit + fuzz-allowed-endpoints: + description: Harden Runner allowlist for Go fuzzing + required: false + type: string + default: "" + +permissions: {} + +jobs: + test: + name: ${{ inputs.test-check-name }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.test-egress-policy }} + allowed-endpoints: ${{ inputs.test-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ inputs.go-version-file }} + cache-dependency-path: ${{ inputs.go-cache-dependency-path }} + + - name: Run repository Go test gate + env: + MODULE_DIRECTORY: ${{ inputs.module-directory }} + run: ./scripts/ci/go-test.sh + + - name: Upload Go test artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: go-test-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/go-test/ + if-no-files-found: ignore + retention-days: 14 + + lint: + name: ${{ inputs.lint-check-name }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.lint-egress-policy }} + allowed-endpoints: ${{ inputs.lint-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ inputs.go-version-file }} + cache-dependency-path: ${{ inputs.go-cache-dependency-path }} + + - name: Run repository Go lint gate + env: + MODULE_DIRECTORY: ${{ inputs.module-directory }} + run: ./scripts/ci/go-lint.sh + + govulncheck: + name: Govulncheck + if: inputs.run-govulncheck + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.govulncheck-egress-policy }} + allowed-endpoints: ${{ inputs.govulncheck-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ inputs.go-version-file }} + cache-dependency-path: ${{ inputs.go-cache-dependency-path }} + + - name: Run repository govulncheck gate + env: + MODULE_DIRECTORY: ${{ inputs.module-directory }} + run: ./scripts/ci/go-govulncheck.sh + + workflow-security: + name: Workflow Security + if: inputs.run-workflow-security + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.workflow-security-egress-policy }} + allowed-endpoints: ${{ inputs.workflow-security-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 + with: + token: ${{ github.token }} + advanced-security: false + online-audits: false + + commit-message: + name: Commit Message + if: inputs.run-commit-message && github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.commit-message-egress-policy }} + allowed-endpoints: ${{ inputs.commit-message-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run repository commit-message gate + env: + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + run: ./scripts/ci/commit-message.sh + + goreleaser: + name: GoReleaser Config + if: inputs.run-goreleaser + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.goreleaser-egress-policy }} + allowed-endpoints: ${{ inputs.goreleaser-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ inputs.go-version-file }} + cache-dependency-path: ${{ inputs.go-cache-dependency-path }} + + - name: Run repository GoReleaser gate + env: + MODULE_DIRECTORY: ${{ inputs.module-directory }} + run: ./scripts/ci/go-release-check.sh + + codeql: + name: CodeQL Analysis + if: inputs.run-codeql + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.codeql-egress-policy }} + allowed-endpoints: ${{ inputs.codeql-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + languages: actions, go + queries: security-and-quality + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ inputs.go-version-file }} + cache-dependency-path: ${{ inputs.go-cache-dependency-path }} + + - name: Build for CodeQL + env: + MODULE_DIRECTORY: ${{ inputs.module-directory }} + run: ./scripts/ci/go-codeql-build.sh + + - name: Analyze with CodeQL + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + + qlty: + name: Qlty Check + if: inputs.run-qlty + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.qlty-egress-policy }} + allowed-endpoints: ${{ inputs.qlty-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Qlty + uses: qltysh/qlty-action/install@08a0a862c159eae9b9003081da6663d96efef637 # v2.3.0 + + - name: Run repository Qlty gate + env: + MODULE_DIRECTORY: ${{ inputs.module-directory }} + run: ./scripts/ci/go-qlty.sh + + fuzz: + name: "Go Fuzz (${{ matrix.fuzzer.name }})" + if: inputs.fuzzers-json != '[]' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + fuzzer: ${{ fromJSON(inputs.fuzzers-json) }} + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.fuzz-egress-policy }} + allowed-endpoints: ${{ inputs.fuzz-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ inputs.go-version-file }} + cache-dependency-path: ${{ inputs.go-cache-dependency-path }} + + - name: Run repository Go fuzzer + env: + FUZZER: ${{ matrix.fuzzer.name }} + MODULE_DIRECTORY: ${{ inputs.module-directory }} + PKG: ${{ matrix.fuzzer.pkg }} + run: ./scripts/ci/go-fuzz.sh + + - name: Upload Go fuzz artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: go-fuzz-${{ matrix.fuzzer.name }}-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/go-fuzz/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml new file mode 100644 index 0000000..e699a9b --- /dev/null +++ b/.github/workflows/node-ci.yml @@ -0,0 +1,186 @@ +name: Reusable Node CI + +on: + workflow_call: + inputs: + node-version: + description: Node.js version used by enabled jobs + required: false + type: string + default: 24 + lockfile-path: + description: Repository-relative npm lockfile path or multiline paths + required: false + type: string + default: package-lock.json + lint-check-name: + description: Exact display name for the Node lint job + required: false + type: string + default: Node Lint + test-check-name: + description: Exact display name for the Node test job + required: false + type: string + default: Node Test + build-check-name: + description: Exact display name for the Node build job + required: false + type: string + default: Node Build + run-lint: + description: Run the fixed repository Node lint gate + required: false + type: boolean + default: false + run-test: + description: Run the fixed repository Node test gate + required: false + type: boolean + default: false + run-build: + description: Run the fixed repository Node build gate + required: false + type: boolean + default: false + lint-egress-policy: + description: Harden Runner egress policy for Node lint + required: false + type: string + default: audit + lint-allowed-endpoints: + description: Harden Runner allowlist for Node lint + required: false + type: string + default: "" + test-egress-policy: + description: Harden Runner egress policy for Node test + required: false + type: string + default: audit + test-allowed-endpoints: + description: Harden Runner allowlist for Node test + required: false + type: string + default: "" + build-egress-policy: + description: Harden Runner egress policy for Node build + required: false + type: string + default: audit + build-allowed-endpoints: + description: Harden Runner allowlist for Node build + required: false + type: string + default: "" + +permissions: {} + +jobs: + lint: + name: ${{ inputs.lint-check-name }} + if: inputs.run-lint + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.lint-egress-policy }} + allowed-endpoints: ${{ inputs.lint-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: ${{ inputs.lockfile-path }} + + - name: Run repository Node lint gate + run: ./scripts/ci/node-lint.sh + + test: + name: ${{ inputs.test-check-name }} + if: inputs.run-test + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.test-egress-policy }} + allowed-endpoints: ${{ inputs.test-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: ${{ inputs.lockfile-path }} + + - name: Run repository Node test gate + run: ./scripts/ci/node-test.sh + + - name: Upload Node test artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: node-test-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/node-test/ + if-no-files-found: ignore + retention-days: 14 + + build: + name: ${{ inputs.build-check-name }} + if: inputs.run-build + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: ${{ inputs.build-egress-policy }} + allowed-endpoints: ${{ inputs.build-allowed-endpoints }} + + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: ${{ inputs.lockfile-path }} + + - name: Run repository Node build gate + run: ./scripts/ci/node-build.sh + + - name: Upload Node build artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: node-build-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/node-build/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 0000000..0602b43 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,138 @@ +name: Reusable Release Gate + +on: + workflow_call: + inputs: + target-sha: + description: Full commit SHA whose branch push runs must have succeeded + required: true + type: string + workflow-files-json: + description: Nonempty JSON array of workflow filenames to verify + required: true + type: string + max-attempts: + description: Maximum polls for each required workflow + required: false + type: number + default: 12 + sleep-seconds: + description: Seconds between polls + required: false + type: number + default: 300 + +permissions: {} + +jobs: + gate: + name: Release Gate + runs-on: ubuntu-24.04 + timeout-minutes: 360 + permissions: + actions: read + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: api.github.com:443 + + - name: Verify required CI workflows + env: + GH_TOKEN: ${{ github.token }} + MAX_ATTEMPTS: ${{ inputs.max-attempts }} + SLEEP_SECONDS: ${{ inputs.sleep-seconds }} + TARGET_SHA: ${{ inputs.target-sha }} + WORKFLOW_FILES_JSON: ${{ inputs.workflow-files-json }} + run: | + set -euo pipefail + + if ! [[ "${TARGET_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::target-sha must be a full lowercase commit SHA." + exit 1 + fi + if ! [[ "${MAX_ATTEMPTS}" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::max-attempts must be a positive integer." + exit 1 + fi + if ! [[ "${SLEEP_SECONDS}" =~ ^[0-9]+$ ]]; then + echo "::error::sleep-seconds must be a nonnegative integer." + exit 1 + fi + if ! jq -e ' + type == "array" + and length > 0 + and all(.[]; + type == "string" + and test("^[A-Za-z0-9][A-Za-z0-9._-]*[.]ya?ml$") + ) + ' <<< "${WORKFLOW_FILES_JSON}" >/dev/null; then + echo "::error::workflow-files-json must be a nonempty JSON array of workflow filenames." + exit 1 + fi + + workflow_files=() + while IFS= read -r workflow_file; do + workflow_files+=("${workflow_file}") + done < <(jq -r '.[]' <<< "${WORKFLOW_FILES_JSON}") + + for workflow_file in "${workflow_files[@]}"; do + workflow_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_file}" + workflow_json="$(curl --proto '=https' --tlsv1.2 --retry 3 --fail --silent --show-error \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "${workflow_url}")" + workflow_id="$(echo "${workflow_json}" | jq -r '.id // empty')" + workflow_name="$(echo "${workflow_json}" | jq -r '.name // empty')" + workflow_path="$(echo "${workflow_json}" | jq -r '.path // empty')" + expected_path=".github/workflows/${workflow_file}" + if [ -z "${workflow_id}" ] || [ -z "${workflow_name}" ] || [ "${workflow_path}" != "${expected_path}" ]; then + echo "::error::Failed to resolve workflow metadata for ${workflow_file}." + exit 1 + fi + + found_success=false + for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + runs_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_id}/runs?per_page=100" + runs_json="$(curl --proto '=https' --tlsv1.2 --retry 3 --fail --silent --show-error \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "${runs_url}")" + filtered="$(echo "${runs_json}" | jq --arg sha "${TARGET_SHA}" '{ + workflow_runs: [.workflow_runs[] + | select( + .head_sha == $sha + and .event == "push" + and ((.head_branch // "") | length > 0) + )] + }')" + + success_count="$(echo "${filtered}" | jq '[.workflow_runs[] | select(.conclusion == "success")] | length')" + in_progress_count="$(echo "${filtered}" | jq '[.workflow_runs[] | select(.status != "completed")] | length')" + completed_count="$(echo "${filtered}" | jq '[.workflow_runs[] | select(.status == "completed")] | length')" + + if [ "${success_count}" -gt 0 ]; then + echo "Found successful ${workflow_name} push run for ${TARGET_SHA}." + found_success=true + break + fi + + if [ "${completed_count}" -gt 0 ] && [ "${in_progress_count}" -eq 0 ]; then + echo "::error::${workflow_name} push runs for ${TARGET_SHA} completed without success." + echo "${filtered}" | jq '.workflow_runs[] | {id, status, conclusion, head_branch, html_url}' + exit 1 + fi + + echo "Attempt ${attempt}/${MAX_ATTEMPTS}: waiting for ${workflow_name} push run for ${TARGET_SHA}." + if [ "${attempt}" -lt "${MAX_ATTEMPTS}" ]; then + sleep "${SLEEP_SECONDS}" + fi + done + + if [ "${found_success}" != "true" ]; then + echo "::error::Timed out waiting for successful ${workflow_name} push run for ${TARGET_SHA}." + exit 1 + fi + done diff --git a/.github/workflows/standards-validation.yml b/.github/workflows/standards-validation.yml index d9079a1..192e5bd 100644 --- a/.github/workflows/standards-validation.yml +++ b/.github/workflows/standards-validation.yml @@ -45,6 +45,7 @@ jobs: ruby -e 'require "yaml"; Dir.glob("**/*.{yml,yaml}", File::FNM_DOTMATCH).sort.each { |path| YAML.parse_file(path) }' python3 -c 'import json; from pathlib import Path; [json.load(path.open()) for path in Path(".").rglob("*.json")]' python3 .github/tests/greptile_config_contract_test.py + python3 .github/tests/reusable_ci_contract_test.py - name: Lint Markdown run: | From 081678f4cc660a0cfb0b8fa1d74425bd5e280e68 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:27:29 -0400 Subject: [PATCH 2/2] fix(workflows): harden reusable release contracts --- .github/tests/reusable_ci_contract_test.py | 110 +++++++++++++++++++-- .github/workflows/go-ci.yml | 2 +- .github/workflows/release-gate.yml | 54 ++++++++-- 3 files changed, 149 insertions(+), 17 deletions(-) diff --git a/.github/tests/reusable_ci_contract_test.py b/.github/tests/reusable_ci_contract_test.py index 8bc5b35..a6ec3f5 100644 --- a/.github/tests/reusable_ci_contract_test.py +++ b/.github/tests/reusable_ci_contract_test.py @@ -173,6 +173,10 @@ def test_central_jobs_have_exact_names_fixed_commands_and_per_job_egress(self): ] self.assertEqual(expected_go_scripts, self.fixed_scripts(go)) self.assertIn("fuzzer: ${{ fromJSON(inputs.fuzzers-json) }}", go) + self.assertIn( + "if: inputs.fuzzers-json != '' && fromJSON(inputs.fuzzers-json)[0] != null", + go, + ) self.assertIn("go-version-file: ${{ inputs.go-version-file }}", go) self.assertIn("cache-dependency-path: ${{ inputs.go-cache-dependency-path }}", go) self.assertIn("MODULE_DIRECTORY: ${{ inputs.module-directory }}", go) @@ -248,6 +252,56 @@ def test_release_gate_checks_every_workflow_by_exact_sha_push_and_nonempty_branc self.assertIn(f"Found successful CI Verify push run for {TARGET_SHA}.", result.stdout) self.assertIn(f"Found successful E2E Playwright push run for {TARGET_SHA}.", result.stdout) + def test_release_gate_keeps_authoritative_client_side_run_filtering(self): + release = self.read_workflow(WORKFLOWS["release"]) + + self.assertIn("/runs?per_page=100\"", release) + self.assertNotRegex(release, r"runs\?[^\"\n]*(?:event|head_sha)=") + self.assertIn("spuriously returning 0 results since 2026-04-27", release) + + def test_release_gate_uses_one_shared_poll_budget_for_every_workflow(self): + in_progress = { + "head_sha": TARGET_SHA, + "event": "push", + "head_branch": "dev/v1.7", + "status": "in_progress", + "conclusion": None, + } + success = {**in_progress, "status": "completed", "conclusion": "success"} + result = self.run_release_gate( + {}, + runs_sequences_by_workflow={ + "ci-verify.yml": [[in_progress], [success]], + "e2e-playwright.yml": [[in_progress], [success]], + }, + max_attempts="2", + ) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, result.stdout.count("FAKE_SLEEP"), result.stdout) + + def test_release_gate_waits_when_an_exact_run_is_still_in_progress(self): + exact_run = { + "head_sha": TARGET_SHA, + "event": "push", + "head_branch": "main", + } + result = self.run_release_gate( + { + "ci-verify.yml": [ + {**exact_run, "status": "completed", "conclusion": "failure"}, + {**exact_run, "status": "in_progress", "conclusion": None}, + ], + } + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("Attempt 1/1: waiting for CI Verify", result.stdout) + self.assertIn( + f"Timed out waiting for successful CI Verify push run for {TARGET_SHA}.", + result.stdout, + ) + def test_release_gate_fails_closed_when_any_workflow_lacks_exact_success(self): result = self.run_release_gate( { @@ -296,6 +350,25 @@ def test_release_gate_rejects_unsafe_inputs_before_api_calls(self): self.assertNotEqual(0, bad_attempts.returncode) self.assertIn("max-attempts must be a positive integer", bad_attempts.stdout) + def test_release_gate_rejects_non_github_api_hosts_before_api_calls(self): + result = self.run_release_gate( + { + "ci-verify.yml": [ + { + "head_sha": TARGET_SHA, + "event": "push", + "head_branch": "main", + "status": "completed", + "conclusion": "success", + } + ] + }, + api_url="https://github.example/api/v3", + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("release-gate supports api.github.com only", result.stdout) + def test_standards_validation_runs_this_contract(self): workflow = self.read_workflow(ROOT / ".github/workflows/standards-validation.yml") self.assertEqual(1, workflow.count("python3 .github/tests/reusable_ci_contract_test.py")) @@ -369,6 +442,8 @@ def run_release_gate( target_sha=TARGET_SHA, workflow_files_json=None, max_attempts="1", + api_url="https://api.github.com", + runs_sequences_by_workflow=None, ): with tempfile.TemporaryDirectory() as temp_dir: temp = Path(temp_dir) @@ -381,9 +456,18 @@ def run_release_gate( "ci-verify.yml": (123, "CI Verify"), "e2e-playwright.yml": (456, "E2E Playwright"), } - for workflow_file, runs in runs_by_workflow.items(): + sequences = { + workflow_file: [runs] + for workflow_file, runs in runs_by_workflow.items() + } + sequences.update(runs_sequences_by_workflow or {}) + for workflow_file, runs_sequence in sequences.items(): workflow_id = metadata[workflow_file][0] - (fixtures / f"{workflow_id}.json").write_text(json.dumps({"workflow_runs": runs})) + for index, runs in enumerate(runs_sequence, start=1): + (fixtures / f"{workflow_id}.{index}.json").write_text( + json.dumps({"workflow_runs": runs}) + ) + (fixtures / f"{workflow_id}.last").write_text(str(len(runs_sequence))) curl = fake_bin / "curl" curl.write_text( @@ -392,20 +476,34 @@ def run_release_gate( "case \"$url\" in\n" " */actions/workflows/ci-verify.yml) printf '%s' '{\"id\":123,\"name\":\"CI Verify\",\"path\":\".github/workflows/ci-verify.yml\"}' ;;\n" " */actions/workflows/e2e-playwright.yml) printf '%s' '{\"id\":456,\"name\":\"E2E Playwright\",\"path\":\".github/workflows/e2e-playwright.yml\"}' ;;\n" - " */actions/workflows/123/runs?per_page=100) cat \"$RUNS_FIXTURES/123.json\" ;;\n" - " */actions/workflows/456/runs?per_page=100) cat \"$RUNS_FIXTURES/456.json\" ;;\n" + " */actions/workflows/123/runs?per_page=100) workflow_id=123 ;;\n" + " */actions/workflows/456/runs?per_page=100) workflow_id=456 ;;\n" " *) printf 'unexpected URL: %s\\n' \"$url\" >&2; exit 22 ;;\n" "esac\n" + "if [ -n \"${workflow_id:-}\" ]; then\n" + " count_file=\"$RUNS_FIXTURES/$workflow_id.count\"\n" + " count=0\n" + " [ ! -f \"$count_file\" ] || count=$(cat \"$count_file\")\n" + " count=$((count + 1))\n" + " printf '%s' \"$count\" > \"$count_file\"\n" + " last=$(cat \"$RUNS_FIXTURES/$workflow_id.last\")\n" + " [ \"$count\" -le \"$last\" ] || count=\"$last\"\n" + " cat \"$RUNS_FIXTURES/$workflow_id.$count.json\"\n" + "fi\n" ) curl.chmod(0o755) + sleep = fake_bin / "sleep" + sleep.write_text("#!/usr/bin/env bash\nprintf 'FAKE_SLEEP %s\\n' \"$1\"\n") + sleep.chmod(0o755) + if workflow_files_json is None: - workflow_files_json = json.dumps(list(runs_by_workflow)) + workflow_files_json = json.dumps(list(sequences)) env = os.environ.copy() env.update( { "GH_TOKEN": "test-token", - "GITHUB_API_URL": "https://api.github.test", + "GITHUB_API_URL": api_url, "GITHUB_REPOSITORY": "CodesWhat/example", "MAX_ATTEMPTS": max_attempts, "PATH": f"{fake_bin}:{env['PATH']}", diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index a1ce024..46601d4 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -415,7 +415,7 @@ jobs: fuzz: name: "Go Fuzz (${{ matrix.fuzzer.name }})" - if: inputs.fuzzers-json != '[]' + if: inputs.fuzzers-json != '' && fromJSON(inputs.fuzzers-json)[0] != null runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 0602b43..f86ff64 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -49,6 +49,10 @@ jobs: run: | set -euo pipefail + if [ "${GITHUB_API_URL}" != "https://api.github.com" ]; then + echo "::error::release-gate supports api.github.com only so its blocked egress allowlist matches the API host." + exit 1 + fi if ! [[ "${TARGET_SHA}" =~ ^[0-9a-f]{40}$ ]]; then echo "::error::target-sha must be a full lowercase commit SHA." exit 1 @@ -78,6 +82,8 @@ jobs: workflow_files+=("${workflow_file}") done < <(jq -r '.[]' <<< "${WORKFLOW_FILES_JSON}") + workflow_ids=() + workflow_names=() for workflow_file in "${workflow_files[@]}"; do workflow_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_file}" workflow_json="$(curl --proto '=https' --tlsv1.2 --retry 3 --fail --silent --show-error \ @@ -92,9 +98,26 @@ jobs: echo "::error::Failed to resolve workflow metadata for ${workflow_file}." exit 1 fi + workflow_ids+=("${workflow_id}") + workflow_names+=("${workflow_name}") + done + + workflow_succeeded=() + for index in "${!workflow_ids[@]}"; do + workflow_succeeded["${index}"]=false + done + + for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + waiting=false + for index in "${!workflow_ids[@]}"; do + if [ "${workflow_succeeded[${index}]}" = "true" ]; then + continue + fi - found_success=false - for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + workflow_id="${workflow_ids[${index}]}" + workflow_name="${workflow_names[${index}]}" + # GitHub's `?head_sha=` and `?event=` query filters have been spuriously returning 0 results since 2026-04-27. + # Filter the latest 100 runs client-side instead. runs_url="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow_id}/runs?per_page=100" runs_json="$(curl --proto '=https' --tlsv1.2 --retry 3 --fail --silent --show-error \ -H "Authorization: Bearer ${GH_TOKEN}" \ @@ -115,8 +138,8 @@ jobs: if [ "${success_count}" -gt 0 ]; then echo "Found successful ${workflow_name} push run for ${TARGET_SHA}." - found_success=true - break + workflow_succeeded["${index}"]=true + continue fi if [ "${completed_count}" -gt 0 ] && [ "${in_progress_count}" -eq 0 ]; then @@ -126,13 +149,24 @@ jobs: fi echo "Attempt ${attempt}/${MAX_ATTEMPTS}: waiting for ${workflow_name} push run for ${TARGET_SHA}." - if [ "${attempt}" -lt "${MAX_ATTEMPTS}" ]; then - sleep "${SLEEP_SECONDS}" - fi + waiting=true done - if [ "${found_success}" != "true" ]; then - echo "::error::Timed out waiting for successful ${workflow_name} push run for ${TARGET_SHA}." - exit 1 + if [ "${waiting}" != "true" ]; then + break + fi + if [ "${attempt}" -lt "${MAX_ATTEMPTS}" ]; then + sleep "${SLEEP_SECONDS}" fi done + + timed_out=false + for index in "${!workflow_ids[@]}"; do + if [ "${workflow_succeeded[${index}]}" != "true" ]; then + echo "::error::Timed out waiting for successful ${workflow_names[${index}]} push run for ${TARGET_SHA}." + timed_out=true + fi + done + if [ "${timed_out}" = "true" ]; then + exit 1 + fi