diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0414f60a1..39e4e97b1 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -97,6 +97,55 @@ jobs: # ref: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} fetch-depth: 0 + # ``CHANGELOG.md`` is generated by ``util/changelog_md.py`` from the newest + # entry in the ``docs/source/changelog.rst`` toctree, so it describes the + # version being *prepared* -- which is not always the version being + # *released*. The two agree on a normal release and disagree on every + # automated one, because ``util/bump_version.py`` bumps without ever adding + # an entry: ``1.5.0b3`` becomes ``1.5.0b4``, and a final release becomes + # ``.post1``. A ``.postN`` is the weekly vendor registry refresh, carries no + # library change, and is deliberately recorded as an "Also released" line + # inside its base version's entry rather than as an entry of its own. + # + # Handing the file over unconditionally would therefore republish the base + # version's notes as the body of a release that changed no library code. So + # the body is used only when the generated file's own heading names the + # version being released, and otherwise the release keeps the generated notes + # alone -- which for a vendor refresh is the honest summary, since the commit + # list is the only thing that did change. Both outcomes are announced in the + # log rather than left to be inferred from the published result. + # + # Only the version token is compared, not the rest of the heading, so an + # entry still reading "-- unreleased" is matched and dated-or-not does not + # change the decision. That case gets its own warning below instead. + - name: Select release body + id: release_body + shell: bash + env: + PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} + run: | + set -euo pipefail + + if [ ! -f CHANGELOG.md ]; then + echo "::warning title=Release body generated::CHANGELOG.md is absent, so this release carries the automatically generated notes alone. The file is generated by util/changelog_md.py and the 'Changelog drift' job gates it on the release path, so reaching this branch means that gate did not run." + exit 0 + fi + + # The generated file opens with ``## -- ``. + heading="$(sed -n '1s/^## \([^[:space:]]*\).*/\1/p' CHANGELOG.md)" + + if [ "$heading" != "$PCAPKIT_VERSION" ]; then + echo "::notice title=Release body generated::CHANGELOG.md holds the entry for '${heading:-}', not ${PCAPKIT_VERSION}, so this release carries the automatically generated notes alone. Expected for a pre-release and for a .postN vendor refresh, neither of which has a changelog entry of its own." + exit 0 + fi + + echo 'path=CHANGELOG.md' >> "$GITHUB_OUTPUT" + echo "::notice title=Release body from CHANGELOG.md::The changelog entry for ${PCAPKIT_VERSION} leads the release body; the generated pull-request list follows it." + + if sed -n '1p' CHANGELOG.md | grep -qi 'unreleased'; then + echo "::warning title=Changelog entry is undated::The entry for ${PCAPKIT_VERSION} still reads 'unreleased', so the release body says so too. Date the heading in docs/source/changelog/${PCAPKIT_VERSION}.rst, regenerate CHANGELOG.md and the body can be corrected in place." + fi + - name: Create Release id: create_release uses: softprops/action-gh-release@v3.0.2 @@ -106,6 +155,12 @@ jobs: # allowUpdates: true # artifacts: | # dist/* + # An empty ``body_path`` is skipped by the action's own truthy guard, so + # an unset output here means "no body" rather than "read the file ''". + # ``generate_release_notes`` stays on either way: the action fetches the + # generated notes itself and joins them as ``\n\n``, so + # the hand-written entry leads and the pull-request list follows. + body_path: ${{ steps.release_body.outputs.path }} generate_release_notes: true # makeLatest: true name: PyPCAPKit v${{ needs.version_check.outputs.PCAPKIT_VERSION }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 124278bd0..fac6e5f99 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -155,6 +155,47 @@ jobs: - name: Run full test suite run: python -m pytest -q + # ``CHANGELOG.md`` is generated from the newest entry under + # ``docs/source/changelog/`` by ``util/changelog_md.py``, so it falls out of step + # the moment an entry is edited without regenerating it. That is worth its own + # job rather than leaving it to review, because ``Create Release`` feeds the file + # to the GitHub Release body: a drifted copy is not merely wrong in the tree, it + # is published. + # + # Deliberately *not* gated on ``gate-only``, unlike the three jobs above, and + # that is the point of putting it here at all. ``create-release.yml`` calls this + # workflow as its release gate, so an ungated job runs on the release path and + # the release body cannot be built from a file that has drifted. The matrix is + # skipped per caller because it is expensive and already ran for the commit; + # this is neither expensive nor covered elsewhere on that path. + # + # ``tests/project/test_changelog_md.py`` asserts the same identity, so the + # matrix covers it too -- but that test ``skipTest``s when either file is + # absent, and reports a mismatch as two 17 KB strings. ``--check`` cannot skip, + # exits 1 when the file is missing, and prints a unified diff. + changelog: + name: Changelog drift + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + + # No install step: the generator imports argparse, difflib, pathlib, re, sys + # and typing and nothing else, so it runs against a bare interpreter. + # Verified with ``python -S``. + - name: Check CHANGELOG.md is in step with its source entry + shell: bash + run: | + if ! python util/changelog_md.py --check; then + echo "::error title=CHANGELOG.md has drifted::CHANGELOG.md is generated from the newest entry under docs/source/changelog/ and no longer matches it. Regenerate it with 'python util/changelog_md.py' and commit the result. The unified diff above shows what moved." + exit 1 + fi + # The verdict a shipping workflow asks for: the full suite, fixtures and # all, on one interpreter. It is deliberately not the matrix -- the matrix # has already run against this very commit, from this workflow's own push or