Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`
Expand Down
92 changes: 92 additions & 0 deletions tests/release_publish_invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Comment thread
Fieldnote-Echo marked this conversation as resolved.
fail(f"{path}: id-token: write must be scoped to explicit signing/publishing jobs, not workflow-wide")
Comment thread
Fieldnote-Echo marked this conversation as resolved.

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"
)
Comment thread
Fieldnote-Echo marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

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")
Expand Down Expand Up @@ -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)
Expand Down
Loading