Skip to content

ci(release): promote v0 only after PyPI publish, verify the published action - #81

Open
uipreliga wants to merge 6 commits into
mainfrom
fix/verify-published-action
Open

ci(release): promote v0 only after PyPI publish, verify the published action#81
uipreliga wants to merge 6 commits into
mainfrom
fix/verify-published-action

Conversation

@uipreliga

@uipreliga uipreliga commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Rewritten after a second review round (40345b6). The previous description
documented behavior that review changed — most importantly, preflight referenced a
step output that does not exist, which would have made it red on 100% of runs. This
body describes the branch as it now stands; the per-finding walk-through is in
the review-response comment.

Problem

Consumers pin uses: UiPath/coder_eval@v0. The composite action installs
coder-eval==<action.yml's version: default>, a pin the release commit bumps. In the OLD
release.yml the release job pushed main + the version tag, moved v0, then built the
wheel; PyPI publishing happens in a SEPARATE publish-pypi job (needs: release, behind
a pypi deployment environment for OIDC Trusted Publishing). So v0 moved to an
action.yml pinning version X before X existed on PyPI.

Two reachable paths:

  1. publish-pypi fails, or waits on the environment gate.
  2. The tag move sat before uv build, so a build failure stranded the pin with PyPI
    never involved.

Either way every @v0 consumer's uv tool install coder-eval==X 404s, and nothing
detected it. release.yml's own comment named this seam; the continue-on-error on the
Release step plus its "Flag missing GitHub Release" annotation were a workaround for it.

Separately, nothing verified the published composite: action-dogfood in
pr-checks.yml runs uses: ./ with version: local, which proves a PR's code works but
never touches v0 or PyPI.

Part 1 — prevention (release.yml)

The v0 tag move and the GitHub Release creation moved out of release into a new
promote job gated on needs: [release, publish-pypi]. Nothing a consumer can resolve
happens 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:

  • Monotonic, not merely idempotent. promote refuses to promote anything but the
    newest vX.Y.Z tag. Force-push is only self-idempotent and says nothing about
    ordering: GitHub keeps "Re-run failed jobs" live for 30 days, so replaying an older
    release's promote would walk v0 backwards and silently downgrade every consumer.
  • skip-existing: true on publish-pypi. Without it, an upload that succeeds but
    whose step then fails (lost response, job timeout) gets 400 File already exists
    forever — so promote could never run for a version that is published, which is the
    stranded state from the other direction.
  • …and an artifact-identity assertion to pay for it. skip-existing makes twine treat
    that 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 (promote moves v0 on job success alone; preflight checks
    reachability, not identity). A new step compares urls[].digests.sha256 from PyPI's
    version JSON against sha256sum dist/*. Mismatch is fatal — it must stop promote.
    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-pypi carries no if:. It used to gate on
    if: needs.release.outputs.version != '', which was both dead (the release job already
    exit 1s on an empty version) and dangerous: on a partial "Re-run failed jobs" attempt a
    lost output resolved the job to SKIPPED-green, which — since promote needs it —
    also skipped the promotion, for a fully green run that published no wheel and never
    moved v0. The implicit success() on needs: release is the real gate; emptiness is
    asserted in-job, so a lost output is red.
  • Prereleases discriminated on github.ref, the same signal "Determine release mode"
    already uses, rather than on a needs output — same hazard as above. Emptiness is
    enforced inside promote, so a lost output is red.
  • Release creation normalizes an existing draft/prerelease
    (gh release edit --draft=false --prerelease=false --latest) instead of treating mere
    existence as done, which could report success while announcing nothing.
  • Least privilege. Both create-github-app-token mints declare
    permission-contents: write (omitting permission-* yields a token holding every
    permission of the installation — and this is the app with the main-branch ruleset
    bypass); promote declares permissions: contents: read, dropping the workflow-level
    packages: write that only the GHCR steps need.
  • The old continue-on-error + "Flag missing GitHub Release" scaffolding is removed.
    It existed only because a failure there would have skipped publish-pypi and stranded
    the tag; promote is strictly downstream, so a failure can no longer skip anything
    upstream, and the job is re-runnable — it can fail loudly instead.

Residuals, accepted and documented in-file:

  • vX.Y.Z and main are still pushed by the release job, so if publish-pypi fails
    they briefly reference an unpublished version. Narrower than the @v0 window by design
    (@v0 is the documented pin; @vX.Y.Z/@main are opt-in) and cleared by re-running
    publish-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.
  • The GHCR agent image is deliberately not covered by the promote ordering: it is
    still pushed (and :latest still moved) inside the release job, best-effort. It is an
    internal convenience rather than a ref a stranger's pipeline resolves, and it must be
    built in the job holding the bumped pyproject. Stated in the promote header rather
    than left as an inconsistency.

Part 2 — detection (verify-published-action.yml)

For drift a release cannot cause: a PyPI yank, the pinned setup-uv SHA, runner-image
changes, the @anthropic-ai/claude-code npm package, model deprecation, or the listing
being renamed/delisted.

Tier 1 preflight — free, deterministic, gates tier 2. The hard gate is the
consumer contract: the version action.yml at the v0 tag pins must be installable
from 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 (the uses: below can't be an
expression, 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:

v0 state Verdict
At newest tag, pin matches pass
At newest tag, pin ≠ that release hard fail — release.yml's pin bump didn't take
Lags, newest version is on PyPI hard failpromote didn't run; re-run it
Lags, newest version absent from PyPI warning — release incomplete, @v0 consumers healthy
Lags, PyPI gave no definitive answer warning — inconclusive

Every 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 / 5xx mean we
learned 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, default version:, no repo checkout, task YAML written inline.
Doubles as a live proof that the documented Node + @anthropic-ai/claude-code
prerequisite 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 the
step's exit code and not steps.run.outputs.* (the action step is continue-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 as task_results
rows (parse-alone let an empty report through), and non-zero tokens — plus assertions that
distinguish our breakage from the model's:

  • Zero tokens branches on the error_category every run.json row already carries:
    agent_api_error / agent_rate_limit / agent_timeout / agent_crash warn as
    inconclusive; 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_FAILED with a non-upstream category hard-fail rather than being
    tolerated as a model flake — those are exactly the harness failures this gate exists for.
  • Output wiring must be exact when the step went green; when it went red, a missing
    output is ambiguous (runner behavior) and only warns, but a present-but-wrong one is
    not ambiguous and warns explicitly rather than falling through.
  • The step must be green when every task reported SUCCESS, catching a regression in
    the action's own exit logic while still tolerating a model flake.
  • The run dir uploads on always(), so the routinely-tolerated red step keeps the
    evidence that explains it.

Triggers on Release completion regardless of conclusion — a failed publish-pypi
makes the run's conclusion failure, so gating on success would skip the check exactly
when 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 actionlint models
steps.*.outputs as an open string map, so it passed clean there too. Two additions close
that:

  • CE035 — workflow output-key parity (tests/lint/workflow_outputs.py, wired as
    tests/test_custom_lint.py::TestCE035WorkflowOutputParity). Resolves every
    ${{ steps.<id>.outputs.<key> }} and ${{ needs.<job>.outputs.<key> }} in
    .github/workflows/** + action.yml to a writer that actually produces it. Sound
    boundaries: third-party uses: are skipped (their metadata is not on disk), and a body
    whose 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 nothing
    asserted: the workflow_run: ["Release"] display-name link to release.yml's name:;
    Marketplace slug parity between the workflow's shell pipeline and the tested
    marketplace_slug() over a punctuation/whitespace table; all three
    # <-- kept in sync pin-anchor readers (both seds executed against the real
    action.yml); and the inline consumer task YAML loading through the real load_task.

The slug case was live, not hypothetical: the shipped tr ' ' '-' pipeline turned
Coder Eval (CI gate) into coder-eval-(ci-gate), which 404s, while marketplace_slug()
(and therefore the doc links CE026 pins) produced coder-eval-ci-gate. They agreed only
because action.yml's name: is the one input where both are the identity function.

Docs

CONTRIBUTING.md gains a § Releasing runbook — the three-job table, which jobs are
re-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 names promote and the
verification workflow instead of describing the old single-job shape.

Verification

Every guard was exercised by reproducing the failure, not by reading:

  • 4 parity breakages in a throwaway clone: stale major tag, stranded pin (amend step
    failed), detached # <-- kept in sync anchor, v1 bump that would rot the hardcoded
    @v0.
  • Both lag-classification branches, incl. that the publish-pypi-failure case warns with
    the right diagnosis instead of hard-failing with the wrong one.
  • 10 e2e-gate fixture paths against the extracted Python: happy path, upstream
    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.
  • 5 digest-check fixture paths: digests match, foreign wheel pre-uploaded (fatal),
    filename absent (fatal), index unreachable (warning, publish stands), empty dist (fatal).
  • The monotonicity refusal, both directions.
  • CE035's negative test reproduces the shipped reference verbatim (a step echoing
    pin/newest read as outputs.version) and asserts the rule flags it, alongside the
    missing-step-id and undeclared-needs-output cases and the two skip boundaries.
  • 000000 from || echo 000 confirmed empirically; gh release edit flags confirmed present.
  • actionlint clean on both files; bash -n and ast.parse clean over every run: body
    and embedded Python block.
  • make check clean, custom lint 175 passed, full suite 3894 passed.

make verify fails at pyright on 3 unresolved imports in codex_agent.py for the
optional [codex] extra, which isn't installed locally. Pre-existing and unrelated —
this PR contains no src/ changes.

Cannot be verified pre-merge

workflow_run and schedule only activate once the file is on main, and there is
deliberately no pull_request trigger — so this PR's own checks do not exercise the new
workflow at all
. After merge, workflow_dispatch proves tier 1 immediately (free, no API
spend); 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.*.outputs
survive a partial re-run (both publish-pypi and promote fail loudly either way), and
whether composite outputs propagate through a failed step (the gate no longer depends on
it). One deliberate publish-pypi failure + re-run after merge would settle both.

Behavior changes worth a second opinion:

  • If the pypi environment has required reviewers, promote now waits behind that
    approval before v0 moves. Correct, but the tag previously moved before the gate.
  • permission-contents: write on the app-token mints first executes on a real release. A
    wrong scope key would 422 at mint time; contents is the correct GitHub App permission
    key, but it is worth watching on the next release.

Deliberately deferred

Booked in .claude/harness-candidates.md rather than silently dropped: extracting the two
oversized inline blocks into .github/scripts/ with fixture tests (CE040 — agreed in
principle, 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 a continue-on-error step), both
shapes 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); and
extending CE026's REQUIRED_PREREQ_TOKENS anchor to the e2e job, now a third copy of the
prerequisite steps.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Claude finished @uipreliga's task in 1m 40s —— View job


Code Review in Progress

Review Checklist:

  • Read .github/code_review.md for review guidelines
  • Read CLAUDE.md for project conventions
  • Get full diff with git diff origin/main...HEAD
  • Read .github/workflows/release.yml (full file)
  • Read .github/workflows/verify-published-action.yml (full file)
  • Analyze release workflow changes for correctness
  • Review verification workflow design
  • Check cross-file consistency
  • Perform "what's missing" analysis
  • Provide comprehensive review feedback

Starting code review now...

uipreliga

This comment was marked as outdated.

@akshaylive

Copy link
Copy Markdown
Collaborator

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. steps.parity.outputs.version doesn't exist — preflight fails on every run

The parity step only emits pin, newest, and lagging (verify-published-action.yml:124-128):

{
  echo "pin=$PIN"
  echo "newest=$VERSION"
  echo "lagging=$LAGGING"
} >> "$GITHUB_OUTPUT"

But two later steps reference steps.parity.outputs.version, which resolves to empty:

  • Line 202: TAG_REF: v${{ steps.parity.outputs.version }}git show "v:action.yml" fails.
  • Line 247: VERSION: ${{ steps.parity.outputs.version }}uv tool install "coder-eval==" fails.

Since e2e depends on needs: preflight, this means preflight is red on every trigger and the paid e2e tier — the whole point of this PR — never runs. Easiest fix is probably swapping in steps.parity.outputs.pin at both sites, since the pin is the actual consumer contract this tier is meant to verify (and using newest instead would break under the legitimate lagging=true state).

2. publish-pypi's if: still has the skipped-green hazard that promote was just fixed to avoid

release.yml:321 still gates publish-pypi on:

if: needs.release.outputs.version != ''

promote's own comment right above it explains why this shape is risky — a lost output on a partial "Re-run failed jobs" resolves to skipped-green rather than failing loudly — and promote was switched to gate on github.ref instead. But publish-pypi is still on the old pattern, and since promote requires needs: [release, publish-pypi], a skipped publish-pypi silently skips promote too. That's the exact recovery scenario this whole PR is built around, so it'd be good to bring publish-pypi in line with promote's approach (or just drop the guard, since release's "Resolve published version" step at line 188 already hard-fails on an empty version, making this condition dead weight either way).

Everything else in the earlier review (rationale-comment drift, the PyPI probe's missing transient-code handling, GHCR image push not being gated behind publish-pypi, etc.) reads as solid non-blocking feedback for a follow-up pass. Happy to help with either fix if useful!

uipreliga added a commit that referenced this pull request Aug 6, 2026
…, 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>
@uipreliga

This comment was marked as outdated.

uipreliga and others added 6 commits August 13, 2026 08:45
… 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>
@uipreliga
uipreliga force-pushed the fix/verify-published-action branch from 40345b6 to 096485b Compare August 13, 2026 21:01
@uipreliga

Copy link
Copy Markdown
Collaborator Author

Thanks @akshaylive — both blockers were real, and both are fixed. Full walkthrough is in the comment above; the short version:

1. steps.parity.outputs.version. Fixed by keying each reader off a value that exists, rather than adding the missing output. The Marketplace step now uses a new major output — and your reasoning holds twice over there, because in the lagging=true state no Release was cut for the newest tag, so the live listing reflects the major tag's commit anyway. The install/smoke step uses pin, as you suggested: installing newest would fail during a legitimate lag while @v0 consumers are perfectly healthy. The false "same commit" comment at :200-201 went with it.

2. publish-pypi's if:. Dropped entirely — dead as you showed (Resolve published version already exit 1s on empty) and dangerous in the way promote's own header condemns. Emptiness is now asserted in-job, mirroring promote's "Validate version shape", so a lost output is red rather than a silent skip that also takes the promotion with it.

The thing your first finding really exposed is that nothing in the repo binds a steps.*.outputs.* reference to a step that writes the key — actionlint models it as an open string map, so the typo was invisible to every gate. That's now CE035 (tests/lint/workflow_outputs.py): every steps.<id>.outputs.<key> and needs.<job>.outputs.<key> in .github/workflows/** must resolve to a real writer, and its negative test is the exact shape of blocker #1.

Two commits have landed since that reply:

  • 407b5ea — rebase onto main (plugin ship + the managed-runner migration). promote and preflight follow the pool; 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.
  • 096485b — five gate-correctness fixes. The two that matter most: agent_crash no longer excuses a zero-token run (it's the categorizer's catch-all, so it's exactly what a missing or broken claude CLI produces — arriving under the one label that let the gate exit 0 with an unread ::warning::), and the PyPI artifact assertion is now set equality rather than containment (installers prefer a platform-specific wheel over our py3-none-any, so one planted …-cp313-manylinux_*.whl would be what uv tool install actually resolves while every file we built still matched byte-for-byte).

The branch was force-pushed with that rebased history just now. Still to do before merge: a rebase onto current main — one append conflict in .claude/harness-candidates.md. Everything from the review that wasn't fixed is booked in that file with reasons rather than dropped: CE036 (ban the skipped-green job gate), CE037, CE040 / extracting the oversized run: blocks into .github/scripts/, and exercising the Action's score gate in the failing direction.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants