diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa185ad..c950135 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,8 +82,8 @@ # workflow_dispatch setting (branch = `main` only) would now deadlock # publishing — this workflow runs on `refs/tags/...`, never on # `refs/heads/main`. The "tag must come from main" guarantee is preserved -# by `require-ci-green` (which queries `?branch=main&status=success` for -# the SHA), plus branch protection on `main`. See RELEASING.md. +# by `require-ci-green` (which queries successful push runs on `main` +# for the SHA), plus branch protection on `main`. See RELEASING.md. # # Tag glob is deliberately loose (`v[0-9]*...`); the `guard` job enforces strict # SemVer (no leading zeros, no pre-release/build suffix). The tag name is only @@ -140,12 +140,13 @@ jobs: # The build jobs below rebuild + retest, but do NOT cover the core crate's # lint / no-default / experimental / MSRV 1.89 / deps + cargo-deny gates # (ci.yml), the binding's full OS/Python matrix (python.yml), the loader / - # FastScan fuzz smoke (fuzz.yml), or the CodeQL scan (codeql.yml). Require + # FastScan fuzz smoke (fuzz.yml), the CodeQL scan (codeql.yml), and the + # workflow syntax/security lint gates (actionlint.yml, zizmor.yml). Require # each to have concluded `success` for this exact SHA on main. # # Only per-push-to-main workflows are gated, so a run for the release SHA is # guaranteed to exist: audit.yml is schedule-only; coverage*/scorecard are - # advisory/external-flaky; zizmor/actionlint are pre-merge hygiene. + # advisory/external-flaky. name: require full CI green for this commit needs: guard if: needs.guard.outputs.ok == 'true' @@ -158,23 +159,23 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - name: assert ci.yml, python.yml, fuzz.yml and codeql.yml are green for this commit + - name: assert release-gated workflows are green for this commit env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} SHA: ${{ github.sha }} run: | set -euo pipefail - # Require a SUCCESSFUL run for this SHA *on main* for each workflow. + # Require a SUCCESSFUL push run for this SHA *on main* for each workflow. # Filtering on branch as well as head_sha stops a green run for the same # commit on an unrelated branch from satisfying the gate. - for wf in ci.yml python.yml fuzz.yml codeql.yml; do + for wf in ci.yml python.yml fuzz.yml codeql.yml actionlint.yml zizmor.yml; do ok="$(gh api \ - "repos/${REPO}/actions/workflows/${wf}/runs?head_sha=${SHA}&branch=main&status=success&per_page=20" \ - --jq '[.workflow_runs[] | select(.head_branch == "main" and .conclusion == "success")] | length')" - echo "successful ${wf} runs for ${SHA} on main: ${ok}" + "repos/${REPO}/actions/workflows/${wf}/runs?head_sha=${SHA}&branch=main&event=push&status=success&per_page=20" \ + --jq '[.workflow_runs[] | select(.head_branch == "main" and .event == "push" and .conclusion == "success")] | length')" + echo "successful push ${wf} runs for ${SHA} on main: ${ok}" if [ "${ok}" -lt 1 ]; then - echo "::error::no successful ${wf} run for ${SHA} on main. Push to main, let CI pass, then re-tag." + echo "::error::no successful push ${wf} run for ${SHA} on main. Push to main, let CI pass, then re-tag." exit 1 fi done diff --git a/RELEASING.md b/RELEASING.md index c0e23cc..9982628 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,7 +22,8 @@ The unified `release.yml`: step rejects pre-release / leading-zero / non-SemVer tags so they wake the workflow but skip every job below the gate; - runs a **`require-ci-green`** gate confirming the per-commit CI is green on - `main` for the tagged SHA — `ci.yml`, `python.yml`, `fuzz.yml`, `codeql.yml` + `main` for the tagged SHA — `ci.yml`, `python.yml`, `fuzz.yml`, `codeql.yml`, + `actionlint.yml`, `zizmor.yml` (a *successful* run for that exact SHA on `main`); - publishes via **OIDC trusted publishing** (no long-lived crates.io / PyPI tokens in the repo) for both Rust crates and the Python distribution; @@ -150,8 +151,8 @@ filename. Until a record is updated, the corresponding gated publish fails compatibility break. Commit on `main`. 4. Confirm CI is **green for current `main` HEAD**. `require-ci-green` checks `main` HEAD's SHA — which needs a **completed, successful** (not - `cancelled`, not in-progress) run of `ci.yml`, `python.yml`, `fuzz.yml`, and - `codeql.yml`. + `cancelled`, not in-progress) run of `ci.yml`, `python.yml`, `fuzz.yml`, + `codeql.yml`, `actionlint.yml`, and `zizmor.yml`. - The `ci.yml` AVX-512 job is release-blocking and installs Intel SDE. A downloadmirror `403` / outage is external infrastructure, but it still means the SHA is **not releasable** until that same SHA has a successful `ci.yml` diff --git a/tests/release_publish_invariants.py b/tests/release_publish_invariants.py index e07bfb5..1159c0a 100644 --- a/tests/release_publish_invariants.py +++ b/tests/release_publish_invariants.py @@ -449,6 +449,97 @@ def check_hash_requirement_temp_paths(paths: list[str]) -> None: fail(f"{path}: hash requirement files must be written under ${{RUNNER_TEMP}}, not /tmp") +def trigger_names(on_value: Any) -> set[str]: + if isinstance(on_value, str): + return {on_value} + if isinstance(on_value, list): + return {item for item in on_value if isinstance(item, str)} + if isinstance(on_value, dict): + return {key for key in on_value if isinstance(key, str)} + return set() + + +def check_release_security_gates(workflow: dict[str, Any], path: str) -> None: + blocked_triggers = {"pull_request_target", "workflow_run"} + on_value = workflow.get("on", workflow.get(True)) + blocked = trigger_names(on_value) & blocked_triggers + if blocked: + fail( + f"{path}: release workflow must not use trusted-publishing-blocked triggers: " + f"{', '.join(sorted(blocked))}" + ) + + top_permissions = workflow.get("permissions") + if top_permissions is not None and not isinstance(top_permissions, dict): + fail(f"{path}: workflow permissions must be an explicit mapping, not {top_permissions!r}") + if isinstance(top_permissions, dict) and top_permissions.get("id-token") == "write": + fail(f"{path}: id-token: write must be scoped to explicit signing/publishing jobs, not workflow-wide") + + jobs = mapping(workflow.get("jobs"), f"{path}: jobs") + require_job = mapping(jobs.get("require-ci-green"), f"{path}: jobs.require-ci-green") + steps = sequence(require_job.get("steps"), f"{path}: jobs.require-ci-green.steps") + gated_workflows = ("ci.yml", "python.yml", "fuzz.yml", "codeql.yml", "actionlint.yml", "zizmor.yml") + found_loop: tuple[str, ...] | None = None + found_gate_run: str | None = None + for index, raw_step in enumerate(steps): + step = mapping(raw_step, f"{path}: jobs.require-ci-green.steps[{index}]") + run = step.get("run") + if not isinstance(run, str): + continue + match = re.search(r"(?m)^\s*for\s+wf\s+in\s+(.+?);\s+do\s*$", run) + if match: + found_loop = tuple(shlex.split(match.group(1))) + found_gate_run = run + break + if found_loop is None: + fail(f"{path}: require-ci-green must loop over the release-gated workflow filenames") + if found_loop != gated_workflows: + fail( + f"{path}: require-ci-green gates {found_loop!r}; expected {gated_workflows!r}" + ) + if found_gate_run is None or "event=push" not in found_gate_run or '.event == "push"' not in found_gate_run: + fail(f"{path}: require-ci-green must require successful push workflow runs") + + allowed_id_token_jobs = { + "attest", + "provenance", + "publish-crate", + "attest-manifest", + "manifest-provenance", + "publish-manifest-crate", + "publish-pypi", + } + for job_name, raw_job in jobs.items(): + if not isinstance(job_name, str): + continue + job = mapping(raw_job, f"{path}: jobs.{job_name}") + permissions = job.get("permissions") + if permissions is not None and not isinstance(permissions, dict): + fail(f"{path}: jobs.{job_name}.permissions must be an explicit mapping, not {permissions!r}") + if not isinstance(permissions, dict): + continue + id_token = permissions.get("id-token") + if id_token == "write" and job_name not in allowed_id_token_jobs: + fail( + f"{path}: jobs.{job_name} grants id-token: write but is not an allowed " + "release signing/publishing job" + ) + + for job_name, environment in ( + ("publish-crate", "crates-io"), + ("publish-manifest-crate", "crates-io"), + ("publish-pypi", "pypi"), + ): + job = mapping(jobs.get(job_name), f"{path}: jobs.{job_name}") + raw_environment = job.get("environment") + if isinstance(raw_environment, dict): + actual = raw_environment.get("name") + else: + actual = raw_environment + if actual != environment: + fail(f"{path}: jobs.{job_name} must use environment {environment!r}; got {actual!r}") + + def check_aarch64_smoke_selector(workflow: dict[str, Any], path: str) -> None: jobs = mapping(workflow.get("jobs"), f"{path}: jobs") job = mapping(jobs.get("smoke-linux-aarch64-wheel"), f"{path}: jobs.smoke-linux-aarch64-wheel") @@ -899,6 +990,7 @@ def main() -> None: check_hash_requirement_temp_paths( [WORKFLOW_PATH, PYTHON_WORKFLOW_PATH, CI_WORKFLOW_PATH, COVERAGE_WORKFLOW_PATH] ) + check_release_security_gates(workflow, WORKFLOW_PATH) check_aarch64_smoke_selector(workflow, WORKFLOW_PATH) check_pypi_canonical_dist(workflow, WORKFLOW_PATH) check_publish_crates(workflow, WORKFLOW_PATH)