Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <commit> # 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
Expand Down
3 changes: 3 additions & 0 deletions src/km/templates/team/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
48 changes: 47 additions & 1 deletion src/km/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
30 changes: 30 additions & 0 deletions tests/team_smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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", []))
Expand Down
Loading