| name | ops-change |
| description | 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. |
| disable-model-invocation | true |
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 ; if commitlint rejects the message, fix the message.
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
npm run build
-
Formatting, only if you touched files Prettier owns: npm run format:check at the root.
-
Return {"ok": true, "failures": []}, or 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.
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.