Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 159 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 .
Expand All @@ -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."
37 changes: 28 additions & 9 deletions docs/distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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.
28 changes: 28 additions & 0 deletions tests/test_action_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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