diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2fe7d7b..64675b08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,20 +21,35 @@ jobs: contents: write id-token: write steps: - - name: Checkout release branch + - name: Checkout workflow SHA uses: actions/checkout@v4 with: - ref: ${{ github.ref_name }} + ref: ${{ github.sha }} + fetch-depth: 0 - - name: Pin checkout to workflow SHA - run: git reset --hard ${{ github.sha }} + - name: Restore release branch at workflow SHA + run: git checkout -B "$GITHUB_REF_NAME" "$GITHUB_SHA" + + - name: Check workflow SHA is still current + id: release_head + run: | + set -o pipefail + remote_sha="$(git ls-remote --exit-code origin "refs/heads/${GITHUB_REF_NAME}" | cut -f1)" + if [[ "$remote_sha" == "$GITHUB_SHA" ]]; then + echo "current=true" >> "$GITHUB_OUTPUT" + else + echo "current=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping stale release run for ${GITHUB_SHA}; ${GITHUB_REF_NAME} is now at ${remote_sha}." + fi - name: Set up Python + if: steps.release_head.outputs.current == 'true' uses: actions/setup-python@v5 with: python-version: '3.12' cache: pip - name: Set up Node + if: steps.release_head.outputs.current == 'true' uses: actions/setup-node@v4 with: node-version: '22' @@ -42,22 +57,41 @@ jobs: cache-dependency-path: dashboard/package-lock.json - name: Install package with test dependencies + if: steps.release_head.outputs.current == 'true' run: python -m pip install -e '.[test]' - name: Install dashboard dependencies + if: steps.release_head.outputs.current == 'true' run: npm ci working-directory: dashboard - name: Run dashboard tests + if: steps.release_head.outputs.current == 'true' run: npm test working-directory: dashboard - name: Build dashboard + if: steps.release_head.outputs.current == 'true' run: npm run build working-directory: dashboard - name: Run tests before release + if: steps.release_head.outputs.current == 'true' run: pytest -q + - name: Recheck workflow SHA before release + id: pre_release_head + if: steps.release_head.outputs.current == 'true' + run: | + set -o pipefail + remote_sha="$(git ls-remote --exit-code origin "refs/heads/${GITHUB_REF_NAME}" | cut -f1)" + if [[ "$remote_sha" == "$GITHUB_SHA" ]]; then + echo "current=true" >> "$GITHUB_OUTPUT" + else + echo "current=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping release because ${GITHUB_REF_NAME} advanced to ${remote_sha} during validation." + fi + - name: Semantic version release id: release + if: steps.pre_release_head.outputs.current == 'true' uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/releases.md b/docs/releases.md index 72c4cb4f..ee9f40e2 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -29,6 +29,10 @@ When commits land on `main`, the `release` workflow: 8. creates a GitHub Release; 9. builds `sdist`/wheel artifacts and uploads them to the GitHub Release. +Release runs are serialized. If another commit reaches `main` before an older +run starts publishing, the stale run exits successfully and leaves the release +to the run for the current branch head. + No PyPI publish is configured yet. ## Commit convention diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 00000000..b47297f9 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,53 @@ +from pathlib import Path + + +WORKFLOW = Path(__file__).parents[1] / ".github" / "workflows" / "release.yml" + + +def release_steps(): + sections = WORKFLOW.read_text().split("\n - name: ")[1:] + return { + name: body + for section in sections + for name, _, body in [section.partition("\n")] + } + + +def test_release_checkout_can_pin_an_older_workflow_sha(): + steps = release_steps() + + checkout = steps["Checkout workflow SHA"] + assert "ref: ${{ github.sha }}" in checkout + assert "fetch-depth: 0" in checkout + assert 'git checkout -B "$GITHUB_REF_NAME" "$GITHUB_SHA"' in steps[ + "Restore release branch at workflow SHA" + ] + + +def test_stale_release_runs_skip_validation_and_publish_steps(): + steps = release_steps() + initial_guard = steps["Check workflow SHA is still current"] + + assert "id: release_head" in initial_guard + assert 'refs/heads/${GITHUB_REF_NAME}' in initial_guard + assert 'echo "current=false" >> "$GITHUB_OUTPUT"' in initial_guard + + guarded_steps = [ + "Set up Python", + "Set up Node", + "Install package with test dependencies", + "Install dashboard dependencies", + "Run dashboard tests", + "Build dashboard", + "Run tests before release", + "Recheck workflow SHA before release", + ] + for name in guarded_steps: + assert "if: steps.release_head.outputs.current == 'true'" in steps[name] + + final_guard = steps["Recheck workflow SHA before release"] + assert "id: pre_release_head" in final_guard + assert 'refs/heads/${GITHUB_REF_NAME}' in final_guard + assert "if: steps.pre_release_head.outputs.current == 'true'" in steps[ + "Semantic version release" + ]