Release v4.8.5
#5047
This file contains 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: Build & release | |
# Read https://github.com/actions/runner/issues/491 for insights on complex workflow execution logic. | |
"on": | |
workflow_call: | |
secrets: | |
PYPI_TOKEN: | |
required: false | |
inputs: | |
binaries-test-plan: | |
description: Test plan for binaries | |
required: false | |
type: string | |
timeout: | |
description: Timeout in seconds for each binary test | |
required: false | |
type: number | |
outputs: | |
nuitka_matrix: | |
description: Nuitka build matrix | |
value: ${{ jobs.project-metadata.outputs.nuitka_matrix }} | |
# Target are chosen so that all commits get a chance to have their build tested. | |
push: | |
branches: | |
- main | |
pull_request: | |
jobs: | |
project-metadata: | |
name: Project metadata | |
runs-on: ubuntu-24.04 | |
outputs: | |
# There's a design issue with GitHub actions: matrix outputs are not cumulative. The last job wins | |
# (see: https://git.luolix.topmunity/t/bug-jobs-output-should-return-a-list-for-a-matrix-job/128626). | |
# This means in a graph of jobs, a matrix-based one is terminal, and cannot be depended on. Same goes for | |
# (reusable) workflows. We use this preliminary job to produce all matrix we need to trigger depending jobs | |
# over the dimensions. | |
new_commits_matrix: ${{ steps.project-metadata.outputs.new_commits_matrix }} | |
release_commits_matrix: ${{ steps.project-metadata.outputs.release_commits_matrix }} | |
# Export Python project metadata. | |
nuitka_matrix: ${{ steps.project-metadata.outputs.nuitka_matrix }} | |
is_python_project: ${{ steps.project-metadata.outputs.is_python_project }} | |
package_name: ${{ steps.project-metadata.outputs.package_name }} | |
release_notes: ${{ steps.project-metadata.outputs.release_notes }} | |
steps: | |
- uses: actions/checkout@v4.2.2 | |
with: | |
# Checkout pull request HEAD commit to ignore actions/checkout's merge commit. Fallback to push SHA. | |
ref: ${{ github.event.pull_request.head.sha || github.sha }} | |
# We're going to browse all new commits. | |
fetch-depth: 0 | |
- name: List all branches | |
run: | | |
git branch --all | |
- name: List all commits | |
run: | | |
git log --decorate=full --oneline | |
- uses: actions/setup-python@v5.3.0 | |
with: | |
python-version: "3.12" | |
cache: "pip" | |
cache-dependency-path: | | |
**/pyproject.toml | |
*requirements.txt | |
requirements/*.txt | |
- name: Install uv | |
run: | | |
python -m pip install -r https://raw.githubusercontent.com/kdeldycke/workflows/main/requirements/uv.txt | |
- name: Install gha-utils | |
run: > | |
uv tool install --with-requirements | |
https://raw.githubusercontent.com/kdeldycke/workflows/main/requirements/gha-utils.txt gha-utils | |
- name: Project metadata | |
id: project-metadata | |
env: | |
GITHUB_CONTEXT: ${{ toJSON(github) }} | |
run: | | |
gha-utils --verbosity DEBUG metadata --overwrite "$GITHUB_OUTPUT" | |
package-build: | |
name: "Build & check package" | |
needs: | |
- project-metadata | |
if: fromJSON(needs.project-metadata.outputs.is_python_project) | |
strategy: | |
matrix: ${{ fromJSON(needs.project-metadata.outputs.new_commits_matrix) }} | |
runs-on: ubuntu-24.04 | |
steps: | |
- uses: actions/checkout@v4.2.2 | |
with: | |
ref: ${{ matrix.commit }} | |
- uses: actions/setup-python@v5.3.0 | |
with: | |
python-version: "3.12" | |
cache: "pip" | |
cache-dependency-path: | | |
**/pyproject.toml | |
*requirements.txt | |
requirements/*.txt | |
- name: Install uv | |
run: | | |
python -m pip install -r https://raw.githubusercontent.com/kdeldycke/workflows/main/requirements/uv.txt | |
- name: Install build dependencies | |
run: | | |
uv --no-progress venv | |
uv --no-progress pip install \ | |
--requirement https://raw.githubusercontent.com/kdeldycke/workflows/main/requirements/build.txt | |
- name: Build package | |
run: | | |
uv --no-progress build | |
- name: Upload artifacts | |
uses: actions/upload-artifact@v4.4.3 | |
with: | |
name: ${{ github.event.repository.name }}-build-${{ matrix.short_sha }} | |
path: ./dist/* | |
- name: Validates package metadata | |
# XXX These checks might be replaced by uv one day: | |
# https://github.com/astral-sh/uv/issues/8641 | |
# https://github.com/astral-sh/uv/issues/8774 | |
run: | | |
uv --no-progress run --frozen -- twine check ./dist/* | |
uv --no-progress run --frozen -- check-wheel-contents ./dist/*.whl | |
compile-binaries: | |
name: "Nuitka: generate binaries" | |
needs: | |
- project-metadata | |
if: needs.project-metadata.outputs.nuitka_matrix | |
strategy: | |
matrix: ${{ fromJSON(needs.project-metadata.outputs.nuitka_matrix) }} | |
runs-on: ${{ matrix.os }} | |
steps: | |
- uses: actions/checkout@v4.2.2 | |
with: | |
ref: ${{ matrix.commit }} | |
- uses: actions/setup-python@v5.3.0 | |
with: | |
python-version: "3.13" | |
cache: "pip" | |
cache-dependency-path: | | |
**/pyproject.toml | |
*requirements.txt | |
requirements/*.txt | |
- name: Install uv | |
run: | | |
python -m pip install -r https://raw.githubusercontent.com/kdeldycke/workflows/main/requirements/uv.txt | |
- name: Install Nuitka | |
# XXX We cannot break the long "pip install" line below with a class "\" because it will not be able to run on | |
# Windows' shell: | |
# ParserError: D:\a\_temp\330d7ec7-c0bf-4856-b2d8-407b69be9ee2.ps1:4 | |
# Line | | |
# 4 | --requirement https://raw.githubusercontent.com/kdeldycke/workflows/m … | |
# | ~ | |
# | Missing expression after unary operator '--'. | |
# yamllint disable rule:line-length | |
run: | | |
uv --no-progress venv | |
uv --no-progress pip install --requirement https://raw.githubusercontent.com/kdeldycke/workflows/main/requirements/nuitka.txt | |
# yamllint enable | |
- name: Nuitka + compilers versions | |
# XXX Nuitka needs the ".cmd" extension on Windows: | |
# https://github.com/Nuitka/Nuitka/issues/3173 | |
# https://github.com/astral-sh/uv/issues/8770 | |
# https://github.com/astral-sh/uv/pull/9099 | |
run: > | |
uv --no-progress run --frozen -- nuitka${{ runner.os == 'Windows' && '.cmd' || '' }} | |
--version | |
- name: Build binary | |
run: > | |
uv --no-progress run --frozen -- nuitka${{ runner.os == 'Windows' && '.cmd' || '' }} | |
--onefile --assume-yes-for-downloads --output-filename=${{ matrix.bin_name }} | |
${{ matrix.module_path }} | |
- name: Upload binaries | |
uses: actions/upload-artifact@v4.4.3 | |
with: | |
name: ${{ matrix.bin_name }} | |
if-no-files-found: error | |
path: ${{ matrix.bin_name }} | |
test-binaries: | |
name: Test binaries | |
needs: | |
- project-metadata | |
- compile-binaries | |
if: needs.project-metadata.outputs.nuitka_matrix | |
strategy: | |
matrix: ${{ fromJSON(needs.project-metadata.outputs.nuitka_matrix) }} | |
runs-on: ${{ matrix.os }} | |
steps: | |
- name: Download artifact | |
uses: actions/download-artifact@v4.1.8 | |
id: artifacts | |
with: | |
name: ${{ matrix.bin_name }} | |
- name: Set binary permissions | |
if: runner.os != 'Windows' | |
run: | | |
chmod +x ${{ steps.artifacts.outputs.download-path }}/${{ matrix.bin_name }} | |
- name: Run tests | |
shell: python | |
run: | | |
import shlex | |
import sys | |
from subprocess import run, SubprocessError | |
from textwrap import dedent | |
from pathlib import Path | |
# Load test plan from workflow input, or use a default one. | |
test_plan = r"""${{ inputs.binaries-test-plan }}""".strip() | |
if not test_plan: | |
test_plan = dedent("""\ | |
# Example of a test plan for a binary. | |
# Each line is a test, composed of CLI parameters. | |
# Empty lines and lines starting with "#" are ignored. | |
# Output the version of the CLI. | |
--version | |
# Test combination of version and verbosity. | |
--verbosity DEBUG --version | |
# Test help output. | |
--help | |
""") | |
# Load timeout from workflow input, or use a default one. | |
timeout = r"${{ inputs.timeout }}".strip() | |
if timeout: | |
assert timeout.isdigit() | |
timeout = int(timeout) | |
if not timeout: | |
timeout = 60 | |
else: | |
assert timeout > 0 | |
# Resolve binary path. | |
bin_name = r"${{ matrix.bin_name }}" | |
bin_path = (Path(r"${{ steps.artifacts.outputs.download-path }}") / bin_name).resolve(strict=True) | |
assert bin_path.is_file() | |
# Run tests. | |
for index, params in enumerate(test_plan.splitlines()): | |
params = params.strip() | |
if not params or params.startswith("#"): | |
continue | |
print(f"\n======== Test from line #{index + 1} ========\n$ {bin_name} {params}") | |
cli = [str(bin_path)] | |
if sys.platform == "win32": | |
cli.extend(params.split()) | |
else: | |
cli.extend(shlex.split(params)) | |
try: | |
result = run( | |
cli, | |
capture_output=True, | |
shell=True, | |
timeout=timeout, | |
check=True, | |
# XXX Do not force encoding to let CLIs figure out by themselves the contextual encoding to use. | |
# This avoid UnicodeDecodeError on output in Window's console which still defaults to legacy | |
# encoding (e.g. cp1252, cp932, etc...): | |
# | |
# Traceback (most recent call last): | |
# File "C:\...\__main__.py", line 49, in <module> | |
# File "C:\...\__main__.py", line 45, in main | |
# File "C:\...\click\core.py", line 1157, in __call__ | |
# File "C:\...\click_extra\commands.py", line 347, in main | |
# File "C:\...\click\core.py", line 1078, in main | |
# File "C:\...\click_extra\commands.py", line 377, in invoke | |
# File "C:\...\click\core.py", line 1688, in invoke | |
# File "C:\...\click_extra\commands.py", line 377, in invoke | |
# File "C:\...\click\core.py", line 1434, in invoke | |
# File "C:\...\click\core.py", line 783, in invoke | |
# File "C:\...\cloup\_context.py", line 47, in new_func | |
# File "C:\...\meta_package_manager\cli.py", line 570, in managers | |
# File "C:\...\meta_package_manager\output.py", line 187, in print_table | |
# File "C:\...\click_extra\tabulate.py", line 97, in render_csv | |
# File "encodings\cp1252.py", line 19, in encode | |
# UnicodeEncodeError: 'charmap' codec can't encode character '\u2713' in position 128: | |
# character maps to <undefined> | |
# | |
# encoding="utf-8", | |
text=True, | |
) | |
except SubprocessError as ex: | |
print(f"\n=== stdout ===\n{ex.stdout}") | |
print(f"\n=== stderr ===\n{ex.stderr}") | |
raise ex | |
print(result.stdout) | |
git-tag: | |
name: Tag release | |
needs: | |
- project-metadata | |
# Only consider pushes to main branch as triggers for releases. | |
if: github.ref == 'refs/heads/main' && needs.project-metadata.outputs.release_commits_matrix | |
strategy: | |
matrix: ${{ fromJSON(needs.project-metadata.outputs.release_commits_matrix) }} | |
runs-on: ubuntu-24.04 | |
steps: | |
- uses: actions/checkout@v4.2.2 | |
with: | |
ref: ${{ matrix.commit }} | |
# XXX We need custom PAT with workflows permissions because tag generation will work but it will not trigger | |
# any other workflows that use `on.push.tags` triggers. See: | |
# https://stackoverflow.com/questions/60963759/use-github-actions-to-create-a-tag-but-not-a-release#comment135891921_64479344 | |
# https://github.com/orgs/community/discussions/27028 | |
token: ${{ secrets.WORKFLOW_UPDATE_GITHUB_PAT || secrets.GITHUB_TOKEN }} | |
- name: Check if tag exists | |
id: tag_exists | |
run: | | |
echo "tag_exists=$(git show-ref --tags "v${{ matrix.current_version }}" --quiet )" >> "$GITHUB_OUTPUT" | |
- name: Tag search results | |
run: | | |
echo "Does tag exist? ${{ steps.tag_exists.outputs.tag_exists && true || false }}" | |
- name: Push tag | |
# Skip the tag creation if it already exists instead of failing flat. This allows us to re-run the workflow if | |
# it was interrupted the first time. Which is really useful if the tagging fails during a release: we can | |
# simply push the new tag by hand and re-launch the workflow run. | |
if: ${{ ! steps.tag_exists.outputs.tag_exists }} | |
run: | | |
git tag "v${{ matrix.current_version }}" "${{ matrix.commit }}" | |
git push origin "v${{ matrix.current_version }}" | |
pypi-publish: | |
name: Publish to PyPi | |
needs: | |
- project-metadata | |
- package-build | |
- git-tag | |
if: needs.project-metadata.outputs.package_name | |
strategy: | |
matrix: ${{ fromJSON(needs.project-metadata.outputs.release_commits_matrix) }} | |
runs-on: ubuntu-24.04 | |
steps: | |
- name: Install uv | |
run: | | |
python -m pip install -r https://raw.githubusercontent.com/kdeldycke/workflows/main/requirements/uv.txt | |
- name: Download build artifacts | |
uses: actions/download-artifact@v4.1.8 | |
id: download | |
with: | |
name: ${{ github.event.repository.name }}-build-${{ matrix.short_sha }} | |
- name: Push to PyPi | |
run: | | |
uv --no-progress publish --token "${{ secrets.PYPI_TOKEN }}" "${{ steps.download.outputs.download-path }}/*" | |
github-release: | |
name: Publish GitHub release | |
needs: | |
- project-metadata | |
- compile-binaries | |
- git-tag | |
- pypi-publish | |
# Make sure this job always starts if git-tag ran and succeeded. | |
if: always() && needs.git-tag.result == 'success' | |
strategy: | |
matrix: ${{ fromJSON(needs.project-metadata.outputs.release_commits_matrix) }} | |
runs-on: ubuntu-24.04 | |
steps: | |
- name: Download all artifacts | |
# Do not try to fetch build artifacts if any of the job producing them was skipped. | |
if: needs.pypi-publish.result != 'skipped' || needs.compile-binaries.result != 'skipped' | |
uses: actions/download-artifact@v4.1.8 | |
id: artifacts | |
with: | |
path: release_artifact | |
# Only consider artifacts produced by the release commit. | |
pattern: "*-build-${{ matrix.short_sha }}*" | |
merge-multiple: true | |
- name: Rename binary artifacts, collect all others | |
# Do not try to rename artifacts if the job producing them was skipped. | |
if: needs.compile-binaries.result != 'skipped' | |
id: rename_artifacts | |
shell: python | |
run: | | |
import json | |
import os | |
from pathlib import Path | |
from random import randint | |
download_folder = Path("""${{ steps.artifacts.outputs.download-path }}""") | |
nuitka_matrix = json.loads("""${{ needs.project-metadata.outputs.nuitka_matrix }}""") | |
binaries = {entry["bin_name"] for entry in nuitka_matrix["include"] if "bin_name" in entry} | |
artifacts_path = [] | |
for artifact in download_folder.glob("*"): | |
print(f"Processing {artifact} ...") | |
assert artifact.is_file() | |
# Rename binary artifacts to remove the build ID. | |
if artifact.name in binaries: | |
new_name = f'{artifact.stem.split("""-build-${{ matrix.short_sha }}""", 1)[0]}{artifact.suffix}' | |
new_path = artifact.with_name(new_name) | |
print(f"Renaming {artifact} to {new_path} ...") | |
assert not new_path.exists() | |
artifact.rename(new_path) | |
artifacts_path.append(new_path) | |
# Collect other artifacts as-is. | |
else: | |
print(f"Collecting {artifact} ...") | |
artifacts_path.append(artifact) | |
# Produce a unique delimiter to feed multiline content to GITHUB_OUTPUT: | |
# https://github.com/orgs/community/discussions/26288#discussioncomment-3876281 | |
delimiter = f"ghadelimiter_{randint(10**8, (10**9) - 1)}" | |
output = f"artifacts_path<<{delimiter}\n" | |
output += "\n".join(str(p) for p in artifacts_path) | |
output += f"\n{delimiter}" | |
env_file = Path(os.getenv("GITHUB_OUTPUT")) | |
env_file.write_text(output) | |
- name: Create GitHub release | |
uses: softprops/action-gh-release@v2.1.0 | |
# XXX We need custom PAT with workflows permissions because tag generation will work but it will not trigger | |
# any other workflows that use `on.push.tags` triggers. See: | |
# https://stackoverflow.com/questions/60963759/use-github-actions-to-create-a-tag-but-not-a-release#comment135891921_64479344 | |
# https://github.com/orgs/community/discussions/27028 | |
env: | |
GITHUB_TOKEN: ${{ secrets.WORKFLOW_UPDATE_GITHUB_PAT || secrets.GITHUB_TOKEN }} | |
with: | |
tag_name: v${{ matrix.current_version }} | |
target_commitish: ${{ matrix.commit }} | |
files: ${{ steps.rename_artifacts.outputs.artifacts_path }} | |
body: ${{ needs.project-metadata.outputs.release_notes }} |