v3.7.3 #23
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Publish workflow. | |
| # Purpose: build and publish artifacts when a GitHub release is published. | |
| # This workflow does not compute versions or create tags. | |
| name: "Publish" | |
| on: | |
| release: | |
| types: | |
| - published | |
| concurrency: | |
| # Preserve release order on PyPI: publish runs execute one-by-one. | |
| group: publish-pypi | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| id-token: write | |
| env: | |
| PYTHON_VERSION: "3.11" | |
| POETRY_VERSION: "2.4.1" | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| TRUSTED_RELEASE_ACTORS: "vstack-release-bot[bot],eschaar" | |
| HOMEBREW_TAP_ENABLED: "false" | |
| HOMEBREW_TAP_NAME: "eschaar/vstack" | |
| HOMEBREW_TAP_REPOSITORY: "eschaar/homebrew-vstack" | |
| HOMEBREW_TAP_REPOSITORY_OWNER: "eschaar" | |
| HOMEBREW_TAP_REPOSITORY_NAME: "homebrew-vstack" | |
| HOMEBREW_FORMULA_NAME: "vstack" | |
| HOMEBREW_FULLY_QUALIFIED_FORMULA: "eschaar/vstack/vstack" | |
| jobs: | |
| publish: | |
| name: Build and Publish to PyPI | |
| if: github.event.release.prerelease == false | |
| runs-on: ubuntu-latest | |
| environment: pypi | |
| env: | |
| PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} | |
| steps: | |
| - name: Validate release tag format | |
| # Fail fast before checkout if the tag is not a plain SemVer X.Y.Z. | |
| # Prevents non-versioned or pre-release tags from reaching the publish step. | |
| shell: bash | |
| run: | | |
| TAG="${{ github.event.release.tag_name }}" | |
| if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "ERROR: release tag '$TAG' is not in expected SemVer X.Y.Z format." | |
| echo "Only plain version tags (e.g. 1.2.3) are permitted to trigger publish." | |
| exit 1 | |
| fi | |
| echo "tag=$TAG format is valid." | |
| - name: Validate release actor | |
| # Only allow trusted actors to trigger a PyPI publish. | |
| # vstack-release-bot[bot] covers release-please GitHub App automation. | |
| # eschaar covers direct maintainer releases. | |
| shell: bash | |
| run: | | |
| ACTOR="${{ github.event.release.author.login }}" | |
| IFS=',' read -r -a ALLOWED <<< "$TRUSTED_RELEASE_ACTORS" | |
| TRUSTED=false | |
| for allowed_actor in "${ALLOWED[@]}"; do | |
| if [[ "$ACTOR" == "$allowed_actor" ]]; then | |
| TRUSTED=true | |
| break | |
| fi | |
| done | |
| if [[ "$TRUSTED" != "true" ]]; then | |
| echo "ERROR: release created by '$ACTOR' which is not in the trusted actor list." | |
| echo "Allowed: $TRUSTED_RELEASE_ACTORS" | |
| echo "Releases must be created by vstack-release-bot automation or a trusted maintainer." | |
| exit 1 | |
| fi | |
| echo "release_actor='$ACTOR' is trusted." | |
| - name: Checkout release tag | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 | |
| with: | |
| ref: refs/tags/${{ github.event.release.tag_name }} | |
| fetch-depth: 0 | |
| - name: Install Poetry | |
| run: pipx install "poetry==${POETRY_VERSION}" | |
| - name: Install poetry-dynamic-versioning plugin | |
| run: pipx inject poetry "poetry-dynamic-versioning[plugin]>=1.10.0,<2.0.0" | |
| - name: Setup Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| - name: Validate release tag checkout | |
| run: | | |
| EXPECTED="${{ github.event.release.tag_name }}" | |
| ACTUAL_TAG="$(git tag --points-at HEAD | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)" | |
| echo "expected_tag=$EXPECTED" | |
| echo "head_tag=$ACTUAL_TAG" | |
| if [[ "$ACTUAL_TAG" != "$EXPECTED" ]]; then | |
| echo "ERROR: build checkout is not pinned to expected release tag." | |
| exit 1 | |
| fi | |
| - name: Build distributions | |
| run: poetry build | |
| - name: Smoke test built wheel | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install dist/*.whl | |
| vstack --help >/dev/null | |
| - name: Validate built artifact version | |
| run: | | |
| EXPECTED="${{ github.event.release.tag_name }}" | |
| shopt -s nullglob | |
| MATCHES=(dist/*"$EXPECTED"*.whl dist/*"$EXPECTED"*.tar.gz) | |
| echo "expected_version=$EXPECTED" | |
| ls -1 dist/ | |
| if [[ ${#MATCHES[@]} -eq 0 ]]; then | |
| echo "ERROR: no built artifacts contain expected version '$EXPECTED'." | |
| exit 1 | |
| fi | |
| - name: Publish to PyPI (trusted publishing) | |
| id: publish_trusted | |
| continue-on-error: true | |
| uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b | |
| - name: Publish to PyPI (API token fallback) | |
| if: steps.publish_trusted.outcome == 'failure' && env.PYPI_API_TOKEN != '' | |
| uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b | |
| with: | |
| user: __token__ | |
| password: ${{ env.PYPI_API_TOKEN }} | |
| - name: Fail when trusted publishing fails and no fallback token exists | |
| if: steps.publish_trusted.outcome == 'failure' && env.PYPI_API_TOKEN == '' | |
| shell: bash | |
| run: | | |
| echo "ERROR: trusted publishing failed and secret PYPI_API_TOKEN is not configured." | |
| echo "Either fix PyPI trusted publisher mapping or add PYPI_API_TOKEN as a fallback." | |
| exit 1 | |
| publish-homebrew: | |
| name: Update Homebrew Tap | |
| needs: publish | |
| # NOTE: job-level `if` cannot use the `env` context. Toggle via repository variable | |
| # HOMEBREW_TAP_ENABLED (value: "true" to enable). Default/absent keeps this disabled. | |
| if: needs.publish.result == 'success' && github.event.release.prerelease == false && vars.HOMEBREW_TAP_ENABLED == 'true' | |
| runs-on: ubuntu-latest | |
| environment: homebrew | |
| env: | |
| HOMEBREW_TAP_APP_CLIENT_ID: ${{ secrets.HOMEBREW_TAP_APP_CLIENT_ID }} | |
| HOMEBREW_TAP_APP_PRIVATE_KEY: ${{ secrets.HOMEBREW_TAP_APP_PRIVATE_KEY }} | |
| HOMEBREW_TAP_DISPATCH_SECRET: ${{ secrets.HOMEBREW_TAP_DISPATCH_SECRET }} | |
| steps: | |
| - name: Validate release actor | |
| # Restrict dispatch to trusted release automation/maintainers only. | |
| shell: bash | |
| run: | | |
| ACTOR="${{ github.event.release.author.login }}" | |
| IFS=',' read -r -a ALLOWED <<< "$TRUSTED_RELEASE_ACTORS" | |
| TRUSTED=false | |
| for allowed_actor in "${ALLOWED[@]}"; do | |
| if [[ "$ACTOR" == "$allowed_actor" ]]; then | |
| TRUSTED=true | |
| break | |
| fi | |
| done | |
| if [[ "$TRUSTED" != "true" ]]; then | |
| echo "ERROR: release created by '$ACTOR' which is not in the trusted actor list." | |
| echo "Allowed: $TRUSTED_RELEASE_ACTORS" | |
| exit 1 | |
| fi | |
| - name: Verify Homebrew tap dispatch configuration | |
| shell: bash | |
| run: | | |
| if [[ -z "$HOMEBREW_TAP_APP_CLIENT_ID" ]]; then | |
| echo "ERROR: HOMEBREW_TAP_APP_CLIENT_ID is not configured in the pypi environment." | |
| exit 1 | |
| fi | |
| if [[ -z "$HOMEBREW_TAP_APP_PRIVATE_KEY" ]]; then | |
| echo "ERROR: HOMEBREW_TAP_APP_PRIVATE_KEY is not configured in the pypi environment." | |
| exit 1 | |
| fi | |
| if [[ -z "$HOMEBREW_TAP_DISPATCH_SECRET" ]]; then | |
| echo "ERROR: HOMEBREW_TAP_DISPATCH_SECRET is not configured in the pypi environment." | |
| exit 1 | |
| fi | |
| - name: Create Homebrew tap app token | |
| id: create_tap_token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 | |
| with: | |
| client-id: ${{ env.HOMEBREW_TAP_APP_CLIENT_ID }} | |
| private-key: ${{ env.HOMEBREW_TAP_APP_PRIVATE_KEY }} | |
| owner: ${{ env.HOMEBREW_TAP_REPOSITORY_OWNER }} | |
| repositories: ${{ env.HOMEBREW_TAP_REPOSITORY_NAME }} | |
| - name: Fetch sdist metadata and verify checksum | |
| id: verify_sdist | |
| shell: bash | |
| run: | | |
| python - <<'PY' | |
| import hashlib | |
| import json | |
| import os | |
| import sys | |
| import urllib.request | |
| version = os.environ["GITHUB_REF_NAME"] | |
| pypi_json_url = f"https://pypi.org/pypi/vstack/{version}/json" | |
| with urllib.request.urlopen(pypi_json_url) as response: | |
| metadata = json.load(response) | |
| sdist = next((item for item in metadata.get("urls", []) if item.get("packagetype") == "sdist"), None) | |
| if sdist is None: | |
| print("ERROR: no sdist artifact found in PyPI metadata for the release version.", file=sys.stderr) | |
| sys.exit(1) | |
| sdist_url = sdist["url"] | |
| pypi_sha = sdist.get("digests", {}).get("sha256", "") | |
| if not pypi_sha: | |
| print("ERROR: PyPI metadata did not provide an sdist sha256 digest.", file=sys.stderr) | |
| sys.exit(1) | |
| with urllib.request.urlopen(sdist_url) as response: | |
| content = response.read() | |
| local_sha = hashlib.sha256(content).hexdigest() | |
| if local_sha != pypi_sha: | |
| print("ERROR: sha256 mismatch between PyPI metadata and downloaded tarball.", file=sys.stderr) | |
| sys.exit(1) | |
| with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: | |
| output.write(f"sdist_url={sdist_url}\n") | |
| output.write(f"sdist_sha256={local_sha}\n") | |
| PY | |
| env: | |
| GITHUB_REF_NAME: ${{ github.event.release.tag_name }} | |
| - name: Dispatch formula update to Homebrew tap | |
| shell: bash | |
| env: | |
| RELEASE_VERSION: ${{ github.event.release.tag_name }} | |
| SDIST_URL: ${{ steps.verify_sdist.outputs.sdist_url }} | |
| SDIST_SHA256: ${{ steps.verify_sdist.outputs.sdist_sha256 }} | |
| run: | | |
| python - <<'PY' | |
| import hmac | |
| import json | |
| import os | |
| from hashlib import sha256 | |
| payload = { | |
| "version": os.environ["RELEASE_VERSION"], | |
| "sdist_url": os.environ["SDIST_URL"], | |
| "sha256": os.environ["SDIST_SHA256"], | |
| } | |
| secret = os.environ.get("HOMEBREW_TAP_DISPATCH_SECRET", "") | |
| if secret: | |
| canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True) | |
| payload["signature"] = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), sha256).hexdigest() | |
| request_body = { | |
| "event_type": "update-formula", | |
| "client_payload": payload, | |
| } | |
| with open("dispatch-body.json", "w", encoding="utf-8") as output: | |
| json.dump(request_body, output, separators=(",", ":")) | |
| PY | |
| curl -fsS -X POST \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer ${{ steps.create_tap_token.outputs.token }}" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| "https://api.github.com/repos/${HOMEBREW_TAP_REPOSITORY}/dispatches" \ | |
| --data-binary @dispatch-body.json | |
| - name: Publish Homebrew install UX summary | |
| shell: bash | |
| run: | | |
| { | |
| echo "## Homebrew install UX" | |
| echo | |
| echo "- First-time users (private tap): \`brew tap ${HOMEBREW_TAP_NAME} && brew install ${HOMEBREW_FORMULA_NAME}\`" | |
| echo "- Returning users (tap already configured): \`brew install ${HOMEBREW_FORMULA_NAME}\`" | |
| echo "- Fully-qualified one-liner (no prior tap): \`brew install ${HOMEBREW_FULLY_QUALIFIED_FORMULA}\`" | |
| echo | |
| echo "Plain \`brew install ${HOMEBREW_FORMULA_NAME}\` without tapping cannot be guaranteed while distribution is private-tap only." | |
| echo "That UX becomes universal only after acceptance into Homebrew/homebrew-core." | |
| } >> "$GITHUB_STEP_SUMMARY" |