diff --git a/docs/releasing.md b/docs/releasing.md index 5f35b1b..3226934 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -51,7 +51,7 @@ count. So a build reaches people only when you publish it, after testing it. The script checks that the tag is on GitHub, that exact-commit main CI (including e2e), native amd64/ARM64 Decky runtime checks, and the tagged release workflow are green with no failed, - cancelled, skipped or running jobs, and that all ten installers + cancelled, skipped or running jobs in any run attempt, and that all ten installers are attached, each with the SHA-256 digest GitHub records. It also checks that the draft does not begin with a `WITHDRAWN` notice, so the preserved v0.4.48 draft cannot accidentally be offered again. It checks diff --git a/scripts/verify-release-gates.py b/scripts/verify-release-gates.py index 96a4c1a..5664afb 100644 --- a/scripts/verify-release-gates.py +++ b/scripts/verify-release-gates.py @@ -77,19 +77,25 @@ def successful_jobs(run, required): attempt = run.get("run_attempt") if not isinstance(attempt, int) or attempt < 1: raise GateError(f"run {run['id']} has no valid attempt number") - data = api(f"actions/runs/{run['id']}/attempts/{attempt}/jobs?per_page=100") - jobs = data.get("jobs") or [] - if data.get("total_count") != len(jobs): - raise GateError(f"run {run['id']} has more jobs than the gate inspected") - names = [job.get("name") for job in jobs] - if len(names) != len(set(names)) or not required.issubset(names): + latest_jobs = None + for number in range(1, attempt + 1): + data = api(f"actions/runs/{run['id']}/attempts/{number}/jobs?per_page=100") + jobs = data.get("jobs") or [] + if data.get("total_count") != len(jobs): + raise GateError(f"run {run['id']} attempt {number} has more jobs than the gate inspected") + names = [job.get("name") for job in jobs] + if not jobs or len(names) != len(set(names)): + raise GateError(f"run {run['id']} attempt {number} has missing or duplicate jobs") + bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}" + for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"] + if bad: + raise GateError(f"run {run['id']} attempt {number} has unfinished, failed, cancelled, or skipped jobs: {bad}") + latest_jobs = jobs + names = [job.get("name") for job in latest_jobs] + if not required.issubset(names): missing = sorted(required.difference(names)) - raise GateError(f"run {run['id']} has missing or duplicate jobs: {missing}") - bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}" - for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"] - if bad: - raise GateError(f"run {run['id']} has unfinished, failed, cancelled, or skipped jobs: {bad}") - print(f"gate passed: {run.get('html_url')} ({len(jobs)} jobs)") + raise GateError(f"run {run['id']} has missing jobs in its latest attempt: {missing}") + print(f"gate passed: {run.get('html_url')} ({len(latest_jobs)} jobs, {attempt} attempts)") def main(tag): diff --git a/tests/fakegh/gh b/tests/fakegh/gh index 7ccba97..5ca5b11 100755 --- a/tests/fakegh/gh +++ b/tests/fakegh/gh @@ -91,17 +91,22 @@ elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"): is_main = workflow != "release.yml" run_id = {"checks.yml": 501, "release.yml": 502, "decky-runtime.yml": 503}[workflow] run = {"id": run_id, "head_sha": gate.get("main_sha" if is_main else "release_sha", commit), - "head_branch": branch, "event": "push", "run_attempt": 1, + "head_branch": branch, "event": "push", "run_attempt": gate.get("release_attempts", 1) if run_id == 502 else 1, "status": gate.get("decky_status" if run_id == 503 else ("main_status" if is_main else "release_status"), "completed"), "conclusion": gate.get("decky_conclusion" if run_id == 503 else ("main_conclusion" if is_main else "release_conclusion"), "success"), "html_url": "https://github.com/saphid/frame-control/actions/runs/%s" % run_id} print(json.dumps({"workflow_runs": [run]})) elif method == "GET" and endpoint.startswith(repo + "actions/runs/"): - run_id = endpoint[len(repo + "actions/runs/"):].split("/")[0] + parts = endpoint[len(repo + "actions/runs/"):].split("/") + run_id = parts[0] + attempt = int(parts[2]) names = {"501": main_jobs, "502": release_jobs, "503": decky_jobs}[run_id] names = [name for name in names if name != gate.get("omit_job")] jobs = [{"name": name, "status": "completed", - "conclusion": gate.get("bad_job_conclusion", "success") if name == gate.get("bad_job") else "success"} + "conclusion": (gate.get("prior_bad_job_conclusion", "failure") + if run_id == "502" and attempt == 1 and name == gate.get("prior_bad_job") + else gate.get("bad_job_conclusion", "success") if name == gate.get("bad_job") + else "success")} for name in names] print(json.dumps({"total_count": len(jobs), "jobs": jobs})) elif method == "GET" and endpoint.startswith(repo + "releases?"): diff --git a/tests/test_publish_release.py b/tests/test_publish_release.py index a263695..a5ded8e 100644 --- a/tests/test_publish_release.py +++ b/tests/test_publish_release.py @@ -134,6 +134,17 @@ class PublishRelease(unittest.TestCase): self.assertIsNone(upload) self.assertFalse(any("-X" in call for call in log)) + def test_refuses_a_prior_cancelled_installer_after_successful_rerun(self): + gates = {"release_attempts": 2, + "prior_bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)", + "prior_bad_job_conclusion": "cancelled"} + p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates) + self.assertNotEqual(p.returncode, 0) + self.assertIn("attempt 1", p.stderr) + self.assertIn("cancelled", p.stderr) + self.assertIsNone(upload) + self.assertFalse(any("-X" in call for call in log)) + def test_refuses_missing_or_nonexact_gate_results(self): for gates in ({"omit_job": "e2e"}, {"main_sha": "c" * 40}, {"decky_conclusion": "failure"}, {"release_conclusion": "failure"},