diff --git a/README.md b/README.md index 38a49d3..fe17c0f 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ generated indexes, a README, and CI that installs km through this repository's G - run: km gen-index --check ``` +The team brain's ci workflow also runs on `workflow_dispatch`: a pull request that a workflow opens with `GITHUB_TOKEN` starts no `pull_request` run, so such a workflow (a pin bump, say) dispatches ci on its branch and the pull request gets its check. `km upgrade` adds the trigger to a km ci workflow that lacks it. + The pre-commit hook works the same way (`repo: https://github.com/pasrom/km`, `rev: # frozen: v1.9.0`, `id: km-validate`). Every pin names a release by its commit, so a tag moved later cannot change what a brain runs; the version rides along as a comment, and diff --git a/src/km/templates/team/ci.yml b/src/km/templates/team/ci.yml index 4236bdb..f9688f9 100644 --- a/src/km/templates/team/ci.yml +++ b/src/km/templates/team/ci.yml @@ -6,6 +6,9 @@ on: push: branches: [main] pull_request: + # A pull request a workflow opens with GITHUB_TOKEN starts no pull_request run; such a workflow + # dispatches this one on its branch instead, so the pull request gets its check. + workflow_dispatch: concurrency: group: ci-${{ github.ref }} diff --git a/src/km/upgrade.py b/src/km/upgrade.py index 831ac3b..79d3ded 100644 --- a/src/km/upgrade.py +++ b/src/km/upgrade.py @@ -16,7 +16,8 @@ - a legacy schema.yaml becomes schema.local.yaml, the overlay it already acts as; - a team brain gets the Dependabot config, and the obsolete `team_brain:` line goes. - A team brain gets the main-guard workflow and the pre-commit hooks that refuse a commit on main - and a push to main, whichever it lacks. + and a push to main, whichever it lacks, and its km ci workflow gets the `workflow_dispatch` + trigger, so a workflow that opens a pull request with GITHUB_TOKEN can run the check on it. - A brain without a .gitattributes gets km's, so text files are LF on every platform; one whose own file sets no line ending gets a note. @@ -30,6 +31,9 @@ import re import shutil import sys +from pathlib import Path + +import yaml from km import KM_REF from km.init import ensure_lf, pin_values, template, with_team_hooks @@ -63,6 +67,40 @@ def fingerprint(text: str) -> str: return hashlib.sha256(content.encode()).hexdigest()[:16] +def _parse(text: str): + try: + return yaml.safe_load(text) + except yaml.YAMLError: + return None + + +def with_dispatch(text: str) -> str | None: + """The workflow with `workflow_dispatch:` added at the end of its `on:` block (unchanged when it + has the trigger), or None when the edit cannot be made safely (a one-line `on:`, or a layout this + edit does not handle): the result must parse to the same workflow with exactly that trigger added.""" + before = _parse(text) + on = before.get(True, before.get("on")) if isinstance(before, dict) else None # YAML 1.1 reads a bare `on` as true + if isinstance(on, (dict, list, str)) and "workflow_dispatch" in on: + return text + lines = text.splitlines(keepends=True) + if lines and not lines[-1].endswith("\n"): + lines[-1] += "\n" + top = next((i for i, ln in enumerate(lines) if re.match(r"(on|['\"]on['\"]):\s*(#.*)?$", ln)), None) + if top is None or not isinstance(on, dict): + return None + end, pad = top + 1, None # the `on:` block ends before the next top-level line + for i in range(top + 1, len(lines)): + ln = lines[i] + if ln.strip() and not ln[0].isspace(): + break + if ln.strip() and not ln.lstrip().startswith("#"): + pad = pad or ln[:len(ln) - len(ln.lstrip())] + end = i + 1 + out = "".join(lines[:end]) + f"{pad}workflow_dispatch:\n" + "".join(lines[end:]) + key = True if True in before else "on" + return out if pad and _parse(out) == {**before, key: {**on, "workflow_dispatch": None}} else None + + def main() -> int: ap = argparse.ArgumentParser(prog="km upgrade", description="Move a brain to this km version.") ap.add_argument("--force", action="store_true", help="also move a pin to a newer km back to this one") @@ -140,6 +178,14 @@ def main() -> int: p.parent.mkdir(parents=True, exist_ok=True) write_text(p, template(tpl, {})) done.append(f"added {rel}") + ci = str(Path(".github/workflows/ci.yml")) # as read_pins names it, with the OS's separator + if ci in pins and (got := with_dispatch(text := (root / ci).read_text(encoding="utf-8"))) != text: # km's ci workflow + if got is None: + done.append(f"could not add the workflow_dispatch trigger to {ci}: add `workflow_dispatch:` under its `on:` " + f"by hand, so a pull request a workflow opens can run the check") + else: + write_text(root / ci, got) + done.append(f"added the workflow_dispatch trigger to {ci}") hook = root / ".pre-commit-config.yaml" if not hook.is_file(): done.append(f"no {hook.name}: nothing refuses a commit on main or a push to main locally; `km init` writes one") diff --git a/tests/team_smoke.sh b/tests/team_smoke.sh index 2a8233b..f22dc8c 100644 --- a/tests/team_smoke.sh +++ b/tests/team_smoke.sh @@ -428,6 +428,36 @@ readme = {v for ln in lines for v in re.findall(r"\bv\d+\.\d+\.\d+\b", ln)} sys.exit(0 if "v" + json.load(open(f"{repo}/.claude-plugin/plugin.json"))["version"] == ref and readme == {ref} else 1) PY +# --- ci.yml takes workflow_dispatch, so a workflow that opens a pull request can run the check on it --- +on_keys(){ kmpy -c 'import sys, yaml; d = yaml.safe_load(open(sys.argv[1])); print(" ".join(d.get(True, d.get("on"))))' "$1"; } +sans_dispatch(){ kmpy -c 'import sys, yaml; d = yaml.safe_load(open(sys.argv[1])); d[True].pop("workflow_dispatch", None); print(d)' "$1"; } +[ "$(on_keys "$T/.github/workflows/ci.yml")" = "push pull_request workflow_dispatch" ] && ok "init writes ci.yml with workflow_dispatch" || no "init ci.yml triggers ($(on_keys "$T/.github/workflows/ci.yml"))" +[ "$(on_keys "$O/.github/workflows/ci.yml")" = "push pull_request workflow_dispatch" ] && ok "a copied-in ci.yml is rewritten with workflow_dispatch" || no "old ci.yml triggers" +D="$F/dispatch"; mkdir -p "$D/.github/workflows"; printf 'meta: {profile: d}\n' > "$D/schema.local.yaml" +grep -v "workflow_dispatch\|GITHUB_TOKEN starts no\|dispatches this one" "$T/.github/workflows/ci.yml" > "$D/.github/workflows/ci.yml" +printf ' extra:\n steps: [{run: make}]\n' >> "$D/.github/workflows/ci.yml" # the brain's own job stays +before="$(sans_dispatch "$D/.github/workflows/ci.yml")" +out="$(km upgrade --root "$D" 2>&1)" +{ [ "$(on_keys "$D/.github/workflows/ci.yml")" = "push pull_request workflow_dispatch" ] && out_has "added the workflow_dispatch trigger" \ + && [ "$(sans_dispatch "$D/.github/workflows/ci.yml")" = "$before" ]; } \ + && ok "upgrade adds workflow_dispatch to a km ci.yml without it, and changes nothing else" || no "upgrade adds workflow_dispatch ($out; $(cat "$D/.github/workflows/ci.yml"))" +cp "$D/.github/workflows/ci.yml" "$F/dispatch.before"; out="$(km upgrade --root "$D" 2>&1)" +{ cmp -s "$D/.github/workflows/ci.yml" "$F/dispatch.before" && ! out_has "workflow_dispatch"; } && ok "a second upgrade leaves the trigger as it is" || no "dispatch idempotent ($out)" +printf 'on:\n pull_request:\n# workflow_dispatch: not yet\njobs:\n v:\n steps:\n - uses: %s\n' "$PINW" > "$D/.github/workflows/ci.yml" +out="$(km upgrade --root "$D" 2>&1)" +[ "$(on_keys "$D/.github/workflows/ci.yml")" = "pull_request workflow_dispatch" ] && ok "a comment that names the trigger does not count as the trigger" || no "dispatch behind a comment ($out)" +printf 'jobs:\n v:\n steps:\n - uses: %s\non:\n pull_request:' "$PINW" > "$D/.github/workflows/ci.yml" # on: last, no newline at the end +out="$(km upgrade --root "$D" 2>&1)" +[ "$(on_keys "$D/.github/workflows/ci.yml")" = "pull_request workflow_dispatch" ] && ok "an on: block at the end of a file without a final newline gets the trigger" || no "dispatch at the end ($out)" +printf 'on: [push, pull_request]\njobs:\n v:\n steps:\n - uses: %s\n' "$PINW" > "$D/.github/workflows/ci.yml" +cp "$D/.github/workflows/ci.yml" "$F/dispatch.before"; out="$(km upgrade --root "$D" 2>&1)" +{ cmp -s "$D/.github/workflows/ci.yml" "$F/dispatch.before" && out_has "add .workflow_dispatch:. under its .on:. by hand"; } \ + && ok "a one-line on: is left for a hand edit, with a note" || no "one-line on ($out)" +cp "$T/.github/workflows/staleness.yml" "$D/.github/workflows/" # still a team brain without km in ci.yml +printf 'on:\n pull_request:\njobs:\n v:\n steps:\n - run: make\n' > "$D/.github/workflows/ci.yml" +out="$(km upgrade --root "$D" 2>&1)" +[ "$(on_keys "$D/.github/workflows/ci.yml")" = "pull_request" ] && ok "a ci.yml that does not install km gets no trigger" || no "own ci.yml gets no trigger ($out)" + # --- main guard: the hook that refuses a commit on main, and the workflow that flags a direct push --- hooks_of(){ kmpy -c 'import sys, yaml print(" ".join(r["repo"].rsplit("/", 1)[-1] + ":" + h["id"] + ":" + ",".join(h.get("args", []))