ci(release): promote v0 only after PyPI publish, verify the published action - #81
ci(release): promote v0 only after PyPI publish, verify the published action#81uipreliga wants to merge 6 commits into
Conversation
|
Claude finished @uipreliga's task in 1m 40s —— View job Code Review in Progress
|
|
Nice work here — the release/promote redesign and the new verification tier are well thought through, and the review history shows a lot of care already went into hardening the failure modes. I double-checked the two blockers from the posted review directly against the checked-out branch, and both are real and still present: 1. The {
echo "pin=$PIN"
echo "newest=$VERSION"
echo "lagging=$LAGGING"
} >> "$GITHUB_OUTPUT"But two later steps reference
Since 2.
if: needs.release.outputs.version != ''
Everything else in the earlier review (rationale-comment drift, the PyPI probe's missing transient-code handling, GHCR image push not being gated behind |
…, CE035 Both blockers from the multi-model review, plus every non-blocking finding that held up on inspection. Blockers: * `steps.parity.outputs.version` does not exist (the step writes pin/newest/ lagging), so `TAG_REF` expanded to the bare `v`, `git show "v:action.yml"` exited 128 under `set -euo pipefail`, and preflight was red on 100% of runs — taking the paid e2e tier (`needs: preflight`) with it. Keyed each reader off a value that exists: a new `major` output for the Marketplace step, and `pin` for the install/smoke step (installing `newest` fails during a legitimate lagging state while @v0 consumers are healthy). * Removed publish-pypi's `if: needs.release.outputs.version != ''`. Dead ("Resolve published version" already exits 1 on empty) and dangerous: on a partial re-run it resolved to SKIPPED-green, which — since promote needs [release, publish-pypi] — also skipped the promotion, for a green run that published no wheel and never moved v0. Emptiness is now asserted in-job, as promote does. Resilience and diagnosis: * PyPI probes gain the 403/429/5xx-vs-404 split the Marketplace probe already performs, so a throttle no longer reports "Stranded action.yml pin" and sends the operator to re-publish a healthy version. * The zero-token gate branches on run.json's error_category: upstream categories warn (inconclusive), everything else stays a hard wiring error. * ERROR/BUILD_FAILED with a non-upstream category now fail rather than being tolerated as model flakes. * JUnit assertion is no longer vacuous (testcase count >= task_results rows), the output-wiring check warns on a present-but-wrong value, and the run-dir upload is `always()` so the tolerated-red case keeps its evidence. Supply chain and least privilege: * skip-existing made a green publish stop proving PyPI serves this run's wheel; a new step compares urls[].digests.sha256 against sha256sum dist/*. Mismatch is fatal, an unreadable index is a warning (propagation lag must not redden a successful publish). * permission-contents: write on both app-token mints; promote drops the inherited packages: write. Guardrails, so this class cannot ship again: * CE035 (tests/lint/workflow_outputs.py) resolves every steps./needs. outputs reference to a real writer; its negative test is the exact shape of the bug above. actionlint models steps.*.outputs as an open string map and does not catch it. * tests/test_verify_published_workflow.py binds the four couplings nothing asserted: the workflow_run display-name link to release.yml, Marketplace slug parity with the tested marketplace_slug() over a punctuation table, all three `# <-- kept in sync` anchor readers, and the inline consumer task YAML loading through the real load_task. Docs: CONTRIBUTING gains a release runbook (job table, recovery flows, the nightly's annotation taxonomy); the GHCR image's exemption from the promote ordering and the unpinned agent-runtime install are recorded as accepted risks. Deferred items (script extraction, score-gate failure direction, CE036/CE037/ CE040) are booked in .claude/harness-candidates.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
… action Consumers pin `UiPath/coder_eval@v0`, and the composite action installs `coder-eval==<action.yml's version: default>`. The release job moved `v0` and cut the GitHub Release *before* the wheel was on PyPI, so a failure after the tag move stranded `@v0` on a pin that cannot resolve — `uv tool install` 404s and every consumer's pipeline breaks. It was reachable two ways: publish-pypi is a separate `needs: release` job that can fail or wait on the `pypi` environment gate, and the tag move sat before "Build wheel + sdist", so a build failure stranded the pin with PyPI never involved. Prevention, not detection: - Move the `v0` promotion and the GitHub Release into a new `promote` job gated on `needs: [release, publish-pypi]`. Nothing consumer-visible happens until the wheel is published. - `promote` is idempotent (force-push tag move, existence-guarded release create), so a failure is recovered by re-running the failed jobs — unlike the `release` job, which would bump a second version. That is what lets these steps fail loudly and removes the `continue-on-error` + annotation dance that existed only because a failure would have skipped publish-pypi. Detection, for what ordering cannot cover (a yank, a rename, a delisting): - New `verify-published-action.yml`. Tier 1 is free and deterministic: assert the major tag points at the newest release, that action.yml *at that tag* pins that version, that the version is on PyPI (retried for index propagation), that the Marketplace listing resolves, and that the wheel installs. Tier 2 consumes the action as a stranger would — `@v0`, default `version:`, no repo checkout, task YAML written inline. - Triggered on Release completion regardless of conclusion: a failed publish-pypi makes the run conclusion `failure`, so gating on success would skip the check exactly when it matters. Plus a daily cron and dispatch. - The e2e gate is ARTIFACTS, not the step's exit code. action.yml exits with coder-eval's own code, and coder-eval exits 1 on any failed task, so `minimum-task-score: 0.0` does not stop a model flake from reddening the build. It asserts run.json, a parseable JUnit, wired outputs, and non-zero tokens — "does the published action work", not "is the model still good". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four findings from a multi-model review (gemini-3.1-pro, gpt-5.6-sol), all verified by reproducing the failure before fixing: - `verify-published-action.yml`: `NEWEST=$(git tag -l … | grep … | head -1)` under `set -euo pipefail` aborts the step when grep matches nothing (exit 1) or head closes the pipe early (141), so the `if [ -z "$NEWEST" ]` diagnostic below it was dead code — a repo with no release tags got a bare exit 1 with no message. Reproduced both ways; `|| true` lets the emptiness check own every failure mode. - `verify-published-action.yml`: the Marketplace probe treated `403` and `000` as proof of delisting. GitHub commonly serves 403 to unauthenticated page fetches from CI runners, and `000` is curl failing outright (DNS/network/TLS) — both are "we learned nothing", not "it's gone". They now warn alongside 429/5xx; only 4xx proper still hard-fails. This was the exact cry-wolf failure the step's own comment set out to avoid. - `verify-published-action.yml`: the e2e gate ignored the action step's exit code entirely, which also hid regressions in the action's OWN exit logic (e.g. a broken score gate reddening a run whose every task succeeded) — a genuine "published action is broken" signal. Now conditional: tolerate a red step when any task under-performed (model flake), require green when all reported SUCCESS. Verified it fires on the regression case and stays quiet on the flake case. Note the reviewer's proposed patch keyed on `final_status`, which does not exist in run.json — `eval_result_to_task_dict` writes `status`. Implemented against the real key and confirmed the suggested form would have been dead on arrival. The same typo was live in this workflow's own diagnostic line (printing `status=None` every run); fixed. - `release.yml`: `gh release view` also matches a DRAFT or prerelease, so promote could skip creation and report success while announcing nothing to the Marketplace. Now normalizes with `gh release edit --draft=false --prerelease=false --latest`, making the job's idempotency claim true in fact. Also records two deferred harness candidates: CE034 for the dead-guard shell pattern (confirmed NOT caught by actionlint+shellcheck, so the existing actionlint candidate does not subsume it), and runtime-key parity for the `run.json` keys that shell consumers depend on but no test binds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lthy lag Second review pass (Opus) on top of the gemini/gpt-5 findings. Six issues, each reproduced before fixing: 1. `v0` force-move was idempotent but NOT monotonic. GitHub keeps "Re-run failed jobs" live for 30 days, so replaying an OLD release's promote (0.9.5 fails at publish-pypi, operator ships 0.9.6, later cleans up the red 0.9.5 run) walked `v0` BACKWARDS and silently downgraded every consumer. The removed comment claimed re-running "is always safe" — force-push is only self-idempotent and says nothing about ordering. Now refuses to promote anything but the newest release tag, with a message naming the version to promote instead. 2. preflight treated the holding state THIS PR introduces as a defect. Because promote now moves `v0` only after publish-pypi, `v0` legitimately lags for the whole interval — including publish-pypi failing (where @v0 consumers are perfectly HEALTHY on the previous release) and the `pypi` environment approval window. Old code hard-failed at "consumers are not getting the newest release" and never reached the accurate stranded-pin diagnostic; every nightly during an approval window would have gone red on a working artifact. The two halves of this PR contradicted each other. The hard gate is now the consumer contract — the version @v0's action.yml PINS must be installable — and lag is classified: newest on PyPI => promote didn't run (hard fail, actionable); newest absent => release merely incomplete (warning, consumers unaffected). 3. `publish-pypi` was not re-runnable, which the whole recovery story assumes. An upload that succeeds but whose step then fails (lost response, timeout) gets 400 "File already exists" forever, so promote could never run for a version that IS published. Added `skip-existing: true`. 4. `|| echo 000` double-appended: curl's own `-w '%{http_code}'` already prints 000 on transport failure, so CODE became the literal "000000" and matched neither the transient allowlist nor 5xx. A DNS/TLS blip was reported as "renamed or delisted" / a stranded pin. Verified `000000` empirically; the previous commit's attempt to allowlist "000" was therefore ineffective. Removed the append in both probes and split "unreachable" from "absent" in the messages. 5. e2e gate was load-bearing on composite `outputs:` surviving a continue-on-error failure — undocumented behavior, and if it does not hold every model flake reddens the workflow with "did not set the junit-path output", defeating the artifact-gate design. File checks now use the literal paths the workflow itself passes in `with:`; output wiring is asserted separately, hard only when the step went green (where propagation is guaranteed) and as a warning otherwise. 6. promote's `if:` failed in the SKIP direction. Gated on `needs.release.outputs.released_version != ''`, a lost output on a partial re-run resolves to skipped-green: green re-run, tag never moved, no Release. Now discriminates prereleases on `github.ref` (the same signal "Determine release mode" uses, and one that cannot evaporate), with emptiness enforced inside the job so a lost output is RED, not silent. Also fixed two Low findings while here: removed dead `git config user.email/name` (a lightweight `git tag -f` needs no committer identity), and scoped the paid e2e tier off branch-dispatched prereleases, which cannot change the published artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, CE035 Both blockers from the multi-model review, plus every non-blocking finding that held up on inspection. Blockers: * `steps.parity.outputs.version` does not exist (the step writes pin/newest/ lagging), so `TAG_REF` expanded to the bare `v`, `git show "v:action.yml"` exited 128 under `set -euo pipefail`, and preflight was red on 100% of runs — taking the paid e2e tier (`needs: preflight`) with it. Keyed each reader off a value that exists: a new `major` output for the Marketplace step, and `pin` for the install/smoke step (installing `newest` fails during a legitimate lagging state while @v0 consumers are healthy). * Removed publish-pypi's `if: needs.release.outputs.version != ''`. Dead ("Resolve published version" already exits 1 on empty) and dangerous: on a partial re-run it resolved to SKIPPED-green, which — since promote needs [release, publish-pypi] — also skipped the promotion, for a green run that published no wheel and never moved v0. Emptiness is now asserted in-job, as promote does. Resilience and diagnosis: * PyPI probes gain the 403/429/5xx-vs-404 split the Marketplace probe already performs, so a throttle no longer reports "Stranded action.yml pin" and sends the operator to re-publish a healthy version. * The zero-token gate branches on run.json's error_category: upstream categories warn (inconclusive), everything else stays a hard wiring error. * ERROR/BUILD_FAILED with a non-upstream category now fail rather than being tolerated as model flakes. * JUnit assertion is no longer vacuous (testcase count >= task_results rows), the output-wiring check warns on a present-but-wrong value, and the run-dir upload is `always()` so the tolerated-red case keeps its evidence. Supply chain and least privilege: * skip-existing made a green publish stop proving PyPI serves this run's wheel; a new step compares urls[].digests.sha256 against sha256sum dist/*. Mismatch is fatal, an unreadable index is a warning (propagation lag must not redden a successful publish). * permission-contents: write on both app-token mints; promote drops the inherited packages: write. Guardrails, so this class cannot ship again: * CE035 (tests/lint/workflow_outputs.py) resolves every steps./needs. outputs reference to a real writer; its negative test is the exact shape of the bug above. actionlint models steps.*.outputs as an open string map and does not catch it. * tests/test_verify_published_workflow.py binds the four couplings nothing asserted: the workflow_run display-name link to release.yml, Marketplace slug parity with the tested marketplace_slug() over a punctuation table, all three `# <-- kept in sync` anchor readers, and the inline consumer task YAML loading through the real load_task. Docs: CONTRIBUTING gains a release runbook (job table, recovery flows, the nightly's annotation taxonomy); the GHCR image's exemption from the promote ordering and the unpinned agent-runtime install are recorded as accepted risks. Deferred items (script extraction, score-gate failure direction, CE036/CE037/ CE040) are booked in .claude/harness-candidates.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Semantic fallout of rebasing onto main (plugin ship + managed runner pool), none of it caught by the textual merge: * release.yml renamed the pin-bump step to "... action.yml + plugin.json pins", which test_verify_published_workflow.py binds by name — the anchor-parity test was failing on a stale literal. * main moved CI to the uipath-* managed pool. `promote` and `preflight` follow; `e2e` deliberately stays on stock ubuntu-latest, with the reason recorded at its own runs-on, because it exists to reproduce what the documented consumer snippet gets. * CExxx ids collided: main shipped CE034 (armed-positive) and this branch ships CE035 (workflow output parity), while both sides had minted candidates under those numbers. Renumbered the three candidates to CE038/CE039/CE041 and listed CE035 among the whole-tree rules in CLAUDE.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1. `agent_crash` no longer excuses a zero-token run. It is the categorizer's catch-all last resort for any unclassified AgentCrashError, so it is exactly what a missing or broken `claude` CLI produces — arriving under the one label that let the gate exit 0 with an unread ::warning::, disarming its only proof that credential passthrough and the agent runtime work. Split into UNREACHABLE (model/API genuinely unavailable) for the zero-token check and TOLERABLE_AFTER_TOKENS for the harness-error check, where a crash with tokens already billed is a plausible transient. 2. The inline nightly task declares run_limits (max_turns / task_timeout / max_usd). Every RunLimits cap defaults to None, so the repo's only unattended PAID run was bounded solely by the job's timeout-minutes — a cancellation that leaves no run.json for the gate to read, i.e. maximally expensive and minimally diagnosable. A tripped cap produces a row the gate already prints. 3. "Assert PyPI serves this run's artifacts" is now set equality, not containment. Digest-matching each local file said nothing about files we did NOT build, and installers prefer a platform-specific wheel over our py3-none-any — so one planted `...-cp313-manylinux_*.whl` would be what `uv tool install` resolves while every file we built still matched. The point-in-time scope of the guarantee is now stated in both the step comment and the CONTRIBUTING runbook, which claimed more than the check delivered. 4. The promote job's two consumer-visible guards get tests, using the lifted- shell harness this branch already built: the monotonicity check refuses to walk `v0` backwards over a v0.9.5/v0.9.6 git fixture (the 30-day "Re-run failed jobs" hazard) and fails loudly when no vX.Y.Z tag exists; the shape regex rejects `0.9`, `0.9.6rc1` and an injection-shaped value, with the separate `-z` branch owning the empty-version diagnostic. 5. CE035 hardening. The writer scan captured the conversion letter out of a format string (`printf "%s=%s\n"` -> the key `s`), and that non-empty-but- wrong set defeated the "no readable key => skip" contract — a false FAILURE, the one direction the docstring promises the rule can never take. The key must now start at a token boundary. A `needs.<job>` naming a job that does not exist is now a finding rather than a deferral to actionlint, which this repo does not run as a gate. Five previously-unexercised branches gained tests (local-composite arm, writes-no-outputs, nonexistent job, the printf regression, Finding.line); each was mutation-checked to confirm it goes red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
40345b6 to
096485b
Compare
|
Thanks @akshaylive — both blockers were real, and both are fixed. Full walkthrough is in the comment above; the short version: 1. 2. The thing your first finding really exposed is that nothing in the repo binds a Two commits have landed since that reply:
The branch was force-pushed with that rebased history just now. Still to do before merge: a rebase onto current Appreciate the offer to help — the fixes were small once you'd pinned them down. A re-review when you have a moment would be very welcome. |

Problem
Consumers pin
uses: UiPath/coder_eval@v0. The composite action installscoder-eval==<action.yml's version: default>, a pin the release commit bumps. In the OLDrelease.ymlthereleasejob pushed main + the version tag, movedv0, then built thewheel; PyPI publishing happens in a SEPARATE
publish-pypijob (needs: release, behinda
pypideployment environment for OIDC Trusted Publishing). Sov0moved to anaction.ymlpinning version X before X existed on PyPI.Two reachable paths:
publish-pypifails, or waits on the environment gate.uv build, so a build failure stranded the pin with PyPInever involved.
Either way every
@v0consumer'suv tool install coder-eval==X404s, and nothingdetected it.
release.yml's own comment named this seam; thecontinue-on-erroron theRelease step plus its "Flag missing GitHub Release" annotation were a workaround for it.
Separately, nothing verified the published composite:
action-dogfoodinpr-checks.ymlrunsuses: ./withversion: local, which proves a PR's code works butnever touches
v0or PyPI.Part 1 — prevention (
release.yml)The
v0tag move and the GitHub Release creation moved out ofreleaseinto a newpromotejob gated onneeds: [release, publish-pypi]. Nothing a consumer can resolvehappens until the wheel is published. Marketplace listings are cut from a published
Release, so creating one also announces a version — hence both moved, not just the tag.
Supporting changes that make the recovery story actually hold:
promoterefuses to promote anything but thenewest
vX.Y.Ztag. Force-push is only self-idempotent and says nothing aboutordering: GitHub keeps "Re-run failed jobs" live for 30 days, so replaying an older
release's promote would walk
v0backwards and silently downgrade every consumer.skip-existing: trueonpublish-pypi. Without it, an upload that succeeds butwhose step then fails (lost response, job timeout) gets 400
File already existsforever — so
promotecould never run for a version that is published, which is thestranded state from the other direction.
skip-existingmakes twine treatthat 400 as success without comparing content, so on its own a green publish stops
proving the wheel this run built is the one PyPI serves — and nothing downstream
re-established it (
promotemovesv0on job success alone; preflight checksreachability, not identity). A new step compares
urls[].digests.sha256from PyPI'sversion JSON against
sha256sum dist/*. Mismatch is fatal — it must stoppromote.An unreadable index is a warning, because the JSON API can lag an upload by seconds
and a transient must not redden a publish that succeeded.
publish-pypicarries noif:. It used to gate onif: needs.release.outputs.version != '', which was both dead (thereleasejob alreadyexit 1s on an empty version) and dangerous: on a partial "Re-run failed jobs" attempt alost output resolved the job to SKIPPED-green, which — since
promoteneeds it —also skipped the promotion, for a fully green run that published no wheel and never
moved
v0. The implicitsuccess()onneeds: releaseis the real gate; emptiness isasserted in-job, so a lost output is red.
github.ref, the same signal "Determine release mode"already uses, rather than on a
needsoutput — same hazard as above. Emptiness isenforced inside
promote, so a lost output is red.(
gh release edit --draft=false --prerelease=false --latest) instead of treating mereexistence as done, which could report success while announcing nothing.
create-github-app-tokenmints declarepermission-contents: write(omittingpermission-*yields a token holding everypermission of the installation — and this is the app with the main-branch ruleset
bypass);
promotedeclarespermissions: contents: read, dropping the workflow-levelpackages: writethat only the GHCR steps need.continue-on-error+ "Flag missing GitHub Release" scaffolding is removed.It existed only because a failure there would have skipped
publish-pypiand strandedthe tag;
promoteis strictly downstream, so a failure can no longer skip anythingupstream, and the job is re-runnable — it can fail loudly instead.
Residuals, accepted and documented in-file:
vX.Y.Zandmainare still pushed by thereleasejob, so ifpublish-pypifailsthey briefly reference an unpublished version. Narrower than the
@v0window by design(
@v0is the documented pin;@vX.Y.Z/@mainare opt-in) and cleared by re-runningpublish-pypi. Closing it entirely means publishing to PyPI before pushing any git ref,which requires carrying the bumped commit between jobs as an artifact — not worth the
new failure modes.
still pushed (and
:lateststill moved) inside thereleasejob, best-effort. It is aninternal convenience rather than a ref a stranger's pipeline resolves, and it must be
built in the job holding the bumped
pyproject. Stated in thepromoteheader ratherthan left as an inconsistency.
Part 2 — detection (
verify-published-action.yml)For drift a release cannot cause: a PyPI yank, the pinned
setup-uvSHA, runner-imagechanges, the
@anthropic-ai/claude-codenpm package, model deprecation, or the listingbeing renamed/delisted.
Tier 1
preflight— free, deterministic, gates tier 2. The hard gate is theconsumer contract: the version
action.ymlat thev0tag pins must be installablefrom PyPI, and that same pin is what the install/smoke step installs. Also asserts the pin
anchor is readable, that the major is still
v0(theuses:below can't be anexpression, so a 1.0.0 bump must fail loudly — and the error enumerates the doc surfaces
that hardcode the major, since CE026 checks the slug, not the major), and that the
Marketplace listing resolves.
Tag lag is classified, not failed:
v0statepromotedidn't run; re-run it@v0consumers healthyEvery HTTP probe in this tier — both PyPI ones and the Marketplace one — uses the same
transient split: only a definitive 4xx is a verdict;
000/403/429/5xxmean welearned nothing. The hard gate stays red on an unproven pin, but it no longer says
"stranded pin, re-run publish-pypi" for a version PyPI already has.
Tier 2
e2e— cents. Consumes the action as a stranger would:uses: UiPath/coder_eval@v0, defaultversion:, no repo checkout, task YAML written inline.Doubles as a live proof that the documented Node +
@anthropic-ai/claude-codeprerequisite steps still work. Skipped for branch-dispatched prereleases, which cannot
change the published artifact.
The gate is artifacts at the literal paths the workflow passes in
with:, not thestep's exit code and not
steps.run.outputs.*(the action step iscontinue-on-error,and composite-output propagation through a failed step is undocumented). It requires
run.json, a JUnit report with at least as many<testcase>elements astask_resultsrows (parse-alone let an empty report through), and non-zero tokens — plus assertions that
distinguish our breakage from the model's:
error_categoryeveryrun.jsonrow already carries:agent_api_error/agent_rate_limit/agent_timeout/agent_crashwarn asinconclusive; anything else (auth, billing, config, sandbox, or no category) is the hard
"wiring is broken" error. A daily cron will eventually meet a transient, and sending the
operator to audit credentials for an Anthropic outage is how a check earns being ignored.
ERROR/BUILD_FAILEDwith a non-upstream category hard-fail rather than beingtolerated as a model flake — those are exactly the harness failures this gate exists for.
output is ambiguous (runner behavior) and only warns, but a present-but-wrong one is
not ambiguous and warns explicitly rather than falling through.
SUCCESS, catching a regression inthe action's own exit logic while still tolerating a model flake.
always(), so the routinely-tolerated red step keeps theevidence that explains it.
Triggers on Release completion regardless of
conclusion— a failedpublish-pypimakes the run's conclusion
failure, so gating onsuccesswould skip the check exactlywhen it matters. Plus a daily cron and
workflow_dispatch.Part 3 — guardrails, so this class cannot ship again
The first round of this PR shipped a reference to a step output that does not exist. It
was invisible to ruff, pyright, pytest and the CE runner, and
actionlintmodelssteps.*.outputsas an open string map, so it passed clean there too. Two additions closethat:
tests/lint/workflow_outputs.py, wired astests/test_custom_lint.py::TestCE035WorkflowOutputParity). Resolves every${{ steps.<id>.outputs.<key> }}and${{ needs.<job>.outputs.<key> }}in.github/workflows/**+action.ymlto a writer that actually produces it. Soundboundaries: third-party
uses:are skipped (their metadata is not on disk), and a bodywhose writers are not statically readable is skipped rather than guessed at. Its negative
test is the exact shape of the shipped bug.
tests/test_verify_published_workflow.py(8 tests) binds the four couplings nothingasserted: the
workflow_run: ["Release"]display-name link torelease.yml'sname:;Marketplace slug parity between the workflow's shell pipeline and the tested
marketplace_slug()over a punctuation/whitespace table; all three# <-- kept in syncpin-anchor readers (both seds executed against the realaction.yml); and the inline consumer task YAML loading through the realload_task.The slug case was live, not hypothetical: the shipped
tr ' ' '-'pipeline turnedCoder Eval (CI gate)intocoder-eval-(ci-gate), which 404s, whilemarketplace_slug()(and therefore the doc links CE026 pins) produced
coder-eval-ci-gate. They agreed onlybecause
action.yml'sname:is the one input where both are the identity function.Docs
CONTRIBUTING.mdgains a § Releasing runbook — the three-job table, which jobs arere-runnable, four named recovery flows, and a table of every annotation the nightly emits
with what it means and what to do.
CLAUDE.md's tree line now namespromoteand theverification workflow instead of describing the old single-job shape.
Verification
Every guard was exercised by reproducing the failure, not by reading:
failed), detached
# <-- kept in syncanchor,v1bump that would rot the hardcoded@v0.the right diagnosis instead of hard-failing with the wrong one.
rate-limit tolerated, zero-tokens-no-category fatal, auth error fatal, harness error
fatal, model flake tolerated, agent crash tolerated, all-SUCCESS-with-red-step fatal,
empty JUnit fatal, no rows fatal.
filename absent (fatal), index unreachable (warning, publish stands), empty dist (fatal).
pin/newestread asoutputs.version) and asserts the rule flags it, alongside themissing-step-id and undeclared-
needs-output cases and the two skip boundaries.000000from|| echo 000confirmed empirically;gh release editflags confirmed present.actionlintclean on both files;bash -nandast.parseclean over everyrun:bodyand embedded Python block.
make checkclean, custom lint 175 passed, full suite 3894 passed.make verifyfails atpyrighton 3 unresolved imports incodex_agent.pyfor theoptional
[codex]extra, which isn't installed locally. Pre-existing and unrelated —this PR contains no
src/changes.Cannot be verified pre-merge
workflow_runandscheduleonly activate once the file is onmain, and there isdeliberately no
pull_requesttrigger — so this PR's own checks do not exercise the newworkflow at all. After merge,
workflow_dispatchproves tier 1 immediately (free, no APIspend); CE035 means the failure that made that first dispatch mandatory can no longer be
the one that greets it.
Runner behaviors that remain assumptions, all now failing safe: whether
needs.*.outputssurvive a partial re-run (both
publish-pypiandpromotefail loudly either way), andwhether composite outputs propagate through a failed step (the gate no longer depends on
it). One deliberate
publish-pypifailure + re-run after merge would settle both.Behavior changes worth a second opinion:
pypienvironment has required reviewers,promotenow waits behind thatapproval before
v0moves. Correct, but the tag previously moved before the gate.permission-contents: writeon the app-token mints first executes on a real release. Awrong scope key would 422 at mint time;
contentsis the correct GitHub App permissionkey, but it is worth watching on the next release.
Deliberately deferred
Booked in
.claude/harness-candidates.mdrather than silently dropped: extracting the twooversized inline blocks into
.github/scripts/with fixture tests (CE040 — agreed inprinciple, but it is a refactor of a workflow that cannot be exercised pre-merge, and
CE035 + the new tests close the specific classes); CE036 (ban the skipped-green job
gate) and CE037 (
if: failure()in a job containing acontinue-on-errorstep), bothshapes now hand-fixed twice; exercising the action's score gate in the failing direction
(needs a second paid agent run — belongs in
action-dogfood, which already pays); andextending CE026's
REQUIRED_PREREQ_TOKENSanchor to thee2ejob, now a third copy of theprerequisite steps.
🤖 Generated with Claude Code