Skip to content

fix(release): publish the CHANGELOG section and lock the release-time dev closure - #368

Open
pengfei-threemoonslab wants to merge 2 commits into
mainfrom
claude/github-issue-345-679d08
Open

fix(release): publish the CHANGELOG section and lock the release-time dev closure#368
pengfei-threemoonslab wants to merge 2 commits into
mainfrom
claude/github-issue-345-679d08

Conversation

@pengfei-threemoonslab

Copy link
Copy Markdown
Contributor

Summary

Closes #345 — the two non-blocking leftovers from the release-workflow review.

  • The release page now carries the changelog. The GitHub Release body was
    the placeholder Agents Shipgate <tag> while CHANGELOG.md held the entry
    describing what actually shipped, so the one artifact users read said nothing.
    scripts/release_notes.py extracts the section matching the tag and stage
    publishes it through --notes-file, verbatim, from the checkout pinned to the
    verified commit rather than retyped at tag time. A missing section fails
    verification
    , which the rehearsal also runs, so it is caught while the tag
    does not yet exist — and ## Unreleased never matches a tag, which is what
    makes promoting that heading a step the pipeline enforces rather than one an
    operator remembers. A body over GitHub's 125,000-character limit is refused
    there too, rather than by a 422 after tagging; the 0.16 development section is
    already ~75,000 characters. Draft repair rewrites the body as well as the
    assets; an already-published release is still left untouched.
  • CI and the release install the same closure. pip install -e ".[dev]"
    resolved fresh at tag time, so the run that decided whether to publish could
    install different packages than the run that approved the commit — a ruff,
    pytest or plugin release landing in between was enough — and a release-only
    failure was not reproducible from the same tree. Both now install the
    hash-locked constraints/dev.txt with the byte-identical command, add the
    project separately (--no-deps, backend pinned by
    constraints/release-build.txt) because an editable install cannot be hashed,
    and run python -m pip check to prove the closure satisfies what the project
    declares.
  • The lock update path is one command, and staleness is gated.
    scripts/update_locks.py recompiles every lock and restores the prose header
    uv would overwrite — that overwrite is why regenerating had been a per-file
    ritual. scripts/verify_dependency_lock.py runs in CI and before
    publication, failing on a declared requirement with no pin, a pin outside the
    declared range, a direct requirement the declarations no longer contain (uv's
    # via provenance makes that detectable), or a pin without a hash. It never
    re-resolves against the index, so an unrelated upload cannot turn the build
    red.

For the reviewer

v0.16.0b7 fails the new changelog gate today, by designCHANGELOG.md
still says ## Unreleased. Cutting a release now starts by promoting that
heading to ## <version> - <date>; it is step 1 in the runbook and is called
out in CONTRIBUTING.md.

Every control #345 asked to preserve is untouched: SHA-pinned actions, the
protected pypi environment, the dist/ and qualified-dist/ pre-existence
and symlink checks, HTTPS-only downloads, the strict qualified-wheel filename,
and the tag ↔ package-version check. The one new job-written path
(release-notes.md) follows the same pre-existence rule as the directories the
verification job creates, so a committed symlink cannot decide where the write
lands.

Nothing here changes what the token-bearing jobs install. constraints/dev.txt
is the verification closure: it installs the project and runs its tests, so it
is outside the blast radius release-seal.txt and release-publish.txt are
locked against. That distinction is written into the lock's header.

Type

  • CLI or GitHub Action behavior
  • Check or risk-model change
  • Input adapter change
  • Report, schema, or SARIF output
  • Documentation only

(Release pipeline and repository tooling; no product surface, no new check IDs,
no schema or contract version change.)

Verification

CI is authoritative for python -m ruff check .,
python -m compileall -q src tests, and python -m pytest.

Additional local checks run:

  • CI's own selection re-run inside a clean 3.12 venv built from the new
    lock
    pytest -n auto -m "not perf" --cov-fail-under=85: green, 88.09%
    coverage. ruff (0.16.2, from the lock), compileall, generate_schemas.py --check, test_adapter_static_only.py, python -m build + twine check,
    and cyclonedx-py environment all pass in that environment too. This is the
    check that matters for the lock change: the packages CI will use are the ones
    the suite actually ran against.
  • Full suite on the development environment: green.
  • 25 new tests in tests/test_release_pipeline.py, and every new gate was
    mutation-tested rather than assumed: restoring the placeholder --notes,
    deleting the changelog step from verification, and drifting CI's install from
    the release's each fail the corresponding test; the three real lock failure
    modes (range widened past the pin, dependency added, dependency removed) each
    fail verify_dependency_lock.py against this repository with an actionable
    message.
  • Dogfood: agents-shipgate verify --config shipgate-self.yaml returns
    review_required — "edits a release trust root … a human must review it" —
    which is the correct verdict for a .github/workflows/** change and does not
    fail advisory CI.
  • pip_audit . cannot run on this Mac (its temporary venv's ensurepip dies
    with SIGABRT); reproduced on the unmodified environment, so it is pre-existing
    and not caused by this change. CI is the authority for that step.

Only CI can prove one thing I could not: the lock was installed and
exercised on macOS/arm64, and the universal lock's Linux wheels resolve for the
first time on this PR's ubuntu runner.

Release-readiness notes

  • No user-code import added to default scan paths
  • No network access added to default scan paths
  • New or changed check IDs are documented in docs/checks.md (none)
  • Report/schema changes are additive or documented in STABILITY.md (none)

🤖 Generated with Claude Code

Two loose ends from the release-workflow review (#345).

The GitHub Release body was the placeholder `Agents Shipgate <tag>` while
CHANGELOG.md held the entry describing what actually shipped, so the one
artifact users read on the release page said nothing. And
`pip install -e ".[dev]"` resolved fresh at tag time, so the run that
decided whether to publish could install different packages than the CI
run that approved the commit — a ruff, pytest or plugin release landing
in between was enough, and a release-only failure was not reproducible
from the same tree.

Release notes:

- `scripts/release_notes.py` extracts the CHANGELOG section matching the
  tag and `stage` publishes it through `--notes-file`, verbatim, from the
  checkout pinned to the verified commit rather than retyped at tag time.
- Verification requires the section, so the rehearsal fails on a missing
  one while the tag does not yet exist. `## Unreleased` never matches a
  tag, which makes promoting that heading a step the pipeline enforces.
- A body over GitHub's 125,000-character limit is refused there too,
  rather than by a 422 after tagging; the 0.16 development section is
  already ~75,000 characters.
- Draft repair rewrites the body as well as the assets; an already
  published release is still left untouched.

Dependency locks:

- CI and release verification install the identical hash-locked closure
  in `constraints/dev.txt`, add the project separately (`--no-deps`,
  backend pinned by `constraints/release-build.txt`) because an editable
  install cannot be hashed, and run `python -m pip check` to prove the
  closure satisfies what the project declares.
- `scripts/update_locks.py` regenerates every lock in one command and
  restores the headers `uv` would overwrite — the reason regenerating had
  been a per-file ritual.
- `scripts/verify_dependency_lock.py` runs in CI and before publication
  and fails on a declared requirement with no pin, a pin outside the
  declared range, a direct requirement the declarations no longer
  contain, or a pin without a hash. It never re-resolves against the
  index, so an unrelated upload cannot turn the build red.

Verified by installing the closure into a clean 3.12 environment and
running CI's own selection there: 88.09% coverage, suite green.

Refs #345

Co-Authored-By: Claude Opus 5 <[email protected]>

@pengfei-threemoonslab pengfei-threemoonslab left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Engineering review — changes requested

The overall direction is good and CI is green, but two correctness gaps defeat the core lock guarantees: the editable install still resolves its PEP 517 backend outside the reviewed lock, and the stale-lock verifier ignores extras, markers, and direct URLs. The four additional inline comments cover valid universal-lock shapes and release-note integrity/rehearsal edge cases.

I recommend addressing all six comments before merge, then rerunning the release-pipeline tests, the committed-lock verifier, and Agents Shipgate.

Submitted as a comment review because GitHub does not permit a pull request author to formally request changes on their own pull request.

Comment thread .github/workflows/ci.yml Outdated
# byte-reproducible.
run: |
python -m pip install --require-hashes --requirement constraints/dev.txt
PIP_CONSTRAINT=constraints/release-build.txt python -m pip install -e . --no-deps

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Editable install escapes the lock

--no-deps does not disable PEP 517 build isolation. This PR’s lock installs pip 26.2.1, where PIP_CONSTRAINT no longer constrains isolated build environments; the PR CI log confirms fresh Installing build dependencies and Installing backend dependencies, and constraints/dev.txt contains no Hatchling closure. CI and release verification can therefore execute different backend bytes as the index changes, defeating #345’s core invariant.

Please hash-lock and preinstall the complete backend closure, then add --no-build-isolation here and in release verification, with a regression that asserts both.

Comment thread scripts/verify_dependency_lock.py Outdated
Comment on lines +207 to +220
declared: set[str] = set()
for requirement in requirements:
name = canonicalize_name(requirement.name)
declared.add(name)
pin = pins.get(name)
if pin is None:
problems.append(
f"{target.source} declares {requirement} but {target.lock} pins no {name}; "
"the lock is stale. Regenerate it with scripts/update_locks.py."
)
continue
if requirement.specifier and not requirement.specifier.contains(
pin.version, prereleases=True
):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Full requirement semantics are ignored

This loop validates only canonical name plus version specifier. Requirement.extras, .marker, and .url are ignored, and the lock parser discards pin markers. A declaration can change from demo>=1 to demo[feature]>=1, move to a platform marker, or switch to a direct URL without regenerating; verify_lock_target() still returns no problems, and pip check does not activate the project’s dev extra to recover that coverage.

Please bind and compare complete normalized PEP 508 direct declarations (or a declaration digest), including sources and marker branches.

Comment thread scripts/verify_dependency_lock.py Outdated
Comment on lines +136 to +141
name = canonicalize_name(match.group("name"))
if name in pins:
raise ReleaseError(f"{lock_path}:{number} pins {name} a second time.")
current = Pin(version=match.group("version"), line=number)
in_via = False
pins[name] = current

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Universal locks may repeat names

uv pip compile --universal may validly emit the same distribution multiple times with different versions or URLs under disjoint markers, but this dictionary rejects every second canonical name. A future dependency fork across Python or platform versions will make update_locks.py generate a valid pip lock that this verifier refuses.

Please model marker-qualified pin lists instead of one pin per name.

if [ "$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" = "true" ]; then
# Repair replaces the notes too. A draft left by an earlier attempt
# can carry a body from a superseded changelog, and finalisation
# re-derives every asset but never looks at the release text.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate notes before undrafting

The remote assets are content-bound before publication, but the release body is not. Between stage and finalize—including the protected-environment approval window—a release-write actor or integration can edit the draft notes; the final gh release edit --draft=false then publishes text that is not the reviewed changelog section.

Please carry a verified release-notes.md digest or copy through the handoff and compare or atomically reapply it when undrafting.

Comment thread scripts/release_notes.py Outdated
marker = fenced.group("marker")
if fence is None:
fence = marker
elif marker[0] == fence[0] and len(marker) >= len(fence) and not fenced.group("info"):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Accept whitespace after closing fences

CommonMark permits spaces or tabs after a closing fence, but not fenced.group("info") only accepts an exactly empty suffix. A valid closer such as ````` `` leaves fence open and raises `ReleaseError: unterminated code fence`, blocking rehearsal and release.

Please treat a suffix containing only spaces or tabs as empty and add a regression fixture.

Comment thread .github/workflows/release-verify.yml Outdated
# before the build, the downloads, and the qualification checks.
env:
RELEASE_TAG: ${{ steps.candidate.outputs.release_tag }}
run: python scripts/release_notes.py --tag "${RELEASE_TAG}"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Rehearsal misses the output-path guard

This shared verification step only validates extraction. Stage later refuses any pre-existing file, directory, or broken symlink named release-notes.md, so a candidate containing that path can pass mandatory pre-tag rehearsal and fail only after the tag is pushed.

Please exercise the same precondition here or write the notes to a trusted $RUNNER_TEMP path.

Addresses the six review comments on #368.

P1 — the editable install escaped the lock. `--no-deps` does not disable
PEP 517 build isolation, and current pip does not apply `PIP_CONSTRAINT`
to an isolated build environment: constraining hatchling to a version
that does not exist still built successfully, and the CI log showed
`Installing build dependencies` on every run. The backend and its own
dependencies were therefore resolved from the index each time, which is
the drift #345 exists to remove. New `constraints/build-backend.txt`
is the backend's hashed closure, compiled from the existing
hand-maintained `constraints/release-build.txt` so the version still
lives in one reviewed place, and both pipelines now install it and use
`--no-build-isolation`. Verified: zero build-environment resolutions,
backend 1.31.0, suite green in a clean environment built that way.

The same inert `PIP_CONSTRAINT` claim appeared in the sealing job's
comment and in the documented qualification-promotion command, so both
are corrected to the install-then-`--no-isolation` form that works.

P1 — the lock gate ignored most of PEP 508. It compared canonical name
plus version specifier, so a declaration could grow an extra, move
behind a marker, or become a direct URL with no problem reported. Each
lock now records the normalized declarations it was compiled from, in a
generated block `update_locks.py` writes and `verify_dependency_lock.py`
compares; all four failure shapes are covered by tests, and two of them
are reproduced against the real pyproject.toml.

P2 — universal locks may pin one name several times under disjoint
markers. Pins are modelled as marker-qualified lists, every branch must
satisfy the declaration, and only a same-marker repeat is a duplicate.

P2 — the release body was not bound. Between staging and finalisation,
the environment approval window included, a release-write actor can edit
a draft's text; asset digests are re-derived before undrafting but the
body was not. Verification now publishes the notes digest, staging and
finalisation pass it back with `--expected-sha256`, and finalisation
re-derives the notes from `CHANGELOG.md` at the verified commit and
reapplies the body in the same API call that undrafts.

P2 — a closing code fence may carry trailing whitespace per CommonMark;
requiring an exactly empty suffix raised "unterminated code fence" on a
valid changelog and would have blocked rehearsal and release.

P2 — the output-path guard existed only in staging, so a candidate
committing `release-notes.md` could pass the mandatory rehearsal and
fail only after the tag was pushed. All three jobs now write to
`$RUNNER_TEMP`, which removes the class rather than duplicating the
guard.

Also adds a cross-lock invariant: locks installed into one environment
must agree on every shared distribution, or the second install moves
part of the first one's closure.

Refs #345

Co-Authored-By: Claude Opus 5 <[email protected]>
@pengfei-threemoonslab

Copy link
Copy Markdown
Contributor Author

All six addressed in 171c694e. Both P1s were real and both are reproduced below rather than taken on trust.

P1 — Editable install escapes the lock

Confirmed, including the mechanism. Constraining hatchling to a version that does not exist still built successfully under this PR's pip 26.2.1:

$ PIP_CONSTRAINT=<hatchling==0.0.1> pip install -e . --no-deps
  Installing build dependencies: started
Successfully installed agents-shipgate-0.16.0b7      # exit 0

So PIP_CONSTRAINT was inert and the backend closure was resolving from the index on every run, exactly as the CI log shows.

New constraints/build-backend.txt is the backend's hashed closure, compiled from the existing hand-maintained constraints/release-build.txt — so the version still lives in one reviewed place and the reproducibility argument recorded there governs the closure. Both pipelines install it and pass --no-build-isolation. Regression asserts both flags, the backend lock, ordering, and the absence of the inert PIP_CONSTRAINT.

Verified in a clean 3.12 environment built with the new sequence: zero Installing build dependencies / Installing backend dependencies lines, backend resolves to 1.31.0, pip check clean, CI's own selection green at 88.09%.

The same inert claim appeared twice more, so I corrected both rather than fix one instance of the bug class: the sealing job's comment ("PIP_CONSTRAINT pins the build backend" — there --no-isolation plus release-seal.txt is what actually pins it), and the documented qualification-promotion command in release-build.txt and the runbook, which told a future operator to rely on PIP_CONSTRAINT with isolation and would silently have built with a different backend — the precise failure the byte-equality gate blames on the candidate.

P1 — Full requirement semantics are ignored

Correct. Each lock now carries a generated block recording the normalized PEP 508 declarations it was compiled from:

#   declares: pytest<10,>=9.1.1

update_locks.py writes it, verify_dependency_lock.py compares it. I chose a readable block over a digest so the diff shows what changed, not just that it did. Names are canonicalized and specifier sets sorted, so ruamel.yaml>=0.19.1,<1 and ruamel-yaml<1,>=0.19.1 are one declaration.

Reproduced against the real pyproject.toml — both of these previously reported nothing:

pip-audit>=2.10.1,<3  ->  pip-audit[extra]>=2.10.1,<3
  constraints/dev.txt was not compiled from the current declarations
  (pyproject.toml now declares pip-audit[extra]<3,>=2.10.1; ...)

twine>=7.0.0,<8  ->  twine>=7.0.0,<8 ; sys_platform == 'linux'
  constraints/dev.txt was not compiled from the current declarations (...)

A lock with no block at all is rejected rather than assumed fine.

P2 — Universal locks may repeat names

Fixed. Pins are marker-qualified lists per name; every branch must satisfy the declaration (checking only the first would fail open when one fork is out of range), and only a repeat under the same marker is a duplicate. Tests cover the valid fork, the out-of-range branch, and the genuine duplicate.

P2 — Validate notes before undrafting

Fixed by atomic reapply. Verification publishes release_notes_sha256; stage and finalize both pass it back via --expected-sha256 (rejecting empty/malformed, same fail-open rule verify-manifest documents). finalize re-derives the notes from CHANGELOG.md fetched at the verified commit — like the other stdlib-only scripts it runs — and sets body and draft flag in one gh release edit. Compare-then-fix would leave a window of its own, and reapplying after undrafting would publish the tampered text first.

P2 — Accept whitespace after closing fences

Fixed; parametrized over ```, ``` , ```\t, ````, asserting both that the section ends correctly and that the following section is still reachable — i.e. the fence really closed.

P2 — Rehearsal misses the output-path guard

Took the $RUNNER_TEMP option, which removes the class instead of duplicating the guard: all three jobs write there, no candidate-controlled path is involved, and the rehearsal now exercises the identical write. The pre-existence check is gone with it.

Also

Locks installed into one environment must agree on every shared distribution, or the second pip install moves part of the first one's closure. dev.txt and build-backend.txt do share distributions (packaging, pluggy), so the guard is not vacuous.

Verification

Every fix was mutation-tested — dropping --no-build-isolation, the digest binding, or the body from the undraft call each fails its test. Full suite green; clean-environment run of CI's selection green at 88.09% coverage; ruff, compileall, schema check, static-only lint, build + twine check all pass there. Dogfood still returns review_required (trust-root edit).

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.

release cleanup: use CHANGELOG notes and lock release dependencies

1 participant