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
53 changes: 53 additions & 0 deletions .github/scripts/pep440-to-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
#
# Convert a PEP 440 version string to the canonical SemVer release tag.
# Inverse of tag-to-pep440.sh β€” used by release-python.yml to cut the
# python-sdk's own GitHub Release at the published version.
#
# Examples:
# 0.0.1 -> v0.0.1
# 0.0.1a8 -> v0.0.1-alpha.8
# 0.0.1b2 -> v0.0.1-beta.2
# 0.0.1rc3 -> v0.0.1-rc.3
#
# Usage (CLI):
# .github/scripts/pep440-to-tag.sh 0.0.1b1
#
# Usage (library β€” preferred from CI/workflows):
# source .github/scripts/pep440-to-tag.sh
# tag="$(pep440_to_tag "0.0.1b1")"
#
# This is the single source of truth for the reverse conversion. The
# release-python.yml `resolve` job sources this file so the workflow and
# the AAASM-2956 fixture suite exercise the exact same code path.
#
# Owner: python-sdk release pipeline (AAASM-2956).

# Conversion function. Reads the PEP 440 version from $1 and prints the
# SemVer tag (with a leading `v`) to stdout. Returns non-zero only if the
# input is empty. `.post`/`.dev` suffixes are not valid SemVer pre-release
# identifiers in this scheme, so they are rejected β€” those are PyPI-only
# republish forms that do not correspond to a new GitHub Release tag.
pep440_to_tag() {
local version="${1-}"
if [[ -z "$version" ]]; then
echo "pep440_to_tag: missing version argument" >&2
return 1
fi
# Strip any leading `v` the caller may have passed by mistake.
local stripped="${version#v}"
# Expand the PEP 440 pre-release shorthand (a/b/rc) back to the
# hyphenated SemVer form. Anchored on the digits immediately following
# the marker so we never touch the base x.y.z component.
local tag
tag="$(printf '%s\n' "$stripped" | sed -E 's/a([0-9]+)$/-alpha.\1/; s/b([0-9]+)$/-beta.\1/; s/rc([0-9]+)$/-rc.\1/')"
printf 'v%s\n' "$tag"
}

# When executed directly (not sourced), behave as a CLI: take the version
# as $1 and print the converted tag. When sourced, define the function
# only and let the caller drive it.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set -euo pipefail
pep440_to_tag "${1-}"
fi
101 changes: 101 additions & 0 deletions .github/scripts/test_pep440_to_tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
#
# Unit tests for `.github/scripts/pep440-to-tag.sh`.
#
# Exercises the conversion function that release-python.yml's `resolve`
# job sources to turn a published PEP 440 version back into the canonical
# SemVer release tag the create-github-release job cuts. Because the
# workflow sources the same file, these tests cover the actual code path
# CI runs, not a copy of the regex.
#
# Run locally:
# bash .github/scripts/test_pep440_to_tag.sh
#
# Run from CI: see .github/workflows/release-python-conversion-test.yml.
#
# Exits 0 when every fixture passes, 1 otherwise.
#
# Refs AAASM-2956.

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=.github/scripts/pep440-to-tag.sh
source "${SCRIPT_DIR}/pep440-to-tag.sh"
# shellcheck source=.github/scripts/tag-to-pep440.sh
source "${SCRIPT_DIR}/tag-to-pep440.sh"

pass=0
fail=0
total=0

# assert_eq <label> <expected> <actual>
assert_eq() {
local label="$1"
local expected="$2"
local actual="$3"
total=$((total + 1))
if [[ "$expected" == "$actual" ]]; then
printf 'OK %-50s -> %s\n' "$label" "$actual"
pass=$((pass + 1))
else
printf 'FAIL %-50s expected=%q actual=%q\n' "$label" "$expected" "$actual" >&2
fail=$((fail + 1))
fi
}

# check <pep440> <expected_tag>
check() {
local version="$1"
local expected="$2"
local actual
actual="$(pep440_to_tag "$version")"
assert_eq "$version" "$expected" "$actual"
}

echo "== pep440_to_tag fixtures =="

# Stable form (no pre-release suffix).
check "0.0.1" "v0.0.1"

# Alpha pre-release, single + double digit.
check "0.0.1a1" "v0.0.1-alpha.1"
check "0.0.1a10" "v0.0.1-alpha.10"

# Beta β€” the version this ticket backfills (0.0.1b1 -> v0.0.1-beta.1).
check "0.0.1b1" "v0.0.1-beta.1"
check "0.0.1b2" "v0.0.1-beta.2"

# Release candidate.
check "0.0.1rc3" "v0.0.1-rc.3"

# Multi-digit base components + large pre-release counter β€” guard against
# greedy matching clobbering the minor/patch numbers.
check "1.23.456a789" "v1.23.456-alpha.789"

# A stray leading "v" the caller passed by mistake is tolerated.
check "v0.0.1b1" "v0.0.1-beta.1"

echo "== roundtrip with tag_to_pep440 =="
# Every canonical tag must survive a tag -> pep440 -> tag roundtrip.
for tag in v0.0.1 v0.0.1-alpha.8 v0.0.1-beta.1 v0.0.1-rc.3 v1.23.456-alpha.789; do
pep="$(tag_to_pep440 "$tag")"
back="$(pep440_to_tag "$pep")"
assert_eq "roundtrip ${tag}" "$tag" "$back"
done

echo "== empty-input guard =="
total=$((total + 1))
if err="$(pep440_to_tag "" 2>&1 >/dev/null)"; then
printf 'FAIL empty input should have exited non-zero (stderr=%q)\n' "$err" >&2
fail=$((fail + 1))
else
printf 'OK empty input rejected (stderr=%q)\n' "$err"
pass=$((pass + 1))
fi

echo
echo "Summary: ${pass} passed, ${fail} failed, ${total} total"
if (( fail > 0 )); then
exit 1
fi
10 changes: 9 additions & 1 deletion .github/workflows/release-python-conversion-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ on:
paths:
- ".github/scripts/tag-to-pep440.sh"
- ".github/scripts/test_tag_to_pep440.sh"
- ".github/scripts/pep440-to-tag.sh"
- ".github/scripts/test_pep440_to_tag.sh"
- ".github/workflows/release-python.yml"
- ".github/workflows/release-python-conversion-test.yml"
pull_request:
Expand All @@ -23,6 +25,8 @@ on:
paths:
- ".github/scripts/tag-to-pep440.sh"
- ".github/scripts/test_tag_to_pep440.sh"
- ".github/scripts/pep440-to-tag.sh"
- ".github/scripts/test_pep440_to_tag.sh"
- ".github/workflows/release-python.yml"
- ".github/workflows/release-python-conversion-test.yml"

Expand All @@ -43,5 +47,9 @@ jobs:
run: |
shellcheck .github/scripts/tag-to-pep440.sh
shellcheck -x .github/scripts/test_tag_to_pep440.sh
- name: Run fixture suite
shellcheck .github/scripts/pep440-to-tag.sh
shellcheck -x .github/scripts/test_pep440_to_tag.sh
- name: Run tag β†’ PEP 440 fixture suite
run: bash .github/scripts/test_tag_to_pep440.sh
- name: Run PEP 440 β†’ tag fixture suite
run: bash .github/scripts/test_pep440_to_tag.sh
84 changes: 80 additions & 4 deletions .github/workflows/release-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ jobs:
binary_source_tag: ${{ steps.r.outputs.binary_source_tag }}
pypi_version: ${{ steps.r.outputs.pypi_version }}
dry_run: ${{ steps.r.outputs.dry_run }}
release_tag: ${{ steps.r.outputs.release_tag }}
steps:
# Need the working tree on disk so the resolve step can source the
# tag β†’ PEP 440 conversion script (.github/scripts/tag-to-pep440.sh).
# That script is the single source of truth for the conversion,
# shared with the AAASM-2863 unit tests.
# tag β†’ PEP 440 conversion script (.github/scripts/tag-to-pep440.sh)
# and its inverse (.github/scripts/pep440-to-tag.sh). Those scripts
# are the single source of truth for the conversion, shared with the
# AAASM-2863 / AAASM-2956 unit tests.
- uses: actions/checkout@v6
- id: r
env:
Expand Down Expand Up @@ -124,12 +126,32 @@ jobs:
echo "::error::pypi_version '$pypi_version' is not valid PEP 440 (use 0.0.1a8.post1 not 0.0.1-alpha.8.1)"
exit 1
fi
# Resolve the canonical SemVer release tag the create-github-release
# job will cut at the published version (AAASM-2956). The
# repository_dispatch event already carries the canonical tag, so use
# it verbatim. The workflow_dispatch path only has the PEP 440
# pypi_version, so derive the tag from it via the single-source-of-
# truth inverse converter. .post/.dev republish forms have no own
# GitHub Release tag, so leave release_tag empty for those.
release_tag=""
if [[ "$dry_run" != "true" ]]; then
if [[ "$EVENT_NAME" == "repository_dispatch" ]]; then
release_tag="$DISPATCH_PAYLOAD_TAG"
elif [[ "$pypi_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(a|b|rc)?[0-9]*$ ]]; then
# shellcheck source=.github/scripts/pep440-to-tag.sh
source "${GITHUB_WORKSPACE}/.github/scripts/pep440-to-tag.sh"
release_tag="$(pep440_to_tag "$pypi_version")"
else
echo "::notice::pypi_version '$pypi_version' is a post/dev republish β€” no GitHub Release tag will be cut"
fi
fi
{
echo "binary_source_tag=${binary_source_tag}"
echo "pypi_version=${pypi_version}"
echo "dry_run=${dry_run}"
echo "release_tag=${release_tag}"
} >> "$GITHUB_OUTPUT"
echo "Resolved binary_source_tag=${binary_source_tag} pypi_version=${pypi_version} dry_run=${dry_run}"
echo "Resolved binary_source_tag=${binary_source_tag} pypi_version=${pypi_version} dry_run=${dry_run} release_tag=${release_tag}"

build-sdist:
name: Build sdist
Expand Down Expand Up @@ -415,6 +437,60 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1
# No `with: password:` β€” Trusted Publisher uses OIDC, no token stored.

# Cut python-sdk's own GitHub Release at the just-published version so the
# repo's release line tracks PyPI (and the README `github/v/release` badge
# resolves) instead of drifting onto a separate source-of-truth tag like
# v0.0.2a1. Runs only on the real-publish path, gated on `publish` so the
# tag is never created for a wheel that failed to upload. See AAASM-2956.
create-github-release:
name: Cut GitHub Release at published version
needs:
- resolve
- publish
runs-on: ubuntu-latest
# Real publish only, and only when resolve produced a SemVer tag (empty
# for .post/.dev republishes, which do not get their own Release).
if: needs.resolve.outputs.dry_run != 'true' && needs.resolve.outputs.release_tag != ''
permissions:
contents: write # create the git tag + GitHub Release
steps:
- uses: actions/checkout@v6
- name: Create tag and GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# client_payload is attacker-controllable on repository_dispatch;
# RELEASE_TAG was validated as a SemVer tag by the resolve job and
# is only ever passed through env (never inlined into a run: body).
RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }}
PYPI_VERSION: ${{ needs.resolve.outputs.pypi_version }}
run: |
set -euo pipefail
# Defence in depth: re-validate the tag shape here so a future
# change to resolve cannot smuggle an arbitrary string into the
# tag/gh commands below.
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then
echo "::error::release_tag '$RELEASE_TAG' is not a valid SemVer release tag"
exit 1
fi
# Idempotent: skip if a Release already exists for this tag (e.g. a
# re-run after a partial failure), so the job never errors on retry.
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::notice::GitHub Release $RELEASE_TAG already exists β€” nothing to do"
exit 0
fi
prerelease_flag=""
if [[ "$RELEASE_TAG" =~ -(alpha|beta|rc)\. ]]; then
prerelease_flag="--prerelease"
fi
# `gh release create` creates the annotated tag at the current
# commit (the workflow ref) when the tag does not yet exist.
gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
$prerelease_flag \
--title "$RELEASE_TAG" \
--notes "python-sdk agent-assembly==${PYPI_VERSION} β€” coordinated with agent-assembly ${RELEASE_TAG}."
echo "Created GitHub Release $RELEASE_TAG (agent-assembly==${PYPI_VERSION})"

# Publish the *real* release tag as a tiny artifact so the documentation
# workflow (which is triggered by `workflow_run` after this workflow
# completes) can label the frozen docs snapshot with the human-facing tag
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[![CI](https://img.shields.io/github/actions/workflow/status/ai-agent-assembly/python-sdk/ci.yaml?branch=master&logo=githubactions&logoColor=white&label=CI)](https://github.com/ai-agent-assembly/python-sdk/actions/workflows/ci.yaml)
[![Docs](https://img.shields.io/github/actions/workflow/status/ai-agent-assembly/python-sdk/documentation.yaml?branch=master&logo=readthedocs&logoColor=white&label=docs)](https://github.com/ai-agent-assembly/python-sdk/actions/workflows/documentation.yaml)
[![PyPI version](https://img.shields.io/pypi/v/agent-assembly?logo=pypi&logoColor=white)](https://pypi.org/project/agent-assembly/)
[![GitHub release](https://img.shields.io/github/v/release/ai-agent-assembly/python-sdk?include_prereleases&sort=semver&label=release&logo=github)](https://github.com/ai-agent-assembly/python-sdk/releases)
[![Python versions](https://img.shields.io/pypi/pyversions/agent-assembly?logo=python&logoColor=white)](https://pypi.org/project/agent-assembly/)
[![Coverage](https://img.shields.io/codecov/c/github/ai-agent-assembly/python-sdk?logo=codecov&logoColor=white)](https://codecov.io/gh/ai-agent-assembly/python-sdk)
[![Quality Gate](https://img.shields.io/sonar/quality_gate/AI-agent-assembly_python-sdk?server=https%3A%2F%2Fsonarcloud.io&logo=sonarcloud)](https://sonarcloud.io/project/overview?id=AI-agent-assembly_python-sdk)
Expand Down