diff --git a/.github/workflows/publish-sdk.yaml b/.github/workflows/publish-sdk.yaml new file mode 100644 index 0000000..bc27b0d --- /dev/null +++ b/.github/workflows/publish-sdk.yaml @@ -0,0 +1,63 @@ +# This workflow is used to publish the Python SDK to the actual PyPI. +# It is triggered by a tag push, and will only publish if the tag is valid. +# The tag must match the format sdk-v*.*.* + +name: Publish Python SDK + +on: + push: + tags: + - "sdk-v*.*.*" # Trigger on version tags like sdk-v0.1.0 etc. + +jobs: + validate: + runs-on: ubuntu-latest + environment: production + outputs: + release_tag: ${{ steps.set_release_tag.outputs.release_tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for checking branch + - name: Set release tag + id: set_release_tag + # ensure the tag is valid (matches code, is on main, etc) + run: | + RELEASE_TAG=${GITHUB_REF#refs/tags/} + echo "Using tag: $RELEASE_TAG" + ./scripts/validate-release-tag.sh "$RELEASE_TAG" + echo "RELEASE_TAG=$RELEASE_TAG" >> $GITHUB_ENV + echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT + + build-and-publish: + needs: validate + runs-on: ubuntu-latest + environment: production + + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + RELEASE_TAG: ${{ needs.validate.outputs.release_tag }} + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install build dependencies + run: make install-build-deps + - name: Build + run: make build + - name: Test wheel + run: make test-wheel + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: sdk-dist + path: dist/ + retention-days: 5 + - name: Publish to PyPI + run: make _publish + env: + PYPI_REPO: pypi diff --git a/.github/workflows/release-tag.yaml b/.github/workflows/release-tag.yaml new file mode 100644 index 0000000..ae495c3 --- /dev/null +++ b/.github/workflows/release-tag.yaml @@ -0,0 +1,78 @@ +# This workflow creates and pushes a release tag using the push-release-tag.sh script. +# It can be triggered manually and will prompt for confirmation before creating the tag. + +name: Create Release Tag + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Run in dry-run mode (show what would be done without actually creating/pushing the tag)" + required: false + type: boolean + default: true + confirm_release: + description: "Type 'YES' to confirm you want to create and push the release tag" + required: true + type: string + +jobs: + check-branch: + runs-on: ubuntu-latest + environment: production + steps: + - name: Check if running on release branch + run: | + if [ "${{ github.ref }}" != "refs/heads/release" ]; then + echo "Error: This workflow can only be run from the 'release' branch." + echo "Current branch: ${{ github.ref }}" + echo "Please switch to the 'release' branch and try again." + exit 1 + fi + echo "Running on release branch - proceeding with workflow." + + create-release-tag: + runs-on: ubuntu-latest + needs: check-branch + environment: production + if: github.ref == 'refs/heads/release' + + permissions: + contents: write # Required to create and push tags + + steps: + - name: Validate confirmation + if: github.event.inputs.confirm_release != 'YES' && github.event.inputs.dry_run != 'true' + run: | + echo "Error: You must type 'YES' in the confirm_release input to proceed with creating a release tag." + echo "Received: '${{ github.event.inputs.confirm_release }}'" + exit 1 + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history and tags + + - name: Make scripts executable + run: | + chmod +x scripts/push-release-tag.sh + chmod +x scripts/get_version.sh + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Run push-release-tag script (dry-run) + if: github.event.inputs.dry_run == 'true' + run: | + echo "Running in dry-run mode..." + make push-release-tag DRY_RUN=--dry-run + + - name: Run push-release-tag script + if: github.event.inputs.dry_run != 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Creating and pushing release tag..." + # Override the interactive confirmation since we already confirmed via workflow input + echo "YES" | make push-release-tag diff --git a/.github/workflows/test-publish-sdk.yaml b/.github/workflows/test-publish-sdk.yaml new file mode 100644 index 0000000..9703970 --- /dev/null +++ b/.github/workflows/test-publish-sdk.yaml @@ -0,0 +1,41 @@ +# This workflow is used to publish the Python SDK to TestPyPI. Do not need to upgrade the +# version number to use this workflow. +# Only upgrade the version number when you are ready to publish to PyPi +# The script will automatically add an "rc" suffix to the version number for test.pypi.org releases. + +name: Publish Python SDK to TestPyPI + +on: + workflow_dispatch: + inputs: + ref: + description: "Publish the given Git ref to test.pypi.org (branch, tag, or commit SHA)" + required: true + type: string + default: "main" + +jobs: + build-and-publish-test: + runs-on: ubuntu-latest + + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + PYPI_REPO: testpypi + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref }} + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install build dependencies + run: make install-build-deps + - name: Build + run: make build + - name: Test wheel + run: make test-wheel + - name: Publish to TestPyPI + run: make _publish diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e511695 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +install-build-deps: + pip install build twine + +build: clean _template-version + python -m build + # Restore the original version file after the build + git checkout src/layerlens/_version.py + +test-wheel: + pip install dist/*.whl + python -c "import layerlens; print('Package imported successfully')" + +clean: + rm -rf build dist + +_publish: + ./scripts/publish.sh + +_template-version: + @bash scripts/template-version.sh + +_check-git-clean: + @if [ -n "$$(git status --porcelain)" ]; then \ + echo "Error: Git working directory is not clean. Won't run publish."; \ + exit 1; \ + fi + +_verify-build-publish: _check-git-clean build test-wheel _publish + +publish-to-testpypi: export PYPI_REPO := testpypi +publish-to-testpypi: _verify-build-publish + +publish-to-pypi: export PYPI_REPO := pypi +publish-to-pypi: _verify-build-publish + +push-release-tag: + @bash scripts/push-release-tag.sh $(DRY_RUN) + +help: + @echo "Available targets:" + @echo " build - Build Python package" + @echo " clean - Remove build artifacts" + @echo " help - Show this help message" + @echo " install-build-deps - Install build dependencies for CI" + @echo " test-wheel - Run tests against built wheel" + @echo " publish-to-pypi - Publish to PyPI" + @echo " publish-to-testpypi - Publish to TestPyPI" + @echo " push-release-tag - Create and push a release tag" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ef86a87..f5efccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,14 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/layerlens/_version.py" +pattern = '__version__ = "(?P[^"]+)"' + [project] name = "layerlens" -version = "1.2.0" +dynamic = ["version"] description = "The official Python library for the LayerLens Stratix API" license = "Apache-2.0" authors = [{ name = "LayerLens", email = "support@layerlens.ai" }] @@ -30,7 +38,6 @@ Repository = "https://github.com/LayerLens/stratix-python" [project.scripts] layerlens = "layerlens.cli:main" - [tool.rye] managed = true # version pins are in requirements-dev.lock @@ -41,6 +48,8 @@ dev-dependencies = [ "pytest-cov>=6.2.1", "ruff", "types-requests", + "build", + "twine==6.1.0", ] [tool.rye.scripts] diff --git a/requirements-dev.lock b/requirements-dev.lock index 9c0f730..2aaa85b 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -4,7 +4,7 @@ # last locked with the following flags: # pre: false # features: [] -# all-features: false +# all-features: true # with-sources: false # generate-hashes: false # universal: false @@ -14,6 +14,9 @@ annotated-types==0.7.0 # via pydantic anyio==4.9.0 # via httpx +backports-tarfile==1.2.0 + # via jaraco-context +build==1.3.0 certifi==2025.7.14 # via httpcore # via httpx @@ -22,6 +25,8 @@ charset-normalizer==3.4.3 # via requests coverage==7.10.2 # via pytest-cov +docutils==0.22 + # via readme-renderer exceptiongroup==1.3.0 # via anyio # via pytest @@ -30,44 +35,86 @@ h11==0.16.0 httpcore==1.0.9 # via httpx httpx==0.28.1 - # via atlas + # via test-atlas-lzok +id==1.5.0 + # via twine idna==3.10 # via anyio # via httpx # via requests +importlib-metadata==8.7.0 + # via build + # via keyring + # via twine iniconfig==2.1.0 # via pytest +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.2.1 + # via keyring +keyring==25.6.0 + # via twine +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.7.0 + # via jaraco-classes + # via jaraco-functools mypy==1.17.0 mypy-extensions==1.1.0 # via mypy +nh3==0.3.0 + # via readme-renderer nodeenv==1.9.1 # via pyright packaging==25.0 + # via build # via pytest + # via twine pathspec==0.12.1 # via mypy pluggy==1.6.0 # via pytest # via pytest-cov pydantic==2.11.7 - # via atlas + # via test-atlas-lzok pydantic-core==2.33.2 # via pydantic pygments==2.19.2 # via pytest + # via readme-renderer + # via rich +pyproject-hooks==1.2.0 + # via build pyright==1.1.399 pytest==8.4.1 # via pytest-cov pytest-cov==6.2.1 +readme-renderer==44.0 + # via twine requests==2.32.5 - # via atlas + # via id + # via layerlens + # via requests-toolbelt + # via twine +requests-toolbelt==1.0.0 + # via twine +rfc3986==2.0.0 + # via twine +rich==14.1.0 + # via twine ruff==0.12.7 sniffio==1.3.1 # via anyio tomli==2.2.1 + # via build # via coverage # via mypy # via pytest +twine==6.1.0 types-requests==2.32.4.20250809 typing-extensions==4.14.1 # via anyio @@ -81,4 +128,7 @@ typing-inspection==0.4.1 # via pydantic urllib3==2.5.0 # via requests + # via twine # via types-requests +zipp==3.23.0 + # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 887d3cc..540f4d6 100644 --- a/requirements.lock +++ b/requirements.lock @@ -4,7 +4,7 @@ # last locked with the following flags: # pre: false # features: [] -# all-features: false +# all-features: true # with-sources: false # generate-hashes: false # universal: false @@ -27,13 +27,13 @@ h11==0.16.0 httpcore==1.0.9 # via httpx httpx==0.28.1 - # via atlas + # via test-atlas-lzok idna==3.10 # via anyio # via httpx # via requests pydantic==2.11.7 - # via atlas + # via test-atlas-lzok pydantic-core==2.33.2 # via pydantic requests==2.32.5 diff --git a/scripts/get_version.sh b/scripts/get_version.sh index 42caa27..04ab2da 100755 --- a/scripts/get_version.sh +++ b/scripts/get_version.sh @@ -6,24 +6,16 @@ set -e ROOT_DIR=$(git rev-parse --show-toplevel) VERSION_FILE="$ROOT_DIR/src/layerlens/_version.py" -echo "Debug: ROOT_DIR=$ROOT_DIR" >&2 -echo "Debug: VERSION_FILE=$VERSION_FILE" >&2 - if [ ! -f "$VERSION_FILE" ]; then echo "Error: Version file not found at $VERSION_FILE" >&2 exit 1 fi -echo "Debug: File exists, content:" >&2 -cat "$VERSION_FILE" >&2 - VERSION=$(grep -E '^__version__\s*=' "$VERSION_FILE" | grep -o '".*"' | tr -d '"') -echo "Debug: Extracted version='$VERSION'" >&2 - if [ -z "$VERSION" ]; then echo "Error: Could not extract version from $VERSION_FILE" >&2 exit 1 fi -echo "$VERSION" \ No newline at end of file +echo "$VERSION" diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..e6ac0f7 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Publish the package to PyPI or TestPyPI depending +# on the PYPI_REPO (pypi | testpypi) environment variable + +if [ -z "$PYPI_REPO" ]; then + echo "Error: PYPI_REPO environment variable must be set" + exit 1 +fi + +if [ "$PYPI_REPO" != "pypi" ] && [ "$PYPI_REPO" != "testpypi" ]; then + echo "Error: PYPI_REPO must be either 'pypi' or 'testpypi'" + exit 1 +fi + +VERSION=$(bash scripts/get_version.sh) + +if [ -z "$VERSION" ]; then + echo "Error: Could not determine version" + exit 1 +fi + +echo "Publishing version $VERSION to $PYPI_REPO" + +twine upload --repository "$PYPI_REPO" dist/* \ No newline at end of file diff --git a/scripts/push-release-tag.sh b/scripts/push-release-tag.sh new file mode 100755 index 0000000..64ba944 --- /dev/null +++ b/scripts/push-release-tag.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR=$(git rev-parse --show-toplevel) + +# Parse command line arguments +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=true + shift + ;; + *) + echo "Unknown option: $1" + echo "Usage: $0 [--dry-run]" + exit 1 + ;; + esac +done + +git fetch --tags --prune + +REPO_URL="https://github.com/LayerLens/atlas-python" +TAG_PREFIX="sdk-v" +COMMIT=$(git rev-parse --short HEAD) +VERSION=$(bash "$ROOT_DIR/scripts/get_version.sh") +TAG="${TAG_PREFIX}${VERSION}" + +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Error: Tag $TAG already exists" + exit 1 +fi + +# Find the most recent version tag +LAST_RELEASE=$(git tag -l "${TAG_PREFIX}*" --sort=-v:refname | head -n 1) + +echo "================================================" +echo " Atlas Python SDK Release" +echo "================================================" +echo "version: ${TAG}" +echo "commit: ${COMMIT}" +echo "code: ${REPO_URL}/commit/${COMMIT}" +echo "changeset: ${REPO_URL}/compare/${LAST_RELEASE}...${COMMIT}" + +if [ "$DRY_RUN" = true ]; then + exit 0 +fi + +echo "" +echo "" +echo "Are you ready to release version ${VERSION}? Type 'YES' to continue:" +read -r CONFIRMATION + +if [ "$CONFIRMATION" != "YES" ]; then + echo "Release cancelled." + exit 1 +fi + +# Create and push the tag +echo "" +echo "Creating and pushing tag ${TAG}" +echo "" + +git tag "$TAG" "$COMMIT" +git push origin "$TAG" + +echo "" +echo "Tag ${TAG} has been created and pushed to origin. Check GitHub Actions for build progress:" +echo "https://github.com/LayerLens/atlas-python/actions/workflows/publish-sdk.yaml" +echo "" \ No newline at end of file diff --git a/scripts/template-version.sh b/scripts/template-version.sh new file mode 100644 index 0000000..d3d8b84 --- /dev/null +++ b/scripts/template-version.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -e + +VERSION_FILE="src/layerlens/_version.py" + +GIT_COMMIT=$(git rev-parse HEAD) + +sed_inplace() { + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "$@" + else + sed -i "$@" + fi +} + +# Update git commit hash +sed_inplace "s/__GIT_COMMIT__/$GIT_COMMIT/g" "$VERSION_FILE" + +# Get current version +CURRENT_VERSION=$(grep '__version__ = ' "$VERSION_FILE" | cut -d'"' -f2) + +# If we're uploading to testpypi, add a run number to the version so we can +# test multiple times. +if [[ "$PYPI_REPO" == "testpypi" ]] && [[ -n "$GITHUB_RUN_NUMBER" ]]; then + NEW_VERSION="${CURRENT_VERSION}rc${GITHUB_RUN_NUMBER}" + sed_inplace "s/__version__ = \".*\"/__version__ = \"$NEW_VERSION\"/" "$VERSION_FILE" +fi diff --git a/scripts/validate-release-tag.sh b/scripts/validate-release-tag.sh new file mode 100755 index 0000000..175464e --- /dev/null +++ b/scripts/validate-release-tag.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Validate release requirements +# - Checks if the tag matches naming convention (sdk-v*.*.*) +# - Checks if the tag matches the version in the package +# - Ensures we're releasing from the release branch + +set -e + +# Get the tag from the first command line argument +if [ $# -eq 0 ]; then + echo "ERROR: Release tag argument not provided" + echo "Usage: $0 " + exit 1 +fi + +ROOT_DIR=$(git rev-parse --show-toplevel) + +# Fetch the latest tags to ensure we're up to date +git fetch --tags --prune --force + +TAG=$1 + +# Check if tag starts with sdk-v +if [[ ! "$TAG" =~ ^sdk-v ]]; then + echo "ERROR: Tag must start with 'sdk-v'" + exit 1 +fi + +# Extract version without the 'sdk-v' prefix +VERSION=${TAG#sdk-v} + +PACKAGE_VERSION=$(bash "$ROOT_DIR/scripts/get_version.sh") + +# Check if the tag version matches the package version +if [ "$VERSION" != "$PACKAGE_VERSION" ]; then + echo "ERROR: Tag version ($VERSION) does not match package version ($PACKAGE_VERSION)" + exit 1 +fi + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [ "$CURRENT_BRANCH" != "release" ]; then + # If we're in detached HEAD state (which is likely in GitHub Actions with a tag), + # we need to check if the tag is on the release branch + if ! git rev-parse "$TAG" &>/dev/null; then + echo "ERROR: Tag $TAG does not exist in the repository" + exit 1 + fi + + TAG_COMMIT=$(git rev-parse "$TAG") + + # Ensure we have release branch history + git fetch origin release --depth=1000 + + # Check if tag is on release branch + if ! git merge-base --is-ancestor "$TAG_COMMIT" origin/release; then + echo "ERROR: Tag $TAG is not on the release branch" + exit 1 + fi +fi + +# All checks passed +exit 0 \ No newline at end of file diff --git a/src/layerlens/_version.py b/src/layerlens/_version.py index c68196d..8fb65ee 100644 --- a/src/layerlens/_version.py +++ b/src/layerlens/_version.py @@ -1 +1,4 @@ __version__ = "1.2.0" + +# Will be templated during the build +__git_commit__ = "__GIT_COMMIT__"