diff --git a/.github/workflows/README.md b/.github/workflows/README.md index be9d1dc..c54754b 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -470,60 +470,363 @@ The workflow run summary includes a per-entry table: ## `publish-pypi.yml` -Builds a Python package with `uv build`, stages on TestPyPI with install verification, promotes to production PyPI via OIDC trusted publishing, and creates a GitHub Release. +Builds a Python package with `uv build`, stages on TestPyPI with install +verification, promotes to production PyPI via OIDC trusted publishing, and +creates a GitHub Release. + +> **Trusted Publishing status:** this reusable workflow is not supported for +> PyPI/TestPyPI Trusted Publishing from package repos. Current PyPI behavior +> does not authorize cross-repo reusable workflows as Trusted Publisher +> workflows: the caller repo owns the OIDC repository claim, while the called +> workflow path points at `j7an/shared-workflows`. + +Long-lived API-token publishing is intentionally out of scope for this repo's +recommended PyPI release path. Keep package publish jobs in the package repo and +use the caller-owned template below for Trusted Publishing. + +The workflow file remains in this repo for compatibility with the published +`@v4` surface. Do not use it as the trusted-publisher workflow for new package +releases. ### Inputs | Input | Type | Required | Default | Description | |---|---|---|---|---| -| `tag` | string | yes | — | Semver tag to publish (e.g. `tools/v0.1.0`). | -| `package-dir` | string | no | `.` | Directory containing `pyproject.toml` (relative to repo root). | -| `testpypi-package` | string | yes | — | Distribution name on TestPyPI for install verification. | +| `tag` | string | yes | - | Semver tag to publish, such as `tools/v0.1.0` or `v1.2.3`. | +| `package-dir` | string | no | `.` | Directory containing `pyproject.toml` relative to repo root. | +| `testpypi-package` | string | yes | - | Distribution name on TestPyPI for install verification. | | `draft-release` | boolean | no | `false` | Create the GitHub release as a draft. | -| `attach-assets` | boolean | no | `true` | Attach wheel + sdist to the GitHub release. | +| `attach-assets` | boolean | no | `true` | Attach wheel and sdist to the GitHub release. | -### Secrets +### Compatibility note + +If PyPI later supports cross-repo reusable workflows as Trusted Publisher +workflows, reassess whether this reusable workflow should become the recommended +path again. Until then, prefer the caller-owned template. + +## Caller-owned PyPI Trusted Publishing template + +Use this pattern in each package repo that publishes to TestPyPI and PyPI with +Trusted Publishing. The caller repo owns the workflow identity, GitHub +Environments, PyPI Trusted Publisher records, and any package-specific jobs. + +### One-time package setup -None. OIDC handles publishing; `GITHUB_TOKEN` handles the release. +- Claim the package name on [PyPI](https://pypi.org/) and + [TestPyPI](https://test.pypi.org/). +- Create GitHub Environments `testpypi` and `pypi` in the package repo. +- Configure PyPI Trusted Publisher for the package repo, the workflow path of + the caller-owned release workflow, and environment `pypi`. +- Configure TestPyPI Trusted Publisher for the package repo, the same workflow + path, and environment `testpypi`. +- Copy `scripts/derive-published-version.sh` and + `scripts/classify-prerelease.sh` into the package repo, or embed their bodies + directly in the local workflow steps. -### Pipeline +Use normalized tag tails such as `v1.2.3`, `tools/v1.2.3`, `v1.2.3rc1`, or +`tools/v1.2.3rc1`. Do not tag prereleases as `v1.2.3-rc1`; the build guard +requires the tag tail to exactly equal the normalized version emitted by the +wheel. -1. **build** — `uv build` produces wheel + sdist; uploaded as `pypi-dist` artifact. Includes a tag-on-main guard. -2. **publish-testpypi** (`environment: testpypi`) — publishes to TestPyPI; verifies install in a clean venv with exponential backoff (5 attempts at 30/60/90/120/150s). -3. **publish-pypi** (`environment: pypi`) — publishes to production PyPI. -4. **github-release** — creates a GitHub release with auto-generated notes; attaches artifacts; auto-detects prerelease from `-` in tag. +The standard trigger shown below matches only plain tags (`v*.*.*`). If your +tag stream is path-prefixed (for example `tools/v`), add the matching trigger +pattern (for example `tools/v*.*.*`) so pushes to `tools/v1.2.3` trigger this +release workflow. -### Caller example +### Standard release workflow ```yaml -# .github/workflows/release-tools.yml — tag-driven publish +name: Release Python Package + on: push: tags: - - 'tools/v*.*.*' + - 'v*.*.*' # for plain tags like `v1.2.3` + +permissions: + contents: read + +concurrency: + group: pypi-release-${{ github.ref_name }} + cancel-in-progress: false + +env: + PACKAGE_NAME: example-pkg + PACKAGE_DIR: . + VERIFY_COMMAND: example-pkg --version + DRAFT_RELEASE: "true" + ATTACH_ASSETS: "true" jobs: - publish: - uses: j7an/shared-workflows/.github/workflows/publish-pypi.yml@v4 - with: - tag: ${{ github.ref_name }} - package-dir: tools - testpypi-package: epiphany-tools + build: + runs-on: ubuntu-latest + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout at tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Verify tag is ancestor of main + env: + TAG: ${{ github.ref_name }} + run: | + TAG_SHA=$(git rev-list -n1 "$TAG") + git fetch origin main + if ! git merge-base --is-ancestor "$TAG_SHA" origin/main; then + echo "::error::Tag ${TAG} (${TAG_SHA}) is not an ancestor of origin/main" + exit 1 + fi + + - name: Set up uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Build wheel and sdist + working-directory: ${{ env.PACKAGE_DIR }} + run: uv build + + - name: Verify built version matches tag + env: + TAG: ${{ github.ref_name }} + run: bash scripts/derive-published-version.sh "${PACKAGE_DIR}/dist" "$TAG" + + - name: Upload dist artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pypi-dist + path: ${{ env.PACKAGE_DIR }}/dist/ + if-no-files-found: error + retention-days: 7 + + publish-testpypi: + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/example-pkg + permissions: + id-token: write + attestations: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Download dist artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pypi-dist + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: dist/ + skip-existing: false + + verify-testpypi: + needs: publish-testpypi + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Set up uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Verify install from TestPyPI + env: + PACKAGE_NAME: ${{ env.PACKAGE_NAME }} + VERIFY_COMMAND: ${{ env.VERIFY_COMMAND }} + VERSION_TAG: ${{ github.ref_name }} + run: | + VERSION="${VERSION_TAG##*/}" + VERSION="${VERSION#v}" + echo "Verifying TestPyPI install of ${PACKAGE_NAME}==${VERSION}" + + INSTALLED=false + ATTEMPT=0 + for SLEEP_SECONDS in 30 60 90 120 150; do + ATTEMPT=$((ATTEMPT + 1)) + echo "Attempt ${ATTEMPT}/5: sleeping ${SLEEP_SECONDS}s before install..." + sleep "$SLEEP_SECONDS" + rm -rf .verify + uv venv .verify + . .verify/bin/activate + if uv pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "${PACKAGE_NAME}==${VERSION}"; then + INSTALLED=true + break + fi + echo "Attempt ${ATTEMPT} failed; retrying." + done + + if [ "$INSTALLED" != "true" ]; then + echo "::error::TestPyPI install verification failed after 5 attempts" + exit 1 + fi + + if [ -n "${VERIFY_COMMAND:-}" ]; then + if ! VERIFY_OUTPUT=$(bash -euo pipefail -c "$VERIFY_COMMAND" 2>&1); then + printf '%s\n' "$VERIFY_OUTPUT" + echo "::error::Verification command failed" + exit 1 + fi + printf '%s\n' "$VERIFY_OUTPUT" + case "$VERIFY_OUTPUT" in + *"$VERSION"*) ;; + *) + echo "::error::Verification command output did not contain version '${VERSION}'" + exit 1 + ;; + esac + fi + + publish-pypi: + needs: verify-testpypi + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/example-pkg + permissions: + id-token: write + attestations: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Download dist artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pypi-dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + packages-dir: dist/ + + github-release: + needs: publish-pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout at tag + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.ref_name }} + + - name: Download dist artifact + if: env.ATTACH_ASSETS == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pypi-dist + path: dist/ + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + VERSION_TAG: ${{ github.ref_name }} + run: | + VERSION="${VERSION_TAG##*/}" + VERSION="${VERSION#v}" + IS_PRERELEASE=$(bash scripts/classify-prerelease.sh "$VERSION") + + ARGS=( "$TAG" --generate-notes --title "$TAG" ) + if [ "$IS_PRERELEASE" = "true" ]; then + ARGS+=( --prerelease ) + fi + if [ "$DRAFT_RELEASE" = "true" ]; then + ARGS+=( --draft ) + fi + if [ "$ATTACH_ASSETS" = "true" ]; then + ARGS+=( dist/*.whl dist/*.tar.gz ) + fi + + gh release create "${ARGS[@]}" ``` -### Per-package onboarding checklist +Set TestPyPI `skip-existing: true` only when rerun ergonomics are worth the +freshness tradeoff: enabling it can let verification install an old same-version +artifact already present on TestPyPI. Do not set `skip-existing` on the +production PyPI publish step. -For each new PyPI package that uses this workflow, complete **once**: +The verification command is caller-controlled shell text. Pass it through +`env:` and execute it intentionally with `bash -euo pipefail -c +"$VERIFY_COMMAND"`; it must never be interpolated directly into `run:`. -- [ ] Claim the package name on [PyPI](https://pypi.org/) and [TestPyPI](https://test.pypi.org/). -- [ ] On PyPI, configure trusted publisher: workflow `j7an/shared-workflows/.github/workflows/publish-pypi.yml`, ref `v4`, environment `pypi`. -- [ ] On TestPyPI, configure the same trusted publisher with environment `testpypi`. -- [ ] Confirm GitHub Environments `testpypi` and `pypi` exist in `j7an/shared-workflows` repo settings. +### Add a pre-publish CI gate -### Recovery from a failed publish +For packages that run tests before publishing, add a local `test` job and make +`build` depend on it: -PyPI never lets you re-publish the same version, even after deletion. If a publish fails: +```yaml +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - run: uv run ruff check . + - run: uv run mypy . + - run: uv run pytest + + build: + needs: test +``` + +### Migration parity table + +| To reproduce this behavior | Template setting | +|---|---| +| Release stays a draft for human publish | Set `DRAFT_RELEASE: "true"` so `gh release create` receives `--draft`. | +| Post-install runs console script and asserts version | Set `VERIFY_COMMAND`, for example `example-pkg --version`. | +| TestPyPI re-upload is skipped for reruns | Set TestPyPI `skip-existing: true`, acknowledging the freshness tradeoff. | +| Install verification targets the package name | Set `PACKAGE_NAME` to the PyPI/TestPyPI distribution name. | +| Deployment UI links are preserved | Set caller-local `environment.url` values on `publish-testpypi` and `publish-pypi`. | +| MCP Registry publish runs after package release | Keep a caller-local MCP job gated on `github-release` success. | + +MCP Registry publishing remains caller-local. A package such as `nexus-mcp` +should keep its MCP job in the package repo with its own `id-token: write` +permission and gate it on the local release workflow result. + +### Recovery from a failed publish -- **TestPyPI fail, PyPI not yet attempted:** the workflow halts; recover by tagging a new prerelease (`tools/v0.1.0-rc2`) once the underlying issue is fixed. -- **PyPI fail after TestPyPI success:** rare; usually a transient GitHub→PyPI handshake problem. Re-run the failed job from the Actions UI. If it persists, tag a new patch. -- **GitHub release fail after PyPI success:** the package is live on PyPI; manually create the release with `gh release create` against the same tag, or re-run the `github-release` job (note: `gh release create` is not idempotent — if a release already exists for the tag, the re-run will fail with "release already exists" and you'll need to use `gh release edit` to update it). +PyPI never lets you re-publish the same version, even after deletion. If a +publish fails: + +- **TestPyPI fail, PyPI not yet attempted:** fix the issue, then tag a new + prerelease or intentionally rerun with TestPyPI `skip-existing: true` when + the existing TestPyPI artifact is the artifact you meant to verify. +- **PyPI fail after TestPyPI success:** re-run the failed job from the Actions + UI if the failure was transient. If the version was rejected as already + existing, tag a new patch or prerelease. +- **GitHub release fail after PyPI success:** the package is live on PyPI. + Create or repair the release with the first-party `gh` CLI. `gh release + create` fails loudly when the release already exists. diff --git a/scripts/classify-prerelease.sh b/scripts/classify-prerelease.sh new file mode 100755 index 0000000..d27bd87 --- /dev/null +++ b/scripts/classify-prerelease.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# classify-prerelease.sh - classify a normalized PyPI-publishable version. +# +# Caller-copy reference: this script is a canonical reference for package +# release workflows in caller repos. It has no local inline workflow consumer in +# shared-workflows and is intentionally excluded from check-inline-sync.sh +# INLINE_PAIRS. +# +# Input contract: exactly one normalized, PyPI-publishable version. Local +# versions containing '+label' are unsupported because PyPI publish artifacts +# should not use local versions. +# +# Output: +# true - version is a pre-release or dev release +# false - version is stable or post-release only +# +# Exit: +# 0 - classified successfully +# 2 - malformed or unsupported input +# +# Bash 3.2 compatible. + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +version="$1" + +if [ -z "$version" ]; then + echo "version must not be empty" >&2 + exit 2 +fi + +case "$version" in + *+*) + echo "local versions are unsupported input: $version" >&2 + exit 2 + ;; +esac + +if printf '%s\n' "$version" | grep -Eq '(a|b|rc)[0-9]|\.dev[0-9]'; then + echo "true" +else + echo "false" +fi diff --git a/scripts/derive-published-version.sh b/scripts/derive-published-version.sh new file mode 100755 index 0000000..8005b39 --- /dev/null +++ b/scripts/derive-published-version.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# derive-published-version.sh - derive and verify the version being published. +# +# Caller-copy reference: this script is a canonical reference for package +# release workflows in caller repos. It has no local inline workflow consumer in +# shared-workflows and is intentionally excluded from check-inline-sync.sh +# INLINE_PAIRS. +# +# Usage: +# scripts/derive-published-version.sh +# +# Output: +# normalized artifact version on stdout +# +# Exit: +# 0 - exactly one wheel, exactly one sdist, and tag tail equals wheel version +# 1 - artifact count error or tag/artifact mismatch +# 2 - malformed invocation +# +# Bash 3.2 compatible. + +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +dist_dir="$1" +tag="$2" + +if [ ! -d "$dist_dir" ]; then + echo "dist directory does not exist: $dist_dir" >&2 + exit 1 +fi + +count_matches() { + find "$1" -maxdepth 1 -type f -name "$2" | wc -l | tr -d '[:space:]' +} + +first_match() { + find "$1" -maxdepth 1 -type f -name "$2" | sed -n '1p' +} + +wheel_count=$(count_matches "$dist_dir" '*.whl') +sdist_count=$(count_matches "$dist_dir" '*.tar.gz') + +if [ "$wheel_count" -ne 1 ]; then + echo "expected exactly one wheel in $dist_dir, found $wheel_count" >&2 + exit 1 +fi + +if [ "$sdist_count" -ne 1 ]; then + echo "expected exactly one sdist in $dist_dir, found $sdist_count" >&2 + exit 1 +fi + +wheel_path=$(first_match "$dist_dir" '*.whl') +wheel_base="${wheel_path##*/}" + +artifact_remainder="${wheel_base#*-}" +artifact_version="${artifact_remainder%%-*}" + +if [ -z "$artifact_version" ] || [ "$artifact_version" = "$wheel_base" ]; then + echo "could not derive version from wheel filename: $wheel_base" >&2 + exit 1 +fi + +tag_tail="${tag##*/}" +tag_tail="${tag_tail#v}" + +if [ "$tag_tail" != "$artifact_version" ]; then + echo "Tag tail '$tag_tail' does not equal built version '$artifact_version'." >&2 + echo "Tag the canonical normalized version, e.g. 'v${artifact_version}', or update project metadata." >&2 + exit 1 +fi + +printf '%s\n' "$artifact_version" diff --git a/tests/classify-prerelease.bats b/tests/classify-prerelease.bats new file mode 100644 index 0000000..511bdcd --- /dev/null +++ b/tests/classify-prerelease.bats @@ -0,0 +1,48 @@ +#!/usr/bin/env bats +# classify-prerelease.bats - tests for scripts/classify-prerelease.sh + +SCRIPT="$BATS_TEST_DIRNAME/../scripts/classify-prerelease.sh" + +run_classifier() { + run bash "$SCRIPT" "$1" +} + +@test "classifies normalized pre-release spellings as prerelease" { + for version in 1.0a1 1.0b2 1.0rc1; do + run_classifier "$version" + [ "$status" -eq 0 ] + [ "$output" = "true" ] + done +} + +@test "classifies dev releases as prerelease" { + for version in 1.0.dev1 1.0.post1.dev2; do + run_classifier "$version" + [ "$status" -eq 0 ] + [ "$output" = "true" ] + done +} + +@test "classifies stable, post, epoch, and multi-segment releases as stable" { + for version in 1.0.post1 '1!2.0' 1.2.3 1.2.3.4; do + run_classifier "$version" + [ "$status" -eq 0 ] + [ "$output" = "false" ] + done +} + +@test "rejects local versions as unsupported input" { + run_classifier "1.2.3+b1" + [ "$status" -eq 2 ] + [[ "$output" == *"local versions"* ]] +} + +@test "requires exactly one version argument" { + run bash "$SCRIPT" + [ "$status" -eq 2 ] + [[ "$output" == *"usage:"* ]] + + run bash "$SCRIPT" "1.2.3" "1.2.4" + [ "$status" -eq 2 ] + [[ "$output" == *"usage:"* ]] +} diff --git a/tests/derive-published-version.bats b/tests/derive-published-version.bats new file mode 100644 index 0000000..d085270 --- /dev/null +++ b/tests/derive-published-version.bats @@ -0,0 +1,104 @@ +#!/usr/bin/env bats +# derive-published-version.bats - tests for scripts/derive-published-version.sh + +SCRIPT="$BATS_TEST_DIRNAME/../scripts/derive-published-version.sh" + +setup() { + TMPDIR=$(mktemp -d) + DIST="$TMPDIR/dist" + mkdir -p "$DIST" +} + +teardown() { + rm -rf "$TMPDIR" +} + +make_artifacts() { + local version="$1" + touch "$DIST/example_pkg-${version}-py3-none-any.whl" + touch "$DIST/example_pkg-${version}.tar.gz" +} + +run_deriver() { + run bash "$SCRIPT" "$DIST" "$1" +} + +@test "one wheel and one sdist extracts the normalized version" { + make_artifacts "1.2.3" + run_deriver "v1.2.3" + [ "$status" -eq 0 ] + [ "$output" = "1.2.3" ] +} + +@test "version extraction uses wheel field two and tolerates a wheel build tag" { + touch "$DIST/example_pkg-1.2.3-1-py3-none-any.whl" + touch "$DIST/example_pkg-1.2.3.tar.gz" + run_deriver "v1.2.3" + [ "$status" -eq 0 ] + [ "$output" = "1.2.3" ] +} + +@test "path-prefixed tags strip to the canonical version tail" { + make_artifacts "0.1.0" + run_deriver "tools/v0.1.0" + [ "$status" -eq 0 ] + [ "$output" = "0.1.0" ] +} + +@test "no wheel fails loudly" { + touch "$DIST/example_pkg-1.2.3.tar.gz" + run_deriver "v1.2.3" + [ "$status" -eq 1 ] + [[ "$output" == *"expected exactly one wheel"* ]] +} + +@test "multiple wheels fail loudly" { + touch "$DIST/example_pkg-1.2.3-py3-none-any.whl" + touch "$DIST/example_pkg-1.2.3-1-py3-none-any.whl" + touch "$DIST/example_pkg-1.2.3.tar.gz" + run_deriver "v1.2.3" + [ "$status" -eq 1 ] + [[ "$output" == *"expected exactly one wheel"* ]] +} + +@test "no sdist fails loudly" { + touch "$DIST/example_pkg-1.2.3-py3-none-any.whl" + run_deriver "v1.2.3" + [ "$status" -eq 1 ] + [[ "$output" == *"expected exactly one sdist"* ]] +} + +@test "multiple sdists fail loudly" { + touch "$DIST/example_pkg-1.2.3-py3-none-any.whl" + touch "$DIST/example_pkg-1.2.3.tar.gz" + touch "$DIST/example_pkg-1.2.3.post1.tar.gz" + run_deriver "v1.2.3" + [ "$status" -eq 1 ] + [[ "$output" == *"expected exactly one sdist"* ]] +} + +@test "tag and artifact version mismatch fails before publishing" { + make_artifacts "1.2.3" + run_deriver "v1.2.4" + [ "$status" -eq 1 ] + [[ "$output" == *"Tag tail '1.2.4' does not equal built version '1.2.3'"* ]] + [[ "$output" == *"v1.2.3"* ]] +} + +@test "hyphenated prerelease tag fails with canonical spelling guidance" { + make_artifacts "1.2.3rc1" + run_deriver "v1.2.3-rc1" + [ "$status" -eq 1 ] + [[ "$output" == *"Tag tail '1.2.3-rc1' does not equal built version '1.2.3rc1'"* ]] + [[ "$output" == *"v1.2.3rc1"* ]] +} + +@test "requires dist directory and tag arguments" { + run bash "$SCRIPT" + [ "$status" -eq 2 ] + [[ "$output" == *"usage:"* ]] + + run bash "$SCRIPT" "$DIST" + [ "$status" -eq 2 ] + [[ "$output" == *"usage:"* ]] +} diff --git a/tests/pypi-publish-template-docs.bats b/tests/pypi-publish-template-docs.bats new file mode 100644 index 0000000..62bb451 --- /dev/null +++ b/tests/pypi-publish-template-docs.bats @@ -0,0 +1,115 @@ +#!/usr/bin/env bats +# pypi-publish-template-docs.bats - lint-grade checks for PyPI publish docs. + +README=".github/workflows/README.md" + +assert_contains() { + case "$1" in + *"$2"*) return 0 ;; + *) + printf 'expected text to contain:\n%s\n' "$2" + return 1 + ;; + esac +} + +assert_lacks() { + case "$1" in + *"$2"*) + printf 'expected text not to contain:\n%s\n' "$2" + return 1 + ;; + *) return 0 ;; + esac +} + +publish_pypi_section() { + awk ' + /^## `publish-pypi.yml`$/ { flag=1; print; next } + flag && /^## / { exit } + flag { print } + ' "$README" +} + +template_section() { + awk ' + /^## Caller-owned PyPI Trusted Publishing template$/ { flag=1; print; next } + flag && /^## / { exit } + flag { print } + ' "$README" +} + +normalize_text() { + printf '%s\n' "$1" | sed -E 's/^[[:space:]]*> ?//' | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g' +} + +@test "publish-pypi.yml docs no longer advertise impossible Trusted Publishing setup" { + section=$(normalize_text "$(publish_pypi_section)") + assert_contains "$section" "not supported for PyPI/TestPyPI Trusted Publishing" + assert_contains "$section" "Current PyPI behavior" + assert_contains "$section" "cross-repo reusable workflows as Trusted Publisher" + assert_contains "$section" "Long-lived API-token publishing is intentionally out of scope" + assert_lacks "$section" 'workflow `j7an/shared-workflows/.github/workflows/publish-pypi.yml`' + assert_lacks "$section" 'GitHub Environments `testpypi` and `pypi` exist in `j7an/shared-workflows`' +} + +@test "caller-owned template documents required local trusted-publisher setup" { + section=$(normalize_text "$(template_section)") + assert_contains "$section" 'Create GitHub Environments `testpypi` and `pypi` in the package repo' + assert_contains "$section" "Configure PyPI Trusted Publisher" + assert_contains "$section" "Configure TestPyPI Trusted Publisher" + assert_contains "$section" "workflow path of the caller-owned release workflow" +} + +@test "caller-owned template includes the safe release job graph" { + section=$(normalize_text "$(template_section)") + assert_contains "$section" "build:" + assert_contains "$section" "publish-testpypi:" + assert_contains "$section" "verify-testpypi:" + assert_contains "$section" "publish-pypi:" + assert_contains "$section" "github-release:" + assert_contains "$section" "needs: publish-testpypi" + assert_contains "$section" "needs: verify-testpypi" + assert_contains "$section" "needs: publish-pypi" + assert_lacks "$section" "needs.build.outputs" +} + +@test "verification job is documented as no-OIDC and command-safe" { + section=$(normalize_text "$(template_section)") + assert_contains "$section" "permissions:" + assert_contains "$section" "contents: read" + assert_contains "$section" 'VERIFY_COMMAND: example-pkg --version' + assert_contains "$section" 'bash -euo pipefail -c "$VERIFY_COMMAND"' + assert_contains "$section" 'must never be interpolated directly into `run:`' +} + +@test "template uses first-party gh release and not softprops" { + section=$(normalize_text "$(template_section)") + assert_contains "$section" "gh release create" + assert_contains "$section" "scripts/classify-prerelease.sh" + assert_lacks "$section" "softprops/action-gh-release" + assert_lacks "$section" '[[ "$TAG" == *-* ]]' +} + +@test "template documents TestPyPI skip-existing as opt-in and keeps production strict" { + section=$(normalize_text "$(template_section)") + assert_contains "$section" 'Set TestPyPI `skip-existing: true` only when rerun ergonomics are worth' + assert_contains "$section" "freshness tradeoff" + assert_contains "$section" 'Do not set `skip-existing`' + assert_contains "$section" "production PyPI publish step" +} + +@test "template documents normalized prerelease tag spelling" { + section=$(normalize_text "$(template_section)") + assert_contains "$section" "Use normalized tag tails" + assert_contains "$section" '`v1.2.3rc1`' + assert_contains "$section" 'Do not tag prereleases as `v1.2.3-rc1`' +} + +@test "template documents trigger adjustment for path-prefixed tags" { + section=$(normalize_text "$(template_section)") + assert_contains "$section" "path-prefixed (for example" + assert_contains "$section" "standard trigger" + assert_contains "$section" "tools/v*.*.*" + assert_contains "$section" "add the matching trigger pattern" +}