From 400d85a784b9fc22c1adafe18e8c8c590fab3a8a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:35:22 -0500 Subject: [PATCH 01/35] feat(harness-ops): add a --ci mode to changelog apply apply --ci decides every row on evidence with nobody to answer: only a cited, verified correct row confined to one owner plugin and not security-relevant is committed on a local branch per owner plugin, and every other row becomes a handoff with a dedupe key. It asks nothing, pushes nothing, opens no pull request, files nothing and never moves the read marker. It writes ci-plan.json for the release-intake workflow's push and publish jobs. Two eval cases cover the capped-range stop and an adopt row handed off. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .../feat-changelog-apply-ci-89c5a295.md | 8 ++ .../evals/cl-ci-adopt-is-handoff/case.yaml | 32 +++++ .../evals/cl-ci-adopt-is-handoff/prompt.md | 12 ++ .../samples/no-ask.json | 43 ++++++ .../samples/no-branch-or-push.json | 58 +++++++++ .../samples/reports-plan-path.json | 21 +++ .../samples/reports-planned.json | 21 +++ .../evals/cl-ci-adopt-is-handoff/scaffold.sh | 25 ++++ .../evals/cl-ci-cap-exceeded-stops/case.yaml | 43 ++++++ .../evals/cl-ci-cap-exceeded-stops/prompt.md | 12 ++ .../samples/no-ask.json | 43 ++++++ .../samples/no-branch-or-push.json | 58 +++++++++ .../samples/no-decision-rows.json | 35 +++++ .../samples/ran-status-with-range.json | 58 +++++++++ .../samples/reports-plan-path.json | 21 +++ .../samples/reports-stopped.json | 21 +++ .../cl-ci-cap-exceeded-stops/scaffold.sh | 12 ++ plugins/harness-ops/skills/changelog/SKILL.md | 16 ++- .../skills/changelog/context/ci-mode.md | 122 ++++++++++++++++++ 19 files changed, 659 insertions(+), 2 deletions(-) create mode 100644 .changes/harness-ops/feat-changelog-apply-ci-89c5a295.md create mode 100644 plugins/harness-ops/evals/cl-ci-adopt-is-handoff/case.yaml create mode 100644 plugins/harness-ops/evals/cl-ci-adopt-is-handoff/prompt.md create mode 100644 plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/no-ask.json create mode 100644 plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/no-branch-or-push.json create mode 100644 plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/reports-plan-path.json create mode 100644 plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/reports-planned.json create mode 100755 plugins/harness-ops/evals/cl-ci-adopt-is-handoff/scaffold.sh create mode 100644 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/case.yaml create mode 100644 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/prompt.md create mode 100644 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-ask.json create mode 100644 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-branch-or-push.json create mode 100644 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-decision-rows.json create mode 100644 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/ran-status-with-range.json create mode 100644 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/reports-plan-path.json create mode 100644 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/reports-stopped.json create mode 100755 plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/scaffold.sh create mode 100644 plugins/harness-ops/skills/changelog/context/ci-mode.md diff --git a/.changes/harness-ops/feat-changelog-apply-ci-89c5a295.md b/.changes/harness-ops/feat-changelog-apply-ci-89c5a295.md new file mode 100644 index 00000000000..481562d8994 --- /dev/null +++ b/.changes/harness-ops/feat-changelog-apply-ci-89c5a295.md @@ -0,0 +1,8 @@ +--- +bump: minor +--- + +### Added + +- `/harness-ops:changelog apply --ci ` runs `apply` with nobody to answer. Only a cited, verified `correct` row confined to one owner plugin and not security-relevant is committed, on a local `changelog//v` branch per owner plugin; every other row becomes a handoff. It asks nothing, pushes nothing, opens no pull request, files nothing and never moves the read marker, and writes `ci-plan.json` (branches with base and head commits and PR title and body, handoffs with dedupe keys) for the calling workflow. The rule and the plan format are in the skill's `context/ci-mode.md`. +- Two eval cases: `cl-ci-cap-exceeded-stops` and `cl-ci-adopt-is-handoff`. diff --git a/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/case.yaml b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/case.yaml new file mode 100644 index 00000000000..10033c3d62e --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/case.yaml @@ -0,0 +1,32 @@ +schema_version: "1.1" +name: cl-ci-adopt-is-handoff +context: + scaffold_script: scaffold.sh +graders: + - name: reports-plan-path + type: regex + pattern: '^[ \t>*-]*`?ci-plan:`?[ \t]*`?[^\s`]*ci-plan\.json' + flags: 'm' + target: last_message + - name: reports-planned + type: regex + pattern: '^[ \t>*-]*`?status:`?[ \t]*`?planned\b' + flags: 'mi' + target: last_message + - name: no-ask + type: tool_used + tool: 'AskUserQuestion' + min: 0 + max: 0 + arm: 'both' + - name: no-branch-or-push + type: tool_used + tool: 'Bash' + input_match: '"command":"(?:[^"\\]|\\.)*?\bgit\s+(?:-C\s+\S+\s+)?(?:switch\s+-c|checkout\s+-b|commit|push)\b' + min: 0 + max: 0 + arm: 'both' + - name: adopt-row-is-handoff + type: llm + focus: trace + criteria: 'PASS only if: the ci-plan.json the run wrote has status planned, an empty branches list, and a handoff of kind adopt on plugins/mcp-kit/scripts/reconnect-servers.sh naming item 2.1.284-001 and the problem the script solves (reconnecting failed MCP servers one at a time); reconnect-servers.sh is unchanged; and the run neither moved the read marker nor wrote a ledger file. FAIL otherwise.' diff --git a/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/prompt.md b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/prompt.md new file mode 100644 index 00000000000..ee4f660516d --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/prompt.md @@ -0,0 +1,12 @@ +--- +description: "apply --ci on a release whose only row is adopt: adopting is a decision, so the run asks nothing, creates no branch and leaves the script untouched, writes the row as an adopt handoff in ci-plan.json, and ends with the ci-plan and status planned lines. Reaches SKILL.md 'Unattended: apply --ci' and context/ci-mode.md 'Which rows get a branch' and 'The plan'. Needs --scaffold --allow-tools Bash Write Edit (AskUserQuestion granted for the must-not-ask grader)." +tags: [harness-ops, changelog, outcome, ci] +max_turns: 24 +timeout_seconds: 600 +allowed_tools: [Skill, Read, Glob, Grep, Bash, Write, Edit, AskUserQuestion] +expected_outcome: "The skill runs the status script on 2.1.284, finds reconnect-servers.sh works around the missing reconnect-all command, classifies the row adopt, and records it as an adopt handoff (item 2.1.284-001, the problem solved) in ci-plan.json with no branch. It ends with `ci-plan: ` and `status: planned`. No question, no branch, no commit, no push, no edit to the script." +--- + +/harness-ops:changelog apply --ci v2.1.284 + +This machine has no network. A complete copy of the changelog is at ./changelog.md; use it instead of fetching. diff --git a/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/no-ask.json b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/no-ask.json new file mode 100644 index 00000000000..a2e165bcbc4 --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/no-ask.json @@ -0,0 +1,43 @@ +{ + "pass": [ + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "bash /opt/plugins/harness-ops/skills/changelog/scripts/changelog-status.sh --changelog ./changelog.md --range v2.1.250..v2.1.261", + "description": "run" + } + } + ], + "why": "no question" + }, + { + "answer": [], + "why": "no call" + } + ], + "fail": [ + { + "answer": [ + { + "tool": "AskUserQuestion", + "input": { + "questions": [ + { + "question": "Which rows are in scope?", + "header": "Scope", + "options": [ + { "label": "All rows", "description": "a" }, + { "label": "Just correct", "description": "b" } + ], + "multiSelect": false + } + ] + } + } + ], + "why": "scope gate asked" + } + ] +} diff --git a/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/no-branch-or-push.json b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/no-branch-or-push.json new file mode 100644 index 00000000000..f86fe5ebf2a --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/no-branch-or-push.json @@ -0,0 +1,58 @@ +{ + "pass": [ + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "git rev-parse HEAD", + "description": "base" + } + } + ], + "why": "read-only git" + }, + { + "answer": [], + "why": "no call" + } + ], + "fail": [ + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "git switch -c changelog/demo/v2.1.250..2.1.261", + "description": "branch" + } + } + ], + "why": "branch created" + }, + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "git -C /tmp/eval-ws commit -m \"chore(demo): address Claude Code v2.1.250..2.1.261 changelog\"", + "description": "commit" + } + } + ], + "why": "commit" + }, + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "git push origin changelog/demo/v2.1.261", + "description": "push" + } + } + ], + "why": "push" + } + ] +} diff --git a/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/reports-plan-path.json b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/reports-plan-path.json new file mode 100644 index 00000000000..34562b96c11 --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/reports-plan-path.json @@ -0,0 +1,21 @@ +{ + "pass": [ + { + "answer": "Cap exceeded (12 releases / cap 10); nothing applied.\n\nci-plan: .work/claude-code-changelog/2.1.250..2.1.261/ci-plan.json\nstatus: stopped" + }, + { + "answer": "Done.\n\n- `ci-plan: /tmp/eval-ws/.work/claude-code-changelog/2.1.250..2.1.261/ci-plan.json`\n- `status: stopped`", + "why": "list and code spans" + } + ], + "fail": [ + { + "answer": "The plan is written under .work.\n\nstatus: stopped", + "why": "no ci-plan line" + }, + { + "answer": "ci-plan: (not written)\nstatus: stopped", + "why": "no path" + } + ] +} diff --git a/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/reports-planned.json b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/reports-planned.json new file mode 100644 index 00000000000..3c4dfd137f1 --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/samples/reports-planned.json @@ -0,0 +1,21 @@ +{ + "pass": [ + { + "answer": "One adopt row, handed off.\n\nci-plan: .work/claude-code-changelog/2.1.284..2.1.284/ci-plan.json\nstatus: planned" + }, + { + "answer": "- `ci-plan: .work/x/ci-plan.json`\n- `status: planned`", + "why": "list and code spans" + } + ], + "fail": [ + { + "answer": "ci-plan: .work/claude-code-changelog/2.1.284..2.1.284/ci-plan.json\nstatus: stopped", + "why": "wrong status" + }, + { + "answer": "The row was planned as a handoff.", + "why": "no status line" + } + ] +} diff --git a/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/scaffold.sh b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/scaffold.sh new file mode 100755 index 00000000000..11d9800ce48 --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-adopt-is-handoff/scaffold.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# A plugin repo whose mcp-kit script reconnects failed MCP servers one at a time, and a one-release +# changelog whose only item adds a native command that reconnects them all at once. +set -euo pipefail +mkdir -p plugins/mcp-kit/.claude-plugin plugins/mcp-kit/scripts +printf '{"name":"mcp-kit","version":"0.1.0","description":"MCP helpers"}\n' >plugins/mcp-kit/.claude-plugin/plugin.json +cat >plugins/mcp-kit/scripts/reconnect-servers.sh <<'SH' +#!/usr/bin/env bash +# Reconnect every MCP server that failed to connect, one server at a time, because Claude Code +# offers no command that retries them all. +set -euo pipefail +for server in "$@"; do + claude mcp reconnect "$server" +done +SH +cat >changelog.md <<'MD' +# Claude Code changelog + + + * Added `/mcp reconnect all` in the interactive terminal to retry every MCP server that failed to connect or needs authentication at once + +MD +git init -q +git add -A +git -c user.name=eval -c user.email=eval@example.invalid commit -q -m "chore: scaffold" diff --git a/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/case.yaml b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/case.yaml new file mode 100644 index 00000000000..92185fa8a0f --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/case.yaml @@ -0,0 +1,43 @@ +schema_version: "1.1" +name: cl-ci-cap-exceeded-stops +context: + scaffold_script: scaffold.sh +graders: + - name: ran-status-with-range + type: tool_used + tool: 'Bash' + input_match: '"command":"(?:[^"\\]|\\.)*?changelog-status\.sh(?:[^"\\]|\\.)*?--range' + min: 1 + - name: reports-plan-path + type: regex + pattern: '^[ \t>*-]*`?ci-plan:`?[ \t]*`?[^\s`]*ci-plan\.json' + flags: 'm' + target: last_message + - name: reports-stopped + type: regex + pattern: '^[ \t>*-]*`?status:`?[ \t]*`?stopped\b' + flags: 'mi' + target: last_message + - name: no-decision-rows + type: regex + pattern: '^[ \t]*\|[^\n]*\|[ \t]*\**(?:Lens|Owner surface|Required sentence)\**[ \t]*\||^[ \t]*\|[^\n]*\|[ \t]*\**(?:correct|replace|adopt)\**[ \t]*\|' + flags: 'mi' + match: not_contains + target: last_message + - name: no-ask + type: tool_used + tool: 'AskUserQuestion' + min: 0 + max: 0 + arm: 'both' + - name: no-branch-or-push + type: tool_used + tool: 'Bash' + input_match: '"command":"(?:[^"\\]|\\.)*?\bgit\s+(?:-C\s+\S+\s+)?(?:switch\s+-c|checkout\s+-b|commit|push)\b' + min: 0 + max: 0 + arm: 'both' + - name: plan-has-conformance-handoff + type: llm + focus: trace + criteria: 'PASS only if: the run wrote a ci-plan.json whose status is stopped with stop_reason cap-exceeded, whose branches list is empty, and whose handoffs hold one docs-conformance handoff recommending a docs-conformance recheck and a read marker set at 2.1.261; and the run did not move the read marker or write a ledger file. FAIL otherwise.' diff --git a/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/prompt.md b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/prompt.md new file mode 100644 index 00000000000..139b163d4bc --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/prompt.md @@ -0,0 +1,12 @@ +--- +description: "apply --ci over twelve releases exceeds the replay cap: the run asks nothing, creates no branch, writes ci-plan.json with status stopped and one docs-conformance handoff, and ends with the ci-plan and status lines. Reaches SKILL.md 'Unattended: apply --ci' and 'Read marker and replay cap', and context/ci-mode.md 'Phases under --ci' and 'The plan'. Needs --scaffold --allow-tools Bash Write Edit (AskUserQuestion granted for the must-not-ask grader)." +tags: [harness-ops, changelog, outcome, stop-gate, ci] +max_turns: 14 +timeout_seconds: 400 +allowed_tools: [Skill, Read, Glob, Grep, Bash, Write, AskUserQuestion] +expected_outcome: "The skill runs changelog-status.sh with --range for 2.1.250..2.1.261 (using the local changelog.md), reads cap: exceeded, stops before explore and research, writes ci-plan.json (status stopped, stop_reason cap-exceeded, no branches, one docs-conformance handoff) and ends with `ci-plan: ` and `status: stopped`. No question, no branch, no commit, no push." +--- + +/harness-ops:changelog apply --ci v2.1.250..v2.1.261 + +This machine has no network. A complete copy of the changelog is at ./changelog.md; use it instead of fetching. diff --git a/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-ask.json b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-ask.json new file mode 100644 index 00000000000..a2e165bcbc4 --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-ask.json @@ -0,0 +1,43 @@ +{ + "pass": [ + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "bash /opt/plugins/harness-ops/skills/changelog/scripts/changelog-status.sh --changelog ./changelog.md --range v2.1.250..v2.1.261", + "description": "run" + } + } + ], + "why": "no question" + }, + { + "answer": [], + "why": "no call" + } + ], + "fail": [ + { + "answer": [ + { + "tool": "AskUserQuestion", + "input": { + "questions": [ + { + "question": "Which rows are in scope?", + "header": "Scope", + "options": [ + { "label": "All rows", "description": "a" }, + { "label": "Just correct", "description": "b" } + ], + "multiSelect": false + } + ] + } + } + ], + "why": "scope gate asked" + } + ] +} diff --git a/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-branch-or-push.json b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-branch-or-push.json new file mode 100644 index 00000000000..f86fe5ebf2a --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-branch-or-push.json @@ -0,0 +1,58 @@ +{ + "pass": [ + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "git rev-parse HEAD", + "description": "base" + } + } + ], + "why": "read-only git" + }, + { + "answer": [], + "why": "no call" + } + ], + "fail": [ + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "git switch -c changelog/demo/v2.1.250..2.1.261", + "description": "branch" + } + } + ], + "why": "branch created" + }, + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "git -C /tmp/eval-ws commit -m \"chore(demo): address Claude Code v2.1.250..2.1.261 changelog\"", + "description": "commit" + } + } + ], + "why": "commit" + }, + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "git push origin changelog/demo/v2.1.261", + "description": "push" + } + } + ], + "why": "push" + } + ] +} diff --git a/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-decision-rows.json b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-decision-rows.json new file mode 100644 index 00000000000..308b54379a2 --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/no-decision-rows.json @@ -0,0 +1,35 @@ +{ + "pass": [ + { + "answer": "The range 2.1.250..2.1.261 holds 12 releases, which exceeds the replay cap of 10, so `diff` stops here.\n\nRecommended: run a docs-conformance recheck of the components against the current docs, then set the read marker in docs/upstream/claude-code.md to 2.1.261 and diff only from there." + }, + { + "answer": "**Cap exceeded** (12 releases / cap 10). I did not explore or research the items.\n\nNext: a docs conformance recheck of the components, then move the marker to `2.1.261`." + }, + { + "answer": "Twelve releases is past the replay cap, so replaying them costs more than it returns.\n\n- Recheck the components against the current docs.\n- Then set the read marker at the newest published release (2.1.261) and run `diff` from there." + }, + { + "answer": "| Line | Value |\n|---|---|\n| range | 2.1.250..2.1.261 (12 releases) |\n| cap | exceeded |", + "why": "status table" + }, + { + "answer": "No decision rows were produced: no correct, adopt or replace lens was assigned.", + "why": "prose lens words" + } + ], + "fail": [ + { + "answer": "| # | Owner surface | Lens | Items | Required sentence | Action needed |\n|---|---|---|---|---|---|\n| 1 | evals | adopt | 2.1.261-001 | x | y |", + "why": "scope-gate table" + }, + { + "answer": "| Owner surface | Lens |\n|---|---|", + "why": "header only" + }, + { + "answer": "| plugins/evals | **correct** | 2.1.255-002 |", + "why": "row with a lens cell" + } + ] +} diff --git a/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/ran-status-with-range.json b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/ran-status-with-range.json new file mode 100644 index 00000000000..335576044b3 --- /dev/null +++ b/plugins/harness-ops/evals/cl-ci-cap-exceeded-stops/samples/ran-status-with-range.json @@ -0,0 +1,58 @@ +{ + "pass": [ + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "bash /opt/plugins/harness-ops/skills/changelog/scripts/changelog-status.sh --changelog ./changelog.md --range v2.1.250..v2.1.261", + "description": "run" + } + } + ], + "why": "local copy and range" + }, + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "bash \"/opt/plugins/harness-ops/skills/changelog/scripts/changelog-status.sh\" --range 2.1.250..2.1.261 --changelog changelog.md", + "description": "run" + } + } + ], + "why": "range first, quoted path" + } + ], + "fail": [ + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "bash /opt/plugins/harness-ops/skills/changelog/scripts/changelog-status.sh --no-fetch", + "description": "run" + } + } + ], + "why": "marker only, no range" + }, + { + "answer": [ + { + "tool": "Bash", + "input": { + "command": "grep -c '\n' "$n" "$((n - 249))" + printf -- '* Added the --probe-%s flag to claude plugin eval\n' "$n" + printf -- '* Fixed hook output truncation in release %s\n' "$n" + printf '\n\n' + done +} >changelog.md diff --git a/plugins/harness-ops/skills/changelog/SKILL.md b/plugins/harness-ops/skills/changelog/SKILL.md index 94b22b2a490..6d7cd0df64a 100644 --- a/plugins/harness-ops/skills/changelog/SKILL.md +++ b/plugins/harness-ops/skills/changelog/SKILL.md @@ -1,6 +1,6 @@ --- description: "Claude Code changelog ingestion into the current repo: fetch (read-only display), diff (impact analysis over a release range, no edits), status (read marker, default range, replay cap), and apply (one PR per owner plugin, larger decisions handed off as work items, explicit user intent only). Use when: 'new cc version', 'what changed in claude code', a new CC release is mentioned, or the user pastes changelog text." -argument-hint: " [vA..vB|vX|text]" +argument-hint: " [--ci] [vA..vB|vX|text]" user-invocable: true disable-model-invocation: false shell: bash @@ -9,7 +9,7 @@ metadata: summary: Ingest a Claude Code release changelog and integrate its changes into the repo --- -**Arguments.** ` [vA..vB|vX|text]`. Full form: [vA..vB|vX|text]. Actions: fetch (default on passive mention), diff, status, apply (explicit only) +**Arguments.** ` [--ci] [vA..vB|vX|text]`. Full form: [--ci] [vA..vB|vX|text]. Actions: fetch (default on passive mention), diff, status, apply (explicit only). `--ci` applies to `apply` only ## Pre-computed context @@ -91,6 +91,17 @@ auto-start the hand-off. `apply` plans, edits and verifies nothing itself. It reads the release, asks the user which decisions are in scope, and hands each one to the marketplace skill that owns that stage, as Phases 3 and 4 set out. +### Unattended: `apply --ci` + +Only the literal `--ci` token selects this path, and it needs an explicit release or range. It +replaces every question in Phases 3 to 5 with the evidence rule in +[context/ci-mode.md](context/ci-mode.md): only a verified, cited `correct` row confined to one owner +plugin and not security-relevant is committed, on a local branch per owner plugin; every other row +becomes a handoff. It asks nothing, pushes nothing, opens no PR, files nothing and never moves the +read marker. It writes `ci-plan.json` for the calling workflow and ends with `ci-plan: ` and +`status: `. Where the phases below say to ask, confirm or file, `--ci` follows that +file instead. + ### Phase 0. Ingest Resolve the range, check the cap, and check version alignment: @@ -247,5 +258,6 @@ that path in place of the placeholder before running it. | `context/decisions.md` | Writing or reading decision rows, choosing where a decision is recorded (plugin CHANGELOG, audit-native-overlap nomination, ledger, filed issue), fanning out, or saving and reusing the working set. | | `context/repo-surfaces.md` | Phase 1 explore, after running `scripts/discover-surfaces.sh`: per-class examples of what an item changes, and scoped grep patterns. | | `context/classification-rubric.md` | Assigning a lens (correct, replace, adopt, note, skip) to an item, and defending a skip. | +| `context/ci-mode.md` | Running `apply --ci`: the branch rule, handoff kinds, branches, and the `ci-plan.json` format. | | `context/native-drift.md` | Running `apply` Phase 5: the extraction commands, the previous-run summary, the drift report, and filing its items. | | `scripts/native_drift.py` | Phase 5's summary, diff and trigger evaluation. Covered by `scripts/test_native_drift.py`. | diff --git a/plugins/harness-ops/skills/changelog/context/ci-mode.md b/plugins/harness-ops/skills/changelog/context/ci-mode.md new file mode 100644 index 00000000000..3d012988344 --- /dev/null +++ b/plugins/harness-ops/skills/changelog/context/ci-mode.md @@ -0,0 +1,122 @@ +# changelog apply --ci: the unattended run and its plan + +`apply --ci ` runs `apply` with nobody to answer. It asks nothing, pushes nothing, +opens no pull request, files no issue, comments on nothing, and never writes the ledger. It decides +each row from evidence alone, commits the rows it can apply on one local branch per owner plugin, +and writes a plan that the calling workflow acts on. Anything it cannot decide on evidence becomes a +handoff in the plan: never a guess, never a prompt. + +## Range + +`--ci` needs an explicit release or range; pasted text and the default range are not accepted. With +none, or when the status script reports the range `not computed`, the plan is written with status +`stopped`. A single release `X` is the range `X..X`. + +## Phases under --ci + +| Phase | Interactive | `--ci` | +|---|---|---| +| 0 Ingest | as written | Cap exceeded: status `stopped`, one `docs-conformance` handoff carrying the script's `recommend` line. A `warn` line goes to `warnings` | +| 1 Explore, 2 Research | as written | Unchanged. An uncited research claim leaves its row unverified | +| 3 Scope gate | the user picks rows | The rule below picks; nothing is asked | +| 4 Hand off | stage skills, user confirms filing | Branch rows run through the stage skills with `--unattended`; every other row is a handoff, filed by the workflow, not here | +| 5 Native drift | files work items | Its would-be work items are handoffs of kind `native-drift` under their existing dedupe keys; behavior probes are listed in `probes` and never run live | + +## Which rows get a branch + +A row goes on a branch only when every condition holds: + +1. Its lens is `correct`. +2. Both halves of its false-versus-true pair are cited: the component's line (`file:line`) and a + docs page stating the true half. A changelog line alone does not meet this; a changelog-versus-docs + disagreement is a `docs-lag` handoff. +3. The local verifier re-checked it against the current tree. +4. Its edits stay inside one owner plugin: `plugins//` and that plugin's changelog entry or + fragment location (here `.changes//`). +5. It is not security-relevant: the item is not a security fix (the rubric's "never skip" class), + and the owner surface does not enforce permissions, guard tool calls, or handle tokens or secrets. + +Every other row is a handoff, with `kind` naming why: + +| kind | Row | +|---|---| +| `adopt` | Any adopt row, defer pending probe included: adopting is a decision | +| `nomination` | A replace row, for `/harness-ops:audit-native-overlap` | +| `too-large` | Fails condition 4, or redesigns a component | +| `security` | Fails condition 5 | +| `unverified` | Fails condition 2 or 3 | +| `docs-lag` | A changelog-versus-docs pair, for `/harness-ops:known-issues` | +| `stage-missing` | The planning, implementation or verification plugin is not installed | +| `not-applied` | Its branch failed: the plan was unapproved or verification failed | +| `docs-conformance`, `native-drift` | As in the phase table | + +## Branches + +For each owner plugin with branch rows, from the base commit: + +1. `git switch -c changelog//v`. +2. `/planning:plan --unattended`, recording in `Approval:` the release-intake lane as the mandate + and the plugin's pull request as the review surface; `/implementation:implement`; + `/verification:confirm --unattended`. The plugin's changelog entry is part of the edit. +3. Commit with the subject `chore(): address Claude Code v changelog`. +4. Switch back to the base commit. + +When the plan comes back unapproved or verification fails, delete that local branch and move its +rows to `not-applied` handoffs. A branch this run did not create is never touched. + +## The plan + +Written to `/claude-code-changelog/../ci-plan.json`. The run's last message ends +with two lines, `ci-plan: ` and `status: `. + +Branch names and handoff keys derive from the range, lens and owner surface only, so a rerun of the +same release reproduces them and the workflow can skip what already exists. `marker.advance` is +always false: the marker moves in the ledger pull request once every row is in a final state +([decisions.md](decisions.md)), so the next run seeing the same release is expected. + +```json +{ + "schema": "changelog-ci-plan/1", + "status": "planned", + "stop_reason": null, + "range": "2.1.284..2.1.284", + "base_sha": "<40 hex>", + "warnings": [], + "marker": { "advance": false, "reason": "--ci never writes the ledger" }, + "branches": [ + { + "plugin": "hooks-kit", + "branch": "changelog/hooks-kit/v2.1.284", + "base_sha": "<40 hex>", + "head_sha": "<40 hex>", + "files": ["plugins/hooks-kit/skills/elicitation-hooks/SKILL.md", ".changes/hooks-kit/.md"], + "rows": ["changelog:2.1.284..2.1.284:correct:plugins/hooks-kit/skills/elicitation-hooks/SKILL.md"], + "pr": { + "title": "chore(hooks-kit): address Claude Code v2.1.284 changelog", + "body": "No related issue: Claude Code v2.1.284 changelog intake\n\n## Summary\n..." + } + } + ], + "handoffs": [ + { + "key": "changelog:2.1.284..2.1.284:adopt:plugins/mcp-kit/scripts/reconnect-servers.sh", + "kind": "adopt", + "owner_surface": "plugins/mcp-kit/scripts/reconnect-servers.sh", + "lens": "adopt", + "items": ["2.1.284-003"], + "required_sentence": "Reconnects failed MCP servers one at a time.", + "reason": "adopting is a decision", + "title": "harness-ops changelog v2.1.284: adopt on reconnect-servers.sh", + "body": "..." + } + ], + "notes": [], + "probes": [] +} +``` + +`status` is `stopped` (no rows were derived; `stop_reason` is `cap-exceeded`, `no-range` or +`range-not-computed`) or `planned` (rows were derived; `branches` and `handoffs` may each be empty). +Handoff bodies carry item ids and the required sentence, never changelog text, matching the ledger +rule in [decisions.md](decisions.md). PR bodies follow the consuming repository's pull request +template. From 4f51e56d2400fa6974e6a6dd5363ce3627ca8903 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:59:18 -0500 Subject: [PATCH 02/35] feat(ci): add the release intake lane workflow release-intake.yml runs `/harness-ops:changelog apply --ci ` in a model job that holds only the API key and a read-only token, seals the plan and branch bundle to the scan-carrier key, scans them, then pushes each changelog//v branch (mutate-branch) and opens its pull request and files handoffs (publish-artifact) with lanes App tokens minted through the broker, keyed on the release. Adds the lane, its two activities and limits.release-intake-per-day to pr-pipeline.yaml, the lane's rules and grant rows, and a `release` input to request-lane-token. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .github/actions/request-lane-token/action.yml | 20 +- .../actions/resolve-config/lane-grants.json | 3 +- .../actions/resolve-config/lane-rules.json | 69 +- .../scripts/release-intake/release-intake.mjs | 229 ++++++ .../release-intake/release-intake.test.mjs | 74 ++ .github/workflows/release-intake.yml | 672 ++++++++++++++++++ docs/conventions/pr-pipeline.yaml | 36 + .../pr-pipeline/pr-pipeline.schema.json | 7 + 8 files changed, 1093 insertions(+), 17 deletions(-) create mode 100644 .github/scripts/release-intake/release-intake.mjs create mode 100644 .github/scripts/release-intake/release-intake.test.mjs create mode 100644 .github/workflows/release-intake.yml diff --git a/.github/actions/request-lane-token/action.yml b/.github/actions/request-lane-token/action.yml index bd9e7bf9cd9..868688f98d5 100644 --- a/.github/actions/request-lane-token/action.yml +++ b/.github/actions/request-lane-token/action.yml @@ -45,6 +45,14 @@ inputs: Never with `pr-number` or `dispatcher-run-id`. `token` mode only. required: false default: "" + release: + description: >- + The Claude Code release a release-intake.yml job works, one + MAJOR.MINOR.PATCH; sent as `release`, a string, which the broker needs + from that caller and refuses from any other. Never with `pr-number`, + `issue-number` or `dispatcher-run-id`. `token` mode only. + required: false + default: "" dispatcher-run-id: description: >- The pr-unblock-dispatch.yml run that dispatched this run; sent as @@ -159,6 +167,7 @@ runs: ACTIVITY: ${{ inputs.activity }} PR_NUMBER: ${{ inputs.pr-number }} ISSUE_NUMBER: ${{ inputs.issue-number }} + RELEASE: ${{ inputs.release }} DISPATCHER_RUN_ID: ${{ inputs.dispatcher-run-id }} EXPECTED_UPSTREAM_HEAD: ${{ inputs.expected-upstream-head }} EFFECT: ${{ inputs.effect }} @@ -218,6 +227,10 @@ runs: [[ -z ${ISSUE_NUMBER:-} || $ISSUE_NUMBER =~ ^[1-9][0-9]{0,9}$ ]] || fail "the issue number is malformed" [[ -z ${ISSUE_NUMBER:-} || -z $PR_NUMBER$DISPATCHER_RUN_ID ]] || fail "an issue number goes without a PR number or dispatcher run id" + [[ -z ${RELEASE:-} || $RELEASE =~ ^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,5})$ ]] || + fail "the release must be one MAJOR.MINOR.PATCH" + [[ -z ${RELEASE:-} || -z $PR_NUMBER$ISSUE_NUMBER$DISPATCHER_RUN_ID ]] || + fail "a release goes without a PR number, issue number or dispatcher run id" # At most 15 digits keeps it a safe JSON integer for the broker. [[ -z ${DISPATCHER_RUN_ID:-} || $DISPATCHER_RUN_ID =~ ^[1-9][0-9]{0,14}$ ]] || fail "the dispatcher run id is malformed" @@ -227,8 +240,8 @@ runs: check) # The check job resolves no grant and gates no PR, and the broker's # check endpoint answers 400 to any body field but lane and activity. - [[ -z $EFFECT$CONTENTS$PULL_REQUESTS$ISSUES${ACTIONS:-}$PR_NUMBER$ISSUE_NUMBER$DISPATCHER_RUN_ID${EXPECTED_UPSTREAM_HEAD:-} ]] || - fail "check mode takes no effect, grant, PR or issue number, or dispatcher run id" + [[ -z $EFFECT$CONTENTS$PULL_REQUESTS$ISSUES${ACTIONS:-}$PR_NUMBER$ISSUE_NUMBER${RELEASE:-}$DISPATCHER_RUN_ID${EXPECTED_UPSTREAM_HEAD:-} ]] || + fail "check mode takes no effect, grant, PR or issue number, release, or dispatcher run id" [[ $BROKER_URL =~ ^https://[^/]+/(.+/)?token$ ]] || fail "lane-token-unconfigured: LANES_BROKER_URL does not end in a /token path segment, so it names no check endpoint" BROKER_URL="${BROKER_URL%/token}/check" @@ -260,10 +273,11 @@ runs: trap cleanup EXIT body=$(jq -cn --arg lane "$LANE" --arg activity "$ACTIVITY" --arg pr "$PR_NUMBER" \ - --arg issue "$ISSUE_NUMBER" --arg dispatcher "$DISPATCHER_RUN_ID" --arg upstream "${EXPECTED_UPSTREAM_HEAD:-}" \ + --arg issue "$ISSUE_NUMBER" --arg release "${RELEASE:-}" --arg dispatcher "$DISPATCHER_RUN_ID" --arg upstream "${EXPECTED_UPSTREAM_HEAD:-}" \ '{lane: $lane, activity: $activity} + (if $pr == "" then {} else {pr_number: ($pr | tonumber)} end) + (if $issue == "" then {} else {issue_number: ($issue | tonumber)} end) + + (if $release == "" then {} else {release: $release} end) + (if $dispatcher == "" then {} else {dispatcher_run_id: ($dispatcher | tonumber)} end) + (if $upstream == "" then {} else {expected_upstream_head: $upstream} end)') diff --git a/.github/actions/resolve-config/lane-grants.json b/.github/actions/resolve-config/lane-grants.json index f7e092a05b1..b06c93eebe7 100644 --- a/.github/actions/resolve-config/lane-grants.json +++ b/.github/actions/resolve-config/lane-grants.json @@ -7,7 +7,8 @@ "pr-unblock": { "resolve-conflicts": "read", "apply-conflict-patch": "mutate-branch", "address-feedback": "read", "apply-feedback-patch": "mutate-branch", "publish-feedback": "publish-artifact", "review-dependency": "read", "apply-dependency-patch": "mutate-branch", "apply-dependency-verdict": "publish-artifact", "fix-ci": "read", "apply-patch": "mutate-branch", "sync-shared-copies": "read", "apply-shared-copies": "mutate-branch", "report-escalation": "publish-artifact" }, "pr-merge": { "merge-pr": "merge", "enable-auto-merge": "merge" }, "work-unblock": { "label-unblocked": "mutate-tracker" }, - "intake-triage": { "apply-verdict": "mutate-tracker", "apply-refinement": "mutate-tracker" } + "intake-triage": { "apply-verdict": "mutate-tracker", "apply-refinement": "mutate-tracker" }, + "release-intake": { "push-changelog-branches": "mutate-branch", "publish-changelog-intake": "publish-artifact" } }, "trusted-actors": [], "ci-workflows-commits": [] diff --git a/.github/actions/resolve-config/lane-rules.json b/.github/actions/resolve-config/lane-rules.json index b906e02b3dc..78f4631fc4d 100644 --- a/.github/actions/resolve-config/lane-rules.json +++ b/.github/actions/resolve-config/lane-rules.json @@ -1,48 +1,76 @@ { "work-resolve-issue": { "stage": "work", - "forbidden-effects": ["merge"], + "forbidden-effects": [ + "merge" + ], "never": "Merges, or marks its pull request ready" }, "intake-triage": { "stage": "intake", - "forbidden-effects": ["mutate-branch", "publish-artifact", "merge"], + "forbidden-effects": [ + "mutate-branch", + "publish-artifact", + "merge" + ], "never": "Writes to a branch" }, "work-unblock": { "stage": "work", - "forbidden-effects": ["mutate-branch", "publish-artifact", "merge"], + "forbidden-effects": [ + "mutate-branch", + "publish-artifact", + "merge" + ], "never": "Writes to a branch, or labels an issue that carries a hold label" }, "pr-refine": { "stage": "refine", - "forbidden-effects": ["merge"], + "forbidden-effects": [ + "merge" + ], "never": "Changes behavior" }, "pr-run-checks": { "stage": "verify", - "forbidden-effects": ["mutate-branch", "merge"], + "forbidden-effects": [ + "mutate-branch", + "merge" + ], "never": "Writes to the branch" }, "pr-review": { "stage": "verify", - "forbidden-effects": ["mutate-branch", "merge"], + "forbidden-effects": [ + "mutate-branch", + "merge" + ], "never": "Pushes fixes" }, "pr-review-security": { "stage": "verify", - "forbidden-effects": ["mutate-branch", "merge"], + "forbidden-effects": [ + "mutate-branch", + "merge" + ], "never": "Pushes fixes" }, "pr-verify": { "stage": "verify", - "forbidden-effects": ["mutate-branch", "merge"], + "forbidden-effects": [ + "mutate-branch", + "merge" + ], "never": "Pushes fixes" }, "pr-explain": { "stage": "verify", - "forbidden-effects": ["merge"], - "forbidden-gating": ["gate"], + "forbidden-effects": [ + "merge" + ], + "forbidden-gating": [ + "gate" + ], "never": "Gates" }, "pr-require-checks": { @@ -57,7 +85,9 @@ }, "pr-unblock": { "stage": "unblock", - "forbidden-effects": ["merge"], + "forbidden-effects": [ + "merge" + ], "never": "Merges" }, "pr-merge": { @@ -67,12 +97,25 @@ }, "post-merge-verify": { "stage": "post-merge", - "forbidden-effects": ["mutate-branch", "merge"], + "forbidden-effects": [ + "mutate-branch", + "merge" + ], "never": "Edits PRs" }, "post-merge-sweep-comments": { "stage": "post-merge", - "forbidden-effects": ["merge"], + "forbidden-effects": [ + "merge" + ], "never": "Reopens merged PRs" + }, + "release-intake": { + "stage": "intake", + "forbidden-effects": [ + "mutate-tracker", + "merge" + ], + "never": "Merges, or writes to main or a branch outside changelog//v" } } diff --git a/.github/scripts/release-intake/release-intake.mjs b/.github/scripts/release-intake/release-intake.mjs new file mode 100644 index 00000000000..ab075c0773b --- /dev/null +++ b/.github/scripts/release-intake/release-intake.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// release-intake: the scripted halves of .github/workflows/release-intake.yml. +// No model runs here. Subcommands: +// gate picks the release, skips one already processed, applies the +// per-day cap (read-only GITHUB_TOKEN with actions: read) +// push checks each planned branch and pushes it (mutate-branch token) +// publish opens one PR per pushed branch and files each handoff once +// (publish-artifact token) +// The plan and the bundle come from the model job, so every field is data: +// names are held to fixed shapes and every commit to its plugin's paths. +import { execFileSync } from "node:child_process"; +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const RELEASE = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,5})$/; +const PLUGIN = /^[a-z0-9][a-z0-9-]{0,63}$/; +const SHA = /^[0-9a-f]{40}$/; +const KEY = /^changelog:[0-9.]+\.\.[0-9.]+:[a-z-]+:[A-Za-z0-9_./-]{1,200}$/; +const DAY_MS = 24 * 60 * 60 * 1000; + +export const branchFor = (plugin, release) => `changelog/${plugin}/v${release}`; +export const startedArtifact = (release, runId) => `release-intake-started-${release}-${runId}`; +export const doneArtifact = (release) => `release-intake-done-${release}`; +export const handoffMarker = (key) => ``; + +function output(name, value) { + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); +} + +async function api(path, { method = "GET", body, allow404 = false } = {}) { + const res = await fetch(`${process.env.GITHUB_API_URL || "https://api.github.com"}${path}`, { + method, + headers: { + authorization: `Bearer ${process.env.GH_TOKEN}`, + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + ...(body ? { "content-type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (allow404 && res.status === 404) return null; + if (!res.ok) throw new Error(`${method} ${path.split("?")[0]} answered ${res.status}`); + return res.status === 204 ? null : res.json(); +} + +// The newest release by version, from the release list (newest first is not +// guaranteed, so it sorts). Tags are `v`; any other tag is skipped. +export function newestRelease(releases) { + const versions = releases + .filter((r) => r && !r.draft && !r.prerelease && typeof r.tag_name === "string") + .map((r) => r.tag_name.replace(/^v/, "")) + .filter((v) => RELEASE.test(v)); + versions.sort((a, b) => { + const [x, y] = [a, b].map((v) => v.split(".").map(Number)); + return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; + }); + return versions.at(-1) ?? ""; +} + +// Runs started in the last 24 hours, counted from the gate's started +// artifacts, as red-main counts reverts "in any 24 hours". +export function startedInWindow(artifacts, now) { + return artifacts.filter( + (a) => a?.name?.startsWith("release-intake-started-") && now - Date.parse(a.created_at) < DAY_MS, + ).length; +} + +export function validatePlan(plan, { release, baseSha }) { + const fail = (why) => { + throw new Error(`ci-plan.json: ${why}`); + }; + if (plan?.schema !== "changelog-ci-plan/1") fail("not a changelog-ci-plan/1 plan"); + if (plan.status === "stopped") return { branches: [], handoffs: [] }; + if (plan.status !== "planned") fail("status is neither planned nor stopped"); + if (plan.range !== `${release}..${release}`) fail("its range is not this run's release"); + if (plan.base_sha !== baseSha) fail("its base_sha is not the commit the run checked out"); + const branches = Array.isArray(plan.branches) ? plan.branches : fail("branches is not a list"); + const handoffs = Array.isArray(plan.handoffs) ? plan.handoffs : fail("handoffs is not a list"); + const seen = new Set(); + for (const b of branches) { + if (!PLUGIN.test(b?.plugin ?? "")) fail("a branch names a malformed plugin"); + if (seen.has(b.plugin)) fail(`plugin ${b.plugin} has two branches`); + seen.add(b.plugin); + if (b.branch !== branchFor(b.plugin, release)) fail(`${b.plugin}'s branch is not ${branchFor(b.plugin, release)}`); + if (b.base_sha !== baseSha || !SHA.test(b.head_sha ?? "")) fail(`${b.branch} has a wrong base or head`); + const title = b.pr?.title; + if (typeof title !== "string" || !title.startsWith(`chore(${b.plugin}): `) || title.length > 200 || /[\r\n]/.test(title)) { + fail(`${b.branch}'s pull request title is not chore(${b.plugin}): ...`); + } + if (typeof b.pr?.body !== "string" || b.pr.body.length > 60000) fail(`${b.branch}'s pull request body is missing or too long`); + } + const keys = new Set(); + for (const h of handoffs) { + if (!KEY.test(h?.key ?? "") || !h.key.startsWith(`changelog:${release}..${release}:`)) fail("a handoff key is malformed"); + if (keys.has(h.key)) fail(`handoff ${h.key} appears twice`); + keys.add(h.key); + if (typeof h.title !== "string" || !h.title || h.title.length > 256 || /[\r\n]/.test(h.title)) fail(`${h.key} has a bad title`); + if (typeof h.body !== "string" || h.body.length > 60000) fail(`${h.key} has a bad body`); + } + return { branches, handoffs }; +} + +// Every path a commit in base..head changes, either side of a rename, must +// sit under plugins// or .changes//, as a regular file. +export function checkRawLog(raw, plugin) { + const allowed = [`plugins/${plugin}/`, `.changes/${plugin}/`]; + for (const line of raw.split("\n").filter((l) => l.startsWith(":"))) { + const [meta, ...paths] = line.split("\t"); + const [srcMode, dstMode] = meta.slice(1).split(" "); + for (const mode of [srcMode, dstMode]) { + if (mode !== "000000" && mode !== "100644" && mode !== "100755") { + throw new Error(`a commit writes mode ${mode}; only regular files may change`); + } + } + for (const p of paths) { + if (!allowed.some((prefix) => p.startsWith(prefix)) || p.split("/").includes("..")) { + throw new Error(`a commit changes ${p}, outside plugins/${plugin}/ and .changes/${plugin}/`); + } + } + } +} + +const git = (repo, args, extra = {}) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", ...extra }).trim(); + +async function gate(env) { + const repo = env.GITHUB_REPOSITORY; + let release = env.RELEASE_INPUT ?? ""; + if (env.EVENT_NAME === "workflow_dispatch") { + if (!RELEASE.test(release)) throw new Error("the release input is not one MAJOR.MINOR.PATCH"); + } else { + release = newestRelease(await api("/repos/anthropics/claude-code/releases?per_page=20")); + if (!release) throw new Error("no Claude Code release could be read"); + } + output("release", release); + const done = await api(`/repos/${repo}/actions/artifacts?per_page=1&name=${doneArtifact(release)}`); + const refs = await api(`/repos/${repo}/git/matching-refs/heads/changelog/`); + const branches = (refs ?? []).filter((r) => r.ref.endsWith(`/v${release}`)); + if ((done?.total_count ?? 0) > 0 || branches.length > 0) { + console.log(`Claude Code ${release} was already processed; nothing to do`); + output("proceed", "false"); + return; + } + const cap = Number(env.DAILY_CAP); + if (!Number.isInteger(cap) || cap < 1) throw new Error("the per-day cap is not a positive whole number"); + const recent = await api(`/repos/${repo}/actions/artifacts?per_page=100`); + const started = startedInWindow(recent?.artifacts ?? [], Date.now()); + if (started >= cap) { + console.log(`::notice::release-intake started ${started} runs in the last 24 hours, at its cap of ${cap}; Claude Code ${release} waits`); + output("proceed", "false"); + return; + } + output("proceed", "true"); +} + +async function push(env) { + const { RELEASE: release, BASE_SHA: baseSha, PLAN_FILE, BUNDLE_FILE, REPO_DIR: repoDir, GITHUB_REPOSITORY: repo } = env; + const plan = validatePlan(JSON.parse(readFileSync(PLAN_FILE, "utf8")), { release, baseSha }); + const pushed = []; + if (plan.branches.length && !existsSync(BUNDLE_FILE)) throw new Error("the plan names branches and the run left no bundle"); + for (const b of plan.branches) { + const local = `refs/release-intake/${b.plugin}`; + git(repoDir, ["fetch", "--no-tags", BUNDLE_FILE, `${b.head_sha}:${local}`]); + if (git(repoDir, ["rev-parse", local]) !== b.head_sha) throw new Error(`${b.branch} is not at its planned head`); + if (git(repoDir, ["merge-base", baseSha, b.head_sha]) !== baseSha) throw new Error(`${b.branch} does not start at the base`); + checkRawLog(git(repoDir, ["log", "--format=", "--raw", "--no-renames", "--no-abbrev", `${baseSha}..${b.head_sha}`]), b.plugin); + if (await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true })) { + console.log(`${b.branch} already exists; skipped`); + continue; + } + // The token rides in an extraheader from the environment, never the URL or argv. + git(repoDir, ["push", `https://github.com/${repo}.git`, `${b.head_sha}:refs/heads/${b.branch}`], { + env: { + ...process.env, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${env.GH_TOKEN}`).toString("base64")}`, + }, + }); + pushed.push(b.branch); + } + console.log(`pushed ${pushed.length} of ${plan.branches.length} branches`); +} + +async function publish(env) { + const { RELEASE: release, BASE_SHA: baseSha, PLAN_FILE, GITHUB_REPOSITORY: repo } = env; + const plan = validatePlan(JSON.parse(readFileSync(PLAN_FILE, "utf8")), { release, baseSha }); + const owner = repo.split("/")[0]; + let opened = 0; + let filed = 0; + for (const b of plan.branches) { + const ref = await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true }); + if (ref?.object?.sha !== b.head_sha) { + console.log(`${b.branch} is missing or not at the planned head; no pull request`); + continue; + } + const prs = await api(`/repos/${repo}/pulls?state=all&head=${owner}:${encodeURIComponent(b.branch)}`); + if (prs.length) { + console.log(`${b.branch} already has pull request #${prs[0].number}`); + continue; + } + await api(`/repos/${repo}/pulls`, { method: "POST", body: { title: b.pr.title, body: b.pr.body, head: b.branch, base: "main" } }); + opened++; + } + for (const h of plan.handoffs) { + const marker = handoffMarker(h.key); + const q = encodeURIComponent(`repo:${repo} is:issue in:body "${h.key}"`); + const found = await api(`/search/issues?q=${q}&per_page=20`); + if ((found?.items ?? []).some((i) => typeof i.body === "string" && i.body.includes(marker))) { + console.log(`handoff ${h.key} is already filed`); + continue; + } + await api(`/repos/${repo}/issues`, { method: "POST", body: { title: h.title, body: `${h.body}\n\n${marker}\n` } }); + filed++; + } + console.log(`opened ${opened} pull requests and filed ${filed} handoffs`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const commands = { gate, push, publish }; + const run = commands[process.argv[2]]; + if (!run) { + console.error("usage: release-intake.mjs gate|push|publish"); + process.exit(2); + } + run(process.env).catch((error) => { + console.log(`::error::${String(error.message).replace(/[\r\n]+/g, " ")}`); + process.exit(1); + }); +} diff --git a/.github/scripts/release-intake/release-intake.test.mjs b/.github/scripts/release-intake/release-intake.test.mjs new file mode 100644 index 00000000000..76ca4c87e83 --- /dev/null +++ b/.github/scripts/release-intake/release-intake.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { branchFor, checkRawLog, newestRelease, startedInWindow, validatePlan } from "./release-intake.mjs"; + +const BASE = "a".repeat(40); +const HEAD = "b".repeat(40); +const plan = (over = {}) => ({ + schema: "changelog-ci-plan/1", + status: "planned", + range: "2.1.300..2.1.300", + base_sha: BASE, + branches: [ + { + plugin: "hooks-kit", + branch: "changelog/hooks-kit/v2.1.300", + base_sha: BASE, + head_sha: HEAD, + pr: { title: "chore(hooks-kit): address Claude Code v2.1.300 changelog", body: "Refs: #7407" }, + }, + ], + handoffs: [{ key: "changelog:2.1.300..2.1.300:adopt:plugins/x/a.sh", title: "t", body: "b" }], + ...over, +}); +const ctx = { release: "2.1.300", baseSha: BASE }; + +test("the newest release is the highest version, skipping drafts, prereleases and other tags", () => { + const releases = [ + { tag_name: "v2.1.99" }, + { tag_name: "v2.1.300" }, + { tag_name: "v2.1.301", prerelease: true }, + { tag_name: "v9.9.9", draft: true }, + { tag_name: "nightly" }, + ]; + assert.equal(newestRelease(releases), "2.1.300"); + assert.equal(newestRelease([]), ""); +}); + +test("the per-day cap counts only start records from the last 24 hours", () => { + const now = Date.parse("2026-11-02T12:00:00Z"); + const artifacts = [ + { name: "release-intake-started-2.1.300-1", created_at: "2026-11-02T01:00:00Z" }, + { name: "release-intake-started-2.1.299-2", created_at: "2026-11-01T11:00:00Z" }, + { name: "release-intake-done-2.1.300", created_at: "2026-11-02T02:00:00Z" }, + ]; + assert.equal(startedInWindow(artifacts, now), 1); +}); + +test("a valid plan passes and a stopped plan acts on nothing", () => { + assert.equal(validatePlan(plan(), ctx).branches.length, 1); + assert.deepEqual(validatePlan(plan({ status: "stopped" }), ctx), { branches: [], handoffs: [] }); +}); + +test("a plan for another release, base or branch name is refused", () => { + assert.throws(() => validatePlan(plan({ range: "2.1.299..2.1.299" }), ctx), /range/); + assert.throws(() => validatePlan(plan({ base_sha: HEAD }), ctx), /base_sha/); + const renamed = plan(); + renamed.branches[0].branch = "main"; + assert.throws(() => validatePlan(renamed, ctx), /branch is not changelog\/hooks-kit\/v2\.1\.300/); + const titled = plan(); + titled.branches[0].pr.title = "feat(other): x"; + assert.throws(() => validatePlan(titled, ctx), /title/); + assert.throws(() => validatePlan(plan({ handoffs: [{ key: "changelog:2.1.1..2.1.1:adopt:x", title: "t", body: "b" }] }), ctx), /key/); + assert.equal(branchFor("hooks-kit", "2.1.300"), "changelog/hooks-kit/v2.1.300"); +}); + +test("a commit outside its plugin, or writing a link or submodule, is refused", () => { + const ok = ":000000 100644 0000 1111 A\tplugins/hooks-kit/SKILL.md\n:000000 100644 0000 2222 A\t.changes/hooks-kit/x.md"; + assert.doesNotThrow(() => checkRawLog(ok, "hooks-kit")); + assert.throws(() => checkRawLog(":100644 100644 1 2 M\tplugins/other/SKILL.md", "hooks-kit"), /outside/); + assert.throws(() => checkRawLog(":100644 100644 1 2 M\t.github/workflows/x.yml", "hooks-kit"), /outside/); + assert.throws(() => checkRawLog(":000000 120000 0 2 A\tplugins/hooks-kit/link", "hooks-kit"), /mode 120000/); + assert.throws(() => checkRawLog(":000000 160000 0 2 A\tplugins/hooks-kit/sub", "hooks-kit"), /mode 160000/); +}); diff --git a/.github/workflows/release-intake.yml b/.github/workflows/release-intake.yml new file mode 100644 index 00000000000..da7cef263ec --- /dev/null +++ b/.github/workflows/release-intake.yml @@ -0,0 +1,672 @@ +name: release-intake + +# The release intake lane (#7407): for one Claude Code release, a model job +# runs `/harness-ops:changelog apply --ci `, which commits one local +# branch per owner plugin and writes ci-plan.json +# (plugins/harness-ops/skills/changelog/context/ci-mode.md). Scripted jobs +# then act on that plan with lanes App tokens from the token broker, which +# mints for this file on schedule and workflow_dispatch from main only, keyed +# on the `release` request field: +# push-branches (mutate-branch: contents write) pushes each branch whose +# commits change only plugins// and .changes//; +# publish (publish-artifact: pull-requests and issues write, contents read) +# opens one pull request per pushed branch and files each handoff once. +# The broker never grants contents and pull-requests write in one mint, nor +# merge or actions, so no job here can merge. +# +# A schedule run takes the newest Claude Code release; a dispatch names one. +# A release is skipped when a changelog//v branch or this +# workflow's release-intake-done- artifact already exists, since +# --ci never moves the read marker. At most limits.release-intake-per-day +# runs start in any 24 hours (docs/conventions/pr-pipeline.yaml). +# +# How to stop it: set the organization or repository lanes kill-switch +# variable to anything but "false"; every job checks it first and the broker +# reads it live. + +on: + # Daily, after maintenance-file-claude-code-release-issues (08:27), at a + # minute no other schedule here uses. + schedule: + - cron: "53 9 * * *" + workflow_dispatch: + inputs: + release: + description: The Claude Code release to take in, MAJOR.MINOR.PATCH. + required: true + type: string + +permissions: {} + +# One run at a time; a later run waits and is never cancelled. +concurrency: + group: release-intake + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + # Picks the release, skips one already processed, and applies the per-day + # cap. No model, no secret, a read-only token. + gate: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read # list this repository's artifacts for the done marker and the cap + contents: read # check out main and list the changelog branches + outputs: + proceed: ${{ steps.gate.outputs.proceed }} + release: ${{ steps.gate.outputs.release }} + base-sha: ${{ github.sha }} + env: + API_BUDGET_TOKEN: github-token + steps: + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: .base + persist-credentials: false + + - name: Set up Node + uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] + with: + node-version-file: .base/.node-version + + - name: Check the kill switch + id: kill + uses: ./.base/.github/actions/check-kill-switch # zizmor: ignore[self-repository] + with: + value: ${{ vars.CLAUDE_LANES_DISABLED }} + repo-value: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} + + - name: Report the kill switch + if: steps.kill.outputs.proceed != 'true' + env: + REASON: ${{ steps.kill.outputs.reason }} + run: echo "::notice::the lanes kill switch stops this run (reason ${REASON:-unreadable}); nothing read or written" + + # A dispatch, and any re-run, must come from a listed human; the broker + # checks the same, so this only keeps the model from running for nothing. + - name: Check who started the run + id: who + if: steps.kill.outputs.proceed == 'true' && (github.event_name == 'workflow_dispatch' || github.run_attempt != '1') + uses: ./.base/.github/actions/check-trusted-trigger # zizmor: ignore[self-repository] + with: + trusted-actors-path: .base/.github/standards/trusted-actors/trusted-actors.json + denied-actor-ids: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} + triggering-actor: ${{ github.triggering_actor }} + require-pr: "false" + require-kind: human + + - name: Pick the release + id: gate + if: >- + steps.kill.outputs.proceed == 'true' + && (steps.who.outcome == 'skipped' || steps.who.outputs.proceed == 'true') + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + RELEASE_INPUT: ${{ inputs.release }} + run: | + DAILY_CAP=$(yq '.limits."release-intake-per-day" // 2' .base/docs/conventions/pr-pipeline.yaml) + export DAILY_CAP + node .base/.github/scripts/release-intake/release-intake.mjs gate + + # The per-day cap counts these, so it counts runs that reached the model. + - name: Record the start + if: steps.gate.outputs.proceed == 'true' + env: + DIR: ${{ runner.temp }}/started + RELEASE: ${{ steps.gate.outputs.release }} + run: | + mkdir -p "$DIR" + printf '%s\n' "$RELEASE" >"$DIR/release.txt" + + - name: Upload the start record + if: steps.gate.outputs.proceed == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-intake-started-${{ steps.gate.outputs.release }}-${{ github.run_id }} + path: ${{ runner.temp }}/started/release.txt + retention-days: 2 + if-no-files-found: error + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token + + # The model, on ci-workflows' run-claude-cli, with the API key and a + # read-only GITHUB_TOKEN and nothing else: the skill needs Bash to commit, + # so this job holds no write token, and what it leaves is sealed to the + # scan-carrier key and opened only by the scan job. run-claude-cli runs the + # CLI with --restricted (no user, project or local settings); the plugins + # load from read-only copies staged from main before the root checkout, and + # every CLAUDE.md, AGENTS.md, .mcp.json and .claude/ in the checkout is + # removed from the working tree first (R13). + model: + needs: gate + if: needs.gate.outputs.proceed == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + cache-mode: read + permissions: + contents: read # check out main with history, to branch from it + outputs: + handoff: ${{ steps.run.outputs.handoff }} + lane-class: ${{ steps.run.outputs.lane-class }} + sealed-length: ${{ steps.seal.outputs.sealed-length }} + sealed-1: ${{ steps.seal.outputs.sealed-1 }} + sealed-2: ${{ steps.seal.outputs.sealed-2 }} + sealed-3: ${{ steps.seal.outputs.sealed-3 }} + sealed-4: ${{ steps.seal.outputs.sealed-4 }} + sealed-5: ${{ steps.seal.outputs.sealed-5 }} + sealed-6: ${{ steps.seal.outputs.sealed-6 }} + steps: + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.gate.outputs.base-sha }} + path: .base + persist-credentials: false + + - name: Set up Node + uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] + with: + node-version-file: .base/.node-version + + - name: Check the kill switch + id: kill + uses: ./.base/.github/actions/check-kill-switch # zizmor: ignore[self-repository] + with: + value: ${{ vars.CLAUDE_LANES_DISABLED }} + repo-value: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} + + # An existing route: the broker's pinned resolver refuses a config that + # names a route outside its fixed set. + - name: Resolve the model + id: models + if: steps.kill.outputs.proceed == 'true' + uses: ./.base/.github/actions/resolve-config # zizmor: ignore[self-repository] + with: + model-route: resolve-issue + base-path: .base + output-path: ${{ runner.temp }}/release-intake-models.json + + # Everything a later step of this job runs, copied out of main before + # the root checkout replaces .base: the sealer, its key, and the + # plugins the skill and its stage skills load. + - name: Stage the base files + if: steps.models.outcome == 'success' + env: + STAGE: ${{ runner.temp }}/release-intake-stage + run: | + mkdir -p "$STAGE/seal" "$STAGE/plugins" + cp .base/.github/actions/seal-lane-output/seal.mjs .base/.github/lane-keys/scan-carrier.pub "$STAGE/seal/" + for plugin in harness-ops planning implementation verification; do + cp -R ".base/plugins/$plugin" "$STAGE/plugins/" + printf '{"mcpServers":{}}\n' >"$STAGE/plugins/$plugin/.mcp.json" + done + [ -z "$(find "$STAGE" -type l -print -quit)" ] || { echo "::error::a staged plugin holds a symbolic link"; exit 1; } + + - name: Apply the Claude managed settings + if: steps.models.outcome == 'success' + uses: melodic-software/ci-workflows/.github/actions/apply-claude-managed-settings@2476d5dde8979eab34374150b7e4b0a2b99f4e88 # 2476d5d 2026-10-29 + + - name: Isolate the runner + if: steps.models.outcome == 'success' + uses: ./.base/.github/actions/setup-runner-isolation # zizmor: ignore[self-repository] + with: + install-subprocess-isolation: "true" + + # The skill branches from here. Nothing after this step runs from .base. + - name: Check out main with history + if: steps.models.outcome == 'success' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.gate.outputs.base-sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Prepare the working tree + id: prepare + if: steps.models.outcome == 'success' + env: + STAGE: ${{ runner.temp }}/release-intake-stage + RELEASE: ${{ needs.gate.outputs.release }} + MODEL: ${{ steps.models.outputs.model }} + run: | + [ -n "$MODEL" ] || { echo "::error::the resolve-issue route's model is default, which run-claude-cli cannot pass"; exit 1; } + git config user.name "release-intake" + git config user.email "release-intake@users.noreply.github.com" + printf '.lane/\n' >>.git/info/exclude + # Head instruction, settings and server files stay out of the + # session; skip-worktree keeps their removal out of every commit. + mapfile -t held < <(git ls-files -- ':(glob)**/CLAUDE.md' ':(glob)**/AGENTS.md' ':(glob)**/.mcp.json' ':(glob)**/.claude/**' CLAUDE.md AGENTS.md .mcp.json) + if ((${#held[@]})); then + git update-index --skip-worktree -- "${held[@]}" + rm -f -- "${held[@]}" + fi + mkdir -p .lane/memory + cp -R "$STAGE/plugins" .lane/plugins + chmod -R a-w .lane/plugins + cat >"$RUNNER_TEMP/release-intake-prompt.md" <>"$GITHUB_OUTPUT" + + - name: Open the model job's sealed payloads + uses: ./.base/.github/actions/open-lane-output # zizmor: ignore[self-repository] + with: + private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} + expected-key-ids: ${{ steps.key.outputs.id }} + job-id: model + max-bytes: | + plan.json=262144 + branches.bundle=393216 + directory: ${{ runner.temp }}/scan-in + sealed-length: ${{ needs.model.outputs.sealed-length }} + sealed-1: ${{ needs.model.outputs.sealed-1 }} + sealed-2: ${{ needs.model.outputs.sealed-2 }} + sealed-3: ${{ needs.model.outputs.sealed-3 }} + sealed-4: ${{ needs.model.outputs.sealed-4 }} + sealed-5: ${{ needs.model.outputs.sealed-5 }} + sealed-6: ${{ needs.model.outputs.sealed-6 }} + + # The plan's checks, and each branch's diff as text for the scan: the + # bundle itself is compressed, so its bytes say nothing to a scanner. + - name: Check the plan and write the diffs + id: check + env: + IN: ${{ runner.temp }}/scan-in + RELEASE: ${{ needs.gate.outputs.release }} + BASE_SHA: ${{ needs.gate.outputs.base-sha }} + run: | + [ -f "$IN/plan.json" ] || { echo "::error::the model job left no ci-plan.json"; exit 1; } + node --input-type=module -e ' + import { readFileSync } from "node:fs"; + import { validatePlan } from "./.base/.github/scripts/release-intake/release-intake.mjs"; + const plan = validatePlan(JSON.parse(readFileSync(process.env.IN + "/plan.json", "utf8")), + { release: process.env.RELEASE, baseSha: process.env.BASE_SHA }); + console.log(`${plan.branches.length} branches, ${plan.handoffs.length} handoffs`); + ' + : >"$IN/diffs.txt" + count=0 + if [ -f "$IN/branches.bundle" ]; then + git -C .base bundle verify "$IN/branches.bundle" + while read -r head; do + [[ $head =~ ^[0-9a-f]{40}$ ]] || exit 1 + git -C .base fetch --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" + git -C .base diff --no-renames --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" + count=$((count + 1)) + done < <(jq -r '.branches[]?.head_sha' "$IN/plan.json") + fi + echo "branches=$count" >>"$GITHUB_OUTPUT" + + - name: Scan the plan for credentials + id: scan-plan + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + with: + path: ${{ runner.temp }}/scan-in/plan.json + + - name: Scan the branch diffs for credentials + id: scan-diffs + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + with: + path: ${{ runner.temp }}/scan-in/diffs.txt + + - name: Upload the plan and the branches + id: upload + if: steps.scan-plan.outputs.clean == 'true' && steps.scan-diffs.outputs.clean == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-intake-plan-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/scan-in/plan.json + ${{ runner.temp }}/scan-in/branches.bundle + retention-days: 1 + if-no-files-found: error + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token + + # mutate-branch: contents write only. Pushes each planned branch that does + # not exist yet, after checking its commits stay inside its plugin. + push-branches: + needs: [gate, scan] + if: ${{ !cancelled() && needs.scan.outputs.clean == 'true' && needs.scan.outputs.branches != '0' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read # check out main + id-token: write # prove to the token broker which workflow this is + outputs: + skip-reason: ${{ steps.lane-token.outputs.skip-reason }} + steps: + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.gate.outputs.base-sha }} + path: .base + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node + uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] + with: + node-version-file: .base/.node-version + + - name: Check the kill switch + id: kill + uses: ./.base/.github/actions/check-kill-switch # zizmor: ignore[self-repository] + with: + value: ${{ vars.CLAUDE_LANES_DISABLED }} + repo-value: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} + + - name: Resolve the config + id: config + if: steps.kill.outputs.proceed == 'true' + env: + API_BUDGET_TOKEN: github-token + uses: ./.base/.github/actions/resolve-config # zizmor: ignore[self-repository] + with: + lane: release-intake + activity: push-changelog-branches + base-path: .base + output-path: ${{ runner.temp }}/resolved-config.json + + # A kill switch turned on since the step above is a designed stop + # (pr-run-activity.md, The lane token broker). + - name: Request the lane token + id: lane-token + if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' + uses: ./.base/.github/actions/request-lane-token # zizmor: ignore[self-repository] + with: + broker-url: ${{ vars.LANES_BROKER_URL }} + audience: ${{ vars.LANES_BROKER_AUDIENCE }} + lane: release-intake + activity: push-changelog-branches + release: ${{ needs.gate.outputs.release }} + effect: ${{ steps.config.outputs.effect }} + contents: ${{ steps.config.outputs.contents }} + pull-requests: ${{ steps.config.outputs.pull-requests }} + issues: ${{ steps.config.outputs.issues }} + kill-switch-ok: "true" + + - name: Download the plan and the branches + if: steps.lane-token.outputs.token != '' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-intake-plan-${{ github.run_attempt }} + path: ${{ runner.temp }}/plan + + - name: Push the branches + if: steps.lane-token.outputs.token != '' + env: + GH_TOKEN: ${{ steps.lane-token.outputs.token }} + API_BUDGET_TOKEN: lanes-app + RELEASE: ${{ needs.gate.outputs.release }} + BASE_SHA: ${{ needs.gate.outputs.base-sha }} + PLAN_FILE: ${{ runner.temp }}/plan/plan.json + BUNDLE_FILE: ${{ runner.temp }}/plan/branches.bundle + REPO_DIR: ${{ github.workspace }}/.base + run: node .base/.github/scripts/release-intake/release-intake.mjs push + + - name: Revoke the lane token + if: always() && steps.lane-token.outputs.token != '' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.lane-token.outputs.token }} + run: | + status=$(printf 'Authorization: Bearer %s\n' "$GH_TOKEN" | + curl -sS -o /dev/null -w '%{http_code}' --max-time 30 -X DELETE -H @- \ + -H 'Accept: application/vnd.github+json' "$GITHUB_API_URL/installation/token" || true) + [ "$status" = 204 ] || echo "::warning::the lane token was not revoked (DELETE $status); it expires within the hour" + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: lanes-app github-token + + # publish-artifact: pull-requests and issues write, contents read. Opens a + # pull request for each branch at its planned head that has none, and files + # each handoff whose key no issue carries; then records the release done. + publish: + needs: [gate, scan, push-branches] + if: >- + ${{ !cancelled() && needs.scan.outputs.clean == 'true' + && (needs.push-branches.result == 'success' || needs.push-branches.result == 'skipped') + && needs.push-branches.outputs.skip-reason != 'kill-switch' }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read # check out main + id-token: write # prove to the token broker which workflow this is + steps: + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.gate.outputs.base-sha }} + path: .base + persist-credentials: false + + - name: Set up Node + uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] + with: + node-version-file: .base/.node-version + + - name: Check the kill switch + id: kill + uses: ./.base/.github/actions/check-kill-switch # zizmor: ignore[self-repository] + with: + value: ${{ vars.CLAUDE_LANES_DISABLED }} + repo-value: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} + + - name: Resolve the config + id: config + if: steps.kill.outputs.proceed == 'true' + env: + API_BUDGET_TOKEN: github-token + uses: ./.base/.github/actions/resolve-config # zizmor: ignore[self-repository] + with: + lane: release-intake + activity: publish-changelog-intake + base-path: .base + output-path: ${{ runner.temp }}/resolved-config.json + + - name: Request the lane token + id: lane-token + if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' + uses: ./.base/.github/actions/request-lane-token # zizmor: ignore[self-repository] + with: + broker-url: ${{ vars.LANES_BROKER_URL }} + audience: ${{ vars.LANES_BROKER_AUDIENCE }} + lane: release-intake + activity: publish-changelog-intake + release: ${{ needs.gate.outputs.release }} + effect: ${{ steps.config.outputs.effect }} + contents: ${{ steps.config.outputs.contents }} + pull-requests: ${{ steps.config.outputs.pull-requests }} + issues: ${{ steps.config.outputs.issues }} + kill-switch-ok: "true" + + - name: Download the plan + if: steps.lane-token.outputs.token != '' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-intake-plan-${{ github.run_attempt }} + path: ${{ runner.temp }}/plan + + - name: Open the pull requests and file the handoffs + id: publish + if: steps.lane-token.outputs.token != '' + env: + GH_TOKEN: ${{ steps.lane-token.outputs.token }} + API_BUDGET_TOKEN: lanes-app + RELEASE: ${{ needs.gate.outputs.release }} + BASE_SHA: ${{ needs.gate.outputs.base-sha }} + PLAN_FILE: ${{ runner.temp }}/plan/plan.json + run: node .base/.github/scripts/release-intake/release-intake.mjs publish + + - name: Revoke the lane token + if: always() && steps.lane-token.outputs.token != '' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.lane-token.outputs.token }} + run: | + status=$(printf 'Authorization: Bearer %s\n' "$GH_TOKEN" | + curl -sS -o /dev/null -w '%{http_code}' --max-time 30 -X DELETE -H @- \ + -H 'Accept: application/vnd.github+json' "$GITHUB_API_URL/installation/token" || true) + [ "$status" = 204 ] || echo "::warning::the lane token was not revoked (DELETE $status); it expires within the hour" + + # The gate's done marker: a later run skips this release even when its + # plan made no branch. + - name: Upload the done marker + if: steps.publish.outcome == 'success' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-intake-done-${{ needs.gate.outputs.release }} + path: ${{ runner.temp }}/plan/plan.json + retention-days: 90 + if-no-files-found: error + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: lanes-app github-token diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index 6ff6ec265c9..98089be87d0 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -650,6 +650,28 @@ activities: # sub-issue never splits again, and the issue's body is never edited. # Admission waits on an issue while a sub-issue is open. inputs: [issue] + push-changelog-branches: + # Pushes each changelog//v branch the release-intake + # model job's `/harness-ops:changelog apply --ci` committed, after + # checking every commit changes only plugins// and + # .changes// as regular files; skips a branch that exists. A + # script, no model. .github/workflows/release-intake.yml runs it. + script: .github/scripts/release-intake/release-intake.mjs + args: push + effect: mutate-branch + gating: advisory + # Reads the plan and the bundle the model wrote. + reads-untrusted: true + publish-changelog-intake: + # Opens one pull request per pushed branch at its planned head that has + # none, and files each plan handoff whose key no issue carries. A script, + # no model. .github/workflows/release-intake.yml runs it. + script: .github/scripts/release-intake/release-intake.mjs + args: publish + effect: publish-artifact + gating: advisory + # Posts the plan's titles and bodies, which the model wrote. + reads-untrusted: true lanes: # Started by the agent-ready label on an issue (.github/workflows/work-resolve-issue.yml). @@ -737,6 +759,16 @@ lanes: - activity: apply-verdict - activity: apply-refinement needs: [apply-verdict] + # Started daily and by a listed human's dispatch naming one Claude Code + # release (.github/workflows/release-intake.yml). Push and pull request + # open are separate jobs: the broker refuses contents and pull-requests + # write in one mint. + release-intake: + stage: intake + slots: + - activity: push-changelog-branches + - activity: publish-changelog-intake + needs: [push-changelog-branches] # The model, effort and turn budget of every model run (README.md, Models). # Aliases only, never full model IDs. A profile with no effort keeps its @@ -971,6 +1003,10 @@ limits: # stranded issues wait for the next one; far above it, queued runs go stale # on a lane workflow change. restarts-per-run: 10 + # release-intake runs that reach the model in any 24 hours (counted from + # its gate's start records, as red-main.daily-cap counts reverts). 2 is + # judgment: one scheduled run plus one dispatch a day. + release-intake-per-day: 2 # GitHub API margins per pool (`api:` with github-token, lanes-app, # graphql, owner-token, content-creation). limit-cap sets each pool's planned # budget, the header's x-ratelimit-limit at most the cap (github-token: diff --git a/docs/conventions/pr-pipeline/pr-pipeline.schema.json b/docs/conventions/pr-pipeline/pr-pipeline.schema.json index 257b890b89b..596543e649d 100644 --- a/docs/conventions/pr-pipeline/pr-pipeline.schema.json +++ b/docs/conventions/pr-pipeline/pr-pipeline.schema.json @@ -157,6 +157,13 @@ "default": 1, "description": "Further fix-ci dispatches on one PR head after its first fix-ci run ended in failure." }, + "release-intake-per-day": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 2, + "description": "release-intake runs that reach the model in any 24 hours." + }, "fix-docs-per-run": { "type": "integer", "minimum": 1, From 1dd2f1497ab498130789ad78b4c79f96a01a4e76 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:09:03 -0500 Subject: [PATCH 03/35] refactor(ci): rename the release intake lane to upstream-apply-changelog Rename the workflow, scripts, lane, limit, artifacts and handoff marker to upstream-apply-changelog (stage upstream, function apply-changelog), and add the reserved vocabulary entry the standards change adds. The gate now reads the unread releases from changelog-status.sh and skips a release only on its done artifact, so a run after a failed publish opens the missing pull request. lane-rules.json keeps its original formatting. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .github/actions/request-lane-token/action.yml | 2 +- .../actions/resolve-config/lane-grants.json | 2 +- .../actions/resolve-config/lane-rules.json | 68 ++++------------- .../upstream-apply-changelog.mjs} | 60 ++++++++++----- .../upstream-apply-changelog.test.mjs} | 21 +++++- .../vocabulary.json | 2 +- ...ntake.yml => upstream-apply-changelog.yml} | 73 ++++++++++--------- docs/conventions/pr-pipeline.yaml | 24 +++--- .../pr-pipeline/pr-pipeline.schema.json | 4 +- 9 files changed, 131 insertions(+), 125 deletions(-) rename .github/scripts/{release-intake/release-intake.mjs => upstream-apply-changelog/upstream-apply-changelog.mjs} (79%) rename .github/scripts/{release-intake/release-intake.test.mjs => upstream-apply-changelog/upstream-apply-changelog.test.mjs} (69%) rename .github/workflows/{release-intake.yml => upstream-apply-changelog.yml} (90%) diff --git a/.github/actions/request-lane-token/action.yml b/.github/actions/request-lane-token/action.yml index 868688f98d5..0e98051a563 100644 --- a/.github/actions/request-lane-token/action.yml +++ b/.github/actions/request-lane-token/action.yml @@ -47,7 +47,7 @@ inputs: default: "" release: description: >- - The Claude Code release a release-intake.yml job works, one + The Claude Code release a upstream-apply-changelog.yml job works, one MAJOR.MINOR.PATCH; sent as `release`, a string, which the broker needs from that caller and refuses from any other. Never with `pr-number`, `issue-number` or `dispatcher-run-id`. `token` mode only. diff --git a/.github/actions/resolve-config/lane-grants.json b/.github/actions/resolve-config/lane-grants.json index b06c93eebe7..05e0107c343 100644 --- a/.github/actions/resolve-config/lane-grants.json +++ b/.github/actions/resolve-config/lane-grants.json @@ -8,7 +8,7 @@ "pr-merge": { "merge-pr": "merge", "enable-auto-merge": "merge" }, "work-unblock": { "label-unblocked": "mutate-tracker" }, "intake-triage": { "apply-verdict": "mutate-tracker", "apply-refinement": "mutate-tracker" }, - "release-intake": { "push-changelog-branches": "mutate-branch", "publish-changelog-intake": "publish-artifact" } + "upstream-apply-changelog": { "apply-changelog-branches": "mutate-branch", "publish-changelog-intake": "publish-artifact" } }, "trusted-actors": [], "ci-workflows-commits": [] diff --git a/.github/actions/resolve-config/lane-rules.json b/.github/actions/resolve-config/lane-rules.json index 78f4631fc4d..dfe6b55df3a 100644 --- a/.github/actions/resolve-config/lane-rules.json +++ b/.github/actions/resolve-config/lane-rules.json @@ -1,76 +1,48 @@ { "work-resolve-issue": { "stage": "work", - "forbidden-effects": [ - "merge" - ], + "forbidden-effects": ["merge"], "never": "Merges, or marks its pull request ready" }, "intake-triage": { "stage": "intake", - "forbidden-effects": [ - "mutate-branch", - "publish-artifact", - "merge" - ], + "forbidden-effects": ["mutate-branch", "publish-artifact", "merge"], "never": "Writes to a branch" }, "work-unblock": { "stage": "work", - "forbidden-effects": [ - "mutate-branch", - "publish-artifact", - "merge" - ], + "forbidden-effects": ["mutate-branch", "publish-artifact", "merge"], "never": "Writes to a branch, or labels an issue that carries a hold label" }, "pr-refine": { "stage": "refine", - "forbidden-effects": [ - "merge" - ], + "forbidden-effects": ["merge"], "never": "Changes behavior" }, "pr-run-checks": { "stage": "verify", - "forbidden-effects": [ - "mutate-branch", - "merge" - ], + "forbidden-effects": ["mutate-branch", "merge"], "never": "Writes to the branch" }, "pr-review": { "stage": "verify", - "forbidden-effects": [ - "mutate-branch", - "merge" - ], + "forbidden-effects": ["mutate-branch", "merge"], "never": "Pushes fixes" }, "pr-review-security": { "stage": "verify", - "forbidden-effects": [ - "mutate-branch", - "merge" - ], + "forbidden-effects": ["mutate-branch", "merge"], "never": "Pushes fixes" }, "pr-verify": { "stage": "verify", - "forbidden-effects": [ - "mutate-branch", - "merge" - ], + "forbidden-effects": ["mutate-branch", "merge"], "never": "Pushes fixes" }, "pr-explain": { "stage": "verify", - "forbidden-effects": [ - "merge" - ], - "forbidden-gating": [ - "gate" - ], + "forbidden-effects": ["merge"], + "forbidden-gating": ["gate"], "never": "Gates" }, "pr-require-checks": { @@ -85,9 +57,7 @@ }, "pr-unblock": { "stage": "unblock", - "forbidden-effects": [ - "merge" - ], + "forbidden-effects": ["merge"], "never": "Merges" }, "pr-merge": { @@ -97,25 +67,17 @@ }, "post-merge-verify": { "stage": "post-merge", - "forbidden-effects": [ - "mutate-branch", - "merge" - ], + "forbidden-effects": ["mutate-branch", "merge"], "never": "Edits PRs" }, "post-merge-sweep-comments": { "stage": "post-merge", - "forbidden-effects": [ - "merge" - ], + "forbidden-effects": ["merge"], "never": "Reopens merged PRs" }, - "release-intake": { + "upstream-apply-changelog": { "stage": "intake", - "forbidden-effects": [ - "mutate-tracker", - "merge" - ], + "forbidden-effects": ["mutate-tracker", "merge"], "never": "Merges, or writes to main or a branch outside changelog//v" } } diff --git a/.github/scripts/release-intake/release-intake.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs similarity index 79% rename from .github/scripts/release-intake/release-intake.mjs rename to .github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index ab075c0773b..9ad2610deb6 100644 --- a/.github/scripts/release-intake/release-intake.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// release-intake: the scripted halves of .github/workflows/release-intake.yml. +// upstream-apply-changelog: the scripted halves of .github/workflows/upstream-apply-changelog.yml. // No model runs here. Subcommands: // gate picks the release, skips one already processed, applies the // per-day cap (read-only GITHUB_TOKEN with actions: read) @@ -19,9 +19,10 @@ const KEY = /^changelog:[0-9.]+\.\.[0-9.]+:[a-z-]+:[A-Za-z0-9_./-]{1,200}$/; const DAY_MS = 24 * 60 * 60 * 1000; export const branchFor = (plugin, release) => `changelog/${plugin}/v${release}`; -export const startedArtifact = (release, runId) => `release-intake-started-${release}-${runId}`; -export const doneArtifact = (release) => `release-intake-done-${release}`; -export const handoffMarker = (key) => ``; +export const startedArtifact = (release, runId) => `upstream-apply-changelog-started-${release}-${runId}`; +export const doneArtifact = (release) => `upstream-apply-changelog-done-${release}`; +export const alreadyProcessed = (artifacts) => (artifacts?.total_count ?? 0) > 0; +export const handoffMarker = (key) => ``; function output(name, value) { if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); @@ -43,6 +44,19 @@ async function api(path, { method = "GET", body, allow404 = false } = {}) { return res.status === 204 ? null : res.json(); } +// The unread releases, oldest first, from changelog-status.sh's status file +// (the file scripts/claude-code-release-issues.sh reads): `range: up to date` +// is none; `cap: within budget` lists them on `releases:`; any other shape +// (cap exceeded, range not computed) throws, so a person handles it. +export function releasesFromStatus(text) { + const value = (key) => text.match(new RegExp(`^${key}: (.*)$`, "m"))?.[1]?.trim() ?? ""; + if (value("range") === "up to date") return []; + if (value("cap") !== "within budget") throw new Error(`changelog-status.sh reports cap: ${value("cap") || "missing"}`); + const versions = value("releases").split(/[\s,]+/).filter(Boolean); + if (!versions.length || !versions.every((v) => RELEASE.test(v))) throw new Error("the status file's releases line is malformed"); + return versions; +} + // The newest release by version, from the release list (newest first is not // guaranteed, so it sorts). Tags are `v`; any other tag is skipped. export function newestRelease(releases) { @@ -61,7 +75,7 @@ export function newestRelease(releases) { // artifacts, as red-main counts reverts "in any 24 hours". export function startedInWindow(artifacts, now) { return artifacts.filter( - (a) => a?.name?.startsWith("release-intake-started-") && now - Date.parse(a.created_at) < DAY_MS, + (a) => a?.name?.startsWith("upstream-apply-changelog-started-") && now - Date.parse(a.created_at) < DAY_MS, ).length; } @@ -124,28 +138,38 @@ const git = (repo, args, extra = {}) => execFileSync("git", ["-C", repo, ...args async function gate(env) { const repo = env.GITHUB_REPOSITORY; - let release = env.RELEASE_INPUT ?? ""; + let candidates; if (env.EVENT_NAME === "workflow_dispatch") { - if (!RELEASE.test(release)) throw new Error("the release input is not one MAJOR.MINOR.PATCH"); + if (!RELEASE.test(env.RELEASE_INPUT ?? "")) throw new Error("the release input is not one MAJOR.MINOR.PATCH"); + candidates = [env.RELEASE_INPUT]; } else { - release = newestRelease(await api("/repos/anthropics/claude-code/releases?per_page=20")); - if (!release) throw new Error("no Claude Code release could be read"); + candidates = releasesFromStatus(readFileSync(env.STATUS_FILE, "utf8")).reverse(); } - output("release", release); - const done = await api(`/repos/${repo}/actions/artifacts?per_page=1&name=${doneArtifact(release)}`); - const refs = await api(`/repos/${repo}/git/matching-refs/heads/changelog/`); - const branches = (refs ?? []).filter((r) => r.ref.endsWith(`/v${release}`)); - if ((done?.total_count ?? 0) > 0 || branches.length > 0) { - console.log(`Claude Code ${release} was already processed; nothing to do`); + let release = ""; + for (const candidate of candidates) { + const done = await api(`/repos/${repo}/actions/artifacts?per_page=1&name=${doneArtifact(candidate)}`); + // Only the done marker skips a release: push skips a branch that exists + // and publish opens a pull request a pushed branch lacks, so a run after a + // failed publish finishes the work instead of stranding the branch. + if (alreadyProcessed(done)) { + console.log(`Claude Code ${candidate} was already processed`); + continue; + } + release = candidate; + break; + } + if (!release) { + console.log("no unprocessed Claude Code release; nothing to do"); output("proceed", "false"); return; } + output("release", release); const cap = Number(env.DAILY_CAP); if (!Number.isInteger(cap) || cap < 1) throw new Error("the per-day cap is not a positive whole number"); const recent = await api(`/repos/${repo}/actions/artifacts?per_page=100`); const started = startedInWindow(recent?.artifacts ?? [], Date.now()); if (started >= cap) { - console.log(`::notice::release-intake started ${started} runs in the last 24 hours, at its cap of ${cap}; Claude Code ${release} waits`); + console.log(`::notice::upstream-apply-changelog started ${started} runs in the last 24 hours, at its cap of ${cap}; Claude Code ${release} waits`); output("proceed", "false"); return; } @@ -158,7 +182,7 @@ async function push(env) { const pushed = []; if (plan.branches.length && !existsSync(BUNDLE_FILE)) throw new Error("the plan names branches and the run left no bundle"); for (const b of plan.branches) { - const local = `refs/release-intake/${b.plugin}`; + const local = `refs/upstream-apply-changelog/${b.plugin}`; git(repoDir, ["fetch", "--no-tags", BUNDLE_FILE, `${b.head_sha}:${local}`]); if (git(repoDir, ["rev-parse", local]) !== b.head_sha) throw new Error(`${b.branch} is not at its planned head`); if (git(repoDir, ["merge-base", baseSha, b.head_sha]) !== baseSha) throw new Error(`${b.branch} does not start at the base`); @@ -219,7 +243,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { const commands = { gate, push, publish }; const run = commands[process.argv[2]]; if (!run) { - console.error("usage: release-intake.mjs gate|push|publish"); + console.error("usage: upstream-apply-changelog.mjs gate|push|publish"); process.exit(2); } run(process.env).catch((error) => { diff --git a/.github/scripts/release-intake/release-intake.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs similarity index 69% rename from .github/scripts/release-intake/release-intake.test.mjs rename to .github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index 76ca4c87e83..bad5db03eff 100644 --- a/.github/scripts/release-intake/release-intake.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { branchFor, checkRawLog, newestRelease, startedInWindow, validatePlan } from "./release-intake.mjs"; +import { alreadyProcessed, branchFor, releasesFromStatus, checkRawLog, newestRelease, startedInWindow, validatePlan } from "./upstream-apply-changelog.mjs"; const BASE = "a".repeat(40); const HEAD = "b".repeat(40); @@ -39,9 +39,9 @@ test("the newest release is the highest version, skipping drafts, prereleases an test("the per-day cap counts only start records from the last 24 hours", () => { const now = Date.parse("2026-11-02T12:00:00Z"); const artifacts = [ - { name: "release-intake-started-2.1.300-1", created_at: "2026-11-02T01:00:00Z" }, - { name: "release-intake-started-2.1.299-2", created_at: "2026-11-01T11:00:00Z" }, - { name: "release-intake-done-2.1.300", created_at: "2026-11-02T02:00:00Z" }, + { name: "upstream-apply-changelog-started-2.1.300-1", created_at: "2026-11-02T01:00:00Z" }, + { name: "upstream-apply-changelog-started-2.1.299-2", created_at: "2026-11-01T11:00:00Z" }, + { name: "upstream-apply-changelog-done-2.1.300", created_at: "2026-11-02T02:00:00Z" }, ]; assert.equal(startedInWindow(artifacts, now), 1); }); @@ -72,3 +72,16 @@ test("a commit outside its plugin, or writing a link or submodule, is refused", assert.throws(() => checkRawLog(":000000 120000 0 2 A\tplugins/hooks-kit/link", "hooks-kit"), /mode 120000/); assert.throws(() => checkRawLog(":000000 160000 0 2 A\tplugins/hooks-kit/sub", "hooks-kit"), /mode 160000/); }); + +test("only the done marker skips a release, so a run after a failed publish finishes the work", () => { + assert.equal(alreadyProcessed({ total_count: 1, artifacts: [{ name: "upstream-apply-changelog-done-2.1.300" }] }), true); + assert.equal(alreadyProcessed({ total_count: 0, artifacts: [] }), false); + assert.equal(alreadyProcessed(null), false); +}); + +test("the status file gives the unread releases, none when up to date, and stops on anything else", () => { + assert.deepEqual(releasesFromStatus("range: 2.1.299..2.1.300\ncap: within budget\nreleases: 2.1.299 2.1.300\n"), ["2.1.299", "2.1.300"]); + assert.deepEqual(releasesFromStatus("range: up to date\n"), []); + assert.throws(() => releasesFromStatus("range: 2.1.1..2.1.90\ncap: exceeded\n"), /cap: exceeded/); + assert.throws(() => releasesFromStatus("range: a..b\ncap: within budget\nreleases: v2\n"), /malformed/); +}); diff --git a/.github/standards/github-actions-conventions/vocabulary.json b/.github/standards/github-actions-conventions/vocabulary.json index daa3f604d73..cd179f7a76e 100644 --- a/.github/standards/github-actions-conventions/vocabulary.json +++ b/.github/standards/github-actions-conventions/vocabulary.json @@ -117,7 +117,7 @@ { "name": "sync-standards", "status": "in-use" }, { "name": "verify-governance", "status": "in-use" } ], - "upstream": [] + "upstream": [ { "name": "apply-changelog", "status": "reserved", "purpose": "Apply an upstream release's changelog: a branch and pull request per owner plugin, and handoff issues for the rest." } ] }, "verbs": [ "address", diff --git a/.github/workflows/release-intake.yml b/.github/workflows/upstream-apply-changelog.yml similarity index 90% rename from .github/workflows/release-intake.yml rename to .github/workflows/upstream-apply-changelog.yml index da7cef263ec..e81a9634dc1 100644 --- a/.github/workflows/release-intake.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -1,4 +1,4 @@ -name: release-intake +name: upstream-apply-changelog # The release intake lane (#7407): for one Claude Code release, a model job # runs `/harness-ops:changelog apply --ci `, which commits one local @@ -14,10 +14,11 @@ name: release-intake # The broker never grants contents and pull-requests write in one mint, nor # merge or actions, so no job here can merge. # -# A schedule run takes the newest Claude Code release; a dispatch names one. -# A release is skipped when a changelog//v branch or this -# workflow's release-intake-done- artifact already exists, since -# --ci never moves the read marker. At most limits.release-intake-per-day +# A schedule run takes the newest unread release changelog-status.sh lists +# that is not done yet; a dispatch names one. +# A release is skipped only when this workflow's +# upstream-apply-changelog-done- artifact exists, since --ci never +# moves the read marker; existing branches and PRs are skipped per item. At most limits.upstream-apply-changelog-per-day # runs start in any 24 hours (docs/conventions/pr-pipeline.yaml). # # How to stop it: set the organization or repository lanes kill-switch @@ -40,7 +41,7 @@ permissions: {} # One run at a time; a later run waits and is never cancelled. concurrency: - group: release-intake + group: upstream-apply-changelog cancel-in-progress: false defaults: @@ -107,14 +108,20 @@ jobs: if: >- steps.kill.outputs.proceed == 'true' && (steps.who.outcome == 'skipped' || steps.who.outputs.proceed == 'true') + working-directory: .base env: GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} RELEASE_INPUT: ${{ inputs.release }} + STATUS_FILE: ${{ runner.temp }}/changelog-status.txt run: | - DAILY_CAP=$(yq '.limits."release-intake-per-day" // 2' .base/docs/conventions/pr-pipeline.yaml) + # The unread releases, as maintenance-file-claude-code-release-issues.yml reads them. + if [ "$EVENT_NAME" = schedule ]; then + bash plugins/harness-ops/skills/changelog/scripts/changelog-status.sh --ledger docs/upstream/claude-code.md >"$STATUS_FILE" + fi + DAILY_CAP=$(yq '.limits."upstream-apply-changelog-per-day" // 2' docs/conventions/pr-pipeline.yaml) export DAILY_CAP - node .base/.github/scripts/release-intake/release-intake.mjs gate + node .github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs gate # The per-day cap counts these, so it counts runs that reached the model. - name: Record the start @@ -130,7 +137,7 @@ jobs: if: steps.gate.outputs.proceed == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: release-intake-started-${{ steps.gate.outputs.release }}-${{ github.run_id }} + name: upstream-apply-changelog-started-${{ steps.gate.outputs.release }}-${{ github.run_id }} path: ${{ runner.temp }}/started/release.txt retention-days: 2 if-no-files-found: error @@ -198,7 +205,7 @@ jobs: with: model-route: resolve-issue base-path: .base - output-path: ${{ runner.temp }}/release-intake-models.json + output-path: ${{ runner.temp }}/upstream-apply-changelog-models.json # Everything a later step of this job runs, copied out of main before # the root checkout replaces .base: the sealer, its key, and the @@ -206,7 +213,7 @@ jobs: - name: Stage the base files if: steps.models.outcome == 'success' env: - STAGE: ${{ runner.temp }}/release-intake-stage + STAGE: ${{ runner.temp }}/upstream-apply-changelog-stage run: | mkdir -p "$STAGE/seal" "$STAGE/plugins" cp .base/.github/actions/seal-lane-output/seal.mjs .base/.github/lane-keys/scan-carrier.pub "$STAGE/seal/" @@ -239,13 +246,13 @@ jobs: id: prepare if: steps.models.outcome == 'success' env: - STAGE: ${{ runner.temp }}/release-intake-stage + STAGE: ${{ runner.temp }}/upstream-apply-changelog-stage RELEASE: ${{ needs.gate.outputs.release }} MODEL: ${{ steps.models.outputs.model }} run: | [ -n "$MODEL" ] || { echo "::error::the resolve-issue route's model is default, which run-claude-cli cannot pass"; exit 1; } - git config user.name "release-intake" - git config user.email "release-intake@users.noreply.github.com" + git config user.name "upstream-apply-changelog" + git config user.email "upstream-apply-changelog@users.noreply.github.com" printf '.lane/\n' >>.git/info/exclude # Head instruction, settings and server files stay out of the # session; skip-worktree keeps their removal out of every commit. @@ -257,7 +264,7 @@ jobs: mkdir -p .lane/memory cp -R "$STAGE/plugins" .lane/plugins chmod -R a-w .lane/plugins - cat >"$RUNNER_TEMP/release-intake-prompt.md" <"$RUNNER_TEMP/upstream-apply-changelog-prompt.md" </v branch the release-intake + apply-changelog-branches: + # Pushes each changelog//v branch the upstream-apply-changelog # model job's `/harness-ops:changelog apply --ci` committed, after # checking every commit changes only plugins// and # .changes// as regular files; skips a branch that exists. A - # script, no model. .github/workflows/release-intake.yml runs it. - script: .github/scripts/release-intake/release-intake.mjs + # script, no model. .github/workflows/upstream-apply-changelog.yml runs it. + script: .github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs args: push effect: mutate-branch gating: advisory @@ -665,8 +665,8 @@ activities: publish-changelog-intake: # Opens one pull request per pushed branch at its planned head that has # none, and files each plan handoff whose key no issue carries. A script, - # no model. .github/workflows/release-intake.yml runs it. - script: .github/scripts/release-intake/release-intake.mjs + # no model. .github/workflows/upstream-apply-changelog.yml runs it. + script: .github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs args: publish effect: publish-artifact gating: advisory @@ -760,15 +760,15 @@ lanes: - activity: apply-refinement needs: [apply-verdict] # Started daily and by a listed human's dispatch naming one Claude Code - # release (.github/workflows/release-intake.yml). Push and pull request + # release (.github/workflows/upstream-apply-changelog.yml). Push and pull request # open are separate jobs: the broker refuses contents and pull-requests # write in one mint. - release-intake: + upstream-apply-changelog: stage: intake slots: - - activity: push-changelog-branches + - activity: apply-changelog-branches - activity: publish-changelog-intake - needs: [push-changelog-branches] + needs: [apply-changelog-branches] # The model, effort and turn budget of every model run (README.md, Models). # Aliases only, never full model IDs. A profile with no effort keeps its @@ -1003,10 +1003,10 @@ limits: # stranded issues wait for the next one; far above it, queued runs go stale # on a lane workflow change. restarts-per-run: 10 - # release-intake runs that reach the model in any 24 hours (counted from + # upstream-apply-changelog runs that reach the model in any 24 hours (counted from # its gate's start records, as red-main.daily-cap counts reverts). 2 is # judgment: one scheduled run plus one dispatch a day. - release-intake-per-day: 2 + upstream-apply-changelog-per-day: 2 # GitHub API margins per pool (`api:` with github-token, lanes-app, # graphql, owner-token, content-creation). limit-cap sets each pool's planned # budget, the header's x-ratelimit-limit at most the cap (github-token: diff --git a/docs/conventions/pr-pipeline/pr-pipeline.schema.json b/docs/conventions/pr-pipeline/pr-pipeline.schema.json index 596543e649d..05c76256952 100644 --- a/docs/conventions/pr-pipeline/pr-pipeline.schema.json +++ b/docs/conventions/pr-pipeline/pr-pipeline.schema.json @@ -157,12 +157,12 @@ "default": 1, "description": "Further fix-ci dispatches on one PR head after its first fix-ci run ended in failure." }, - "release-intake-per-day": { + "upstream-apply-changelog-per-day": { "type": "integer", "minimum": 1, "maximum": 10, "default": 2, - "description": "release-intake runs that reach the model in any 24 hours." + "description": "upstream-apply-changelog runs that reach the model in any 24 hours." }, "fix-docs-per-run": { "type": "integer", From 3fc0797644fc14190ef409c070fdf73710f993d5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:12:38 -0500 Subject: [PATCH 04/35] fix(ci): read the cap line changelog-status.sh prints The cap line carries a count suffix, so match its prefix; releases are space-separated, oldest first. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog/upstream-apply-changelog.mjs | 6 ++++-- .../upstream-apply-changelog.test.mjs | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index 9ad2610deb6..36bc0f260fd 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -51,8 +51,10 @@ async function api(path, { method = "GET", body, allow404 = false } = {}) { export function releasesFromStatus(text) { const value = (key) => text.match(new RegExp(`^${key}: (.*)$`, "m"))?.[1]?.trim() ?? ""; if (value("range") === "up to date") return []; - if (value("cap") !== "within budget") throw new Error(`changelog-status.sh reports cap: ${value("cap") || "missing"}`); - const versions = value("releases").split(/[\s,]+/).filter(Boolean); + // changelog-status.sh:59 prints `cap: within budget ( releases / items)`; + // :57 prints `releases:` space-separated, oldest first. + if (!value("cap").startsWith("within budget")) throw new Error(`changelog-status.sh reports cap: ${value("cap") || "missing"}`); + const versions = value("releases").split(/ +/).filter(Boolean); if (!versions.length || !versions.every((v) => RELEASE.test(v))) throw new Error("the status file's releases line is malformed"); return versions; } diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index bad5db03eff..b424fdc256a 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -80,8 +80,8 @@ test("only the done marker skips a release, so a run after a failed publish fini }); test("the status file gives the unread releases, none when up to date, and stops on anything else", () => { - assert.deepEqual(releasesFromStatus("range: 2.1.299..2.1.300\ncap: within budget\nreleases: 2.1.299 2.1.300\n"), ["2.1.299", "2.1.300"]); + assert.deepEqual(releasesFromStatus("range: 2.1.299..2.1.300 (2 releases, 5 core items)\nreleases: 2.1.299 2.1.300\ncap: within budget (2 releases / 5 items)\n"), ["2.1.299", "2.1.300"]); assert.deepEqual(releasesFromStatus("range: up to date\n"), []); - assert.throws(() => releasesFromStatus("range: 2.1.1..2.1.90\ncap: exceeded\n"), /cap: exceeded/); + assert.throws(() => releasesFromStatus("range: 2.1.1..2.1.90 (90 releases, 400 core items)\ncap: exceeded (90 releases / 400 items)\n"), /cap: exceeded/); assert.throws(() => releasesFromStatus("range: a..b\ncap: within budget\nreleases: v2\n"), /malformed/); }); From 983cb67f4068410596d75e733839c37a4a826ea1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:14:50 -0500 Subject: [PATCH 05/35] fix(ci): put upstream-apply-changelog in the upstream stage The schema's stage enum lists upstream, the scheduled producer that files work into intake, and the lane's name carries that stage word. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .github/actions/resolve-config/lane-rules.json | 2 +- docs/conventions/pr-pipeline.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/resolve-config/lane-rules.json b/.github/actions/resolve-config/lane-rules.json index dfe6b55df3a..cb593b0b9e3 100644 --- a/.github/actions/resolve-config/lane-rules.json +++ b/.github/actions/resolve-config/lane-rules.json @@ -76,7 +76,7 @@ "never": "Reopens merged PRs" }, "upstream-apply-changelog": { - "stage": "intake", + "stage": "upstream", "forbidden-effects": ["mutate-tracker", "merge"], "never": "Merges, or writes to main or a branch outside changelog//v" } diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index a9c15c4c374..a7a88d0f79c 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -764,7 +764,7 @@ lanes: # open are separate jobs: the broker refuses contents and pull-requests # write in one mint. upstream-apply-changelog: - stage: intake + stage: upstream slots: - activity: apply-changelog-branches - activity: publish-changelog-intake From 5caabf51ea483e8ff8ec6624a5f9c8f772380efb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:25:02 -0500 Subject: [PATCH 06/35] feat(ci): register upstream-apply-changelog in the lane lists and tests Add the workflow to the log-api-budget job table, the kill-switch-ok caller list, the read-only cache list and the lane-funding catalog; give live-config a grant test for both activities and request-lane-token release cases (valid, malformed, with another id, check mode). Regenerate the hold floor, which now gates the workflow. Document the release field in the request-lane-token README and the broker body and refusal rows. The workflow's kill-switch-ok is a YAML boolean, publish skips on any push-branches skip-reason, scan and push-branches restore the cache read-only, and a missing space in the scan job's outputs is fixed. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .../log-api-budget/log-api-budget.test.mjs | 6 +++ .github/actions/request-lane-token/README.md | 5 ++- .github/actions/request-lane-token/action.yml | 2 +- .../request-lane-token.test.mjs | 40 ++++++++++++++++++- .../actions/resolve-config/callers.test.mjs | 3 ++ .../resolve-config/live-config.test.mjs | 12 ++++++ .github/lane-funding.json | 3 +- .github/scripts/pr-merge/merge-holds.mjs | 1 + .github/workflows/pr-require-checks.yml | 2 +- .github/workflows/pr-review-hosted.yml | 1 + .../workflows/pr-review-security-hosted.yml | 1 + .../workflows/upstream-apply-changelog.yml | 8 ++-- docs/conventions/pr-pipeline.yaml | 2 + docs/conventions/pr-pipeline/README.md | 2 +- .../pr-pipeline/pr-run-activity.md | 14 +++++-- 15 files changed, 88 insertions(+), 14 deletions(-) diff --git a/.github/actions/log-api-budget/log-api-budget.test.mjs b/.github/actions/log-api-budget/log-api-budget.test.mjs index fcc51c3e5b6..c6e808ad0a5 100644 --- a/.github/actions/log-api-budget/log-api-budget.test.mjs +++ b/.github/actions/log-api-budget/log-api-budget.test.mjs @@ -1037,6 +1037,12 @@ test("each heavy lane job names its API_BUDGET_TOKEN and ends with the log-api-b // own notice (a key, and no other action from a checkout); both pinned below. "pr-run-activity-read.yml": { "broker-check": "github-token", "issue-fields": "github-token", summary: "github-token", report: "github-token" }, "pr-run-activity-write.yml": { report: "github-token" }, + "upstream-apply-changelog.yml": { + gate: "github-token", + scan: "github-token", + "push-branches": ["lanes-app", "lanes-app github-token"], + publish: ["lanes-app", "lanes-app github-token"], + }, }; for (const [file, tokens] of Object.entries(jobs)) { const text = readFileSync(new URL(`../../workflows/${file}`, import.meta.url), "utf8"); diff --git a/.github/actions/request-lane-token/README.md b/.github/actions/request-lane-token/README.md index f03a5772391..1cdf72ddc33 100644 --- a/.github/actions/request-lane-token/README.md +++ b/.github/actions/request-lane-token/README.md @@ -21,6 +21,7 @@ run, and mints nothing ([Check mode](#check-mode)). | `lane`, `activity` | Sent to the broker; the lane must be the caller workflow's file stem | | `pr-number` | `token` mode: the gated PR; sent as `pr_number` when set, which the broker needs off `pull_request` | | `issue-number` | `token` mode: the issue an `issues` lane works; sent as `issue_number` when set, which the broker needs on an `issues` event. Never with `pr-number` or `dispatcher-run-id` | +| `release` | `token` mode: the Claude Code release an `upstream-apply-changelog.yml` job works, one `MAJOR.MINOR.PATCH`; sent as `release`, a string, when set, which the broker needs from that caller and refuses from any other. Anything else fails red. Never with `pr-number`, `issue-number` or `dispatcher-run-id` | | `dispatcher-run-id` | `token` mode: the `pr-unblock-dispatch.yml` run that dispatched this one; sent as `dispatcher_run_id` when set. The broker needs it to trust a run sent by `github-actions[bot]` | | `effect`, `contents`, `pull-requests`, `issues`, `actions` | `token` mode: this job's own `resolve-config` outputs, compared with the broker's answer. `actions` is `read` or empty; empty expects no `actions` permission | | `superseded-ok` | `true` ends green, with a `lane-token-superseded` warning and no token, on a 403 whose reason is `workflow-modified` (a newer copy of the caller workflow on the default branch, whose run does the work). Default `false` | @@ -44,7 +45,7 @@ run, and mints nothing ([Check mode](#check-mode)). each of `contents`, `pull-requests` and `issues`, or when the job lacks `id-token: write`. 2. Requests an OIDC token for `audience` and masks it. 3. Sends a `POST` to `broker-url` with `Authorization: Bearer ` and the body - `{"lane", "activity", "pr_number"?, "issue_number"?, "dispatcher_run_id"?}`. It retries, up to + `{"lane", "activity", "pr_number"?, "issue_number"?, "release"?, "dispatcher_run_id"?}`. It retries, up to three times and each with a fresh OIDC token, only two 503s that come before the broker reserves the job's mint, as GitHub's [rate-limit guidance](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit) @@ -135,7 +136,7 @@ controls that job, so this adds nothing it could not already do. ## Check mode With `mode: check` the action takes only `broker-url`, `audience`, `lane` and `activity`; a -`pr-number`, `issue-number`, effect or grant fails it red, so the read file's check job never carries the write +`pr-number`, `issue-number`, `release`, effect or grant fails it red, so the read file's check job never carries the write path's inputs. It posts to `broker-url`, retrying a 503 `github-unavailable` or `github-rate-limited` as in step 3, with its final `/token` replaced by `/check` (a URL that does not match `^https://[^/]+/(.+/)?token$` fails red), with the body `{"lane", "activity"}`, and passes only on a 200 whose `decision` is `allow` and whose `lane` and `activity` are its own. Any other answer fails red diff --git a/.github/actions/request-lane-token/action.yml b/.github/actions/request-lane-token/action.yml index 0e98051a563..7bc72ede99d 100644 --- a/.github/actions/request-lane-token/action.yml +++ b/.github/actions/request-lane-token/action.yml @@ -47,7 +47,7 @@ inputs: default: "" release: description: >- - The Claude Code release a upstream-apply-changelog.yml job works, one + The Claude Code release an upstream-apply-changelog.yml job works, one MAJOR.MINOR.PATCH; sent as `release`, a string, which the broker needs from that caller and refuses from any other. Never with `pr-number`, `issue-number` or `dispatcher-run-id`. `token` mode only. diff --git a/.github/actions/request-lane-token/request-lane-token.test.mjs b/.github/actions/request-lane-token/request-lane-token.test.mjs index 3b870a88d18..5ae8e94b6cd 100644 --- a/.github/actions/request-lane-token/request-lane-token.test.mjs +++ b/.github/actions/request-lane-token/request-lane-token.test.mjs @@ -685,7 +685,45 @@ for (const [name, inputs] of [ test("check mode refuses an issue number before the broker request", () => { const run = request({ MODE: "check", EFFECT: "", CONTENTS: "", PULL_REQUESTS: "", ISSUES: "", PR_NUMBER: "", ISSUE_NUMBER: "77" }); assert.equal(run.status, 1); - assert.match(run.stdout, /::error::check mode takes no effect, grant, PR or issue number, or dispatcher run id/); + assert.match(run.stdout, /::error::check mode takes no effect, grant, PR or issue number, release, or dispatcher run id/); + assert.equal(run.body, null); +}); + +const RELEASE_LANE = { LANE: "upstream-apply-changelog", ACTIVITY: "apply-changelog-branches", PR_NUMBER: "" }; + +test("a release is sent as release, a string, with no pr_number or issue_number", () => { + const run = request({ ...RELEASE_LANE, RELEASE: "2.1.312" }); + assert.equal(run.status, 0, run.stdout); + assert.deepEqual(JSON.parse(run.body), { lane: "upstream-apply-changelog", activity: "apply-changelog-branches", release: "2.1.312" }); + assert.match(run.output, /^token=ghs_lane$/m); +}); + +for (const value of ["2.1", "2.1.312.1", "v2.1.312", "02.1.312", "2.01.312", "2.1.0312", "2.1.3a", "2.1.312 ", "12345.1.1", "1.1.1234567"]) { + test(`a malformed release (${JSON.stringify(value)}) fails red before the broker request`, () => { + const run = request({ ...RELEASE_LANE, RELEASE: value }); + assert.equal(run.status, 1); + assert.match(run.stdout, /::error::the release must be one MAJOR\.MINOR\.PATCH/); + assert.equal(run.body, null); + }); +} + +for (const [name, inputs] of [ + ["a PR number", { PR_NUMBER: "42" }], + ["an issue number", { ISSUE_NUMBER: "77" }], + ["a dispatcher run id", { DISPATCHER_RUN_ID: "38155572048" }], +]) { + test(`a release with ${name} fails red before the broker request`, () => { + const run = request({ ...RELEASE_LANE, RELEASE: "2.1.312", ...inputs }); + assert.equal(run.status, 1); + assert.match(run.stdout, /::error::a release goes without a PR number, issue number or dispatcher run id/); + assert.equal(run.body, null); + }); +} + +test("check mode refuses a release before the broker request", () => { + const run = request({ MODE: "check", EFFECT: "", CONTENTS: "", PULL_REQUESTS: "", ISSUES: "", PR_NUMBER: "", RELEASE: "2.1.312" }); + assert.equal(run.status, 1); + assert.match(run.stdout, /::error::check mode takes no effect, grant, PR or issue number, release, or dispatcher run id/); assert.equal(run.body, null); }); diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index c1686cc478f..63590cb2647 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -7570,6 +7570,8 @@ test("every kill-switch-ok caller runs no token step and no dependent job on a k "work-resolve-issue.yml:apply-issue-patch -> pr-run-activity-write.yml", "work-resolve-issue.yml:report-outcome -> pr-run-activity-write.yml", "work-resolve-issue.yml:report-skip -> pr-run-activity-write.yml", + "upstream-apply-changelog.yml:push-branches", + "upstream-apply-changelog.yml:publish", ].sort()); }); @@ -7737,6 +7739,7 @@ test("every job that runs or reads untrusted head content has a read-only Action "intake-triage.yml": ["refine-issue", "refine-issue-subscription"], "pr-review-main.yml": ["review", "review-subscription", "review-fallback"], "pr-verify.yml": ["check-intent", "check-intent-subscription", "check-intent-fallback"], + "upstream-apply-changelog.yml": ["model", "scan", "push-branches"], }; for (const [file, names] of Object.entries(readOnly)) { const { jobs } = workflow(file); diff --git a/.github/actions/resolve-config/live-config.test.mjs b/.github/actions/resolve-config/live-config.test.mjs index b29f70dbfe8..575fddf1c65 100644 --- a/.github/actions/resolve-config/live-config.test.mjs +++ b/.github/actions/resolve-config/live-config.test.mjs @@ -651,6 +651,18 @@ test("live config: label-unblocked may only write issues", () => { }); }); +test("live config: upstream-apply-changelog pushes with contents write only and publishes without it", () => { + const grant = (activity) => resolveGrant(FILES, { lane: "upstream-apply-changelog", activity, configPath: CONFIG_PATH }); + assert.deepEqual(grant("apply-changelog-branches"), { + effect: "mutate-branch", + grant: { contents: "write", "pull-requests": "read", issues: "read" }, + }); + assert.deepEqual(grant("publish-changelog-intake"), { + effect: "publish-artifact", + grant: { contents: "read", "pull-requests": "write", issues: "write" }, + }); +}); + test("live config: fix-docs applies to a Markdown change and waits on do-not-merge", () => { assert.equal(selected("pr-refine", "fix-docs").applies, true); assert.equal( diff --git a/.github/lane-funding.json b/.github/lane-funding.json index 80152adaeab..4ee8b5530fb 100644 --- a/.github/lane-funding.json +++ b/.github/lane-funding.json @@ -28,7 +28,8 @@ { "id": "resolve-issue", "workflow": "work-resolve-issue.yml", "launcher": "run-claude-cli", "triggers": ["issues"], "gate": "author", "currentSecrets": ["ISSUE_LANE_CLAUDE_API_KEY"] }, { "id": "resolve-issue-subscription", "workflow": "work-resolve-issue.yml", "launcher": "run-claude-cli", "triggers": ["issues"], "gate": "author", "currentSecrets": ["ISSUE_LANE_CLAUDE_CODE_OAUTH_TOKEN"] }, { "id": "resolve-issue-handoff", "workflow": "work-resolve-issue.yml", "launcher": "run-claude-cli", "triggers": ["issues"], "gate": "author", "currentSecrets": ["FALLBACK_CLAUDE_CODE_OAUTH_TOKEN"] }, - { "id": "resolve-issue-fallback", "workflow": "work-resolve-issue.yml", "launcher": "run-claude-cli", "triggers": ["issues"], "gate": "author", "currentSecrets": ["FALLBACK_CLAUDE_CODE_OAUTH_TOKEN"] } + { "id": "resolve-issue-fallback", "workflow": "work-resolve-issue.yml", "launcher": "run-claude-cli", "triggers": ["issues"], "gate": "author", "currentSecrets": ["FALLBACK_CLAUDE_CODE_OAUTH_TOKEN"] }, + { "id": "model", "workflow": "upstream-apply-changelog.yml", "launcher": "run-claude-cli", "triggers": ["schedule", "workflow_dispatch"], "currentSecrets": ["ISSUE_LANE_CLAUDE_API_KEY"] } ] }, { diff --git a/.github/scripts/pr-merge/merge-holds.mjs b/.github/scripts/pr-merge/merge-holds.mjs index a35965e9294..98fae65a0f7 100755 --- a/.github/scripts/pr-merge/merge-holds.mjs +++ b/.github/scripts/pr-merge/merge-holds.mjs @@ -405,6 +405,7 @@ export const HOLD_PATH_CLASSES = Object.freeze([ [".github/workflows/pr-unblock.yml", "gate"], [".github/workflows/pr-verify.yml", "gate"], [".github/workflows/release-publish-plugins.yml", "gate"], + [".github/workflows/upstream-apply-changelog.yml", "gate"], [".github/workflows/work-resolve-issue.yml", "gate"], [".github/workflows/work-unblock.yml", "gate"], // END hold-floor HOLD_PATH_CLASSES diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index b8622343f19..0061a1d7767 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -6792,7 +6792,7 @@ jobs: held_paths() { # The trust-gate paths, as bash patterns (`*` crosses `/`). # BEGIN hold-floor hold_patterns: generated by scripts/sync-hold-floor.py - hold_patterns=('.github/standards/trusted-actors/*' 'CODEOWNERS' '*/CODEOWNERS' 'docs/conventions/pr-pipeline.yaml' '.gitmodules' '*/.gitmodules' 'plugins/source-control/skills/babysit-prs/scripts/babysit_risk.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_check_inputs.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_checks.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_classify.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_dependabot.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_merge.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_repo_config.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_run_cache.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_state.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_tracker_binding.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_util.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_whole_review.py' '.claude/*' '*/.claude/*' 'docs/conventions/source-control.yaml' '.claude-lane/*' '*/.claude-lane/*' '.mcp.json' '*/.mcp.json' 'CLAUDE.md' '*/CLAUDE.md' 'CLAUDE.local.md' '*/CLAUDE.local.md' 'AGENTS.md' '*/AGENTS.md' 'REVIEW.md' '.github/actions/check-trusted-trigger/*' '.github/actions/resolve-config/*' '.github/actions/check-kill-switch/*' '.github/actions/request-lane-token/*' '.github/actions/log-api-budget/rate-guard.mjs' '.github/actions/log-api-budget/rate-limits.json' '.github/scripts/pr-merge/merge-holds.mjs' '.github/scripts/pr-merge/enable-auto-merge.mjs' '.github/scripts/pr-merge/superseded.mjs' '.github/scripts/pr-merge/clear-escalations.mjs' '.github/scripts/pr-merge/carry-run-cache.mjs' '.github/scripts/pr-merge/gate-check.mjs' '.github/scripts/pr-merge/head-ref-map.mjs' '.github/scripts/pr-merge/lane-records.mjs' '.github/scripts/pr-merge/last-report.mjs' '.github/scripts/pr-merge/rate-stop.bash' '.github/scripts/pr-merge/retry-call.bash' '.github/scripts/pr-merge/refire-refused.mjs' '.github/scripts/pr-merge/rotation.mjs' '.github/scripts/pr-merge/sweep-summary.mjs' '.github/scripts/pr-merge/who.mjs' '.github/scripts/pr-merge/post-unarmed-status.mjs' '.github/scripts/pr-merge/verify-answers.mjs' '.github/scripts/pr-merge/listing-carry.mjs' '.github/actions/report-check-run/posting-run.mjs' 'scripts/sync-hold-floor.py' '.github/scripts/pr-unblock/address-feedback.mjs' '.github/scripts/pr-unblock/escalation-routes.mjs' '.github/scripts/pr-unblock/escalation-routes.json' '.github/scripts/pr-unblock/gate-source.mjs' '.github/scripts/pr-unblock/thread-scope.mjs' '.github/scripts/pr-unblock/retry-fetch.mjs' '.github/scripts/work-resolve-issue/report-outcome.mjs' '.github/scripts/work-resolve-issue/limits.mjs' '.github/scripts/work-resolve-issue/retry-fetch.mjs' '.github/scripts/pr-unblock/report-escalation.sh' '.github/scripts/pr-unblock/adjudicate-feedback.mjs' '.github/scripts/pr-unblock/publish-feedback.mjs' '.github/scripts/pr-unblock/request-budget.mjs' '.github/scripts/pr-verify/resolve-pr.mjs' '.github/scripts/pr-verify/write-verdict.mjs' '.github/scripts/intake-triage/admit.mjs' '.github/actions/select-trusted-text/select-trusted-text.mjs' 'lib/publish-gate.mjs' '.github/recurring-schedule.json' 'plugins/work-items/scripts/charge-words.json' '.github/scripts/pr-unblock/dependency-classify.mjs' '.github/scripts/apply-patch/apply-patch.mjs' '.github/scripts/apply-patch/predicates.mjs' '.github/scripts/apply-patch/protected-paths.mjs' '.github/scripts/apply-patch/retry-fetch.mjs' '.github/scripts/pr-unblock/fix-ci-context.mjs' '.github/scripts/pr-unblock/feedback-dispatch.mjs' '.github/scripts/pr-unblock/verify-notes.mjs' '.github/scripts/pr-unblock/limit-stop.mjs' '.github/scripts/lib/lane-outcome.mjs' '.github/scripts/pr-unblock/dispatch-record.mjs' '.github/scripts/pr-unblock/verify-cap.mjs' '.github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs' '.github/scripts/work-resolve-issue/stop-routes.json' '.github/scripts/work-resolve-issue/stop-routes.mjs' '.github/scripts/lib/stop-reason.mjs' '.github/scripts/lib/admission-comment.mjs' '.github/scripts/lib/done-evidence.mjs' '.github/scripts/pr-review-main/*' 'docs/conventions/instruction-exception-register/*' 'docs/conventions/untrusted-content/*' 'docs/standards/*' 'docs/review/*' '.github/workflows/dependabot-fragments.yml' '.github/workflows/eval-browser-dispatch.yml' '.github/workflows/intake-reenter.yml' '.github/workflows/intake-triage.yml' '.github/workflows/maintenance-audit-lane-runs.yml' '.github/workflows/maintenance-reconcile-lost-runs.yml' '.github/workflows/post-merge-demotion-alert.yml' '.github/workflows/post-merge-test-mutation.yml' '.github/workflows/pr-feedback-dispatch.yml' '.github/workflows/pr-merge.yml' '.github/workflows/pr-refine.yml' '.github/workflows/pr-require-checks.yml' '.github/workflows/pr-review-approved.yml' '.github/workflows/pr-review-comment-relay.yml' '.github/workflows/pr-review-hosted.yml' '.github/workflows/pr-review-main.yml' '.github/workflows/pr-review-security-hosted.yml' '.github/workflows/pr-run-activity-read.yml' '.github/workflows/pr-run-activity-write.yml' '.github/workflows/pr-unblock-dispatch.yml' '.github/workflows/pr-unblock.yml' '.github/workflows/pr-verify.yml' '.github/workflows/release-publish-plugins.yml' '.github/workflows/work-resolve-issue.yml' '.github/workflows/work-unblock.yml') + hold_patterns=('.github/standards/trusted-actors/*' 'CODEOWNERS' '*/CODEOWNERS' 'docs/conventions/pr-pipeline.yaml' '.gitmodules' '*/.gitmodules' 'plugins/source-control/skills/babysit-prs/scripts/babysit_risk.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_check_inputs.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_checks.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_classify.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_dependabot.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_merge.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_repo_config.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_run_cache.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_state.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_tracker_binding.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_util.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_whole_review.py' '.claude/*' '*/.claude/*' 'docs/conventions/source-control.yaml' '.claude-lane/*' '*/.claude-lane/*' '.mcp.json' '*/.mcp.json' 'CLAUDE.md' '*/CLAUDE.md' 'CLAUDE.local.md' '*/CLAUDE.local.md' 'AGENTS.md' '*/AGENTS.md' 'REVIEW.md' '.github/actions/check-trusted-trigger/*' '.github/actions/resolve-config/*' '.github/actions/check-kill-switch/*' '.github/actions/request-lane-token/*' '.github/actions/log-api-budget/rate-guard.mjs' '.github/actions/log-api-budget/rate-limits.json' '.github/scripts/pr-merge/merge-holds.mjs' '.github/scripts/pr-merge/enable-auto-merge.mjs' '.github/scripts/pr-merge/superseded.mjs' '.github/scripts/pr-merge/clear-escalations.mjs' '.github/scripts/pr-merge/carry-run-cache.mjs' '.github/scripts/pr-merge/gate-check.mjs' '.github/scripts/pr-merge/head-ref-map.mjs' '.github/scripts/pr-merge/lane-records.mjs' '.github/scripts/pr-merge/last-report.mjs' '.github/scripts/pr-merge/rate-stop.bash' '.github/scripts/pr-merge/retry-call.bash' '.github/scripts/pr-merge/refire-refused.mjs' '.github/scripts/pr-merge/rotation.mjs' '.github/scripts/pr-merge/sweep-summary.mjs' '.github/scripts/pr-merge/who.mjs' '.github/scripts/pr-merge/post-unarmed-status.mjs' '.github/scripts/pr-merge/verify-answers.mjs' '.github/scripts/pr-merge/listing-carry.mjs' '.github/actions/report-check-run/posting-run.mjs' 'scripts/sync-hold-floor.py' '.github/scripts/pr-unblock/address-feedback.mjs' '.github/scripts/pr-unblock/escalation-routes.mjs' '.github/scripts/pr-unblock/escalation-routes.json' '.github/scripts/pr-unblock/gate-source.mjs' '.github/scripts/pr-unblock/thread-scope.mjs' '.github/scripts/pr-unblock/retry-fetch.mjs' '.github/scripts/work-resolve-issue/report-outcome.mjs' '.github/scripts/work-resolve-issue/limits.mjs' '.github/scripts/work-resolve-issue/retry-fetch.mjs' '.github/scripts/pr-unblock/report-escalation.sh' '.github/scripts/pr-unblock/adjudicate-feedback.mjs' '.github/scripts/pr-unblock/publish-feedback.mjs' '.github/scripts/pr-unblock/request-budget.mjs' '.github/scripts/pr-verify/resolve-pr.mjs' '.github/scripts/pr-verify/write-verdict.mjs' '.github/scripts/intake-triage/admit.mjs' '.github/actions/select-trusted-text/select-trusted-text.mjs' 'lib/publish-gate.mjs' '.github/recurring-schedule.json' 'plugins/work-items/scripts/charge-words.json' '.github/scripts/pr-unblock/dependency-classify.mjs' '.github/scripts/apply-patch/apply-patch.mjs' '.github/scripts/apply-patch/predicates.mjs' '.github/scripts/apply-patch/protected-paths.mjs' '.github/scripts/apply-patch/retry-fetch.mjs' '.github/scripts/pr-unblock/fix-ci-context.mjs' '.github/scripts/pr-unblock/feedback-dispatch.mjs' '.github/scripts/pr-unblock/verify-notes.mjs' '.github/scripts/pr-unblock/limit-stop.mjs' '.github/scripts/lib/lane-outcome.mjs' '.github/scripts/pr-unblock/dispatch-record.mjs' '.github/scripts/pr-unblock/verify-cap.mjs' '.github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs' '.github/scripts/work-resolve-issue/stop-routes.json' '.github/scripts/work-resolve-issue/stop-routes.mjs' '.github/scripts/lib/stop-reason.mjs' '.github/scripts/lib/admission-comment.mjs' '.github/scripts/lib/done-evidence.mjs' '.github/scripts/pr-review-main/*' 'docs/conventions/instruction-exception-register/*' 'docs/conventions/untrusted-content/*' 'docs/standards/*' 'docs/review/*' '.github/workflows/dependabot-fragments.yml' '.github/workflows/eval-browser-dispatch.yml' '.github/workflows/intake-reenter.yml' '.github/workflows/intake-triage.yml' '.github/workflows/maintenance-audit-lane-runs.yml' '.github/workflows/maintenance-reconcile-lost-runs.yml' '.github/workflows/post-merge-demotion-alert.yml' '.github/workflows/post-merge-test-mutation.yml' '.github/workflows/pr-feedback-dispatch.yml' '.github/workflows/pr-merge.yml' '.github/workflows/pr-refine.yml' '.github/workflows/pr-require-checks.yml' '.github/workflows/pr-review-approved.yml' '.github/workflows/pr-review-comment-relay.yml' '.github/workflows/pr-review-hosted.yml' '.github/workflows/pr-review-main.yml' '.github/workflows/pr-review-security-hosted.yml' '.github/workflows/pr-run-activity-read.yml' '.github/workflows/pr-run-activity-write.yml' '.github/workflows/pr-unblock-dispatch.yml' '.github/workflows/pr-unblock.yml' '.github/workflows/pr-verify.yml' '.github/workflows/release-publish-plugins.yml' '.github/workflows/upstream-apply-changelog.yml' '.github/workflows/work-resolve-issue.yml' '.github/workflows/work-unblock.yml') # merge.diff-check.path-holds is false: only Dependabot stays held. [ "$AUTHOR_ID" = 49699333 ] || hold_patterns=() # END hold-floor hold_patterns diff --git a/.github/workflows/pr-review-hosted.yml b/.github/workflows/pr-review-hosted.yml index bcf54e34a61..e27b37dfb5a 100644 --- a/.github/workflows/pr-review-hosted.yml +++ b/.github/workflows/pr-review-hosted.yml @@ -373,6 +373,7 @@ jobs: .github/workflows/pr-unblock.yml .github/workflows/pr-verify.yml .github/workflows/release-publish-plugins.yml + .github/workflows/upstream-apply-changelog.yml .github/workflows/work-resolve-issue.yml .github/workflows/work-unblock.yml plugins/review/skills/security-review/** diff --git a/.github/workflows/pr-review-security-hosted.yml b/.github/workflows/pr-review-security-hosted.yml index 9e7acb8edc1..11538114d81 100644 --- a/.github/workflows/pr-review-security-hosted.yml +++ b/.github/workflows/pr-review-security-hosted.yml @@ -373,6 +373,7 @@ jobs: .github/workflows/pr-unblock.yml .github/workflows/pr-verify.yml .github/workflows/release-publish-plugins.yml + .github/workflows/upstream-apply-changelog.yml .github/workflows/work-resolve-issue.yml .github/workflows/work-unblock.yml plugins/review/skills/security-review/** diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index e81a9634dc1..e0cab9fefe4 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -360,6 +360,7 @@ jobs: if: ${{ !cancelled() && needs.model.outputs.sealed-length != '' }} runs-on: ubuntu-24.04 timeout-minutes: 15 + cache-mode: read permissions: contents: read # check out main outputs: @@ -474,6 +475,7 @@ jobs: if: ${{ !cancelled() && needs.scan.outputs.clean == 'true' && needs.scan.outputs.branches != '0' }} runs-on: ubuntu-24.04 timeout-minutes: 15 + cache-mode: read permissions: contents: read # check out main id-token: write # prove to the token broker which workflow this is @@ -528,7 +530,7 @@ jobs: contents: ${{ steps.config.outputs.contents }} pull-requests: ${{ steps.config.outputs.pull-requests }} issues: ${{ steps.config.outputs.issues }} - kill-switch-ok: "true" + kill-switch-ok: true - name: Download the plan and the branches if: steps.lane-token.outputs.token != '' @@ -576,7 +578,7 @@ jobs: if: >- ${{ !cancelled() && needs.scan.outputs.clean == 'true' && (needs.push-branches.result == 'success' || needs.push-branches.result == 'skipped') - && needs.push-branches.outputs.skip-reason != 'kill-switch' }} + && needs.push-branches.outputs.skip-reason == '' }} runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -628,7 +630,7 @@ jobs: contents: ${{ steps.config.outputs.contents }} pull-requests: ${{ steps.config.outputs.pull-requests }} issues: ${{ steps.config.outputs.issues }} - kill-switch-ok: "true" + kill-switch-ok: true - name: Download the plan if: steps.lane-token.outputs.token != '' diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index a7a88d0f79c..a210ef964f3 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -1548,6 +1548,8 @@ merge: class: gate - glob: ".github/workflows/release-publish-plugins.yml" class: gate + - glob: ".github/workflows/upstream-apply-changelog.yml" + class: gate - glob: ".github/workflows/work-resolve-issue.yml" class: gate - glob: ".github/workflows/work-unblock.yml" diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 53825b7b15c..cd1f072e4f4 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -3127,7 +3127,7 @@ Which GitHub identity each workflow in `.github/workflows/` writes as. GitHub's | Identity | Workflows | Why | |---|---|---| | `GITHUB_TOKEN` (`github-actions[bot]`) | every workflow not named below, and the reads and check runs of those that are | Its writes start no workflow run, a dispatch aside, which the lane sweep's digest and the monitoring lane rely on so their own issues start no lane; it has its own per-repository API quota ([ADR 0085](../../adr/0085-run-org-lanes-from-ci-workflows-under-one-lanes-app-with-central-grants.md)) | -| Lanes App `melodic-automation-lanes`, minted per job by the broker (`.github/actions/request-lane-token`) | `pr-merge.yml`, `pr-run-activity-write.yml` (which the write jobs of `pr-unblock`, `pr-refine` and `work-resolve-issue` call), `intake-triage.yml`, `work-unblock.yml` | Its pushes, labels and auto-merge arms must start the next lane's runs, which a `GITHUB_TOKEN` write does not; one App identity also lets the person tests tell a lane from a person ([lane write identity](../loop-lane/README.md#lane-write-identity)) | +| Lanes App `melodic-automation-lanes`, minted per job by the broker (`.github/actions/request-lane-token`) | `pr-merge.yml`, `pr-run-activity-write.yml` (which the write jobs of `pr-unblock`, `pr-refine` and `work-resolve-issue` call), `intake-triage.yml`, `work-unblock.yml`, `upstream-apply-changelog.yml` | Its pushes, labels and auto-merge arms must start the next lane's runs, which a `GITHUB_TOKEN` write does not; one App identity also lets the person tests tell a lane from a person ([lane write identity](../loop-lane/README.md#lane-write-identity)) | | Release App (`PLUGIN_RELEASE_APP_PRIVATE_KEY`, `actions/create-github-app-token`) | `release-publish-plugins.yml`, `dependabot-fragments.yml` | A pull request `GITHUB_TOKEN` opens or updates gets its `pull_request` runs only after approval, and these pull requests need theirs unattended | | Owner token | none | Only the owner's own sessions use it; the babysit scripts count its calls as the `owner-token` API pool | diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index a7a108ccb5b..70b5745e554 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -1971,7 +1971,13 @@ Both are organization variables declared in github-iac The request is `POST ` with `Authorization: Bearer ` and the JSON body `{"lane": "", "activity": "", "pr_number": , "dispatcher_run_id": }`; `pr_number` is the gate's PR and is required on `workflow_dispatch` and `workflow_run`, except from -`intake-triage.yml`. An `issues` request, allowed from `work-resolve-issue.yml`, `work-unblock.yml` +`intake-triage.yml` and `upstream-apply-changelog.yml`. A request from `upstream-apply-changelog.yml`, +on its daily `schedule` or a `workflow_dispatch`, sends `release` (the `release` input), the +Claude Code release it works as a `MAJOR.MINOR.PATCH` string, and none of `pr_number`, +`issue_number` or `dispatcher_run_id`; the broker refuses `release` from any other caller. Its +dispatch, and a re-run of either, must be started by a listed human. Each of its jobs mints one +effect: the broker never grants `contents` and `pull_requests` write in one mint, nor `merge` or +`actions`. An `issues` request, allowed from `work-resolve-issue.yml`, `work-unblock.yml` and `intake-triage.yml`, sends `issue_number` (the `issue-number` input) instead, and neither of the other two. From `work-resolve-issue.yml` its actor, the labeler, must be a listed human, or the lanes App; from the other two a listed human. `work-unblock.yml` (also on its hourly `schedule`) @@ -2015,17 +2021,17 @@ reason; no response at all is `broker-unreachable`. Broker reasons: | Status | Reason | Meaning | |---|---|---| -| 400 | (none) | The body is not `{lane, activity, pr_number?, issue_number?, dispatcher_run_id?}` with valid names and positive integers, or `issue_number` is missing on `issues` or present off it | +| 400 | (none) | The body is not `{lane, activity, pr_number?, issue_number?, release?, dispatcher_run_id?}` with valid names, positive integers and a `MAJOR.MINOR.PATCH` release, `issue_number` is missing on `issues` or present off it, or `release` is missing from `upstream-apply-changelog.yml` or present from any other caller | | 403 | `invalid-token`, `wrong-audience` | The OIDC token does not verify, or is for another audience | | 403 | `wrong-owner`, `repo-not-allowed`, `self-hosted-runner` | Not a melodic-software repository on the broker's allowlist, or not a GitHub-hosted runner | -| 403 | `event-not-allowed` | Not `pull_request` on `refs/pull//merge`, or `workflow_dispatch` or `workflow_run` on `refs/heads/main`, or `issues` on `refs/heads/main` from `work-resolve-issue.yml`, `work-unblock.yml` or `intake-triage.yml`, or `schedule` on `refs/heads/main` from `pr-merge.yml` or `work-unblock.yml` only | +| 403 | `event-not-allowed` | Not `pull_request` on `refs/pull//merge`, or `workflow_dispatch` or `workflow_run` on `refs/heads/main`, or `issues` on `refs/heads/main` from `work-resolve-issue.yml`, `work-unblock.yml` or `intake-triage.yml`, or `schedule` on `refs/heads/main` from `pr-merge.yml`, `work-unblock.yml` or `upstream-apply-changelog.yml` only | | 403 | `workflow-not-allowed` | The job is not this repository's `pr-run-activity-write.yml`, or the caller is not a lane under `lanes:` in the tip's `pr-pipeline.yaml` | | 403 | `workflow-modified` | The caller or `pr-run-activity-write.yml` differs from the default-branch tip (a report job, `work-resolve-issue` `report-outcome` or `pr-unblock` `report-escalation`, still mints when every main commit that changed the file is signed, GitHub-made, on a linear range and landed after run start, at most 50 minutes ago: [azure-iac#39](https://github.com/melodic-software/azure-iac/pull/39)) | | 403 | `workflow-modified` | With `dispatcher_run_id`: the dispatcher workflow at the dispatcher run's head differs from the tip (same report-job exception) | | 403 | `lane-mismatch` | The requested lane is not the caller's file stem | | 403 | `activity-not-in-lane`, `slot-disabled`, `config-invalid`, `effect-forbidden-by-lane` | The tip config does not give this activity a write grant | | 403 | `effect-read` | The activity is `read`; it belongs in the read file | -| 403 | `untrusted-actor`, `untrusted-author`, `bot-actor` | The actor, the PR author or a re-runner is not on the tip's trusted-actor list (a scheduled `pr-merge.yml` run has no actor check, and its re-runner must be a listed human; a scheduled `work-unblock.yml` run has no actor check either and skips the workflow's own closer check, so the broker alone gates its re-runs; a `github-actions[bot]` dispatch of `intake-triage.yml` has no actor check and needs its issue open, and its re-runner must be a listed human), or is the lanes bot | +| 403 | `untrusted-actor`, `untrusted-author`, `bot-actor` | The actor, the PR author or a re-runner is not on the tip's trusted-actor list (a scheduled `pr-merge.yml` run has no actor check, and its re-runner must be a listed human; a scheduled `work-unblock.yml` run has no actor check either and skips the workflow's own closer check, so the broker alone gates its re-runs; a `github-actions[bot]` dispatch of `intake-triage.yml` has no actor check and needs its issue open, and its re-runner must be a listed human; a scheduled `upstream-apply-changelog.yml` run has no actor check, and its re-runner must be a listed human), or is the lanes bot | | 403 | `untrusted-actor` | With `dispatcher_run_id` from `github-actions[bot]`: the dispatcher run or this run is missing or fails a dispatcher check above | | 503 | `github-unavailable` | With `dispatcher_run_id`: a dispatcher, run, tag or compare read failed; nothing was minted | | 403 | `pr-not-eligible` | The PR is not open, not from a branch of this repository, or does not target `main` | From 69846c07a86814e4c99352039fe17cde80d6c9da Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:27:44 -0500 Subject: [PATCH 07/35] fix(ci): pass check-trusted-trigger only the inputs the gate needs The gate's trusted-actor check passed trusted-actors-path and triggering-actor at their action defaults; it now passes only denied-actor-ids, require-pr and require-kind. The step's comment says that actor-only mode refuses a schedule event, so a re-run of a scheduled run stops and the release is dispatched instead. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/upstream-apply-changelog.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index e0cab9fefe4..3fcd62a9919 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -90,16 +90,16 @@ jobs: REASON: ${{ steps.kill.outputs.reason }} run: echo "::notice::the lanes kill switch stops this run (reason ${REASON:-unreadable}); nothing read or written" - # A dispatch, and any re-run, must come from a listed human; the broker - # checks the same, so this only keeps the model from running for nothing. + # A dispatch, and any re-run of one, must come from a listed human; the + # broker checks the same, so this only keeps the model from running for + # nothing. Actor-only mode takes no schedule event, so a re-run of a + # scheduled run stops here: dispatch the release instead. - name: Check who started the run id: who if: steps.kill.outputs.proceed == 'true' && (github.event_name == 'workflow_dispatch' || github.run_attempt != '1') uses: ./.base/.github/actions/check-trusted-trigger # zizmor: ignore[self-repository] with: - trusted-actors-path: .base/.github/standards/trusted-actors/trusted-actors.json denied-actor-ids: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} - triggering-actor: ${{ github.triggering_actor }} require-pr: "false" require-kind: human From db0fd3741532f0821a657e764efc75d716d5f7c1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:32:09 -0500 Subject: [PATCH 08/35] feat(ci): push changelog branches as signed lanes App commits push-branches now recreates each planned branch through the Git Data API (blobs, trees, commits with no author or committer, then the ref) with apply-patch.mjs's createGitHubApi, so GitHub authors and signs every commit as the lanes App, as work-resolve-issue's apply-patch does. It refuses a merge commit, reads blobs as bytes, and refuses a replay whose tree is not the planned head's, so what is pushed is what the scan job scanned. The mutate-branch token's contents write covers every call. A branch that already exists counts only when its tip holds the planned tree. push reports each branch's head as a job output, and publish opens a pull request only for a branch at that head. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog.mjs | 84 +++++++++++++++---- .../upstream-apply-changelog.test.mjs | 77 ++++++++++++++++- .../workflows/upstream-apply-changelog.yml | 15 ++-- docs/conventions/pr-pipeline.yaml | 13 +-- 4 files changed, 161 insertions(+), 28 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index 36bc0f260fd..5d0c214d4fc 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -3,7 +3,8 @@ // No model runs here. Subcommands: // gate picks the release, skips one already processed, applies the // per-day cap (read-only GITHUB_TOKEN with actions: read) -// push checks each planned branch and pushes it (mutate-branch token) +// push checks each planned branch and recreates it through the Git Data +// API as lanes App commits (mutate-branch token) // publish opens one PR per pushed branch and files each handoff once // (publish-artifact token) // The plan and the bundle come from the model job, so every field is data: @@ -12,6 +13,8 @@ import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { createGitHubApi } from "../apply-patch/apply-patch.mjs"; + export const RELEASE = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,5})$/; const PLUGIN = /^[a-z0-9][a-z0-9-]{0,63}$/; const SHA = /^[0-9a-f]{40}$/; @@ -137,6 +140,47 @@ export function checkRawLog(raw, plugin) { } const git = (repo, args, extra = {}) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", ...extra }).trim(); +const MAX_BLOB = 64 * 1024 * 1024; + +// The commits in base..head, oldest first, each as its message and the files +// it changes: a deleted file has content null. A merge commit is refused. +export function readCommits(repoDir, baseSha, headSha) { + if (git(repoDir, ["rev-list", "--min-parents=2", `${baseSha}..${headSha}`])) throw new Error("a planned branch holds a merge commit"); + return git(repoDir, ["rev-list", "--reverse", `${baseSha}..${headSha}`]) + .split("\n") + .filter(Boolean) + .map((sha) => ({ + message: execFileSync("git", ["-C", repoDir, "show", "-s", "--format=%B", sha], { encoding: "utf8" }), + entries: git(repoDir, ["diff-tree", "-r", "--no-renames", "--raw", "--no-abbrev", "--no-commit-id", sha]) + .split("\n") + .filter((line) => line.startsWith(":")) + .map((line) => { + const [meta, path] = line.split("\t"); + const [srcMode, dstMode, , dstSha] = meta.slice(1).split(" "); + if (dstMode === "000000") return { path, mode: srcMode, content: null }; + return { path, mode: dstMode, content: execFileSync("git", ["-C", repoDir, "cat-file", "blob", dstSha], { maxBuffer: MAX_BLOB }) }; + }), + })); +} + +// Replays the commits through the Git Data API with no author or committer, +// so GitHub authors and signs each as the lanes App, as apply-patch does +// (apply-patch.mjs createGitHubApi). Returns the new head, whose tree must be +// the planned head's: what is pushed is exactly what the scan job scanned. +export async function replayCommits(commits, { baseSha, baseTree, headTree, api }) { + let parent = baseSha; + let tree = baseTree; + for (const { message, entries } of commits) { + const blobs = []; + for (const { path, mode, content } of entries) { + blobs.push({ path, mode, type: "blob", sha: content === null ? null : await api.createBlob(Buffer.from(content).toString("base64")) }); + } + if (blobs.length) tree = await api.createTree(tree, blobs); + parent = await api.createCommit(message, tree, [parent]); + } + if (tree !== headTree) throw new Error(`the replayed tree ${tree} is not the planned head's ${headTree}`); + return parent; +} async function gate(env) { const repo = env.GITHUB_REPOSITORY; @@ -181,7 +225,8 @@ async function gate(env) { async function push(env) { const { RELEASE: release, BASE_SHA: baseSha, PLAN_FILE, BUNDLE_FILE, REPO_DIR: repoDir, GITHUB_REPOSITORY: repo } = env; const plan = validatePlan(JSON.parse(readFileSync(PLAN_FILE, "utf8")), { release, baseSha }); - const pushed = []; + const writer = createGitHubApi({ token: env.GH_TOKEN, repository: repo, apiUrl: env.GITHUB_API_URL || "https://api.github.com" }); + const heads = {}; if (plan.branches.length && !existsSync(BUNDLE_FILE)) throw new Error("the plan names branches and the run left no bundle"); for (const b of plan.branches) { const local = `refs/upstream-apply-changelog/${b.plugin}`; @@ -189,34 +234,41 @@ async function push(env) { if (git(repoDir, ["rev-parse", local]) !== b.head_sha) throw new Error(`${b.branch} is not at its planned head`); if (git(repoDir, ["merge-base", baseSha, b.head_sha]) !== baseSha) throw new Error(`${b.branch} does not start at the base`); checkRawLog(git(repoDir, ["log", "--format=", "--raw", "--no-renames", "--no-abbrev", `${baseSha}..${b.head_sha}`]), b.plugin); - if (await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true })) { - console.log(`${b.branch} already exists; skipped`); + const headTree = git(repoDir, ["rev-parse", `${b.head_sha}^{tree}`]); + const existing = await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true }); + if (existing) { + // A re-run's branch counts only when its tip holds the planned tree. + const tip = existing.object?.sha; + const commit = SHA.test(tip ?? "") ? await api(`/repos/${repo}/git/commits/${tip}`) : null; + if (commit?.tree?.sha === headTree) heads[b.branch] = tip; + console.log(`${b.branch} already exists${heads[b.branch] ? "" : " with another tree"}; not pushed`); continue; } - // The token rides in an extraheader from the environment, never the URL or argv. - git(repoDir, ["push", `https://github.com/${repo}.git`, `${b.head_sha}:refs/heads/${b.branch}`], { - env: { - ...process.env, - GIT_CONFIG_COUNT: "1", - GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", - GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${env.GH_TOKEN}`).toString("base64")}`, - }, + const head = await replayCommits(readCommits(repoDir, baseSha, b.head_sha), { + baseSha, + baseTree: git(repoDir, ["rev-parse", `${baseSha}^{tree}`]), + headTree, + api: writer, }); - pushed.push(b.branch); + await writer.createRef(`refs/heads/${b.branch}`, head); + heads[b.branch] = head; } - console.log(`pushed ${pushed.length} of ${plan.branches.length} branches`); + output("heads", JSON.stringify(heads)); + console.log(`${Object.keys(heads).length} of ${plan.branches.length} branches are at their planned tree`); } async function publish(env) { const { RELEASE: release, BASE_SHA: baseSha, PLAN_FILE, GITHUB_REPOSITORY: repo } = env; const plan = validatePlan(JSON.parse(readFileSync(PLAN_FILE, "utf8")), { release, baseSha }); const owner = repo.split("/")[0]; + // push's heads output: each branch it pushed or found at the planned tree. + const heads = JSON.parse(env.HEADS || "{}"); let opened = 0; let filed = 0; for (const b of plan.branches) { const ref = await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true }); - if (ref?.object?.sha !== b.head_sha) { - console.log(`${b.branch} is missing or not at the planned head; no pull request`); + if (!SHA.test(heads[b.branch] ?? "") || ref?.object?.sha !== heads[b.branch]) { + console.log(`${b.branch} is missing or not at the head push left; no pull request`); continue; } const prs = await api(`/repos/${repo}/pulls?state=all&head=${owner}:${encodeURIComponent(b.branch)}`); diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index b424fdc256a..b0adc881a12 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -1,7 +1,21 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { test } from "node:test"; -import { alreadyProcessed, branchFor, releasesFromStatus, checkRawLog, newestRelease, startedInWindow, validatePlan } from "./upstream-apply-changelog.mjs"; +import { + alreadyProcessed, + branchFor, + checkRawLog, + newestRelease, + readCommits, + releasesFromStatus, + replayCommits, + startedInWindow, + validatePlan, +} from "./upstream-apply-changelog.mjs"; const BASE = "a".repeat(40); const HEAD = "b".repeat(40); @@ -85,3 +99,64 @@ test("the status file gives the unread releases, none when up to date, and stops assert.throws(() => releasesFromStatus("range: 2.1.1..2.1.90 (90 releases, 400 core items)\ncap: exceeded (90 releases / 400 items)\n"), /cap: exceeded/); assert.throws(() => releasesFromStatus("range: a..b\ncap: within budget\nreleases: v2\n"), /malformed/); }); + +// A fake Git Data API: each object's sha is its index in the calls made. +function fakeApi() { + const calls = []; + const sha = (kind) => { + calls.push(kind); + return `${kind}-${calls.length}`; + }; + return { + calls, + createBlob: async (content) => sha(`blob:${content}`), + createTree: async (base, entries) => sha(`tree:${base}:${entries.map((e) => `${e.path}=${e.sha}`).join(",")}`), + createCommit: async (message, tree, parents) => sha(`commit:${message.trim()}:${tree}:${parents.join(",")}`), + }; +} + +test("the replay makes one App commit per planned commit, chained from the base, and deletes a removed file", async () => { + const api = fakeApi(); + const commits = [ + { message: "first\n", entries: [{ path: "plugins/p/a.md", mode: "100644", content: Buffer.from("a") }] }, + { message: "second\n", entries: [{ path: "plugins/p/a.md", mode: "100644", content: null }] }, + ]; + const tree1 = "tree:t0:plugins/p/a.md=blob:YQ==-1-2"; + const tree2 = `tree:${tree1}:plugins/p/a.md=null-4`; + const head = await replayCommits(commits, { baseSha: BASE, baseTree: "t0", headTree: tree2, api }); + assert.equal(api.calls[2], `commit:first:${tree1}:${BASE}`); + assert.equal(api.calls[4], `commit:second:${tree2}:commit:first:${tree1}:${BASE}-3`); + assert.equal(head, `${api.calls[4]}-5`); +}); + +test("a replay whose tree is not the planned head's is refused before any ref is made", async () => { + const commits = [{ message: "m", entries: [{ path: "plugins/p/a.md", mode: "100644", content: Buffer.from("a") }] }]; + await assert.rejects(replayCommits(commits, { baseSha: BASE, baseTree: "t0", headTree: "other", api: fakeApi() }), /not the planned head's/); +}); + +test("the planned commits are read oldest first with their files, and a merge commit is refused", (t) => { + const dir = mkdtempSync(join(tmpdir(), "uac-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const env = { PATH: process.env.PATH, HOME: dir, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }; + const git = (...args) => execFileSync("git", ["-C", dir, ...args], { env, encoding: "utf8" }).trim(); + git("init", "-q", "-b", "main"); + writeFileSync(join(dir, "keep.md"), "k"); + git("add", "."); + git("commit", "-qm", "base"); + const base = git("rev-parse", "HEAD"); + writeFileSync(join(dir, "new.md"), "n"); + git("add", "."); + git("commit", "-qm", "add new"); + git("rm", "-q", "keep.md"); + git("commit", "-qm", "drop keep"); + const commits = readCommits(dir, base, git("rev-parse", "HEAD")); + assert.deepEqual(commits.map((c) => c.message.trim()), ["add new", "drop keep"]); + assert.deepEqual(commits[0].entries.map((e) => [e.path, e.mode, String(e.content)]), [["new.md", "100644", "n"]]); + assert.deepEqual(commits[1].entries, [{ path: "keep.md", mode: "100644", content: null }]); + git("checkout", "-qb", "side", base); + writeFileSync(join(dir, "side.md"), "s"); + git("add", "."); + git("commit", "-qm", "side"); + git("merge", "-q", "--no-edit", "main"); + assert.throws(() => readCommits(dir, base, git("rev-parse", "HEAD")), /merge commit/); +}); diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 3fcd62a9919..d7e5c07a725 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -7,8 +7,9 @@ name: upstream-apply-changelog # then act on that plan with lanes App tokens from the token broker, which # mints for this file on schedule and workflow_dispatch from main only, keyed # on the `release` request field: -# push-branches (mutate-branch: contents write) pushes each branch whose -# commits change only plugins// and .changes//; +# push-branches (mutate-branch: contents write) recreates each branch whose +# commits change only plugins// and .changes// through the +# Git Data API, so GitHub authors and signs every commit as the lanes App; # publish (publish-artifact: pull-requests and issues write, contents read) # opens one pull request per pushed branch and files each handoff once. # The broker never grants contents and pull-requests write in one mint, nor @@ -468,8 +469,9 @@ jobs: with: tokens: github-token - # mutate-branch: contents write only. Pushes each planned branch that does - # not exist yet, after checking its commits stay inside its plugin. + # mutate-branch: contents write only. Recreates each planned branch that does + # not exist yet as lanes App commits, as apply-patch does for + # work-resolve-issue, after checking its commits stay inside its plugin. push-branches: needs: [gate, scan] if: ${{ !cancelled() && needs.scan.outputs.clean == 'true' && needs.scan.outputs.branches != '0' }} @@ -481,6 +483,7 @@ jobs: id-token: write # prove to the token broker which workflow this is outputs: skip-reason: ${{ steps.lane-token.outputs.skip-reason }} + heads: ${{ steps.push.outputs.heads }} steps: - name: Check out the default branch tip uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -540,6 +543,7 @@ jobs: path: ${{ runner.temp }}/plan - name: Push the branches + id: push if: steps.lane-token.outputs.token != '' env: GH_TOKEN: ${{ steps.lane-token.outputs.token }} @@ -571,7 +575,7 @@ jobs: tokens: lanes-app github-token # publish-artifact: pull-requests and issues write, contents read. Opens a - # pull request for each branch at its planned head that has none, and files + # pull request for each branch at the head push-branches left that has none, and files # each handoff whose key no issue carries; then records the release done. publish: needs: [gate, scan, push-branches] @@ -648,6 +652,7 @@ jobs: RELEASE: ${{ needs.gate.outputs.release }} BASE_SHA: ${{ needs.gate.outputs.base-sha }} PLAN_FILE: ${{ runner.temp }}/plan/plan.json + HEADS: ${{ needs.push-branches.outputs.heads }} run: node .base/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs publish - name: Revoke the lane token diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index a210ef964f3..2c2e6fe7aea 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -651,11 +651,12 @@ activities: # Admission waits on an issue while a sub-issue is open. inputs: [issue] apply-changelog-branches: - # Pushes each changelog//v branch the upstream-apply-changelog - # model job's `/harness-ops:changelog apply --ci` committed, after + # Recreates each changelog//v branch the + # upstream-apply-changelog model job's `/harness-ops:changelog apply --ci` + # committed, as lanes App commits through the Git Data API, after # checking every commit changes only plugins// and - # .changes// as regular files; skips a branch that exists. A - # script, no model. .github/workflows/upstream-apply-changelog.yml runs it. + # .changes// as regular files and that the result holds the + # planned tree; skips a branch that exists. A script, no model. .github/workflows/upstream-apply-changelog.yml runs it. script: .github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs args: push effect: mutate-branch @@ -663,8 +664,8 @@ activities: # Reads the plan and the bundle the model wrote. reads-untrusted: true publish-changelog-intake: - # Opens one pull request per pushed branch at its planned head that has - # none, and files each plan handoff whose key no issue carries. A script, + # Opens one pull request per pushed branch at the head push left that + # has none, and files each plan handoff whose key no issue carries. A script, # no model. .github/workflows/upstream-apply-changelog.yml runs it. script: .github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs args: publish From 4ff6aae24d0bbc01a43d159a15d422d2811bef38 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:35:33 -0500 Subject: [PATCH 09/35] feat(ci): keep the changelog model job's sealed transcript The model job stages upload-lane-transcript, the seal-lane-output and scan-upload actions it loads, and lib/publish-gate.mjs into RUNNER_TEMP before the root checkout replaces .base. After the leg it copies them back to a fresh .lane-transcript and uploads the sealed transcript of the run step's execution file under the job's id, always and without changing the job's outcome. Nothing loads from ./.base after the root checkout. The skill's memory directory is now its default .work/ (decisions.md Persistence; plugin-artifact-protocol.md), which it resolves on its own because this job removes every project instruction file; .work/ is gitignored, so the plan stays out of the skill's commits. The seal step reads the plan from there. publish restores the Actions cache read-only, since it posts the model's plan text, and the read-only cache test lists it. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 2 +- .../workflows/upstream-apply-changelog.yml | 40 +++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 63590cb2647..e966eabd950 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -7739,7 +7739,7 @@ test("every job that runs or reads untrusted head content has a read-only Action "intake-triage.yml": ["refine-issue", "refine-issue-subscription"], "pr-review-main.yml": ["review", "review-subscription", "review-fallback"], "pr-verify.yml": ["check-intent", "check-intent-subscription", "check-intent-fallback"], - "upstream-apply-changelog.yml": ["model", "scan", "push-branches"], + "upstream-apply-changelog.yml": ["model", "scan", "push-branches", "publish"], }; for (const [file, names] of Object.entries(readOnly)) { const { jobs } = workflow(file); diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index d7e5c07a725..2722245c104 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -209,15 +209,20 @@ jobs: output-path: ${{ runner.temp }}/upstream-apply-changelog-models.json # Everything a later step of this job runs, copied out of main before - # the root checkout replaces .base: the sealer, its key, and the - # plugins the skill and its stage skills load. + # the root checkout replaces .base: the sealer, its key, the transcript + # uploader with the actions and library it loads, and the plugins the + # skill and its stage skills load. - name: Stage the base files if: steps.models.outcome == 'success' env: STAGE: ${{ runner.temp }}/upstream-apply-changelog-stage run: | - mkdir -p "$STAGE/seal" "$STAGE/plugins" + mkdir -p "$STAGE/seal" "$STAGE/plugins" "$STAGE/transcript/.github/actions" "$STAGE/transcript/lib" cp .base/.github/actions/seal-lane-output/seal.mjs .base/.github/lane-keys/scan-carrier.pub "$STAGE/seal/" + for action in upload-lane-transcript seal-lane-output scan-upload; do + cp -R ".base/.github/actions/$action" "$STAGE/transcript/.github/actions/" + done + cp .base/lib/publish-gate.mjs "$STAGE/transcript/lib/" for plugin in harness-ops planning implementation verification; do cp -R ".base/plugins/$plugin" "$STAGE/plugins/" printf '{"mcpServers":{}}\n' >"$STAGE/plugins/$plugin/.mcp.json" @@ -262,13 +267,12 @@ jobs: git update-index --skip-worktree -- "${held[@]}" rm -f -- "${held[@]}" fi - mkdir -p .lane/memory cp -R "$STAGE/plugins" .lane/plugins chmod -R a-w .lane/plugins cat >"$RUNNER_TEMP/upstream-apply-changelog-prompt.md" < Date: Mon, 2 Nov 2026 13:36:52 -0500 Subject: [PATCH 10/35] fix(ci): drop the template literal from the scan job's plan check shellcheck SC2016, which actionlint reports, read the backtick template's ${...} inside the single-quoted node -e script as a shell expansion that never happens; plain string joins print the same line. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/upstream-apply-changelog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 2722245c104..1f29c39e40e 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -447,7 +447,7 @@ jobs: import { validatePlan } from "./.base/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs"; const plan = validatePlan(JSON.parse(readFileSync(process.env.IN + "/plan.json", "utf8")), { release: process.env.RELEASE, baseSha: process.env.BASE_SHA }); - console.log(`${plan.branches.length} branches, ${plan.handoffs.length} handoffs`); + console.log(plan.branches.length + " branches, " + plan.handoffs.length + " handoffs"); ' : >"$IN/diffs.txt" count=0 From ba79cdd420217296f2bcd8fe11ff150af2c674ae Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:37:33 -0500 Subject: [PATCH 11/35] chore(ci): add the pr-pipeline fragment and mark the lane script executable The lane changes docs/conventions/pr-pipeline/, so it carries a minor pr-pipeline fragment describing the upstream-apply-changelog lane, and upstream-apply-changelog.mjs, which starts with #!, is 100755 in the index. Refs: #7407 Co-Authored-By: Claude Opus 5.5 --- .../feat-release-intake-workflow-7c41e9a2.md | 12 ++++++++++++ .../upstream-apply-changelog.mjs | 0 2 files changed, 12 insertions(+) create mode 100644 .changes/pr-pipeline/feat-release-intake-workflow-7c41e9a2.md mode change 100644 => 100755 .github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs diff --git a/.changes/pr-pipeline/feat-release-intake-workflow-7c41e9a2.md b/.changes/pr-pipeline/feat-release-intake-workflow-7c41e9a2.md new file mode 100644 index 00000000000..caf3385353d --- /dev/null +++ b/.changes/pr-pipeline/feat-release-intake-workflow-7c41e9a2.md @@ -0,0 +1,12 @@ +--- +bump: minor +--- + +### Added + +- The `upstream-apply-changelog` lane takes in one Claude Code release a run: a model job runs + `/harness-ops:changelog apply --ci` with the API key and a read-only token, a scan job checks + and credential-scans its sealed plan and branches, `push-branches` (`apply-changelog-branches`, + mutate-branch) recreates each branch as signed lanes App commits, and `publish` + (`publish-changelog-intake`, publish-artifact) opens one pull request per branch and files each + handoff once. The token broker mints for it keyed on a new `release` request field. diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs old mode 100644 new mode 100755 From d1ceef2275dd2abfc788e7e22207f2f5dc7bb20f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:40:40 -0500 Subject: [PATCH 12/35] feat(ci): let the upstream-apply-changelog scan job hold the scan-carrier key Owner decision (2026-11-02, in the Automation Lanes session): "Second holder (Recommended)". The lane's scan job names the pr-pipeline-scan-carrier environment so its open step gets the key. The callers test pins the holder to that one job and one open step, with contents: read alone, a read-only cache, and the main-only gate before it. The scan also covers the branch commit messages, which push replays as public commits. Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 22 +++++++++++++++++-- .../workflows/upstream-apply-changelog.yml | 6 +++++ .../pr-pipeline/pr-run-activity.md | 5 ++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index e966eabd950..52a379785b9 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -4530,7 +4530,7 @@ test("pr-run-activity-read.yml: the scan-carrier key is passed by name by each r assert.equal(job.secrets["scan-carrier-private-key"], CARRIER_PASS, `${file} ${id}`); const rest = { ...job, secrets: { ...job.secrets, "scan-carrier-private-key": undefined } }; assert.doesNotMatch(JSON.stringify(rest), /SCAN_CARRIER_PRIVATE_KEY|scan-carrier-private-key/i, `${file} ${id}`); - } else if (file !== "pr-run-activity-read.yml") { + } else if (file !== "pr-run-activity-read.yml" && !(file === "upstream-apply-changelog.yml" && id === "scan")) { assert.doesNotMatch(JSON.stringify(job), /SCAN_CARRIER_PRIVATE_KEY|scan-carrier-private-key/i, `${file} ${id}`); } } @@ -4546,9 +4546,27 @@ test("pr-run-activity-read.yml: the scan-carrier key is passed by name by each r assert.equal(open.with["private-key"], CARRIER_SECRET); assert.equal(open.env, undefined); assert.equal(open.uses, "./.base/.github/actions/open-lane-output"); + // The second holder (owner decision, 2026-11-02): upstream-apply-changelog.yml's scan job, one + // open step, only while it runs from main with no id-token, no write token and a read-only cache. + const intake = workflow("upstream-apply-changelog.yml").jobs; + for (const [id, job] of Object.entries(intake)) { + const steps = (job.steps ?? []).filter((step) => /scan-carrier-private-key|SCAN_CARRIER_PRIVATE_KEY/i.test(JSON.stringify(step))); + assert.deepEqual(steps.map((step) => step.id), id === "scan" ? ["open"] : [], `upstream-apply-changelog.yml ${id}`); + } + const holder = intake.scan; + const intakeOpen = holder.steps.find((s) => s.id === "open"); + assert.deepEqual(stepSecrets(intakeOpen), ["SCAN_CARRIER_PRIVATE_KEY"]); + assert.equal(intakeOpen.with["private-key"], "${{ secrets.SCAN_CARRIER_PRIVATE_KEY }}"); + assert.equal(intakeOpen.env, undefined); + assert.equal(intakeOpen.uses, "./.base/.github/actions/open-lane-output"); + assert.deepEqual(holder.permissions, { contents: "read" }); + assert.equal(holder["cache-mode"], "read"); + // Main only: the scan job runs after the gate job, which runs only on refs/heads/main. + assert.ok([holder.needs].flat().includes("gate")); + assert.match(intake.gate.if ?? "", /github\.ref == 'refs\/heads\/main'/); for (const file of files) { for (const [id, job] of Object.entries(workflow(file).jobs ?? {})) { - if (file === "pr-run-activity-read.yml" && id === "scan") assert.equal(job.environment, "pr-pipeline-scan-carrier"); + if ((file === "pr-run-activity-read.yml" || file === "upstream-apply-changelog.yml") && id === "scan") assert.equal(job.environment, "pr-pipeline-scan-carrier"); else assert.doesNotMatch(JSON.stringify(job.environment ?? ""), /pr-pipeline-scan-carrier/, `${file} ${id}`); } } diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 1f29c39e40e..663ed2246a5 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -387,6 +387,9 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 cache-mode: read + # The scan-carrier key's second holder (owner decision, 2026-11-02): main only, no id-token, no + # write token, read-only cache; the environment admits main only. + environment: pr-pipeline-scan-carrier permissions: contents: read # check out main outputs: @@ -415,6 +418,7 @@ jobs: echo "id=$id" >>"$GITHUB_OUTPUT" - name: Open the model job's sealed payloads + id: open uses: ./.base/.github/actions/open-lane-output # zizmor: ignore[self-repository] with: private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }} @@ -457,6 +461,8 @@ jobs: [[ $head =~ ^[0-9a-f]{40}$ ]] || exit 1 git -C .base fetch --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" git -C .base diff --no-renames --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" + # Push replays these messages as public commits, so they are scanned too. + git -C .base log --format=%B "$BASE_SHA..$head" >>"$IN/diffs.txt" count=$((count + 1)) done < <(jq -r '.branches[]?.head_sha' "$IN/plan.json") fi diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index 70b5745e554..1cc9a574a60 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -1931,7 +1931,10 @@ other than `${{ github.event.repository.default_branch }}` or a `with` key other `./.base/.github/actions/open-lane-output` with `private-key: ${{ secrets.scan-carrier-private-key }}` exactly; a job that calls the read file passes it by name only, `scan-carrier-private-key: ${{ secrets.SCAN_CARRIER_PRIVATE_KEY }}` in its `secrets` map; and only -the `scan` job may name an `environment`, `pr-pipeline-scan-carrier`. GitHub gives an environment +the `scan` job may name an `environment`, `pr-pipeline-scan-carrier`. The one second holder (owner +decision, 2026-11-02) is `upstream-apply-changelog.yml`'s `scan` job: one `open` step, the same +action and exact `private-key`, after the main-only gate job, with `contents: read` alone, no +id-token and a read-only cache; the environment admits main only. GitHub gives an environment secret to a reusable workflow only when the caller passes it by name, and the value it then holds is the environment's only in the job that names the environment ([Reuse workflows](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows)). From 28ebc16274a293b89526896b7d2427c9200be6be Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:44:07 -0500 Subject: [PATCH 13/35] feat(ci): file a release's changelog handoffs as one issue The publish job filed every plan handoff as its own issue. It now files one issue per release, titled "Claude Code v changelog: handoffs", opening with a release marker and holding one checklist item per handoff, each body indented under it with its key marker. Bodies over GitHub's issue body limit are cut in proportion and a line says so. An issue carrying the release marker, in any state, is never filed twice; an open one missing keys gets the missing items appended in one update, and a closed one is left alone. The update stays within the existing issues write grant. Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog.mjs | 71 +++++++++++--- .../upstream-apply-changelog.test.mjs | 98 +++++++++++++++++++ .../workflows/upstream-apply-changelog.yml | 6 +- .../skills/changelog/context/ci-mode.md | 3 +- 4 files changed, 160 insertions(+), 18 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index 5d0c214d4fc..b92d1cbb5f0 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -5,8 +5,8 @@ // per-day cap (read-only GITHUB_TOKEN with actions: read) // push checks each planned branch and recreates it through the Git Data // API as lanes App commits (mutate-branch token) -// publish opens one PR per pushed branch and files each handoff once -// (publish-artifact token) +// publish opens one PR per pushed branch and files the release's handoffs +// as one issue (publish-artifact token) // The plan and the bundle come from the model job, so every field is data: // names are held to fixed shapes and every commit to its plugin's paths. import { execFileSync } from "node:child_process"; @@ -26,6 +26,58 @@ export const startedArtifact = (release, runId) => `upstream-apply-changelog-sta export const doneArtifact = (release) => `upstream-apply-changelog-done-${release}`; export const alreadyProcessed = (artifacts) => (artifacts?.total_count ?? 0) > 0; export const handoffMarker = (key) => ``; +export const releaseMarker = (release) => ``; +const ISSUE_BODY_MAX = 65536; // GitHub's issue body limit +const TRUNCATED = "Handoff bodies are truncated proportionally to fit GitHub's 65536-character issue body limit."; +const KIND = /^[a-z][a-z-]{0,39}$/; + +const releaseTitle = (release, n) => `Claude Code v${release} changelog: ${n} handoff${n === 1 ? "" : "s"}`; +const keysIn = (body) => [...body.matchAll(//g)].map((m) => m[1]); + +// One checklist item per handoff, its body indented under it, within `budget` +// characters: when the bodies do not fit, each is cut in proportion to its +// length and a line says so. +export function renderItems(handoffs, budget) { + const indent = (text) => text.replace(/^(?=.)/gm, " "); + const item = (h, body) => { + const kind = KIND.test(h.kind ?? "") ? h.kind : "handoff"; + return `- [ ] ${kind}: ${h.title}\n\n${body ? `${body}\n\n` : ""} ${handoffMarker(h.key)}\n`; + }; + const bodies = handoffs.map((h) => indent(h.body)); + const full = handoffs.map((h, i) => item(h, bodies[i])).join("\n"); + if (full.length <= budget) return full; + const fixed = handoffs.map((h) => item(h, "")).join("\n").length + TRUNCATED.length + 2; + const room = budget - fixed - handoffs.length * 2; + if (room < 0) throw new Error("the handoff checklist does not fit in one issue body"); + const total = bodies.reduce((sum, b) => sum + b.length, 0); + const cut = bodies.map((b) => b.slice(0, Math.floor((b.length * room) / total)).trimEnd()); + return `${TRUNCATED}\n\n${handoffs.map((h, i) => item(h, cut[i])).join("\n")}`; +} + +// Files a release's handoffs as one issue, found again by its release marker +// in any state. An open issue missing some keys gets them appended in one +// update; a closed one is left alone. +export async function fileReleaseHandoffs({ repo, release, handoffs, api }) { + if (!handoffs.length) return "no handoffs; nothing filed"; + const marker = releaseMarker(release); + const q = encodeURIComponent(`repo:${repo} is:issue in:body "upstream-apply-changelog release=${release}"`); + const found = await api(`/search/issues?q=${q}&per_page=20`); + const issue = (found?.items ?? []).find((i) => typeof i.body === "string" && i.body.includes(marker)); + if (!issue) { + const head = `${marker}\n\n`; + const body = head + renderItems(handoffs, ISSUE_BODY_MAX - head.length); + await api(`/repos/${repo}/issues`, { method: "POST", body: { title: releaseTitle(release, handoffs.length), body } }); + return `filed ${handoffs.length} handoffs as one issue`; + } + if (issue.state !== "open") return `the release issue #${issue.number} is closed; nothing filed`; + const have = new Set(keysIn(issue.body)); + const missing = handoffs.filter((h) => !have.has(h.key)); + if (!missing.length) return `the release issue #${issue.number} already holds every handoff`; + const base = `${issue.body.trimEnd()}\n\n`; + const body = base + renderItems(missing, ISSUE_BODY_MAX - base.length); + await api(`/repos/${repo}/issues/${issue.number}`, { method: "PATCH", body: { title: releaseTitle(release, have.size + missing.length), body } }); + return `appended ${missing.length} handoffs to the release issue #${issue.number}`; +} function output(name, value) { if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); @@ -264,7 +316,6 @@ async function publish(env) { // push's heads output: each branch it pushed or found at the planned tree. const heads = JSON.parse(env.HEADS || "{}"); let opened = 0; - let filed = 0; for (const b of plan.branches) { const ref = await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true }); if (!SHA.test(heads[b.branch] ?? "") || ref?.object?.sha !== heads[b.branch]) { @@ -279,18 +330,8 @@ async function publish(env) { await api(`/repos/${repo}/pulls`, { method: "POST", body: { title: b.pr.title, body: b.pr.body, head: b.branch, base: "main" } }); opened++; } - for (const h of plan.handoffs) { - const marker = handoffMarker(h.key); - const q = encodeURIComponent(`repo:${repo} is:issue in:body "${h.key}"`); - const found = await api(`/search/issues?q=${q}&per_page=20`); - if ((found?.items ?? []).some((i) => typeof i.body === "string" && i.body.includes(marker))) { - console.log(`handoff ${h.key} is already filed`); - continue; - } - await api(`/repos/${repo}/issues`, { method: "POST", body: { title: h.title, body: `${h.body}\n\n${marker}\n` } }); - filed++; - } - console.log(`opened ${opened} pull requests and filed ${filed} handoffs`); + console.log(`opened ${opened} pull requests`); + console.log(await fileReleaseHandoffs({ repo, release, handoffs: plan.handoffs, api })); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index b0adc881a12..00536191fa3 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -9,7 +9,11 @@ import { alreadyProcessed, branchFor, checkRawLog, + fileReleaseHandoffs, + handoffMarker, newestRelease, + releaseMarker, + renderItems, readCommits, releasesFromStatus, replayCommits, @@ -160,3 +164,97 @@ test("the planned commits are read oldest first with their files, and a merge co git("merge", "-q", "--no-edit", "main"); assert.throws(() => readCommits(dir, base, git("rev-parse", "HEAD")), /merge commit/); }); + +const REPO = "o/r"; +const REL = "2.1.300"; +const handoff = (n, over = {}) => ({ key: `changelog:${REL}..${REL}:adopt:plugins/x/${n}.sh`, kind: "adopt", title: `adopt ${n}`, body: `body ${n}\nline two`, ...over }); +// A fake API: answers the search with `items` and records every call. +const issueApi = (items = []) => { + const calls = []; + const api = async (path, opts = {}) => { + calls.push({ path, method: opts.method ?? "GET", body: opts.body }); + return path.startsWith("/search/issues") ? { items } : {}; + }; + return { api, calls, writes: () => calls.filter((c) => c.method !== "GET") }; +}; + +test("no handoffs files nothing and makes no call", async () => { + const f = issueApi(); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [], api: f.api }); + assert.equal(f.calls.length, 0); +}); + +test("one handoff files one release issue with the release marker and its keyed checklist item", async () => { + const f = issueApi(); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1)], api: f.api }); + const [w] = f.writes(); + assert.equal(f.writes().length, 1); + assert.equal(w.path, `/repos/${REPO}/issues`); + assert.equal(w.method, "POST"); + assert.equal(w.body.title, `Claude Code v${REL} changelog: 1 handoff`); + assert.ok(w.body.body.startsWith(`${releaseMarker(REL)}\n`)); + assert.ok(w.body.body.includes("- [ ] adopt: adopt 1\n\n body 1\n line two\n\n ")); + assert.ok(w.body.body.includes(handoffMarker(handoff(1).key))); +}); + +test("several handoffs go into one issue, one checklist item each, in plan order", async () => { + const f = issueApi(); + const hs = [handoff(1), handoff(2, { kind: "security" }), handoff(3)]; + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, api: f.api }); + assert.equal(f.writes().length, 1); + const { title, body } = f.writes()[0].body; + assert.equal(title, `Claude Code v${REL} changelog: 3 handoffs`); + assert.equal(body.match(/^- \[ \] /gm).length, 3); + assert.ok(body.includes("- [ ] security: adopt 2")); + const at = hs.map((h) => body.indexOf(handoffMarker(h.key))); + assert.ok(at.every((i) => i > 0) && at[0] < at[1] && at[1] < at[2]); +}); + +test("bodies over the issue limit are cut in proportion, every key kept, and a line says so", async () => { + const f = issueApi(); + const hs = [handoff(1, { body: "a".repeat(50000) }), handoff(2, { body: "b".repeat(25000) })]; + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, api: f.api }); + const { body } = f.writes()[0].body; + assert.ok(body.length <= 65536); + assert.match(body, /truncated proportionally/); + for (const h of hs) assert.ok(body.includes(handoffMarker(h.key))); + const a = body.match(/a+/g).reduce((m, s) => Math.max(m, s.length), 0); + const b = body.match(/b{100,}/g)[0].length; + assert.ok(Math.abs(a / b - 2) < 0.01); + assert.equal(renderItems(hs.slice(0, 1).map((h) => ({ ...h, body: "x" })), 1000).includes("truncated"), false); +}); + +test("a release issue holding every key gets no write", async () => { + const hs = [handoff(1), handoff(2)]; + const body = `${releaseMarker(REL)}\n\n${renderItems(hs, 65536)}`; + const f = issueApi([{ number: 7, state: "open", body }]); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, api: f.api }); + assert.equal(f.writes().length, 0); +}); + +test("an open release issue missing a key gets the missing item appended in one update", async () => { + const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; + const f = issueApi([{ number: 7, state: "open", body }]); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2)], api: f.api }); + const writes = f.writes(); + assert.equal(writes.length, 1); + assert.equal(writes[0].path, `/repos/${REPO}/issues/7`); + assert.equal(writes[0].method, "PATCH"); + assert.equal(writes[0].body.title, `Claude Code v${REL} changelog: 2 handoffs`); + assert.ok(writes[0].body.body.startsWith(body.trimEnd())); + assert.equal(writes[0].body.body.split(handoffMarker(handoff(1).key)).length, 2); + assert.ok(writes[0].body.body.includes(handoffMarker(handoff(2).key))); +}); + +test("a closed release issue gets no write, even when it lacks a key", async () => { + const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; + const f = issueApi([{ number: 7, state: "closed", body }]); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2)], api: f.api }); + assert.equal(f.writes().length, 0); +}); + +test("an issue for another release does not count as this release's issue", async () => { + const f = issueApi([{ number: 7, state: "open", body: `${releaseMarker("2.1.30")}\n` }]); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1)], api: f.api }); + assert.equal(f.writes()[0].method, "POST"); +}); diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 663ed2246a5..bf8275dc8a4 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -11,7 +11,8 @@ name: upstream-apply-changelog # commits change only plugins// and .changes// through the # Git Data API, so GitHub authors and signs every commit as the lanes App; # publish (publish-artifact: pull-requests and issues write, contents read) -# opens one pull request per pushed branch and files each handoff once. +# opens one pull request per pushed branch and files the release's +# handoffs as one issue. # The broker never grants contents and pull-requests write in one mint, nor # merge or actions, so no job here can merge. # @@ -607,7 +608,8 @@ jobs: # publish-artifact: pull-requests and issues write, contents read. Opens a # pull request for each branch at the head push-branches left that has none, and files - # each handoff whose key no issue carries; then records the release done. + # the release's handoffs as one issue, appending keys an open one lacks; then + # records the release done. publish: needs: [gate, scan, push-branches] if: >- diff --git a/plugins/harness-ops/skills/changelog/context/ci-mode.md b/plugins/harness-ops/skills/changelog/context/ci-mode.md index 3d012988344..6b59c703163 100644 --- a/plugins/harness-ops/skills/changelog/context/ci-mode.md +++ b/plugins/harness-ops/skills/changelog/context/ci-mode.md @@ -118,5 +118,6 @@ always false: the marker moves in the ledger pull request once every row is in a `status` is `stopped` (no rows were derived; `stop_reason` is `cap-exceeded`, `no-range` or `range-not-computed`) or `planned` (rows were derived; `branches` and `handoffs` may each be empty). Handoff bodies carry item ids and the required sentence, never changelog text, matching the ledger -rule in [decisions.md](decisions.md). PR bodies follow the consuming repository's pull request +rule in [decisions.md](decisions.md). The workflow groups a release's handoffs into one issue, a +checklist item per handoff. PR bodies follow the consuming repository's pull request template. From fb7e05d7d08c21c6f4c7446bc54b30a0860978a6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:47:12 -0500 Subject: [PATCH 14/35] fix(ci): comment changelog handoffs a full release issue cannot hold When an open release issue lacks keys and appending them would pass GitHub's issue body limit, publish now posts the missing items as one comment on that issue instead of throwing, each with its key marker. Dedupe reads the issue's comments for key markers too, a full page at a time up to a fixed cap, and files nothing when a read fails or the cap is reached. Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog.mjs | 38 +++++++++++-- .../upstream-apply-changelog.test.mjs | 53 +++++++++++++++++-- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index b92d1cbb5f0..5fd211bbf4b 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -55,8 +55,9 @@ export function renderItems(handoffs, budget) { } // Files a release's handoffs as one issue, found again by its release marker -// in any state. An open issue missing some keys gets them appended in one -// update; a closed one is left alone. +// in any state. An open issue missing some keys, in its body and comments, +// gets them appended in one update, or in one comment when the body would +// pass the limit; a closed one is left alone. export async function fileReleaseHandoffs({ repo, release, handoffs, api }) { if (!handoffs.length) return "no handoffs; nothing filed"; const marker = releaseMarker(release); @@ -70,15 +71,42 @@ export async function fileReleaseHandoffs({ repo, release, handoffs, api }) { return `filed ${handoffs.length} handoffs as one issue`; } if (issue.state !== "open") return `the release issue #${issue.number} is closed; nothing filed`; - const have = new Set(keysIn(issue.body)); + const comments = await listComments(api, repo, issue.number); + const have = new Set([issue.body, ...comments.map((c) => c?.body ?? "")].flatMap(keysIn)); const missing = handoffs.filter((h) => !have.has(h.key)); if (!missing.length) return `the release issue #${issue.number} already holds every handoff`; const base = `${issue.body.trimEnd()}\n\n`; - const body = base + renderItems(missing, ISSUE_BODY_MAX - base.length); - await api(`/repos/${repo}/issues/${issue.number}`, { method: "PATCH", body: { title: releaseTitle(release, have.size + missing.length), body } }); + const items = renderItems(missing, Number.POSITIVE_INFINITY); + if (base.length + items.length > ISSUE_BODY_MAX) { + // The body is full: the missing items go in one comment instead. + const head = `${marker}\n\n`; + const body = head + renderItems(missing, ISSUE_BODY_MAX - head.length); + await api(`/repos/${repo}/issues/${issue.number}/comments`, { method: "POST", body: { body } }); + return `commented ${missing.length} handoffs on the full release issue #${issue.number}`; + } + await api(`/repos/${repo}/issues/${issue.number}`, { + method: "PATCH", + body: { title: releaseTitle(release, have.size + missing.length), body: base + items }, + }); return `appended ${missing.length} handoffs to the release issue #${issue.number}`; } +const COMMENT_PAGES = 10; + +// Every comment the release issue holds, read a full page at a time up to +// COMMENT_PAGES pages; more than that, or an answer that is not a list, +// throws so no key is filed twice. +async function listComments(api, repo, number) { + const all = []; + for (let page = 1; page <= COMMENT_PAGES; page++) { + const batch = await api(`/repos/${repo}/issues/${number}/comments?per_page=100&page=${page}`); + if (!Array.isArray(batch)) throw new Error(`the comments on #${number} did not come back as a list`); + all.push(...batch); + if (batch.length < 100) return all; + } + throw new Error(`#${number} has more comments than ${COMMENT_PAGES} pages hold`); +} + function output(name, value) { if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); } diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index 00536191fa3..e449a4d3d4c 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -168,12 +168,20 @@ test("the planned commits are read oldest first with their files, and a merge co const REPO = "o/r"; const REL = "2.1.300"; const handoff = (n, over = {}) => ({ key: `changelog:${REL}..${REL}:adopt:plugins/x/${n}.sh`, kind: "adopt", title: `adopt ${n}`, body: `body ${n}\nline two`, ...over }); -// A fake API: answers the search with `items` and records every call. -const issueApi = (items = []) => { +// A fake API: answers the search with `items`, a comment read with the page of +// `comments` it asks for (or `comments` itself when not a list), and records every call. +const issueApi = (items = [], comments = []) => { const calls = []; const api = async (path, opts = {}) => { - calls.push({ path, method: opts.method ?? "GET", body: opts.body }); - return path.startsWith("/search/issues") ? { items } : {}; + const method = opts.method ?? "GET"; + calls.push({ path, method, body: opts.body }); + if (path.startsWith("/search/issues")) return { items }; + if (method === "GET" && path.includes("/comments?")) { + if (!Array.isArray(comments)) return comments; + const page = Number(path.match(/page=(\d+)$/)[1]); + return comments.slice((page - 1) * 100, page * 100); + } + return {}; }; return { api, calls, writes: () => calls.filter((c) => c.method !== "GET") }; }; @@ -258,3 +266,40 @@ test("an issue for another release does not count as this release's issue", asyn await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1)], api: f.api }); assert.equal(f.writes()[0].method, "POST"); }); + +test("missing items that would push the body past the limit go in one comment instead", async () => { + const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1, { body: "a".repeat(60000) })], 65536)}`; + const f = issueApi([{ number: 7, state: "open", body }]); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2, { body: "b".repeat(10000) })], api: f.api }); + const writes = f.writes(); + assert.equal(writes.length, 1); + assert.equal(writes[0].path, `/repos/${REPO}/issues/7/comments`); + assert.equal(writes[0].method, "POST"); + assert.ok(writes[0].body.body.includes(handoffMarker(handoff(2).key))); + assert.ok(!writes[0].body.body.includes(handoffMarker(handoff(1).key))); + assert.ok(writes[0].body.body.length <= 65536); +}); + +test("a key already in a comment, past the first page, is not added again", async () => { + const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; + const comments = [...Array.from({ length: 100 }, () => ({ body: "noise" })), { body: renderItems([handoff(2)], 65536) }]; + const f = issueApi([{ number: 7, state: "open", body }], comments); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2), handoff(3)], api: f.api }); + const writes = f.writes(); + assert.equal(writes.length, 1); + assert.equal(writes[0].method, "PATCH"); + assert.ok(writes[0].body.body.includes(handoffMarker(handoff(3).key))); + assert.ok(!writes[0].body.body.includes(handoffMarker(handoff(2).key))); + assert.equal(f.calls.filter((c) => c.path.includes("/comments?")).length, 2); +}); + +test("a comment read that fails, or runs past the page cap, files nothing", async () => { + const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; + const hs = [handoff(1), handoff(2)]; + const bad = issueApi([{ number: 7, state: "open", body }], { message: "Server Error" }); + await assert.rejects(fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, api: bad.api }), /not come back as a list/); + assert.equal(bad.writes().length, 0); + const many = issueApi([{ number: 7, state: "open", body }], Array.from({ length: 1000 }, () => ({ body: "" }))); + await assert.rejects(fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, api: many.api }), /more comments/); + assert.equal(many.writes().length, 0); +}); From 35671d0f70d2618e82366867d4777bb9b05c2de8 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:04:24 -0500 Subject: [PATCH 15/35] fix(ci): run the changelog model on the first attempt only A partial or full re-run of upstream-apply-changelog skips the model job, so a retry of the model is a new dispatch. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/upstream-apply-changelog.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index bf8275dc8a4..6ea3dd4cbe0 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -95,7 +95,9 @@ jobs: # A dispatch, and any re-run of one, must come from a listed human; the # broker checks the same, so this only keeps the model from running for # nothing. Actor-only mode takes no schedule event, so a re-run of a - # scheduled run stops here: dispatch the release instead. + # scheduled run stops here: dispatch the release instead. Any re-run, + # partial or full, also skips the model job (its run_attempt check), so + # a retry of the model is a new dispatch. - name: Check who started the run id: who if: steps.kill.outputs.proceed == 'true' && (github.event_name == 'workflow_dispatch' || github.run_attempt != '1') @@ -162,7 +164,8 @@ jobs: # removed from the working tree first (R13). model: needs: gate - if: needs.gate.outputs.proceed == 'true' + # The first attempt only: a re-run never runs the model again. + if: needs.gate.outputs.proceed == 'true' && github.run_attempt == '1' runs-on: ubuntu-24.04 timeout-minutes: 90 cache-mode: read From 909af78851a0f412fd0cc60303a234def993197b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:04:59 -0500 Subject: [PATCH 16/35] fix(ci): trust only the lanes App's changelog release issue fileReleaseHandoffs searches with author:app/melodic-automation-lanes, takes an issue only when its user is the lanes App, and reads key markers only from the App's comments, so an outsider's issue or comment carrying the markers cannot suppress a handoff. Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog.mjs | 18 ++++--- .../upstream-apply-changelog.test.mjs | 47 +++++++++++++++---- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index 5fd211bbf4b..de43be7171b 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -14,6 +14,7 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { createGitHubApi } from "../apply-patch/apply-patch.mjs"; +import { LANES_APP_LOGIN } from "../pr-merge/merge-holds.mjs"; export const RELEASE = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,5})$/; const PLUGIN = /^[a-z0-9][a-z0-9-]{0,63}$/; @@ -55,15 +56,19 @@ export function renderItems(handoffs, budget) { } // Files a release's handoffs as one issue, found again by its release marker -// in any state. An open issue missing some keys, in its body and comments, -// gets them appended in one update, or in one comment when the body would -// pass the limit; a closed one is left alone. +// in any state. Only an issue the lanes App opened counts, and only key +// markers in its body and the App's comments: anyone can type a marker. An +// open issue missing some keys gets them appended in one update, or in one +// comment when the body would pass the limit; a closed one is left alone. export async function fileReleaseHandoffs({ repo, release, handoffs, api }) { if (!handoffs.length) return "no handoffs; nothing filed"; const marker = releaseMarker(release); - const q = encodeURIComponent(`repo:${repo} is:issue in:body "upstream-apply-changelog release=${release}"`); + const app = LANES_APP_LOGIN.replace(/\[bot\]$/, ""); + const q = encodeURIComponent(`repo:${repo} is:issue author:app/${app} in:body "upstream-apply-changelog release=${release}"`); const found = await api(`/search/issues?q=${q}&per_page=20`); - const issue = (found?.items ?? []).find((i) => typeof i.body === "string" && i.body.includes(marker)); + const issue = (found?.items ?? []).find( + (i) => i?.user?.login === LANES_APP_LOGIN && typeof i.body === "string" && i.body.includes(marker), + ); if (!issue) { const head = `${marker}\n\n`; const body = head + renderItems(handoffs, ISSUE_BODY_MAX - head.length); @@ -72,7 +77,8 @@ export async function fileReleaseHandoffs({ repo, release, handoffs, api }) { } if (issue.state !== "open") return `the release issue #${issue.number} is closed; nothing filed`; const comments = await listComments(api, repo, issue.number); - const have = new Set([issue.body, ...comments.map((c) => c?.body ?? "")].flatMap(keysIn)); + const own = comments.filter((c) => c?.user?.login === LANES_APP_LOGIN && typeof c.body === "string"); + const have = new Set([issue.body, ...own.map((c) => c.body)].flatMap(keysIn)); const missing = handoffs.filter((h) => !have.has(h.key)); if (!missing.length) return `the release issue #${issue.number} already holds every handoff`; const base = `${issue.body.trimEnd()}\n\n`; diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index e449a4d3d4c..4534506208b 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -167,6 +167,8 @@ test("the planned commits are read oldest first with their files, and a merge co const REPO = "o/r"; const REL = "2.1.300"; +const APP = { login: "melodic-automation-lanes[bot]", type: "Bot" }; +const OUTSIDER = { login: "someone", type: "User" }; const handoff = (n, over = {}) => ({ key: `changelog:${REL}..${REL}:adopt:plugins/x/${n}.sh`, kind: "adopt", title: `adopt ${n}`, body: `body ${n}\nline two`, ...over }); // A fake API: answers the search with `items`, a comment read with the page of // `comments` it asks for (or `comments` itself when not a list), and records every call. @@ -235,14 +237,14 @@ test("bodies over the issue limit are cut in proportion, every key kept, and a l test("a release issue holding every key gets no write", async () => { const hs = [handoff(1), handoff(2)]; const body = `${releaseMarker(REL)}\n\n${renderItems(hs, 65536)}`; - const f = issueApi([{ number: 7, state: "open", body }]); + const f = issueApi([{ user: APP, number: 7, state:"open", body }]); await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, api: f.api }); assert.equal(f.writes().length, 0); }); test("an open release issue missing a key gets the missing item appended in one update", async () => { const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; - const f = issueApi([{ number: 7, state: "open", body }]); + const f = issueApi([{ user: APP, number: 7, state:"open", body }]); await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2)], api: f.api }); const writes = f.writes(); assert.equal(writes.length, 1); @@ -256,20 +258,20 @@ test("an open release issue missing a key gets the missing item appended in one test("a closed release issue gets no write, even when it lacks a key", async () => { const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; - const f = issueApi([{ number: 7, state: "closed", body }]); + const f = issueApi([{ user: APP, number: 7, state:"closed", body }]); await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2)], api: f.api }); assert.equal(f.writes().length, 0); }); test("an issue for another release does not count as this release's issue", async () => { - const f = issueApi([{ number: 7, state: "open", body: `${releaseMarker("2.1.30")}\n` }]); + const f = issueApi([{ user: APP, number: 7, state:"open", body: `${releaseMarker("2.1.30")}\n` }]); await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1)], api: f.api }); assert.equal(f.writes()[0].method, "POST"); }); test("missing items that would push the body past the limit go in one comment instead", async () => { const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1, { body: "a".repeat(60000) })], 65536)}`; - const f = issueApi([{ number: 7, state: "open", body }]); + const f = issueApi([{ user: APP, number: 7, state:"open", body }]); await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2, { body: "b".repeat(10000) })], api: f.api }); const writes = f.writes(); assert.equal(writes.length, 1); @@ -282,8 +284,8 @@ test("missing items that would push the body past the limit go in one comment in test("a key already in a comment, past the first page, is not added again", async () => { const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; - const comments = [...Array.from({ length: 100 }, () => ({ body: "noise" })), { body: renderItems([handoff(2)], 65536) }]; - const f = issueApi([{ number: 7, state: "open", body }], comments); + const comments = [...Array.from({ length: 100 }, () => ({ body: "noise" })), { user: APP, body: renderItems([handoff(2)], 65536) }]; + const f = issueApi([{ user: APP, number: 7, state:"open", body }], comments); await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2), handoff(3)], api: f.api }); const writes = f.writes(); assert.equal(writes.length, 1); @@ -296,10 +298,37 @@ test("a key already in a comment, past the first page, is not added again", asyn test("a comment read that fails, or runs past the page cap, files nothing", async () => { const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; const hs = [handoff(1), handoff(2)]; - const bad = issueApi([{ number: 7, state: "open", body }], { message: "Server Error" }); + const bad = issueApi([{ user: APP, number: 7, state:"open", body }], { message: "Server Error" }); await assert.rejects(fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, api: bad.api }), /not come back as a list/); assert.equal(bad.writes().length, 0); - const many = issueApi([{ number: 7, state: "open", body }], Array.from({ length: 1000 }, () => ({ body: "" }))); + const many = issueApi([{ user: APP, number: 7, state:"open", body }], Array.from({ length: 1000 }, () => ({ body: "" }))); await assert.rejects(fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, api: many.api }), /more comments/); assert.equal(many.writes().length, 0); }); + +test("the search asks for issues the lanes App opened", async () => { + const f = issueApi(); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1)], api: f.api }); + const q = decodeURIComponent(f.calls[0].path.match(/q=([^&]+)/)[1]); + assert.match(q, /(^| )author:app\/melodic-automation-lanes( |$)/); +}); + +test("an outsider's issue carrying the release marker is ignored and a new issue is filed", async () => { + const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; + const f = issueApi([{ user: OUTSIDER, number: 7, state: "open", body }]); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1)], api: f.api }); + const writes = f.writes(); + assert.equal(writes.length, 1); + assert.equal(writes[0].path, `/repos/${REPO}/issues`); + assert.equal(writes[0].method, "POST"); +}); + +test("a key marker in an outsider's comment does not count as filed", async () => { + const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; + const f = issueApi([{ user: APP, number: 7, state: "open", body }], [{ user: OUTSIDER, body: renderItems([handoff(2)], 65536) }]); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2)], api: f.api }); + const writes = f.writes(); + assert.equal(writes.length, 1); + assert.equal(writes[0].method, "PATCH"); + assert.ok(writes[0].body.body.includes(handoffMarker(handoff(2).key))); +}); From 08f2d137d7fbad9d11bb32ed584ea066c4fb3b13 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:06:32 -0500 Subject: [PATCH 17/35] fix(ci): keep model text out of the changelog scan job's logs Before the credential scan, nothing prints plan or bundle text: validatePlan and checkRawLog name the field and index, the plan parse throws a fixed message, the bundle verifies quietly, and its list-heads output is checked against the validated plan heads without being printed. A plan naming branches with no bundle, or a head equal to the base, fails. Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog.mjs | 94 ++++++++++++------- .../upstream-apply-changelog.test.mjs | 44 ++++++++- .../workflows/upstream-apply-changelog.yml | 30 ++++-- 3 files changed, 120 insertions(+), 48 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index de43be7171b..a4ef84b3485 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -170,59 +170,81 @@ export function startedInWindow(artifacts, now) { ).length; } +// The plan file parsed and checked. A parse error names no input: Node +// quotes the text it could not parse, and the plan is model output. +export function readPlan(text, ctx) { + let plan; + try { + plan = JSON.parse(text); + } catch { + throw new Error("ci-plan.json: not valid JSON"); + } + return validatePlan(plan, ctx); +} + +// Every message names a field and its index, never a value: the plan is +// model output and the scan job reports these before it scans anything. export function validatePlan(plan, { release, baseSha }) { const fail = (why) => { throw new Error(`ci-plan.json: ${why}`); }; - if (plan?.schema !== "changelog-ci-plan/1") fail("not a changelog-ci-plan/1 plan"); + if (plan?.schema !== "changelog-ci-plan/1") fail("schema is not changelog-ci-plan/1"); if (plan.status === "stopped") return { branches: [], handoffs: [] }; if (plan.status !== "planned") fail("status is neither planned nor stopped"); - if (plan.range !== `${release}..${release}`) fail("its range is not this run's release"); - if (plan.base_sha !== baseSha) fail("its base_sha is not the commit the run checked out"); + if (plan.range !== `${release}..${release}`) fail("range is not this run's release"); + if (plan.base_sha !== baseSha) fail("base_sha is not the commit the run checked out"); const branches = Array.isArray(plan.branches) ? plan.branches : fail("branches is not a list"); const handoffs = Array.isArray(plan.handoffs) ? plan.handoffs : fail("handoffs is not a list"); const seen = new Set(); - for (const b of branches) { - if (!PLUGIN.test(b?.plugin ?? "")) fail("a branch names a malformed plugin"); - if (seen.has(b.plugin)) fail(`plugin ${b.plugin} has two branches`); + branches.forEach((b, i) => { + const at = `branches[${i}]`; + if (!PLUGIN.test(b?.plugin ?? "")) fail(`${at}.plugin is malformed`); + if (seen.has(b.plugin)) fail(`${at}.plugin repeats an earlier branch's plugin`); seen.add(b.plugin); - if (b.branch !== branchFor(b.plugin, release)) fail(`${b.plugin}'s branch is not ${branchFor(b.plugin, release)}`); - if (b.base_sha !== baseSha || !SHA.test(b.head_sha ?? "")) fail(`${b.branch} has a wrong base or head`); + if (b.branch !== branchFor(b.plugin, release)) fail(`${at}.branch is not changelog//v`); + if (b.base_sha !== baseSha) fail(`${at}.base_sha is not the run's base`); + if (!SHA.test(b.head_sha ?? "") || b.head_sha === baseSha) fail(`${at}.head_sha is not a commit after the base`); const title = b.pr?.title; if (typeof title !== "string" || !title.startsWith(`chore(${b.plugin}): `) || title.length > 200 || /[\r\n]/.test(title)) { - fail(`${b.branch}'s pull request title is not chore(${b.plugin}): ...`); + fail(`${at}.pr.title is not chore(): ... on one line of at most 200 characters`); } - if (typeof b.pr?.body !== "string" || b.pr.body.length > 60000) fail(`${b.branch}'s pull request body is missing or too long`); - } + if (typeof b.pr?.body !== "string" || b.pr.body.length > 60000) fail(`${at}.pr.body is missing or too long`); + }); const keys = new Set(); - for (const h of handoffs) { - if (!KEY.test(h?.key ?? "") || !h.key.startsWith(`changelog:${release}..${release}:`)) fail("a handoff key is malformed"); - if (keys.has(h.key)) fail(`handoff ${h.key} appears twice`); + handoffs.forEach((h, i) => { + const at = `handoffs[${i}]`; + if (!KEY.test(h?.key ?? "") || !h.key.startsWith(`changelog:${release}..${release}:`)) fail(`${at}.key is malformed`); + if (keys.has(h.key)) fail(`${at}.key repeats an earlier handoff's key`); keys.add(h.key); - if (typeof h.title !== "string" || !h.title || h.title.length > 256 || /[\r\n]/.test(h.title)) fail(`${h.key} has a bad title`); - if (typeof h.body !== "string" || h.body.length > 60000) fail(`${h.key} has a bad body`); - } + if (typeof h.title !== "string" || !h.title || h.title.length > 256 || /[\r\n]/.test(h.title)) fail(`${at}.title is bad`); + if (typeof h.body !== "string" || h.body.length > 60000) fail(`${at}.body is bad`); + }); return { branches, handoffs }; } -// Every path a commit in base..head changes, either side of a rename, must -// sit under plugins// or .changes//, as a regular file. -export function checkRawLog(raw, plugin) { +// Every path the raw diff changes, either side of a rename, must sit under +// plugins// or .changes//, as a regular file. Messages name +// the branch index and the raw line number, never a path. +export function checkRawLog(raw, plugin, index = 0) { const allowed = [`plugins/${plugin}/`, `.changes/${plugin}/`]; - for (const line of raw.split("\n").filter((l) => l.startsWith(":"))) { - const [meta, ...paths] = line.split("\t"); - const [srcMode, dstMode] = meta.slice(1).split(" "); - for (const mode of [srcMode, dstMode]) { - if (mode !== "000000" && mode !== "100644" && mode !== "100755") { - throw new Error(`a commit writes mode ${mode}; only regular files may change`); + raw + .split("\n") + .filter((l) => l.startsWith(":")) + .forEach((line, n) => { + const at = `branches[${index}] raw line ${n + 1}`; + const [meta, ...paths] = line.split("\t"); + const [srcMode, dstMode] = meta.slice(1).split(" "); + for (const mode of [srcMode, dstMode]) { + if (mode !== "000000" && mode !== "100644" && mode !== "100755") { + throw new Error(`${at} writes a mode other than a regular file; only regular files may change`); + } } - } - for (const p of paths) { - if (!allowed.some((prefix) => p.startsWith(prefix)) || p.split("/").includes("..")) { - throw new Error(`a commit changes ${p}, outside plugins/${plugin}/ and .changes/${plugin}/`); + for (const p of paths) { + if (!allowed.some((prefix) => p.startsWith(prefix)) || p.split("/").includes("..")) { + throw new Error(`${at} changes a path outside plugins/${plugin}/ and .changes/${plugin}/`); + } } - } - } + }); } const git = (repo, args, extra = {}) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", ...extra }).trim(); @@ -310,16 +332,16 @@ async function gate(env) { async function push(env) { const { RELEASE: release, BASE_SHA: baseSha, PLAN_FILE, BUNDLE_FILE, REPO_DIR: repoDir, GITHUB_REPOSITORY: repo } = env; - const plan = validatePlan(JSON.parse(readFileSync(PLAN_FILE, "utf8")), { release, baseSha }); + const plan = readPlan(readFileSync(PLAN_FILE, "utf8"), { release, baseSha }); const writer = createGitHubApi({ token: env.GH_TOKEN, repository: repo, apiUrl: env.GITHUB_API_URL || "https://api.github.com" }); const heads = {}; if (plan.branches.length && !existsSync(BUNDLE_FILE)) throw new Error("the plan names branches and the run left no bundle"); - for (const b of plan.branches) { + for (const [i, b] of plan.branches.entries()) { const local = `refs/upstream-apply-changelog/${b.plugin}`; git(repoDir, ["fetch", "--no-tags", BUNDLE_FILE, `${b.head_sha}:${local}`]); if (git(repoDir, ["rev-parse", local]) !== b.head_sha) throw new Error(`${b.branch} is not at its planned head`); if (git(repoDir, ["merge-base", baseSha, b.head_sha]) !== baseSha) throw new Error(`${b.branch} does not start at the base`); - checkRawLog(git(repoDir, ["log", "--format=", "--raw", "--no-renames", "--no-abbrev", `${baseSha}..${b.head_sha}`]), b.plugin); + checkRawLog(git(repoDir, ["log", "--format=", "--raw", "--no-renames", "--no-abbrev", `${baseSha}..${b.head_sha}`]), b.plugin, i); const headTree = git(repoDir, ["rev-parse", `${b.head_sha}^{tree}`]); const existing = await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true }); if (existing) { @@ -345,7 +367,7 @@ async function push(env) { async function publish(env) { const { RELEASE: release, BASE_SHA: baseSha, PLAN_FILE, GITHUB_REPOSITORY: repo } = env; - const plan = validatePlan(JSON.parse(readFileSync(PLAN_FILE, "utf8")), { release, baseSha }); + const plan = readPlan(readFileSync(PLAN_FILE, "utf8"), { release, baseSha }); const owner = repo.split("/")[0]; // push's heads output: each branch it pushed or found at the planned tree. const heads = JSON.parse(env.HEADS || "{}"); diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index 4534506208b..7053da842cb 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -15,6 +15,7 @@ import { releaseMarker, renderItems, readCommits, + readPlan, releasesFromStatus, replayCommits, startedInWindow, @@ -74,21 +75,54 @@ test("a plan for another release, base or branch name is refused", () => { assert.throws(() => validatePlan(plan({ base_sha: HEAD }), ctx), /base_sha/); const renamed = plan(); renamed.branches[0].branch = "main"; - assert.throws(() => validatePlan(renamed, ctx), /branch is not changelog\/hooks-kit\/v2\.1\.300/); + assert.throws(() => validatePlan(renamed, ctx), /branches\[0\]\.branch is not changelog\/\/v/); const titled = plan(); titled.branches[0].pr.title = "feat(other): x"; - assert.throws(() => validatePlan(titled, ctx), /title/); - assert.throws(() => validatePlan(plan({ handoffs: [{ key: "changelog:2.1.1..2.1.1:adopt:x", title: "t", body: "b" }] }), ctx), /key/); + assert.throws(() => validatePlan(titled, ctx), /branches\[0\]\.pr\.title/); + assert.throws(() => validatePlan(plan({ handoffs: [{ key: "changelog:2.1.1..2.1.1:adopt:x", title: "t", body: "b" }] }), ctx), /handoffs\[0\]\.key/); + const empty = plan(); + empty.branches[0].head_sha = BASE; + assert.throws(() => validatePlan(empty, ctx), /branches\[0\]\.head_sha/); assert.equal(branchFor("hooks-kit", "2.1.300"), "changelog/hooks-kit/v2.1.300"); }); +test("a refusal names the field and index, never the plan's value", () => { + const SECRET = "zzPLANTEDzz"; + const cases = []; + const renamed = plan(); + renamed.branches[0].branch = SECRET; + cases.push([renamed, /branches\[0\]\.branch/]); + const twice = plan(); + twice.branches.push({ ...twice.branches[0], branch: SECRET }); + cases.push([twice, /branches\[1\]\.plugin/]); + const titled = plan(); + titled.branches[0].pr.title = `chore(hooks-kit): ${SECRET}\n`; + cases.push([titled, /branches\[0\]\.pr\.title/]); + const keyed = plan({ handoffs: [{ key: `changelog:2.1.300..2.1.300:adopt:${SECRET}`, title: "t", body: "b" }, { key: `changelog:2.1.300..2.1.300:adopt:${SECRET}`, title: "t", body: "b" }] }); + cases.push([keyed, /handoffs\[1\]\.key/]); + const handTitled = plan({ handoffs: [{ key: "changelog:2.1.300..2.1.300:adopt:x", title: `${SECRET}\n`, body: "b" }] }); + cases.push([handTitled, /handoffs\[0\]\.title/]); + for (const [p, want] of cases) { + assert.throws( + () => validatePlan(p, ctx), + (e) => want.test(e.message) && !e.message.includes(SECRET), + ); + } + assert.throws(() => readPlan(`{"schema": ${SECRET}}`, ctx), (e) => /not valid JSON/.test(e.message) && !e.message.includes(SECRET)); + assert.equal(readPlan(JSON.stringify(plan()), ctx).branches.length, 1); + assert.throws( + () => checkRawLog(`:100644 100644 1 2 M\tplugins/hooks-kit/a\n:100644 100644 1 2 M\t${SECRET}`, "hooks-kit", 3), + (e) => /branches\[3\] raw line 2/.test(e.message) && !e.message.includes(SECRET), + ); +}); + test("a commit outside its plugin, or writing a link or submodule, is refused", () => { const ok = ":000000 100644 0000 1111 A\tplugins/hooks-kit/SKILL.md\n:000000 100644 0000 2222 A\t.changes/hooks-kit/x.md"; assert.doesNotThrow(() => checkRawLog(ok, "hooks-kit")); assert.throws(() => checkRawLog(":100644 100644 1 2 M\tplugins/other/SKILL.md", "hooks-kit"), /outside/); assert.throws(() => checkRawLog(":100644 100644 1 2 M\t.github/workflows/x.yml", "hooks-kit"), /outside/); - assert.throws(() => checkRawLog(":000000 120000 0 2 A\tplugins/hooks-kit/link", "hooks-kit"), /mode 120000/); - assert.throws(() => checkRawLog(":000000 160000 0 2 A\tplugins/hooks-kit/sub", "hooks-kit"), /mode 160000/); + assert.throws(() => checkRawLog(":000000 120000 0 2 A\tplugins/hooks-kit/link", "hooks-kit"), /mode other than a regular file/); + assert.throws(() => checkRawLog(":000000 160000 0 2 A\tplugins/hooks-kit/sub", "hooks-kit"), /mode other than a regular file/); }); test("only the done marker skips a release, so a run after a failed publish finishes the work", () => { diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 6ea3dd4cbe0..8ab2aeffe60 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -450,25 +450,41 @@ jobs: BASE_SHA: ${{ needs.gate.outputs.base-sha }} run: | [ -f "$IN/plan.json" ] || { echo "::error::the model job left no ci-plan.json"; exit 1; } + # Nothing here prints model text before the scans below: the + # plan's checks name fields, and git runs quiet. node --input-type=module -e ' - import { readFileSync } from "node:fs"; - import { validatePlan } from "./.base/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs"; - const plan = validatePlan(JSON.parse(readFileSync(process.env.IN + "/plan.json", "utf8")), + import { readFileSync, writeFileSync } from "node:fs"; + import { readPlan } from "./.base/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs"; + const plan = readPlan(readFileSync(process.env.IN + "/plan.json", "utf8"), { release: process.env.RELEASE, baseSha: process.env.BASE_SHA }); + writeFileSync(process.env.IN + "/heads.txt", plan.branches.map((b) => b.head_sha + "\n").join("")); console.log(plan.branches.length + " branches, " + plan.handoffs.length + " handoffs"); ' : >"$IN/diffs.txt" count=0 - if [ -f "$IN/branches.bundle" ]; then - git -C .base bundle verify "$IN/branches.bundle" + if [ -s "$IN/heads.txt" ] && [ ! -f "$IN/branches.bundle" ]; then + echo "::error::the plan names branches and the model job left no bundle" + exit 1 + fi + if [ -s "$IN/heads.txt" ]; then + git -C .base bundle verify --quiet "$IN/branches.bundle" >/dev/null 2>&1 || { echo "::error::the branches bundle does not verify against the base"; exit 1; } + # Every planned head must be a head of the bundle; its ref names + # are model text and are never printed. + mapfile -t have < <(git -C .base bundle list-heads "$IN/branches.bundle" 2>/dev/null | cut -d' ' -f1 | sort -u) + mapfile -t want < <(sort -u "$IN/heads.txt") + missing=$(comm -13 <(printf '%s\n' "${have[@]}") <(printf '%s\n' "${want[@]}") | grep -c . || true) + if [ "$missing" != 0 ]; then + echo "::error::$missing of ${#want[@]} planned heads are not heads of the bundle" + exit 1 + fi while read -r head; do [[ $head =~ ^[0-9a-f]{40}$ ]] || exit 1 - git -C .base fetch --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" + git -C .base fetch --quiet --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" >/dev/null 2>&1 || { echo "::error::a planned head did not fetch from the bundle"; exit 1; } git -C .base diff --no-renames --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" # Push replays these messages as public commits, so they are scanned too. git -C .base log --format=%B "$BASE_SHA..$head" >>"$IN/diffs.txt" count=$((count + 1)) - done < <(jq -r '.branches[]?.head_sha' "$IN/plan.json") + done <"$IN/heads.txt" fi echo "branches=$count" >>"$GITHUB_OUTPUT" From b2a53f5cfdb6316b934b2e5556c140dea5e044af Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:07:44 -0500 Subject: [PATCH 18/35] fix(ci): push each changelog branch as one commit of its scanned tree Push no longer replays the model's commits: each branch becomes one lanes App commit on the base whose tree is the planned head's (still checked) and whose message is the plan's scanned pull request title. The path check reads the same net diff, so a merge commit cannot hide a change from it. The scan job scans the net diff and the plan, and no longer logs commit messages, which are no longer published. Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog.mjs | 75 +++++++++---------- .../upstream-apply-changelog.test.mjs | 59 ++++++++------- .../workflows/upstream-apply-changelog.yml | 14 ++-- 3 files changed, 76 insertions(+), 72 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index a4ef84b3485..fafd0a6b2ab 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -4,11 +4,11 @@ // gate picks the release, skips one already processed, applies the // per-day cap (read-only GITHUB_TOKEN with actions: read) // push checks each planned branch and recreates it through the Git Data -// API as lanes App commits (mutate-branch token) +// API as one lanes App commit on the base (mutate-branch token) // publish opens one PR per pushed branch and files the release's handoffs // as one issue (publish-artifact token) // The plan and the bundle come from the model job, so every field is data: -// names are held to fixed shapes and every commit to its plugin's paths. +// names are held to fixed shapes and each branch's net diff to its plugin's paths. import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; @@ -250,44 +250,38 @@ export function checkRawLog(raw, plugin, index = 0) { const git = (repo, args, extra = {}) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", ...extra }).trim(); const MAX_BLOB = 64 * 1024 * 1024; -// The commits in base..head, oldest first, each as its message and the files -// it changes: a deleted file has content null. A merge commit is refused. -export function readCommits(repoDir, baseSha, headSha) { - if (git(repoDir, ["rev-list", "--min-parents=2", `${baseSha}..${headSha}`])) throw new Error("a planned branch holds a merge commit"); - return git(repoDir, ["rev-list", "--reverse", `${baseSha}..${headSha}`]) +// The net raw diff base..head: what the one published commit changes, and +// what the scan job scanned. Intermediate commits are never published. +export const netRawDiff = (repoDir, baseSha, headSha) => + git(repoDir, ["diff-tree", "-r", "--no-renames", "--raw", "--no-abbrev", baseSha, headSha]); + +// The files the net diff changes: a deleted file has content null. +export function readNetChanges(repoDir, baseSha, headSha) { + return netRawDiff(repoDir, baseSha, headSha) .split("\n") - .filter(Boolean) - .map((sha) => ({ - message: execFileSync("git", ["-C", repoDir, "show", "-s", "--format=%B", sha], { encoding: "utf8" }), - entries: git(repoDir, ["diff-tree", "-r", "--no-renames", "--raw", "--no-abbrev", "--no-commit-id", sha]) - .split("\n") - .filter((line) => line.startsWith(":")) - .map((line) => { - const [meta, path] = line.split("\t"); - const [srcMode, dstMode, , dstSha] = meta.slice(1).split(" "); - if (dstMode === "000000") return { path, mode: srcMode, content: null }; - return { path, mode: dstMode, content: execFileSync("git", ["-C", repoDir, "cat-file", "blob", dstSha], { maxBuffer: MAX_BLOB }) }; - }), - })); + .filter((line) => line.startsWith(":")) + .map((line) => { + const [meta, path] = line.split("\t"); + const [srcMode, dstMode, , dstSha] = meta.slice(1).split(" "); + if (dstMode === "000000") return { path, mode: srcMode, content: null }; + return { path, mode: dstMode, content: execFileSync("git", ["-C", repoDir, "cat-file", "blob", dstSha], { maxBuffer: MAX_BLOB }) }; + }); } -// Replays the commits through the Git Data API with no author or committer, -// so GitHub authors and signs each as the lanes App, as apply-patch does -// (apply-patch.mjs createGitHubApi). Returns the new head, whose tree must be -// the planned head's: what is pushed is exactly what the scan job scanned. -export async function replayCommits(commits, { baseSha, baseTree, headTree, api }) { - let parent = baseSha; - let tree = baseTree; - for (const { message, entries } of commits) { - const blobs = []; - for (const { path, mode, content } of entries) { - blobs.push({ path, mode, type: "blob", sha: content === null ? null : await api.createBlob(Buffer.from(content).toString("base64")) }); - } - if (blobs.length) tree = await api.createTree(tree, blobs); - parent = await api.createCommit(message, tree, [parent]); +// One commit on the base, with no author or committer, so GitHub authors and +// signs it as the lanes App, as apply-patch does (apply-patch.mjs +// createGitHubApi). Its message is the scanned pull request title and its +// tree must be the planned head's: what is pushed is exactly what the scan +// job scanned. Returns the new commit. +export async function commitNetTree(changes, { message, baseSha, baseTree, headTree, api }) { + if (!changes.length) throw new Error("a planned branch changes nothing against the base"); + const entries = []; + for (const { path, mode, content } of changes) { + entries.push({ path, mode, type: "blob", sha: content === null ? null : await api.createBlob(Buffer.from(content).toString("base64")) }); } - if (tree !== headTree) throw new Error(`the replayed tree ${tree} is not the planned head's ${headTree}`); - return parent; + const tree = await api.createTree(baseTree, entries); + if (tree !== headTree) throw new Error(`the built tree ${tree} is not the planned head's ${headTree}`); + return api.createCommit(message, tree, [baseSha]); } async function gate(env) { @@ -339,9 +333,9 @@ async function push(env) { for (const [i, b] of plan.branches.entries()) { const local = `refs/upstream-apply-changelog/${b.plugin}`; git(repoDir, ["fetch", "--no-tags", BUNDLE_FILE, `${b.head_sha}:${local}`]); - if (git(repoDir, ["rev-parse", local]) !== b.head_sha) throw new Error(`${b.branch} is not at its planned head`); - if (git(repoDir, ["merge-base", baseSha, b.head_sha]) !== baseSha) throw new Error(`${b.branch} does not start at the base`); - checkRawLog(git(repoDir, ["log", "--format=", "--raw", "--no-renames", "--no-abbrev", `${baseSha}..${b.head_sha}`]), b.plugin, i); + if (git(repoDir, ["rev-parse", local]) !== b.head_sha) throw new Error(`branches[${i}] is not at its planned head`); + if (git(repoDir, ["merge-base", baseSha, b.head_sha]) !== baseSha) throw new Error(`branches[${i}] does not start at the base`); + checkRawLog(netRawDiff(repoDir, baseSha, b.head_sha), b.plugin, i); const headTree = git(repoDir, ["rev-parse", `${b.head_sha}^{tree}`]); const existing = await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true }); if (existing) { @@ -352,7 +346,8 @@ async function push(env) { console.log(`${b.branch} already exists${heads[b.branch] ? "" : " with another tree"}; not pushed`); continue; } - const head = await replayCommits(readCommits(repoDir, baseSha, b.head_sha), { + const head = await commitNetTree(readNetChanges(repoDir, baseSha, b.head_sha), { + message: b.pr.title, baseSha, baseTree: git(repoDir, ["rev-parse", `${baseSha}^{tree}`]), headTree, diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index 7053da842cb..e84c5bd2824 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -9,15 +9,16 @@ import { alreadyProcessed, branchFor, checkRawLog, + commitNetTree, fileReleaseHandoffs, handoffMarker, + netRawDiff, newestRelease, - releaseMarker, - renderItems, - readCommits, + readNetChanges, readPlan, + releaseMarker, releasesFromStatus, - replayCommits, + renderItems, startedInWindow, validatePlan, } from "./upstream-apply-changelog.mjs"; @@ -153,26 +154,28 @@ function fakeApi() { }; } -test("the replay makes one App commit per planned commit, chained from the base, and deletes a removed file", async () => { +test("a branch becomes one App commit on the base, titled with its pull request title, deleting a removed file", async () => { const api = fakeApi(); - const commits = [ - { message: "first\n", entries: [{ path: "plugins/p/a.md", mode: "100644", content: Buffer.from("a") }] }, - { message: "second\n", entries: [{ path: "plugins/p/a.md", mode: "100644", content: null }] }, + const changes = [ + { path: "plugins/p/a.md", mode: "100644", content: Buffer.from("a") }, + { path: "plugins/p/old.md", mode: "100644", content: null }, ]; - const tree1 = "tree:t0:plugins/p/a.md=blob:YQ==-1-2"; - const tree2 = `tree:${tree1}:plugins/p/a.md=null-4`; - const head = await replayCommits(commits, { baseSha: BASE, baseTree: "t0", headTree: tree2, api }); - assert.equal(api.calls[2], `commit:first:${tree1}:${BASE}`); - assert.equal(api.calls[4], `commit:second:${tree2}:commit:first:${tree1}:${BASE}-3`); - assert.equal(head, `${api.calls[4]}-5`); + const tree = "tree:t0:plugins/p/a.md=blob:YQ==-1,plugins/p/old.md=null-2"; + const head = await commitNetTree(changes, { message: "chore(p): title", baseSha: BASE, baseTree: "t0", headTree: tree, api }); + assert.equal(api.calls.length, 3); + assert.equal(api.calls[2], `commit:chore(p): title:${tree}:${BASE}`); + assert.equal(head, `${api.calls[2]}-3`); }); -test("a replay whose tree is not the planned head's is refused before any ref is made", async () => { - const commits = [{ message: "m", entries: [{ path: "plugins/p/a.md", mode: "100644", content: Buffer.from("a") }] }]; - await assert.rejects(replayCommits(commits, { baseSha: BASE, baseTree: "t0", headTree: "other", api: fakeApi() }), /not the planned head's/); +test("a built tree that is not the planned head's, or an empty change, makes no commit", async () => { + const changes = [{ path: "plugins/p/a.md", mode: "100644", content: Buffer.from("a") }]; + const api = fakeApi(); + await assert.rejects(commitNetTree(changes, { message: "m", baseSha: BASE, baseTree: "t0", headTree: "other", api }), /not the planned head's/); + assert.ok(!api.calls.some((c) => c.startsWith("commit:"))); + await assert.rejects(commitNetTree([], { message: "m", baseSha: BASE, baseTree: "t0", headTree: "t0", api: fakeApi() }), /changes nothing/); }); -test("the planned commits are read oldest first with their files, and a merge commit is refused", (t) => { +test("the net change reads each file once at its head content, whatever the commits in between", (t) => { const dir = mkdtempSync(join(tmpdir(), "uac-")); t.after(() => rmSync(dir, { recursive: true, force: true })); const env = { PATH: process.env.PATH, HOME: dir, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }; @@ -182,21 +185,25 @@ test("the planned commits are read oldest first with their files, and a merge co git("add", "."); git("commit", "-qm", "base"); const base = git("rev-parse", "HEAD"); - writeFileSync(join(dir, "new.md"), "n"); + writeFileSync(join(dir, "new.md"), "n1"); git("add", "."); git("commit", "-qm", "add new"); + writeFileSync(join(dir, "new.md"), "n2"); + git("add", "."); + git("commit", "-qm", "edit new"); git("rm", "-q", "keep.md"); git("commit", "-qm", "drop keep"); - const commits = readCommits(dir, base, git("rev-parse", "HEAD")); - assert.deepEqual(commits.map((c) => c.message.trim()), ["add new", "drop keep"]); - assert.deepEqual(commits[0].entries.map((e) => [e.path, e.mode, String(e.content)]), [["new.md", "100644", "n"]]); - assert.deepEqual(commits[1].entries, [{ path: "keep.md", mode: "100644", content: null }]); + const changes = readNetChanges(dir, base, git("rev-parse", "HEAD")); + assert.deepEqual( + changes.map((e) => [e.path, e.mode, e.content === null ? null : String(e.content)]), + [["keep.md", "100644", null], ["new.md", "100644", "n2"]], + ); + // A merge commit's change shows in the net diff, so the path check sees it. git("checkout", "-qb", "side", base); - writeFileSync(join(dir, "side.md"), "s"); - git("add", "."); + git("checkout", "-q", "main", "--", "new.md"); git("commit", "-qm", "side"); git("merge", "-q", "--no-edit", "main"); - assert.throws(() => readCommits(dir, base, git("rev-parse", "HEAD")), /merge commit/); + assert.match(netRawDiff(dir, base, git("rev-parse", "HEAD")), /\tnew\.md$/m); }); const REPO = "o/r"; diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 8ab2aeffe60..e9d58c73c44 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -8,8 +8,9 @@ name: upstream-apply-changelog # mints for this file on schedule and workflow_dispatch from main only, keyed # on the `release` request field: # push-branches (mutate-branch: contents write) recreates each branch whose -# commits change only plugins// and .changes// through the -# Git Data API, so GitHub authors and signs every commit as the lanes App; +# net diff changes only plugins// and .changes// as one +# commit on the base through the Git Data API, titled with the plan's pull +# request title, so GitHub authors and signs it as the lanes App; # publish (publish-artifact: pull-requests and issues write, contents read) # opens one pull request per pushed branch and files the release's # handoffs as one issue. @@ -480,9 +481,9 @@ jobs: while read -r head; do [[ $head =~ ^[0-9a-f]{40}$ ]] || exit 1 git -C .base fetch --quiet --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" >/dev/null 2>&1 || { echo "::error::a planned head did not fetch from the bundle"; exit 1; } + # The net diff: push publishes one commit with the head's tree + # and the plan's scanned title, never the model's commits. git -C .base diff --no-renames --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" - # Push replays these messages as public commits, so they are scanned too. - git -C .base log --format=%B "$BASE_SHA..$head" >>"$IN/diffs.txt" count=$((count + 1)) done <"$IN/heads.txt" fi @@ -521,8 +522,9 @@ jobs: tokens: github-token # mutate-branch: contents write only. Recreates each planned branch that does - # not exist yet as lanes App commits, as apply-patch does for - # work-resolve-issue, after checking its commits stay inside its plugin. + # not exist yet as one lanes App commit holding the scanned head tree, as + # apply-patch does for work-resolve-issue, after checking its net diff stays + # inside its plugin. push-branches: needs: [gate, scan] if: ${{ !cancelled() && needs.scan.outputs.clean == 'true' && needs.scan.outputs.branches != '0' }} From 6244c74cb9e04e263ab6984c28080f5365a61801 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:08:07 -0500 Subject: [PATCH 19/35] fix(ci): refuse binary files in changelog branches The credential scanners read text, so the scan job and push both refuse a branch whose net diff numstat marks any file binary, naming no path. Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog.mjs | 9 +++++++++ .../upstream-apply-changelog.test.mjs | 7 +++++++ .github/workflows/upstream-apply-changelog.yml | 5 +++++ 3 files changed, 21 insertions(+) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index fafd0a6b2ab..d9c3f03bab2 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -268,6 +268,14 @@ export function readNetChanges(repoDir, baseSha, headSha) { }); } +// `git diff --numstat` marks a binary file `-\t-\t`; the scan reads text, +// so a branch with one is refused. The message names no path. +export function refuseBinary(numstat, index = 0) { + if (numstat.split("\n").some((line) => line.startsWith("-\t-\t"))) { + throw new Error(`branches[${index}] changes a binary file; only text is published`); + } +} + // One commit on the base, with no author or committer, so GitHub authors and // signs it as the lanes App, as apply-patch does (apply-patch.mjs // createGitHubApi). Its message is the scanned pull request title and its @@ -336,6 +344,7 @@ async function push(env) { if (git(repoDir, ["rev-parse", local]) !== b.head_sha) throw new Error(`branches[${i}] is not at its planned head`); if (git(repoDir, ["merge-base", baseSha, b.head_sha]) !== baseSha) throw new Error(`branches[${i}] does not start at the base`); checkRawLog(netRawDiff(repoDir, baseSha, b.head_sha), b.plugin, i); + refuseBinary(git(repoDir, ["diff", "--numstat", "--no-renames", baseSha, b.head_sha]), i); const headTree = git(repoDir, ["rev-parse", `${b.head_sha}^{tree}`]); const existing = await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true }); if (existing) { diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index e84c5bd2824..5b6dab243e1 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -16,6 +16,7 @@ import { newestRelease, readNetChanges, readPlan, + refuseBinary, releaseMarker, releasesFromStatus, renderItems, @@ -198,6 +199,12 @@ test("the net change reads each file once at its head content, whatever the comm changes.map((e) => [e.path, e.mode, e.content === null ? null : String(e.content)]), [["keep.md", "100644", null], ["new.md", "100644", "n2"]], ); + writeFileSync(join(dir, "img.bin"), Buffer.from([0, 1, 2, 0, 255])); + git("add", "."); + git("commit", "-qm", "binary"); + const numstat = git("diff", "--numstat", base, "HEAD"); + assert.throws(() => refuseBinary(numstat, 2), (e) => /branches\[2\] changes a binary file/.test(e.message) && !e.message.includes("img.bin")); + assert.doesNotThrow(() => refuseBinary(git("diff", "--numstat", base, "HEAD~1"), 2)); // A merge commit's change shows in the net diff, so the path check sees it. git("checkout", "-qb", "side", base); git("checkout", "-q", "main", "--", "new.md"); diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index e9d58c73c44..0d12b6f9dd2 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -481,6 +481,11 @@ jobs: while read -r head; do [[ $head =~ ^[0-9a-f]{40}$ ]] || exit 1 git -C .base fetch --quiet --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" >/dev/null 2>&1 || { echo "::error::a planned head did not fetch from the bundle"; exit 1; } + # The scanners read text, so a binary file is refused, unnamed. + if git -C .base diff --numstat --no-renames "$BASE_SHA" "$head" | grep -q $'^-\t-\t'; then + echo "::error::a planned branch changes a binary file; only text is published" + exit 1 + fi # The net diff: push publishes one commit with the head's tree # and the plan's scanned title, never the model's commits. git -C .base diff --no-renames --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" From 6d3c1ebeb9e3d72db1cef3cde0ffe33506ea8901 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:08:43 -0500 Subject: [PATCH 20/35] fix(ci): upload a changelog bundle rebuilt from the planned heads The scan job rebuilds the bundle from the refs/scan heads it fetched and checked, and uploads that copy with the plan from a fresh directory, so nothing else the model's bundle carried reaches push. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/upstream-apply-changelog.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 0d12b6f9dd2..bb319ca82a6 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -447,6 +447,7 @@ jobs: id: check env: IN: ${{ runner.temp }}/scan-in + OUT: ${{ runner.temp }}/scan-out RELEASE: ${{ needs.gate.outputs.release }} BASE_SHA: ${{ needs.gate.outputs.base-sha }} run: | @@ -492,6 +493,16 @@ jobs: count=$((count + 1)) done <"$IN/heads.txt" fi + # What push and publish get: the plan, and a bundle rebuilt from + # the planned heads only, never the model's bundle with whatever + # else it carries. + rm -rf "$OUT" + mkdir -p "$OUT" + cp "$IN/plan.json" "$OUT/plan.json" + if ((count)); then + mapfile -t refs < <(sed 's|^|refs/scan/|' "$IN/heads.txt") + git -C .base bundle create --quiet "$OUT/branches.bundle" "^$BASE_SHA" "${refs[@]}" >/dev/null 2>&1 || { echo "::error::the planned heads did not bundle"; exit 1; } + fi echo "branches=$count" >>"$GITHUB_OUTPUT" - name: Scan the plan for credentials @@ -506,15 +517,14 @@ jobs: with: path: ${{ runner.temp }}/scan-in/diffs.txt + # The plan and the rebuilt bundle, scan-out's only files. - name: Upload the plan and the branches id: upload if: steps.scan-plan.outputs.clean == 'true' && steps.scan-diffs.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: upstream-apply-changelog-plan-${{ github.run_attempt }} - path: | - ${{ runner.temp }}/scan-in/plan.json - ${{ runner.temp }}/scan-in/branches.bundle + path: ${{ runner.temp }}/scan-out/ retention-days: 1 if-no-files-found: error From ce77054e832b39729fca53f9db77873e452078b5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:09:34 -0500 Subject: [PATCH 21/35] fix(ci): scan the changelog pull request and issue text as posted After the plan's checks, the scan job writes every string publish can post (each pull request title and body, the release issue title and body from the same renderer publish uses, and the untruncated handoff items an append or comment posts) decoded to a text file, and scans it before the upload. Co-Authored-By: Claude Opus 5.5 --- .../upstream-apply-changelog.mjs | 22 +++++++++++++--- .../upstream-apply-changelog.test.mjs | 25 +++++++++++++++++++ .../workflows/upstream-apply-changelog.yml | 14 +++++++++-- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index d9c3f03bab2..412e3bfb0f7 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -55,6 +55,24 @@ export function renderItems(handoffs, budget) { return `${TRUNCATED}\n\n${handoffs.map((h, i) => item(h, cut[i])).join("\n")}`; } +// The new release issue's title and body, as publish posts them. +export function releaseIssue(release, handoffs) { + const head = `${releaseMarker(release)}\n\n`; + return { title: releaseTitle(release, handoffs.length), body: head + renderItems(handoffs, ISSUE_BODY_MAX - head.length) }; +} + +// Every string publish can post from the plan, as plain text for the scan +// job: each pull request title and body, the new release issue, and the +// handoff items untruncated, as an append or a comment posts them. +export function publishText(plan, release) { + const parts = plan.branches.flatMap((b) => [b.pr.title, b.pr.body]); + if (plan.handoffs.length) { + const issue = releaseIssue(release, plan.handoffs); + parts.push(issue.title, issue.body, renderItems(plan.handoffs, Number.POSITIVE_INFINITY)); + } + return `${parts.join("\n\n")}\n`; +} + // Files a release's handoffs as one issue, found again by its release marker // in any state. Only an issue the lanes App opened counts, and only key // markers in its body and the App's comments: anyone can type a marker. An @@ -70,9 +88,7 @@ export async function fileReleaseHandoffs({ repo, release, handoffs, api }) { (i) => i?.user?.login === LANES_APP_LOGIN && typeof i.body === "string" && i.body.includes(marker), ); if (!issue) { - const head = `${marker}\n\n`; - const body = head + renderItems(handoffs, ISSUE_BODY_MAX - head.length); - await api(`/repos/${repo}/issues`, { method: "POST", body: { title: releaseTitle(release, handoffs.length), body } }); + await api(`/repos/${repo}/issues`, { method: "POST", body: releaseIssue(release, handoffs) }); return `filed ${handoffs.length} handoffs as one issue`; } if (issue.state !== "open") return `the release issue #${issue.number} is closed; nothing filed`; diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index 5b6dab243e1..495d173dc8b 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -14,9 +14,11 @@ import { handoffMarker, netRawDiff, newestRelease, + publishText, readNetChanges, readPlan, refuseBinary, + releaseIssue, releaseMarker, releasesFromStatus, renderItems, @@ -118,6 +120,29 @@ test("a refusal names the field and index, never the plan's value", () => { ); }); +test("the publish text holds every pull request and issue string publish can post, decoded", async () => { + const p = plan({ + handoffs: [ + { key: "changelog:2.1.300..2.1.300:adopt:a", kind: "adopt", title: "first \"quoted\"", body: "line one\nline two" }, + { key: "changelog:2.1.300..2.1.300:adopt:b", kind: "adopt", title: "second", body: "z".repeat(50000) }, + { key: "changelog:2.1.300..2.1.300:adopt:c", kind: "adopt", title: "third", body: "y".repeat(50000) }, + ], + }); + p.branches[0].pr.body = "Refs: #7407\nwith \"quotes\" and a tab\t."; + const v = validatePlan(p, ctx); + const text = publishText(v, "2.1.300"); + assert.ok(text.includes(v.branches[0].pr.title)); + assert.ok(text.includes("with \"quotes\" and a tab\t.")); + const f = issueApi(); + await fileReleaseHandoffs({ repo: REPO, release: "2.1.300", handoffs: v.handoffs, api: f.api }); + const posted = f.writes()[0].body; + assert.deepEqual(posted, releaseIssue("2.1.300", v.handoffs)); + assert.ok(text.includes(posted.title) && text.includes(posted.body)); + assert.ok(posted.body.includes("truncated proportionally")); + assert.ok(text.includes(renderItems(v.handoffs, Number.POSITIVE_INFINITY))); + assert.equal(publishText(validatePlan(plan({ handoffs: [] }), ctx), "2.1.300").includes(releaseMarker("2.1.300")), false); +}); + test("a commit outside its plugin, or writing a link or submodule, is refused", () => { const ok = ":000000 100644 0000 1111 A\tplugins/hooks-kit/SKILL.md\n:000000 100644 0000 2222 A\t.changes/hooks-kit/x.md"; assert.doesNotThrow(() => checkRawLog(ok, "hooks-kit")); diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index bb319ca82a6..1ad0bd09b1a 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -456,10 +456,12 @@ jobs: # plan's checks name fields, and git runs quiet. node --input-type=module -e ' import { readFileSync, writeFileSync } from "node:fs"; - import { readPlan } from "./.base/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs"; + import { publishText, readPlan } from "./.base/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs"; const plan = readPlan(readFileSync(process.env.IN + "/plan.json", "utf8"), { release: process.env.RELEASE, baseSha: process.env.BASE_SHA }); writeFileSync(process.env.IN + "/heads.txt", plan.branches.map((b) => b.head_sha + "\n").join("")); + // What publish posts, decoded from JSON, so the scan reads it as posted. + writeFileSync(process.env.IN + "/publish.txt", publishText(plan, process.env.RELEASE)); console.log(plan.branches.length + " branches, " + plan.handoffs.length + " handoffs"); ' : >"$IN/diffs.txt" @@ -517,10 +519,18 @@ jobs: with: path: ${{ runner.temp }}/scan-in/diffs.txt + - name: Scan the pull request and issue text for credentials + id: scan-publish + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + with: + path: ${{ runner.temp }}/scan-in/publish.txt + # The plan and the rebuilt bundle, scan-out's only files. - name: Upload the plan and the branches id: upload - if: steps.scan-plan.outputs.clean == 'true' && steps.scan-diffs.outputs.clean == 'true' + if: >- + steps.scan-plan.outputs.clean == 'true' && steps.scan-diffs.outputs.clean == 'true' + && steps.scan-publish.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: upstream-apply-changelog-plan-${{ github.run_attempt }} From 77a26e75e24049e1680768d694896c08bf658c57 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:11:39 -0500 Subject: [PATCH 22/35] fix(ci): seal changelog output from a fresh checkout and pin its artifact The model job takes the sealer, its key and the transcript uploader from a fresh checkout of the base made after the leg, at a cleared path, instead of a RUNNER_TEMP stage the model could rewrite; only the plugins stay staged. The scan job outputs the uploaded artifact's id and the SHA-256 of plan.json and branches.bundle; push and publish download by artifact-ids and check the hashes before the lane token is minted. Co-Authored-By: Claude Opus 5.5 --- .../workflows/upstream-apply-changelog.yml | 151 ++++++++++++------ 1 file changed, 105 insertions(+), 46 deletions(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 1ad0bd09b1a..443ac77d8a6 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -213,21 +213,14 @@ jobs: base-path: .base output-path: ${{ runner.temp }}/upstream-apply-changelog-models.json - # Everything a later step of this job runs, copied out of main before - # the root checkout replaces .base: the sealer, its key, the transcript - # uploader with the actions and library it loads, and the plugins the - # skill and its stage skills load. - - name: Stage the base files + # The plugins the skill and its stage skills load, copied out of main + # before the root checkout replaces .base. + - name: Stage the base plugins if: steps.models.outcome == 'success' env: STAGE: ${{ runner.temp }}/upstream-apply-changelog-stage run: | - mkdir -p "$STAGE/seal" "$STAGE/plugins" "$STAGE/transcript/.github/actions" "$STAGE/transcript/lib" - cp .base/.github/actions/seal-lane-output/seal.mjs .base/.github/lane-keys/scan-carrier.pub "$STAGE/seal/" - for action in upload-lane-transcript seal-lane-output scan-upload; do - cp -R ".base/.github/actions/$action" "$STAGE/transcript/.github/actions/" - done - cp .base/lib/publish-gate.mjs "$STAGE/transcript/lib/" + mkdir -p "$STAGE/plugins" for plugin in harness-ops planning implementation verification; do cp -R ".base/plugins/$plugin" "$STAGE/plugins/" printf '{"mcpServers":{}}\n' >"$STAGE/plugins/$plugin/.mcp.json" @@ -335,36 +328,39 @@ jobs: .lane/plugins/verification leg-timeout-minutes: "70" - # The transcript uploader, copied back from the stage: the root - # checkout removed .base, and the model could have written anything - # under the workspace. - - name: Restore the transcript uploader - if: always() && steps.run.outputs.execution-file != '' - continue-on-error: true - env: - STAGE: ${{ runner.temp }}/upstream-apply-changelog-stage - run: | - rm -rf "${GITHUB_WORKSPACE:?}/.lane-transcript" - cp -R "$STAGE/transcript" "$GITHUB_WORKSPACE/.lane-transcript" + # The sealer, its key and the transcript uploader come from a fresh + # checkout of the base after the leg: the model could have written + # anything under the workspace and RUNNER_TEMP, including this path. + - name: Clear the trusted checkout path + if: always() && steps.run.outcome != 'skipped' + run: rm -rf "${GITHUB_WORKSPACE:?}/.lane-trusted" + + - name: Check out the base for the sealer + if: always() && steps.run.outcome != 'skipped' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.gate.outputs.base-sha }} + path: .lane-trusted + persist-credentials: false - name: Upload the sealed transcript if: always() && steps.run.outputs.execution-file != '' continue-on-error: true - uses: ./.lane-transcript/.github/actions/upload-lane-transcript # zizmor: ignore[self-repository] + uses: ./.lane-trusted/.github/actions/upload-lane-transcript # zizmor: ignore[self-repository] with: execution-file: ${{ steps.run.outputs.execution-file }} public-key: ${{ vars.LANE_TRANSCRIPT_PUBLIC_KEY }} job-id: ${{ github.job }} # The plan, and the planned branches as one git bundle from the base, - # sealed with the sealer staged before the model ran. + # sealed with the sealer from the trusted checkout. - name: Seal the plan and the branches id: seal if: always() && steps.run.outcome != 'skipped' env: RELEASE: ${{ needs.gate.outputs.release }} BASE_SHA: ${{ needs.gate.outputs.base-sha }} - STAGE: ${{ runner.temp }}/upstream-apply-changelog-stage + TRUSTED: ${{ github.workspace }}/.lane-trusted OUT: ${{ runner.temp }}/upstream-apply-changelog-out JOB_ID: model MAX_CHUNKS: "6" @@ -379,10 +375,10 @@ jobs: if ((${#refs[@]})); then git bundle create "$OUT/branches.bundle" "^$BASE_SHA" "${refs[@]}" fi - PUBLIC_KEY_FILE="$STAGE/seal/scan-carrier.pub" + PUBLIC_KEY_FILE="$TRUSTED/.github/lane-keys/scan-carrier.pub" PAYLOADS=$(printf 'plan.json=%s\nbranches.bundle=%s\n' "$OUT/plan.json" "$OUT/branches.bundle") export PUBLIC_KEY_FILE PAYLOADS - node "$STAGE/seal/seal.mjs" + node "$TRUSTED/.github/actions/seal-lane-output/seal.mjs" # Opens the sealed plan and bundle, scans what will be published for # credentials, and uploads only a clean set. No model, no write token. @@ -400,6 +396,11 @@ jobs: outputs: clean: ${{ steps.upload.outcome == 'success' }} branches: ${{ steps.check.outputs.branches }} + # Push and publish download this artifact by id and check these + # hashes before anything is minted. + artifact-id: ${{ steps.upload.outputs.artifact-id }} + plan-sha256: ${{ steps.hash.outputs.plan }} + bundle-sha256: ${{ steps.hash.outputs.bundle }} env: API_BUDGET_TOKEN: github-token steps: @@ -525,12 +526,26 @@ jobs: with: path: ${{ runner.temp }}/scan-in/publish.txt - # The plan and the rebuilt bundle, scan-out's only files. - - name: Upload the plan and the branches - id: upload + # The SHA-256 of each file uploaded below; no bundle, no bundle hash. + - name: Hash the plan and the branches + id: hash if: >- steps.scan-plan.outputs.clean == 'true' && steps.scan-diffs.outputs.clean == 'true' && steps.scan-publish.outputs.clean == 'true' + env: + OUT: ${{ runner.temp }}/scan-out + run: | + sum=$(sha256sum <"$OUT/plan.json") + echo "plan=${sum%% *}" >>"$GITHUB_OUTPUT" + if [ -f "$OUT/branches.bundle" ]; then + sum=$(sha256sum <"$OUT/branches.bundle") + echo "bundle=${sum%% *}" >>"$GITHUB_OUTPUT" + fi + + # The plan and the rebuilt bundle, scan-out's only files. + - name: Upload the plan and the branches + id: upload + if: steps.hash.outcome == 'success' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: upstream-apply-changelog-plan-${{ github.run_attempt }} @@ -595,11 +610,42 @@ jobs: base-path: .base output-path: ${{ runner.temp }}/resolved-config.json + # Before the mint: the scan job's artifact, by id, and the SHA-256 it + # recorded for each file. A missing or altered file fails the job, so + # nothing is minted. + - name: Download the plan and the branches + if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.scan.outputs.artifact-id }} + path: ${{ runner.temp }}/plan + + - name: Check the plan and the branches + id: verify + if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' + env: + DIR: ${{ runner.temp }}/plan + PLAN_SHA256: ${{ needs.scan.outputs.plan-sha256 }} + BUNDLE_SHA256: ${{ needs.scan.outputs.bundle-sha256 }} + run: | + for pair in "plan.json:$PLAN_SHA256" "branches.bundle:$BUNDLE_SHA256"; do + file="$DIR/${pair%%:*}" want="${pair#*:}" + if [ -L "$file" ] || [ ! -f "$file" ] || [ "$(stat -c %s "$file")" -gt 1048576 ]; then + echo "::error::the plan artifact holds no ${pair%%:*} of at most 1 MiB" + exit 1 + fi + sum=$(sha256sum <"$file") + if ! [[ $want =~ ^[0-9a-f]{64}$ ]] || [ "${sum%% *}" != "$want" ]; then + echo "::error::${pair%%:*} does not match the SHA-256 the scan job recorded" + exit 1 + fi + done + # A kill switch turned on since the step above is a designed stop # (pr-run-activity.md, The lane token broker). - name: Request the lane token id: lane-token - if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' + if: steps.verify.outcome == 'success' uses: ./.base/.github/actions/request-lane-token # zizmor: ignore[self-repository] with: broker-url: ${{ vars.LANES_BROKER_URL }} @@ -613,13 +659,6 @@ jobs: issues: ${{ steps.config.outputs.issues }} kill-switch-ok: true - - name: Download the plan and the branches - if: steps.lane-token.outputs.token != '' - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: upstream-apply-changelog-plan-${{ github.run_attempt }} - path: ${{ runner.temp }}/plan - - name: Push the branches id: push if: steps.lane-token.outputs.token != '' @@ -700,9 +739,36 @@ jobs: base-path: .base output-path: ${{ runner.temp }}/resolved-config.json + # Before the mint: the scan job's artifact, by id, and the plan's + # SHA-256 (publish reads no bundle). A missing or altered plan fails + # the job, so nothing is minted. + - name: Download the plan + if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.scan.outputs.artifact-id }} + path: ${{ runner.temp }}/plan + + - name: Check the plan + id: verify + if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' + env: + PLAN_FILE: ${{ runner.temp }}/plan/plan.json + PLAN_SHA256: ${{ needs.scan.outputs.plan-sha256 }} + run: | + if [ -L "$PLAN_FILE" ] || [ ! -f "$PLAN_FILE" ] || [ "$(stat -c %s "$PLAN_FILE")" -gt 1048576 ]; then + echo "::error::the plan artifact holds no plan.json of at most 1 MiB" + exit 1 + fi + sum=$(sha256sum <"$PLAN_FILE") + if ! [[ $PLAN_SHA256 =~ ^[0-9a-f]{64}$ ]] || [ "${sum%% *}" != "$PLAN_SHA256" ]; then + echo "::error::plan.json does not match the SHA-256 the scan job recorded" + exit 1 + fi + - name: Request the lane token id: lane-token - if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' + if: steps.verify.outcome == 'success' uses: ./.base/.github/actions/request-lane-token # zizmor: ignore[self-repository] with: broker-url: ${{ vars.LANES_BROKER_URL }} @@ -716,13 +782,6 @@ jobs: issues: ${{ steps.config.outputs.issues }} kill-switch-ok: true - - name: Download the plan - if: steps.lane-token.outputs.token != '' - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: upstream-apply-changelog-plan-${{ github.run_attempt }} - path: ${{ runner.temp }}/plan - - name: Open the pull requests and file the handoffs id: publish if: steps.lane-token.outputs.token != '' From 51cd8805c75b718b97972d098f16edc911048343 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:14:37 -0500 Subject: [PATCH 23/35] fix(ci): close a fail-open binary check and verify the bundle in publish The scan job's binary check captured numstat before grep -q, so a SIGPIPE under pipefail can no longer read as no match. Publish verifies the bundle hash as push does, and refuses a bundle the scan job recorded no hash for. Co-Authored-By: Claude Opus 5.5 --- .../workflows/upstream-apply-changelog.yml | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 443ac77d8a6..dbfdddfb8bf 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -486,7 +486,8 @@ jobs: [[ $head =~ ^[0-9a-f]{40}$ ]] || exit 1 git -C .base fetch --quiet --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" >/dev/null 2>&1 || { echo "::error::a planned head did not fetch from the bundle"; exit 1; } # The scanners read text, so a binary file is refused, unnamed. - if git -C .base diff --numstat --no-renames "$BASE_SHA" "$head" | grep -q $'^-\t-\t'; then + numstat=$(git -C .base diff --numstat --no-renames "$BASE_SHA" "$head") + if grep -q $'^-\t-\t' <<<"$numstat"; then echo "::error::a planned branch changes a binary file; only text is published" exit 1 fi @@ -739,9 +740,9 @@ jobs: base-path: .base output-path: ${{ runner.temp }}/resolved-config.json - # Before the mint: the scan job's artifact, by id, and the plan's - # SHA-256 (publish reads no bundle). A missing or altered plan fails - # the job, so nothing is minted. + # Before the mint: the scan job's artifact, by id, and the SHA-256 it + # recorded for each file; with no bundle hash there must be no bundle. + # A missing or altered file fails the job, so nothing is minted. - name: Download the plan if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -749,22 +750,33 @@ jobs: artifact-ids: ${{ needs.scan.outputs.artifact-id }} path: ${{ runner.temp }}/plan - - name: Check the plan + - name: Check the plan and the branches id: verify if: steps.config.outcome == 'success' && steps.config.outputs.applies == 'true' env: - PLAN_FILE: ${{ runner.temp }}/plan/plan.json + DIR: ${{ runner.temp }}/plan PLAN_SHA256: ${{ needs.scan.outputs.plan-sha256 }} + BUNDLE_SHA256: ${{ needs.scan.outputs.bundle-sha256 }} run: | - if [ -L "$PLAN_FILE" ] || [ ! -f "$PLAN_FILE" ] || [ "$(stat -c %s "$PLAN_FILE")" -gt 1048576 ]; then - echo "::error::the plan artifact holds no plan.json of at most 1 MiB" - exit 1 - fi - sum=$(sha256sum <"$PLAN_FILE") - if ! [[ $PLAN_SHA256 =~ ^[0-9a-f]{64}$ ]] || [ "${sum%% *}" != "$PLAN_SHA256" ]; then - echo "::error::plan.json does not match the SHA-256 the scan job recorded" + pairs=("plan.json:$PLAN_SHA256") + if [ -n "$BUNDLE_SHA256" ]; then + pairs+=("branches.bundle:$BUNDLE_SHA256") + elif [ -e "$DIR/branches.bundle" ] || [ -L "$DIR/branches.bundle" ]; then + echo "::error::the plan artifact holds a bundle the scan job recorded no SHA-256 for" exit 1 fi + for pair in "${pairs[@]}"; do + file="$DIR/${pair%%:*}" want="${pair#*:}" + if [ -L "$file" ] || [ ! -f "$file" ] || [ "$(stat -c %s "$file")" -gt 1048576 ]; then + echo "::error::the plan artifact holds no ${pair%%:*} of at most 1 MiB" + exit 1 + fi + sum=$(sha256sum <"$file") + if ! [[ $want =~ ^[0-9a-f]{64}$ ]] || [ "${sum%% *}" != "$want" ]; then + echo "::error::${pair%%:*} does not match the SHA-256 the scan job recorded" + exit 1 + fi + done - name: Request the lane token id: lane-token From 1c54508025053ce8e864e96e80a42f5a1d247298 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:16:53 -0500 Subject: [PATCH 24/35] fix(ci): scan every commit the release bundle carries The rebuilt bundle push and publish download is an artifact and still holds each commit's message and blobs, so the scan now reads the per-commit log and refuses a binary file in any commit. The seal step's git calls ignore global and system config and fsmonitor the model could have set during its leg. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/upstream-apply-changelog.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index dbfdddfb8bf..4df6352cddd 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -364,6 +364,10 @@ jobs: OUT: ${{ runner.temp }}/upstream-apply-changelog-out JOB_ID: model MAX_CHUNKS: "6" + # The model ran as this user: no global or system git config it + # could have written applies here. + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_NOSYSTEM: "1" run: | rm -rf "$OUT" mkdir -p "$OUT" @@ -371,9 +375,9 @@ jobs: if [ -f "$plan" ] && [ ! -L "$plan" ]; then cp "$plan" "$OUT/plan.json" fi - mapfile -t refs < <(git for-each-ref --format='%(refname)' "refs/heads/changelog/*/v$RELEASE") + mapfile -t refs < <(git -c core.fsmonitor=false for-each-ref --format='%(refname)' "refs/heads/changelog/*/v$RELEASE") if ((${#refs[@]})); then - git bundle create "$OUT/branches.bundle" "^$BASE_SHA" "${refs[@]}" + git -c core.fsmonitor=false bundle create "$OUT/branches.bundle" "^$BASE_SHA" "${refs[@]}" fi PUBLIC_KEY_FILE="$TRUSTED/.github/lane-keys/scan-carrier.pub" PAYLOADS=$(printf 'plan.json=%s\nbranches.bundle=%s\n' "$OUT/plan.json" "$OUT/branches.bundle") @@ -486,7 +490,8 @@ jobs: [[ $head =~ ^[0-9a-f]{40}$ ]] || exit 1 git -C .base fetch --quiet --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" >/dev/null 2>&1 || { echo "::error::a planned head did not fetch from the bundle"; exit 1; } # The scanners read text, so a binary file is refused, unnamed. - numstat=$(git -C .base diff --numstat --no-renames "$BASE_SHA" "$head") + # Every commit counts: the bundle push and publish get carries them all. + numstat=$(git -C .base log --numstat --no-renames --format= "$BASE_SHA..$head") if grep -q $'^-\t-\t' <<<"$numstat"; then echo "::error::a planned branch changes a binary file; only text is published" exit 1 @@ -494,6 +499,9 @@ jobs: # The net diff: push publishes one commit with the head's tree # and the plan's scanned title, never the model's commits. git -C .base diff --no-renames --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" + # The rebuilt bundle is an artifact and holds each commit's + # message and blobs, so those are scanned too. + git -C .base log -p --no-renames --format=%B "$BASE_SHA..$head" >>"$IN/diffs.txt" count=$((count + 1)) done <"$IN/heads.txt" fi From 1f7f1f73420ffeaa3c39429262759de8c970d790 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:42:53 -0500 Subject: [PATCH 25/35] fix(ci): bundle one synthetic commit per head instead of the model's history The scan job rebuilt the bundle from the model's heads, so merge commits, commit headers and blobs a merge added then removed reached the artifact unscanned: git log -p shows no merge diffs and never prints author or committer fields. The scan job now commits each planned head's tree on the base with a fixed identity, date and message (git commit-tree, deterministic) and bundles only those commits. What it scans is the net diff, the plan and the publish text; the per-commit log -p and numstat are gone, and the binary refusal reads the net numstat. The scan job outputs each branch's synthetic commit and tree; push fetches that commit, checks it is one commit on the base holding the recorded tree, and builds its published commit from that tree. The plan's head_sha is only the scan's input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VcupojVgxFqBnrah9DwFLE --- .../upstream-apply-changelog.mjs | 73 ++++++++++++++++--- .../upstream-apply-changelog.test.mjs | 66 +++++++++++++++++ .../workflows/upstream-apply-changelog.yml | 33 +++++---- 3 files changed, 150 insertions(+), 22 deletions(-) diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index 412e3bfb0f7..b376366f9ad 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -7,8 +7,9 @@ // API as one lanes App commit on the base (mutate-branch token) // publish opens one PR per pushed branch and files the release's handoffs // as one issue (publish-artifact token) -// The plan and the bundle come from the model job, so every field is data: -// names are held to fixed shapes and each branch's net diff to its plugin's paths. +// The plan and each branch's tree come from the model job, so every field is +// data: names are held to fixed shapes and each branch's net diff to its +// plugin's paths. The bundle holds the scan job's synthetic commits only. import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; @@ -308,6 +309,59 @@ export async function commitNetTree(changes, { message, baseSha, baseTree, headT return api.createCommit(message, tree, [baseSha]); } +const SYNTHETIC_ENV = { + GIT_AUTHOR_NAME: "upstream-apply-changelog", + GIT_AUTHOR_EMAIL: "upstream-apply-changelog@users.noreply.github.com", + GIT_AUTHOR_DATE: "1970-01-01T00:00:00Z", + GIT_COMMITTER_NAME: "upstream-apply-changelog", + GIT_COMMITTER_EMAIL: "upstream-apply-changelog@users.noreply.github.com", + GIT_COMMITTER_DATE: "1970-01-01T00:00:00Z", +}; +export const SYNTHETIC_MESSAGE = "upstream-apply-changelog scanned tree"; + +// The scan job's stand-in for each planned head: one commit on the base +// holding the head's tree, with a fixed identity, date and message, bundled +// alone. The bundle push gets then carries no model commit, header, merge or +// intermediate blob, only what the scan read as the net diff. Returns each +// branch's { commit, tree } in plan order. +export function syntheticBundle(repoDir, baseSha, heads, bundlePath) { + const env = { ...process.env, ...SYNTHETIC_ENV }; + const quiet = { stdio: ["ignore", "pipe", "pipe"] }; + const made = heads.map((head, i) => { + const tree = git(repoDir, ["rev-parse", "--verify", `${head}^{tree}`], quiet); + const commit = git(repoDir, ["commit-tree", "--no-gpg-sign", tree, "-p", baseSha, "-m", SYNTHETIC_MESSAGE], { env, ...quiet }); + if (git(repoDir, ["rev-parse", `${commit}^{tree}`], quiet) !== tree) throw new Error(`branches[${i}]'s synthetic commit does not hold its head's tree`); + git(repoDir, ["update-ref", `refs/synthetic/${i}`, commit], quiet); + return { commit, tree }; + }); + git(repoDir, ["bundle", "create", "--quiet", bundlePath, `^${baseSha}`, ...heads.map((_, i) => `refs/synthetic/${i}`)], quiet); + return made; +} + +// The scan job's synthetic output: one { commit, tree } per planned branch. +export function readSynthetic(text, count) { + let list; + try { + list = JSON.parse(text || "[]"); + } catch { + throw new Error("the scan job's synthetic commits are not JSON"); + } + if (!Array.isArray(list) || list.length !== count || !list.every((s) => SHA.test(s?.commit ?? "") && SHA.test(s?.tree ?? ""))) { + throw new Error("the scan job's synthetic commits do not match the plan's branches"); + } + return list; +} + +// Fetches a branch's synthetic commit from the scanned bundle and checks it +// is one commit on the base holding the tree the scan recorded. +export function fetchSynthetic(repoDir, bundleFile, baseSha, { commit, tree }, local, i) { + git(repoDir, ["fetch", "--quiet", "--no-tags", bundleFile, `${commit}:${local}`]); + if (git(repoDir, ["rev-parse", local]) !== commit) throw new Error(`branches[${i}] is not at its synthetic commit`); + if (git(repoDir, ["rev-list", "--parents", "-n", "1", commit]) !== `${commit} ${baseSha}`) throw new Error(`branches[${i}] is not one commit on the base`); + if (git(repoDir, ["rev-parse", `${commit}^{tree}`]) !== tree) throw new Error(`branches[${i}] does not hold the scanned tree`); + return commit; +} + async function gate(env) { const repo = env.GITHUB_REPOSITORY; let candidates; @@ -354,14 +408,15 @@ async function push(env) { const writer = createGitHubApi({ token: env.GH_TOKEN, repository: repo, apiUrl: env.GITHUB_API_URL || "https://api.github.com" }); const heads = {}; if (plan.branches.length && !existsSync(BUNDLE_FILE)) throw new Error("the plan names branches and the run left no bundle"); + // The bundle holds the scan job's synthetic commits only, never the + // model's heads, which push never reads. + const synthetic = readSynthetic(env.SYNTHETIC, plan.branches.length); for (const [i, b] of plan.branches.entries()) { const local = `refs/upstream-apply-changelog/${b.plugin}`; - git(repoDir, ["fetch", "--no-tags", BUNDLE_FILE, `${b.head_sha}:${local}`]); - if (git(repoDir, ["rev-parse", local]) !== b.head_sha) throw new Error(`branches[${i}] is not at its planned head`); - if (git(repoDir, ["merge-base", baseSha, b.head_sha]) !== baseSha) throw new Error(`branches[${i}] does not start at the base`); - checkRawLog(netRawDiff(repoDir, baseSha, b.head_sha), b.plugin, i); - refuseBinary(git(repoDir, ["diff", "--numstat", "--no-renames", baseSha, b.head_sha]), i); - const headTree = git(repoDir, ["rev-parse", `${b.head_sha}^{tree}`]); + const commit = fetchSynthetic(repoDir, BUNDLE_FILE, baseSha, synthetic[i], local, i); + checkRawLog(netRawDiff(repoDir, baseSha, commit), b.plugin, i); + refuseBinary(git(repoDir, ["diff", "--numstat", "--no-renames", baseSha, commit]), i); + const headTree = synthetic[i].tree; const existing = await api(`/repos/${repo}/git/ref/heads/${b.branch}`, { allow404: true }); if (existing) { // A re-run's branch counts only when its tip holds the planned tree. @@ -371,7 +426,7 @@ async function push(env) { console.log(`${b.branch} already exists${heads[b.branch] ? "" : " with another tree"}; not pushed`); continue; } - const head = await commitNetTree(readNetChanges(repoDir, baseSha, b.head_sha), { + const head = await commitNetTree(readNetChanges(repoDir, baseSha, commit), { message: b.pr.title, baseSha, baseTree: git(repoDir, ["rev-parse", `${baseSha}^{tree}`]), diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index 495d173dc8b..a91c4b02150 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -10,6 +10,7 @@ import { branchFor, checkRawLog, commitNetTree, + fetchSynthetic, fileReleaseHandoffs, handoffMarker, netRawDiff, @@ -17,12 +18,15 @@ import { publishText, readNetChanges, readPlan, + readSynthetic, refuseBinary, releaseIssue, releaseMarker, releasesFromStatus, renderItems, startedInWindow, + SYNTHETIC_MESSAGE, + syntheticBundle, validatePlan, } from "./upstream-apply-changelog.mjs"; @@ -238,6 +242,68 @@ test("the net change reads each file once at its head content, whatever the comm assert.match(netRawDiff(dir, base, git("rev-parse", "HEAD")), /\tnew\.md$/m); }); +test("the scanned bundle holds one fixed commit per head with its tree, and no blob a merge added then removed", (t) => { + const dir = mkdtempSync(join(tmpdir(), "uac-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const env = { PATH: process.env.PATH, HOME: dir, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "model", GIT_AUTHOR_EMAIL: "m@m", GIT_COMMITTER_NAME: "model", GIT_COMMITTER_EMAIL: "m@m" }; + const repo = join(dir, "repo"); + const fresh = join(dir, "fresh"); + const git = (cwd, ...args) => execFileSync("git", ["-C", cwd, ...args], { env, encoding: "utf8" }).trim(); + git(dir, "init", "-q", "-b", "main", repo); + writeFileSync(join(repo, "keep.md"), "k"); + git(repo, "add", "."); + git(repo, "commit", "-qm", "base"); + const base = git(repo, "rev-parse", "HEAD"); + git(repo, "tag", "base"); + git(repo, "checkout", "-qb", "side"); + writeFileSync(join(repo, "secret.md"), "SECRET-VALUE-0123456789"); + git(repo, "add", "."); + git(repo, "commit", "-qm", "add secret"); + const secret = git(repo, "rev-parse", "side:secret.md"); + git(repo, "checkout", "-qb", "head", base); + writeFileSync(join(repo, "plugin.md"), "p"); + git(repo, "add", "."); + git(repo, "commit", "-qm", "plugin change"); + git(repo, "merge", "-q", "--no-edit", "side"); + git(repo, "rm", "-q", "secret.md"); + git(repo, "commit", "-qm", "drop secret"); + const head = git(repo, "rev-parse", "HEAD"); + assert.equal(git(repo, "cat-file", "-t", secret), "blob"); + + const bundle = join(dir, "branches.bundle"); + const [made] = syntheticBundle(repo, base, [head], bundle); + assert.equal(made.tree, git(repo, "rev-parse", `${head}^{tree}`)); + assert.deepEqual(syntheticBundle(repo, base, [head], join(dir, "again.bundle")), [made]); + assert.equal(git(repo, "log", "-1", "--format=%P|%an|%ae|%aI|%cn|%ce|%cI|%B", made.commit), + `${base}|upstream-apply-changelog|upstream-apply-changelog@users.noreply.github.com|1970-01-01T00:00:00Z|upstream-apply-changelog|upstream-apply-changelog@users.noreply.github.com|1970-01-01T00:00:00Z|${SYNTHETIC_MESSAGE}`); + + git(dir, "init", "-q", "-b", "main", fresh); + git(fresh, "fetch", "-q", "--no-tags", repo, "refs/tags/base:refs/base"); + assert.equal(fetchSynthetic(fresh, bundle, base, made, "refs/scanned", 0), made.commit); + const has = (sha) => { + try { + git(fresh, "cat-file", "-e", sha); + return true; + } catch { + return false; + } + }; + assert.equal(has(made.commit), true); + assert.equal(has(secret), false); + assert.equal(has(head), false); + assert.deepEqual(readNetChanges(fresh, base, made.commit).map((e) => e.path), ["plugin.md"]); + assert.throws(() => fetchSynthetic(fresh, bundle, base, { ...made, tree: base }, "refs/other", 3), /branches\[3\] does not hold the scanned tree/); +}); + +test("the scan job's synthetic list must name one commit and tree per planned branch", () => { + const one = [{ commit: HEAD, tree: BASE }]; + assert.deepEqual(readSynthetic(JSON.stringify(one), 1), one); + assert.deepEqual(readSynthetic("", 0), []); + assert.throws(() => readSynthetic(JSON.stringify(one), 2), /do not match/); + assert.throws(() => readSynthetic(JSON.stringify([{ commit: "x", tree: BASE }]), 1), /do not match/); + assert.throws(() => readSynthetic("{", 1), /not JSON/); +}); + const REPO = "o/r"; const REL = "2.1.300"; const APP = { login: "melodic-automation-lanes[bot]", type: "Bot" }; diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 4df6352cddd..0289d51b102 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -400,6 +400,9 @@ jobs: outputs: clean: ${{ steps.upload.outcome == 'success' }} branches: ${{ steps.check.outputs.branches }} + # Each planned branch's synthetic commit and tree, in plan order: push + # fetches that commit from the bundle and publishes that tree. + synthetic: ${{ steps.check.outputs.synthetic }} # Push and publish download this artifact by id and check these # hashes before anything is minted. artifact-id: ${{ steps.upload.outputs.artifact-id }} @@ -490,32 +493,35 @@ jobs: [[ $head =~ ^[0-9a-f]{40}$ ]] || exit 1 git -C .base fetch --quiet --no-tags "$IN/branches.bundle" "$head:refs/scan/$head" >/dev/null 2>&1 || { echo "::error::a planned head did not fetch from the bundle"; exit 1; } # The scanners read text, so a binary file is refused, unnamed. - # Every commit counts: the bundle push and publish get carries them all. - numstat=$(git -C .base log --numstat --no-renames --format= "$BASE_SHA..$head") + numstat=$(git -C .base diff --numstat --no-renames "$BASE_SHA" "$head") if grep -q $'^-\t-\t' <<<"$numstat"; then echo "::error::a planned branch changes a binary file; only text is published" exit 1 fi - # The net diff: push publishes one commit with the head's tree - # and the plan's scanned title, never the model's commits. + # The net diff is all that leaves this job: the bundle below + # carries one synthetic commit with the head's tree, and push + # publishes one commit with that tree and the plan's title. git -C .base diff --no-renames --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" - # The rebuilt bundle is an artifact and holds each commit's - # message and blobs, so those are scanned too. - git -C .base log -p --no-renames --format=%B "$BASE_SHA..$head" >>"$IN/diffs.txt" count=$((count + 1)) done <"$IN/heads.txt" fi - # What push and publish get: the plan, and a bundle rebuilt from - # the planned heads only, never the model's bundle with whatever - # else it carries. + # What push and publish get: the plan, and a bundle of one + # synthetic commit per planned head (its tree on the base, a fixed + # identity, date and message), never the model's commits, whose + # headers, merges and intermediate blobs the scan never read. rm -rf "$OUT" mkdir -p "$OUT" cp "$IN/plan.json" "$OUT/plan.json" + synthetic='[]' if ((count)); then - mapfile -t refs < <(sed 's|^|refs/scan/|' "$IN/heads.txt") - git -C .base bundle create --quiet "$OUT/branches.bundle" "^$BASE_SHA" "${refs[@]}" >/dev/null 2>&1 || { echo "::error::the planned heads did not bundle"; exit 1; } + synthetic=$(node --input-type=module -e ' + import { readFileSync } from "node:fs"; + import { syntheticBundle } from "./.base/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs"; + const heads = readFileSync(process.env.IN + "/heads.txt", "utf8").split("\n").filter(Boolean); + console.log(JSON.stringify(syntheticBundle(".base", process.env.BASE_SHA, heads, process.env.OUT + "/branches.bundle"))); + ') || { echo "::error::the planned heads did not bundle"; exit 1; } fi - echo "branches=$count" >>"$GITHUB_OUTPUT" + { echo "branches=$count"; echo "synthetic=$synthetic"; } >>"$GITHUB_OUTPUT" - name: Scan the plan for credentials id: scan-plan @@ -678,6 +684,7 @@ jobs: BASE_SHA: ${{ needs.gate.outputs.base-sha }} PLAN_FILE: ${{ runner.temp }}/plan/plan.json BUNDLE_FILE: ${{ runner.temp }}/plan/branches.bundle + SYNTHETIC: ${{ needs.scan.outputs.synthetic }} REPO_DIR: ${{ github.workspace }}/.base run: node .base/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs push From 2fca2fbc999256b2fc27e1ba79e8da2ca72b562f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:44:15 -0500 Subject: [PATCH 26/35] fix(ci): ignore global and system git config in every changelog job A ~/.gitconfig the model writes during its leg reached the checkout after it, which copies the global config into its temporary HOME, and every later git call; only the seal step ignored it. GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 are now job-level env on the model, scan, push-branches and publish jobs, and the seal step's copies are gone. The leg needs no global config: it commits with the repository-local identity the prepare step sets, and the pinned run-claude-cli and apply-claude-managed-settings actions read none. actions/checkout's safe.directory write under --global is caught and logged, not fatal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VcupojVgxFqBnrah9DwFLE --- .../workflows/upstream-apply-changelog.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 0289d51b102..99034c4152d 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -172,6 +172,13 @@ jobs: cache-mode: read permissions: contents: read # check out main with history, to branch from it + # No global or system git config applies to any step: one the model + # writes during its leg would otherwise reach the checkout after it + # (which copies the global config into its temporary HOME) and every + # later git call. The leg commits with the repository's own identity. + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_NOSYSTEM: "1" outputs: handoff: ${{ steps.run.outputs.handoff }} lane-class: ${{ steps.run.outputs.lane-class }} @@ -364,10 +371,6 @@ jobs: OUT: ${{ runner.temp }}/upstream-apply-changelog-out JOB_ID: model MAX_CHUNKS: "6" - # The model ran as this user: no global or system git config it - # could have written applies here. - GIT_CONFIG_GLOBAL: /dev/null - GIT_CONFIG_NOSYSTEM: "1" run: | rm -rf "$OUT" mkdir -p "$OUT" @@ -410,6 +413,8 @@ jobs: bundle-sha256: ${{ steps.hash.outputs.bundle }} env: API_BUDGET_TOKEN: github-token + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_NOSYSTEM: "1" steps: - name: Check out the default branch tip uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -589,6 +594,9 @@ jobs: permissions: contents: read # check out main id-token: write # prove to the token broker which workflow this is + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_NOSYSTEM: "1" outputs: skip-reason: ${{ steps.lane-token.outputs.skip-reason }} heads: ${{ steps.push.outputs.heads }} @@ -723,6 +731,9 @@ jobs: permissions: contents: read # check out main id-token: write # prove to the token broker which workflow this is + env: + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_NOSYSTEM: "1" steps: - name: Check out the default branch tip uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 55a8780901f2075dbdd7440cfa224efda7f996dc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:12:47 -0500 Subject: [PATCH 27/35] fix(ci): scan each changelog branch as a diff and by file scan-upload reads diffs.txt as plain text, so a token split across added lines joined with its `+` prefixes and never matched. The scan job now runs the lane-branch scan (scan-reuse.mjs) on every planned head: the diff read as a diff, and each changed file's bytes. The net diff ignores external diff and textconv drivers, and checkouts under GIT_CONFIG_GLOBAL=/dev/null skip the safe.directory write that cannot land there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VcupojVgxFqBnrah9DwFLE --- .github/workflows/upstream-apply-changelog.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 99034c4152d..b69b1ca2a4d 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -196,6 +196,7 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} path: .base persist-credentials: false + set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] @@ -252,6 +253,7 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} fetch-depth: 0 persist-credentials: false + set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Prepare the working tree id: prepare @@ -349,6 +351,7 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} path: .lane-trusted persist-credentials: false + set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Upload the sealed transcript if: always() && steps.run.outputs.execution-file != '' @@ -423,6 +426,7 @@ jobs: path: .base fetch-depth: 0 persist-credentials: false + set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] @@ -503,10 +507,18 @@ jobs: echo "::error::a planned branch changes a binary file; only text is published" exit 1 fi + # The lane-branch scan: the diff read as a diff, so a token + # split across added lines joins without its `+` prefixes, and + # every changed file's whole bytes. + scan=$(node .base/.github/scripts/work-resolve-issue/scan-reuse.mjs .base "$BASE_SHA" "$head" >"$IN/diffs.txt" + git -C .base diff --no-renames --no-ext-diff --no-textconv --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" count=$((count + 1)) done <"$IN/heads.txt" fi @@ -608,6 +620,7 @@ jobs: path: .base fetch-depth: 0 persist-credentials: false + set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] @@ -741,6 +754,7 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} path: .base persist-credentials: false + set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] From 3f8f74b5e4c6ee62d64581b94bb32b689843c0cc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:17:29 -0500 Subject: [PATCH 28/35] docs(harness-ops): add the changelog fragment for one handoff issue per release Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VcupojVgxFqBnrah9DwFLE --- .../harness-ops/feat-release-intake-workflow-3e9b6d21.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md diff --git a/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md b/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md new file mode 100644 index 00000000000..c61484ffffa --- /dev/null +++ b/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Changed + +- `changelog apply --ci` now says the calling workflow files a release's handoffs as one issue, with + one checklist item per handoff. From 5c059e5748bb3df45cfcad280b3d2e7fca420569 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:32:14 -0500 Subject: [PATCH 29/35] revert(ci): defer the changelog lane's split-token scan to hardening The second scan of each planned branch (a diff read as a diff), the diff driver flags and the safe.directory skip were defense in depth against a naive split-token leak, rated LOW. The credential scan of the plan, the branch diffs and the published text stays. This reverts commit 55a8780901. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VcupojVgxFqBnrah9DwFLE --- .github/workflows/upstream-apply-changelog.yml | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index b69b1ca2a4d..99034c4152d 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -196,7 +196,6 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} path: .base persist-credentials: false - set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] @@ -253,7 +252,6 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} fetch-depth: 0 persist-credentials: false - set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Prepare the working tree id: prepare @@ -351,7 +349,6 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} path: .lane-trusted persist-credentials: false - set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Upload the sealed transcript if: always() && steps.run.outputs.execution-file != '' @@ -426,7 +423,6 @@ jobs: path: .base fetch-depth: 0 persist-credentials: false - set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] @@ -507,18 +503,10 @@ jobs: echo "::error::a planned branch changes a binary file; only text is published" exit 1 fi - # The lane-branch scan: the diff read as a diff, so a token - # split across added lines joins without its `+` prefixes, and - # every changed file's whole bytes. - scan=$(node .base/.github/scripts/work-resolve-issue/scan-reuse.mjs .base "$BASE_SHA" "$head" >"$IN/diffs.txt" + git -C .base diff --no-renames --binary "$BASE_SHA" "$head" >>"$IN/diffs.txt" count=$((count + 1)) done <"$IN/heads.txt" fi @@ -620,7 +608,6 @@ jobs: path: .base fetch-depth: 0 persist-credentials: false - set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] @@ -754,7 +741,6 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} path: .base persist-credentials: false - set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] From b711fbb394aebdab8c63e13218255957f4c77236 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:11:14 -0500 Subject: [PATCH 30/35] fix(ci): keep stopped plans and stranded branches from marking a release done - A stopped plan keeps and files its handoffs, publishes no branch, and fails publish so the done marker is not uploaded. - An existing branch with another tree and no pull request fails push instead of being skipped. - The start record has one fixed name and the per-day cap lists it by name, so other workflows' artifacts no longer push it off the page. - Create .lane before copying the staged plugins into it. - Restore set-safe-directory: false on the six checkouts whose job sets GIT_CONFIG_GLOBAL=/dev/null: checkout's --global write cannot lock it. - Run the suite through its own Node package in the test-node lane. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VcupojVgxFqBnrah9DwFLE --- .../upstream-apply-changelog/package.json | 7 ++++ .../upstream-apply-changelog.mjs | 33 ++++++++++++++----- .../upstream-apply-changelog.test.mjs | 19 ++++++++--- .../workflows/upstream-apply-changelog.yml | 9 ++++- scripts/outside-node-packages.txt | 1 + 5 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 .github/scripts/upstream-apply-changelog/package.json diff --git a/.github/scripts/upstream-apply-changelog/package.json b/.github/scripts/upstream-apply-changelog/package.json new file mode 100644 index 00000000000..01b12199b82 --- /dev/null +++ b/.github/scripts/upstream-apply-changelog/package.json @@ -0,0 +1,7 @@ +{ + "name": "upstream-apply-changelog", + "private": true, + "scripts": { + "test": "node --test" + } +} diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index b376366f9ad..dfdfcfa0a95 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -24,7 +24,9 @@ const KEY = /^changelog:[0-9.]+\.\.[0-9.]+:[a-z-]+:[A-Za-z0-9_./-]{1,200}$/; const DAY_MS = 24 * 60 * 60 * 1000; export const branchFor = (plugin, release) => `changelog/${plugin}/v${release}`; -export const startedArtifact = (release, runId) => `upstream-apply-changelog-started-${release}-${runId}`; +// One name for every start record (an artifact is scoped to its run), so the +// cap lists them by name instead of paging the repository's artifacts. +export const STARTED_ARTIFACT = "upstream-apply-changelog-started"; export const doneArtifact = (release) => `upstream-apply-changelog-done-${release}`; export const alreadyProcessed = (artifacts) => (artifacts?.total_count ?? 0) > 0; export const handoffMarker = (key) => ``; @@ -183,7 +185,7 @@ export function newestRelease(releases) { // artifacts, as red-main counts reverts "in any 24 hours". export function startedInWindow(artifacts, now) { return artifacts.filter( - (a) => a?.name?.startsWith("upstream-apply-changelog-started-") && now - Date.parse(a.created_at) < DAY_MS, + (a) => a?.name === STARTED_ARTIFACT && now - Date.parse(a.created_at) < DAY_MS, ).length; } @@ -206,11 +208,13 @@ export function validatePlan(plan, { release, baseSha }) { throw new Error(`ci-plan.json: ${why}`); }; if (plan?.schema !== "changelog-ci-plan/1") fail("schema is not changelog-ci-plan/1"); - if (plan.status === "stopped") return { branches: [], handoffs: [] }; - if (plan.status !== "planned") fail("status is neither planned nor stopped"); + const { status } = plan; + if (status !== "planned" && status !== "stopped") fail("status is neither planned nor stopped"); if (plan.range !== `${release}..${release}`) fail("range is not this run's release"); if (plan.base_sha !== baseSha) fail("base_sha is not the commit the run checked out"); - const branches = Array.isArray(plan.branches) ? plan.branches : fail("branches is not a list"); + // A stopped plan derived no rows, so it publishes no branch; its handoffs + // (ci-mode.md: cap exceeded carries a docs-conformance one) are still filed. + const branches = status === "stopped" ? [] : Array.isArray(plan.branches) ? plan.branches : fail("branches is not a list"); const handoffs = Array.isArray(plan.handoffs) ? plan.handoffs : fail("handoffs is not a list"); const seen = new Set(); branches.forEach((b, i) => { @@ -236,7 +240,7 @@ export function validatePlan(plan, { release, baseSha }) { if (typeof h.title !== "string" || !h.title || h.title.length > 256 || /[\r\n]/.test(h.title)) fail(`${at}.title is bad`); if (typeof h.body !== "string" || h.body.length > 60000) fail(`${at}.body is bad`); }); - return { branches, handoffs }; + return { status, branches, handoffs }; } // Every path the raw diff changes, either side of a rename, must sit under @@ -392,7 +396,7 @@ async function gate(env) { output("release", release); const cap = Number(env.DAILY_CAP); if (!Number.isInteger(cap) || cap < 1) throw new Error("the per-day cap is not a positive whole number"); - const recent = await api(`/repos/${repo}/actions/artifacts?per_page=100`); + const recent = await api(`/repos/${repo}/actions/artifacts?per_page=100&name=${STARTED_ARTIFACT}`); const started = startedInWindow(recent?.artifacts ?? [], Date.now()); if (started >= cap) { console.log(`::notice::upstream-apply-changelog started ${started} runs in the last 24 hours, at its cap of ${cap}; Claude Code ${release} waits`); @@ -422,8 +426,16 @@ async function push(env) { // A re-run's branch counts only when its tip holds the planned tree. const tip = existing.object?.sha; const commit = SHA.test(tip ?? "") ? await api(`/repos/${repo}/git/commits/${tip}`) : null; - if (commit?.tree?.sha === headTree) heads[b.branch] = tip; - console.log(`${b.branch} already exists${heads[b.branch] ? "" : " with another tree"}; not pushed`); + if (commit?.tree?.sha === headTree) { + heads[b.branch] = tip; + console.log(`${b.branch} already exists at the planned tree; not pushed`); + continue; + } + // Another tree is an earlier run's work: done once it has a pull + // request, else stranded, so the run fails before the done marker. + const prs = await api(`/repos/${repo}/pulls?state=all&head=${repo.split("/")[0]}:${encodeURIComponent(b.branch)}`); + if (!prs?.length) throw new Error(`branches[${i}] already exists with another tree and no pull request; open one for it or delete it, then dispatch again`); + console.log(`${b.branch} already has pull request #${prs[0].number} with another tree; not pushed`); continue; } const head = await commitNetTree(readNetChanges(repoDir, baseSha, commit), { @@ -463,6 +475,9 @@ async function publish(env) { } console.log(`opened ${opened} pull requests`); console.log(await fileReleaseHandoffs({ repo, release, handoffs: plan.handoffs, api })); + // Its handoffs are filed, but the release was never applied: fail so the + // done marker is not uploaded and the next run takes the release again. + if (plan.status === "stopped") throw new Error(`the plan for Claude Code ${release} stopped; its handoffs are filed and the release is not marked done`); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index a91c4b02150..2b08a5c3ebe 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -66,16 +66,27 @@ test("the newest release is the highest version, skipping drafts, prereleases an test("the per-day cap counts only start records from the last 24 hours", () => { const now = Date.parse("2026-11-02T12:00:00Z"); const artifacts = [ - { name: "upstream-apply-changelog-started-2.1.300-1", created_at: "2026-11-02T01:00:00Z" }, - { name: "upstream-apply-changelog-started-2.1.299-2", created_at: "2026-11-01T11:00:00Z" }, + { name: "upstream-apply-changelog-started", created_at: "2026-11-02T01:00:00Z" }, + { name: "upstream-apply-changelog-started", created_at: "2026-11-01T11:00:00Z" }, { name: "upstream-apply-changelog-done-2.1.300", created_at: "2026-11-02T02:00:00Z" }, ]; assert.equal(startedInWindow(artifacts, now), 1); }); -test("a valid plan passes and a stopped plan acts on nothing", () => { +test("a valid plan passes and a stopped plan keeps its handoffs and publishes no branch", () => { assert.equal(validatePlan(plan(), ctx).branches.length, 1); - assert.deepEqual(validatePlan(plan({ status: "stopped" }), ctx), { branches: [], handoffs: [] }); + // ci-mode.md: cap exceeded is status stopped with one docs-conformance handoff. + const conformance = { key: "changelog:2.1.300..2.1.300:docs-conformance:plugins/harness-ops/skills/changelog/SKILL.md", title: "t", body: "b" }; + assert.deepEqual(validatePlan(plan({ status: "stopped", handoffs: [conformance] }), ctx), { + status: "stopped", + branches: [], + handoffs: [conformance], + }); + assert.throws( + () => validatePlan(plan({ status: "stopped", handoffs: [{ ...conformance, key: "changelog:2.1.1..2.1.1:docs-conformance:x" }] }), ctx), + /handoffs\[0\]\.key/, + ); + assert.throws(() => validatePlan(plan({ status: "stopped", range: "2.1.299..2.1.299" }), ctx), /range/); }); test("a plan for another release, base or branch name is refused", () => { diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 99034c4152d..5a6c1eb47c2 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -142,7 +142,7 @@ jobs: if: steps.gate.outputs.proceed == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: upstream-apply-changelog-started-${{ steps.gate.outputs.release }}-${{ github.run_id }} + name: upstream-apply-changelog-started path: ${{ runner.temp }}/started/release.txt retention-days: 2 if-no-files-found: error @@ -196,6 +196,7 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} path: .base persist-credentials: false + set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Set up Node uses: ./.base/.github/actions/setup-pinned-node # zizmor: ignore[self-repository] @@ -252,6 +253,7 @@ jobs: ref: ${{ needs.gate.outputs.base-sha }} fetch-depth: 0 persist-credentials: false + set-safe-directory: false # GIT_CONFIG_GLOBAL is /dev/null, so its --global write cannot land - name: Prepare the working tree id: prepare @@ -272,6 +274,7 @@ jobs: git update-index --skip-worktree -- "${held[@]}" rm -f -- "${held[@]}" fi + mkdir .lane cp -R "$STAGE/plugins" .lane/plugins chmod -R a-w .lane/plugins cat >"$RUNNER_TEMP/upstream-apply-changelog-prompt.md" < Date: Mon, 2 Nov 2026 18:03:12 -0500 Subject: [PATCH 31/35] fix(ci): mark a stopped changelog release done once its handoffs are filed A stopped plan is the same on every run for its release, so failing publish on purpose only re-ran the model on that release up to the per-day cap. - validatePlan requires a stopped plan's stop_reason to be cap-exceeded or range-not-computed and returns it. - The release issue opens with a line naming the stop_reason, and a stopped plan files it even with no handoffs; the scan text matches. - Publish no longer throws on a stopped plan: once the issue holds every key it succeeds and the done marker uploads. A closed issue missing a key, or a failed write, still fails publish so the release is retried. - ci-mode.md, the workflow comments and the harness-ops fragment say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VcupojVgxFqBnrah9DwFLE --- .../feat-release-intake-workflow-3e9b6d21.md | 3 +- .../upstream-apply-changelog.mjs | 47 +++++++---- .../upstream-apply-changelog.test.mjs | 78 ++++++++++++++++++- .../workflows/upstream-apply-changelog.yml | 5 +- .../skills/changelog/context/ci-mode.md | 5 +- 5 files changed, 115 insertions(+), 23 deletions(-) diff --git a/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md b/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md index c61484ffffa..b25a09e8ad4 100644 --- a/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md +++ b/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md @@ -5,4 +5,5 @@ bump: patch ### Changed - `changelog apply --ci` now says the calling workflow files a release's handoffs as one issue, with - one checklist item per handoff. + one checklist item per handoff. A stopped plan's issue names its `stop_reason`, and the release is + marked done once that issue holds every handoff. diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index dfdfcfa0a95..e1bf5ff005b 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -34,7 +34,12 @@ export const releaseMarker = (release) => `/g)].map((m) => m[1]); @@ -58,9 +63,10 @@ export function renderItems(handoffs, budget) { return `${TRUNCATED}\n\n${handoffs.map((h, i) => item(h, cut[i])).join("\n")}`; } -// The new release issue's title and body, as publish posts them. -export function releaseIssue(release, handoffs) { - const head = `${releaseMarker(release)}\n\n`; +// The new release issue's title and body, as publish posts them; a stopped +// plan's issue opens with its stop_reason. +export function releaseIssue(release, handoffs, stop = null) { + const head = `${releaseMarker(release)}\n\n${stop ? `${stopLine(release, stop)}\n\n` : ""}`; return { title: releaseTitle(release, handoffs.length), body: head + renderItems(handoffs, ISSUE_BODY_MAX - head.length) }; } @@ -69,8 +75,8 @@ export function releaseIssue(release, handoffs) { // handoff items untruncated, as an append or a comment posts them. export function publishText(plan, release) { const parts = plan.branches.flatMap((b) => [b.pr.title, b.pr.body]); - if (plan.handoffs.length) { - const issue = releaseIssue(release, plan.handoffs); + if (plan.handoffs.length || plan.stop_reason) { + const issue = releaseIssue(release, plan.handoffs, plan.stop_reason); parts.push(issue.title, issue.body, renderItems(plan.handoffs, Number.POSITIVE_INFINITY)); } return `${parts.join("\n\n")}\n`; @@ -81,8 +87,11 @@ export function publishText(plan, release) { // markers in its body and the App's comments: anyone can type a marker. An // open issue missing some keys gets them appended in one update, or in one // comment when the body would pass the limit; a closed one is left alone. -export async function fileReleaseHandoffs({ repo, release, handoffs, api }) { - if (!handoffs.length) return "no handoffs; nothing filed"; +// A stopped plan (`stop` is its stop_reason) is marked done once this +// returns, so it files the issue even with no handoffs, and a closed issue +// missing a key throws instead. +export async function fileReleaseHandoffs({ repo, release, handoffs, stop = null, api }) { + if (!handoffs.length && !stop) return "no handoffs; nothing filed"; const marker = releaseMarker(release); const app = LANES_APP_LOGIN.replace(/\[bot\]$/, ""); const q = encodeURIComponent(`repo:${repo} is:issue author:app/${app} in:body "upstream-apply-changelog release=${release}"`); @@ -91,15 +100,18 @@ export async function fileReleaseHandoffs({ repo, release, handoffs, api }) { (i) => i?.user?.login === LANES_APP_LOGIN && typeof i.body === "string" && i.body.includes(marker), ); if (!issue) { - await api(`/repos/${repo}/issues`, { method: "POST", body: releaseIssue(release, handoffs) }); + await api(`/repos/${repo}/issues`, { method: "POST", body: releaseIssue(release, handoffs, stop) }); return `filed ${handoffs.length} handoffs as one issue`; } - if (issue.state !== "open") return `the release issue #${issue.number} is closed; nothing filed`; + if (issue.state !== "open" && !stop) return `the release issue #${issue.number} is closed; nothing filed`; const comments = await listComments(api, repo, issue.number); const own = comments.filter((c) => c?.user?.login === LANES_APP_LOGIN && typeof c.body === "string"); const have = new Set([issue.body, ...own.map((c) => c.body)].flatMap(keysIn)); const missing = handoffs.filter((h) => !have.has(h.key)); if (!missing.length) return `the release issue #${issue.number} already holds every handoff`; + if (issue.state !== "open") { + throw new Error(`the release issue #${issue.number} is closed and lacks ${missing.length} handoffs; the stopped release is not marked done`); + } const base = `${issue.body.trimEnd()}\n\n`; const items = renderItems(missing, Number.POSITIVE_INFINITY); if (base.length + items.length > ISSUE_BODY_MAX) { @@ -213,8 +225,11 @@ export function validatePlan(plan, { release, baseSha }) { if (plan.range !== `${release}..${release}`) fail("range is not this run's release"); if (plan.base_sha !== baseSha) fail("base_sha is not the commit the run checked out"); // A stopped plan derived no rows, so it publishes no branch; its handoffs - // (ci-mode.md: cap exceeded carries a docs-conformance one) are still filed. - const branches = status === "stopped" ? [] : Array.isArray(plan.branches) ? plan.branches : fail("branches is not a list"); + // (ci-mode.md: cap exceeded carries a docs-conformance one) are still + // filed, under an issue line naming its stop_reason. + const stopped = status === "stopped"; + const stop_reason = !stopped ? null : STOP_REASONS.has(plan.stop_reason) ? plan.stop_reason : fail("stop_reason is neither cap-exceeded nor range-not-computed"); + const branches = stopped ? [] : Array.isArray(plan.branches) ? plan.branches : fail("branches is not a list"); const handoffs = Array.isArray(plan.handoffs) ? plan.handoffs : fail("handoffs is not a list"); const seen = new Set(); branches.forEach((b, i) => { @@ -240,7 +255,7 @@ export function validatePlan(plan, { release, baseSha }) { if (typeof h.title !== "string" || !h.title || h.title.length > 256 || /[\r\n]/.test(h.title)) fail(`${at}.title is bad`); if (typeof h.body !== "string" || h.body.length > 60000) fail(`${at}.body is bad`); }); - return { status, branches, handoffs }; + return { status, stop_reason, branches, handoffs }; } // Every path the raw diff changes, either side of a rename, must sit under @@ -474,10 +489,10 @@ async function publish(env) { opened++; } console.log(`opened ${opened} pull requests`); - console.log(await fileReleaseHandoffs({ repo, release, handoffs: plan.handoffs, api })); - // Its handoffs are filed, but the release was never applied: fail so the - // done marker is not uploaded and the next run takes the release again. - if (plan.status === "stopped") throw new Error(`the plan for Claude Code ${release} stopped; its handoffs are filed and the release is not marked done`); + // A stopped plan is the same on every run for its release, so once its + // issue holds every handoff publish succeeds and the release is marked + // done; a filing that fails throws, and the next run takes it again. + console.log(await fileReleaseHandoffs({ repo, release, handoffs: plan.handoffs, stop: plan.stop_reason, api })); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index 2b08a5c3ebe..d5f71193f8c 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -77,16 +77,29 @@ test("a valid plan passes and a stopped plan keeps its handoffs and publishes no assert.equal(validatePlan(plan(), ctx).branches.length, 1); // ci-mode.md: cap exceeded is status stopped with one docs-conformance handoff. const conformance = { key: "changelog:2.1.300..2.1.300:docs-conformance:plugins/harness-ops/skills/changelog/SKILL.md", title: "t", body: "b" }; - assert.deepEqual(validatePlan(plan({ status: "stopped", handoffs: [conformance] }), ctx), { + assert.deepEqual(validatePlan(plan({ status: "stopped", stop_reason: "cap-exceeded", handoffs: [conformance] }), ctx), { status: "stopped", + stop_reason: "cap-exceeded", branches: [], handoffs: [conformance], }); + assert.equal(validatePlan(plan({ status: "stopped", stop_reason: "range-not-computed", handoffs: [] }), ctx).stop_reason, "range-not-computed"); + assert.equal(validatePlan(plan(), ctx).stop_reason, null); assert.throws( - () => validatePlan(plan({ status: "stopped", handoffs: [{ ...conformance, key: "changelog:2.1.1..2.1.1:docs-conformance:x" }] }), ctx), + () => validatePlan(plan({ status: "stopped", stop_reason: "cap-exceeded", handoffs: [{ ...conformance, key: "changelog:2.1.1..2.1.1:docs-conformance:x" }] }), ctx), /handoffs\[0\]\.key/, ); - assert.throws(() => validatePlan(plan({ status: "stopped", range: "2.1.299..2.1.299" }), ctx), /range/); + assert.throws(() => validatePlan(plan({ status: "stopped", stop_reason: "cap-exceeded", range: "2.1.299..2.1.299" }), ctx), /range/); +}); + +test("a stopped plan without a known stop_reason is refused, naming the field and not the value", () => { + const SECRET = "zzPLANTEDzz"; + for (const stop_reason of [undefined, null, "", SECRET]) { + assert.throws( + () => validatePlan(plan({ status: "stopped", stop_reason }), ctx), + (e) => /stop_reason/.test(e.message) && !e.message.includes(SECRET), + ); + } }); test("a plan for another release, base or branch name is refused", () => { @@ -158,6 +171,16 @@ test("the publish text holds every pull request and issue string publish can pos assert.equal(publishText(validatePlan(plan({ handoffs: [] }), ctx), "2.1.300").includes(releaseMarker("2.1.300")), false); }); +test("a stopped plan's publish text holds the issue it posts, stop line included, even with no handoffs", async () => { + const v = validatePlan(plan({ status: "stopped", stop_reason: "range-not-computed", handoffs: [] }), ctx); + const f = issueApi(); + await fileReleaseHandoffs({ repo: REPO, release: "2.1.300", handoffs: v.handoffs, stop: v.stop_reason, api: f.api }); + const posted = f.writes()[0].body; + const text = publishText(v, "2.1.300"); + assert.ok(text.includes(posted.title) && text.includes(posted.body)); + assert.ok(posted.body.includes("stop_reason `range-not-computed`")); +}); + test("a commit outside its plugin, or writing a link or submodule, is refused", () => { const ok = ":000000 100644 0000 1111 A\tplugins/hooks-kit/SKILL.md\n:000000 100644 0000 2222 A\t.changes/hooks-kit/x.md"; assert.doesNotThrow(() => checkRawLog(ok, "hooks-kit")); @@ -473,6 +496,55 @@ test("an outsider's issue carrying the release marker is ignored and a new issue assert.equal(writes[0].method, "POST"); }); +test("a stopped plan with no handoffs still files one issue that names its stop_reason", async () => { + const f = issueApi(); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [], stop: "range-not-computed", api: f.api }); + const writes = f.writes(); + assert.equal(writes.length, 1); + assert.equal(writes[0].path, `/repos/${REPO}/issues`); + assert.equal(writes[0].body.title, `Claude Code v${REL} changelog: 0 handoffs`); + assert.ok(writes[0].body.body.startsWith(`${releaseMarker(REL)}\n\n`)); + assert.ok(writes[0].body.body.includes(`Claude Code v${REL} stopped (stop_reason \`range-not-computed\`)`)); +}); + +test("a stopped plan's handoff issue opens with the stop line above its checklist", async () => { + const f = issueApi(); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1)], stop: "cap-exceeded", api: f.api }); + const { body } = f.writes()[0].body; + const stopAt = body.indexOf("stop_reason `cap-exceeded`"); + assert.ok(stopAt > 0 && stopAt < body.indexOf("- [ ] adopt: adopt 1")); + assert.ok(body.includes(handoffMarker(handoff(1).key))); +}); + +test("a stopped plan whose closed release issue lacks a key fails with no write", async () => { + const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; + const f = issueApi([{ user: APP, number: 7, state: "closed", body }]); + await assert.rejects( + fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1), handoff(2)], stop: "cap-exceeded", api: f.api }), + /#7 is closed and lacks 1 handoffs/, + ); + assert.equal(f.writes().length, 0); +}); + +test("a stopped plan whose release issue already holds every key succeeds with no write, open or closed", async () => { + const hs = [handoff(1), handoff(2)]; + const body = `${releaseMarker(REL)}\n\n${renderItems(hs, 65536)}`; + for (const state of ["open", "closed"]) { + const f = issueApi([{ user: APP, number: 7, state, body }]); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: hs, stop: "cap-exceeded", api: f.api }); + assert.equal(f.writes().length, 0); + } +}); + +test("a stopped plan whose issue filing fails rejects, so publish fails and the release is retried", async () => { + const f = issueApi(); + const failing = async (path, opts = {}) => { + if (opts.method === "POST") throw new Error("POST /repos/o/r/issues answered 502"); + return f.api(path, opts); + }; + await assert.rejects(fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [handoff(1)], stop: "cap-exceeded", api: failing }), /answered 502/); +}); + test("a key marker in an outsider's comment does not count as filed", async () => { const body = `${releaseMarker(REL)}\n\n${renderItems([handoff(1)], 65536)}`; const f = issueApi([{ user: APP, number: 7, state: "open", body }], [{ user: OUTSIDER, body: renderItems([handoff(2)], 65536) }]); diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 5a6c1eb47c2..25e872a55d1 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -724,7 +724,8 @@ jobs: # publish-artifact: pull-requests and issues write, contents read. Opens a # pull request for each branch at the head push-branches left that has none, and files # the release's handoffs as one issue, appending keys an open one lacks; then - # records the release done. + # records the release done. A stopped plan files its issue, naming its + # stop_reason, and is recorded done once that issue holds every handoff key. publish: needs: [gate, scan, push-branches] if: >- @@ -851,7 +852,7 @@ jobs: [ "$status" = 204 ] || echo "::warning::the lane token was not revoked (DELETE $status); it expires within the hour" # The gate's done marker: a later run skips this release even when its - # plan made no branch. + # plan made no branch or stopped. - name: Upload the done marker if: steps.publish.outcome == 'success' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/plugins/harness-ops/skills/changelog/context/ci-mode.md b/plugins/harness-ops/skills/changelog/context/ci-mode.md index 66e2adc3484..051590c9d04 100644 --- a/plugins/harness-ops/skills/changelog/context/ci-mode.md +++ b/plugins/harness-ops/skills/changelog/context/ci-mode.md @@ -128,5 +128,8 @@ always false: the marker moves in the ledger pull request once every row is in a `range-not-computed`) or `planned` (rows were derived; `branches` and `handoffs` may each be empty). Handoff bodies carry item ids and the required sentence, never changelog text, matching the ledger rule in [decisions.md](decisions.md). The workflow groups a release's handoffs into one issue, a -checklist item per handoff. PR bodies follow the consuming repository's pull request +checklist item per handoff. A stopped plan is the same on every run for its release, so the +workflow files its issue even with no handoffs, opens the issue with a line naming the +`stop_reason`, and marks the release done once that issue holds every handoff key; a filing that +fails leaves the release for the next run. PR bodies follow the consuming repository's pull request template. From 7e441a11cdf23a7b80d53ecebfd1f6032fddbbf8 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:59:56 -0500 Subject: [PATCH 32/35] fix(upstream-apply-changelog): retry a range-not-computed stop and fix the branch's CI failures A range-not-computed stop can pass on a later run (the changelog fetch failed, or the page lags the tag), so publish now files nothing for it and outputs done=false; only the done output uploads the done marker. A cap-exceeded stop still files its issue and marks the release done. CI fixes for the new gated workflow: - artifact-retention.json: rows for the started, plan and done artifacts. - model job: API_BUDGET_TOKEN on resolve-config and a job-end log-api-budget step from the trusted checkout. - CARRY_REFUSES (both copies) holds the changelog skill's scripts, which the workflow runs. - Floor pins: 126 classes, 73 person-cleared globs, 54 gate, new digests. - read-lane-broker-check.test.sh: the check-mode refusal names release, with a case for it. Co-Authored-By: Claude Opus 5.5 --- .../feat-release-intake-workflow-3e9b6d21.md | 5 ++-- .../feat-release-intake-workflow-38b891af.md | 8 ++++++ .github/artifact-retention.json | 25 ++++++++++++++++++ .github/scripts/pr-merge/merge-holds.mjs | 2 +- .../upstream-apply-changelog.mjs | 24 ++++++++++++----- .../upstream-apply-changelog.test.mjs | 26 +++++++++++++++---- .../workflows/upstream-apply-changelog.yml | 21 ++++++++++++--- .../skills/changelog/context/ci-mode.md | 7 ++--- .../scripts/babysit_merge_policy.py | 1 + .../scripts/tests/test_babysit_risk.py | 2 +- .../scripts/tests/test_floor_paths.py | 10 +++---- .../scripts/tests/test_merge_holds_sync.py | 2 +- scripts/read-lane-broker-check.test.sh | 11 ++++---- 13 files changed, 110 insertions(+), 34 deletions(-) create mode 100644 .changes/source-control/feat-release-intake-workflow-38b891af.md diff --git a/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md b/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md index b25a09e8ad4..ee8c8791b13 100644 --- a/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md +++ b/.changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md @@ -5,5 +5,6 @@ bump: patch ### Changed - `changelog apply --ci` now says the calling workflow files a release's handoffs as one issue, with - one checklist item per handoff. A stopped plan's issue names its `stop_reason`, and the release is - marked done once that issue holds every handoff. + one checklist item per handoff. A `cap-exceeded` plan's issue names its `stop_reason`, and the + release is marked done once that issue holds every handoff; a `range-not-computed` plan files + nothing and the next run takes the release again. diff --git a/.changes/source-control/feat-release-intake-workflow-38b891af.md b/.changes/source-control/feat-release-intake-workflow-38b891af.md new file mode 100644 index 00000000000..5c5ba85a76d --- /dev/null +++ b/.changes/source-control/feat-release-intake-workflow-38b891af.md @@ -0,0 +1,8 @@ +--- +bump: patch +--- + +### Changed + +- babysit-prs: a push touching `plugins/harness-ops/skills/changelog/scripts/` now stops an + approval carrying, since the gated upstream-apply-changelog workflow runs a script there. diff --git a/.github/artifact-retention.json b/.github/artifact-retention.json index df11111fe27..3a561d68047 100644 --- a/.github/artifact-retention.json +++ b/.github/artifact-retention.json @@ -443,6 +443,31 @@ ], "reason": "Manual inspection only; no workflow reads it after the run. A person reads a dispatched eval's results soon after starting it, and 3 days covers a weekend.", "scan": "upload" + }, + "upstream-apply-changelog-started": { + "retention-days": 2, + "readers": [ + ".github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs gate counts the start records of the last 24 hours against the per-day cap" + ], + "reason": "The cap counts runs started in the last 24 hours, so a record must outlive one day; 2 days keeps the newest day whole. It holds only the release number the gate chose, never model text, so it is not scanned.", + "scan": "none" + }, + "upstream-apply-changelog-plan-*": { + "retention-days": 1, + "readers": [ + ".github/workflows/upstream-apply-changelog.yml push-branches and publish download it by id, same run" + ], + "reason": "Read by its own run; 1 day is the minimum. The scan job uploads it only after its scans of the plan, the branch diffs and the publish text are clean.", + "scan": "plaintext" + }, + "upstream-apply-changelog-done-*": { + "retention-days": "max", + "readers": [ + ".github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs gate skips a release whose done marker exists" + ], + "max-reader": ".github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs", + "reason": "The marker is the only record that a release was processed: once it expires a schedule run takes the release again, so it keeps the longest retention. It holds the plan the scan job cleared and publish posted, downloaded by id and hash; the publish job runs no scan of its own.", + "scan": "none" } } } diff --git a/.github/scripts/pr-merge/merge-holds.mjs b/.github/scripts/pr-merge/merge-holds.mjs index 7c4348ac63e..57e91216940 100755 --- a/.github/scripts/pr-merge/merge-holds.mjs +++ b/.github/scripts/pr-merge/merge-holds.mjs @@ -991,7 +991,7 @@ export function approvalCarry(then, now, from, inBase, merges, based, dismissal // lanes still judge it. babysit_merge_policy.CARRY_REFUSES is the same list. // The trusted-actor list and the pipeline config are read too: a widening the // approver never saw is lifted by the same approval (actorsHold). -export const CARRY_REFUSES = Object.freeze([".github/workflows/**", ".github/actions/**", ".github/scripts/**", ".github/standards/**", "docs/conventions/pr-pipeline/**", "scripts/**", "lib/**", "plugins/source-control/skills/babysit-prs/scripts/**", "plugins/performance/scripts/**", "plugins/harness-ops/skills/inventory/scripts/**", "plugins/ai-briefing/skills/generate/scripts/**", "plugins/harness-config/skills/audit-permission-state/scripts/**", "plugins/knowledge/skills/course-digest/scripts/**", "plugins/knowledge/skills/video-digest/scripts/**", "plugins/skill-quality/scripts/**", "plugins/source-control/scripts/**", "plugins/testing/skills/check-visual-parity/scripts/**", ".github/recurring-schedule.json", "plugins/evals/skills/validate/scripts/**", "plugins/testing/evals/browser-probe/**", "plugins/work-items/scripts/**", "plugins/work-items/tools/work-item-tracker/**"]); +export const CARRY_REFUSES = Object.freeze([".github/workflows/**", ".github/actions/**", ".github/scripts/**", ".github/standards/**", "docs/conventions/pr-pipeline/**", "scripts/**", "lib/**", "plugins/source-control/skills/babysit-prs/scripts/**", "plugins/performance/scripts/**", "plugins/harness-ops/skills/inventory/scripts/**", "plugins/ai-briefing/skills/generate/scripts/**", "plugins/harness-config/skills/audit-permission-state/scripts/**", "plugins/knowledge/skills/course-digest/scripts/**", "plugins/knowledge/skills/video-digest/scripts/**", "plugins/skill-quality/scripts/**", "plugins/source-control/scripts/**", "plugins/testing/skills/check-visual-parity/scripts/**", ".github/recurring-schedule.json", "plugins/evals/skills/validate/scripts/**", "plugins/testing/evals/browser-probe/**", "plugins/work-items/scripts/**", "plugins/work-items/tools/work-item-tracker/**", "plugins/harness-ops/skills/changelog/scripts/**"]); // Whether the compare from...H' `merges` touches no hold path and no file a // hold path loads, old paths of renames included: pathHold with `holdPaths` diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs index e1bf5ff005b..5b661cc039b 100755 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.mjs @@ -36,6 +36,11 @@ const TRUNCATED = "Handoff bodies are truncated proportionally to fit GitHub's 6 const KIND = /^[a-z][a-z-]{0,39}$/; // ci-mode.md: the two reasons a plan stops before deriving any row. const STOP_REASONS = new Set(["cap-exceeded", "range-not-computed"]); +// range-not-computed can pass on a later run (the changelog fetch failed, or +// the page lags the tag), so it files nothing and leaves the release undone; +// cap-exceeded is the same on every run, so its issue is filed and the +// release marked done. +export const marksDone = (stop) => stop !== "range-not-computed"; const stopLine = (release, stop) => `The plan for Claude Code v${release} stopped (stop_reason \`${stop}\`): no changelog row was derived and no branch was opened. ` + @@ -75,7 +80,7 @@ export function releaseIssue(release, handoffs, stop = null) { // handoff items untruncated, as an append or a comment posts them. export function publishText(plan, release) { const parts = plan.branches.flatMap((b) => [b.pr.title, b.pr.body]); - if (plan.handoffs.length || plan.stop_reason) { + if (marksDone(plan.stop_reason) && (plan.handoffs.length || plan.stop_reason)) { const issue = releaseIssue(release, plan.handoffs, plan.stop_reason); parts.push(issue.title, issue.body, renderItems(plan.handoffs, Number.POSITIVE_INFINITY)); } @@ -87,9 +92,9 @@ export function publishText(plan, release) { // markers in its body and the App's comments: anyone can type a marker. An // open issue missing some keys gets them appended in one update, or in one // comment when the body would pass the limit; a closed one is left alone. -// A stopped plan (`stop` is its stop_reason) is marked done once this -// returns, so it files the issue even with no handoffs, and a closed issue -// missing a key throws instead. +// A stopped plan publish files (`stop` is a stop_reason marksDone accepts) +// is marked done once this returns, so it files the issue even with no +// handoffs, and a closed issue missing a key throws instead. export async function fileReleaseHandoffs({ repo, release, handoffs, stop = null, api }) { if (!handoffs.length && !stop) return "no handoffs; nothing filed"; const marker = releaseMarker(release); @@ -489,10 +494,15 @@ async function publish(env) { opened++; } console.log(`opened ${opened} pull requests`); - // A stopped plan is the same on every run for its release, so once its - // issue holds every handoff publish succeeds and the release is marked - // done; a filing that fails throws, and the next run takes it again. + // A deterministic stop's release is marked done once its issue holds every + // handoff; a filing that fails throws, and the next run takes it again. + if (!marksDone(plan.stop_reason)) { + console.log(`::notice::the plan for Claude Code v${release} stopped (${plan.stop_reason}); nothing filed, and the next run takes it again`); + output("done", "false"); + return; + } console.log(await fileReleaseHandoffs({ repo, release, handoffs: plan.handoffs, stop: plan.stop_reason, api })); + output("done", "true"); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs index d5f71193f8c..8301dea5f3c 100644 --- a/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs +++ b/.github/scripts/upstream-apply-changelog/upstream-apply-changelog.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -13,6 +13,7 @@ import { fetchSynthetic, fileReleaseHandoffs, handoffMarker, + marksDone, netRawDiff, newestRelease, publishText, @@ -172,13 +173,28 @@ test("the publish text holds every pull request and issue string publish can pos }); test("a stopped plan's publish text holds the issue it posts, stop line included, even with no handoffs", async () => { - const v = validatePlan(plan({ status: "stopped", stop_reason: "range-not-computed", handoffs: [] }), ctx); + const v = validatePlan(plan({ status: "stopped", stop_reason: "cap-exceeded", handoffs: [] }), ctx); const f = issueApi(); await fileReleaseHandoffs({ repo: REPO, release: "2.1.300", handoffs: v.handoffs, stop: v.stop_reason, api: f.api }); const posted = f.writes()[0].body; const text = publishText(v, "2.1.300"); assert.ok(text.includes(posted.title) && text.includes(posted.body)); - assert.ok(posted.body.includes("stop_reason `range-not-computed`")); + assert.ok(posted.body.includes("stop_reason `cap-exceeded`")); +}); + +test("only a deterministic stop marks the release done; range-not-computed is retried and posts nothing", () => { + assert.equal(marksDone(null), true); + assert.equal(marksDone("cap-exceeded"), true); + assert.equal(marksDone("range-not-computed"), false); + const v = validatePlan(plan({ status: "stopped", stop_reason: "range-not-computed", handoffs: [] }), ctx); + assert.equal(publishText(v, "2.1.300"), "\n"); +}); + +test("the done marker uploads only when publish says the release is done", () => { + const wf = readFileSync(new URL("../../workflows/upstream-apply-changelog.yml", import.meta.url), "utf8"); + const step = wf.split(/\n(?= {6}- name: )/).filter((s) => s.includes("name: upstream-apply-changelog-done-")); + assert.equal(step.length, 1); + assert.match(step[0], /\n {8}if: steps\.publish\.outcome == 'success' && steps\.publish\.outputs\.done == 'true'\n/); }); test("a commit outside its plugin, or writing a link or submodule, is refused", () => { @@ -498,13 +514,13 @@ test("an outsider's issue carrying the release marker is ignored and a new issue test("a stopped plan with no handoffs still files one issue that names its stop_reason", async () => { const f = issueApi(); - await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [], stop: "range-not-computed", api: f.api }); + await fileReleaseHandoffs({ repo: REPO, release: REL, handoffs: [], stop: "cap-exceeded", api: f.api }); const writes = f.writes(); assert.equal(writes.length, 1); assert.equal(writes[0].path, `/repos/${REPO}/issues`); assert.equal(writes[0].body.title, `Claude Code v${REL} changelog: 0 handoffs`); assert.ok(writes[0].body.body.startsWith(`${releaseMarker(REL)}\n\n`)); - assert.ok(writes[0].body.body.includes(`Claude Code v${REL} stopped (stop_reason \`range-not-computed\`)`)); + assert.ok(writes[0].body.body.includes(`Claude Code v${REL} stopped (stop_reason \`cap-exceeded\`)`)); }); test("a stopped plan's handoff issue opens with the stop line above its checklist", async () => { diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 25e872a55d1..5ecf7fb63e8 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -215,6 +215,8 @@ jobs: - name: Resolve the model id: models if: steps.kill.outputs.proceed == 'true' + env: + API_BUDGET_TOKEN: github-token uses: ./.base/.github/actions/resolve-config # zizmor: ignore[self-repository] with: model-route: resolve-issue @@ -391,6 +393,15 @@ jobs: export PUBLIC_KEY_FILE PAYLOADS node "$TRUSTED/.github/actions/seal-lane-output/seal.mjs" + # From the trusted checkout: the root checkout replaced .base. + - name: Log the API budget at the end + if: always() && steps.run.outcome != 'skipped' + continue-on-error: true + timeout-minutes: 1 + uses: ./.lane-trusted/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token + # Opens the sealed plan and bundle, scans what will be published for # credentials, and uploads only a clean set. No model, no write token. scan: @@ -724,8 +735,10 @@ jobs: # publish-artifact: pull-requests and issues write, contents read. Opens a # pull request for each branch at the head push-branches left that has none, and files # the release's handoffs as one issue, appending keys an open one lacks; then - # records the release done. A stopped plan files its issue, naming its - # stop_reason, and is recorded done once that issue holds every handoff key. + # records the release done. A cap-exceeded plan files its issue, naming its + # stop_reason, and is recorded done once that issue holds every handoff key; + # a range-not-computed plan files nothing and is not recorded done, so the + # next run takes the release again. publish: needs: [gate, scan, push-branches] if: >- @@ -852,9 +865,9 @@ jobs: [ "$status" = 204 ] || echo "::warning::the lane token was not revoked (DELETE $status); it expires within the hour" # The gate's done marker: a later run skips this release even when its - # plan made no branch or stopped. + # plan made no branch or stopped on cap-exceeded. - name: Upload the done marker - if: steps.publish.outcome == 'success' + if: steps.publish.outcome == 'success' && steps.publish.outputs.done == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: upstream-apply-changelog-done-${{ needs.gate.outputs.release }} diff --git a/plugins/harness-ops/skills/changelog/context/ci-mode.md b/plugins/harness-ops/skills/changelog/context/ci-mode.md index 051590c9d04..121f30f0dbb 100644 --- a/plugins/harness-ops/skills/changelog/context/ci-mode.md +++ b/plugins/harness-ops/skills/changelog/context/ci-mode.md @@ -128,8 +128,9 @@ always false: the marker moves in the ledger pull request once every row is in a `range-not-computed`) or `planned` (rows were derived; `branches` and `handoffs` may each be empty). Handoff bodies carry item ids and the required sentence, never changelog text, matching the ledger rule in [decisions.md](decisions.md). The workflow groups a release's handoffs into one issue, a -checklist item per handoff. A stopped plan is the same on every run for its release, so the +checklist item per handoff. A `cap-exceeded` plan is the same on every run for its release, so the workflow files its issue even with no handoffs, opens the issue with a line naming the `stop_reason`, and marks the release done once that issue holds every handoff key; a filing that -fails leaves the release for the next run. PR bodies follow the consuming repository's pull request -template. +fails leaves the release for the next run. A `range-not-computed` plan can pass on a later run (the +changelog fetch failed, or the page lags the tag), so the workflow files nothing and leaves the +release for the next run. PR bodies follow the consuming repository's pull request template. diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py index b0d636217e5..48b3909b433 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py @@ -3197,6 +3197,7 @@ def _skill_scripts(skill: str) -> str: "plugins/testing/evals/browser-probe/**", "plugins/work-items/scripts/**", "plugins/work-items/tools/work-item-tracker/**", + _skill_scripts("harness-ops:changelog"), ) diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_risk.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_risk.py index 0ace8170d2f..0d6204f2ed2 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_risk.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_risk.py @@ -294,7 +294,7 @@ def test_floor_paths_are_high_whatever_the_switch(self): # generated copies hold; here the count of each class. self.assertEqual( Counter(c for _, c in FLOOR.classes), - {"trust": 19, "gate": 53, "enforcement": 53}, + {"trust": 19, "gate": 54, "enforcement": 53}, ) self.assertEqual( FLOOR.high, diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_floor_paths.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_floor_paths.py index 4aa85924abb..6fef5895b47 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_floor_paths.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_floor_paths.py @@ -90,21 +90,21 @@ class TheEffectiveFloorDidNotMove(unittest.TestCase): change updates these pins in the pull request that makes it.""" def test_each_glob_keeps_its_class(self): - self.assertEqual(len(FLOOR.classes), 125) + self.assertEqual(len(FLOOR.classes), 126) self.assertEqual( digest(f"{g}\t{c}" for g, c in FLOOR.classes), - "247c5cb31f4b4b0356f6b2cd635df9be0eab6b2b5195fea91c460d21bce0e1f6", + "ec9dffd684b2aeae787d25f86efb0bbfebfc5f947e4be280a52e3fc2b3696f8a", ) def test_each_clearing_route_keeps_its_globs(self): pins = { - FLOOR.high: "a90483acbad6edb33c4579fa4f1c26350449205ba4d32430bd4ec1a12cc32a62", + FLOOR.high: "89537e9030059a12ee6d85b9527dfda457383eaeaf12576241b32ed3338d95f0", FLOOR.review: "147fbbb746111bc40c801aa7787a35efdca7992f063bc150dd6115f771a2e38b", FLOOR.author_review: "10af3daeaaeaaa01c77eafd040b831be3f147fdf3fa850b636b87a8e64094399", } self.assertEqual( (len(FLOOR.high), len(FLOOR.review), len(FLOOR.author_review)), - (72, 53, 15), + (73, 53, 15), ) for globs, pin in pins.items(): self.assertEqual(digest(globs), pin) @@ -170,7 +170,7 @@ def test_it_holds_only_what_every_consumer_shares(self): self.assertEqual(len(risk.FLOOR_CLASSES), 30) def test_every_other_row_sits_in_the_config(self): - self.assertEqual(len(ROWS), 125 - 30) + self.assertEqual(len(ROWS), 126 - 30) self.assertFalse(table(ROWS) & set(risk.FLOOR_CLASSES)) def test_the_config_every_consumer_reads_is_held_by_a_built_in_row(self): diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_merge_holds_sync.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_merge_holds_sync.py index 8f40b874597..79e42032af1 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_merge_holds_sync.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_merge_holds_sync.py @@ -442,7 +442,7 @@ def test_the_person_floor_is_the_same_list(self) -> None: check=True, ) self.assertEqual(tuple(json.loads(proc.stdout)), FLOOR.high) - self.assertEqual(len(FLOOR.high), 72) + self.assertEqual(len(FLOOR.high), 73) def test_the_floor_holds_what_its_actions_load(self) -> None: files = import_closure(FLOOR_ENTRIES) diff --git a/scripts/read-lane-broker-check.test.sh b/scripts/read-lane-broker-check.test.sh index a37f5a58573..ffce109c83f 100755 --- a/scripts/read-lane-broker-check.test.sh +++ b/scripts/read-lane-broker-check.test.sh @@ -66,12 +66,12 @@ gate_case "an allow reused from an earlier attempt fails" 1 true 1 2 gate_case "no allow (the broker was skipped) fails" 1 false 2 2 gate_case "no broker-check outputs at all fails" 1 "" "" 1 -# url_case