From 6bdad1f3b2e31d38673146da362ca5dd4ddbb456 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 15 May 2026 13:21:48 +0530 Subject: [PATCH] ci: add PyPI publish workflow (salvaged from #25901) (#26148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(pypi): add publish workflow for automated PyPI releases Triggered by CalVer tag pushes from scripts/release.py (v20* pattern). Three jobs: build (uv build) → publish (OIDC trusted publishing) → sign (Sigstore + attach to existing GitHub Release). - workflow_dispatch as manual escape hatch - skip-existing for safe re-runs - Graceful skip when GitHub Release not found (sign job) - Top-level permissions: contents: read (CodeQL compliant) Requires one-time setup: PyPI trusted publisher + GitHub pypi environment. Co-authored-by: dmahan93 <44207705+dmahan93@users.noreply.github.com> * fix(release): address review findings - Stage acp_registry/agent.json in version bump commit (was silently left unstaged) - Add missing return when no previous tags found without --first-release - Fix get_pr_number return type annotation (str -> str | None) - Prefer uv build over python -m build (matches CI workflow), with fallback - Use unit separator (%x1f) in git log format to handle | in author names - Add explicit encoding='utf-8' to .release_notes.md write Workflow hardening: - Gracefully skip signing when GitHub Release not found (env var gate instead of exit 1, so PyPI publish still shows green) * fix(ci): harden PyPI workflow — SHA-pin actions, guard workflow_dispatch, explicit build flags - Pin all actions to commit SHAs (supply-chain hardening for id-token:write) - workflow_dispatch now requires confirm_tag input + checks out that tag - Both uv build paths explicitly pass --sdist --wheel --------- Co-authored-by: dmahan93 <44207705+dmahan93@users.noreply.github.com> --- .github/workflows/upload_to_pypi.yml | 137 +++++++++++++++++++++++++++ scripts/release.py | 36 ++++--- 2 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/upload_to_pypi.yml diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml new file mode 100644 index 00000000000..4e2fe4748d3 --- /dev/null +++ b/.github/workflows/upload_to_pypi.yml @@ -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 diff --git a/scripts/release.py b/scripts/release.py index d3118bc128e..53db4bbec2c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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, @@ -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()) @@ -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: hashauthor_nameauthor_emailsubject\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", ) @@ -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 "hashnameemailsubject" 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 @@ -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: @@ -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) @@ -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 @@ -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,