From 014ff26643f26592e2408601312564b7fef98833 Mon Sep 17 00:00:00 2001 From: "dilux-bot[bot]" <333187390+dilux-bot[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:37:09 +0000 Subject: [PATCH 01/11] chore(autopilot): start on #748 The draft pull request opens before any code: the Spec as its checklist. From fe1030f1d5ee1065132d4144684cf0fbeca0bdb2 Mon Sep 17 00:00:00 2001 From: "dilux-bot[bot]" <333187390+dilux-bot[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:04:20 +0000 Subject: [PATCH 02/11] fix: the test repository runs its own pull-request pipeline (C1) workflow-templates/test-pull-request.yml is the required workflow's on: and jobs, job for job, plus a pipeline job that is the whole run's result as one check. The engine-test pack lists it, so the release lane's point_callers moves its review, auto-merge and fix-round calls to @v7-next with the other callers of the test repository and back after. A template a pack lists that every-repository does not belongs to that kind alone: the sync compares it with the file of its own name in a repository of that kind, and nowhere else. Assisted-by: Claude Opus 5.5 (Anthropic) --- kinds/engine-test/pack.yml | 1 + scripts/release-scenarios.py | 12 ++ scripts/sync-repos.py | 73 +++++-- tests/workflows/test_test_repo_pipeline.py | 39 ++++ .../test-pull-request.properties.json | 8 + workflow-templates/test-pull-request.yml | 193 ++++++++++++++++++ 6 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 tests/workflows/test_test_repo_pipeline.py create mode 100644 workflow-templates/test-pull-request.properties.json create mode 100644 workflow-templates/test-pull-request.yml diff --git a/kinds/engine-test/pack.yml b/kinds/engine-test/pack.yml index 3c34233b..b44106a1 100644 --- a/kinds/engine-test/pack.yml +++ b/kinds/engine-test/pack.yml @@ -32,6 +32,7 @@ pre-pr-tools: [] callers: - issues.yml - pull-request-comments.yml + - test-pull-request.yml # No checks of its own to run, so no engines. adapters: [] diff --git a/scripts/release-scenarios.py b/scripts/release-scenarios.py index be5ea4c3..b003c683 100644 --- a/scripts/release-scenarios.py +++ b/scripts/release-scenarios.py @@ -2510,6 +2510,18 @@ def none_yet(*a, input=None): ([c for c in fresh if c[:2] == ("pr", "merge")], [c for c in fresh if "--auto" in c], any(c[:2] == ("pr", "checks") for c in fresh)), ([("pr", "merge", "21", "--repo", "o/t", "--squash", "--match-head-commit", "head-21")], [], True)) check("#622: put_back with nothing moved does nothing", put_back("o/t", "v7-next", [], lambda *a, input=None: 1 / 0), []) + # #748 C1: the test repository's pull-request caller, the shipped + # template as it is, moves with the others. + import yaml + shipped = open(os.path.join(ROOT, "workflow-templates", "test-pull-request.yml"), encoding="utf-8").read() + shipped = shipped.replace("DiluxOne/.github/", "O/.github/") + st, fake = repo_fake() + st["main"]["test-pull-request.yml"] = shipped + moved, rows = move_callers("o/t", "v7-next", fake, "O/.github", lambda s: None, 3) + jobs = yaml.safe_load(st["main"]["test-pull-request.yml"])["jobs"] + check("#748 C1: after point_callers, every engine call of test-pull-request.yml's review, auto-merge and autopilot-fix jobs is at @v7-next", + (rows, sorted(m["name"] for m in moved), {j: jobs[j]["uses"].rsplit("@", 1)[1] for j in ("review", "auto-merge", "autopilot-fix")}), + ([], ["issues.yml", "test-pull-request.yml"], {"review": "v7-next", "auto-merge": "v7-next", "autopilot-fix": "v7-next"})) st, fake = repo_fake() moved = [] run_all(one, "o/t", "v7-next", run=fake, sleep=lambda s: None, minutes=3, central="O/.github", moved_out=moved) diff --git a/scripts/sync-repos.py b/scripts/sync-repos.py index 584c85a2..50e8a70c 100755 --- a/scripts/sync-repos.py +++ b/scripts/sync-repos.py @@ -1007,14 +1007,14 @@ def sync_callers(repo, conf, dry): behind = old_versions(added.values(), current) added = {p: to_major(t, behind, current) for p, t in added.items()} if behind else added parsed_moved = {p: (t, parse_yaml(t)) for p, t in moved.items()} - shared = shared_callers([(n, d, caller_for(dict(d, _name=n), parsed_moved, False)) for n, (_, d) in templates(major).items()]) + shared = shared_callers([(n, d, caller_for(dict(d, _name=n), parsed_moved, False)) for n, (_, d) in templates_for(kind, conf, major).items()]) open_prs = json.loads(gh("pr", "list", "--repo", full, "--state", "open", "--limit", "500", "--json", "headRefName").stdout) if any(p["headRefName"].endswith("-org-workflow-callers") for p in open_prs): print(" a pull request for the workflow callers is already open; skipped") return owned = (conf.get("callers") or {}).get("repository-owned") or {} wanted, notes, stuck = {}, list(held), set() - for name, path, found in callers_drift(moved, conf, major=major, default=branch): + for name, path, found in callers_drift(moved, conf, major=major, default=branch, kind=kind): if not path: if f".github/workflows/{name}" not in added: notes.append(f"no caller of workflow-templates/{name}: add it from the template by hand (docs/adopting.md)") @@ -1042,7 +1042,7 @@ def sync_callers(repo, conf, dry): stuck.add(path) continue new = granted - left = [f for n, p, f in callers_drift({**moved, path: new}, conf, major=major, default=branch) if p == path and n == name] if new else [] + left = [f for n, p, f in callers_drift({**moved, path: new}, conf, major=major, default=branch, kind=kind) if p == path and n == name] if new else [] if new is None: notes.append(f"{path}: no job of the repository gives {', '.join(missing)}; bring it in step by hand") stuck.add(path) @@ -1064,7 +1064,7 @@ def sync_callers(repo, conf, dry): wanted[path] = text # A caller in step but for a template's placeholder (#562): the value # repos.yml gives it, its entry's or the organisation's default. - for tname, (ttext, data) in templates(major).items(): + for tname, (ttext, data) in templates_for(kind, conf, major).items(): path = caller_for(dict(data, _name=tname), parsed_moved, False) if path is None or path in stuck: continue @@ -1081,7 +1081,7 @@ def sync_callers(repo, conf, dry): print(f" {note}") # Nothing still holding a template's placeholder is written (#671): that # file stays out, the others go in. - refused = refuse_placeholders(full, wanted, added, parsed_moved, owned, values, major) + refused = refuse_placeholders(full, wanted, added, parsed_moved, owned, values, major, kind, conf) if old and refused: print(f" the move to @v{current} waits: {', '.join(refused)} must be brought in step first") return @@ -1096,7 +1096,7 @@ def sync_callers(repo, conf, dry): sys.stdout.writelines(" " + l for l in difflib.unified_diff(files.get(path, "").splitlines(True), new.splitlines(True), path, path)) return default = (repo.get("defaultBranchRef") or {}).get("name") or "main" - templates_named = ", ".join(sorted({f"workflow-templates/{n}" for n, p, _ in callers_drift(moved, conf, major=major, default=branch) if p in wanted})) or "the templates" + templates_named = ", ".join(sorted({f"workflow-templates/{n}" for n, p, _ in callers_drift(moved, conf, major=major, default=branch, kind=kind) if p in wanted})) or "the templates" to = f"@v{current}" move = (f" It also moves every call of the shared workflows from {', '.join('@v' + str(v) for v in old)} to {to}, the repository's own jobs too: " f"that major asks a repository for nothing but its callers (docs/migrating.md)." if old else "") @@ -1116,12 +1116,12 @@ def sync_callers(repo, conf, dry): assisted_form(CALLERS_KEEP), mode="callers") -def refuse_placeholders(full, wanted, added, parsed, owned, values, major): +def refuse_placeholders(full, wanted, added, parsed, owned, values, major, kind=None, conf=None): """Takes out of `wanted` each caller placeholder_problems() refuses, saying the repository, the path and why (#671): a file in `added` is new, from its template; any other, the caller (in `parsed`) of the templates it calls. The paths it took out, sorted.""" - tpls = templates(major) + tpls = templates_for(kind, conf, major) if conf is not None else templates(major) of = {} for tname, (_, data) in tpls.items(): path = caller_for(dict(data, _name=tname), parsed, False) @@ -1236,6 +1236,30 @@ def templates(major=None): return found +def kinds_only(conf, root=None): + """{template: the kinds whose packs list it}, for the templates a pack's + `callers` lists that `callers: every-repository` does not (#748): those + belong to the repositories of those kinds alone (test-pull-request.yml to + the test repository), and are never compared with, nor written to, any + other.""" + every = set((conf.get("callers") or {}).get("every-repository") or []) + root = root or CENTRAL + out = {} + for kind in sorted(os.listdir(os.path.join(root, "kinds"))): + for name in pack_of(kind, root).get("callers") or []: + if name not in every: + out.setdefault(str(name), set()).add(kind) + return out + + +def templates_for(kind, conf, major=None): + """templates(major), less those of other kinds alone (kinds_only()). One + of this kind alone is marked `_own`: its caller is the file of its name, + never another that calls some of the same workflows (caller_for()).""" + only = kinds_only(conf) + return {n: (t, dict(d, _own=True) if n in only else d) for n, (t, d) in templates(major).items() if n not in only or kind in only[n]} + + def split_uses(uses, central=False): """(shared workflow, ref) for a call of a shared workflow, else None. In DiluxOne/.github itself a local call (./.github/workflows/x.yml) is the @@ -1262,7 +1286,10 @@ def shared_calls(data, central=False): def caller_for(template, files, central=False): """The repository's workflow file that calls this template's shared workflows: the one calling most of them, the template's own name first - on a tie. None when no file calls any.""" + on a tie. None when no file calls any. A template of some kinds alone + (`_own`, templates_for()) has only the file of its own name (#748).""" + if template.get("_own"): + return next((p for p in sorted(files) if os.path.basename(p) == template.get("_name")), None) wanted = shared_calls(template) best = None for path in sorted(files): @@ -1328,16 +1355,18 @@ def shared_callers(found_for): return {p for p in paths if paths.count(p) > 1} -def callers_drift(files, conf, central=False, major=None, default="main"): +def callers_drift(files, conf, central=False, major=None, default="main", kind=None): """What differs in a repository's callers from the templates that apply to it, as [(template, caller path or None, [phrases])], only those that differ. `files` is {path: text}; major, as templates() takes it; default, - the repository's default branch (a template's `$default-branch`).""" + the repository's default branch (a template's `$default-branch`); kind, + the repository's (a template of a kind alone applies to that kind only, + kinds_only()).""" spec = conf.get("callers") or {} every, owned = set(spec.get("every-repository") or []), spec.get("repository-owned") or {} parsed = {p: (t, parse_yaml(t)) for p, t in files.items()} out = [] - found_for = [(name, data, caller_for(dict(data, _name=name), parsed, central)) for name, (_, data) in templates(major).items()] + found_for = [(name, data, caller_for(dict(data, _name=name), parsed, central)) for name, (_, data) in templates_for(kind, conf, major).items()] shared = shared_callers(found_for) for name, data, path in found_for: data = dict(data, _name=name) @@ -1500,12 +1529,12 @@ def placeholder_problems(path, text, template_text, owned, values, whole): return reasons -def placeholder_lines(files, conf, name, central=False, major=None): +def placeholder_lines(files, conf, name, central=False, major=None, kind=None): """The drift lines of the callers that hold a template's placeholder.""" owned, values = (conf.get("callers") or {}).get("repository-owned") or {}, repos_conf.caller_values(conf, name) parsed = {p: (t, parse_yaml(t)) for p, t in files.items()} out = [] - for tname, (ttext, data) in templates(major).items(): + for tname, (ttext, data) in templates_for(kind, conf, major).items(): path = caller_for(dict(data, _name=tname), parsed, central) if path is None: continue @@ -2740,19 +2769,19 @@ def run(*args): # once it is on the current one. try: central = repo["name"] == ".github" - drifted = callers_drift(files, conf, central, default=default or "main") + kind = repo_kind(full, default) + drifted = callers_drift(files, conf, central, default=default or "main", kind=kind) if not central: # A caller it lacks is listed as `callers` adds it, from # the same rule (new_callers(), #671), once; one whose # file name a workflow of its own takes, which `callers` # does not write, is still listed here. drifted = [d for d in drifted if d[1] or f".github/workflows/{d[0]}" in files] - kind = repo_kind(full, default) found += [f"lacks `{p}` (workflow-templates/{os.path.basename(p)}): `sync-repos.py callers` adds it" for p in new_callers(files, conf, kind, default or "main", name=repo["name"], root=root_of(full, default or "main") if kind_battery(kind) else None)] found += caller_lines(drifted, central) - found += placeholder_lines(files, conf, repo["name"], central) + found += placeholder_lines(files, conf, repo["name"], central, kind=kind) except ValueError as exc: # a workflow that does not parse is reported, not skipped found.append(f"its workflow callers could not be compared: {str(exc)[:200]}") for w, r in pinned_calls(texts): @@ -2970,6 +2999,16 @@ def callers_test(check, conf): commented = {**c2, ".github/workflows/issues.yml": before, ".github/workflows/pull-request-comments.yml": old_profile} check("C2 (#170): a caller whose comments differ from the template's is in step", before != same and old_profile != comments and callers_drift(commented, conf) == [], callers_drift(commented, conf)) + tpr = tpl["test-pull-request.yml"] + got = (kinds_only(conf), [n for n, _, _ in callers_drift(c2, conf)], [n for n, _, _ in callers_drift({**c2, ".github/workflows/test-pull-request.yml": tpr}, conf, kind="wordpress-plugin")]) + check("#748: test-pull-request.yml, a template of the test repository's kind alone, is compared nowhere else, not even with a caller that calls its workflows", + got == ({"test-pull-request.yml": {"engine-test"}}, [], []), got) + got = ([n for n, _, _ in callers_drift(c2, conf, kind="engine-test")], + callers_drift({**c2, ".github/workflows/test-pull-request.yml": tpr}, conf, kind="engine-test"), + callers_drift({**c2, ".github/workflows/test-pull-request.yml": tpr.replace(" needs: [conventions, assignee", " needs: [assignee")}, conf, kind="engine-test")) + check("#748: in the test repository its caller is the file of its name alone: none is compared with issues.yml, and the template is held job for job", + got[:2] == ([], []) and [(n, p) for n, p, _ in got[2]] == [("test-pull-request.yml", ".github/workflows/test-pull-request.yml")] + and got[2][0][2][0].startswith("job `pipeline` needs"), got) check("callers: a template whose workflows the repository does not call does not apply", not any(n == "release-wp.yml" for n, _, _ in callers_drift(files, conf)), callers_drift(files, conf)) check("callers: a repository with no caller of an every-repository template is told so", diff --git a/tests/workflows/test_test_repo_pipeline.py b/tests/workflows/test_test_repo_pipeline.py new file mode 100644 index 00000000..f63de758 --- /dev/null +++ b/tests/workflows/test_test_repo_pipeline.py @@ -0,0 +1,39 @@ +"""The test repository runs the candidate's pull-request pipeline (#748): +its own caller, workflow-templates/test-pull-request.yml, which the release +lane moves to `@v7-next` with its other callers, in place of the required +workflow, which the organisation's ruleset reads at `v7`.""" +import importlib.util +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +TEMPLATE = ROOT / "workflow-templates" / "test-pull-request.yml" +REQUIRED = ROOT / ".github" / "workflows" / "org-pull-request.yml" + + +def scenarios(): + spec = importlib.util.spec_from_file_location("release_scenarios", ROOT / "scripts" / "release-scenarios.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def load(path): + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def test_the_review_auto_merge_and_fix_rounds_run_the_candidate_once_moved(): + mod = scenarios() + text = TEMPLATE.read_text(encoding="utf-8") + jobs = yaml.safe_load(mod.at_ref(text, "DiluxOne/.github", "v7-next"))["jobs"] + for job in ("review", "auto-merge", "autopilot-fix", "autopilot-queue"): + assert jobs[job]["uses"].startswith("DiluxOne/.github/.github/workflows/"), job + assert jobs[job]["uses"].endswith("@v7-next"), job + # Every engine call there, not those three alone, is one the move rewrites. + assert "@v7\n" not in mod.at_ref(text, "DiluxOne/.github", "v7-next") + + +def test_the_pack_gives_the_test_repository_the_caller(): + pack = load(ROOT / "kinds" / "engine-test" / "pack.yml") + assert "test-pull-request.yml" in pack["callers"] diff --git a/workflow-templates/test-pull-request.properties.json b/workflow-templates/test-pull-request.properties.json new file mode 100644 index 00000000..75bce79a --- /dev/null +++ b/workflow-templates/test-pull-request.properties.json @@ -0,0 +1,8 @@ +{ + "name": "Pull request, test repository (DiluxOne)", + "description": "The organisation's pull request pipeline for its test repository alone (org.yml test-repo), where the release lane moves it to the candidate's engine; every other repository runs it as the required workflow.", + "iconName": "octicon check", + "categories": [ + "Automation" + ] +} diff --git a/workflow-templates/test-pull-request.yml b/workflow-templates/test-pull-request.yml new file mode 100644 index 00000000..a7af8005 --- /dev/null +++ b/workflow-templates/test-pull-request.yml @@ -0,0 +1,193 @@ +name: Pull request (test repository) + +# The organisation's pull request pipeline in its test repository alone +# (org.yml `test-repo`, kinds/engine-test/pack.yml `callers`, #748). Every +# other repository runs it as the required workflow, org-pull-request.yml, +# which the organisation ruleset "org: required workflow" reads at `v7` with +# its calls hard-wired to `@v7`; that ruleset leaves the test repository out +# (scripts/sync-repos.py rulesets), and this caller runs the same jobs there, +# job for job (the same ids and `uses:`, so the same check names). Its calls +# are the repository's own, so while the release lane tries a candidate +# they move to `@v7-next` with its other callers and back after +# (scripts/release-scenarios.py point_callers): a scenario's review, +# auto-merge decision and fix rounds run the candidate's engine, not `v7`. +# The `pipeline` job is the whole run's result as one check, which the +# test repository's ruleset "required pipeline" requires on its default +# branch, on the pull request and in the merge queue: the gate the required +# workflow gives everywhere else. Do not add this caller to any other +# repository: there the required workflow already runs it. + +on: + pull_request: + # `dequeued` (#479): the merge queue dropped it; only autopilot-queue + # runs then. + types: [opened, synchronize, reopened, ready_for_review, edited, dequeued] + # In a repository with a merge queue (#451), the group the queue builds + # runs this workflow too, which its ruleset requires there as well: the + # pull request's jobs (its conventions, its review, its merge) were done + # on the pull request, so only `queue` runs, and the queue waits for it. + merge_group: + types: [checks_requested] + +permissions: + contents: read + +jobs: + conventions: + if: github.event_name == 'pull_request' && github.event.action != 'dequeued' && github.repository != 'DiluxOne/.github' + permissions: + contents: read + pull-requests: read # the pull request's timeline, for the base-branch check on `edited` + issues: read # the issues it closes, for the accepted-issue gate + actions: read # the run that opened a lane's issue (policy/autonomy.yml) + uses: DiluxOne/.github/.github/workflows/conventions.yml@v7 + + # The issues it closes go to its author, when a member of the + # organisation (#73); on open, reopen, ready and a changed description. + assignee: + if: github.event_name == 'pull_request' && github.event.action != 'dequeued' && github.repository != 'DiluxOne/.github' + permissions: {} + uses: DiluxOne/.github/.github/workflows/pull-request-assignee.yml@v7 + secrets: + DILUX_BOT_PRIVATE_KEY: ${{ secrets.DILUX_BOT_PRIVATE_KEY }} + + # The VM the private repositories' jobs run on is off while idle: start it + # now (#154). On GitHub's runner, a few seconds; skipped where + # DX_RUNNER_HEAVY names no self-hosted runner, so in every public repository. Not on `edited`: an + # edit queues only the conventions, mostly right after the push or the + # review that already woke the VM, and the schedule covers the rest, so + # edits spend no minutes on it. + wake-vm: + if: github.repository != 'DiluxOne/.github' && github.event.action != 'edited' && github.event.action != 'dequeued' + permissions: {} + uses: DiluxOne/.github/.github/workflows/runner-wake.yml@v7 + secrets: + DILUX_BOT_PRIVATE_KEY: ${{ secrets.DILUX_BOT_PRIVATE_KEY }} + + review: + # order: the conventions gate passes first, so no review is paid for a + # pull request it refuses. + needs: [conventions] + if: github.event_name == 'pull_request' && github.event.action != 'dequeued' && github.repository != 'DiluxOne/.github' + permissions: + contents: read + pull-requests: write + issues: write + uses: DiluxOne/.github/.github/workflows/claude-review.yml@v7 + with: + # The repository's kind (kind: in .github/review-policy.yml) adds its + # own profile to the general one. + profile: general + secrets: inherit + + auto-merge: + needs: review + if: always() && github.event_name == 'pull_request' && github.repository != 'DiluxOne/.github' && needs.review.result != 'skipped' + # Its `repro` job reads the lane issue a reproduced bug's fix closes (#397). + permissions: + contents: read + issues: read + pull-requests: read + uses: DiluxOne/.github/.github/workflows/auto-merge.yml@v7 + with: + risk: ${{ needs.review.outputs.risk }} + complexity: ${{ needs.review.outputs.complexity }} + floor: ${{ needs.review.outputs.floor }} + trusted: ${{ needs.review.outputs.trusted }} + blocking: ${{ needs.review.outputs.blocking }} + description-ok: ${{ needs.review.outputs.description-ok }} + enabled: ${{ needs.review.outputs.auto-merge }} + secrets: inherit + + # What waits on whom (#564, waiting-update.yml), after the review: whom + # the pull request waits on now (a failed check, a thread, a person's + # merge), on its board with one mention, and the organisation's view. A + # fork's pull request has no secrets here: issues.yml covers it, by its + # metadata only. + waiting: + # order: after the review, whose verdict and labels the board shows. + needs: review + if: always() && github.event_name == 'pull_request' && github.repository != 'DiluxOne/.github' && github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + uses: DiluxOne/.github/.github/workflows/waiting-update.yml@v7 + secrets: + DILUX_BOT_PRIVATE_KEY: ${{ secrets.DILUX_BOT_PRIVATE_KEY }} + + # The before and after screenshots of an autopilot pull request that + # changes the browser layer, when it is marked ready (#564, + # pr-screenshots.yml): only dilux-bot's own branch here, never a fork's. + screenshots: + if: github.event_name == 'pull_request' && github.repository != 'DiluxOne/.github' && github.event.action == 'ready_for_review' && contains(github.event.pull_request.labels.*.name, 'autopilot') && github.event.pull_request.user.login == vars.DX_BOT_LOGIN && github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + uses: DiluxOne/.github/.github/workflows/pr-screenshots.yml@v7 + secrets: + DILUX_BOT_PRIVATE_KEY: ${{ secrets.DILUX_BOT_PRIVATE_KEY }} + + # An autopilot pull request whose review blocks: one round of fixes by the + # autopilot (#83), which pushes and so runs this pipeline again; after its + # rounds it hands the pull request to a person. + autopilot-fix: + needs: review + # Only dilux-bot's own pull requests, and only for a new commit (an edit + # of the description keeps the review's verdict and must not spend a + # round). + if: always() && github.event_name == 'pull_request' && github.repository != 'DiluxOne/.github' && needs.review.outputs.blocking == 'true' && contains(github.event.pull_request.labels.*.name, 'autopilot') && github.event.pull_request.user.login == vars.DX_BOT_LOGIN && github.event.action != 'edited' + # The agent's jobs read the repository with the job's own token (#287). + permissions: + contents: read + uses: DiluxOne/.github/.github/workflows/autopilot.yml@v7 + with: + mode: fix + pr: ${{ format('{0}', github.event.pull_request.number) }} + secrets: inherit + + # An autopilot pull request the merge queue dropped (#479): one round of + # fixes, with the failed jobs' log of its last merge group, within the + # rounds cap. autopilot.yml knows it from the event (`dequeued`), and runs + # it only when a check failed there (not for a conflict, nor for a person + # who took it out of the queue). + autopilot-queue: + if: github.event_name == 'pull_request' && github.event.action == 'dequeued' && github.repository != 'DiluxOne/.github' && contains(github.event.pull_request.labels.*.name, 'autopilot') && github.event.pull_request.user.login == vars.DX_BOT_LOGIN + # The agent's jobs read the repository with the job's own token (#287). + permissions: + contents: read + uses: DiluxOne/.github/.github/workflows/autopilot.yml@v7 + with: + mode: fix + pr: ${{ format('{0}', github.event.pull_request.number) }} + secrets: inherit + + # The merge queue's group (#451): what this pipeline decides was decided + # on each pull request in it, so the group only has to see this workflow + # pass. Nothing is checked out and no secret is read. + queue: + if: github.event_name == 'merge_group' && github.repository != 'DiluxOne/.github' + runs-on: ubuntu-latest + permissions: {} + steps: + - name: The pull requests in the group passed this pipeline + env: + HEAD_REF: ${{ github.event.merge_group.head_ref }} + run: | + echo "Merge group $HEAD_REF: conventions, review and the merge decision ran on each pull request." + + # The whole run as one check (#748): what the required workflow's ruleset + # requires elsewhere, a run that succeeds. It needs every other job and + # fails when one of them failed or was cancelled; a skipped one is fine. + # It runs on the pull request and in the merge queue's group alike. + pipeline: + needs: [conventions, assignee, wake-vm, review, auto-merge, waiting, screenshots, autopilot-fix, autopilot-queue, queue] + if: always() + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Every job of the pipeline passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "The pipeline's jobs: $RESULTS" + case " $RESULTS " in + *" failure "*|*" cancelled "*) echo "::error::a job of the pipeline failed or was cancelled"; exit 1 ;; + esac From 3af8bedc4c7df1bad89824cd9afcdaccd911515d Mon Sep 17 00:00:00 2001 From: "dilux-bot[bot]" <333187390+dilux-bot[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:04:24 +0000 Subject: [PATCH 03/11] fix(sync): the required workflow leaves the test repository out (C2) sync-repos.py rulesets moves the workflows rule of "org: main" into a ruleset of its own, "org: required workflow", created first with the same org-pull-request.yml at v7, target, bypass actors and ref_name, and only then takes the rule out of "org: main", which keeps its other rules on every repository, the test repository included. The new ruleset covers the repositories with dx-adopted=true and excludes those with org.yml test-property (dx-engine-test), which the sync creates and sets to true on org.yml test-repo alone, once that repository has its own pull-request caller on its default branch. A ruleset body whose conditions lack ref_name or repository_property is never written, and the daily drift check names a workflows rule left in "org: main", the required workflow covering the test repository, a ruleset with no ref_name and a required workflow at another ref than the current major. Assisted-by: Claude Opus 5.5 (Anthropic) --- org.yml | 8 + scripts/org.py | 20 +++ scripts/sync-repos.py | 331 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 348 insertions(+), 11 deletions(-) diff --git a/org.yml b/org.yml index 4bc5d675..bd4d6ec3 100644 --- a/org.yml +++ b/org.yml @@ -85,6 +85,14 @@ core: # repos.yml lists, written by the sync, and the organisation's rulesets # cover the repositories that have it instead of a list of names. adopted-property: dx-adopted +# The custom property that sets the test repository apart (#748, +# scripts/sync-repos.py rulesets): `true` on org.yml `test-repo` alone, +# written by the sync. The ruleset "org: required workflow", which runs +# org-pull-request.yml at `v7` in every adopted repository, excludes the +# repositories that have it: the test repository runs the same pipeline +# from its own caller (workflow-templates/test-pull-request.yml), which the +# release lane moves to `v7-next`. Absent, `dx-engine-test`. +test-property: dx-engine-test # A repository's own commit rules that contradict the organisation's (#562, # scripts/sync-repos.py check): each pattern is matched against its # commit-msg hook (.githooks/, .husky/), its commitlint configuration, and diff --git a/scripts/org.py b/scripts/org.py index 90cd9b96..33e1ef68 100755 --- a/scripts/org.py +++ b/scripts/org.py @@ -133,6 +133,19 @@ def test_repo(conf): TEST_KIND = "engine-test" +def test_property(conf): + """org.yml `test-property` (#748): the custom property of the + organisation, true on the test repository alone, that the + required-workflow ruleset excludes (scripts/sync-repos.py rulesets); + absent, `dx-engine-test`.""" + value = str(conf.get("test-property") or "").strip() + if not value: + return "dx-engine-test" + if not all(c.isalnum() or c in "-_" for c in value): + raise ValueError(f"org.yml `test-property` is `{value}`: a custom property's name") + return value + + def test_identity(env=os.environ): """The test identity's login (#739): DX_TEST_LOGIN, the owner of its token as the release lane read it from GitHub, lowercased; "" when it is @@ -284,6 +297,13 @@ def check(name, got, want): check("C1 (#563): a test-repo with an owner fails", "no error", "ValueError") except ValueError: check("C1 (#563): a test-repo with an owner fails", "ValueError", "ValueError") + check("#748: no test-property is dx-engine-test; org.yml's is its own", + (test_property({}), test_property({"test-property": "acme-test"}), test_property(_conf)), ("dx-engine-test", "acme-test", "dx-engine-test")) + try: + test_property({"test-property": "a b"}) + check("#748: a test-property that is no property's name fails", "no error", "ValueError") + except ValueError: + check("#748: a test-property that is no property's name fails", "ValueError", "ValueError") pack = test_pack({"org": "Acme"}) check("C1 (#739): the pack names the test identity's token, and no login", pack["identity"], {"secret": "DX_TEST_TOKEN"}) check("C1, C4 (#739): the test identity is DX_TEST_LOGIN, whoever owns the token, lowercased; none when it is unset", diff --git a/scripts/sync-repos.py b/scripts/sync-repos.py index 50e8a70c..b03d0c87 100755 --- a/scripts/sync-repos.py +++ b/scripts/sync-repos.py @@ -27,6 +27,17 @@ names to the repositories with the property, only after every adopted one holds it, and never while that list covers a repository repos.yml does not list or excludes (it names them). + Then (#748) the required workflow in a ruleset of its own, "org: + required workflow": created first with "org: main"'s `workflows` + rule as it is (org-pull-request.yml at `v7`), its target, bypass + actors and `ref_name`, and only then that rule taken out of "org: + main", which keeps its other rules everywhere. It leaves out the + test repository (org.yml `test-repo`) by a second custom property, + org.yml `test-property` (dx-engine-test), true there alone, once + that repository has its own pull-request caller + (workflow-templates/test-pull-request.yml) on its default branch; + until then it covers it, and says so. A ruleset whose conditions + would lack `ref_name` or `repository_property` is never written. pending the repositories repos.yml lists that do not hold that property yet, one per line (#562): what org-adopt.yml adopts, read from the organisation rather than from a push, so a run @@ -156,7 +167,10 @@ not run on merge_group, and its own commit rules (commit-msg hook, commitlint, AGENTS.md, CONTRIBUTING.md) that match org.yml `conventions-contradictions`; for the organisation, a ruleset - still naming its repositories. + still naming its repositories, and (#748) a `workflows` rule + still in "org: main", "org: required workflow" covering the test + repository, an organisation ruleset with no `ref_name`, and a + required workflow at a ref other than the current major. i18n a WordPress plugin's translations in the form that merges (#452, scripts/plugin-i18n.sh): languages/*.pot and *.po with @@ -404,11 +418,34 @@ def configuration_defaults(): # says they are adopted (#562), never a list of names that each adoption # has to extend by hand. ADOPTED = str(sys.modules["org"].load().get("adopted-property") or "dx-adopted") -ORG_RULESET_NAMES = ("org: main", "org: release tag creation", "org: release tags") +# The required workflow (#748) is a ruleset of its own, "org: required +# workflow", split out of "org: main": GitHub takes one repository condition +# per org ruleset, so it leaves the test repository out by a second +# property, org.yml `test-property`, true there alone. The test repository +# keeps "org: main"'s other rules, and runs the same pipeline from its own +# caller (workflow-templates/test-pull-request.yml), which the release lane +# moves to `v7-next`. +TEST_PROPERTY = sys.modules["org"].test_property(sys.modules["org"].load()) +TEST_REPO = sys.modules["org"].test_repo(sys.modules["org"].load()) +TEST_CALLER = ".github/workflows/test-pull-request.yml" +MAIN_RULESET, REQUIRED_RULESET = "org: main", "org: required workflow" +ORG_RULESET_NAMES = (MAIN_RULESET, REQUIRED_RULESET, "org: release tag creation", "org: release tags") -def property_condition(): - return {"repository_property": {"include": [{"name": ADOPTED, "property_values": ["true"], "source": "custom"}], "exclude": []}} +def property_condition(exclude=()): + return {"repository_property": {"include": [{"name": ADOPTED, "property_values": ["true"], "source": "custom"}], "exclude": list(exclude)}} + + +def test_exclusion(): + """The repository_property exclusion of the test repository (#748).""" + return {"name": TEST_PROPERTY, "property_values": ["true"], "source": "custom"} + + +def excludes_test(ruleset): + """Whether an org ruleset leaves out the repositories with the test + property (#748).""" + cond = (ruleset.get("conditions") or {}).get("repository_property") or {} + return any(p.get("name") == TEST_PROPERTY and "true" in (p.get("property_values") or []) for p in cond.get("exclude") or []) def by_property(ruleset): @@ -418,16 +455,87 @@ def by_property(ruleset): for p in ((cond.get("repository_property") or {}).get("include") or [])) and "repository_name" not in cond -def with_property_condition(ruleset): +def complete(body): + """A ruleset's PUT or POST body, once its conditions are complete + (#748): both `ref_name` and `repository_property`, since GitHub reads a + ruleset with no `ref_name` as covering no branch. Raises naming the + ruleset, never writes one that would cover nothing.""" + cond = body.get("conditions") or {} + for key in ("ref_name", "repository_property"): + if not cond.get(key): + raise RuntimeError(f"the ruleset \"{body.get('name')}\" would have no `{key}` condition: nothing was written") + return body + + +def with_property_condition(ruleset, exclude=None): """The ruleset as the PUT takes it, its every field as the GET gave it - but its repositories, now those with the property.""" + but its repositories, now those with the property, less what it + excluded by property (`exclude`, when given, in its place).""" body = {k: v for k, v in ruleset.items() if k not in ("id", "source", "source_type", "node_id", "_links", "created_at", "updated_at", "current_user_can_bypass")} cond = {k: v for k, v in (ruleset.get("conditions") or {}).items() if k not in ("repository_name", "repository_id")} - body["conditions"] = {**cond, **property_condition()} + kept = ((ruleset.get("conditions") or {}).get("repository_property") or {}).get("exclude") or [] + body["conditions"] = {**cond, **property_condition(kept if exclude is None else exclude)} + return complete(body) + + +def workflows_rules(ruleset): + """The ruleset's `workflows` rules: the required workflow.""" + return [r for r in (ruleset or {}).get("rules") or [] if r.get("type") == "workflows"] + + +def required_ruleset(main, exclude_test): + """The body of "org: required workflow" (#748), from "org: main": its + `workflows` rule unchanged, its target, enforcement, bypass actors and + `ref_name`, over the repositories with the property, less the test + repository's when `exclude_test`. Pure.""" + return complete({"name": REQUIRED_RULESET, "target": main.get("target") or "branch", "enforcement": main.get("enforcement") or "active", + "bypass_actors": main.get("bypass_actors") or [], + "conditions": {"ref_name": (main.get("conditions") or {}).get("ref_name"), + **property_condition([test_exclusion()] if exclude_test else [])}, + "rules": workflows_rules(main)}) + + +def without_workflows(main): + """The body of "org: main" with its `workflows` rule taken out (#748): + its pull request, deletion and signed commits rules stay, on the test + repository too. Pure.""" + body = with_property_condition(main) + body["rules"] = [r for r in body.get("rules") or [] if r.get("type") != "workflows"] return body +def ref_of(workflow): + """A required workflow's ref, without refs/tags/ or refs/heads/.""" + return re.sub(r"^refs/(tags|heads)/", "", str((workflow or {}).get("ref") or "")) + + +def required_drift(org_rs, major): + """What is wrong with the required workflow's rulesets (#748), as + lines: a `workflows` rule still in "org: main", "org: required + workflow" covering the test repository, an organisation ruleset with no + `ref_name`, and a required workflow at a ref other than `v`. + Pure.""" + found = [] + for r in org_rs or []: + if r.get("name") not in ORG_RULESET_NAMES: + continue + if r.get("name") == MAIN_RULESET and workflows_rules(r): + found.append(f"the ruleset \"{MAIN_RULESET}\" still holds the `workflows` rule (the required workflow): it belongs to \"{REQUIRED_RULESET}\", " + f"which leaves the test repository {TEST_REPO} out (`sync-repos.py rulesets`)") + if r.get("name") == REQUIRED_RULESET and not excludes_test(r): + found.append(f"the ruleset \"{REQUIRED_RULESET}\" covers the test repository {TEST_REPO}: it does not exclude `{TEST_PROPERTY}=true`, so " + f"its pull requests run the pipeline at `v{major}`, not the candidate's (`sync-repos.py rulesets`, once it has {TEST_CALLER})") + if not ((r.get("conditions") or {}).get("ref_name") or {}).get("include"): + found.append(f"the ruleset \"{r['name']}\" has no `ref_name` condition: it covers no branch or tag (`sync-repos.py rulesets`)") + for rule in workflows_rules(r): + for w in (rule.get("parameters") or {}).get("workflows") or []: + if ref_of(w) != f"v{major}": + found.append(f"the ruleset \"{r['name']}\" runs `{w.get('path')}` at `{ref_of(w) or 'no ref'}`, not `v{major}`: every repository but the test " + f"repository keeps the required workflow at `v{major}`") + return found + + def org_repo_names(): """The names of the organisation's repositories that are not archived, forks and those repos.yml excludes included.""" @@ -458,9 +566,10 @@ def would_drop(moving, every): return {r["name"]: sorted(named_coverage(r, names) - adopted) for r in moving if named_coverage(r, names) - adopted} -def property_values(names): - """{repository: its value of the property}, for these names; None when - they could not be read.""" +def property_values(names, prop=None): + """{repository: its value of the property (`prop`, else the adopted + one)}, for these names; None when they could not be read.""" + prop = prop or ADOPTED got = gh("api", "--paginate", "--slurp", f"orgs/{ORG}/properties/values?per_page=100", check=False) if got.returncode != 0: return None @@ -469,7 +578,7 @@ def property_values(names): for page in rows if rows and isinstance(rows[0], list) else [rows]: for r in page: if r.get("repository_name") in names: - out[r["repository_name"]] = next((str(p.get("value")) for p in r.get("properties") or [] if p.get("property_name") == ADOPTED), None) + out[r["repository_name"]] = next((str(p.get("value")) for p in r.get("properties") or [] if p.get("property_name") == prop), None) return out @@ -517,6 +626,95 @@ def sync_rulesets(repos, every, dry): print(f" \"{r['name']}\" covers the repositories with {ADOPTED}=true") if moving and not dry: ORG_RULESETS = None + # The rulesets as read before the move: it changed their repositories + # alone, which the split writes again. + split_required(every, dry, org_rs) + + +def has_test_caller(name): + """Whether the test repository has its pull-request caller on its + default branch (#748).""" + return gh("api", f"repos/{ORG}/{name}/contents/{TEST_CALLER}", check=False).returncode == 0 + + +def sync_test_property(name, dry): + """The test property (org.yml `test-property`, a true_false custom + property of the organisation) created, and set to true on the test + repository (#748).""" + if gh("api", f"orgs/{ORG}/properties/schema/{TEST_PROPERTY}", check=False).returncode != 0: + if dry: + print(f" would create the custom property `{TEST_PROPERTY}`") + else: + gh("api", "-X", "PUT", f"orgs/{ORG}/properties/schema/{TEST_PROPERTY}", "--input", "-", input_text=json.dumps( + {"value_type": "true_false", "required": False, + "description": "The organisation's test repository (DiluxOne/.github org.yml test-repo): the required workflow's ruleset leaves it out."})) + have = property_values({name}, TEST_PROPERTY) + if have is None: + raise RuntimeError(f"the values of {TEST_PROPERTY} could not be read") + if have.get(name) == "true": + return + if dry: + print(f" would set {TEST_PROPERTY}=true on {name}") + return + gh("api", "-X", "PATCH", f"orgs/{ORG}/properties/values", "--input", "-", input_text=json.dumps( + {"repository_names": [name], "properties": [{"property_name": TEST_PROPERTY, "value": "true"}]})) + print(f" {TEST_PROPERTY}=true on {name}") + + +def split_required(every, dry, org_rs=None): + """The required workflow in a ruleset of its own (#748): "org: required + workflow" created first, with "org: main"'s `workflows` rule as it is, + and only then that rule taken out of "org: main", so no repository is + ever without it. It leaves out the test repository (the test property, + set on it first) once that repository has its pull-request caller on its + default branch; until then it covers it, and says so, for otherwise its + pull requests would run no pipeline.""" + global ORG_RULESETS + org_rs = org_rulesets() or [] if org_rs is None else org_rs + main = next((r for r in org_rs if r.get("name") == MAIN_RULESET), None) + required = next((r for r in org_rs if r.get("name") == REQUIRED_RULESET), None) + rules = workflows_rules(main) + if required is None and not rules: + return + test = TEST_REPO if TEST_REPO in {r["name"] for r in every} else None + ready = False + if test: + sync_test_property(test, dry) + ready = has_test_caller(test) + if not ready: + print(f" {test} has no {TEST_CALLER} on its default branch yet: \"{REQUIRED_RULESET}\" still covers it, " + "so its pull requests run a pipeline (`sync-repos.py callers` writes it)") + changed = False + if required is None: + body = required_ruleset(main, ready) + if dry: + print(f" would create \"{REQUIRED_RULESET}\" with the `workflows` rule of \"{MAIN_RULESET}\"" + (f", leaving out {test}" if ready else "")) + else: + gh("api", "-X", "POST", f"orgs/{ORG}/rulesets", "--input", "-", input_text=json.dumps(body)) + print(f" \"{REQUIRED_RULESET}\" created" + (f", leaving out {test}" if ready else "")) + changed = True + carries = bool(body["rules"]) + else: + carries = bool(workflows_rules(required)) + if ready and not excludes_test(required): + if dry: + print(f" would make \"{REQUIRED_RULESET}\" leave out {test} ({TEST_PROPERTY}=true)") + else: + exclude = [p for p in ((required.get("conditions") or {}).get("repository_property") or {}).get("exclude") or []] + [test_exclusion()] + gh("api", "-X", "PUT", f"orgs/{ORG}/rulesets/{required['id']}", "--input", "-", input_text=json.dumps(with_property_condition(required, exclude))) + print(f" \"{REQUIRED_RULESET}\" leaves out {test}") + changed = True + if rules and carries: + if dry: + print(f" would take the `workflows` rule out of \"{MAIN_RULESET}\"") + else: + gh("api", "-X", "PUT", f"orgs/{ORG}/rulesets/{main['id']}", "--input", "-", input_text=json.dumps(without_workflows(main))) + print(f" \"{MAIN_RULESET}\" keeps its other rules; the required workflow is \"{REQUIRED_RULESET}\"'s") + changed = True + elif rules: + print(f" \"{REQUIRED_RULESET}\" has no `workflows` rule: \"{MAIN_RULESET}\" keeps it") + if changed: + ORG_RULESETS = None def not_adopted(every): @@ -2054,6 +2252,8 @@ def covers(org_rs, name, adopted=None): if org_rs.get("enforcement") != "active": return False if by_property(org_rs): + if name == TEST_REPO and excludes_test(org_rs): + return False return listed(name) is not None if adopted is None else adopted cond = ((org_rs.get("conditions") or {}).get("repository_name") or {}) include, exclude = cond.get("include") or [], cond.get("exclude") or [] @@ -2594,6 +2794,8 @@ def org_drift(conf): for r in org_rulesets() or []: if r.get("name") in ORG_RULESET_NAMES and not by_property(r): found.append(f"the ruleset \"{r['name']}\" names its repositories instead of covering those with `{ADOPTED}=true` (`sync-repos.py rulesets`)") + # The required workflow, out of the test repository (#748). + found += required_drift(org_rulesets() or [], current_major()) # The test repository and the test identity (#563). packs = {k: pack_of(k) for k in os.listdir(os.path.join(HERE, "..", "kinds")) if os.path.isfile(os.path.join(HERE, "..", "kinds", k, "pack.yml"))} login = org.test_identity() @@ -3832,6 +4034,112 @@ def repo_gh(*args, check=True, input_text=None): "no Project" in _said(lambda: sync_project({"name": "x"}, {"repos": {"x": {"kinds": []}}}, False))) +def required_split_test(check): + """#748 C2: the required workflow split out of "org: main" into "org: + required workflow", which leaves the test repository out by the test + property once it has its own pull-request caller, against a stand-in for + gh; and the drift check's four findings.""" + def eq(name, got, want): + check(name, got == want, got) + + class Out: + def __init__(self, stdout="", returncode=0, stderr=""): + self.stdout, self.returncode, self.stderr = stdout, returncode, stderr + flow = {"type": "workflows", "parameters": {"do_not_enforce_on_create": True, "workflows": [ + {"path": ".github/workflows/org-pull-request.yml", "repository_id": 1, "ref": "refs/tags/v7"}]}} + refs = {"include": ["~DEFAULT_BRANCH"], "exclude": []} + bypass = [{"actor_id": 333187390, "actor_type": "Integration", "bypass_mode": "always"}] + main = {"id": 3, "name": MAIN_RULESET, "target": "branch", "enforcement": "active", "source_type": "Organization", "bypass_actors": bypass, + "conditions": {"ref_name": refs, **property_condition()}, + "rules": [{"type": "pull_request"}, {"type": "deletion"}, {"type": "required_signatures"}, flow]} + every = [{"name": "old"}, {"name": TEST_REPO}] + + def run(rulesets, caller=True, values=None): + state = {"values": dict(values or {}), "calls": [], "bodies": []} + + def fake(*args, check=True, input_text=None): + joined = " ".join(args) + state["calls"].append(joined) + if input_text is not None: + state["bodies"].append((joined, json.loads(input_text))) + if joined.startswith("api orgs/o/properties/schema/"): + return Out("{}") + if joined.startswith("api --paginate --slurp orgs/o/properties/values"): + return Out(json.dumps([[{"repository_name": n, "properties": [{"property_name": ADOPTED, "value": "true"}] + + ([{"property_name": TEST_PROPERTY, "value": state["values"][n]}] if n in state["values"] else [])} for n in ("old", TEST_REPO)]])) + if joined == "api -X PATCH orgs/o/properties/values --input -": + body = json.loads(input_text) + for n in body["repository_names"]: + state["values"][n] = body["properties"][0]["value"] + return Out("") + if joined == f"api repos/o/{TEST_REPO}/contents/{TEST_CALLER}": + return Out("{}") if caller else Out("", 1, "HTTP 404") + return Out("{}") + saved = {n: globals()[n] for n in ("gh", "ORG", "ORG_RULESETS", "listed")} + import contextlib + import io + try: + globals().update(gh=fake, ORG="o", ORG_RULESETS=rulesets, listed=lambda n: {"kinds": []}) + with contextlib.redirect_stdout(io.StringIO()) as out: + sync_rulesets(every, every, False) + finally: + globals().update(saved) + state["out"] = out.getvalue() + return state + st = run([main]) + posts = [b for c, b in st["bodies"] if c == "api -X POST orgs/o/rulesets --input -"] + puts = [b for c, b in st["bodies"] if c == "api -X PUT orgs/o/rulesets/3 --input -"] + eq("#748 C2: the required workflow's ruleset is created with ref_name and repository_property, the test property excluded", + [(b["name"], b["conditions"]["ref_name"], b["conditions"]["repository_property"]) for b in posts], + [(REQUIRED_RULESET, refs, {"include": [{"name": ADOPTED, "property_values": ["true"], "source": "custom"}], "exclude": [test_exclusion()]})]) + eq("#748 C2: it carries \"org: main\"'s workflows rule unchanged, at v7, with its target, enforcement and bypass actors", + [(b["rules"], b["target"], b["enforcement"], b["bypass_actors"]) for b in posts], [([flow], "branch", "active", bypass)]) + eq("#748 C2: \"org: main\" keeps its other rules, complete conditions and all, for the test repository too", + [([r["type"] for r in b["rules"]], b["conditions"]["ref_name"], b["conditions"]["repository_property"]["exclude"]) for b in puts], + [(["pull_request", "deletion", "required_signatures"], refs, [])]) + eq("#748 C2: it is created before the rule is taken out of \"org: main\"", + st["calls"].index("api -X POST orgs/o/rulesets --input -") < st["calls"].index("api -X PUT orgs/o/rulesets/3 --input -"), True) + eq("#748 C2: the test property is set to true on the test repository alone, before the ruleset excludes it", + (st["values"], [b["repository_names"] for c, b in st["bodies"] if "PATCH" in c], + st["calls"].index("api -X PATCH orgs/o/properties/values --input -") < st["calls"].index("api -X POST orgs/o/rulesets --input -")), + ({TEST_REPO: "true"}, [[TEST_REPO]], True)) + required = dict(posts[0], id=8, source_type="Organization") + kept = dict(main, rules=puts[0]["rules"]) + eq("#748 C2: the required workflow covers every adopted repository but the test repository, which \"org: main\" still covers", + (covers(required, "old", True), covers(required, TEST_REPO, True), covers(kept, TEST_REPO, True)), (True, False, True)) + st = run([main], caller=False) + posts = [b for c, b in st["bodies"] if c == "api -X POST orgs/o/rulesets --input -"] + eq("#748 C2: with no pull-request caller in the test repository yet, the ruleset still covers it, and the sync says so", + ([b["conditions"]["repository_property"]["exclude"] for b in posts], TEST_CALLER in st["out"], any("rulesets/3" in c for c in st["calls"])), ([[]], True, True)) + st = run([dict(posts[0], id=8), kept], values={TEST_REPO: "true"}) + eq("#748 C2: once it has its caller, a later run excludes it, conditions complete, the rest unchanged, and nothing else is written", + ([(c, b["conditions"]["ref_name"], b["conditions"]["repository_property"]["exclude"], b["rules"]) for c, b in st["bodies"]], [c for c in st["calls"] if " -X " in c]), + ([("api -X PUT orgs/o/rulesets/8 --input -", refs, [test_exclusion()], [flow])], ["api -X PUT orgs/o/rulesets/8 --input -"])) + st = run([required, kept], values={TEST_REPO: "true"}) + eq("#748 C2: in step, it only reads", [c for c in st["calls"] if " -X " in c], []) + st = run([dict(required, rules=[]), main], values={TEST_REPO: "true"}) + eq("#748 C2: a required-workflow ruleset with no workflows rule leaves \"org: main\" its own", [c for c in st["calls"] if "rulesets/3" in c], []) + no_ref = dict(main, conditions=property_condition()) + try: + with_property_condition(no_ref) + eq("#748 C2: a ruleset with no ref_name is never written", "written", "RuntimeError") + except RuntimeError as exc: + eq("#748 C2: a ruleset with no ref_name is never written", "`ref_name`" in str(exc), True) + seen = [] + try: + seen.append(run([no_ref])) + except RuntimeError as exc: + seen.append(str(exc)) + eq("#748 C2: nor split: the sync stops, naming it", seen, [f"the ruleset \"{REQUIRED_RULESET}\" would have no `ref_name` condition: nothing was written"]) + got = required_drift([main, dict(required, conditions={"ref_name": refs, **property_condition()}), + dict(required, name="org: release tags", conditions=property_condition(), rules=[]), + dict(required, rules=[{"type": "workflows", "parameters": {"workflows": [dict(flow["parameters"]["workflows"][0], ref="refs/tags/v7-next")]}}])], 7) + eq("#748 C2: the drift check names a workflows rule left in \"org: main\", the test repository covered, a ruleset with no ref_name, and a ref other than v7", + [next(k for k in ("still holds the `workflows` rule", "covers the test repository", "no `ref_name`", "at `v7-next`, not `v7`") if k in line) for line in got], + ["still holds the `workflows` rule", "covers the test repository", "no `ref_name`", "at `v7-next`, not `v7`"]) + eq("#748 C2: the split rulesets are in step", required_drift([required, kept], 7), []) + + def conventions_test(check, block): """#562, C4: a repository whose own commit rules contradict the organisation's is listed by the drift check; one in step is not.""" @@ -4498,6 +4806,7 @@ def rsc(*names): dependabot_test(check, conf, labels, block_file) ai_plugin_drift_test(check, conf, labels, block_file) adopt_test(check, conf, labels, block_file) + required_split_test(check) security_visibility_test(check) conventions_test(check, block_file) release_caller_test(check, conf) From c49be3ad1b8988edc50a28443a45ec43328ea58d Mon Sep 17 00:00:00 2001 From: "dilux-bot[bot]" <333187390+dilux-bot[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:04:28 +0000 Subject: [PATCH 04/11] fix(sync): the test repository gets its pipeline caller and gate (C3) new_callers also writes the callers a kind's pack lists that every repository does not have, so sync-repos.py callers writes test-pull-request.yml to the engine-test repository alone and holds it there job for job; no other template takes that file for its caller. Once the test repository has the caller on its default branch, sync-repos.py rulesets writes there a ruleset of its own, "required pipeline", that requires the caller's pipeline check on the default branch, before the required workflow leaves it: the gate the required workflow gives everywhere else. The template's tests hold its on: and jobs to org-pull-request.yml's plus pipeline, and the release lane's self-test moves it to @v7-next and puts it back. Assisted-by: Claude Opus 5.5 (Anthropic) --- scripts/release-scenarios.py | 3 + scripts/sync-repos.py | 88 +++++++++++++++++++--- tests/workflows/test_test_repo_pipeline.py | 28 +++++++ 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/scripts/release-scenarios.py b/scripts/release-scenarios.py index b003c683..6eb16974 100644 --- a/scripts/release-scenarios.py +++ b/scripts/release-scenarios.py @@ -2522,6 +2522,9 @@ def none_yet(*a, input=None): check("#748 C1: after point_callers, every engine call of test-pull-request.yml's review, auto-merge and autopilot-fix jobs is at @v7-next", (rows, sorted(m["name"] for m in moved), {j: jobs[j]["uses"].rsplit("@", 1)[1] for j in ("review", "auto-merge", "autopilot-fix")}), ([], ["issues.yml", "test-pull-request.yml"], {"review": "v7-next", "auto-merge": "v7-next", "autopilot-fix": "v7-next"})) + check("#748 C3: and put back after, as it was, with the others", + (put_back("o/t", "v7-next", moved, fake, "O/.github", lambda s: None, 3), st["main"]["test-pull-request.yml"] == shipped, st["main"]["issues.yml"] == caller), + ([], True, True)) st, fake = repo_fake() moved = [] run_all(one, "o/t", "v7-next", run=fake, sleep=lambda s: None, minutes=3, central="O/.github", moved_out=moved) diff --git a/scripts/sync-repos.py b/scripts/sync-repos.py index b03d0c87..2252ec0f 100755 --- a/scripts/sync-repos.py +++ b/scripts/sync-repos.py @@ -661,14 +661,44 @@ def sync_test_property(name, dry): print(f" {TEST_PROPERTY}=true on {name}") +REQUIRED_PIPELINE = "required pipeline" + + +def required_pipeline_ruleset(): + """The test repository's own ruleset "required pipeline" (#748): the + `pipeline` check of its pull-request caller required on its default + branch, the gate the required workflow gives everywhere else (a required + workflow requires its whole run to succeed; `pipeline` is that run as one + check, on the pull request and in the merge queue). Pure.""" + return {"name": REQUIRED_PIPELINE, "target": "branch", "enforcement": "active", "bypass_actors": [], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [{"type": "required_status_checks", "parameters": { + "strict_required_status_checks_policy": False, "do_not_enforce_on_create": False, + "required_status_checks": [{"context": "pipeline"}]}}]} + + +def sync_required_pipeline(name, dry): + """"required pipeline" written in the test repository when it has none + (#748); one that exists is left as it is.""" + full = f"{ORG}/{name}" + if any(r.get("name") == REQUIRED_PIPELINE for r in rulesets(full)): + return + if dry: + print(f" would create the ruleset \"{REQUIRED_PIPELINE}\" in {name}, requiring the `pipeline` check") + return + gh("api", "-X", "POST", f"repos/{full}/rulesets", "--input", "-", input_text=json.dumps(required_pipeline_ruleset())) + print(f" \"{REQUIRED_PIPELINE}\" in {name} requires the `pipeline` check") + + def split_required(every, dry, org_rs=None): """The required workflow in a ruleset of its own (#748): "org: required workflow" created first, with "org: main"'s `workflows` rule as it is, and only then that rule taken out of "org: main", so no repository is ever without it. It leaves out the test repository (the test property, set on it first) once that repository has its pull-request caller on its - default branch; until then it covers it, and says so, for otherwise its - pull requests would run no pipeline.""" + default branch, and its own ruleset "required pipeline" requires that + caller's `pipeline` check; until then it covers it, and says so, for + otherwise its pull requests would run no pipeline.""" global ORG_RULESETS org_rs = org_rulesets() or [] if org_rs is None else org_rs main = next((r for r in org_rs if r.get("name") == MAIN_RULESET), None) @@ -681,7 +711,10 @@ def split_required(every, dry, org_rs=None): if test: sync_test_property(test, dry) ready = has_test_caller(test) - if not ready: + if ready: + # Its gate first, then the required workflow leaves it. + sync_required_pipeline(test, dry) + else: print(f" {test} has no {TEST_CALLER} on its default branch yet: \"{REQUIRED_RULESET}\" still covers it, " "so its pull requests run a pipeline (`sync-repos.py callers` writes it)") changed = False @@ -1453,9 +1486,11 @@ def kinds_only(conf, root=None): def templates_for(kind, conf, major=None): """templates(major), less those of other kinds alone (kinds_only()). One of this kind alone is marked `_own`: its caller is the file of its name, - never another that calls some of the same workflows (caller_for()).""" + never another that calls some of the same workflows, and no other + template's caller is that file (`_skip`, caller_for()).""" only = kinds_only(conf) - return {n: (t, dict(d, _own=True) if n in only else d) for n, (t, d) in templates(major).items() if n not in only or kind in only[n]} + return {n: (t, dict(d, _own=True) if n in only else dict(d, _skip=set(only))) for n, (t, d) in templates(major).items() + if n not in only or kind in only[n]} def split_uses(uses, central=False): @@ -1490,7 +1525,7 @@ def caller_for(template, files, central=False): return next((p for p in sorted(files) if os.path.basename(p) == template.get("_name")), None) wanted = shared_calls(template) best = None - for path in sorted(files): + for path in sorted(p for p in files if os.path.basename(p) not in template.get("_skip", ())): overlap = len(shared_calls(files[path][1], central) & wanted) if overlap: key = (overlap, os.path.basename(path) == template.get("_name")) @@ -1783,7 +1818,8 @@ def new_callers(files, conf, kind, default="main", major=None, name=None, notes= """{path: text} of the callers a repository lacks, written whole from their templates (#552): those of `every-repository` (the triage and the autopilot start from issues.yml) and those that call a workflow of its - kind's battery, each at .github/workflows/