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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
bump: patch
---

### Fixed

- The `upstream-apply-changelog` model job resolves its model, effort and turns from a new
`apply-changelog` skill slot of its lane (route `code-fix`), in place of `model-route:
resolve-issue`, which resolve-config refuses as no run route, so every release run failed before
the model.
- The lane's runs wait in line (`queue: max`) instead of a newer run cancelling a pending one, so
two dispatches in quick succession both run.
46 changes: 46 additions & 0 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { generate as generateCarrierKey } from "../open-lane-output/keygen.mjs";
import { decide } from "../report-check-run/decide.mjs";
import { main as reportCheckRun } from "../report-check-run/report-check-run.mjs";
import { tempDir } from "../../../lib/test-tmp.mjs";
import { main as resolveConfig } from "./resolve-config.mjs";

// The leak-scan step reads the real os.tmpdir(); every scratch dir it makes lands inside this one.
process.env.TMPDIR = tempDir("callers-tmp-");
Expand Down Expand Up @@ -268,6 +269,51 @@ test("intake-triage.yml: intake runs only with claude-args resolved from triage-
assert.equal(killSwitch.outputs.proceed, "${{ steps.kill.outputs.proceed }}");
});

// Each resolve-config step of upstream-apply-changelog.yml, run through the
// action's own entry point over this tree on the lane's two events, with no
// network: a mode or route the resolver refuses fails here, not on a release.
test("upstream-apply-changelog.yml: every resolve-config step resolves on schedule and dispatch, and the model step yields a model", async () => {
const root = fileURLToPath(new URL("../../../", import.meta.url));
const dir = tempDir("upstream-resolve-");
const eventPath = join(dir, "event.json");
writeFileSync(eventPath, "{}");
const steps = Object.values(workflow("upstream-apply-changelog.yml").jobs).flatMap((job) =>
(job.steps ?? []).filter((step) => step.uses?.startsWith(RESOLVE)),
);
assert.ok(steps.some((step) => step.id === "models"));
const github = async (method, path) => {
throw new Error(`unexpected API call ${method} ${path}`);
};
for (const step of steps) {
for (const eventName of ["schedule", "workflow_dispatch"]) {
const env = {
OUTPUT_PATH: join(dir, "resolved.json"),
GITHUB_OUTPUT: join(dir, `out-${step.id}-${eventName}`),
EVENT_NAME: eventName,
EVENT_PATH: eventPath,
};
for (const [key, value] of Object.entries(step.with)) {
if (key === "base-path") env.BASE_PATH = root;
else if (key !== "output-path") {
assert.doesNotMatch(String(value), /\$\{\{/, `${step.id} ${key} is an expression this test cannot evaluate`);
env[key.toUpperCase().replaceAll("-", "_")] = String(value);
}
}
writeFileSync(env.GITHUB_OUTPUT, "");
assert.equal(await resolveConfig({ env, github }), 0, `${step.id} on ${eventName}`);
const outputs = Object.fromEntries(
[...readFileSync(env.GITHUB_OUTPUT, "utf8").matchAll(/^([\w-]+)<<(\S+)\n([\s\S]*?)\n\2$/gm)].map((m) => [m[1], m[3]]),
);
assert.equal(outputs.applies, "true", `${step.id} on ${eventName}`);
if (step.id === "models") {
assert.match(outputs.model, /^(haiku|sonnet|opus)$/);
assert.match(outputs["max-turns"], /^[1-9][0-9]*$/);
assert.match(outputs.effort, /^(|low|medium|high|xhigh|max)$/);
}
}
}
});

test("intake-triage.yml: the kill switch stops the born-triaged check and the floor label", () => {
const { jobs } = workflow("intake-triage.yml");
const gate = jobs["born-triaged"];
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/resolve-config/lane-grants.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
"upstream-apply-changelog": { "apply-changelog-branches": "mutate-branch", "publish-changelog-intake": "publish-artifact" },
"upstream-apply-changelog": { "apply-changelog": "read", "apply-changelog-branches": "mutate-branch", "publish-changelog-intake": "publish-artifact" },
"maintenance-tidy-code-upkeep": { "apply-upkeep-branches": "mutate-branch", "publish-upkeep-prs": "publish-artifact" }
},
"trusted-actors": [],
Expand Down
25 changes: 15 additions & 10 deletions .github/workflows/upstream-apply-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,14 @@ on:

permissions: {}

# One run at a time; a later run waits and is never cancelled.
# One run at a time; later runs wait in line and are never cancelled
# (`queue: max`). The default keeps one waiting run and cancels it when a
# newer one queues, which would drop a dispatch. GitHub keeps at most 100
# waiting and cancels any more; a waiting schedule tick costs one gate.
concurrency:
group: upstream-apply-changelog
cancel-in-progress: false
queue: max

defaults:
run:
Expand Down Expand Up @@ -215,23 +219,24 @@ jobs:
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.
# The lane's apply-changelog slot: its route's model, effort and turns.
# model-route takes only run routes, never a skill activity's route.
- 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
lane: upstream-apply-changelog
activity: apply-changelog
base-path: .base
output-path: ${{ runner.temp }}/upstream-apply-changelog-models.json

# 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'
if: steps.models.outcome == 'success' && steps.models.outputs.applies == 'true'
env:
STAGE: ${{ runner.temp }}/upstream-apply-changelog-stage
run: |
Expand All @@ -243,18 +248,18 @@ jobs:
[ -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'
if: steps.models.outcome == 'success' && steps.models.outputs.applies == 'true'
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'
if: steps.models.outcome == 'success' && steps.models.outputs.applies == 'true'
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'
if: steps.models.outcome == 'success' && steps.models.outputs.applies == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.gate.outputs.base-sha }}
Expand All @@ -264,13 +269,13 @@ jobs:

- name: Prepare the working tree
id: prepare
if: steps.models.outcome == 'success'
if: steps.models.outcome == 'success' && steps.models.outputs.applies == 'true'
env:
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; }
[ -n "$MODEL" ] || { echo "::error::the apply-changelog slot resolved no model, which run-claude-cli cannot pass"; exit 1; }
git config user.name "upstream-apply-changelog"
git config user.email "[email protected]"
printf '.lane/\n' >>.git/info/exclude
Expand Down
18 changes: 17 additions & 1 deletion docs/conventions/pr-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,18 @@ 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]
apply-changelog:
# Runs `/harness-ops:changelog apply --ci <release>` in the
# upstream-apply-changelog model job, which commits one local branch per
# owner plugin and writes ci-plan.json; apply-changelog-branches and
# publish-changelog-intake act on what it leaves. The job holds no write
# token, so no broker mint names it.
skill: harness-ops:changelog
args: apply --ci
effect: read
gating: advisory
# Reads the Claude Code changelog, written outside the org.
reads-untrusted: true
apply-changelog-branches:
# Recreates each changelog/<plugin>/v<release> branch the
# upstream-apply-changelog model job's `/harness-ops:changelog apply --ci`
Expand Down Expand Up @@ -780,14 +792,16 @@ lanes:
- activity: apply-verdict
- activity: apply-refinement
needs: [apply-verdict]
# Started daily and by a listed human's dispatch naming one Claude Code
# Started every ten minutes and by a listed human's dispatch naming one Claude Code
# 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.
upstream-apply-changelog:
stage: upstream
slots:
- activity: apply-changelog
- activity: apply-changelog-branches
needs: [apply-changelog]
- activity: publish-changelog-intake
needs: [apply-changelog-branches]
# Started daily and by a listed human's dispatch
Expand Down Expand Up @@ -873,6 +887,8 @@ models:
summarize-reuse: summary
# An issue's code change, on run-claude-cli, which needs a model alias.
resolve-issue: code-fix
# A release's plugin edits, on run-claude-cli, as resolve-issue's.
apply-changelog: code-fix
# pr-unblock's read-only judge of a finding address-feedback disputed: at
# least the review lanes' model, so a weaker model never overrules the
# reviewer.
Expand Down
Loading