chore(release): prepare 0.47.0 (#156) #25
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |