From dc4dec253839e1e43e3df852119e9527dd3e33ff Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sun, 19 Jul 2026 16:42:47 +0200 Subject: [PATCH 1/4] fix(release): fold refreshed lockfile into the version-bump commit (closes #14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cz bump commits and tags atomically, but uv/pixi lockfiles can only be regenerated after the new version is written to pyproject.toml — which means the tagged commit has always shipped with a stale lock. Consumers work around it with a follow-up commit that syncs the lock past the tag, so `git checkout && uv sync --locked` mismatches (especially bad in uv workspaces with many == cross-pins). Insert a new step between the bump and the push that, while everything is still local: 1. cd to the repo root (workspaces keep the lock at the top). 2. Verify HEAD is the tagged commit — if the recovery path ran and didn't tag, skip with a warning. 3. Refresh the lock via `uv lock` / `pixi lock`. 4. If the lock changed, git add + git commit --amend --no-edit --no-verify, then git tag -f onto the amended commit. 5. Sanity-check the tag now points at HEAD. The push step then publishes the amended commit and the correctly re-pointed tag; no force-push against the remote because none of it was published yet. pip is skipped (no native lockfile). Add a matrix test job (uv + pixi) that snapshots the pre-release lockfile, runs the action, and asserts: - the new tag exists, - HEAD equals the tagged commit (no follow-up past the tag), - the tagged lockfile differs from the pre-release snapshot, - the tagged lockfile records the new version. --- .github/workflows/test-release-github.yml | 139 ++++++++++++++++++++++ actions/release/github/action.yml | 70 +++++++++++ 2 files changed, 209 insertions(+) diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index b1b1cd4..5dae4f9 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -1471,3 +1471,142 @@ jobs: exit 1 fi echo "[OK] update_changelog_on_bump restored to true" + + # Regression test for issue #14: the version-bump tag must contain the + # refreshed lockfile so `git checkout && sync --locked` succeeds. + # Before the fold-in-tag fix the lockfile lived in a follow-up commit past + # the tag, leaving the tagged tree stale. + test-release-lockfile-in-tag: + name: Test release - lockfile folded into tag (${{ matrix.package-manager }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package-manager: uv + lock-file: uv.lock + install-groups: 'groups: dev docs' + - package-manager: pixi + lock-file: pixi.lock + install-groups: 'default' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Copy uv test fixture + if: ${{ matrix.package-manager == 'uv' }} + run: | + cp tests/data/release-github/test-basic-uv/pyproject.toml . + cp tests/data/release-github/test-basic-uv/uv.lock . + + - name: Copy pixi test fixture + if: ${{ matrix.package-manager == 'pixi' }} + run: | + cp tests/data/release-github/test-basic-pixi/pyproject.toml . + cp tests/data/release-github/test-basic-pixi/pixi.toml . + cp tests/data/release-github/test-basic-pixi/pixi.lock . + + - name: Clean up repository tags + run: git tag -l | xargs -r git tag -d + + - name: Setup fake git remote for testing + uses: ./.github/workflows/test-helpers/setup-fake-remote + + - name: Snapshot the pre-release lockfile + run: | + # Capture the fixture's original lockfile so we can prove the tagged + # lockfile is different from what was committed at release-init time. + cp "${{ matrix.lock-file }}" /tmp/prerelease.lock + + - name: Create initial commits with a prior tag + run: | + git config user.name "Test Bot" + git config user.email "test@example.com" + + cat > CHANGELOG.md << 'EOF' + # Changelog + + All notable changes to this project will be documented in this file. + EOF + + if [ "${{ matrix.package-manager }}" == "uv" ]; then + git add pyproject.toml uv.lock CHANGELOG.md + else + git add pyproject.toml pixi.toml pixi.lock CHANGELOG.md + fi + git commit -m "chore: initial commit" + git tag 0.1.0 + git push origin main + git push origin 0.1.0 + + echo "feature" > feature.py + git add feature.py + git commit -m "feat: trigger a minor bump" + git push origin main + + - name: Run release action + uses: ./actions/release/github + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + increment: 'minor' + prerelease-type: 'none' + package-manager: ${{ matrix.package-manager }} + python-version: '3.12' + install-groups: ${{ matrix.install-groups }} + skip-github-release: 'true' + + - name: Verify the new tag exists and HEAD is on it + run: | + if ! git rev-parse 0.2.0 >/dev/null 2>&1; then + echo "::error::tag 0.2.0 was not created" + git tag -l + exit 1 + fi + HEAD_SHA=$(git rev-parse HEAD) + TAG_SHA=$(git rev-parse "0.2.0^{commit}") + if [ "$HEAD_SHA" != "$TAG_SHA" ]; then + echo "::error::HEAD ($HEAD_SHA) is not the tagged commit ($TAG_SHA)" + echo "This means the lockfile amend/retag did not run or ran wrong." + git log --oneline -n 5 + exit 1 + fi + echo "[OK] tag 0.2.0 exists and HEAD is the tagged commit" + + - name: Verify the tagged lockfile differs from the pre-release snapshot + run: | + # Prove the tag contains the FOLDED (refreshed) lockfile, not the + # pre-release lockfile that was committed at test setup. + git show "0.2.0:${{ matrix.lock-file }}" > /tmp/tagged.lock + if diff -q /tmp/prerelease.lock /tmp/tagged.lock >/dev/null; then + echo "::error::Tagged ${{ matrix.lock-file }} is identical to the pre-release lockfile" + echo "The action did not fold the refreshed lockfile into the tagged commit." + exit 1 + fi + echo "[OK] tagged ${{ matrix.lock-file }} was refreshed (differs from pre-release lockfile)" + + - name: Verify the tagged lockfile records the new version + run: | + # The freshly-generated lockfile should mention the new version 0.2.0 + # in at least one line (the project's own version pin). + if ! git show "0.2.0:${{ matrix.lock-file }}" | grep -q '0\.2\.0'; then + echo "::error::Tagged ${{ matrix.lock-file }} does not reference the new version 0.2.0" + echo "First 40 lines of tagged lockfile:" + git show "0.2.0:${{ matrix.lock-file }}" | head -40 + exit 1 + fi + echo "[OK] tagged ${{ matrix.lock-file }} records version 0.2.0" + + - name: Verify no follow-up commit past the tag + run: | + # If a follow-up lockfile commit existed past the tag (the pre-fix + # behavior), branch tip would be ahead of the tag. + BRANCH_TIP=$(git rev-parse HEAD) + TAG_COMMIT=$(git rev-parse "0.2.0^{commit}") + if [ "$BRANCH_TIP" != "$TAG_COMMIT" ]; then + echo "::error::Branch tip has moved past the tag (found a follow-up commit)" + git log --oneline "$TAG_COMMIT..$BRANCH_TIP" + exit 1 + fi + echo "[OK] no follow-up commit past tag; lockfile lives inside the tagged commit" diff --git a/actions/release/github/action.yml b/actions/release/github/action.yml index f5e7d0a..fb32182 100644 --- a/actions/release/github/action.yml +++ b/actions/release/github/action.yml @@ -423,6 +423,76 @@ runs: echo "==========================" echo "::endgroup::" + - name: Fold refreshed lockfile into the version-bump commit + if: ${{ inputs.package-manager == 'uv' || inputs.package-manager == 'pixi' }} + shell: bash + run: | + echo "::group::Folding refreshed lockfile into the version-bump commit" + + # Workspaces keep the lockfile at the repo root even when the release + # action's WORK_DIR is a sub-package. Always operate at the repo root. + REPO_ROOT=$(git rev-parse --show-toplevel) + cd "$REPO_ROOT" + + NEW_VERSION="${{ steps.bump_version.outputs.NEW_VERSION }}" + + # Safety gate: only fold when HEAD is the commit the new tag points to. + # If the bump step's recovery path ran (cz bump --files-only, which does + # not commit or tag) or anything else left HEAD off the bump commit, + # amending here would rewrite the wrong commit. + HEAD_SHA=$(git rev-parse HEAD) + TAG_SHA=$(git rev-parse "$NEW_VERSION^{commit}" 2>/dev/null || true) + if [ -z "$TAG_SHA" ]; then + echo "::warning::No commit resolves for tag $NEW_VERSION; skipping lockfile fold" + echo "::endgroup::" + exit 0 + fi + if [ "$HEAD_SHA" != "$TAG_SHA" ]; then + echo "::warning::HEAD ($HEAD_SHA) is not the tagged commit ($TAG_SHA); skipping lockfile fold" + echo "::endgroup::" + exit 0 + fi + + # Pick the lockfile + command for the active package manager. + case "${{ inputs.package-manager }}" in + uv) LOCK_FILE="uv.lock" ; LOCK_CMD="uv lock" ;; + pixi) LOCK_FILE="pixi.lock" ; LOCK_CMD="pixi lock" ;; + esac + + if [ ! -f "$LOCK_FILE" ]; then + echo "No $LOCK_FILE at repo root; nothing to fold." + echo "::endgroup::" + exit 0 + fi + + echo "Refreshing $LOCK_FILE via '$LOCK_CMD'..." + if ! $LOCK_CMD; then + echo "::error::'$LOCK_CMD' failed; refusing to push a release with a stale $LOCK_FILE" + exit 1 + fi + + if git diff --quiet -- "$LOCK_FILE"; then + echo "$LOCK_FILE unchanged after bump; nothing to fold." + echo "::endgroup::" + exit 0 + fi + + echo "$LOCK_FILE changed; folding into the bump commit and re-pointing the tag." + git add -- "$LOCK_FILE" + git commit --amend --no-edit --no-verify + git tag -f "$NEW_VERSION" + + NEW_HEAD_SHA=$(git rev-parse HEAD) + NEW_TAG_SHA=$(git rev-parse "$NEW_VERSION^{commit}") + if [ "$NEW_HEAD_SHA" != "$NEW_TAG_SHA" ]; then + echo "::error::Post-amend sanity check failed: tag $NEW_VERSION does not point at HEAD" + exit 1 + fi + + echo "✓ $LOCK_FILE folded into the release commit" + echo "✓ Tag $NEW_VERSION re-pointed to $NEW_HEAD_SHA" + echo "::endgroup::" + - name: Push changes and tags shell: bash run: | From 20b1ac500d9892c5f2b3c78d07d6d94618b38a61 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sun, 19 Jul 2026 17:01:20 +0200 Subject: [PATCH 2/4] test(release): strengthen lockfile-in-tag coverage and add pip regression guard Add the issue #14 acceptance check to the uv/pixi happy-path job: after the release, re-run the lock against the tagged pyproject.toml and assert no diff (the package-manager-agnostic equivalent of `git checkout && sync --locked`), proving the committed lock is consistent, not merely refreshed. Add test-release-pip-no-lockfile-fold: a pip release must leave the tag on the bump commit with no amend and produce no lockfile, guarding that the fold step stays skipped for package-manager: pip. --- .github/workflows/test-release-github.yml | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index 5dae4f9..e38395f 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -1610,3 +1610,103 @@ jobs: exit 1 fi echo "[OK] no follow-up commit past tag; lockfile lives inside the tagged commit" + + - name: Verify the tagged lockfile is consistent (issue #14 acceptance) + run: | + # The DoD for issue #14: `git checkout && sync --locked` + # must succeed. HEAD is already the tagged commit, so a package-manager + # -agnostic equivalent is: re-locking against the tagged pyproject.toml + # must produce NO diff. If it does, the committed lock is stale. + case "${{ matrix.package-manager }}" in + uv) uv lock ;; + pixi) pixi lock ;; + esac + if ! git diff --quiet -- "${{ matrix.lock-file }}"; then + echo "::error::Re-locking changed ${{ matrix.lock-file }}; the tagged lock is stale/incomplete" + git --no-pager diff -- "${{ matrix.lock-file }}" + exit 1 + fi + echo "[OK] tagged ${{ matrix.lock-file }} satisfies --locked (re-lock is a no-op)" + + # Regression guard for the issue #14 fix: pip has no native lockfile, so the + # fold step is guarded off for package-manager: pip. A pip release must still + # land the tag on the bump commit with NO amend and produce NO lockfile. + test-release-pip-no-lockfile-fold: + name: Test release - pip unaffected by lockfile fold + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Copy pip test fixture + run: | + cp tests/data/release-github/test-basic-pip/pyproject.toml . + cp -r tests/data/release-github/test-basic-pip/src . + + - name: Clean up repository tags + run: git tag -l | xargs -r git tag -d + + - name: Setup fake git remote for testing + uses: ./.github/workflows/test-helpers/setup-fake-remote + + - name: Create initial commits with a prior tag + run: | + git config user.name "Test Bot" + git config user.email "test@example.com" + + cat > CHANGELOG.md << 'EOF' + # Changelog + + All notable changes to this project will be documented in this file. + EOF + + git add pyproject.toml src CHANGELOG.md + git commit -m "chore: initial commit" + git tag 0.1.0 + git push origin main + git push origin 0.1.0 + + echo "feature" > feature.py + git add feature.py + git commit -m "feat: trigger a minor bump" + git push origin main + + - name: Run release action (pip) + uses: ./actions/release/github + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + increment: 'minor' + prerelease-type: 'none' + package-manager: 'pip' + python-version: '3.12' + install-groups: 'groups: dev docs' + skip-github-release: 'true' + + - name: Verify tag created on the bump commit with no amend + run: | + if ! git rev-parse 0.2.0 >/dev/null 2>&1; then + echo "::error::tag 0.2.0 was not created" + git tag -l + exit 1 + fi + HEAD_SHA=$(git rev-parse HEAD) + TAG_SHA=$(git rev-parse "0.2.0^{commit}") + if [ "$HEAD_SHA" != "$TAG_SHA" ]; then + echo "::error::HEAD ($HEAD_SHA) is not the tagged commit ($TAG_SHA) for a pip release" + echo "The fold step must be skipped for pip; something amended the commit." + git log --oneline -n 5 + exit 1 + fi + echo "[OK] pip: tag 0.2.0 sits on the bump commit (no amend)" + + - name: Verify no lockfile was produced + run: | + for f in uv.lock pixi.lock; do + if [ -f "$f" ]; then + echo "::error::pip release unexpectedly produced $f" + exit 1 + fi + done + echo "[OK] pip: no lockfile created; fold step correctly skipped" From aeff92f726521c9016659c8b381da121d0f55a6b Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sun, 19 Jul 2026 17:26:11 +0200 Subject: [PATCH 3/4] test(release): make the release test suite dispatch-testable Every job hard-codes `git push origin main` and the release action itself does a bare `git push`, both of which require a local `main` branch. That holds under the push-to-main trigger (checkout lands on main) but not under workflow_dispatch on a feature branch, where the suite died at setup with "src refspec main does not match any" before any assertion ran. Add a Normalize step after each fake-remote setup that runs `git checkout -B main` and `git config push.default current`, so a local main exists at the current commit and bare pushes resolve regardless of the dispatched ref. This lets the suite (and the new lockfile-fold jobs) be validated from the Run workflow button on any branch, not only post-merge. --- .github/workflows/test-release-github.yml | 234 ++++++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index e38395f..2195e25 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -43,6 +43,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commit for version bumping run: | git config user.name "Test Bot" @@ -125,6 +138,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commit for version bumping run: | git config user.name "Test Bot" @@ -192,6 +218,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commit run: | git config user.name "Test Bot" @@ -294,6 +333,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commit run: | git config user.name "Test Bot" @@ -360,6 +412,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commit run: | git config user.name "Test Bot" @@ -420,6 +485,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create and switch to release branch run: | git config user.name "Test Bot" @@ -489,6 +567,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commit shell: bash run: | @@ -549,6 +640,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commit run: | git config user.name "Test Bot" @@ -609,6 +713,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commit (git already configured by fake remote helper) run: | # Create initial CHANGELOG.md file (required by release action) @@ -674,6 +791,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create test commits run: | git config user.name "Test Bot" @@ -799,6 +929,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create multiple test commits for changelog run: | git config user.name "Test Bot" @@ -895,6 +1038,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Set up Python environment uses: serapeum-org/github-actions/actions/python-setup/uv@uv/v1 with: @@ -1056,6 +1212,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Set up Python environment uses: serapeum-org/github-actions/actions/python-setup/uv@uv/v1 with: @@ -1199,6 +1368,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Set up Python environment uses: serapeum-org/github-actions/actions/python-setup/uv@uv/v1 with: @@ -1282,6 +1464,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Set up Python environment uses: serapeum-org/github-actions/actions/python-setup/uv@uv/v1 with: @@ -1380,6 +1575,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create initial commits with NO tag run: | git config user.name "Test Bot" @@ -1514,6 +1722,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Snapshot the pre-release lockfile run: | # Capture the fixture's original lockfile so we can prove the tagged @@ -1651,6 +1872,19 @@ jobs: - name: Setup fake git remote for testing uses: ./.github/workflows/test-helpers/setup-fake-remote + - name: Normalize local branch to main (dispatch-testability) + shell: bash + run: | + # The suite hard-codes `git push origin main`, and the release action + # itself does a bare `git push`. Both need a local `main`. Under the + # push-to-main trigger that branch exists; under workflow_dispatch on + # a feature branch it does not, so every job would die at setup with + # "src refspec main does not match any". Force a local `main` at the + # current commit and set push.default=current so bare pushes resolve, + # making the suite runnable from any dispatched ref. + git checkout -B main + git config push.default current + - name: Create initial commits with a prior tag run: | git config user.name "Test Bot" From a332612730dc5464343b298a906dbbb56968eb7e Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Sun, 19 Jul 2026 17:33:48 +0200 Subject: [PATCH 4/4] test(release): scope lockfile-change proofs to uv; keep pixi consistency checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A version-only bump changes uv.lock (it records the workspace member version) but not this pixi fixture's pixi.lock, because the fixture does not install the project as a pypi/editable dependency of its own environment — so pixi.lock never records the project version and the fold step correctly no-ops. Gate the "lock differs from pre-release" and "lock records new version" proofs to uv, where folding must happen. For pixi keep the invariants that hold either way: tag sits on the bump commit, no follow-up commit past the tag, and the tagged lock satisfies a re-lock (issue #14 acceptance). Document the uv/pixi difference in the job comment. --- .github/workflows/test-release-github.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index 2195e25..a1492ae 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -1684,6 +1684,17 @@ jobs: # refreshed lockfile so `git checkout && sync --locked` succeeds. # Before the fold-in-tag fix the lockfile lived in a follow-up commit past # the tag, leaving the tagged tree stale. + # + # uv and pixi differ in what a version bump touches: + # - uv.lock always records the workspace member's own version, so a bump + # changes the lock and the fold step amends+retags. Full proof runs. + # - pixi.lock only records the project version when the project is a + # pypi/editable dependency of its own environment. This minimal fixture + # is not self-installed, so a version-only bump does NOT change pixi.lock + # and the fold step correctly no-ops. For pixi we therefore assert the + # invariants that must hold either way (tag on the bump commit, tagged + # lock consistent, no follow-up commit) and skip the "lock changed" + # proofs, which are uv-specific. test-release-lockfile-in-tag: name: Test release - lockfile folded into tag (${{ matrix.package-manager }}) runs-on: ubuntu-latest @@ -1789,16 +1800,18 @@ jobs: TAG_SHA=$(git rev-parse "0.2.0^{commit}") if [ "$HEAD_SHA" != "$TAG_SHA" ]; then echo "::error::HEAD ($HEAD_SHA) is not the tagged commit ($TAG_SHA)" - echo "This means the lockfile amend/retag did not run or ran wrong." + echo "The tag must sit on the (possibly amended) bump commit." git log --oneline -n 5 exit 1 fi echo "[OK] tag 0.2.0 exists and HEAD is the tagged commit" - - name: Verify the tagged lockfile differs from the pre-release snapshot + - name: Verify the tagged lockfile differs from the pre-release snapshot (uv) + if: ${{ matrix.package-manager == 'uv' }} run: | # Prove the tag contains the FOLDED (refreshed) lockfile, not the - # pre-release lockfile that was committed at test setup. + # pre-release lockfile that was committed at test setup. uv.lock always + # records the workspace member version, so a bump must change it. git show "0.2.0:${{ matrix.lock-file }}" > /tmp/tagged.lock if diff -q /tmp/prerelease.lock /tmp/tagged.lock >/dev/null; then echo "::error::Tagged ${{ matrix.lock-file }} is identical to the pre-release lockfile" @@ -1807,7 +1820,8 @@ jobs: fi echo "[OK] tagged ${{ matrix.lock-file }} was refreshed (differs from pre-release lockfile)" - - name: Verify the tagged lockfile records the new version + - name: Verify the tagged lockfile records the new version (uv) + if: ${{ matrix.package-manager == 'uv' }} run: | # The freshly-generated lockfile should mention the new version 0.2.0 # in at least one line (the project's own version pin).