From d105e807df573b6a8f586d76f20adf7c12c3152b Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Fri, 15 May 2026 16:29:34 -0700 Subject: [PATCH] Add manual release dispatch workflow --- .github/workflows/release.yml | 163 +++++++++++++++++++++++++++++++++- docs/distribution.md | 37 ++++++-- tests/test_action_metadata.py | 28 ++++++ 3 files changed, 215 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8b3973..127411f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,12 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + version: + description: "Version to release without leading v, for example 0.10.0. Must match pyproject.toml and agents_shipgate.__version__." + required: true + type: string permissions: contents: write @@ -12,12 +18,50 @@ permissions: jobs: release: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 45 environment: pypi steps: + - name: Resolve release metadata + id: release + shell: bash + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ github.event.inputs.version || '' }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + + if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then + version="${INPUT_VERSION#v}" + tag="v${version}" + checkout_ref="${DEFAULT_BRANCH}" + create_tag="true" + else + tag="${REF_NAME}" + version="${tag#v}" + checkout_ref="${REF_NAME}" + create_tag="false" + fi + + if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([A-Za-z0-9._-]+)?$ ]]; then + echo "::error::Release tag must look like vX.Y.Z; got ${tag}" + exit 1 + fi + + { + echo "version=${version}" + echo "tag=${tag}" + echo "checkout_ref=${checkout_ref}" + echo "create_tag=${create_tag}" + } >> "${GITHUB_OUTPUT}" + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ steps.release.outputs.checkout_ref }} + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 @@ -30,6 +74,55 @@ jobs: python -m pip install -e ".[dev]" python -m pip install "uv==0.11.7" + - name: Validate release version and tag + shell: bash + env: + CREATE_TAG: ${{ steps.release.outputs.create_tag }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + python - <<'PY' + import os + import re + import tomllib + from pathlib import Path + + expected = os.environ["RELEASE_VERSION"] + pyproject_version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"] + init_text = Path("src/agents_shipgate/__init__.py").read_text() + match = re.search(r'__version__\s*=\s*"([^"]+)"', init_text) + init_version = match.group(1) if match else None + + mismatches = [] + if pyproject_version != expected: + mismatches.append(f"pyproject.toml has {pyproject_version!r}") + if init_version != expected: + mismatches.append(f"src/agents_shipgate/__init__.py has {init_version!r}") + + if mismatches: + raise SystemExit( + "Release version mismatch for " + + expected + + ": " + + "; ".join(mismatches) + ) + print(f"Release version {expected} matches pyproject.toml and __init__.py") + PY + + git fetch --tags --force origin + if [ "${CREATE_TAG}" = "true" ]; then + if git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then + echo "::error::Tag ${RELEASE_TAG} already exists locally" + exit 1 + fi + if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + echo "::error::Tag ${RELEASE_TAG} already exists on origin" + exit 1 + fi + fi + - name: Lint and test run: | python -m ruff check . @@ -50,13 +143,75 @@ jobs: - name: Sign release artifacts run: sigstore sign --output-directory dist --overwrite dist/*.whl dist/*.tar.gz dist/agents-shipgate-sbom.json + - name: Create annotated release tag + if: ${{ steps.release.outputs.create_tag == 'true' }} + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${RELEASE_TAG}" -m "Agents Shipgate ${RELEASE_TAG}" + git push origin "refs/tags/${RELEASE_TAG}" + - name: Publish to PyPI with Trusted Publishing run: uv publish --trusted-publishing always dist/*.whl dist/*.tar.gz - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + gh release create "${RELEASE_TAG}" dist/* \ + --title "${RELEASE_TAG}" \ + --notes "Agents Shipgate ${RELEASE_TAG}" \ + --verify-tag + + - name: Verify GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: gh release view "${RELEASE_TAG}" --json tagName,isLatest,createdAt,url + + - name: Verify PyPI release + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + python - <<'PY' + import json + import os + import time + import urllib.request + + version = os.environ["RELEASE_VERSION"] + url = "https://pypi.org/pypi/agents-shipgate/json" + + for attempt in range(1, 31): + try: + with urllib.request.urlopen(url, timeout=20) as response: + payload = json.load(response) + if version in payload.get("releases", {}): + print(f"PyPI shows agents-shipgate {version}") + break + except Exception as exc: + print(f"Attempt {attempt}: PyPI check failed: {exc}") + else: + print(f"Attempt {attempt}: PyPI does not show {version} yet") + time.sleep(10) + else: + raise SystemExit(f"PyPI did not show agents-shipgate {version} in time") + PY + + - name: Check GitHub Marketplace listing + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | - gh release create "${GITHUB_REF_NAME}" dist/* \ - --title "${GITHUB_REF_NAME}" \ - --notes "Agents Shipgate ${GITHUB_REF_NAME}" + for attempt in {1..30}; do + html="$(curl -fsSL https://github.com/marketplace/actions/agents-shipgate || true)" + if grep -Fq "${RELEASE_TAG}" <<< "${html}"; then + echo "GitHub Marketplace shows ${RELEASE_TAG}" + exit 0 + fi + echo "Attempt ${attempt}: GitHub Marketplace does not show ${RELEASE_TAG} yet" + sleep 10 + done + echo "::warning::GitHub Marketplace did not show ${RELEASE_TAG} within the retry window; verify manually." diff --git a/docs/distribution.md b/docs/distribution.md index 1fdd686..25c74cc 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -23,8 +23,24 @@ to install a published PyPI version. - Use Dependabot for Python and GitHub Actions updates. - Add a lockfile for release and dev dependency builds once packaging workflow is finalized. -PyPI Trusted Publishing is configured for this repository's tag-triggered -release workflow and protected `pypi` environment. +PyPI Trusted Publishing is configured for this repository's release workflow +and protected `pypi` environment. + +## Release Automation + +`.github/workflows/release.yml` supports two release paths: + +- Push an existing `v*` tag. +- Run the workflow manually with `workflow_dispatch` and a `version` input + such as `0.10.0`. + +The manual path waits for the protected `pypi` environment approval, validates +that `pyproject.toml` and `src/agents_shipgate/__init__.py` match the requested +version, confirms the tag does not already exist, creates an annotated +`vX.Y.Z` tag on the default branch, then publishes PyPI artifacts and creates +the GitHub release in the same run. Keeping tag creation and publication in one +workflow avoids relying on a second tag-triggered workflow run from a +`GITHUB_TOKEN` push. ## Marketplace And Site @@ -34,12 +50,15 @@ release workflow and protected `pypi` environment. ## Release Fan-Out Checklist -The tag-triggered release workflow publishes PyPI artifacts and creates the -GitHub release. After each release tag, verify the external surfaces that live -outside this repository: +The release workflow publishes PyPI artifacts and creates the GitHub release. +After each release tag, verify the external surfaces that live outside this +repository: - PyPI shows the new package version. -- GitHub Marketplace shows the new release tag as Latest. +- GitHub release exists for the new tag. +- GitHub Marketplace shows the new release tag as Latest. The workflow checks + this with retry/backoff and emits a warning if Marketplace has not refreshed + inside the retry window. - The website header, footer, `/llms.txt`, and `/.well-known/agents-shipgate.json` show the new version. - Website discovery metadata points at the current report schema and GitHub @@ -50,6 +69,6 @@ outside this repository: ## Marketplace Repository Notes The repository keeps a root `action.yml` for GitHub Marketplace publication and -a minimal `.github/workflows/ci.yml` for project validation plus a tag-triggered -release workflow. The action remains a composite action; there is no Docker -action entrypoint in the current release. +a minimal `.github/workflows/ci.yml` for project validation plus a release +workflow. The action remains a composite action; there is no Docker action +entrypoint in the current release. diff --git a/tests/test_action_metadata.py b/tests/test_action_metadata.py index 718ce17..105771d 100644 --- a/tests/test_action_metadata.py +++ b/tests/test_action_metadata.py @@ -151,3 +151,31 @@ def test_release_workflow_uses_release_security_steps(): assert "uv publish --trusted-publishing always" in text assert "sigstore sign" in text assert "cyclonedx-py environment" in text + + +def test_release_workflow_supports_manual_approved_tag_creation(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + + assert "workflow_dispatch:" in text + assert "version:" in text + assert "environment: pypi" in text + assert "Resolve release metadata" in text + assert "Validate release version and tag" in text + assert "pyproject.toml" in text + assert "src/agents_shipgate/__init__.py" in text + assert "Create annotated release tag" in text + assert 'git tag -a "${RELEASE_TAG}"' in text + assert 'git push origin "refs/tags/${RELEASE_TAG}"' in text + assert 'gh release create "${RELEASE_TAG}"' in text + assert "--verify-tag" in text + + +def test_release_workflow_verifies_external_release_surfaces(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + + assert "Verify GitHub release" in text + assert "Verify PyPI release" in text + assert "https://pypi.org/pypi/agents-shipgate/json" in text + assert "Check GitHub Marketplace listing" in text + assert "https://github.com/marketplace/actions/agents-shipgate" in text + assert "::warning::GitHub Marketplace did not show" in text