From 2b3fd3a5c42b94438a50bd7b4e259415ea4f727b Mon Sep 17 00:00:00 2001 From: Bart Van Bos Date: Sat, 14 Nov 2026 01:49:14 +0100 Subject: [PATCH] feat(rollout): plan and prepare the move to the PR workflow scripts/rollout/pr-workflow.py plans the move of every repository in a workspace to the PR workflow (pr-changes.yml and the required-gate action) and prepares one repository at a time: it rewrites the triggers of the heavy workflows to workflow_call + workflow_dispatch, keeps the docs workflow on the push to the default branch, and writes pr.yml with the gate. Dry run by default, idempotent, and it refuses (exit 3) a repository whose workflows need a person: other triggers, dispatch inputs, path filters, their own concurrency group, or expressions that read the event or ref. It never commits, pushes, opens a pull request or touches a ruleset. scripts/selftest/rollout.sh runs it on two fixtures in testdata/rollout. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/selftest.yml | 10 + AGENTS.md | 1 + README.md | 1 + scripts/rollout/README.md | 94 +++++ scripts/rollout/exceptions.txt | 4 + scripts/rollout/pr-workflow.py | 388 ++++++++++++++++++ scripts/selftest/rollout.sh | 69 ++++ .../rollout/manual/.github/workflows/ci.yml | 19 + .../rollout/ready/.github/workflows/ci.yml | 19 + .../rollout/ready/.github/workflows/docs.yml | 13 + .../ready/.github/workflows/interop.yml | 16 + .../ready/.github/workflows/nightly.yml | 14 + testdata/rollout/ready/.otfabric/policy.yaml | 3 + testdata/rollout/ready/README.md | 1 + testdata/rollout/ready/readme.go | 5 + 15 files changed, 657 insertions(+) create mode 100644 scripts/rollout/README.md create mode 100644 scripts/rollout/exceptions.txt create mode 100755 scripts/rollout/pr-workflow.py create mode 100755 scripts/selftest/rollout.sh create mode 100644 testdata/rollout/manual/.github/workflows/ci.yml create mode 100644 testdata/rollout/ready/.github/workflows/ci.yml create mode 100644 testdata/rollout/ready/.github/workflows/docs.yml create mode 100644 testdata/rollout/ready/.github/workflows/interop.yml create mode 100644 testdata/rollout/ready/.github/workflows/nightly.yml create mode 100644 testdata/rollout/ready/.otfabric/policy.yaml create mode 100644 testdata/rollout/ready/README.md create mode 100644 testdata/rollout/ready/readme.go diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 17e1d17..feaa489 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -13,6 +13,7 @@ on: - ".github/workflows/**" - "actions/**" - "scripts/selftest/**" + - "scripts/rollout/**" - "testdata/**" push: branches: [main] @@ -281,3 +282,12 @@ jobs: changes-job: pr-changes-live required: pr-changes-live gate-fixture-light required-for-code: gate-fixture-heavy-code + + # scripts/rollout/pr-workflow.py on a repository it prepares and one it refuses. + rollout-cases: + name: Rollout tool cases + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: scripts/selftest/rollout.sh diff --git a/AGENTS.md b/AGENTS.md index 70cbb3c..4f8e643 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ This repository follows **otfabric/engineering policy 1.2**, profile **infrastru | `go-ci.yml` policy steps | `scripts/selftest/policy.sh` | | release notes extraction | `scripts/selftest/release-notes.sh` | | the Go version of release builds | `scripts/selftest/go-version.sh` | +| `scripts/rollout/pr-workflow.py` | `scripts/selftest/rollout.sh` | | `docs-check.yml` docsync step | `scripts/selftest/docs-check.sh` | | `pr-changes.yml` classification | `scripts/selftest/pr-changes.sh` | | `actions/required-gate` | `scripts/selftest/required-gate.sh` | diff --git a/README.md b/README.md index 6abe4e3..abfacf1 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,6 @@ Shared GitHub configuration of the OT Fabric organisation. | [`actions/`](actions/README.md) | Composite actions: `required-gate` (the required check of a PR workflow), and the upstream check of the `*-interop` repositories | | `profile/` | The organisation profile page | | `scripts/selftest/`, `testdata/` | Selftest of the workflows (`selftest.yml`) | +| [`scripts/rollout/`](scripts/rollout/README.md) | Plans and prepares the move of repositories to the PR workflow | Reference a workflow by release tag: `@v3.1.0`, or the floating `@v2` / `@v3`. `v1` is an old workflow tag. diff --git a/scripts/rollout/README.md b/scripts/rollout/README.md new file mode 100644 index 0000000..8b3780f --- /dev/null +++ b/scripts/rollout/README.md @@ -0,0 +1,94 @@ +# Rollout of the PR workflow + +`pr-workflow.py` moves repositories to the PR workflow +([`.github/workflows/README.md`](../../.github/workflows/README.md#pr-workflow-and-the-required-check)): +heavy validation once per pull request revision, one required check, `OT Fabric +Required Checks`. It plans for the whole organisation and prepares one +repository at a time. It never commits, pushes, opens a pull request or +touches a ruleset. Standard library only; `scripts/selftest/rollout.sh` tests it. + +## Plan + +```bash +# A fresh workspace: one shallow clone per repository (otfabric/engineering). +scripts/audit-inventory.sh /tmp/otfabric-ws +python3 scripts/rollout/pr-workflow.py plan /tmp/otfabric-ws > plan.md +python3 scripts/rollout/pr-workflow.py plan /tmp/otfabric-ws --profile cli --format json +``` + +Per repository, from its `.otfabric/policy.yaml` profile and its workflow files: + +| Status | Meaning | +|---|---| +| `migrated` | `pr.yml` uses `required-gate` | +| `ready` | every workflow is a known pattern: `apply` can prepare it | +| `manual` | a person decides; the notes say why | +| `no-ci` | no workflow runs on push or pull request: nothing to gate | +| `excepted` | listed in [`exceptions.txt`](exceptions.txt), with the reason | + +Workflows are sorted into: + +- **heavy**: `push`/`pull_request` workflows. They become `workflow_call` + `workflow_dispatch` and run in `pr.yml` unless the change is documentation only. +- **light**: `docs-check.yml` callers and `policy.yml`. They keep the push to the default branch and run on every pull request. +- **kept**: schedule, dispatch, tag-push releases, code scanning, workflows that are already reusable. + +A repository is `manual` when a workflow has: + +- other triggers; +- `workflow_dispatch` inputs; +- a `paths:` filter (in `pr.yml` it would run on every code pull request); +- its own `concurrency` group (`github.workflow` is the caller's name, so a group of the same form deadlocks); +- an expression that reads the event or ref (`github.ref`, `github.event_name`, `github.event.pull_request`…), which once the workflow is called is always the caller's `pull_request` event. + +`plan` also suggests `code-paths`: Markdown that Go code names by a path relative to its package, or embeds. + +The profile decides what must be gated, not the tool. The tool carries over what each repository already runs. So a website gets no Go CI, and an information model gets no interop. + +## Prepare one repository + +```bash +cd ../go-cotp && git switch -c ci/pr-required-gate +python3 ../.github/scripts/rollout/pr-workflow.py apply . # dry run: plan and pr.yml +python3 ../.github/scripts/rollout/pr-workflow.py apply . --write # rewrites the on: blocks, writes pr.yml +git diff # only on: blocks and the new pr.yml +(cd ../engineering && go run ./cmd/otfabric-policy check ../go-cotp) +``` + +Then, by hand: + +1. Update the repository's TESTING.md/CONTRIBUTING.md ("When CI runs", as in go-mms). +2. Commit, push, and open a **draft** pull request. +3. On that pull request, check: + - `OT Fabric Required Checks` reports, with that exact name; + - its summary lists every job; + - the push of the branch started no run. +4. Open a disposable stacked pull request that changes one document. It must classify as documentation only and pass, with the heavy jobs skipped. Close it. +5. Mark the pull request ready. The maintainer merges it. + +A second `apply` on a prepared repository reports `migrated` and changes nothing. `apply` exits 3 and writes nothing when the repository needs a person. + +## Order + +Pilot one repository per profile before any broad adoption. Representatives, from the plan of 2026-11-14 (local workspace): + +| Profile | Pilot | Status then | +|---|---|---| +| protocol-library | go-mms, go-iec61850 | prepared by hand (pilot pull requests) | +| foundational-transport | go-cotp | ready | +| cli | a ready `*ctl` | ready (18 of 19) | +| interoperability | a ready `*-interop` | ready (3 of 18); most have tag-push releases or their own concurrency | +| information-model | go-iec61850-model | manual: `nsd.yml` has a `paths:` filter | +| application | iec61850-explorer | manual: own concurrency group | +| website | none | no CI to gate | +| infrastructure | engineering | manual | + +Never mass-merge. One pull request per repository, reviewed and merged by the maintainer. + +## Requiring the check + +A repository joins the required-status-check ruleset only after its PR workflow is merged, and only once `OT Fabric Required Checks` has reported on a pull request after that merge. Otherwise every open pull request waits for a check that never comes. Add repositories to the ruleset by name, one change at a time. The commands are in the Phase 1 report. + +## Rollback of one repository + +1. Remove the repository from the ruleset's `repository_name.include` (or set the ruleset to `disabled`). +2. Revert the merge of its PR workflow pull request (`git revert -m 1 `). This restores the `push`/`pull_request` triggers and deletes `pr.yml`. diff --git a/scripts/rollout/exceptions.txt b/scripts/rollout/exceptions.txt new file mode 100644 index 0000000..d7491f7 --- /dev/null +++ b/scripts/rollout/exceptions.txt @@ -0,0 +1,4 @@ +# Repositories the PR workflow rollout leaves alone, one per line: +# : +# Remove a line to bring the repository back into the plan. +.github: Selftest is path-filtered on pull requests and runs on every push to main by design (AGENTS.md rule 6); its gate is a separate decision diff --git a/scripts/rollout/pr-workflow.py b/scripts/rollout/pr-workflow.py new file mode 100755 index 0000000..972d518 --- /dev/null +++ b/scripts/rollout/pr-workflow.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""Plan and prepare the move of OT Fabric repositories to the PR workflow. + + pr-workflow.py plan [--profile P] [--format md|json] + pr-workflow.py apply [--ref REF] [--write] + +plan reads every checkout in a workspace (one directory per repository, as +otfabric/engineering scripts/audit-inventory.sh clones them) and reports, per +profile, which repositories already have the PR workflow, which can be +prepared automatically, which need a person, and why. It changes nothing. + +apply prepares one repository: the heavy workflows lose their push and +pull_request triggers and gain workflow_call and workflow_dispatch, the light +ones (docs-check, otfabric-policy) keep the push to the default branch, and a +new .github/workflows/pr.yml calls them all and ends in the gate. Without +--write it prints the plan and the new pr.yml and writes nothing. It refuses +a repository that needs a person, and does nothing to one that already has +the gate, so running it twice is safe. It never commits, pushes or opens a +pull request. + +Workflows are read as text, not as YAML, and only the patterns below are +handled; anything else is reported for a person to look at. Standard library +only. +""" +import argparse +import json +import os +import re +import sys + +GATE_NAME = "OT Fabric Required Checks" +HERE = os.path.dirname(os.path.abspath(__file__)) +SIMPLE_TRIGGERS = {"push", "pull_request", "workflow_dispatch"} +# A converted workflow that reads one of these behaved differently on push +# and pull_request, or on main: it now always runs as the caller's event. +EVENT_EXPRESSIONS = re.compile( + r"github\.event_name|github\.ref\b|github\.ref_name|github\.head_ref|github\.base_ref" + r"|github\.event\.(before|after|head_commit|commits|pull_request)") +MD_LITERAL = re.compile(r'"([^"\s]+\.md)"|//go:embed\s+(\S+\.md)') +PROFILE_NOTES = { + "protocol-library": "Go CI and independent interop are mandatory", + "foundational-transport": "Go CI is mandatory; interop where a workflow exists", + "cli": "Go CI is mandatory; interop where a workflow exists", + "interoperability": "the harness's own build and adapter tests; no go-ci", + "information-model": "Go CI; no interop", + "application": "the application's CI", + "website": "the site's build and checks, if any; no Go CI", + "infrastructure": "repository specific; decide by hand", +} + + +def read(path): + with open(path, encoding="utf-8") as f: + return f.read() + + +def load_exceptions(): + out = {} + path = os.path.join(HERE, "exceptions.txt") + if os.path.exists(path): + for line in read(path).splitlines(): + line = line.strip() + if line and not line.startswith("#") and ":" in line: + repo, reason = line.split(":", 1) + out[repo.strip()] = reason.strip() + return out + + +def profile_of(repo): + path = os.path.join(repo, ".otfabric", "policy.yaml") + if os.path.exists(path): + m = re.search(r"^profile:\s*['\"]?([\w-]+)", read(path), re.M) + if m: + return m.group(1) + return "unknown" + + +def on_block(text): + """(start, end, triggers) of the top-level `on:` block; triggers maps a + trigger name to its body text. None when there is no block form.""" + lines = text.splitlines(keepends=True) + for i, line in enumerate(lines): + if re.match(r"^['\"]?on['\"]?:\s*(#.*)?$", line): + break + if re.match(r"^['\"]?on['\"]?:\s*\S", line): + return None # on: push / on: [push, pull_request] + else: + return None + j = i + 1 + while j < len(lines) and (not lines[j].strip() or lines[j].startswith((" ", "#"))): + j += 1 + # Leave trailing blank and comment lines to whatever follows. + while j > i + 1 and (not lines[j - 1].strip() or lines[j - 1].lstrip().startswith("#")): + j -= 1 + body = lines[i + 1:j] + indent = min((len(l) - len(l.lstrip()) for l in body if l.strip() and not l.lstrip().startswith("#")), default=2) + triggers, current = {}, None + for l in body: + m = re.match(r"^ {%d}([a-z_]+):" % indent, l) + if m: + current = m.group(1) + triggers[current] = l + elif current: + triggers[current] += l + start = sum(len(l) for l in lines[:i]) + return start, start + sum(len(l) for l in lines[i:j]), triggers + + +def top_level_block(text, key): + m = re.search(r"^%s:.*\n((?:[ #].*\n|\n)*)" % key, text, re.M) + return m.group(0).rstrip() if m else None + + +def classify_workflow(path, default_branch): + """A dict: kind (light, heavy, keep, manual), why, and for heavy and light + the replacement `on:` block.""" + text = read(path) + name = os.path.basename(path) + w = {"file": name, "text": text, "notes": []} + block = on_block(text) + if block is None: + w.update(kind="manual", why="no `on:` block in a form this tool reads") + return w + _, _, triggers = block + w["triggers"] = sorted(triggers) + if "workflow_call" in triggers and "pull_request" not in triggers and "push" not in triggers: + w.update(kind="keep", why="already reusable; called from elsewhere") + return w + if "pull_request" not in triggers and "push" not in triggers: + w.update(kind="keep", why="not triggered by push or pull_request: " + ", ".join(sorted(triggers))) + return w + push = triggers.get("push", "") + pr = triggers.get("pull_request", "") + if re.search(r"^\s+tags(-ignore)?:", push, re.M): + w.update(kind="keep", why="push of tags (a release); left as is") + return w + if re.search(r"codeql|github/codeql-action", text): + w.update(kind="keep", why="code scanning keeps its default-branch analysis; left as is") + return w + light = "docs-check.yml@" in text or re.fullmatch(r"policy\.ya?ml", name) is not None + reasons = [] + extra = set(triggers) - SIMPLE_TRIGGERS + if extra: + reasons.append("other triggers: " + ", ".join(sorted(extra))) + if re.search(r"^\s+inputs:", triggers.get("workflow_dispatch", ""), re.M): + reasons.append("workflow_dispatch has inputs, which workflow_call would need too") + for trig in (push, pr): + if re.search(r"^\s+paths:", trig, re.M): + reasons.append("a `paths:` filter: in pr.yml it would run on every code pull request") + break + if re.search(r"^\s+paths-ignore:", trig, re.M) and not re.search(r"paths-ignore:\s*\[\s*['\"]\*\*/\*\.md['\"]\s*\]", trig): + reasons.append("a paths-ignore filter other than Markdown") + break + if top_level_block(text, "concurrency"): + reasons.append("its own concurrency group may equal pr.yml's (github.workflow is the caller's name)") + if not light: + # run-name is ignored when the workflow is called. + m = EVENT_EXPRESSIONS.search(re.sub(r"^run-name:.*$", "", text, flags=re.M)) + if m: + reasons.append(f"reads {m.group(0)}, which is the caller's pull_request event once called") + if reasons: + w.update(kind="manual", why="; ".join(reasons)) + return w + if light: + w.update(kind="light", on=f"on:\n push:\n branches: [{default_branch}]\n workflow_call:\n workflow_dispatch:\n") + w["why"] = "light check: runs on every pull request and every push to " + default_branch + if "push" not in triggers: + w["on"] = "on:\n workflow_call:\n workflow_dispatch:\n" + else: + w.update(kind="heavy", on="on:\n workflow_call:\n workflow_dispatch:\n", + why="heavy: once per pull request revision, not on branch pushes or main") + return w + + +def job_id(name): + stem = re.sub(r"\.ya?ml$", "", name) + return re.sub(r"[^A-Za-z0-9_-]", "-", stem).strip("-") or "job" + + +def permissions_lines(text): + block = top_level_block(text, "permissions") + if not block: + return None + first, *rest = block.splitlines() + inline = first.split(":", 1)[1].strip() + if inline: + return [f" permissions: {inline}"] + return [" permissions:"] + [" " + l for l in rest if l.strip() and not l.lstrip().startswith("#")] + + +def needs_secrets(text): + return "secrets: inherit" in text or re.search(r"secrets\.(?!GITHUB_TOKEN\b)\w+", text) is not None + + +def suggest_code_paths(repo): + """Markdown that Go code names by a literal path relative to its package, + or embeds: a test or generator reads it, so it counts as code.""" + found = set() + for root, dirs, files in os.walk(repo): + dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("vendor", "node_modules")] + for f in files: + if not f.endswith(".go"): + continue + try: + text = read(os.path.join(root, f)) + except (OSError, UnicodeDecodeError): + continue + for m in MD_LITERAL.finditer(text): + rel = m.group(1) or m.group(2) + path = os.path.normpath(os.path.join(root, rel)) + if os.path.isfile(path) and "testdata" not in path.split(os.sep): + found.add(os.path.relpath(path, repo).replace(os.sep, "/")) + return sorted(found) + + +def default_branch_of(repo): + head = os.path.join(repo, ".git", "refs", "remotes", "origin", "HEAD") + if os.path.isfile(head): + m = re.search(r"refs/remotes/origin/(\S+)", read(head)) + if m: + return m.group(1) + return "main" + + +def analyse(repo, exceptions): + name = os.path.basename(os.path.normpath(repo)) + wdir = os.path.join(repo, ".github", "workflows") + files = sorted(f for f in os.listdir(wdir) if f.endswith((".yml", ".yaml"))) if os.path.isdir(wdir) else [] + r = {"repo": name, "profile": profile_of(repo), "workflows": [], "status": None, "why": []} + if name in exceptions: + r.update(status="excepted", why=[exceptions[name]]) + return r + if "pr.yml" in files and "required-gate@" in read(os.path.join(wdir, "pr.yml")): + r.update(status="migrated", why=["pr.yml with the gate"]) + return r + if "pr.yml" in files: + r.update(status="manual", why=["a pr.yml without the gate exists"]) + return r + branch = default_branch_of(repo) + for f in files: + r["workflows"].append(classify_workflow(os.path.join(wdir, f), branch)) + heavy = [w for w in r["workflows"] if w["kind"] == "heavy"] + light = [w for w in r["workflows"] if w["kind"] == "light"] + manual = [w for w in r["workflows"] if w["kind"] == "manual"] + if not heavy and not light and not manual: + r.update(status="no-ci", why=["no workflow runs on push or pull_request"]) + elif manual: + r.update(status="manual", why=[f"{w['file']}: {w['why']}" for w in manual]) + elif not light: + r.update(status="manual", why=["no light check (docs.yml or policy.yml) to run on every pull request"]) + else: + r.update(status="ready", why=[]) + if r["profile"] == "protocol-library" and not any("interop" in w["file"] for w in r["workflows"]): + r["why"].append("protocol-library without an interop workflow") + if r["profile"] == "unknown": + r["why"].append("no .otfabric/policy.yaml profile") + r["code_paths"] = suggest_code_paths(repo) if r["status"] in ("ready", "manual") else [] + return r + + +def render_pr_yml(r, ref): + jobs, required, for_code = [], ["changes"], [] + changes = [f" changes:\n uses: otfabric/.github/.github/workflows/pr-changes.yml@{ref}\n"] + if r["code_paths"]: + changes.append(" with:\n # Go code reads these Markdown files: they count as code.\n code-paths: |\n") + changes += [f" {p}\n" for p in r["code_paths"]] + jobs.append("".join(changes)) + for w in r["workflows"]: + if w["kind"] not in ("light", "heavy"): + continue + jid = job_id(w["file"]) + while jid in required + for_code or jid in ("changes", "required"): + jid += "-wf" + lines = [f" {jid}:"] + if w["kind"] == "heavy": + lines += [" needs: changes", " if: needs.changes.outputs.full == 'true'"] + for_code.append(jid) + else: + required.append(jid) + perms = permissions_lines(w["text"]) + if perms: + lines += perms + lines.append(f" uses: ./.github/workflows/{w['file']}") + if needs_secrets(w["text"]): + lines.append(" secrets: inherit") + jobs.append("\n".join(lines) + "\n") + names = ", ".join(required + for_code) + gate = (f" required:\n name: {GATE_NAME}\n needs: [{names}]\n if: always()\n" + f" runs-on: ubuntu-latest\n timeout-minutes: 5\n steps:\n" + f" - uses: otfabric/.github/actions/required-gate@{ref}\n with:\n" + " needs: ${{ toJSON(needs) }}\n" + f" required: {' '.join(required)}\n") + if for_code: + gate += f" required-for-code: {' '.join(for_code)}\n" + head = ("# The pull request gate (otfabric/.github PR workflow), generated by\n" + "# otfabric/.github scripts/rollout/pr-workflow.py. No path filter: the light\n" + "# checks always run, the heavy ones unless the change is documentation only,\n" + f'# then "{GATE_NAME}", the only check a ruleset requires.\n' + "name: PR\non:\n pull_request:\n\npermissions:\n contents: read\n\n" + "concurrency:\n group: ${{ github.workflow }}-${{ github.event.pull_request.number }}\n" + " cancel-in-progress: true\n\njobs:\n") + return head + "\n".join(jobs) + "\n" + gate + + +def cmd_plan(args): + exceptions = load_exceptions() + repos = [] + for d in sorted(os.listdir(args.workspace)): + path = os.path.join(args.workspace, d) + if d.startswith((".", "_")) or not os.path.isdir(os.path.join(path, ".git")): + continue + r = analyse(path, exceptions) + if args.profile and r["profile"] != args.profile: + continue + for w in r["workflows"]: + w.pop("text", None) + w.pop("on", None) + repos.append(r) + if args.format == "json": + json.dump(repos, sys.stdout, indent=1) + print() + return 0 + order = ["migrated", "ready", "manual", "no-ci", "excepted"] + print(f"# PR workflow rollout plan ({len(repos)} repositories)\n") + print("| Profile | " + " | ".join(order) + " |\n|---|" + "---:|" * len(order)) + profiles = sorted({r["profile"] for r in repos}) + for p in profiles: + counts = [sum(1 for r in repos if r["profile"] == p and r["status"] == s) for s in order] + print(f"| {p} | " + " | ".join(map(str, counts)) + " |") + for p in profiles: + print(f"\n## {p}\n\n{PROFILE_NOTES.get(p, '')}\n") + print("| Repository | Status | Heavy | Light | Kept | Notes |\n|---|---|---|---|---|---|") + for r in (r for r in repos if r["profile"] == p): + k = lambda kind: ", ".join(w["file"] for w in r["workflows"] if w["kind"] == kind) or "-" + notes = "; ".join(r["why"] + ([f"code-paths? {', '.join(r['code_paths'])}"] if r.get("code_paths") else [])) + print(f"| {r['repo']} | {r['status']} | {k('heavy')} | {k('light')} | {k('keep')} | {notes.replace('|', '/')} |") + return 0 + + +def cmd_apply(args): + r = analyse(args.repository, load_exceptions()) + print(f"{r['repo']} ({r['profile']}): {r['status']}") + for why in r["why"]: + print(f" - {why}") + if r["status"] != "ready": + print("nothing to do" if r["status"] in ("migrated", "no-ci", "excepted") else "needs a person: nothing written") + return 0 if r["status"] in ("migrated", "no-ci", "excepted") else 3 + wdir = os.path.join(args.repository, ".github", "workflows") + for w in r["workflows"]: + if w["kind"] in ("light", "heavy"): + print(f" {w['file']}: {w['kind']}, triggers {', '.join(w['triggers'])} -> " + f"{', '.join(re.findall(r'^ ([a-z_]+):', w['on'], re.M))}") + else: + print(f" {w['file']}: {w['kind']} ({w['why']})") + if r["code_paths"]: + print(" code-paths (Go code reads these; check them): " + ", ".join(r["code_paths"])) + pr = render_pr_yml(r, args.ref) + if not args.write: + print("\n--- .github/workflows/pr.yml (dry run, not written)\n" + pr) + return 0 + for w in r["workflows"]: + if w["kind"] in ("light", "heavy"): + start, end, _ = on_block(w["text"]) + with open(os.path.join(wdir, w["file"]), "w", encoding="utf-8") as f: + f.write(w["text"][:start] + w["on"] + w["text"][end:]) + with open(os.path.join(wdir, "pr.yml"), "w", encoding="utf-8") as f: + f.write(pr) + print("written; review the diff, then branch, commit and open a draft pull request") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("plan") + p.add_argument("workspace") + p.add_argument("--profile") + p.add_argument("--format", choices=["md", "json"], default="md") + a = sub.add_parser("apply") + a.add_argument("repository") + a.add_argument("--ref", default="v3.7.0", help="otfabric/.github ref for pr-changes.yml and required-gate") + a.add_argument("--write", action="store_true", help="write the files (default: dry run)") + args = ap.parse_args() + return cmd_plan(args) if args.cmd == "plan" else cmd_apply(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/selftest/rollout.sh b/scripts/selftest/rollout.sh new file mode 100755 index 0000000..ed311df --- /dev/null +++ b/scripts/selftest/rollout.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Run scripts/rollout/pr-workflow.py on the fixtures in testdata/rollout: a +# repository it can prepare and one it must refuse. Dry runs write nothing, +# --write produces the PR workflow layout, a second run changes nothing. +set -euo pipefail +cd "$(dirname "$0")/../.." +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +tool=(python3 -I scripts/rollout/pr-workflow.py) +cp -R testdata/rollout/ready testdata/rollout/manual "$tmp/" +fail() { echo "FAIL $*"; exit 1; } + +# Dry run: the plan and the new pr.yml, no file touched. +"${tool[@]}" apply "$tmp/ready" > "$tmp/dry" || fail "dry run exited non-zero" +grep -q "ready (protocol-library): ready" "$tmp/dry" || fail "dry run: not ready" +grep -q "code-paths (Go code reads these; check them): README.md" "$tmp/dry" || fail "dry run: README.md not suggested" +[ ! -e "$tmp/ready/.github/workflows/pr.yml" ] || fail "dry run wrote pr.yml" +diff -r testdata/rollout/ready "$tmp/ready" > /dev/null || fail "dry run changed a file" +echo "ok dry run writes nothing" + +"${tool[@]}" apply "$tmp/ready" --ref v9.9.9 --write > /dev/null +wf="$tmp/ready/.github/workflows" +on() { awk '/^on:/{p=1;next} p&&/^[^ #]/{p=0} p' "$wf/$1" | tr -d ' ' | grep -v '^$' | paste -sd, -; } +[ "$(on ci.yml)" = "workflow_call:,workflow_dispatch:" ] || fail "ci.yml on: $(on ci.yml)" +[ "$(on interop.yml)" = "workflow_call:,workflow_dispatch:" ] || fail "interop.yml on: $(on interop.yml)" +[ "$(on docs.yml)" = "push:,branches:[main],workflow_call:,workflow_dispatch:" ] || fail "docs.yml on: $(on docs.yml)" +cmp -s testdata/rollout/ready/.github/workflows/nightly.yml "$wf/nightly.yml" || fail "nightly.yml changed" +strip_on() { awk '/^on:/{p=1;next} p&&/^[^ #]/{p=0} !p' "$1"; } +for f in ci.yml interop.yml docs.yml; do + diff <(strip_on "testdata/rollout/ready/.github/workflows/$f") <(strip_on "$wf/$f") > /dev/null \ + || fail "$f: more than the on: block changed" +done +echo "ok triggers rewritten, jobs and nightly untouched" + +pr="$wf/pr.yml" +grep -qx " pull_request:" "$pr" || fail "pr.yml: no pull_request trigger" +if grep -qE "^ +paths(-ignore)?:" "$pr"; then fail "pr.yml has a path filter"; fi +grep -qx " name: OT Fabric Required Checks" "$pr" || fail "pr.yml: gate name" +grep -qx " needs: \[changes, docs, ci, interop\]" "$pr" || fail "pr.yml: gate needs: $(grep -m1 'needs: \[' "$pr")" +grep -qx " if: always()" "$pr" || fail "pr.yml: gate condition" +grep -qx " required: changes docs" "$pr" || fail "pr.yml: required" +grep -qx " required-for-code: ci interop" "$pr" || fail "pr.yml: required-for-code" +grep -q "pr-changes.yml@v9.9.9" "$pr" && grep -q "required-gate@v9.9.9" "$pr" || fail "pr.yml: --ref not used" +grep -qx " README.md" "$pr" || fail "pr.yml: code-paths" +[ "$(grep -c "if: needs.changes.outputs.full == 'true'" "$pr")" = 2 ] || fail "pr.yml: heavy jobs not conditional" +if sed -n '/^ interop:/,/^$/p' "$pr" | grep -q "secrets: inherit"; then fail "pr.yml: interop gets secrets it does not use"; fi +sed -n '/^ ci:/,/^$/p' "$pr" | grep -qx " id-token: write" || fail "pr.yml: ci permissions" +echo "ok pr.yml has the gate, every job and its permissions" + +cp -R "$tmp/ready" "$tmp/again" +"${tool[@]}" apply "$tmp/again" --write | grep -q ": migrated" || fail "second run: not migrated" +diff -r "$tmp/ready" "$tmp/again" > /dev/null || fail "second run changed a file" +echo "ok a second run changes nothing" + +got=0 +"${tool[@]}" apply "$tmp/manual" --write > "$tmp/manual.log" || got=$? +[ "$got" = 3 ] || fail "manual: exit $got, want 3" +grep -q "concurrency group" "$tmp/manual.log" && grep -q "reads github\." "$tmp/manual.log" || fail "manual: reasons" +diff -r testdata/rollout/manual "$tmp/manual" > /dev/null || fail "manual: a file was written" +echo "ok a repository that needs a person is refused, untouched" + +mkdir "$tmp/ws" +for r in ready manual; do cp -R "testdata/rollout/$r" "$tmp/ws/$r"; mkdir "$tmp/ws/$r/.git"; done +"${tool[@]}" plan "$tmp/ws" > "$tmp/plan.md" +grep -q "| protocol-library | 0 | 1 | 0 | 0 | 0 |" "$tmp/plan.md" || fail "plan: protocol-library row" +grep -q "| unknown | 0 | 0 | 1 | 0 | 0 |" "$tmp/plan.md" || fail "plan: unknown row" +"${tool[@]}" plan "$tmp/ws" --format json | python3 -I -c 'import json,sys; d=json.load(sys.stdin); assert {r["repo"]: r["status"] for r in d} == {"ready": "ready", "manual": "manual"}, d' +echo "ok plan counts by profile" +echo "rollout: all cases match" diff --git a/testdata/rollout/manual/.github/workflows/ci.yml b/testdata/rollout/manual/.github/workflows/ci.yml new file mode 100644 index 0000000..07335c4 --- /dev/null +++ b/testdata/rollout/manual/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - run: make test diff --git a/testdata/rollout/ready/.github/workflows/ci.yml b/testdata/rollout/ready/.github/workflows/ci.yml new file mode 100644 index 0000000..a055708 --- /dev/null +++ b/testdata/rollout/ready/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI +run-name: "CI (${{ github.event_name }}) on ${{ github.ref_name }}" + +on: + push: + branches: ["**"] + paths-ignore: ["**/*.md"] + pull_request: + branches: ["**"] + paths-ignore: ["**/*.md"] + +permissions: + contents: read + id-token: write + +jobs: + ci: + uses: otfabric/.github/.github/workflows/go-ci.yml@v3.6.0 + secrets: inherit diff --git a/testdata/rollout/ready/.github/workflows/docs.yml b/testdata/rollout/ready/.github/workflows/docs.yml new file mode 100644 index 0000000..e1db735 --- /dev/null +++ b/testdata/rollout/ready/.github/workflows/docs.yml @@ -0,0 +1,13 @@ +name: Docs +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + docs: + uses: otfabric/.github/.github/workflows/docs-check.yml@v3.6.0 + secrets: inherit diff --git a/testdata/rollout/ready/.github/workflows/interop.yml b/testdata/rollout/ready/.github/workflows/interop.yml new file mode 100644 index 0000000..2050004 --- /dev/null +++ b/testdata/rollout/ready/.github/workflows/interop.yml @@ -0,0 +1,16 @@ +name: Interop +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + packages: read + +jobs: + interop: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: make interop diff --git a/testdata/rollout/ready/.github/workflows/nightly.yml b/testdata/rollout/ready/.github/workflows/nightly.yml new file mode 100644 index 0000000..0643195 --- /dev/null +++ b/testdata/rollout/ready/.github/workflows/nightly.yml @@ -0,0 +1,14 @@ +name: Nightly +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzz: + runs-on: ubuntu-latest + steps: + - run: make fuzz diff --git a/testdata/rollout/ready/.otfabric/policy.yaml b/testdata/rollout/ready/.otfabric/policy.yaml new file mode 100644 index 0000000..d1ced5f --- /dev/null +++ b/testdata/rollout/ready/.otfabric/policy.yaml @@ -0,0 +1,3 @@ +policy: "1.5" +profile: protocol-library +mode: enforce diff --git a/testdata/rollout/ready/README.md b/testdata/rollout/ready/README.md new file mode 100644 index 0000000..4a22f0c --- /dev/null +++ b/testdata/rollout/ready/README.md @@ -0,0 +1 @@ +# ready diff --git a/testdata/rollout/ready/readme.go b/testdata/rollout/ready/readme.go new file mode 100644 index 0000000..78600de --- /dev/null +++ b/testdata/rollout/ready/readme.go @@ -0,0 +1,5 @@ +package ready + +import "os" + +func readme() ([]byte, error) { return os.ReadFile("README.md") }