Skip to content

chore(release): prepare 0.48.0 (#163) #26

chore(release): prepare 0.48.0 (#163)

chore(release): prepare 0.48.0 (#163) #26

name: Release (pythinker-code)
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
concurrency:
group: release-pythinker-cli-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
validate:
name: Validate tag
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- name: Derive version from tag
id: version
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Tag scheme is v<MAJOR>.<MINOR>.<PATCH>; strip the leading 'v' so
# downstream steps (README grep, CHANGELOG grep, build env vars,
# artifact names) see the bare PEP 440 version.
version="${REF_NAME#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error title=Invalid tag::Tag must match v<MAJOR>.<MINOR>.<PATCH>" >&2
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "RELEASE_VERSION=${version}" >> "$GITHUB_ENV"
echo "Derived release version: ${version}"
- name: Set up Python 3.14
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # pinned from v6.2.0
with:
python-version: "3.14"
allow-prereleases: true
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # pinned from v8.1.0
with:
version: "0.8.5"
- name: Check version tag
run: |
python scripts/check_version_tag.py \
--pyproject pyproject.toml \
--expected-version "${RELEASE_VERSION}"
- name: Check dependency versions
run: |
uv run python scripts/check_pythinker_dependency_versions.py \
--root-pyproject pyproject.toml \
--pythinker-core-pyproject packages/pythinker-core/pyproject.toml \
--pythinker-host-pyproject packages/pythinker-host/pyproject.toml \
--pythinker-review-pyproject packages/pythinker-review/pyproject.toml \
--pythinker-sdk-pyproject sdks/pythinker-sdk/pyproject.toml
# Hard release gate: every PyPI release must ship a matching README +
# CHANGELOG update. README.md must contain a "What's New in <version>"
# heading and the pinned `pythinker-code==<version>` upgrade snippet
# (both are load-bearing on the project page that PyPI renders).
# CHANGELOG.md must have a top-level "## <version> (...)" entry.
# Missing any of these blocks both the GitHub Release and PyPI publish.
- name: Verify README references the new version
run: |
set -euo pipefail
missing=()
if ! grep -qF "What's New in ${RELEASE_VERSION}" README.md; then
missing+=("a '## What\'s New in ${RELEASE_VERSION}' section")
fi
if ! grep -qF "pythinker-code==${RELEASE_VERSION}" README.md; then
missing+=("a 'pip install --upgrade pythinker-code==${RELEASE_VERSION}' upgrade snippet")
fi
if [ "${#missing[@]}" -gt 0 ]; then
printf '::error title=README out of sync with release %s::Update README.md before tagging. Missing: %s\n' \
"${RELEASE_VERSION}" "${missing[*]}"
exit 1
fi
echo "README.md references release ${RELEASE_VERSION}"
- name: Verify CHANGELOG references the new version
run: |
set -euo pipefail
if ! grep -qE "^## ${RELEASE_VERSION} \\(" CHANGELOG.md; then
printf '::error title=CHANGELOG out of sync with release %s::Add a "## %s (YYYY-MM-DD)" entry to CHANGELOG.md before tagging.\n' \
"${RELEASE_VERSION}" "${RELEASE_VERSION}"
exit 1
fi
echo "CHANGELOG.md has an entry for ${RELEASE_VERSION}"
build:
name: Build binaries (${{ matrix.target }})
needs: validate
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- runner: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- runner: macos-14
target: aarch64-apple-darwin
- runner: macos-15-intel
target: x86_64-apple-darwin
- runner: windows-2022
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.runner }}
env:
PYTHINKER_WEB_STRICT_VERSION: "1"
PYTHINKER_WEB_EXPECT_VERSION: ${{ needs.validate.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- name: Install GNU Make (Windows)
if: runner.os == 'Windows'
run: choco install make -y
- name: Set up Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # pinned from stable
- name: Set up Python 3.14
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # pinned from v6.2.0
with:
python-version: "3.14"
allow-prereleases: true
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # pinned from v8.1.0
with:
version: "0.8.5"
- name: Set up Node.js (web build)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Prepare building environment
run: make prepare-build
# macOS: Determine whether Developer ID signing + Apple notarization
# secrets are configured. When they aren't, downstream codesign/notarize
# steps are skipped and the binary ships ad-hoc-signed by PyInstaller.
- name: Detect macOS signing secrets
id: macos_signing
if: runner.os == 'macOS'
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
run: |
if [[ -n "$APPLE_CERTIFICATE_P12" ]]; then
echo "sign=true" >> "$GITHUB_OUTPUT"
echo "Developer ID signing secrets present"
else
echo "sign=false" >> "$GITHUB_OUTPUT"
echo "APPLE_CERTIFICATE_P12 not set - building ad-hoc-signed macOS binary"
fi
if [[ -n "$APPLE_NOTARIZATION_KEY_P8" ]]; then
echo "notarize=true" >> "$GITHUB_OUTPUT"
else
echo "notarize=false" >> "$GITHUB_OUTPUT"
echo "APPLE_NOTARIZATION_KEY_P8 not set - skipping notarization"
fi
# macOS: Setup signing certificate before build
- name: Setup macOS signing certificate
if: runner.os == 'macOS' && steps.macos_signing.outputs.sign == 'true'
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: actions
run: |
set -euo pipefail
# Decode certificate
cert_path="${RUNNER_TEMP}/certificate.p12"
echo "$APPLE_CERTIFICATE_P12" | base64 -d > "$cert_path"
# Create temporary keychain
keychain_path="${RUNNER_TEMP}/signing.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
# Add to keychain search list
security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"')
security default-keychain -s "$keychain_path"
# Import certificate
security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null
# Find signing identity
IDENTITY=$(security find-identity -v -p codesigning "$keychain_path" | grep "Developer ID Application" | head -1 | sed -n 's/.*"\(Developer ID Application[^"]*\)".*/\1/p')
if [[ -z "$IDENTITY" ]]; then
echo "❌ No Developer ID Application identity found"
security find-identity -v -p codesigning "$keychain_path"
exit 1
fi
echo "✅ Found signing identity: $IDENTITY"
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
rm -f "$cert_path"
# Build onefile and onedir versions (all platforms)
- name: Build standalone binary (onefile)
run: make build-bin
- name: Build standalone binary (onedir)
env:
PYINSTALLER_ONEDIR: "1"
run: make build-bin-onedir
# macOS: Sign onefile binary
- name: Sign macOS onefile binary
if: runner.os == 'macOS' && steps.macos_signing.outputs.sign == 'true'
run: |
set -euo pipefail
echo "Signing onefile binary..."
codesign --deep --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" \
--keychain "$APPLE_KEYCHAIN_PATH" \
dist/onefile/pythinker
echo "✅ Onefile binary signed"
codesign -dv --verbose=2 dist/onefile/pythinker
# macOS: Sign onedir binaries (all dylibs and executables)
- name: Sign macOS onedir binaries
if: runner.os == 'macOS' && steps.macos_signing.outputs.sign == 'true'
run: |
set -euo pipefail
echo "Signing onedir binaries..."
# 1. Sign all dylibs and so files first (excluding those inside frameworks)
find dist/onedir/pythinker -type f \( -name "*.dylib" -o -name "*.so" \) ! -path "*.framework/*" | while read -r lib; do
echo "Signing: $lib"
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" \
--keychain "$APPLE_KEYCHAIN_PATH" \
"$lib"
done
# 2. Sign all frameworks with --deep (important for Python.framework)
find dist/onedir/pythinker -type d -name "*.framework" | while read -r framework; do
echo "Signing framework: $framework"
codesign --deep --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" \
--keychain "$APPLE_KEYCHAIN_PATH" \
"$framework"
done
# 3. Sign the main executable last
echo "Signing main executable: dist/onedir/pythinker/pythinker"
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGNING_IDENTITY" \
--keychain "$APPLE_KEYCHAIN_PATH" \
dist/onedir/pythinker/pythinker
echo "✅ Onedir binaries signed"
codesign -dv --verbose=2 dist/onedir/pythinker/pythinker
codesign --verify --deep --strict dist/onedir/pythinker/pythinker && echo "✅ Deep verification passed"
# macOS: Notarize onefile binary
- name: Notarize macOS onefile binary
if: runner.os == 'macOS' && steps.macos_signing.outputs.sign == 'true' && steps.macos_signing.outputs.notarize == 'true'
env:
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
run: |
set -euo pipefail
# Save API key
key_path="${RUNNER_TEMP}/AuthKey.p8"
echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path"
# Create zip for notarization (use --norsrc to avoid ._ AppleDouble files)
zip_path="${RUNNER_TEMP}/pythinker-onefile.zip"
ditto -c -k --norsrc --keepParent dist/onefile/pythinker "$zip_path"
echo "Submitting onefile for notarization..."
# Submit and capture output for status verification
xcrun notarytool submit "$zip_path" \
--key "$key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
--wait \
--timeout 15m \
2>&1 | tee /tmp/notarize-onefile.log
# Verify notarization was accepted
if ! grep -q "status: Accepted" /tmp/notarize-onefile.log; then
echo "❌ Onefile notarization failed!"
cat /tmp/notarize-onefile.log
# Get detailed error log from Apple
submission_id=$(grep "id:" /tmp/notarize-onefile.log | head -1 | awk '{print $2}')
if [[ -n "$submission_id" ]]; then
echo "Fetching notarization log for submission: $submission_id"
xcrun notarytool log "$submission_id" \
--key "$key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" 2>&1 || true
fi
exit 1
fi
echo "✅ Onefile notarization completed and accepted"
# Verify signature and notarization status
echo "Verifying onefile signature..."
codesign -dv --verbose=2 dist/onefile/pythinker
echo "Verifying onefile notarization (online check)..."
spctl -a -vvv -t install dist/onefile/pythinker
# Cleanup
rm -f "$zip_path" /tmp/notarize-onefile.log
# macOS: Notarize onedir binaries
- name: Notarize macOS onedir binaries
if: runner.os == 'macOS' && steps.macos_signing.outputs.sign == 'true' && steps.macos_signing.outputs.notarize == 'true'
env:
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
run: |
set -euo pipefail
# Save API key (might already exist from previous step)
key_path="${RUNNER_TEMP}/AuthKey.p8"
if [[ ! -f "$key_path" ]]; then
echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path"
fi
# Create zip for notarization (use --norsrc to avoid ._ AppleDouble files)
zip_path="${RUNNER_TEMP}/pythinker-onedir.zip"
ditto -c -k --norsrc --keepParent dist/onedir/pythinker "$zip_path"
echo "Submitting onedir for notarization..."
# Submit and capture output for status verification
xcrun notarytool submit "$zip_path" \
--key "$key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
--wait \
--timeout 15m \
2>&1 | tee /tmp/notarize-onedir.log
# Verify notarization was accepted
if ! grep -q "status: Accepted" /tmp/notarize-onedir.log; then
echo "❌ Onedir notarization failed!"
cat /tmp/notarize-onedir.log
# Get detailed error log from Apple
submission_id=$(grep "id:" /tmp/notarize-onedir.log | head -1 | awk '{print $2}')
if [[ -n "$submission_id" ]]; then
echo "Fetching notarization log for submission: $submission_id"
xcrun notarytool log "$submission_id" \
--key "$key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" 2>&1 || true
fi
exit 1
fi
echo "✅ Onedir notarization completed and accepted"
# Verify signature and notarization status
echo "Verifying onedir signature..."
codesign -dv --verbose=2 dist/onedir/pythinker/pythinker
echo "Verifying onedir notarization (online check)..."
spctl -a -vvv -t install dist/onedir/pythinker/pythinker
# Cleanup
rm -f "$key_path" "$zip_path" /tmp/notarize-onedir.log
# macOS: Cleanup keychain
- name: Cleanup macOS keychain
if: always() && runner.os == 'macOS'
run: |
if [[ -n "${APPLE_KEYCHAIN_PATH:-}" && -f "${APPLE_KEYCHAIN_PATH}" ]]; then
security delete-keychain "$APPLE_KEYCHAIN_PATH" || true
fi
# Package onefile artifact (all platforms)
- name: Package onefile artifact
shell: python
env:
TAG: ${{ needs.validate.outputs.version }}
TARGET: ${{ matrix.target }}
run: |
import os
import pathlib
import tarfile
import zipfile
tag = os.environ["TAG"]
target = os.environ["TARGET"]
dist_dir = pathlib.Path("dist")
artifacts_dir = pathlib.Path("artifacts")
artifacts_dir.mkdir(parents=True, exist_ok=True)
is_windows = "windows" in target
is_macos = "apple-darwin" in target
binary_name = "pythinker.exe" if is_windows else "pythinker"
binary_path = dist_dir / "onefile" / binary_name
if not binary_path.exists():
raise SystemExit(f"Binary not found at {binary_path}")
# Determine archive format and name
# - Windows: .zip
# - macOS: .tar.gz
# - Linux: .tar.gz
if is_windows:
archive_name = f"pythinker-{tag}-{target}.zip"
archive_path = artifacts_dir / archive_name
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive_file:
archive_file.write(binary_path, arcname=binary_name)
else:
archive_name = f"pythinker-{tag}-{target}.tar.gz"
archive_path = artifacts_dir / archive_name
with tarfile.open(archive_path, "w:gz") as archive_file:
archive_file.add(binary_path, arcname="pythinker")
print(f"Built onefile artifact: {archive_path}")
# Package onedir artifact (all platforms)
- name: Package onedir artifact
shell: python
env:
TAG: ${{ needs.validate.outputs.version }}
TARGET: ${{ matrix.target }}
run: |
import os
import pathlib
import tarfile
import zipfile
tag = os.environ["TAG"]
target = os.environ["TARGET"]
dist_dir = pathlib.Path("dist")
artifacts_dir = pathlib.Path("artifacts")
artifacts_dir.mkdir(parents=True, exist_ok=True)
is_windows = "windows" in target
# Windows: onedir is dist/onedir/pythinker with pythinker.exe inside
# Others: onedir is dist/onedir/pythinker with pythinker inside
onedir_path = dist_dir / "onedir" / "pythinker"
if not onedir_path.exists() or not onedir_path.is_dir():
raise SystemExit(f"Onedir directory not found at {onedir_path}")
if is_windows:
archive_name = f"pythinker-{tag}-{target}-onedir.zip"
archive_path = artifacts_dir / archive_name
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive_file:
# Add the directory contents with pythinker/ as the root
for item in onedir_path.rglob("*"):
if item.is_file():
arcname = f"pythinker/{item.relative_to(onedir_path)}"
archive_file.write(item, arcname=arcname)
else:
archive_name = f"pythinker-{tag}-{target}-onedir.tar.gz"
archive_path = artifacts_dir / archive_name
with tarfile.open(archive_path, "w:gz") as archive_file:
# Add the directory contents with pythinker/ as the root
for item in onedir_path.iterdir():
archive_file.add(item, arcname=f"pythinker/{item.name}")
print(f"Built onedir artifact: {archive_path}")
- name: Set artifact name
id: artifact
shell: python
run: |
import os
ref = os.environ["GITHUB_REF_NAME"].replace("/", "-")
target = "${{ matrix.target }}"
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"name=pythinker-{ref}-{target}\n")
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7.0.1
with:
name: ${{ steps.artifact.outputs.name }}
path: artifacts/*
if-no-files-found: error
retention-days: 7
release:
name: Publish GitHub Release
needs: [validate, build]
# Hard release gate: the GitHub Release is always created once the
# validate gate (version tag + README + CHANGELOG) passes, even if
# one or more matrix build targets failed. softprops/action-gh-release
# is configured with `fail_on_unmatched_files: false` so missing
# artifacts produce a warning, not a failure — the Release lists
# whatever binaries did build, and the missing target can be re-run
# against the same tag to upload its asset later.
if: always() && needs.validate.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Download all build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pinned from v8.0.1
with:
path: ./downloads
merge-multiple: true
- name: Show downloaded files
run: ls -laR downloads
- name: Generate per-file SHA256 sums
shell: bash
run: |
set -euxo pipefail
cd downloads
shopt -s nullglob
for f in *.tar.gz *.zip; do
sha256sum "$f" > "$f.sha256"
echo "sha256($(basename "$f"))=$(cut -d' ' -f1 "$f.sha256")"
done
ls -la
- name: Create GitHub Release and upload assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # pinned from v3.0.0
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
# Tolerate missing matrix targets so the Release still publishes
# when (e.g.) a single Windows runner crashed mid-build). Mark it
# prerelease so it stays out of /releases/latest (which is date-based
# and ignores make_latest) until every platform asset is attached;
# promote-release.yml clears prerelease once the expected
# macOS/Linux/Windows update assets exist.
prerelease: "true"
fail_on_unmatched_files: false
make_latest: "false"
files: |
downloads/*.tar.gz
downloads/*.zip
downloads/*.tar.gz.sha256
downloads/*.zip.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
register-bugsink-release:
name: Register release in Bugsink
needs: [validate, release]
# Registering the release at cut time (instead of waiting for the first
# event from that version) makes Bugsink's "resolved in next release"
# semantics flip exactly when the release ships. Telemetry must never
# gate a release: failures here are warnings, not job failures.
if: always() && needs.release.result == 'success'
runs-on: ubuntu-latest
steps:
- name: POST release to Bugsink
env:
VERSION: ${{ needs.validate.outputs.version }}
BUGSINK_TOKEN: ${{ secrets.BUGSINK_RELEASES_TOKEN }}
run: |
set -uo pipefail
if [[ -z "$BUGSINK_TOKEN" ]]; then
echo "::warning title=Bugsink::BUGSINK_RELEASES_TOKEN not set; skipping release registration"
exit 0
fi
body=$(jq -n --arg v "pythinker-code@${VERSION}" '{project: 1, version: $v}')
status=$(curl -sS -o /tmp/resp.json -w "%{http_code}" -m 30 \
-X POST "https://errors.pythinker.com/api/canonical/0/releases/" \
-H "Authorization: Bearer ${BUGSINK_TOKEN}" \
-H "Content-Type: application/json" \
-d "$body" || echo "000")
if [[ "$status" == "201" || "$status" == "200" ]]; then
echo "Registered pythinker-code@${VERSION} in Bugsink"
elif [[ "$status" == "400" ]] && grep -q "already exists" /tmp/resp.json; then
echo "Release pythinker-code@${VERSION} already registered (idempotent re-run)"
else
echo "::warning title=Bugsink::release registration failed (HTTP ${status}): $(cat /tmp/resp.json 2>/dev/null | head -c 200)"
fi
publish-python-testpypi:
name: Publish Python package to TestPyPI
needs: validate
runs-on: ubuntu-latest
# TestPyPI is a non-critical staging smoke-test that runs in parallel with
# the production publish from the same artifacts. A transient flake here
# (e.g. a registry 502 pulling the publish action's image) must not mark the
# release run as failed - production PyPI below is the gating publish.
continue-on-error: true
# Fail fast if a registry pull or upload hangs, so a rerun is quick.
timeout-minutes: 15
environment:
name: testpypi
url: https://test.pypi.org/project/pythinker-code/
permissions:
id-token: write
contents: read
env:
PYTHINKER_WEB_STRICT_VERSION: "1"
PYTHINKER_WEB_EXPECT_VERSION: ${{ needs.validate.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- name: Set up Python 3.14
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # pinned from v6.2.0
with:
python-version: "3.14"
allow-prereleases: true
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # pinned from v8.1.0
with:
version: "0.8.5"
- name: Set up Node.js (web build)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Build distributions
run: make build-pythinker-code
- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # pinned from release/v1
with:
repository-url: https://test.pypi.org/legacy/
packages-dir: dist
skip-existing: true
publish-python:
name: Publish Python package to PyPI
needs: validate
runs-on: ubuntu-latest
# Fail fast if a registry pull or upload hangs; skip-existing below keeps a
# rerun idempotent so a transient flake costs only a rerun, not a version.
timeout-minutes: 15
environment:
name: pypi
url: https://pypi.org/project/pythinker-code/
permissions:
id-token: write
contents: read
env:
PYTHINKER_WEB_STRICT_VERSION: "1"
PYTHINKER_WEB_EXPECT_VERSION: ${{ needs.validate.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- name: Set up Python 3.14
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # pinned from v6.2.0
with:
python-version: "3.14"
allow-prereleases: true
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # pinned from v8.1.0
with:
version: "0.8.5"
- name: Set up Node.js (web build)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Build distributions
run: make build-pythinker-code
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # pinned from release/v1
with:
packages-dir: dist
# Idempotent on re-runs: skip files already published (e.g. when the
# tag was rerun to fix a macOS binary build that didn't affect the
# already-uploaded wheel/sdist).
skip-existing: true