From 51bd58bf42a076ace0a50acafdb460165da54636 Mon Sep 17 00:00:00 2001 From: Deeptendu Santra Date: Wed, 24 Jun 2026 14:07:26 +0530 Subject: [PATCH] feat(ci): add manual Python release workflow --- .github/workflows/release-context-engine.yml | 262 ++++++++++++++++ .github/workflows/release-potpie.yml | 286 +++++++++++++++++ .github/workflows/release-python.yml | 63 ++++ scripts/validate_python_release.py | 305 +++++++++++++++++++ 4 files changed, 916 insertions(+) create mode 100644 .github/workflows/release-context-engine.yml create mode 100644 .github/workflows/release-potpie.yml create mode 100644 .github/workflows/release-python.yml create mode 100644 scripts/validate_python_release.py diff --git a/.github/workflows/release-context-engine.yml b/.github/workflows/release-context-engine.yml new file mode 100644 index 000000000..1ef8da451 --- /dev/null +++ b/.github/workflows/release-context-engine.yml @@ -0,0 +1,262 @@ +name: Release potpie-context-engine + +on: + workflow_call: + inputs: + publish_target: + description: "Where to publish the verified artifacts." + required: true + type: string + confirm_publish: + description: "Required literal value 'publish' when publish_target=pypi." + required: false + default: "" + type: string + allow_python_release_tag: + description: "Allow the aggregate python-v tag when called by the orchestrator." + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + publish_target: + description: "Where to publish the verified artifacts." + required: true + default: build-only + type: choice + options: + - build-only + - testpypi + - pypi + confirm_publish: + description: "Required literal value 'publish' only when publish_target=pypi." + required: false + type: string + +permissions: + contents: read + +concurrency: + group: context-engine-release-${{ inputs.publish_target }}-${{ github.ref_name }} + cancel-in-progress: false + +env: + PYTHONUNBUFFERED: "1" + +jobs: + preflight: + name: Validate context-engine release + runs-on: ubuntu-latest + outputs: + context_engine_version: ${{ steps.validate.outputs.context_engine_version }} + channel: ${{ steps.validate.outputs.channel }} + metadata_path: ${{ steps.validate.outputs.metadata_path }} + steps: + # actions/checkout@v4 + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + # actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.13" + + - name: Install validation dependencies + run: python -m pip install --upgrade packaging + + - name: Validate release metadata + id: validate + run: >- + python scripts/validate_python_release.py + --package context-engine + --publish-target "${{ inputs.publish_target }}" + --confirm-publish "${{ inputs.confirm_publish }}" + ${{ inputs.allow_python_release_tag && '--allow-python-release-tag' || '' }} + + # actions/upload-artifact@v4 + - name: Upload release metadata + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-metadata-context-engine + path: ${{ steps.validate.outputs.metadata_path }} + if-no-files-found: error + + build: + name: Build context-engine distributions + needs: preflight + runs-on: ubuntu-latest + steps: + # actions/checkout@v4 + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + # actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.13" + + - name: Install build tools + run: python -m pip install --upgrade build twine + + - name: Validate CLI OAuth build variables + if: ${{ inputs.publish_target != 'build-only' }} + env: + LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }} + POTPIE_GITHUB_CLIENT_ID: ${{ vars.POTPIE_GITHUB_CLIENT_ID }} + run: | + test -n "$LINEAR_CLIENT_ID" || { echo "LINEAR_CLIENT_ID repository/environment variable is required for publishing"; exit 1; } + test -n "$POTPIE_GITHUB_CLIENT_ID" || { echo "POTPIE_GITHUB_CLIENT_ID repository/environment variable is required for publishing"; exit 1; } + + - name: Build potpie-context-engine + env: + LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }} + POTPIE_GITHUB_CLIENT_ID: ${{ vars.POTPIE_GITHUB_CLIENT_ID }} + run: | + mkdir -p dist/context-engine + python -m build --sdist --wheel --outdir dist/context-engine potpie/context-engine + python -m twine check dist/context-engine/* + + # actions/upload-artifact@v4 + - name: Upload context-engine distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: dist-context-engine + path: dist/context-engine/* + if-no-files-found: error + + smoke-install: + name: Smoke install context-engine (${{ matrix.os }}, Python ${{ matrix.python-version }}) + needs: + - preflight + - build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.12", "3.13"] + steps: + # actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: ${{ matrix.python-version }} + + # actions/download-artifact@v4 + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-context-engine + path: dist + + - name: Smoke install and run CLI + env: + CONTEXT_ENGINE_VERSION: ${{ needs.preflight.outputs.context_engine_version }} + run: | + ls -la dist + python -m venv .release-smoke + .release-smoke/bin/python -m pip install --upgrade pip + .release-smoke/bin/python -m pip install --find-links dist "potpie-context-engine[all]==${CONTEXT_ENGINE_VERSION}" + CONTEXT_ENGINE_HOME="$PWD/.context-engine-home" \ + PATH="$PWD/.release-smoke/bin:$PATH" \ + potpie status + + assemble-artifacts: + name: Assemble context-engine artifacts + needs: + - preflight + - build + - smoke-install + runs-on: ubuntu-latest + steps: + # actions/download-artifact@v4 + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-context-engine + path: dist + + # actions/download-artifact@v4 + - name: Download release metadata + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-metadata-context-engine + path: release-metadata + + - name: Generate checksums + run: | + find dist -maxdepth 1 -type f -print | sort + shasum -a 256 dist/* > SHA256SUMS.txt + cat SHA256SUMS.txt + + # actions/upload-artifact@v4 + - name: Upload release artifact bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-artifacts-context-engine + path: | + dist/* + release-metadata/release-metadata.json + SHA256SUMS.txt + if-no-files-found: error + + publish-testpypi: + name: Publish context-engine to TestPyPI + if: ${{ inputs.publish_target == 'testpypi' }} + needs: + - preflight + - assemble-artifacts + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/project/potpie-context-engine/ + permissions: + contents: read + id-token: write + steps: + # actions/download-artifact@v4 + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-context-engine + path: dist + + # pypa/gh-action-pypi-publish@release/v1 + - name: Publish distributions to TestPyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + with: + packages-dir: dist/ + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + name: Publish context-engine to PyPI + if: ${{ inputs.publish_target == 'pypi' }} + needs: + - preflight + - assemble-artifacts + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/potpie-context-engine/ + permissions: + contents: read + id-token: write + steps: + # actions/download-artifact@v4 + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-context-engine + path: dist + + # pypa/gh-action-pypi-publish@release/v1 + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + with: + packages-dir: dist/ diff --git a/.github/workflows/release-potpie.yml b/.github/workflows/release-potpie.yml new file mode 100644 index 000000000..b2a045b98 --- /dev/null +++ b/.github/workflows/release-potpie.yml @@ -0,0 +1,286 @@ +name: Release potpie + +on: + workflow_call: + inputs: + publish_target: + description: "Where to publish the verified artifacts." + required: true + type: string + confirm_publish: + description: "Required literal value 'publish' when publish_target=pypi." + required: false + default: "" + type: string + allow_python_release_tag: + description: "Allow the aggregate python-v tag when called by the orchestrator." + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + publish_target: + description: "Where to publish the verified artifacts." + required: true + default: build-only + type: choice + options: + - build-only + - testpypi + - pypi + confirm_publish: + description: "Required literal value 'publish' only when publish_target=pypi." + required: false + type: string + +permissions: + contents: read + +concurrency: + group: potpie-release-${{ inputs.publish_target }}-${{ github.ref_name }} + cancel-in-progress: false + +env: + PYTHONUNBUFFERED: "1" + +jobs: + preflight: + name: Validate potpie release + runs-on: ubuntu-latest + outputs: + potpie_version: ${{ steps.validate.outputs.potpie_version }} + channel: ${{ steps.validate.outputs.channel }} + metadata_path: ${{ steps.validate.outputs.metadata_path }} + steps: + # actions/checkout@v4 + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + # actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.13" + + - name: Install validation dependencies + run: python -m pip install --upgrade packaging + + - name: Validate release metadata + id: validate + run: >- + python scripts/validate_python_release.py + --package potpie + --publish-target "${{ inputs.publish_target }}" + --confirm-publish "${{ inputs.confirm_publish }}" + ${{ inputs.allow_python_release_tag && '--allow-python-release-tag' || '' }} + + # actions/upload-artifact@v4 + - name: Upload release metadata + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-metadata-potpie + path: ${{ steps.validate.outputs.metadata_path }} + if-no-files-found: error + + build: + name: Build potpie distributions + needs: preflight + runs-on: ubuntu-latest + steps: + # actions/checkout@v4 + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + # actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.13" + + - name: Install build tools + run: python -m pip install --upgrade build twine + + - name: Build potpie and local smoke dependency + env: + LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }} + POTPIE_GITHUB_CLIENT_ID: ${{ vars.POTPIE_GITHUB_CLIENT_ID }} + run: | + mkdir -p dist/potpie dist/smoke-deps + python -m build --sdist --wheel --outdir dist/smoke-deps potpie/context-engine + python -m build --sdist --wheel --outdir dist/potpie . + python -m twine check dist/smoke-deps/* dist/potpie/* + + # actions/upload-artifact@v4 + - name: Upload potpie distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: dist-potpie + path: dist/potpie/* + if-no-files-found: error + + # actions/upload-artifact@v4 + - name: Upload smoke dependency distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: dist-potpie-smoke-deps + path: dist/smoke-deps/* + if-no-files-found: error + + smoke-install: + name: Smoke install potpie (${{ matrix.os }}, Python ${{ matrix.python-version }}) + needs: + - preflight + - build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.12", "3.13"] + steps: + # actions/checkout@v4 + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + # actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: ${{ matrix.python-version }} + + # actions/download-artifact@v4 + - name: Download potpie distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-potpie + path: dist + + # actions/download-artifact@v4 + - name: Download local dependency distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-potpie-smoke-deps + path: dist + + - name: Smoke install and run CLI + env: + POTPIE_VERSION: ${{ needs.preflight.outputs.potpie_version }} + run: | + ls -la dist + python -m venv .release-smoke + .release-smoke/bin/python -m pip install --upgrade pip + .release-smoke/bin/python -m pip install --find-links dist "potpie==${POTPIE_VERSION}" + PATH="$PWD/.release-smoke/bin:$PATH" potpie status + + tmpdir="$(mktemp -d)" + git init "$tmpdir/dummy-git-repo" + git -C "$tmpdir/dummy-git-repo" config user.email "ci@potpie.ai" + git -C "$tmpdir/dummy-git-repo" config user.name "Potpie CI" + git -C "$tmpdir/dummy-git-repo" commit --allow-empty -m "Initial smoke commit" + ( + cd "$tmpdir/dummy-git-repo" + PATH="$GITHUB_WORKSPACE/.release-smoke/bin:$PATH" \ + CONTEXT_ENGINE_HOME="$tmpdir/potpie-home" \ + CONTEXT_ENGINE_HOST_MODE=in_process \ + potpie setup --repo . --pot wheel-smoke --agent claude --dry-run --yes + ) + + assemble-artifacts: + name: Assemble potpie artifacts + needs: + - preflight + - build + - smoke-install + runs-on: ubuntu-latest + steps: + # actions/download-artifact@v4 + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-potpie + path: dist + + # actions/download-artifact@v4 + - name: Download release metadata + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-metadata-potpie + path: release-metadata + + - name: Generate checksums + run: | + find dist -maxdepth 1 -type f -print | sort + shasum -a 256 dist/* > SHA256SUMS.txt + cat SHA256SUMS.txt + + # actions/upload-artifact@v4 + - name: Upload release artifact bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-artifacts-potpie + path: | + dist/* + release-metadata/release-metadata.json + SHA256SUMS.txt + if-no-files-found: error + + publish-testpypi: + name: Publish potpie to TestPyPI + if: ${{ inputs.publish_target == 'testpypi' }} + needs: + - preflight + - assemble-artifacts + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/project/potpie/ + permissions: + contents: read + id-token: write + steps: + # actions/download-artifact@v4 + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-potpie + path: dist + + # pypa/gh-action-pypi-publish@release/v1 + - name: Publish distributions to TestPyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + with: + packages-dir: dist/ + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + name: Publish potpie to PyPI + if: ${{ inputs.publish_target == 'pypi' }} + needs: + - preflight + - assemble-artifacts + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/potpie/ + permissions: + contents: read + id-token: write + steps: + # actions/download-artifact@v4 + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: dist-potpie + path: dist + + # pypa/gh-action-pypi-publish@release/v1 + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + with: + packages-dir: dist/ diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml new file mode 100644 index 000000000..037d1edc8 --- /dev/null +++ b/.github/workflows/release-python.yml @@ -0,0 +1,63 @@ +name: Release Python packages + +on: + workflow_dispatch: + inputs: + release_scope: + description: "Which Python package release path to run." + required: true + default: all + type: choice + options: + - all + - context-engine + - potpie + publish_target: + description: "Build only, publish to TestPyPI, or publish to PyPI." + required: true + default: build-only + type: choice + options: + - build-only + - testpypi + - pypi + confirm_publish: + description: "Required literal value 'publish' only when publish_target=pypi." + required: false + type: string + +permissions: + contents: read + +concurrency: + group: python-release-${{ inputs.release_scope }}-${{ inputs.publish_target }}-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + release-context-engine: + name: Release potpie-context-engine + if: ${{ inputs.release_scope != 'potpie' }} + uses: ./.github/workflows/release-context-engine.yml + with: + publish_target: ${{ inputs.publish_target }} + confirm_publish: ${{ inputs.confirm_publish }} + allow_python_release_tag: true + + release-potpie: + name: Release potpie + needs: + - release-context-engine + if: >- + ${{ + always() + && inputs.release_scope != 'context-engine' + && ( + inputs.release_scope != 'all' + || needs.release-context-engine.result == 'success' + ) + }} + uses: ./.github/workflows/release-potpie.yml + with: + publish_target: ${{ inputs.publish_target }} + confirm_publish: ${{ inputs.confirm_publish }} + allow_python_release_tag: true diff --git a/scripts/validate_python_release.py b/scripts/validate_python_release.py new file mode 100644 index 000000000..8e3de5ed1 --- /dev/null +++ b/scripts/validate_python_release.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Validate Potpie Python release inputs and emit release metadata.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys +import tomllib +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +try: + from packaging.requirements import InvalidRequirement, Requirement + from packaging.utils import canonicalize_name + from packaging.version import InvalidVersion, Version +except ImportError as exc: # pragma: no cover - exercised in CI bootstrap failures. + raise SystemExit( + "Missing dependency: packaging. Install it with `python -m pip install packaging`." + ) from exc + + +ROOT = Path(__file__).resolve().parents[1] +PYPI_BASE = { + "pypi": "https://pypi.org/pypi", + "testpypi": "https://test.pypi.org/pypi", +} +PACKAGE_SOURCES = { + "potpie": ("pyproject.toml", "potpie"), + "context-engine": ("potpie/context-engine/pyproject.toml", "potpie-context-engine"), +} +PACKAGE_ALIASES = { + "potpie": "potpie", + "context-engine": "context-engine", + "potpie-context-engine": "context-engine", +} +TAG_PREFIXES = { + "potpie": "potpie", + "context-engine": "potpie-context-engine", +} + + +@dataclass(frozen=True) +class PackageInfo: + key: str + name: str + version: str + source: str + + @property + def parsed_version(self) -> Version: + return Version(self.version) + + +def fail(message: str) -> None: + print(f"release validation failed: {message}", file=sys.stderr) + raise SystemExit(1) + + +def load_toml(path: str) -> dict: + return tomllib.loads((ROOT / path).read_text(encoding="utf-8")) + + +def normalize_package(value: str) -> str: + package = PACKAGE_ALIASES.get(value) + if package is None: + allowed = ", ".join(sorted(PACKAGE_ALIASES)) + fail(f"unsupported package {value!r}; allowed: {allowed}") + return package + + +def pyproject_package(key: str) -> PackageInfo: + path, expected_name = PACKAGE_SOURCES[key] + project = load_toml(path).get("project", {}) + name = project.get("name") + version = project.get("version") + if name != expected_name: + fail(f"{path} has project.name={name!r}; expected {expected_name!r}") + if not isinstance(version, str) or not version: + fail(f"{path} must define a static project.version") + return PackageInfo(key=key, name=name, version=version, source=path) + + +def channel_for_version(package: PackageInfo) -> str: + try: + version = package.parsed_version + except InvalidVersion as exc: + fail(f"{package.name} version {package.version!r} is not PEP 440: {exc}") + + if version.local is not None: + fail(f"{package.name} version {package.version!r} must not use a local '+...' segment") + if version.is_devrelease: + fail(f"{package.name} version {package.version!r} must not be a dev release") + if version.pre is None: + return "final" + if version.pre[0] == "b": + if "b" not in package.version.lower() or "beta" in package.version.lower(): + fail(f"{package.name} beta version must use compact bN syntax, not {package.version!r}") + return "beta" + if version.pre[0] == "rc": + return "rc" + fail(f"{package.name} version {package.version!r} must be final, beta bN, or rcN") + + +def infer_channel(packages: list[PackageInfo]) -> str: + channels = {channel_for_version(package) for package in packages} + if len(channels) != 1: + rendered = ", ".join( + f"{package.name}=={package.version} ({channel_for_version(package)})" + for package in packages + ) + fail(f"selected packages must use one release channel; got {rendered}") + return next(iter(channels)) + + +def context_engine_requirement() -> Requirement: + dependencies = load_toml("pyproject.toml").get("project", {}).get("dependencies", []) + for dependency in dependencies: + try: + requirement = Requirement(dependency) + except InvalidRequirement as exc: + fail(f"invalid root dependency {dependency!r}: {exc}") + if canonicalize_name(requirement.name) == "potpie-context-engine": + return requirement + fail("root potpie package must depend on potpie-context-engine") + + +def validate_root_dependency(channel: str, context_version: str) -> None: + requirement = context_engine_requirement() + specifiers = list(requirement.specifier) + if not specifiers: + fail("root potpie dependency on potpie-context-engine must include a version specifier") + + if channel in {"beta", "rc"}: + if len(specifiers) != 1 or specifiers[0].operator != "==" or specifiers[0].version != context_version: + fail( + "beta/rc root potpie must pin potpie-context-engine exactly " + f"as =={context_version}; found {requirement.specifier}" + ) + return + + if not requirement.specifier.contains(Version(context_version), prereleases=True): + fail( + "final root potpie dependency range must include " + f"potpie-context-engine {context_version}; found {requirement.specifier}" + ) + + +def package_version_exists(index: str, package_name: str, version: str) -> bool: + base_url = PYPI_BASE[index] + name = urllib.parse.quote(package_name) + release = urllib.parse.quote(version) + url = f"{base_url}/{name}/{release}/json" + request = urllib.request.Request(url, headers={"User-Agent": "potpie-release-validator/1.0"}) + try: + with urllib.request.urlopen(request, timeout=20) as response: + return response.status == 200 + except urllib.error.HTTPError as exc: + if exc.code == 404: + return False + fail(f"could not check {index} for {package_name}=={version}: HTTP {exc.code}") + except urllib.error.URLError as exc: + fail(f"could not check {index} for {package_name}=={version}: {exc.reason}") + return False + + +def validate_index_availability(publish_target: str, packages: list[PackageInfo]) -> None: + if publish_target == "build-only": + return + index = "testpypi" if publish_target == "testpypi" else "pypi" + for package in packages: + if package_version_exists(index, package.name, package.version): + fail(f"{package.name}=={package.version} already exists on {index}") + + +def validate_publish_policy(args: argparse.Namespace, packages: list[PackageInfo]) -> None: + if args.publish_target != "pypi": + return + if args.confirm_publish != "publish": + fail("publish_target=pypi requires confirm_publish=publish") + + ref_type = os.getenv("GITHUB_REF_TYPE", "") + ref_name = os.getenv("GITHUB_REF_NAME", "") + allowed_tags = { + f"{TAG_PREFIXES[package.key]}-v{package.version}" + for package in packages + } + if args.allow_python_release_tag: + root_potpie = pyproject_package("potpie") + allowed_tags.add(f"python-v{root_potpie.version}") + + if ref_type != "tag" or ref_name not in allowed_tags: + rendered = ", ".join(sorted(allowed_tags)) + fail( + "publish_target=pypi must run from an allowed release tag " + f"({rendered}); got ref_type={ref_type!r}, ref_name={ref_name!r}" + ) + + +def github_run_url() -> str: + server_url = os.getenv("GITHUB_SERVER_URL", "https://github.com") + repository = os.getenv("GITHUB_REPOSITORY", "") + run_id = os.getenv("GITHUB_RUN_ID", "") + if repository and run_id: + return f"{server_url}/{repository}/actions/runs/{run_id}" + return "" + + +def emit_metadata( + args: argparse.Namespace, + packages: list[PackageInfo], + channel: str, +) -> Path: + output_dir = ROOT / args.output_dir + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "release-metadata.json" + metadata = { + "generated_at": dt.datetime.now(dt.UTC).isoformat(), + "publish_target": args.publish_target, + "channel": channel, + "commit_sha": os.getenv("GITHUB_SHA", ""), + "ref": os.getenv("GITHUB_REF", ""), + "ref_type": os.getenv("GITHUB_REF_TYPE", ""), + "ref_name": os.getenv("GITHUB_REF_NAME", ""), + "repository": os.getenv("GITHUB_REPOSITORY", ""), + "run_id": os.getenv("GITHUB_RUN_ID", ""), + "run_attempt": os.getenv("GITHUB_RUN_ATTEMPT", ""), + "run_url": github_run_url(), + "packages": { + package.name: { + "version": package.version, + "source": package.source, + } + for package in packages + }, + } + output_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return output_path + + +def emit_github_outputs(packages: dict[str, PackageInfo], metadata_path: Path, channel: str) -> None: + github_output = os.getenv("GITHUB_OUTPUT") + if not github_output: + return + try: + metadata_output = str(metadata_path.relative_to(ROOT)) + except ValueError: + metadata_output = str(metadata_path) + lines = [ + f"channel={channel}", + f"metadata_path={metadata_output}", + ] + if "potpie" in packages: + lines.append(f"potpie_version={packages['potpie'].version}") + if "context-engine" in packages: + lines.append(f"context_engine_version={packages['context-engine'].version}") + with Path(github_output).open("a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", required=True, choices=sorted(PACKAGE_ALIASES)) + parser.add_argument("--publish-target", required=True, choices=["build-only", "testpypi", "pypi"]) + parser.add_argument("--confirm-publish", default="") + parser.add_argument("--allow-python-release-tag", action="store_true") + parser.add_argument("--output-dir", default="release-metadata") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + package_key = normalize_package(args.package) + selected = {package_key: pyproject_package(package_key)} + package_list = list(selected.values()) + channel = infer_channel(package_list) + + if package_key == "potpie": + context_engine = pyproject_package("context-engine") + validate_root_dependency(channel, context_engine.version) + + validate_publish_policy(args, package_list) + validate_index_availability(args.publish_target, package_list) + metadata_path = emit_metadata(args, package_list, channel) + emit_github_outputs(selected, metadata_path, channel) + + print("release validation passed") + print(f"- channel: {channel}") + for package in package_list: + print(f"- {package.name}=={package.version} ({package.source})") + try: + metadata_display = str(metadata_path.relative_to(ROOT)) + except ValueError: + metadata_display = str(metadata_path) + print(f"- metadata: {metadata_display}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())