diff --git a/.changes/pr-pipeline/fix-upstream-apply-changelog-route-4d1e9a73.md b/.changes/pr-pipeline/fix-upstream-apply-changelog-route-4d1e9a73.md new file mode 100644 index 00000000000..c45d7823a1b --- /dev/null +++ b/.changes/pr-pipeline/fix-upstream-apply-changelog-route-4d1e9a73.md @@ -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. diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 72c838918c8..8148186f118 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -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-"); @@ -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"]; diff --git a/.github/actions/resolve-config/lane-grants.json b/.github/actions/resolve-config/lane-grants.json index 0479e466e1a..4fdb28d475b 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" }, - "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": [], diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 5e14cef7eb9..de79ec23252 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -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: @@ -215,8 +219,8 @@ 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' @@ -224,14 +228,15 @@ jobs: 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: | @@ -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 }} @@ -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 "upstream-apply-changelog@users.noreply.github.com" printf '.lane/\n' >>.git/info/exclude diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index fcc42e10c28..f91b51c3ab1 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -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 ` 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//v branch the # upstream-apply-changelog model job's `/harness-ops:changelog apply --ci` @@ -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 @@ -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.