Skip to content

Build Python Packages #160

Build Python Packages

Build Python Packages #160

Workflow file for this run

name: Build Python Packages
on:
push:
pull_request:
workflow_dispatch:
inputs:
release_date:
description: >
Release tag (YYYYMMDD). When set, a single GitHub release with that
tag is created and all per-platform tarballs from every matrix entry
are published as assets. Leave empty for a build-only run that uploads
per-job artifacts but does not publish a release.
required: false
type: string
default: ""
# Cancel in-flight runs when a newer event arrives for the same logical branch.
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
setup:
name: Read build matrix from manifest
runs-on: ubuntu-latest
outputs:
versions: ${{ steps.read.outputs.versions }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Extract Python versions from manifest.json
id: read
# manifest.json is the single source of truth: it both selects which
# CPython versions are built here and is published as a release asset
# (see publish-release) for serious_python / flet to consume.
run: echo "versions=$(jq -c '[.pythons[].full_version]' manifest.json)" >> "$GITHUB_OUTPUT"
build-matrix:
name: Build Python ${{ matrix.python_version }}
needs: setup
strategy:
fail-fast: false
matrix:
python_version: ${{ fromJSON(needs.setup.outputs.versions) }}
uses: ./.github/workflows/build-python-version.yml
with:
python_version: ${{ matrix.python_version }}
# Release-only, isolated signing job. The provider certificate is never
# available to build-matrix (which runs on every push and PR); it lives in the
# protected `release-signing` environment and is only reachable from an
# explicit release dispatch on main.
#
# Signing operates on the finished archives rather than inside the build, which
# guarantees the required ordering: every mutation (install names, plists,
# privacy manifests, headers, pruning, stripping) is already done by the time
# an archive exists. The script re-packs and re-verifies after a round trip.
sign-darwin-artifacts:
name: Provider-sign Darwin XCFrameworks
runs-on: macos-26
if: >-
github.event_name == 'workflow_dispatch'
&& inputs.release_date != ''
&& github.ref == 'refs/heads/main'
needs:
- build-matrix
environment: release-signing
env:
XCFRAMEWORK_EXPECTED_TEAM_ID: ${{ vars.XCFRAMEWORK_EXPECTED_TEAM_ID }}
# Missing credentials, a missing secure timestamp, a wrong team, or an
# archive containing zero XCFrameworks all fail the release here.
REQUIRE_XCFRAMEWORK_SIGNATURE: '1'
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Download unsigned Darwin archives
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: darwin-unsigned-*
path: unsigned
merge-multiple: true
- name: Import Apple Distribution certificate into a temporary keychain
env:
CERT_P12_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_BASE64 }}
CERT_P12_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_PASSWORD }}
run: |
set -euo pipefail
: "${CERT_P12_BASE64:?APPLE_DISTRIBUTION_CERT_P12_BASE64 is not set}"
: "${CERT_P12_PASSWORD:?APPLE_DISTRIBUTION_CERT_P12_PASSWORD is not set}"
KEYCHAIN_PATH="$RUNNER_TEMP/xcframework-signing.keychain-db"
CERT_PATH="$RUNNER_TEMP/xcframework-signing.p12"
# Ephemeral: the keychain lives for this job only and is deleted in the
# always-run cleanup step, so the password never leaves this step.
KEYCHAIN_PASSWORD=$(openssl rand -base64 24)
printf '%s' "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# No -A: the private key is reachable only by the two Apple tools named
# below, not by any process that happens to run in this job.
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$CERT_P12_PASSWORD" \
-f pkcs12 -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
# codesign resolves an identity through the search list even when
# --keychain is passed, so prepend ours to the user list.
security list-keychains -d user -s "$KEYCHAIN_PATH" \
$(security list-keychains -d user | tr -d '"')
# Derive EXACTLY ONE fingerprint. Selecting by display name is
# ambiguous when a keychain holds more than one matching certificate,
# and codesign then picks arbitrarily; a hard count check turns a
# multi-certificate .p12 into a build failure instead of a coin flip.
IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH")
echo "$IDENTITIES"
FPRS=$(printf '%s\n' "$IDENTITIES" \
| sed -n 's/^ *[0-9]*) \([0-9A-F]\{40\}\) .*/\1/p' | sort -u)
COUNT=$(printf '%s' "$FPRS" | grep -c . || true)
if [ "$COUNT" -ne 1 ]; then
echo "::error::expected exactly 1 codesigning identity in the imported keychain, found $COUNT"
exit 1
fi
# Fingerprint and keychain path are not secrets.
echo "XCFRAMEWORK_CODESIGN_IDENTITY=$FPRS" >> "$GITHUB_ENV"
echo "XCFRAMEWORK_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Sign and re-pack Darwin archives
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
archives=(unsigned/python-*.tar.gz)
if [ "${#archives[@]}" -eq 0 ]; then
echo "::error::no Darwin archives to sign"
exit 1
fi
bash darwin/sign_darwin_archives.sh signed "${archives[@]}"
- name: Upload signed Darwin archives
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-darwin-signed
path: signed/python-*.tar.gz
if-no-files-found: error
- name: Remove temporary keychain and certificate
if: always()
run: |
security delete-keychain "$RUNNER_TEMP/xcframework-signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/xcframework-signing.p12"
publish-release:
name: Publish Release Assets
runs-on: ubuntu-latest
# Date-keyed releases (PBS-style): only publish when an operator explicitly
# triggers via workflow_dispatch with a `release_date` input, and only from
# the protected main branch — the same condition that gates real signing, so
# a release can never be assembled from artifacts that were never signed.
if: >-
github.event_name == 'workflow_dispatch'
&& inputs.release_date != ''
&& github.ref == 'refs/heads/main'
needs:
- setup
- build-matrix
- sign-darwin-artifacts
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
# Downloaded by explicit pattern, never `python-*` wholesale: the unsigned
# Darwin build artifacts are named `darwin-unsigned-*` precisely so no
# pattern here can reach them. The only Darwin tarballs that enter the
# release directory are the ones sign-darwin-artifacts produced.
- name: Download Android build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: python-android-*
path: release-artifacts
merge-multiple: true
- name: Download Linux build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: python-linux-*
path: release-artifacts
merge-multiple: true
- name: Download Windows build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: python-windows-*
path: release-artifacts
merge-multiple: true
- name: Download signed Darwin artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: python-darwin-signed
path: release-artifacts
- name: Assert the release payload carries the signed Darwin tarballs
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
ls -lh release-artifacts
for kind in python-ios-dart python-macos-dart python-ios-mobile-forge; do
found=(release-artifacts/$kind-*.tar.gz)
if [ "${#found[@]}" -eq 0 ]; then
echo "::error::no $kind-*.tar.gz in the release payload"
exit 1
fi
done
- name: Add runtime manifest (with release date) to the release
# Publish the same manifest.json that drove this build, with the release
# date injected, so consumers can fetch a consistent version set by date.
env:
INPUTS_RELEASE_DATE: ${{ inputs.release_date }}
run: jq --arg date "$INPUTS_RELEASE_DATE" '.release = $date' manifest.json > release-artifacts/manifest.json
- name: Publish all artifacts to release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ inputs.release_date }}
name: ${{ inputs.release_date }}
files: release-artifacts/*
fail_on_unmatched_files: true
generate_release_notes: false
draft: false
prerelease: false