ops-change
Build one change in this repo: implement it, prove it, and close the issue that asked for it.
Visibility: service. A framework loop may command it.
Invocation
ops-change <action> '<context-json>'
Two positional arguments. context is a single JSON object encoded as a string; an
absent context is {}, not an error. Reject any action not listed below — never
guess at one, and never silently succeed.
| Action |
What it must do |
implement |
Make the change the issue asks for on a work branch, and push it. Where the context carries a port source this is a port, not a replay: adapt the change to the target line, because a port that needs adapting is a real change. |
verify |
Run this repo's build, tests and sanity checks against the change, and report pass or fail with enough detail for the caller to act on a failure. |
close-issue |
Told that a PR has landed, work out which issue it was for and close that issue only once EVERY target line has landed — one logical change lands N times at N moments. The caller passes the PR, not the issue, because how a PR references its issue and which lines are targets are both repo facts. MUST close explicitly: a Closes #N keyword does not cross repos. MUST tolerate an already-closed issue, and MUST report closed: false with the lines still outstanding rather than closing early. |
Repo facts every action depends on
The product graph. Three products, in dependency order. This is the same graph
azure-pipelines.yml encodes as level 0 / 1 / 2, and it decides test scope:
| Level |
Product |
Path |
Depends on |
Test projects |
| 0 |
Umbraco.Automate |
Umbraco.Automate/ |
— |
Tests.Unit, Tests.Integration |
| 1 |
Umbraco.Automate.OpenIddict |
Umbraco.Automate.OpenIddict/ |
Core |
Tests.Unit, Tests.Integration |
| 2 |
Umbraco.Automate.Slack |
Umbraco.Automate.Slack/ |
OpenIddict, Core |
none |
Branch naming is hook-enforced. .githooks/pre-push rejects any branch that is not
vN/dev, vN/main, vN/{feature,release,hotfix}/* or claude/*. Only v*/feature/* is in
the Azure Pipelines push trigger, so a claude/* branch would push but never get CI — and the
issue loop needs CI green. Work branches are therefore always vN/feature/….
Commits are linted. .githooks/commit-msg runs commitlint. Every commit must be
<type>(<scope>): <Sentence case description> with a type and scope from CLAUDE.md.
Action: implement
Make the change the issue asks for on a work branch, and push it. Where the context carries a
port source this is a port, not a replay: adapt the change to the target line.
Context it receives (guidance — never validate it at runtime):
{"issue":{"repo":"umbraco/Umbraco.Automate","number":159,"title":"Config references inside larger strings are not resolved"},"line":"v18","port":null}
issue — object — the issue being worked, including the repo that holds it
line — string — the line to implement on, e.g. v18
port — object|null — the source line and commit when this is a port of an already-landed change
Facts to return:
branch — string — the branch the work was pushed to
summary — string — what was changed, for the PR body
Steps
Work out the branch name first, because it is also the idempotency key:
<line>/feature/issue-<issue.number>-<slug>, where <slug> is a short kebab-case phrase from
the issue title. Example: v18/feature/issue-159-embedded-config-references.
This action names and creates its own branch rather than calling
ops-branching · start-branch. Reason: the engine default names branches
<type>/<slug>, which .githooks/pre-push rejects outright, and the retired
branching.branch_naming key no longer exists to tell it otherwise. Base resolution
still belongs to ops-branching — see step 3.
Idempotency check. Ask github-ops whether that branch already exists on the remote.
If it does, do not implement again: return the existing branch with a summary read from its
commits, and stop.
Get the workspace. Call ops-workspace · prepare with the branch. Let it root the branch
on the line's integration branch — do not resolve v18/dev here by hand. Everything below
runs inside that workspace.
Read before writing. Read the issue in full, then CLAUDE.md at the repo root and the
CLAUDE.md of the product you are about to touch. Follow the conventions already in the
surrounding files.
Make the change.
- A normal change: implement what the issue asks for, and add or update tests in the
affected product's
Tests.Unit (or Tests.Integration where the behaviour needs a real
database or Umbraco host).
- A port (
port is non-null): start from port.commit, then adapt it to the target
line. Cherry-picking blind is not a port. APIs, namespaces and CMS versions differ
between lines; a conflict is a signal to think, not to force. If the change genuinely
cannot be adapted, stop and return {"ok": false, "detail": "…"} naming what diverged.
- Never upgrade a dependency version that the issue did not ask you to upgrade.
- Never add a secret, key or connection string.
Commit in conventional-commit form, sentence-case, valid scope. Small, coherent commits.
Do not use --no-verify; if commitlint rejects the message, fix the message.
Push the branch.
Return {"ok": true, "branch": "…", "summary": "…"}. Write the summary as PR-body prose: what
changed, why, and how a reviewer can test it — the PR template asks for all three.
Leave the port decision open, and say so. End the summary with the template's Other
version lines section, both boxes unticked, naming the other live lines and asking the
reviewer to pick one. Never tick a box on the reviewer's behalf. That section is what
close-issue reads later, so an unanswered question there is what correctly holds the issue
open instead of closing it early.
Idempotency (a MUST). The branch name is derived purely from issue.number and line, so
the same context always produces the same name. Step 2 detects an existing remote branch and
returns it untouched instead of implementing a second time.
Action: verify
Run this repo's build, tests and sanity checks against the change, and report pass or fail with
enough detail for the caller to act on a failure.
Context it receives (guidance — never validate it at runtime):
{"branch":"v18/feature/issue-159-embedded-config-references"}
branch — string — the branch to verify
scope — string — optional narrowing, e.g. one test project
Facts to return:
ok — boolean
failures — array — one entry per failing check, with its output
Steps
Prepare the workspace for the branch (ops-workspace · prepare) and run everything
inside it. Never verify against a dirty local tree.
Build everything:
dotnet build Umbraco.Automate.slnx
The root solution. A change in one product routinely breaks a downstream one at compile time,
and this is the cheapest place to catch it.
Work out the test scope: what changed, plus its dependents. Diff the branch against the
line's integration branch, map each changed path to a product using the table above, then walk
downstream:
| Changed product |
Test these |
Umbraco.Automate (Core) |
Core, OpenIddict (Slack has no tests — the build in step 2 is its check) |
Umbraco.Automate.OpenIddict |
OpenIddict |
Umbraco.Automate.Slack |
nothing to run; step 2 is its check |
repo root / Directory.Packages.props / scripts/ |
all of them |
Never test upstream. A Slack change cannot break Core.
Run the tests for each product in scope:
dotnet test <Product>/<Product>.slnx
If scope was passed, honour it and narrow to that project — but say in the result that the
run was narrowed, so the caller does not read a partial pass as a full one.
Frontend, only if frontend files changed (anything under a Client/ directory). Install
at the repo root, never inside Client/ — this is an npm workspaces monorepo and
installing in the workspace directory produces a spurious root lockfile diff:
npm ci # repo root
npm run build # repo root
Formatting, only if you touched files Prettier owns: npm run format:check at the root.
Return {"ok": true, "failures": []}, or {"ok": false, "failures": [...]} with, per failure,
the command that ran, the project, and the trimmed output — enough for the caller to fix it
without re-running anything.
Idempotency (a MUST). verify is pure: it builds, tests and reports, and writes nothing to
GitHub, no branch, no tag, no comment. Running it twice costs time and changes nothing, so no
already-ran detection is needed. Keep it that way — if this action ever needs a side effect, it
belongs in a different action.
Action: close-issue
Told that a PR has landed, work out which issue it was for and close that issue only once
every target line has landed.
Context it receives (guidance — never validate it at runtime):
{"landed":{"repo":"umbraco/Umbraco.Automate","pr_number":174,"line":"v18"}}
landed — object — the PR that just landed: repo, pr_number, and its line
issue — object — optional, when the caller already knows which issue it was
Facts to return:
closed — boolean
issue — object — the issue this resolved to, so the caller can report it
waiting_on — array — target lines not yet landed; empty when closed is true
Steps
Resolve the issue. Use context.issue if the caller supplied it. Otherwise read the PR
via github-ops and take the number from its head branch: vN/feature/issue-<N>-<slug>.
The branch is the reliable source here — merged PRs in this repo routinely have an empty body.
Fall back to a #N reference in the PR body or title only if the branch carries no number. If
nothing resolves, return {"ok": false, "detail": "cannot resolve an issue for PR #…"} and
close nothing.
Work out the target lines from the human's confirmed port decision — never from
lines.live alone. Read the decision, in this order, and stop at the first one that answers:
| Where |
What it means |
The landed PR's Other version lines section, only applies to the version line I am targeting ticked |
targets = the landed line only |
The same section, should be ported ticked, with the lines or a linked PR named |
targets = the landed line plus those lines |
| A maintainer comment on the issue or PR saying which lines to port to (or that none apply) |
targets = what it says |
A line-scoped label on the issue (e.g. v17) |
targets = the landed line plus that line |
If none of those answers it, the decision has not been made. Do not assume, in either
direction. Ask for it (step 3) and close nothing.
If the decision is missing, ask a human via ops-notify · send, keyed on the issue number
so the same question is never sent twice. Say which line landed and which other live lines
exist. Then return {"ok": true, "closed": false, "issue": {...}, "waiting_on": ["<undecided lines>"], "detail": "awaiting a port decision"}.
Check each target line has landed. For each one, search for a merged PR whose head branch
is <line>/feature/issue-<N>-…. The lines with no merged PR go in waiting_on.
If waiting_on is non-empty, return {"ok": true, "closed": false, "issue": {...}, "waiting_on": [...]}. Close nothing. Half a change is not a fixed issue.
If every target line has landed, close the issue explicitly via github-ops with a
comment listing each line and its PR number. Never rely on a Closes #N keyword.
Return {"ok": true, "closed": true, "issue": {...}, "waiting_on": []}.
Idempotency (a MUST). Read the issue's state before closing. An already-closed issue is a
success, not an error: return closed: true and add no second comment. Check for an existing
close-comment from this action before writing one.
Rules
Reject an unknown action. Report it; never guess, never silently succeed.
Every action is idempotent. A loop sweeps on a cadence and will hand you the same
work twice.
A failed action leaves a safe state — no partial publish, no dangling branch it
created and cannot resume.
Make success and failure unambiguous. End with a single JSON object:
{"ok": true, ...facts...} or {"ok": false, "detail": "..."}.
All GitHub work goes through github-ops by operation name — never a raw gh or
curl here.
Never port without a human's confirmation, and never assume a change is single-line either.
CLAUDE.md is the rule: before a change counts as done, ask whether it applies to the other
active lines and confirm with a human. Silence is not an answer — an unanswered port question
holds the issue open rather than closing it or opening a speculative PR.
When a port is confirmed, it needs its own PR per line, branched from that line's own
integration branch — version lines are never forward-merged. Title it the way the repo already
does: … (v17 backport) or … (v18 forward-port of #107). Respect each line's phase: a line
in security phase takes security fixes only, and an EOL line is skipped, whatever the
confirmation said.
A port arriving as implement with a non-null port is the confirmation — the caller has
already been told yes. Do not ask again.
Never --no-verify. The commit-msg and pre-push hooks encode rules the loops depend on.
Never force-push a branch another PR is built on.
Never commit a secret. If you find one already in the code, stop and report it.
Never upgrade a dependency that the issue did not ask you to upgrade.
Follow the repo's conventions rather than importing new ones — read the nearest
CLAUDE.md before writing code.
1---2name: ops-change3description: Build one change in Umbraco.Automate: implement it on a `vN/feature/*` branch, prove it with `dotnet build` + the affected product's tests and its dependents' tests, and close the issue once it has landed on every line a human confirmed as a target. Ports are never opened without that confirmation. Called by name with (action, context-json). Not model-invoked.4---56# ops-change78Build one change in this repo: implement it, prove it, and close the issue that asked for it.910**Visibility: service.** A framework loop may command it.1112## Invocation1314```15ops-change <action> '<context-json>'16```1718Two positional arguments. `context` is a single JSON object encoded as a string; an19**absent context is `{}`**, not an error. **Reject any action not listed below** — never20guess at one, and never silently succeed.2122| Action | What it must do |23|---|---|24| `implement` | Make the change the issue asks for on a work branch, and push it. Where the context carries a port source this is a port, not a replay: adapt the change to the target line, because a port that needs adapting is a real change. |25| `verify` | Run this repo's build, tests and sanity checks against the change, and report pass or fail with enough detail for the caller to act on a failure. |26| `close-issue` | Told that a PR has landed, work out which issue it was for and close that issue only once EVERY target line has landed — one logical change lands N times at N moments. The caller passes the PR, not the issue, because how a PR references its issue and which lines are targets are both repo facts. MUST close explicitly: a `Closes #N` keyword does not cross repos. MUST tolerate an already-closed issue, and MUST report `closed: false` with the lines still outstanding rather than closing early. |2728## Repo facts every action depends on2930**The product graph.** Three products, in dependency order. This is the same graph31`azure-pipelines.yml` encodes as level 0 / 1 / 2, and it decides test scope:3233| Level | Product | Path | Depends on | Test projects |34|---|---|---|---|---|35| 0 | `Umbraco.Automate` | `Umbraco.Automate/` | — | `Tests.Unit`, `Tests.Integration` |36| 1 | `Umbraco.Automate.OpenIddict` | `Umbraco.Automate.OpenIddict/` | Core | `Tests.Unit`, `Tests.Integration` |37| 2 | `Umbraco.Automate.Slack` | `Umbraco.Automate.Slack/` | OpenIddict, Core | **none** |3839**Branch naming is hook-enforced.** `.githooks/pre-push` rejects any branch that is not40`vN/dev`, `vN/main`, `vN/{feature,release,hotfix}/*` or `claude/*`. Only `v*/feature/*` is in41the Azure Pipelines push trigger, so a `claude/*` branch would push but never get CI — and the42issue loop needs CI green. Work branches are therefore **always** `vN/feature/…`.4344**Commits are linted.** `.githooks/commit-msg` runs commitlint. Every commit must be45`<type>(<scope>): <Sentence case description>` with a type and scope from `CLAUDE.md`.4647## Action: `implement`4849Make the change the issue asks for on a work branch, and push it. Where the context carries a50port source this is a port, not a replay: adapt the change to the target line.5152**Context it receives** (guidance — never validate it at runtime):5354```json55{"issue":{"repo":"umbraco/Umbraco.Automate","number":159,"title":"Config references inside larger strings are not resolved"},"line":"v18","port":null}56```5758- `issue` — object — the issue being worked, including the repo that holds it59- `line` — string — the line to implement on, e.g. `v18`60- `port` — object|null — the source line and commit when this is a port of an already-landed change6162**Facts to return:**6364- `branch` — string — the branch the work was pushed to65- `summary` — string — what was changed, for the PR body6667### Steps68691. **Work out the branch name first**, because it is also the idempotency key:70 `<line>/feature/issue-<issue.number>-<slug>`, where `<slug>` is a short kebab-case phrase from71 the issue title. Example: `v18/feature/issue-159-embedded-config-references`.7273 > This action names and creates its own branch rather than calling74 > `ops-branching · start-branch`. **Reason:** the engine default names branches75 > `<type>/<slug>`, which `.githooks/pre-push` rejects outright, and the retired76 > `branching.branch_naming` key no longer exists to tell it otherwise. Base *resolution*77 > still belongs to `ops-branching` — see step 3.78792. **Idempotency check.** Ask `github-ops` whether that branch already exists on the remote.80 If it does, do not implement again: return the existing branch with a summary read from its81 commits, and stop.82833. **Get the workspace.** Call `ops-workspace · prepare` with the branch. Let it root the branch84 on the line's integration branch — do not resolve `v18/dev` here by hand. Everything below85 runs inside that workspace.86874. **Read before writing.** Read the issue in full, then `CLAUDE.md` at the repo root and the88 `CLAUDE.md` of the product you are about to touch. Follow the conventions already in the89 surrounding files.90915. **Make the change.**92 - **A normal change:** implement what the issue asks for, and add or update tests in the93 affected product's `Tests.Unit` (or `Tests.Integration` where the behaviour needs a real94 database or Umbraco host).95 - **A port** (`port` is non-null): start from `port.commit`, then **adapt it to the target96 line**. Cherry-picking blind is not a port. APIs, namespaces and CMS versions differ97 between lines; a conflict is a signal to think, not to force. If the change genuinely98 cannot be adapted, stop and return `{"ok": false, "detail": "…"}` naming what diverged.99 - **Never** upgrade a dependency version that the issue did not ask you to upgrade.100 - **Never** add a secret, key or connection string.1011026. **Commit** in conventional-commit form, sentence-case, valid scope. Small, coherent commits.103 Do not use `--no-verify`; if commitlint rejects the message, fix the message.1041057. **Push** the branch.1061078. Return `{"ok": true, "branch": "…", "summary": "…"}`. Write the summary as PR-body prose: what108 changed, why, and how a reviewer can test it — the PR template asks for all three.109110 **Leave the port decision open, and say so.** End the summary with the template's *Other111 version lines* section, both boxes **unticked**, naming the other live lines and asking the112 reviewer to pick one. Never tick a box on the reviewer's behalf. That section is what113 `close-issue` reads later, so an unanswered question there is what correctly holds the issue114 open instead of closing it early.115116**Idempotency (a MUST).** The branch name is derived purely from `issue.number` and `line`, so117the same context always produces the same name. Step 2 detects an existing remote branch and118returns it untouched instead of implementing a second time.119120## Action: `verify`121122Run this repo's build, tests and sanity checks against the change, and report pass or fail with123enough detail for the caller to act on a failure.124125**Context it receives** (guidance — never validate it at runtime):126127```json128{"branch":"v18/feature/issue-159-embedded-config-references"}129```130131- `branch` — string — the branch to verify132- `scope` — string — optional narrowing, e.g. one test project133134**Facts to return:**135136- `ok` — boolean137- `failures` — array — one entry per failing check, with its output138139### Steps1401411. **Prepare the workspace** for the branch (`ops-workspace · prepare`) and run everything142 inside it. Never verify against a dirty local tree.1431442. **Build everything:**145146 ```bash147 dotnet build Umbraco.Automate.slnx148 ```149150 The root solution. A change in one product routinely breaks a downstream one at compile time,151 and this is the cheapest place to catch it.1521533. **Work out the test scope: what changed, plus its dependents.** Diff the branch against the154 line's integration branch, map each changed path to a product using the table above, then walk155 *downstream*:156157 | Changed product | Test these |158 |---|---|159 | `Umbraco.Automate` (Core) | Core, OpenIddict *(Slack has no tests — the build in step 2 is its check)* |160 | `Umbraco.Automate.OpenIddict` | OpenIddict |161 | `Umbraco.Automate.Slack` | nothing to run; step 2 is its check |162 | repo root / `Directory.Packages.props` / `scripts/` | all of them |163164 Never test *upstream*. A Slack change cannot break Core.1651664. **Run the tests** for each product in scope:167168 ```bash169 dotnet test <Product>/<Product>.slnx170 ```171172 If `scope` was passed, honour it and narrow to that project — but say in the result that the173 run was narrowed, so the caller does not read a partial pass as a full one.1741755. **Frontend, only if frontend files changed** (anything under a `Client/` directory). Install176 at the **repo root**, never inside `Client/` — this is an npm workspaces monorepo and177 installing in the workspace directory produces a spurious root lockfile diff:178179 ```bash180 npm ci # repo root181 npm run build # repo root182 ```1831846. **Formatting**, only if you touched files Prettier owns: `npm run format:check` at the root.1851867. Return `{"ok": true, "failures": []}`, or `{"ok": false, "failures": [...]}` with, per failure,187 the command that ran, the project, and the trimmed output — enough for the caller to fix it188 without re-running anything.189190**Idempotency (a MUST).** `verify` is pure: it builds, tests and reports, and writes nothing to191GitHub, no branch, no tag, no comment. Running it twice costs time and changes nothing, so no192already-ran detection is needed. Keep it that way — if this action ever needs a side effect, it193belongs in a different action.194195## Action: `close-issue`196197Told that a PR has landed, work out which issue it was for and close that issue only once198**every** target line has landed.199200**Context it receives** (guidance — never validate it at runtime):201202```json203{"landed":{"repo":"umbraco/Umbraco.Automate","pr_number":174,"line":"v18"}}204```205206- `landed` — object — the PR that just landed: repo, pr_number, and its line207- `issue` — object — optional, when the caller already knows which issue it was208209**Facts to return:**210211- `closed` — boolean212- `issue` — object — the issue this resolved to, so the caller can report it213- `waiting_on` — array — target lines not yet landed; empty when `closed` is true214215### Steps2162171. **Resolve the issue.** Use `context.issue` if the caller supplied it. Otherwise read the PR218 via `github-ops` and take the number from its **head branch**: `vN/feature/issue-<N>-<slug>`.219 The branch is the reliable source here — merged PRs in this repo routinely have an empty body.220 Fall back to a `#N` reference in the PR body or title only if the branch carries no number. If221 nothing resolves, return `{"ok": false, "detail": "cannot resolve an issue for PR #…"}` and222 close nothing.2232242. **Work out the target lines from the human's confirmed port decision** — never from225 `lines.live` alone. Read the decision, in this order, and stop at the first one that answers:226227 | Where | What it means |228 |---|---|229 | The landed PR's **Other version lines** section, `only applies to the version line I am targeting` ticked | targets = the landed line only |230 | The same section, `should be ported` ticked, with the lines or a linked PR named | targets = the landed line plus those lines |231 | A maintainer comment on the issue or PR saying which lines to port to (or that none apply) | targets = what it says |232 | A line-scoped label on the issue (e.g. `v17`) | targets = the landed line plus that line |233234 **If none of those answers it, the decision has not been made.** Do not assume, in either235 direction. Ask for it (step 3) and close nothing.2362373. **If the decision is missing**, ask a human via `ops-notify · send`, keyed on the issue number238 so the same question is never sent twice. Say which line landed and which other live lines239 exist. Then return `{"ok": true, "closed": false, "issue": {...}, "waiting_on": ["<undecided240 lines>"], "detail": "awaiting a port decision"}`.2412424. **Check each target line has landed.** For each one, search for a merged PR whose head branch243 is `<line>/feature/issue-<N>-…`. The lines with no merged PR go in `waiting_on`.2442455. **If `waiting_on` is non-empty**, return `{"ok": true, "closed": false, "issue": {...},246 "waiting_on": [...]}`. Close nothing. Half a change is not a fixed issue.2472486. **If every target line has landed**, close the issue **explicitly** via `github-ops` with a249 comment listing each line and its PR number. Never rely on a `Closes #N` keyword.2502517. Return `{"ok": true, "closed": true, "issue": {...}, "waiting_on": []}`.252253**Idempotency (a MUST).** Read the issue's state before closing. An already-closed issue is a254success, not an error: return `closed: true` and add no second comment. Check for an existing255close-comment from this action before writing one.256257## Rules258259- **Reject an unknown action.** Report it; never guess, never silently succeed.260- **Every action is idempotent.** A loop sweeps on a cadence and will hand you the same261 work twice.262- **A failed action leaves a safe state** — no partial publish, no dangling branch it263 created and cannot resume.264- **Make success and failure unambiguous.** End with a single JSON object:265 `{"ok": true, ...facts...}` or `{"ok": false, "detail": "..."}`.266- **All GitHub work goes through `github-ops`** by operation name — never a raw `gh` or267 `curl` here.268- **Never port without a human's confirmation, and never assume a change is single-line either.**269 `CLAUDE.md` is the rule: before a change counts as done, ask whether it applies to the other270 active lines and confirm with a human. Silence is not an answer — an unanswered port question271 holds the issue open rather than closing it or opening a speculative PR.272273 When a port **is** confirmed, it needs its own PR per line, branched from that line's own274 integration branch — version lines are never forward-merged. Title it the way the repo already275 does: `… (v17 backport)` or `… (v18 forward-port of #107)`. Respect each line's phase: a line276 in **security** phase takes security fixes only, and an **EOL** line is skipped, whatever the277 confirmation said.278279 A port arriving as `implement` with a non-null `port` **is** the confirmation — the caller has280 already been told yes. Do not ask again.281282- **Never `--no-verify`.** The commit-msg and pre-push hooks encode rules the loops depend on.283- **Never force-push** a branch another PR is built on.284- **Never commit a secret.** If you find one already in the code, stop and report it.285- **Never upgrade a dependency** that the issue did not ask you to upgrade.286- **Follow the repo's conventions rather than importing new ones** — read the nearest287 `CLAUDE.md` before writing code.