Skip to content
Merged
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
137 changes: 137 additions & 0 deletions .github/workflows/upload_to_pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: Publish to PyPI

# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15)
# Can also be triggered manually from the Actions tab as an escape hatch.
on:
push:
tags:
- 'v20*' # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
workflow_dispatch:
inputs:
confirm_tag:
description: 'Tag to publish (e.g. v2026.5.15). Must already exist.'
required: true
type: string

# Restrict default token to read-only; each job escalates as needed.
permissions:
contents: read

# Prevent overlapping publishes (e.g. two same-day tags pushed quickly).
concurrency:
group: pypi-publish
cancel-in-progress: false

jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
ref: ${{ inputs.confirm_tag || github.ref }}
fetch-tags: true

- name: Validate tag exists
if: github.event_name == 'workflow_dispatch'
run: |
if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then
echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo"
exit 1
fi

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.13'

- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6

- name: Build wheel and sdist
run: uv build --sdist --wheel

- name: Upload distribution artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: python-package-distributions
path: dist/

publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/hermes-agent
permissions:
id-token: write # OIDC trusted publishing

steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true

sign:
name: Sign and attach to GitHub Release
# Only runs on tag pushes — release.py creates the GitHub Release,
# and workflow_dispatch won't have a matching release to attach to.
if: startsWith(github.ref, 'refs/tags/')
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write # attach assets to the existing release
id-token: write # sigstore signing

steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/

- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ github.token }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
for i in $(seq 1 30); do
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME found"
exit 0
fi
echo "Waiting for release... ($i/30)"
sleep 10
done
echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload"
echo "skip_sign=true" >> "$GITHUB_ENV"

- name: Sign with Sigstore
if: env.skip_sign != 'true'
uses: sigstore/gh-action-sigstore-python@f514d46b907ebcd5bedc05145c03b69c1edd8b46 # v3.0.0
with:
inputs: >-
./dist/*.tar.gz
./dist/*.whl

- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
gh release upload
"$GITHUB_REF_NAME" dist/*.sigstore.json
--repo "$GITHUB_REPOSITORY"
--clobber
36 changes: 23 additions & 13 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -1188,15 +1188,21 @@ def _update_acp_registry_versions(semver: str) -> None:
def build_release_artifacts(semver: str) -> list[Path]:
"""Build sdist/wheel artifacts for the current release.

Returns the artifact paths when the local environment has ``python -m build``
available. If build tooling is missing or the build fails, returns an empty
list and lets the release proceed without attached Python artifacts.
Tries ``uv build`` first (matching the CI workflow), falls back to
``python -m build`` if uv is unavailable.
"""
dist_dir = REPO_ROOT / "dist"
shutil.rmtree(dist_dir, ignore_errors=True)

# Prefer uv build (matches CI workflow), fall back to python -m build.
uv_bin = shutil.which("uv")
if uv_bin:
cmd = [uv_bin, "build", "--sdist", "--wheel"]
else:
cmd = [sys.executable, "-m", "build", "--sdist", "--wheel"]

result = subprocess.run(
[sys.executable, "-m", "build", "--sdist", "--wheel"],
cmd,
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
Expand All @@ -1209,7 +1215,7 @@ def build_release_artifacts(semver: str) -> list[Path]:
print(f" {stderr.splitlines()[-1]}")
elif stdout:
print(f" {stdout.splitlines()[-1]}")
print(" Install the 'build' package to attach semver-named sdist/wheel assets.")
print(" Install uv or the 'build' package to attach sdist/wheel assets.")
return []

artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file())
Expand Down Expand Up @@ -1316,11 +1322,11 @@ def get_commits(since_tag=None):
else:
range_spec = "HEAD"

# Format: hash|author_name|author_email|subject\0body
# Using %x00 (null) as separator between subject and body
# Format: hash<US>author_name<US>author_email<US>subject\0body
# Using %x1f (unit separator) to avoid conflict with | in author names
log = git(
"log", range_spec,
"--format=%H|%an|%ae|%s%x00%b%x00",
"--format=%H%x1f%an%x1f%ae%x1f%s%x00%b%x00",
"--no-merges",
)

Expand All @@ -1334,14 +1340,14 @@ def get_commits(since_tag=None):
entry = entry.strip()
if not entry:
continue
# Split on first null to separate "hash|name|email|subject" from "body"
# Split on first null to separate "hash<US>name<US>email<US>subject" from "body"
if "\0" in entry:
header, body = entry.split("\0", 1)
body = body.strip()
else:
header = entry
body = ""
parts = header.split("|", 3)
parts = header.split("\x1f", 3)
if len(parts) != 4:
continue
sha, name, email, subject = parts
Expand All @@ -1361,7 +1367,7 @@ def get_commits(since_tag=None):
return commits


def get_pr_number(subject: str) -> str:
def get_pr_number(subject: str) -> str | None:
"""Extract PR number from commit subject if present."""
match = re.search(r"#(\d+)", subject)
if match:
Expand Down Expand Up @@ -1512,6 +1518,7 @@ def main():
print("No previous tags found. Use --first-release for the initial release.")
print(f"Would create tag: {tag_name}")
print(f"Would set version: {new_version}")
return

# Get commits
commits = get_commits(since_tag=prev_tag)
Expand Down Expand Up @@ -1556,7 +1563,10 @@ def main():
print(f" ✓ Updated version files to v{new_version} ({calver_date})")

# Commit version bump
add_result = git_result("add", str(VERSION_FILE), str(PYPROJECT_FILE))
add_files = [str(VERSION_FILE), str(PYPROJECT_FILE)]
if ACP_REGISTRY_MANIFEST.exists():
add_files.append(str(ACP_REGISTRY_MANIFEST))
add_result = git_result("add", *add_files)
if add_result.returncode != 0:
print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}")
return
Expand Down Expand Up @@ -1598,7 +1608,7 @@ def main():

# Create GitHub release
changelog_file = REPO_ROOT / ".release_notes.md"
changelog_file.write_text(changelog)
changelog_file.write_text(changelog, encoding="utf-8")

gh_cmd = [
"gh", "release", "create", tag_name,
Expand Down
Loading