diff --git a/tests/ci/desktop_release_roles.py b/tests/ci/desktop_release_roles.py index 428c1f66b1..1f46240866 100644 --- a/tests/ci/desktop_release_roles.py +++ b/tests/ci/desktop_release_roles.py @@ -187,7 +187,8 @@ def admitted(names, *, channel=False): return {name: {"result": "success", "outputs": dict(outputs)} for name in names} -def evaluate(expression, inputs, needs, *, cancelled=False, failed=False, job_if=True, github=None, env=None): +def evaluate(expression, inputs, needs, *, cancelled=False, failed=False, job_if=True, github=None, env=None, + steps=None): """Evaluate the workflow expression subset, including Actions' implicit success. This is not a scheduler simulation; native Actions still owns cancellation @@ -205,12 +206,12 @@ def evaluate(expression, inputs, needs, *, cancelled=False, failed=False, job_if def value(match): bits = match[0].split('.') - result = {'inputs': inputs, 'needs': needs, 'github': github or {}, 'env': env or {}} + result = {'inputs': inputs, 'needs': needs, 'github': github or {}, 'env': env or {}, 'steps': steps or {}} for bit in bits: result = result.get(bit, '') if isinstance(result, dict) else '' return repr(result) - expression = re.sub(r'\b(?:inputs|needs|github|env)(?:\.[\w-]+)+', value, expression) + expression = re.sub(r'\b(?:inputs|needs|github|env|steps)(?:\.[\w-]+)+', value, expression) expression = expression.replace('&&', ' and ').replace('||', ' or ') expression = re.sub(r'!(?!=)', ' not ', expression) expression = re.sub(r'\btrue\b', 'True', expression) diff --git a/tests/ci/test_stable_release_graph.py b/tests/ci/test_stable_release_graph.py index bd7cff1190..f72a32d75d 100644 --- a/tests/ci/test_stable_release_graph.py +++ b/tests/ci/test_stable_release_graph.py @@ -5,6 +5,8 @@ from pathlib import Path from ruamel.yaml import YAML +from tests.ci.desktop_release_roles import gate + ROOT = Path(__file__).resolve().parents[2] @@ -78,8 +80,10 @@ def test_release_reuses_whole_ci_and_docker_before_publication(): assert required <= ancestors(jobs, "publish-bundles") assert {"publish-docker", "publish-bundles", "publication"} <= ancestors(jobs, "complete") assert "promote-docker" not in jobs and "promote-bundles" not in jobs + # The gates must run to judge a failed or skipped prerequisite, not skip with it. for name in ("acceptance", "publication", "complete"): - assert jobs[name]["if"] == "always()" + failed = {need: {"result": "failure"} for need in ancestors(jobs, name)} + assert gate(jobs[name]["if"], {}, failed), name def test_claim_flags_remove_exactly_the_jobs_the_gate_expects_skipped(): @@ -126,7 +130,7 @@ def test_all_applicable_ci_jobs_are_aggregated_and_desktop_e2e_stays_deferred(): jobs = workflow("ci.yaml")["jobs"] checks = {name for name, job in jobs.items() if "uses" in job} assert checks <= set(jobs["all-checks-pass"]["needs"]) - assert jobs["e2e-desktop"]["if"] == "false" + assert not gate(jobs["e2e-desktop"]["if"], {}, {}) assert "workflow_call" in workflow("ci.yaml")["on"] @@ -258,7 +262,17 @@ def test_publication_reconciler_has_every_recovery_trigger_and_shared_lock(): reconcile = publication["jobs"]["reconcile"] assert reconcile["environment"] == "release-signing" assert publication["permissions"] == {"contents": "write", "actions": "write"} - assert "conclusion != 'success'" in reconcile["if"] + # Reconcile after a dispatched run of this repository that did not succeed; + # a manual dispatch of the reconciler always runs. + def run_of(**overrides): + workflow_run = {"conclusion": "failure", "event": "workflow_dispatch", + "head_repository": {"full_name": "o/r"}, **overrides} + return {"event_name": "workflow_run", "repository": "o/r", "event": {"workflow_run": workflow_run}} + + assert gate(reconcile["if"], {}, {}, github=run_of()) + assert gate(reconcile["if"], {}, {}, github={"event_name": "workflow_dispatch"}) + for refused in ({"conclusion": "success"}, {"event": "push"}, {"head_repository": {"full_name": "fork/r"}}): + assert not gate(reconcile["if"], {}, {}, github=run_of(**refused)), refused checkout = reconcile["steps"][0] assert checkout["with"]["ref"] == "${{ github.event.repository.default_branch }}" assert checkout["with"]["persist-credentials"] == "false" diff --git a/tests/scripts/test_desktop_build_cache.py b/tests/scripts/test_desktop_build_cache.py index 46a57f76d4..831275f568 100644 --- a/tests/scripts/test_desktop_build_cache.py +++ b/tests/scripts/test_desktop_build_cache.py @@ -10,6 +10,7 @@ import sys import pytest from scripts.ci.setup_toolchain import current_target +from tests.ci.desktop_release_roles import gate ROOT = Path(__file__).resolve().parents[2] @@ -187,8 +188,13 @@ def test_composite_transports_only_and_uses_the_restore_key_for_save(tmp_path): assert restore["continue-on-error"] is True assert save["continue-on-error"] is True for step in steps: - assert "!cancelled()" in step["if"], "failure salvage must not run during cancellation" - assert any("steps.save.outcome == 'failure'" in step["if"] and "::warning::" in step.get("run", "") for step in steps) + assert not gate(step["if"], {}, {}, job_if=False, cancelled=True), \ + "failure salvage must not run during cancellation" + # A cache service failure is reported instead of failing preparation. + (report,) = [step for step in steps if "::warning::" in step.get("run", "")] + for failed in ("restore", "save"): + assert gate(report["if"], {}, {}, job_if=False, failed=True, steps={failed: {"outcome": "failure"}}) + assert not gate(report["if"], {}, {}, job_if=False, steps={"restore": {"outcome": "success"}}) def test_action_path_join_is_an_actual_newline(tmp_path):