diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e534bc..62c8ed0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,11 @@ on: required: false type: boolean default: false + publish-existing-tag: + description: Publish an existing GitHub release tag to PyPI without creating a new release. + required: false + type: string + default: "" permissions: contents: read @@ -78,7 +83,7 @@ jobs: release-please: name: release please - if: ${{ !inputs.pypi-environment-smoke }} + if: ${{ github.event_name != 'workflow_dispatch' || (!inputs.pypi-environment-smoke && inputs.publish-existing-tag == '') }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -276,7 +281,100 @@ jobs: shell: bash run: | mkdir -p dist - gh release download "$RELEASE_TAG" --dir dist --pattern "*.whl" --pattern "*.tar.gz" + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir dist --pattern "*.whl" --pattern "*.tar.gz" + python - <<'PY' + from pathlib import Path + + artifacts = sorted(path.name for path in Path("dist").iterdir()) + if not any(name.endswith(".whl") for name in artifacts): + raise SystemExit(f"release {artifacts=} does not include a wheel") + if not any(name.endswith(".tar.gz") for name in artifacts): + raise SystemExit(f"release {artifacts=} does not include a source distribution") + PY + + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + + publish-existing-release-gate: + name: existing release gate + if: ${{ github.event_name == 'workflow_dispatch' && !inputs.pypi-environment-smoke && inputs.publish-existing-tag != '' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + permissions: + contents: read + + env: + RELEASE_TAG: ${{ inputs.publish-existing-tag }} + CODEBASE_GRAPH_CONFIRM_TRUSTED_PUBLISHER: ${{ vars.CODEBASE_GRAPH_CONFIRM_TRUSTED_PUBLISHER }} + CODEBASE_GRAPH_CONFIRM_PYPI_ENVIRONMENT: ${{ vars.CODEBASE_GRAPH_CONFIRM_PYPI_ENVIRONMENT }} + CODEBASE_GRAPH_CONFIRM_HOSTED_CI_GREEN: ${{ vars.CODEBASE_GRAPH_CONFIRM_HOSTED_CI_GREEN }} + CODEBASE_GRAPH_CONFIRM_PRIVATE_VULNERABILITY_REPORTING: ${{ vars.CODEBASE_GRAPH_CONFIRM_PRIVATE_VULNERABILITY_REPORTING }} + CODEBASE_GRAPH_REQUIRE_CONDA: ${{ vars.CODEBASE_GRAPH_REQUIRE_CONDA }} + + steps: + - name: Validate release tag + shell: bash + run: | + python - <<'PY' + import os + import re + + tag = os.environ["RELEASE_TAG"] + if re.fullmatch(r"v\d+\.\d+\.\d+", tag) is None: + raise SystemExit(f"release tag must match vX.Y.Z, got {tag!r}") + PY + + - name: Check out release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + + - name: Run production release gate + shell: bash + run: | + args=(--production) + if [[ "${CODEBASE_GRAPH_CONFIRM_TRUSTED_PUBLISHER}" == "true" ]]; then + args+=(--confirm trusted-publisher) + fi + if [[ "${CODEBASE_GRAPH_CONFIRM_PYPI_ENVIRONMENT}" == "true" ]]; then + args+=(--confirm pypi-environment) + fi + if [[ "${CODEBASE_GRAPH_CONFIRM_HOSTED_CI_GREEN}" == "true" ]]; then + args+=(--confirm hosted-ci-green) + fi + if [[ "${CODEBASE_GRAPH_CONFIRM_PRIVATE_VULNERABILITY_REPORTING}" == "true" ]]; then + args+=(--confirm private-vulnerability-reporting) + fi + if [[ "${CODEBASE_GRAPH_REQUIRE_CONDA}" == "true" ]]; then + args+=(--require-conda) + fi + python scripts/check_release_gate.py "${args[@]}" + + publish-existing-pypi: + name: publish existing release to PyPI + needs: publish-existing-release-gate + if: ${{ github.event_name == 'workflow_dispatch' && !inputs.pypi-environment-smoke && inputs.publish-existing-tag != '' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/p/cbasegraph + permissions: + contents: read + id-token: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.publish-existing-tag }} + + steps: + - name: Download distributions from GitHub release + shell: bash + run: | + mkdir -p dist + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir dist --pattern "*.whl" --pattern "*.tar.gz" python - <<'PY' from pathlib import Path diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 370e62f..b9ed19a 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -74,7 +74,21 @@ def test_release_workflow_can_smoke_test_pypi_environment_without_publishing() - def test_release_please_is_skipped_during_pypi_environment_smoke() -> None: text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") - assert "release-please:\n name: release please\n if: ${{ !inputs.pypi-environment-smoke }}" in text + assert "release-please:\n name: release please" in text + assert "!inputs.pypi-environment-smoke" in text + assert "inputs.publish-existing-tag == ''" in text + + +def test_release_workflow_can_publish_existing_release_tag() -> None: + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + + assert "publish-existing-tag:" in text + assert "existing release gate" in text + assert "publish existing release to PyPI" in text + assert "inputs.publish-existing-tag != ''" in text + assert 'RELEASE_TAG: ${{ inputs.publish-existing-tag }}' in text + assert 'gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir dist' in text + assert "release tag must match vX.Y.Z" in text def test_release_please_uses_strict_semver_tags() -> None: @@ -175,7 +189,7 @@ def test_workflows_avoid_node20_artifact_actions() -> None: def test_release_workflow_downloads_distributions_from_github_release() -> None: text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") - assert 'gh release download "$RELEASE_TAG" --dir dist' in text + assert 'gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir dist' in text assert "release {artifacts=} does not include a wheel" in text assert "release {artifacts=} does not include a source distribution" in text