test(ci): evaluate stable-release and build-cache gates instead of spelling them

test_stable_release_graph and test_desktop_build_cache asserted gate text:
`== "always()"`, `== "false"`, `"conclusion != 'success'" in`,
`"!cancelled()" in`. Both files now evaluate the gate with the shared
evaluator, which also resolves `steps.*` now.

- The stable gates (acceptance, publication, complete) must run when every
  ancestor failed.
- Deferred desktop e2e never runs.
- The publication reconciler runs after a failed, dispatched Stable Release
  of this repository and on manual dispatch. It refuses a successful run, a
  push-triggered run, and a fork's run.
- No desktop-build-cache step runs during cancellation. The service-failure
  report runs when restore or save failed and stays quiet otherwise.
This commit is contained in:
ethernet
2026-09-24 14:07:25 -04:00
parent 3689d8c435
commit 1f264c7205
3 changed files with 29 additions and 8 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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):