Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
400d85a
feat(harness-ops): add a --ci mode to changelog apply
kyle-sexton Nov 2, 2026
4f51e56
feat(ci): add the release intake lane workflow
kyle-sexton Nov 2, 2026
1dd2f14
refactor(ci): rename the release intake lane to upstream-apply-changelog
kyle-sexton Nov 2, 2026
3fc0797
fix(ci): read the cap line changelog-status.sh prints
kyle-sexton Nov 2, 2026
983cb67
fix(ci): put upstream-apply-changelog in the upstream stage
kyle-sexton Nov 2, 2026
5caabf5
feat(ci): register upstream-apply-changelog in the lane lists and tests
kyle-sexton Nov 2, 2026
69846c0
fix(ci): pass check-trusted-trigger only the inputs the gate needs
kyle-sexton Nov 2, 2026
db0fd37
feat(ci): push changelog branches as signed lanes App commits
kyle-sexton Nov 2, 2026
4ff6aae
feat(ci): keep the changelog model job's sealed transcript
kyle-sexton Nov 2, 2026
9f2cbc1
fix(ci): drop the template literal from the scan job's plan check
kyle-sexton Nov 2, 2026
ba79cdd
chore(ci): add the pr-pipeline fragment and mark the lane script exec…
kyle-sexton Nov 2, 2026
d1ceef2
feat(ci): let the upstream-apply-changelog scan job hold the scan-car…
kyle-sexton Nov 2, 2026
28ebc16
feat(ci): file a release's changelog handoffs as one issue
kyle-sexton Nov 2, 2026
fb7e05d
fix(ci): comment changelog handoffs a full release issue cannot hold
kyle-sexton Nov 2, 2026
96ed7b7
Merge remote-tracking branch 'origin/main' into feat/release-intake-w…
kyle-sexton Nov 2, 2026
35671d0
fix(ci): run the changelog model on the first attempt only
kyle-sexton Nov 2, 2026
909af78
fix(ci): trust only the lanes App's changelog release issue
kyle-sexton Nov 2, 2026
08f2d13
fix(ci): keep model text out of the changelog scan job's logs
kyle-sexton Nov 2, 2026
b2a53f5
fix(ci): push each changelog branch as one commit of its scanned tree
kyle-sexton Nov 2, 2026
6244c74
fix(ci): refuse binary files in changelog branches
kyle-sexton Nov 2, 2026
6d3c1eb
fix(ci): upload a changelog bundle rebuilt from the planned heads
kyle-sexton Nov 2, 2026
ce77054
fix(ci): scan the changelog pull request and issue text as posted
kyle-sexton Nov 2, 2026
77a26e7
fix(ci): seal changelog output from a fresh checkout and pin its arti…
kyle-sexton Nov 2, 2026
51cd880
fix(ci): close a fail-open binary check and verify the bundle in publish
kyle-sexton Nov 2, 2026
1c54508
fix(ci): scan every commit the release bundle carries
kyle-sexton Nov 2, 2026
1f7f1f7
fix(ci): bundle one synthetic commit per head instead of the model's …
kyle-sexton Nov 2, 2026
2fca2fb
fix(ci): ignore global and system git config in every changelog job
kyle-sexton Nov 2, 2026
e1e12da
Merge remote-tracking branch 'origin/main' into feat/release-intake-w…
kyle-sexton Nov 2, 2026
55a8780
fix(ci): scan each changelog branch as a diff and by file
kyle-sexton Nov 2, 2026
08fb34a
Merge remote-tracking branch 'origin/main' into feat/release-intake-w…
kyle-sexton Nov 2, 2026
3f8f74b
docs(harness-ops): add the changelog fragment for one handoff issue p…
kyle-sexton Nov 2, 2026
5c059e5
revert(ci): defer the changelog lane's split-token scan to hardening
kyle-sexton Nov 2, 2026
b834aff
Merge commit '4a3213c937a770e9bb6ce882d7d2e62a2f4ce7b4' into feat/rel…
kyle-sexton Nov 2, 2026
b711fbb
fix(ci): keep stopped plans and stranded branches from marking a rele…
kyle-sexton Nov 2, 2026
6baf135
Merge origin/main into feat/release-intake-workflow
kyle-sexton Nov 2, 2026
00c84c4
Merge commit 'c376130fa0b84f47e2bd8e9931ba3bbb9a9014f3' into feat/rel…
kyle-sexton Nov 2, 2026
6372955
Merge remote-tracking branch 'origin/main' into feat/release-intake-w…
kyle-sexton Nov 2, 2026
efef643
fix(ci): mark a stopped changelog release done once its handoffs are …
kyle-sexton Nov 2, 2026
47d6145
Merge commit 'e60a15b6c53820305baa04badfc479ab55351626' into feat/rel…
kyle-sexton Nov 2, 2026
1f3b177
Merge remote-tracking branch 'origin/main' into feat/release-intake-w…
kyle-sexton Nov 2, 2026
7e441a1
fix(upstream-apply-changelog): retry a range-not-computed stop and fi…
kyle-sexton Nov 2, 2026
76b59ed
fix(upstream-apply-changelog): stop a release whose branch changes a …
kyle-sexton Nov 3, 2026
11c24ce
Merge remote-tracking branch 'origin/main' into feat/release-intake-w…
kyle-sexton Nov 3, 2026
b4bda83
fix(upstream-apply-changelog): refuse a plan that claims the hold-pat…
kyle-sexton Nov 3, 2026
dbb2cf3
Merge remote-tracking branch 'origin/main' into feat/release-intake-w…
kyle-sexton Nov 3, 2026
5c2851e
fix(pr-pipeline): poll for Claude Code releases every ten minutes
kyle-sexton Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changes/harness-ops/feat-release-intake-workflow-3e9b6d21.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
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 `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.
- `changelog apply --ci` now says a branch row may touch no hold path (the security review's
`hold-paths` list); the workflow stops a release whose branch changes one with `stop_reason`
`hold-path`, files its issue, and marks it done.
14 changes: 14 additions & 0 deletions .changes/pr-pipeline/feat-release-intake-workflow-7c41e9a2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
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. It polls
every ten minutes, checks the ten newest unread releases, and starts at most
`limits.upstream-apply-changelog-per-day` (3) runs in any 24 hours.
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .github/actions/log-api-budget/log-api-budget.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
9 changes: 5 additions & 4 deletions .github/actions/request-lane-token/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -44,9 +45,9 @@ 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 <OIDC token>` and the body
`{"lane", "activity", "pr_number"?, "issue_number"?, "dispatcher_run_id"?}`. It sends at most
three requests, each with a fresh OIDC token, and retries only two 503s that come before the
broker reserves the job's mint, as GitHub's
`{"lane", "activity", "pr_number"?, "issue_number"?, "release"?, "dispatcher_run_id"?}`. It sends
at most three requests, each with a fresh OIDC token, and retries 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)
asks:
- `github-rate-limited`: GitHub rate-limited the broker. The retry waits exactly the
Expand Down Expand Up @@ -136,7 +137,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
Expand Down
20 changes: 17 additions & 3 deletions .github/actions/request-lane-token/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
required: false
default: ""
dispatcher-run-id:
description: >-
The pr-unblock-dispatch.yml run that dispatched this run; sent as
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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)')

Expand Down
40 changes: 39 additions & 1 deletion .github/actions/request-lane-token/request-lane-token.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,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);
});

Expand Down
25 changes: 23 additions & 2 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4568,7 +4568,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}`);
}
}
Expand All @@ -4584,9 +4584,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}`);
}
}
Expand Down Expand Up @@ -7609,6 +7627,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());
});

Expand Down Expand Up @@ -7776,6 +7796,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", "publish"],
};
for (const [file, names] of Object.entries(readOnly)) {
const { jobs } = workflow(file);
Expand Down
3 changes: 2 additions & 1 deletion .github/actions/resolve-config/lane-grants.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
"upstream-apply-changelog": { "apply-changelog-branches": "mutate-branch", "publish-changelog-intake": "publish-artifact" }
},
"trusted-actors": [],
"ci-workflows-commits": []
Expand Down
5 changes: 5 additions & 0 deletions .github/actions/resolve-config/lane-rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,10 @@
"stage": "post-merge",
"forbidden-effects": ["merge"],
"never": "Reopens merged PRs"
},
"upstream-apply-changelog": {
"stage": "upstream",
"forbidden-effects": ["mutate-tracker", "merge"],
"never": "Merges, or writes to main or a branch outside changelog/<plugin>/v<release>"
}
}
12 changes: 12 additions & 0 deletions .github/actions/resolve-config/live-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions .github/artifact-retention.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Loading
Loading