Files
rustfs/scripts/resolve_functional_candidate.py
T
Chris c447ad66e2 fix(ci): verify complete functional chain evidence (#7690)
* fix(ci): bind nightly lanes to one resolved source

* fix(ci): verify complete functional chain evidence
2026-09-12 12:09:58 +08:00

125 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""Resolve a nightly manifest from its exact GitHub build attempt and artifact."""
from __future__ import annotations
import hashlib
import io
import json
import os
from pathlib import Path
import re
import subprocess
import zipfile
REPOSITORY = "rustfs/rustfs"
ROOT = Path(__file__).resolve().parents[1]
MAX_ARCHIVE = 1024 * 1024
def require(condition, message):
if not condition:
raise ValueError(message)
def sha(value, length=40):
return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{%d}" % length, value) is not None
def positive(value):
return type(value) is int and value > 0
def api(path, binary=False):
result = subprocess.run(["gh", "api", path], check=True, capture_output=True, timeout=60)
return result.stdout if binary else json.loads(result.stdout)
def validate_manifest(manifest, run):
require(isinstance(manifest, dict), "manifest must be an object")
common = {"schema", "source_sha", "build_run_id", "build_run_attempt", "package_url", "package_sha256"}
version = manifest.get("schema")
require(type(version) is int and version in (1, 2), "unsupported candidate schema")
require(set(manifest) == (common if version == 1 else common | {"workflow_sha", "source_ref"}), "unexpected candidate fields")
require(positive(manifest["build_run_id"]) and positive(manifest["build_run_attempt"]), "invalid build identity")
require((manifest["build_run_id"], manifest["build_run_attempt"]) == (run["id"], run["run_attempt"]), "candidate belongs to another build attempt")
require(sha(manifest["source_sha"]) and sha(manifest["package_sha256"], 64), "invalid candidate hash")
if version == 1:
require(manifest["source_sha"] == run["head_sha"], "legacy manifest cannot identify a different build source")
else:
require(manifest["workflow_sha"] == run["head_sha"] and sha(manifest["workflow_sha"]), "candidate workflow SHA differs from artifact provenance")
require(isinstance(manifest["source_ref"], str) and bool(re.fullmatch(r"[A-Za-z0-9_./-]{1,200}", manifest["source_ref"])), "invalid build source ref")
expected = (f"https://dl.rustfs.com/artifacts/rustfs/packages/nightly/runs/{run['id']}/"
f"{run['run_attempt']}/{manifest['package_sha256']}/rustfs.deb")
require(manifest["package_url"] == expected, "package URL does not bind the run, attempt and checksum")
return manifest
def read_json_artifact(archive, artifact, run, expected_name, member, max_json=16384):
require(artifact.get("expired") is False, "candidate artifact expired")
require(positive(artifact.get("id")), "invalid artifact id")
require(artifact.get("name") == expected_name, "candidate artifact belongs to another attempt")
provenance = artifact.get("workflow_run") or {}
require(provenance.get("id") == run["id"] and provenance.get("head_sha") == run["head_sha"], "candidate artifact belongs to another workflow run")
require(0 < len(archive) <= MAX_ARCHIVE and artifact.get("size_in_bytes") == len(archive), "candidate artifact size mismatch")
require(artifact.get("digest") == "sha256:" + hashlib.sha256(archive).hexdigest(), "candidate artifact checksum mismatch")
with zipfile.ZipFile(io.BytesIO(archive)) as source:
files = source.infolist()
require(len(files) == 1 and files[0].filename == member, "unexpected candidate archive members")
require(0 < files[0].file_size <= max_json and not files[0].is_dir(), "candidate manifest too large or empty")
manifest = json.loads(source.read(files[0]))
return manifest
def read_manifest(archive, artifact, run):
name = f"nightly-candidate-{run['id']}-{run['run_attempt']}"
return validate_manifest(read_json_artifact(archive, artifact, run, name, name + ".json"), run)
def resolve(run_id, attempt):
require(positive(run_id) and positive(attempt), "build run and attempt are required positive integers")
endpoint = f"repos/{REPOSITORY}/actions/runs/{run_id}"
run = api(f"{endpoint}/attempts/{attempt}")
require(run.get("id") == run_id and run.get("run_attempt") == attempt, "GitHub returned a different build attempt")
require(run.get("path") == ".github/workflows/nightly-gnu.yml" and run.get("head_branch") == "main", "candidate must come from nightly-gnu on main")
require((run.get("head_repository") or {}).get("full_name") == REPOSITORY, "candidate came from another repository")
require(run.get("event") in ("schedule", "workflow_dispatch") and run.get("status") == "completed" and run.get("conclusion") == "success", "nightly attempt has not completed successfully")
name = f"nightly-candidate-{run_id}-{attempt}"
artifacts = []
for page in range(1, 11):
batch = api(f"{endpoint}/artifacts?per_page=100&page={page}")["artifacts"]
artifacts.extend(item for item in batch if item.get("name") == name)
if len(batch) < 100:
break
else:
raise ValueError("too many build artifacts to resolve safely")
require(len(artifacts) == 1, "missing or ambiguous candidate artifact")
artifact = artifacts[0]
require(type(artifact.get("size_in_bytes")) is int and 0 < artifact["size_in_bytes"] <= MAX_ARCHIVE, "candidate artifact size is invalid")
archive = api(f"repos/{REPOSITORY}/actions/artifacts/{artifact['id']}/zip", binary=True)
manifest = read_manifest(archive, artifact, run)
return {"manifest": manifest, "artifact_id": artifact["id"], "artifact_digest": artifact["digest"],
"workflow_sha": run["head_sha"], "workflow_ref": run["head_branch"], "build_started_at": run["run_started_at"]}
def prepare():
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
if os.environ["GITHUB_EVENT_NAME"] == "workflow_run":
build = event["workflow_run"]
require(build.get("event") == "schedule", "automatic chain requires a scheduled build")
run_id, attempt = build["id"], build["run_attempt"]
else:
run_id, attempt = int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"])
candidate = resolve(run_id, attempt)
revision = (ROOT / ".config/functional-script-revision.txt").read_text().strip()
require(sha(revision), "private test script revision must be pinned")
chain = {"schema": 1, "run_id": int(os.environ["GITHUB_RUN_ID"]), "attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]),
"workflow_sha": os.environ["GITHUB_SHA"], "testing_sha": revision, "candidate": candidate}
encoded = json.dumps(chain, sort_keys=True, separators=(",", ":"))
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write("manifest=" + encoded + "\n")
Path(os.environ["CHAIN_OUTPUT"]).write_text(encoded + "\n")
if __name__ == "__main__":
prepare()