@@ -51,11 +51,13 @@ jobs:
5151 binary_source_tag : ${{ steps.r.outputs.binary_source_tag }}
5252 pypi_version : ${{ steps.r.outputs.pypi_version }}
5353 dry_run : ${{ steps.r.outputs.dry_run }}
54+ release_tag : ${{ steps.r.outputs.release_tag }}
5455 steps :
5556 # Need the working tree on disk so the resolve step can source the
56- # tag → PEP 440 conversion script (.github/scripts/tag-to-pep440.sh).
57- # That script is the single source of truth for the conversion,
58- # shared with the AAASM-2863 unit tests.
57+ # tag → PEP 440 conversion script (.github/scripts/tag-to-pep440.sh)
58+ # and its inverse (.github/scripts/pep440-to-tag.sh). Those scripts
59+ # are the single source of truth for the conversion, shared with the
60+ # AAASM-2863 / AAASM-2956 unit tests.
5961 - uses : actions/checkout@v6
6062 - id : r
6163 env :
@@ -124,12 +126,32 @@ jobs:
124126 echo "::error::pypi_version '$pypi_version' is not valid PEP 440 (use 0.0.1a8.post1 not 0.0.1-alpha.8.1)"
125127 exit 1
126128 fi
129+ # Resolve the canonical SemVer release tag the create-github-release
130+ # job will cut at the published version (AAASM-2956). The
131+ # repository_dispatch event already carries the canonical tag, so use
132+ # it verbatim. The workflow_dispatch path only has the PEP 440
133+ # pypi_version, so derive the tag from it via the single-source-of-
134+ # truth inverse converter. .post/.dev republish forms have no own
135+ # GitHub Release tag, so leave release_tag empty for those.
136+ release_tag=""
137+ if [[ "$dry_run" != "true" ]]; then
138+ if [[ "$EVENT_NAME" == "repository_dispatch" ]]; then
139+ release_tag="$DISPATCH_PAYLOAD_TAG"
140+ elif [[ "$pypi_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(a|b|rc)?[0-9]*$ ]]; then
141+ # shellcheck source=.github/scripts/pep440-to-tag.sh
142+ source "${GITHUB_WORKSPACE}/.github/scripts/pep440-to-tag.sh"
143+ release_tag="$(pep440_to_tag "$pypi_version")"
144+ else
145+ echo "::notice::pypi_version '$pypi_version' is a post/dev republish — no GitHub Release tag will be cut"
146+ fi
147+ fi
127148 {
128149 echo "binary_source_tag=${binary_source_tag}"
129150 echo "pypi_version=${pypi_version}"
130151 echo "dry_run=${dry_run}"
152+ echo "release_tag=${release_tag}"
131153 } >> "$GITHUB_OUTPUT"
132- echo "Resolved binary_source_tag=${binary_source_tag} pypi_version=${pypi_version} dry_run=${dry_run}"
154+ echo "Resolved binary_source_tag=${binary_source_tag} pypi_version=${pypi_version} dry_run=${dry_run} release_tag=${release_tag} "
133155
134156 build-sdist :
135157 name : Build sdist
@@ -415,6 +437,60 @@ jobs:
415437 uses : pypa/gh-action-pypi-publish@release/v1
416438 # No `with: password:` — Trusted Publisher uses OIDC, no token stored.
417439
440+ # Cut python-sdk's own GitHub Release at the just-published version so the
441+ # repo's release line tracks PyPI (and the README `github/v/release` badge
442+ # resolves) instead of drifting onto a separate source-of-truth tag like
443+ # v0.0.2a1. Runs only on the real-publish path, gated on `publish` so the
444+ # tag is never created for a wheel that failed to upload. See AAASM-2956.
445+ create-github-release :
446+ name : Cut GitHub Release at published version
447+ needs :
448+ - resolve
449+ - publish
450+ runs-on : ubuntu-latest
451+ # Real publish only, and only when resolve produced a SemVer tag (empty
452+ # for .post/.dev republishes, which do not get their own Release).
453+ if : needs.resolve.outputs.dry_run != 'true' && needs.resolve.outputs.release_tag != ''
454+ permissions :
455+ contents : write # create the git tag + GitHub Release
456+ steps :
457+ - uses : actions/checkout@v6
458+ - name : Create tag and GitHub Release
459+ env :
460+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
461+ # client_payload is attacker-controllable on repository_dispatch;
462+ # RELEASE_TAG was validated as a SemVer tag by the resolve job and
463+ # is only ever passed through env (never inlined into a run: body).
464+ RELEASE_TAG : ${{ needs.resolve.outputs.release_tag }}
465+ PYPI_VERSION : ${{ needs.resolve.outputs.pypi_version }}
466+ run : |
467+ set -euo pipefail
468+ # Defence in depth: re-validate the tag shape here so a future
469+ # change to resolve cannot smuggle an arbitrary string into the
470+ # tag/gh commands below.
471+ if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then
472+ echo "::error::release_tag '$RELEASE_TAG' is not a valid SemVer release tag"
473+ exit 1
474+ fi
475+ # Idempotent: skip if a Release already exists for this tag (e.g. a
476+ # re-run after a partial failure), so the job never errors on retry.
477+ if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
478+ echo "::notice::GitHub Release $RELEASE_TAG already exists — nothing to do"
479+ exit 0
480+ fi
481+ prerelease_flag=""
482+ if [[ "$RELEASE_TAG" =~ -(alpha|beta|rc)\. ]]; then
483+ prerelease_flag="--prerelease"
484+ fi
485+ # `gh release create` creates the annotated tag at the current
486+ # commit (the workflow ref) when the tag does not yet exist.
487+ gh release create "$RELEASE_TAG" \
488+ --repo "$GITHUB_REPOSITORY" \
489+ $prerelease_flag \
490+ --title "$RELEASE_TAG" \
491+ --notes "python-sdk agent-assembly==${PYPI_VERSION} — coordinated with agent-assembly ${RELEASE_TAG}."
492+ echo "Created GitHub Release $RELEASE_TAG (agent-assembly==${PYPI_VERSION})"
493+
418494 # Publish the *real* release tag as a tiny artifact so the documentation
419495 # workflow (which is triggered by `workflow_run` after this workflow
420496 # completes) can label the frozen docs snapshot with the human-facing tag
0 commit comments