Skip to content

fix(release): the console job could never have claimed the name it exists to claim - #45

Merged
wshallwshall merged 3 commits into
mainfrom
claude/asvs-partials-pass-scores-2de207
Jul 29, 2026
Merged

fix(release): the console job could never have claimed the name it exists to claim#45
wshallwshall merged 3 commits into
mainfrom
claude/asvs-partials-pass-scores-2de207

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Blocks the first webconsole-v* tag. As main stands, release-webconsole would fail before its
publish step, so the tag would claim nothing and burn the release.

The defect

release-webconsole used a bare gh release upload, copied from release-harness. The harness can do
that only because needs: release guarantees the engine already created the GitHub release. The
console deliberately has no needs — it fires on its own tag namespace — so on a webconsole-v* tag
no release exists, the upload fails with "release not found", and the job dies before the
publish step. The one job whose purpose is to create the PyPI project and claim
messagefoundry-webconsole could never have claimed it.

Now create-or-update, mirroring the engine. The update arm is load-bearing for a second reason this
workflow already documents: on v0.3.1 a bare gh release create turned a single PyPI failure into a
permanent retry deadlock — every re-run died there and skipped the publish, so a re-run could not
test the fix it existed to verify. Notes come from the console's own changelog; the step order
(reversible GitHub release before irreversible PyPI upload) matches the engine deliberately.

The guard did not work when written

Two derived guards were added with it:

  • test_a_job_without_needs_release_must_create_its_own_github_release — any release job that attaches
    assets without depending on the engine release must create its own.
  • test_every_release_creating_job_is_rerunnable — every gh release create needs a matching
    gh release view guard, or the v0.3.1 deadlock returns.

Mutation-testing caught that the first stayed GREEN when the console was reverted to a bare upload
— it did not catch the exact defect it was written for. Two causes, both fixed:

  1. Job chunks were sliced with body.index(f"{name}:"), which matches the earliest substring
    anywhere including header prose, so every chunk began at the top of the file and contained every
    job. Chunks are now line-anchored on ^ <name>:.
  2. The chunk was matched against raw text — and the job's own comment explaining the v0.3.1 deadlock
    contains the literal gh release create, so it read as "creates a release" with the command deleted.

Both guards now read through a shared _executed_shell() that strips comment lines: count executed
shell, never narration.
That helper exists precisely because the same comment-counting bug was fixed
in one guard and not the other.

All four mutations now red: bare upload · drop the re-run arm · let the engine fire on a console tag ·
couple the cadences with needs: release.

Verification

9442 passed. The single failure (test_installed_metadata_matches_dunder_version) is a local-venv
artifact — this worktree has no .venv, so the run used a stale 0.3.0 install against 0.3.2
source; it passes on a fresh CI install.

Nothing here changes what a tag does until PUBLISH_WEBCONSOLE=true.

🤖 Generated with Claude Code

…y inventory

Two ASVS threads, both about a claim nothing was checking.

## ASVS 15.2.4 — the console has no publish path, so its PyPI name stays free

release.yml had zero mentions of the web console: jobs for the engine and the
harness, none for messagefoundry-webconsole. So the name could never be claimed
by CI, and PyPI confirms it is UNCLAIMED today while messagefoundry and
messagefoundry-harness are both taken. A pending Trusted Publisher is now
registered against this workflow, which grants permission to publish but --
PyPI's own words -- "does not reserve that name".

release-webconsole is a close sibling of release-harness with one deliberate
difference: it is NOT lockstep. The console is separately versioned (0.2.15 vs
the engine's 0.3.2, its own __version__ root, changelog and cadence per
docs/WEBCONSOLE-PACKAGE.md), so it fires on its own `webconsole-v*` tag and
carries no `needs: release`. An engine release must not drag it along and a
console release must not wait on one.

It lives in release.yml rather than its own file because the Trusted Publisher
is registered against `release.yml`; a separate workflow would not match it.

The engine job is now guarded with `!startsWith(github.ref_name, 'webconsole-')`.
Without that a console tag would have run the engine release AT THE CONSOLE'S
VERSION -- and neither job's version check could catch it, since each compares
its own wheel against the same tag.

Publishing stays gated on PUBLISH_WEBCONSOLE, like the harness: the build and
version-check run on every console tag so the path is exercised before it is
armed. Flipping the variable is what actually creates the project and claims the
name.

## Two existing release guards were brittle, and this proved it

Adding the job reds two tests that had nothing to do with it. Both were pinning
an incidental shape rather than the property, so both are de-hardcoded rather
than renumbered:

- test_release_pypi_publish_is_last_step: isolated "the release job" by
  splitting on the literal string "\n  release-harness:". Inserting a job
  between the two silently widened that slice, so the assertion measured the
  wrong job's last step. The boundary is now derived from the next top-level job.
- test_both_wheel_smokes_compare_versions: asserted exactly 2 PEP 440
  comparisons. That number needs bumping every time a wheel job is added, which
  is how it gets bumped without thought. It is now derived from the count of
  jobs that actually build a wheel, so a NEW wheel job carrying a string compare
  fails there instead of lowering the bar.

## ASVS 11.1.2 — the usage-scope clauses were scored Pass and nothing pinned them

`grep -rn 'Usage scope' tests/` returned nothing. The cell was scored Pass on the
**Usage scope:** clauses in ASVS-L2-PHASE0-CHANGES.md section 4, and a new
key-material row with no clause -- or a deleted clause -- would have shipped
green.

Writing the guard found a live gap: the Audit chain row keys its HMAC on an
HKDF-derived subkey of the store DEK (and a named Transit audit key under
vault_transit) and documented no usage scope, despite being exactly 11.1.2's
case -- a key derived from a confidentiality key but used for a different
property, where the separation is the thing worth recording. Clause added.

Classification is explicit rather than a heuristic. A "does this look like key
material" regex mis-sorted 2 of 20 rows on first contact, and a guard with a
non-zero false-positive rate gets suppressed. So every row is classified and an
UNCLASSIFIED row fails -- a new row cannot be added without someone deciding
which side it is on. Same shape as CRITICAL_SECRETS in the rotation inventory.

It also caught two of my own exclusion reasons being too thin to be reasons.

Three mutations red: delete the audit-chain clause, delete the store-DEK clause,
add an unclassified row. Unlike the threat-model guard this document is TRACKED,
so this one runs where CI runs.
…ists to claim

release-webconsole used a bare `gh release upload`, copied from release-harness.
The harness can do that ONLY because `needs: release` guarantees the engine
already created the GitHub release. The console deliberately has no `needs` --
it fires on its own tag namespace -- so on a `webconsole-v*` tag NO release
exists, the upload fails with "release not found", and the job dies BEFORE its
publish step. The one job whose purpose is to create the PyPI project and claim
messagefoundry-webconsole could never have claimed it. The first real tag would
have burned on it.

Now create-or-update, mirroring the engine. The update arm is load-bearing for a
second reason the workflow already documents: on v0.3.1 a bare `gh release
create` turned a single PyPI failure into a permanent retry deadlock -- every
re-run died there and SKIPPED the publish, so a re-run could not test the fix it
existed to verify. Notes come from the CONSOLE's own changelog, not the
engine's; step order (reversible GitHub release before irreversible PyPI upload)
matches the engine deliberately.

Two guards, and the first one did NOT work when written:

- test_a_job_without_needs_release_must_create_its_own_github_release --
  DERIVED, not console-specific: any release job that attaches assets without
  depending on the engine release must create its own.
- test_every_release_creating_job_is_rerunnable -- every `gh release create`
  needs a matching `gh release view` guard, or the v0.3.1 deadlock returns.

Mutation-testing caught that the first guard stayed GREEN when the console was
reverted to a bare upload -- i.e. it did not catch the exact defect it was
written for. Two causes, both now fixed: job chunks were sliced with
`body.index(f"{name}:")`, which matches the earliest substring ANYWHERE
including header prose, so every chunk began at the top of the file; and the
chunk was matched against raw text, where the job's own comment explaining the
v0.3.1 deadlock contains the literal `gh release create`. Chunks are now
line-anchored on `^  <name>:` and both guards read through a shared
_executed_shell() that strips comments -- count executed shell, never narration.
That helper exists because the same comment-counting bug was fixed in one guard
and not the other.

All four mutations now red: bare upload; drop the re-run arm; let the engine
fire on a console tag; couple the cadences with `needs: release`.

Nothing here changes what a tag does until PUBLISH_WEBCONSOLE=true.
PR #44 was SQUASH-merged, so its content reached main as a new sha (aa837f8)
while c27d4c4 stayed unmerged on this branch. git therefore saw the same change
twice and PR #45 came up CONFLICTING.

Resolution: both conflicting files on main are BYTE-IDENTICAL to this branch's
pre-fix commit (verified by comparison, not assumed), so this branch is a clean
superset and taking its side loses nothing from main.

Worth recording because it caught me twice today: `git merge-base --is-ancestor`
reports NOT-MERGED after a squash, because the sha differs. Content comparison
is authoritative; ancestry is not.
@wshallwshall
wshallwshall enabled auto-merge (squash) July 29, 2026 16:36
@wshallwshall
wshallwshall merged commit e3697ae into main Jul 29, 2026
33 checks passed
@wshallwshall
wshallwshall deleted the claude/asvs-partials-pass-scores-2de207 branch July 29, 2026 16:55
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.

1 participant