diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 722b45c..b257743 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -43,6 +43,11 @@ on: - testpypi - pypi default: testpypi + release_tag: + description: "Annotated version tag to publish when dispatching from the default branch" + required: false + type: string + default: "" permissions: contents: read @@ -61,11 +66,15 @@ jobs: timeout-minutes: 10 outputs: version: ${{ steps.metadata.outputs.version }} + source_commit: ${{ steps.metadata.outputs.source_commit }} + release_tag: ${{ steps.metadata.outputs.release_tag }} + source_ref_type: ${{ steps.metadata.outputs.source_ref_type }} steps: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + ref: ${{ inputs.release_tag || github.ref }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -74,18 +83,44 @@ jobs: - name: Read package version id: metadata - run: echo "version=$(tr -d '\\r\\n' < VERSION)" >> "$GITHUB_OUTPUT" + env: + REQUESTED_RELEASE_TAG: ${{ inputs.release_tag || '' }} + run: | + version="$(tr -d '\\r\\n' < VERSION)" + release_tag="$REQUESTED_RELEASE_TAG" + if [[ -n "$release_tag" ]]; then + source_ref_type=tag + source_commit="$(git rev-parse --verify "refs/tags/$release_tag^{}")" + else + source_ref_type="$GITHUB_REF_TYPE" + source_commit="$GITHUB_SHA" + if [[ "$source_ref_type" == tag ]]; then + release_tag="$GITHUB_REF_NAME" + fi + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "source_ref_type=$source_ref_type" >> "$GITHUB_OUTPUT" - name: Validate release ref env: PUBLISH_TARGET: ${{ inputs.publish_target || '' }} + REQUESTED_RELEASE_TAG: ${{ inputs.release_tag || '' }} + SOURCE_REF_TYPE: ${{ steps.metadata.outputs.source_ref_type }} + RELEASE_TAG: ${{ steps.metadata.outputs.release_tag }} + VERSION: ${{ steps.metadata.outputs.version }} run: | - if [[ "$GITHUB_REF_TYPE" == "tag" && "$GITHUB_REF_NAME" != "v${{ steps.metadata.outputs.version }}" ]]; then - echo "Release tag must be v${{ steps.metadata.outputs.version }}; got $GITHUB_REF_NAME" >&2 + if [[ "$SOURCE_REF_TYPE" == "tag" && "$RELEASE_TAG" != "v$VERSION" ]]; then + echo "Release tag must be v$VERSION; got $RELEASE_TAG" >&2 exit 1 fi - if [[ "$PUBLISH_TARGET" == "pypi" && "$GITHUB_REF_TYPE" != "tag" ]]; then - echo "PyPI publication requires dispatching this workflow from the matching version tag." >&2 + if [[ "$PUBLISH_TARGET" == "pypi" && "$SOURCE_REF_TYPE" != "tag" ]]; then + echo "PyPI publication requires a matching version tag." >&2 + exit 1 + fi + if [[ "$PUBLISH_TARGET" == "pypi" && "$GITHUB_EVENT_NAME" == workflow_dispatch && -z "$REQUESTED_RELEASE_TAG" ]]; then + echo "Manual PyPI publication requires the release_tag input." >&2 exit 1 fi @@ -93,9 +128,9 @@ jobs: run: python scripts/validate_changelog.py - name: Validate tagged release notes - if: ${{ github.ref_type == 'tag' }} + if: ${{ steps.metadata.outputs.source_ref_type == 'tag' }} env: - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ steps.metadata.outputs.release_tag }} run: python scripts/validate_release_ref.py - name: Prepare clean artifact destination @@ -111,12 +146,13 @@ jobs: # Keep the reproducibility input numeric; push-event timestamps are # ISO-8601 strings and are not accepted by wheel/build backends. SOURCE_DATE_EPOCH: '0' + SOURCE_COMMIT: ${{ steps.metadata.outputs.source_commit }} run: | rm -rf dist-a dist-b "$RUNNER_TEMP/base-cli-build-a" "$RUNNER_TEMP/base-cli-build-b" mkdir -p dist-a dist-b mkdir -p "$RUNNER_TEMP/base-cli-build-a" "$RUNNER_TEMP/base-cli-build-b" - git archive "$GITHUB_SHA" | tar -x -C "$RUNNER_TEMP/base-cli-build-a" - git archive "$GITHUB_SHA" | tar -x -C "$RUNNER_TEMP/base-cli-build-b" + git archive "$SOURCE_COMMIT" | tar -x -C "$RUNNER_TEMP/base-cli-build-a" + git archive "$SOURCE_COMMIT" | tar -x -C "$RUNNER_TEMP/base-cli-build-b" (cd "$RUNNER_TEMP/base-cli-build-a" && python -m build --sdist --wheel --outdir "$GITHUB_WORKSPACE/dist-a") (cd "$RUNNER_TEMP/base-cli-build-b" && python -m build --sdist --wheel --outdir "$GITHUB_WORKSPACE/dist-b") python - <<'PY' @@ -155,13 +191,13 @@ jobs: - name: Generate release checksums and SPDX SBOM env: - SOURCE_REVISION: ${{ github.sha }} + SOURCE_REVISION: ${{ steps.metadata.outputs.source_commit }} SOURCE_DATE_EPOCH: '0' run: python scripts/generate_release_metadata.py dist - name: Validate release checksums and SPDX SBOM env: - SOURCE_REVISION: ${{ github.sha }} + SOURCE_REVISION: ${{ steps.metadata.outputs.source_commit }} run: python scripts/validate_release_metadata.py dist - name: Upload reviewed distributions @@ -199,6 +235,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + ref: ${{ needs.build.outputs.source_commit }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -250,6 +287,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + ref: ${{ needs.build.outputs.source_commit }} - name: Fetch trusted main history run: git fetch --no-tags --prune origin "refs/heads/main:refs/remotes/origin/main" @@ -257,7 +295,15 @@ jobs: - name: Verify release provenance env: PUBLISH_TARGET: ${{ inputs.publish_target || '' }} - run: python scripts/validate_release_provenance.py + SOURCE_REF_TYPE: ${{ needs.build.outputs.source_ref_type }} + RELEASE_TAG: ${{ needs.build.outputs.release_tag }} + SOURCE_COMMIT: ${{ needs.build.outputs.source_commit }} + run: | + python scripts/validate_release_provenance.py \ + --ref-type "$SOURCE_REF_TYPE" \ + --tag "$RELEASE_TAG" \ + --publish-target "$PUBLISH_TARGET" \ + --source-commit "$SOURCE_COMMIT" publish: name: Publish reviewed distribution @@ -328,7 +374,7 @@ jobs: release: name: Create GitHub Release needs: [build, smoke, provenance, publish, attest] - if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }} + if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && inputs.publish_target == 'pypi' && inputs.release_tag != '') }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -337,6 +383,9 @@ jobs: steps: - name: Check out the tag source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ needs.build.outputs.source_commit }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -358,13 +407,17 @@ jobs: - name: Verify and create immutable GitHub Release env: GH_TOKEN: ${{ github.token }} + SOURCE_REVISION: ${{ needs.build.outputs.source_commit }} + RELEASE_TAG: ${{ needs.build.outputs.release_tag }} + SOURCE_COMMIT: ${{ needs.build.outputs.source_commit }} run: | set -euo pipefail - tag="$GITHUB_REF_NAME" + tag="$RELEASE_TAG" + source_commit="$SOURCE_COMMIT" assets=(dist/*.whl dist/*.tar.gz dist/SHA256SUMS dist/SBOM.spdx.json dist/RELEASE-BOM-ROW.json) tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/$tag" --jq .sha)" - if [[ "$tag_commit" != "$GITHUB_SHA" ]]; then - echo "Release tag $tag resolves to $tag_commit, not reviewed commit $GITHUB_SHA." >&2 + if [[ "$tag_commit" != "$source_commit" ]]; then + echo "Release tag $tag resolves to $tag_commit, not reviewed commit $source_commit." >&2 exit 1 fi @@ -424,7 +477,7 @@ jobs: --release-json "$release_json" \ --version-file VERSION \ --tag "$tag" \ - --source-commit "$GITHUB_SHA" \ + --source-commit "$source_commit" \ --resolved-tag-commit "$tag_commit" } diff --git a/tests/test_package_workflow.py b/tests/test_package_workflow.py index 71de8e8..e4ad821 100644 --- a/tests/test_package_workflow.py +++ b/tests/test_package_workflow.py @@ -12,6 +12,18 @@ def test_package_workflow_uses_numeric_reproducibility_epoch() -> None: assert workflow.count("SOURCE_DATE_EPOCH: '0'") == 2 +def test_package_workflow_can_recover_an_immutable_tag_from_default_branch() -> None: + workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8") + + assert "release_tag:" in workflow + assert "ref: ${{ inputs.release_tag || github.ref }}" in workflow + assert "source_commit: ${{ steps.metadata.outputs.source_commit }}" in workflow + assert "SOURCE_COMMIT: ${{ steps.metadata.outputs.source_commit }}" in workflow + assert 'git archive "$SOURCE_COMMIT"' in workflow + assert "Manual PyPI publication requires the release_tag input." in workflow + assert '--source-commit "$SOURCE_COMMIT"' in workflow + + def test_package_workflow_does_not_replace_published_release_assets() -> None: workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/package.yml").read_text(encoding="utf-8") verifier = (Path(__file__).resolve().parents[1] / "scripts/verify_release_assets.py").read_text(encoding="utf-8") @@ -23,6 +35,7 @@ def test_package_workflow_does_not_replace_published_release_assets() -> None: assert "gh attestation verify" in workflow assert '--source-digest "$GITHUB_SHA"' in workflow assert '--source-ref "$GITHUB_REF"' in workflow + assert '--source-commit "$source_commit"' in workflow assert "--clobber" not in workflow