chore: release 0.36.0 #1353
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 | |
| # ─── Package Registry Configuration ──────────────────────────────────────────── | |
| # Edit these constants to change package names, environments, and registries. | |
| # All values are referenced via ${{ env.VAR }} throughout the workflow. | |
| env: | |
| # PyPI | |
| PYPI_PACKAGE: headroom-ai | |
| PYPI_ENVIRONMENT: pypi | |
| # npm (npmjs.org) | |
| NPM_REGISTRY_URL: https://registry.npmjs.org | |
| NPM_SDK_PACKAGE: headroom-ai | |
| NPM_OPENCLAW_PACKAGE: headroom-openclaw | |
| NPM_OPENCODE_PACKAGE: headroom-opencode | |
| # GitHub Package Registry | |
| GITHUB_PACKAGES_REGISTRY_URL: https://npm.pkg.github.com | |
| # ─── Safety Gates ────────────────────────────────────────────────────────────── | |
| # Set to 'true' to skip a publish target (e.g., when tokens are not configured). | |
| # In GitHub: repo Settings → Variables → Actions Variables → New repository variable. | |
| # Locally via act: pass -e event.yml or set in .actrc.local (see .actrc.example). | |
| PYPI_SKIP: "false" | |
| NPM_SKIP: "false" | |
| GH_PACKAGES_SKIP: "false" | |
| on: | |
| # Triggered when release-please's release PR is merged: the bot | |
| # tags the merge commit `vX.Y.Z` and creates a GitHub Release on | |
| # that tag, which fires the `release: published` event. Ordinary | |
| # pushes to main no longer trigger publishes — that was the old | |
| # behavior that burned PyPI's per-project storage quota by | |
| # uploading a fresh wheel matrix (~200 MB) per merged fix/feat PR. | |
| # See .github/workflows/release-please.yml for the PR-aggregator. | |
| release: | |
| types: [published] | |
| # X2: PR-time release dry-run. Trigger on PRs that touch release- | |
| # critical paths so the wheel matrix + smoke-import gate run BEFORE | |
| # merge — not after the tag is pushed and main is broken. Path | |
| # filter is deliberately narrow: source-only PRs to headroom-core | |
| # / headroom-proxy don't change wheel layout, so they don't need | |
| # the dry-run. Issues this would have caught: #379 (docker bake | |
| # name=), #382 (sdist os-mismatch), #384/#385/#386 (glibc shim | |
| # iterations), #387's own shellcheck regression. | |
| pull_request: | |
| paths: | |
| - ".github/workflows/release.yml" | |
| - ".github/workflows/docker.yml" | |
| - "crates/headroom-py/**" | |
| - "pyproject.toml" | |
| - "scripts/verify-versions.py" | |
| - "scripts/version-sync.py" | |
| - "scripts/build_npm_release_assets.mjs" | |
| - "scripts/verify_npm_release_assets.mjs" | |
| - "Cargo.toml" | |
| - "Cargo.lock" | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Manual version override" | |
| required: false | |
| dry_run: | |
| description: "Skip publish" | |
| type: boolean | |
| default: false | |
| concurrency: | |
| # Main release runs are namespaced by ref_name and never cancel | |
| # (cancel-in-progress: false) — losing a tag-push release mid-flight | |
| # would mean PyPI/Docker get partial state. PR dry-runs are | |
| # namespaced by PR number and DO cancel: rapid PR pushes shouldn't | |
| # spawn N parallel wheel builds, only the latest matters. | |
| group: release-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| detect-version: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| version: ${{ steps.ver.outputs.version }} | |
| npm_version: ${{ steps.ver.outputs.npm_version }} | |
| canonical: ${{ steps.ver.outputs.canonical }} | |
| height: ${{ steps.ver.outputs.height }} | |
| bump: ${{ steps.ver.outputs.bump }} | |
| previous_tag: ${{ steps.ver.outputs.previous_tag }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| # When fired by release-please's release event, the tag the | |
| # bot just published IS the canonical version for this run — | |
| # release_version.py's git-log derivation would re-bump it. | |
| # workflow_dispatch still honors `inputs.version`; PR dry-runs | |
| # leave MANUAL_VER empty and the script computes normally. | |
| - name: Resolve MANUAL_VER from trigger | |
| id: manualver | |
| shell: bash | |
| env: | |
| RELEASE_TAG: ${{ github.event.release.tag_name }} | |
| MANUAL_INPUT: ${{ github.event.inputs.version }} | |
| run: | | |
| if [ "${{ github.event_name }}" = "release" ]; then | |
| # github.event.release.tag_name is "vX.Y.Z" — strip the | |
| # leading "v" so release_version.py's SemVer parser | |
| # accepts it. | |
| echo "value=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT" | |
| elif [ -n "$MANUAL_INPUT" ]; then | |
| echo "value=$MANUAL_INPUT" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "value=" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Compute semantic version from canonical + release history | |
| id: ver | |
| run: | | |
| python headroom/release_version.py | |
| env: | |
| MANUAL_VER: ${{ steps.manualver.outputs.value }} | |
| # Single source of truth for changelog + npm packaging. Wheels are | |
| # built per-platform in `build-wheels` below. publish-pypi merges them. | |
| build: | |
| needs: [detect-version] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.12" | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "20" | |
| - name: Sync version to package files | |
| run: | | |
| python scripts/version-sync.py --version ${{ needs.detect-version.outputs.npm_version }} | |
| - name: Verify package versions are synchronized | |
| run: | | |
| python scripts/verify-versions.py | |
| - name: Run changelog generation | |
| run: | | |
| PREV_TAG="${{ needs.detect-version.outputs.previous_tag }}" | |
| if [ -n "$PREV_TAG" ]; then | |
| python scripts/changelog-gen.py \ | |
| --version ${{ needs.detect-version.outputs.version }} \ | |
| --since "$PREV_TAG" | |
| else | |
| python scripts/changelog-gen.py \ | |
| --version ${{ needs.detect-version.outputs.version }} | |
| fi | |
| - name: Verify changelog exists | |
| run: | | |
| pwd | |
| ls -la .changelog.md | |
| cat .changelog.md | |
| - name: Upload changelog artifact | |
| run: | | |
| if [ -f .changelog.md ]; then | |
| echo "File exists, uploading..." | |
| ls -la .changelog.md | |
| cp .changelog.md /tmp/changelog-backup.md | |
| else | |
| echo "ERROR: .changelog.md does not exist!" | |
| exit 1 | |
| fi | |
| shell: bash | |
| - name: Upload changelog via action | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: changelog | |
| path: /tmp/changelog-backup.md | |
| if-no-files-found: error | |
| - name: Build npm release packages | |
| run: | | |
| node scripts/build_npm_release_assets.mjs "${{ needs.detect-version.outputs.npm_version }}" release-assets | |
| - name: Upload release assets artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: release-assets | |
| path: release-assets/ | |
| # Cross-platform wheel matrix. Each entry produces wheels for cp310/11/12/13 | |
| # in one maturin invocation (PyO3 ABI3 forward-compat handles 3.14+ until | |
| # we bump pyo3 past 0.22). The `headroom-ai` wheel contains the entire | |
| # Python source under `headroom/` plus the compiled `headroom/_core.so` | |
| # — one atomic install via `pip install headroom-ai`. | |
| build-wheels: | |
| needs: [detect-version, build] | |
| # Minimal privilege: this job only checks out source, builds wheels, | |
| # and uploads them as artifacts. It doesn't push, write packages, or | |
| # mutate releases — `contents: read` is sufficient. Mitigates CodeQL | |
| # alert "actions/missing-workflow-permissions" (CWE-275). | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # Use manylinux_2_28 explicitly (instead of `auto`, which resolved | |
| # to manylinux2014 / CentOS 7 / OpenSSL 1.0.2k — too old for | |
| # `openssl-sys 0.9`). manylinux_2_28 is AlmaLinux 8 / glibc 2.28, | |
| # the same baseline our e2e Dockerfiles use post-#360. The | |
| # `openssl/vendored` Cargo feature (added in headroom-proxy) | |
| # compiles OpenSSL from source so the system version doesn't | |
| # matter, but pinning the floor keeps us off CentOS 7's ancient | |
| # gcc/glibc surface anyway. | |
| - os: ubuntu-24.04 | |
| target: x86_64-unknown-linux-gnu | |
| manylinux: 2_28 | |
| # Native arm64 runner (`ubuntu-24.04-arm`, GA Jan 2025, free | |
| # for public repos) replaces the previous `ubuntu-latest` + | |
| # QEMU path. Building inside `manylinux_2_28_aarch64` natively | |
| # on an ARM host cuts the aarch64 wheel build from ~50–60 min | |
| # (emulated) to ~10 min. Wheel ABI is unchanged — the | |
| # `manylinux: 2_28` field still pins the runtime glibc floor; | |
| # `cargo tree` regression in tests/test_release_workflows.py | |
| # keeps the resolved build graph identical. | |
| - os: ubuntu-24.04-arm | |
| target: aarch64-unknown-linux-gnu | |
| manylinux: 2_28 | |
| # Intel macOS x86_64. `ort-sys 2.0.0-rc.12` does not ship | |
| # prebuilt ONNX Runtime binaries for this triple, so | |
| # `headroom-core/Cargo.toml` selects `ort-load-dynamic` (same | |
| # as Windows) and `headroom/_ort.py` pins `ORT_DYLIB_PATH` to | |
| # the pip `onnxruntime` dylib at import time. | |
| - os: macos-15-intel | |
| target: x86_64-apple-darwin | |
| manylinux: "" | |
| - os: macos-14 | |
| target: aarch64-apple-darwin | |
| manylinux: "" | |
| # Windows x86_64. `windows-latest` ships the MSVC toolchain and | |
| # `maturin-action` provisions the Rust toolchain, so users no | |
| # longer need a local Rust install to `pip install headroom-ai` | |
| # on Windows (issue #1328 — sdist build was failing in sandboxed | |
| # / air-gapped environments that can't reach static.rust-lang.org | |
| # or crates.io). `manylinux` is meaningless on Windows (that tag | |
| # is Linux-only) — leave it empty. The ONNX Runtime story is | |
| # already handled: `crates/headroom-core/Cargo.toml` selects | |
| # `ort-load-dynamic` under `cfg(windows)` and Intel macOS, so | |
| # the wheel does not bundle/link platform ORT SDK libs and loads | |
| # ORT at runtime instead. | |
| - os: windows-latest | |
| target: x86_64-pc-windows-msvc | |
| manylinux: "" | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.11" | |
| - name: Sync version to pyproject.toml + Cargo.toml | |
| shell: bash | |
| run: | | |
| python scripts/version-sync.py --version ${{ needs.detect-version.outputs.npm_version }} | |
| - name: Verify package versions are synchronized | |
| run: | | |
| python scripts/verify-versions.py | |
| - name: Build wheels | |
| uses: PyO3/maturin-action@v1 | |
| with: | |
| target: ${{ matrix.target }} | |
| args: --release --out dist --interpreter python3.10 python3.11 python3.12 python3.13 | |
| manylinux: ${{ matrix.manylinux }} | |
| # No before-script-linux needed: the rustls-everywhere refactor | |
| # (PR #371) switched fastembed to `hf-hub-rustls-tls` + | |
| # `ort-download-binaries-rustls-tls` features, which removed | |
| # `openssl-sys` from our build tree entirely. The wheel build | |
| # no longer needs system OpenSSL or any perl modules. | |
| # `cargo tree -p headroom-py -i openssl-sys` returns "not | |
| # found" — that's the regression gate (see test_release_workflows | |
| # ::test_no_openssl_sys_in_wheel_build_tree). | |
| env: | |
| # PyO3 0.22 supports up to Python 3.13; allow forward-compat | |
| # builds for 3.14+ until we bump PyO3 (tracked separately). | |
| PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" | |
| # Build sdist exactly once across the matrix. We key the | |
| # conditional on `target` only — sdist is platform-independent | |
| # so any single matrix row is a fine host. Earlier this | |
| # conditional was `matrix.os == 'ubuntu-latest' && matrix.target | |
| # == 'x86_64-unknown-linux-gnu'`. PR #376 pinned `os` to | |
| # `ubuntu-24.04` (instead of the moving `ubuntu-latest` alias) | |
| # and silently broke this `if`, so sdist never built and | |
| # `gh release upload release-assets/*.tar.gz` failed with | |
| # "no matches found". Keying on `target` decouples the sdist | |
| # build from any future `os` rename. | |
| - name: Build sdist (linux x86_64 only — sdist is platform-independent) | |
| if: matrix.target == 'x86_64-unknown-linux-gnu' | |
| uses: PyO3/maturin-action@v1 | |
| with: | |
| command: sdist | |
| args: --out dist | |
| - name: Verify sdist license-file metadata matches tarball contents | |
| if: matrix.target == 'x86_64-unknown-linux-gnu' | |
| run: | | |
| python - <<'PY' | |
| # PyPI rejects sdists whose `License-File:` metadata entries | |
| # (PEP 639) reference files that aren't physically present | |
| # in the tarball: `400 License-File X does not exist in | |
| # distribution file ... at <root>/X`. This check catches | |
| # that divergence before upload by parsing PKG-INFO's | |
| # License-File lines and asserting each one resolves to a | |
| # real tarball member. Issue trail: sdist publish broke at | |
| # v0.20.16 when the hatch -> maturin migration in 2a91cbb | |
| # dropped NOTICE from the sdist `include` list while PEP 639 | |
| # still emitted it as a License-File. Was masked for ~22 | |
| # releases by an earlier twine "File already exists" failure | |
| # on duplicate wheels; surfaced once PR #412 added | |
| # skip-existing. | |
| from pathlib import Path | |
| import tarfile | |
| sdists = sorted(Path("dist").glob("*.tar.gz")) | |
| if len(sdists) != 1: | |
| raise SystemExit(f"expected exactly one sdist in dist/, found {len(sdists)}") | |
| with tarfile.open(sdists[0], "r:gz") as archive: | |
| names = set(archive.getnames()) | |
| roots = {name.split("/", 1)[0] for name in names if "/" in name} | |
| if len(roots) != 1: | |
| raise SystemExit(f"expected one sdist root directory, found {sorted(roots)}") | |
| root = roots.pop() | |
| pkg_info_path = f"{root}/PKG-INFO" | |
| member = archive.getmember(pkg_info_path) | |
| fh = archive.extractfile(member) | |
| if fh is None: | |
| raise SystemExit(f"could not read {pkg_info_path} from {sdists[0].name}") | |
| pkg_info = fh.read().decode("utf-8") | |
| declared = [] | |
| for line in pkg_info.splitlines(): | |
| if not line.strip(): | |
| break # headers ended (blank line separator); rest is README body | |
| if line.startswith("License-File:"): | |
| declared.append(line.split(":", 1)[1].strip()) | |
| if not declared: | |
| raise SystemExit(f"{sdists[0].name} PKG-INFO declares no License-File entries - expected at least LICENSE") | |
| missing = [name for name in declared if f"{root}/{name}" not in names] | |
| if missing: | |
| raise SystemExit( | |
| f"{sdists[0].name} declares License-File entries that are missing from the tarball: {missing}. " | |
| f"Add them to `[tool.maturin].include` with `format = \"sdist\"`." | |
| ) | |
| print(f"sdist License-File metadata OK ({len(declared)} files, all present): {declared}") | |
| PY | |
| # Audit each Linux wheel's dynamic symbol references against its | |
| # declared manylinux glibc floor. Catches the bug class from | |
| # issue #355 (#386): a manylinux_2_28 wheel that ends up | |
| # referencing `__isoc23_strtoll` (glibc 2.38+) because a | |
| # statically-linked C++ dep was compiled with a recent toolchain. | |
| # The build host has glibc 2.38 so the link succeeds and CI | |
| # passes — but every customer with libc < 2.38 sees | |
| # `ImportError: undefined symbol: __isoc23_strtoll` at | |
| # `import headroom._core`. The script compares every UND symbol | |
| # against the wheel's manylinux tag and fails the release if | |
| # any symbol exceeds the floor. | |
| - name: Audit wheel glibc symbols (Linux only) | |
| if: startsWith(matrix.target, 'x86_64-unknown-linux') || startsWith(matrix.target, 'aarch64-unknown-linux') | |
| run: | | |
| set -e | |
| for whl in dist/*.whl; do | |
| python3 scripts/audit_wheel_glibc_symbols.py "$whl" | |
| done | |
| - name: Upload wheels artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: wheels-${{ matrix.os }}-${{ matrix.target }} | |
| path: dist/* | |
| # Aggregator step: merge all wheel artifacts + the npm release assets | |
| # into the canonical `dist/` directory for downstream publishing jobs. | |
| collect-dist: | |
| needs: [build, build-wheels] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Download all wheel artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: wheels-* | |
| path: wheels-tmp/ | |
| merge-multiple: true | |
| - name: Download release assets | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: release-assets | |
| path: release-assets/ | |
| - name: Stage final dist directory | |
| run: | | |
| mkdir -p dist | |
| cp -v wheels-tmp/*.whl wheels-tmp/*.tar.gz dist/ 2>/dev/null || true | |
| ls -la dist/ | |
| # Mirror the wheels into release-assets so create-release uploads them. | |
| cp -v dist/*.whl dist/*.tar.gz release-assets/ 2>/dev/null || true | |
| ls -la release-assets/ | |
| - name: Upload merged dist artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: dist | |
| path: dist/ | |
| - name: Upload merged release-assets artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: release-assets-merged | |
| path: release-assets/ | |
| # ─── Wheel smoke-import gate ─────────────────────────────────────────────── | |
| # Issue #355's bug class: `manylinux_2_28` wheels that build cleanly, | |
| # pass clippy/tests on the build host, pass auditwheel — and then | |
| # fail to import on a customer's box because of a runtime symbol | |
| # mismatch (#355: `__isoc23_strtoll` from gcc-14 ORT prebuilts; | |
| # earlier #371: openssl-sys's C23 wrappers). | |
| # | |
| # This job spins up a matrix of representative customer environments | |
| # — the manylinux floor we promise + common older/newer glibc + macOS | |
| # native — and runs the actual `import headroom._core` check the | |
| # proxy's `_check_rust_core` does at startup. Failure here BLOCKS | |
| # publish-pypi, publish-docker, and create-release. Better to find | |
| # a broken wheel here than 8 minutes after `pypa/gh-action-pypi-publish` | |
| # has already pushed it to the world. | |
| # | |
| # See also `scripts/audit_wheel_glibc_symbols.py` (the static-symbol | |
| # gate added in PR #384). The audit catches symbol references above | |
| # the floor; this smoke matrix catches the dynamic-link failures | |
| # that survive the static check (linker order quirks, runtime | |
| # dlopen RPATH issues, missing transitive native deps). | |
| smoke-import-wheels: | |
| needs: [build-wheels] | |
| if: github.event.inputs.dry_run != 'true' | |
| runs-on: ${{ matrix.runner }} | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # Linux x86_64 — span manylinux floor + customer glibcs. | |
| # `quay.io/pypa/manylinux_2_28_x86_64` is the floor we | |
| # promise; if the wheel fails here, our manylinux tag is | |
| # a lie. `ubuntu:22.04` (glibc 2.35) is the environment | |
| # from issue #355's reporter. | |
| # | |
| # NOTE: `ubuntu:20.04 + python 3.10` was a third entry, | |
| # dropped on PR #396 because the deadsnakes PPA install | |
| # path stopped reliably provisioning python3.10-venv on | |
| # focal once Ubuntu 20.04 hit End of Standard Support | |
| # (May 2025). The failure mode was an apt-get error, NOT | |
| # a wheel-import error — i.e. the test environment, not | |
| # the wheel. Promising the wheel works on glibc 2.31 in | |
| # CI now requires either ESM-tier images or pinning a | |
| # specific deadsnakes snapshot, neither of which we want | |
| # owning. Manylinux 2.28 (glibc 2.28) is still the floor | |
| # we promise; ubuntu:22.04 covers the most-reported | |
| # customer environment. | |
| - { runner: ubuntu-24.04, image: "quay.io/pypa/manylinux_2_28_x86_64", python: "3.11", wheel_target: x86_64-unknown-linux-gnu, wheel_artifact: wheels-ubuntu-24.04-x86_64-unknown-linux-gnu, glibc_label: "2.28-floor" } | |
| - { runner: ubuntu-24.04, image: "ubuntu:22.04", python: "3.12", wheel_target: x86_64-unknown-linux-gnu, wheel_artifact: wheels-ubuntu-24.04-x86_64-unknown-linux-gnu, glibc_label: "2.35" } | |
| # Linux aarch64 — floor + one customer env. Native arm64 | |
| # runners (PR #376) host the container. | |
| - { runner: ubuntu-24.04-arm, image: "quay.io/pypa/manylinux_2_28_aarch64", python: "3.11", wheel_target: aarch64-unknown-linux-gnu, wheel_artifact: wheels-ubuntu-24.04-arm-aarch64-unknown-linux-gnu, glibc_label: "2.28-floor" } | |
| - { runner: ubuntu-24.04-arm, image: "ubuntu:22.04", python: "3.12", wheel_target: aarch64-unknown-linux-gnu, wheel_artifact: wheels-ubuntu-24.04-arm-aarch64-unknown-linux-gnu, glibc_label: "2.35" } | |
| # macOS arm64 — runs natively on the host runner; no container. | |
| - { runner: macos-14, image: "", python: "3.13", wheel_target: aarch64-apple-darwin, wheel_artifact: wheels-macos-14-aarch64-apple-darwin, glibc_label: "" } | |
| # macOS x86_64 (Intel) — `ort-load-dynamic`; see build-wheels | |
| # matrix comment for rationale. | |
| - { runner: macos-15-intel, image: "", python: "3.12", wheel_target: x86_64-apple-darwin, wheel_artifact: wheels-macos-15-intel-x86_64-apple-darwin, glibc_label: "" } | |
| # Windows x86_64 — runs natively on the host runner; no | |
| # container. `image: ""` routes this row past the Linux docker | |
| # path; a dedicated PowerShell step below installs + imports the | |
| # wheel (venv layout differs: Scripts\ not bin/). This is the | |
| # gate that issue #1328's win_amd64 wheel must clear before | |
| # publish. | |
| - { runner: windows-latest, image: "", python: "3.12", wheel_target: x86_64-pc-windows-msvc, wheel_artifact: wheels-windows-latest-x86_64-pc-windows-msvc, glibc_label: "" } | |
| steps: | |
| - name: Download wheels artifact for this target | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ matrix.wheel_artifact }} | |
| path: dist/ | |
| # Windows is the only host-runner row that can't rely on a | |
| # preinstalled interpreter at the exact requested minor (the Linux | |
| # rows install inside their container; the macOS runner ships | |
| # multiple Pythons). Provision it explicitly so the requested minor | |
| # is on PATH as `python` for the smoke step below. | |
| - name: Set up Python (Windows host) | |
| if: matrix.image == '' && runner.os == 'Windows' | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: ${{ matrix.python }} | |
| - name: Stage smoke-import script | |
| # The smoke script lives in a host file rather than an inline | |
| # heredoc/`-c` invocation. Rationale: | |
| # | |
| # 1. The Linux job wraps the smoke check in `bash -ec '<long>'` | |
| # where the outer single quote preserves whitespace AND | |
| # forbids any internal single quote (closing it terminates | |
| # the script). A `<<PY ... PY` heredoc inside the inner bash | |
| # inherits the YAML indentation, so the closing `PY` is at | |
| # column 14, never matched (caught post-#387 merge: bash | |
| # "here-document at line 65 delimited by end-of-file"). | |
| # 2. Switching to `python -c "<f-string>"` solves the heredoc | |
| # indent problem but reintroduces the single-quote nesting | |
| # issue (Python f-strings need quote chars). | |
| # 3. A host-side file dodges both: bash heredoc body lives at | |
| # YAML `run: |` level (uniform strip), the docker container | |
| # sees it as a read-only mount, and macOS host runs the | |
| # same script — no quoting drift between paths. | |
| # | |
| # `shell: bash` is explicit because the Windows runner defaults to | |
| # pwsh, which can't run this heredoc. GitHub-hosted windows-latest | |
| # ships Git Bash, and `${RUNNER_TEMP}` resolves there too — the | |
| # Windows smoke step below reads the same file via `$env:RUNNER_TEMP`. | |
| shell: bash | |
| run: | | |
| cat > "${RUNNER_TEMP}/smoke_import.py" <<'PY' | |
| import sys | |
| import headroom | |
| from headroom._core import hello as _rust_hello | |
| print(f"smoke-import OK: python={sys.version_info[:3]} hello={_rust_hello()}") | |
| PY | |
| - name: Smoke-import wheel inside container (Linux) | |
| if: matrix.image != '' | |
| env: | |
| IMAGE: ${{ matrix.image }} | |
| PYTHON_VERSION: ${{ matrix.python }} | |
| WHEEL_TARGET: ${{ matrix.wheel_target }} | |
| GLIBC_LABEL: ${{ matrix.glibc_label }} | |
| run: | | |
| set -e | |
| # The container script intentionally avoids `<<EOF` heredoc | |
| # interpolation by passing values via env vars — keeps the | |
| # shell quoting straightforward and makes failure modes | |
| # easier to debug. | |
| docker run --rm \ | |
| -v "$(pwd)/dist:/wheels:ro" \ | |
| -v "${RUNNER_TEMP}/smoke_import.py:/smoke_import.py:ro" \ | |
| -e PYTHON_VERSION="$PYTHON_VERSION" \ | |
| -e WHEEL_TARGET="$WHEEL_TARGET" \ | |
| -e GLIBC_LABEL="$GLIBC_LABEL" \ | |
| "$IMAGE" \ | |
| bash -ec ' | |
| set -e | |
| echo "=== smoke-import: image=$0 python=$PYTHON_VERSION glibc=$GLIBC_LABEL target=$WHEEL_TARGET ===" "'"$IMAGE"'" | |
| # Locate a Python interpreter matching the requested | |
| # version. manylinux images ship interpreters under | |
| # /opt/python/cp{XY}-cp{XY}/bin/; ubuntu/debian images | |
| # need an apt-get install. | |
| py_tag=cp$(echo "$PYTHON_VERSION" | tr -d .) | |
| if [ -d /opt/python ]; then | |
| python_bin="/opt/python/${py_tag}-${py_tag}/bin/python" | |
| if [ ! -x "$python_bin" ]; then | |
| echo "ERROR: $python_bin missing in $0" "'"$IMAGE"'" >&2 | |
| ls -la /opt/python >&2 | |
| exit 1 | |
| fi | |
| elif command -v apt-get >/dev/null 2>&1; then | |
| export DEBIAN_FRONTEND=noninteractive | |
| apt_retry() { | |
| apt_get_args="$*" | |
| for attempt in 1 2 3; do | |
| if apt-get "$@"; then | |
| return 0 | |
| fi | |
| echo "apt-get ${apt_get_args} failed on attempt ${attempt}; refreshing package lists" >&2 | |
| apt-get update -qq || true | |
| sleep $((attempt * 5)) | |
| done | |
| apt-get "$@" | |
| } | |
| apt_retry update -qq | |
| # ubuntu:20.04s default repo only ships 3.8; 3.10 lives | |
| # in deadsnakes. Add it on demand. ubuntu:22.04 has | |
| # 3.10/3.11 in main and 3.12 via deadsnakes. | |
| apt_retry install -y -qq --fix-missing --no-install-recommends ca-certificates software-properties-common >/dev/null | |
| add-apt-repository -y ppa:deadsnakes/ppa >/dev/null 2>&1 || true | |
| apt_retry update -qq | |
| apt_retry install -y -qq --fix-missing --no-install-recommends \ | |
| "python$PYTHON_VERSION" \ | |
| "python$PYTHON_VERSION-venv" \ | |
| "python$PYTHON_VERSION-distutils" >/dev/null 2>&1 \ | |
| || apt_retry install -y -qq --fix-missing --no-install-recommends \ | |
| "python$PYTHON_VERSION" \ | |
| "python$PYTHON_VERSION-venv" >/dev/null | |
| python_bin="python$PYTHON_VERSION" | |
| else | |
| echo "ERROR: image has neither /opt/python nor apt-get" >&2 | |
| exit 1 | |
| fi | |
| "$python_bin" --version | |
| # Print glibc version so failures show what we are | |
| # actually testing against. | |
| ldd --version | head -1 || true | |
| # Pick the wheel that matches python version + arch. | |
| case "$WHEEL_TARGET" in | |
| x86_64-unknown-linux-gnu) arch_tag=manylinux_2_28_x86_64 ;; | |
| aarch64-unknown-linux-gnu) arch_tag=manylinux_2_28_aarch64 ;; | |
| *) echo "ERROR: unknown wheel target $WHEEL_TARGET" >&2; exit 1 ;; | |
| esac | |
| whl=$(find /wheels -maxdepth 1 -type f -name "headroom_ai-*-${py_tag}-${py_tag}-${arch_tag}.whl" -print -quit) | |
| if [ -z "$whl" ]; then | |
| # No version-specific wheel — fall back to a stable-ABI | |
| # (abi3) wheel. A cp3<floor>-abi3 wheel installs on any | |
| # CPython >= floor, which is what makes a single wheel | |
| # forward-compatible to 3.14+ (Cargo.toml pyo3 abi3-py310). | |
| # Pick the abi3 wheel whose floor is <= the interpreter. | |
| py_minor=${PYTHON_VERSION#*.} | |
| abi3=$(find /wheels -maxdepth 1 -type f -name "headroom_ai-*-abi3-${arch_tag}.whl" -print -quit) | |
| if [ -n "$abi3" ]; then | |
| abi3_base=${abi3##*/} | |
| abi3_floor=${abi3_base#*-cp3} | |
| abi3_floor=${abi3_floor%%-*} | |
| case "$abi3_floor" in *[!0-9]*) abi3_floor= ;; esac | |
| if [ -n "$abi3_floor" ] && [ "$abi3_floor" -le "$py_minor" ]; then whl="$abi3"; fi | |
| fi | |
| fi | |
| if [ -z "$whl" ]; then | |
| echo "ERROR: no wheel matching python=$PYTHON_VERSION arch=$arch_tag in /wheels/" >&2 | |
| find /wheels -maxdepth 1 -type f -printf "%f\n" >&2 | |
| exit 1 | |
| fi | |
| echo "Installing: $whl" | |
| "$python_bin" -m venv /tmp/venv | |
| /tmp/venv/bin/pip install --quiet --upgrade pip | |
| /tmp/venv/bin/pip install --quiet "$whl" | |
| # The actual smoke check — mirrors the proxy | |
| # `_check_rust_core` path that fails with exit-78 on | |
| # broken wheels. The script is mounted from the host via | |
| # `-v ${RUNNER_TEMP}/smoke_import.py:/smoke_import.py:ro`; | |
| # see the "Stage smoke-import script" step above for why | |
| # an inline heredoc here would not work. | |
| /tmp/venv/bin/python /smoke_import.py | |
| ' | |
| - name: Smoke-import wheel on macOS host | |
| if: matrix.image == '' && runner.os == 'macOS' | |
| env: | |
| PYTHON_VERSION: ${{ matrix.python }} | |
| WHEEL_TARGET: ${{ matrix.wheel_target }} | |
| run: | | |
| set -e | |
| py_tag=cp$(echo "$PYTHON_VERSION" | tr -d .) | |
| case "$WHEEL_TARGET" in | |
| aarch64-apple-darwin) mac_arch=arm64 ;; | |
| x86_64-apple-darwin) mac_arch=x86_64 ;; | |
| *) echo "ERROR: unknown macOS wheel target $WHEEL_TARGET" >&2; exit 1 ;; | |
| esac | |
| whl=$(find dist -maxdepth 1 -type f -name "headroom_ai-*-${py_tag}-${py_tag}-macosx_*_${mac_arch}.whl" -print -quit) | |
| if [ -z "$whl" ]; then | |
| # Stable-ABI (abi3) fallback — see the Linux path above. A | |
| # cp3<floor>-abi3 wheel installs on any CPython >= floor. | |
| py_minor=${PYTHON_VERSION#*.} | |
| abi3=$(find dist -maxdepth 1 -type f -name "headroom_ai-*-abi3-macosx_*_${mac_arch}.whl" -print -quit) | |
| if [ -n "$abi3" ]; then | |
| abi3_base=${abi3##*/} | |
| abi3_floor=${abi3_base#*-cp3} | |
| abi3_floor=${abi3_floor%%-*} | |
| case "$abi3_floor" in *[!0-9]*) abi3_floor= ;; esac | |
| if [ -n "$abi3_floor" ] && [ "$abi3_floor" -le "$py_minor" ]; then whl="$abi3"; fi | |
| fi | |
| fi | |
| if [ -z "$whl" ]; then | |
| echo "ERROR: no macOS wheel matching python=$PYTHON_VERSION arch=$mac_arch target=$WHEEL_TARGET" | |
| find dist -maxdepth 1 -type f -exec basename {} \; | |
| exit 1 | |
| fi | |
| echo "Installing: $whl" | |
| # Use the system python at the requested minor version. The | |
| # macos-14 runner image preinstalls multiple Python versions. | |
| "python$PYTHON_VERSION" -m venv /tmp/venv | |
| /tmp/venv/bin/pip install --quiet --upgrade pip | |
| /tmp/venv/bin/pip install --quiet "$whl" | |
| # Same staged smoke script as the Linux container path uses, | |
| # invoked directly on the macOS host (no docker mount needed). | |
| /tmp/venv/bin/python "${RUNNER_TEMP}/smoke_import.py" | |
| - name: Smoke-import wheel on Windows host | |
| if: matrix.image == '' && runner.os == 'Windows' | |
| shell: pwsh | |
| env: | |
| PYTHON_VERSION: ${{ matrix.python }} | |
| run: | | |
| $ErrorActionPreference = "Stop" | |
| $pyTag = "cp" + ($env:PYTHON_VERSION -replace '\.','') | |
| # Windows wheels are tagged win_amd64. Prefer a version-specific | |
| # cp3XY wheel; fall back to a stable-ABI (abi3) wheel whose floor | |
| # is <= the interpreter minor — same selection logic as the Linux | |
| # and macOS paths, just expressed in PowerShell. | |
| $whl = Get-ChildItem -Path dist -Filter "headroom_ai-*-$pyTag-$pyTag-win_amd64.whl" -File | | |
| Select-Object -First 1 | |
| if (-not $whl) { | |
| $pyMinor = [int]($env:PYTHON_VERSION -split '\.')[1] | |
| $abi3 = Get-ChildItem -Path dist -Filter "headroom_ai-*-abi3-win_amd64.whl" -File | | |
| Where-Object { | |
| if ($_.Name -match '-cp3(\d+)-abi3-') { [int]$Matches[1] -le $pyMinor } else { $false } | |
| } | Select-Object -First 1 | |
| if ($abi3) { $whl = $abi3 } | |
| } | |
| if (-not $whl) { | |
| Write-Error "no Windows wheel matching python=$env:PYTHON_VERSION (win_amd64)" | |
| Get-ChildItem -Path dist -File | ForEach-Object { $_.Name } | |
| exit 1 | |
| } | |
| Write-Host "Installing: $($whl.FullName)" | |
| # `actions/setup-python` (above) put the requested minor on PATH | |
| # as `python`, so use it directly. The venv exposes the | |
| # interpreter under Scripts\python.exe (not bin/), which is why | |
| # Windows needs its own step rather than reusing the macOS path. | |
| python -m venv venv | |
| venv\Scripts\python.exe -m pip install --quiet --upgrade pip | |
| venv\Scripts\python.exe -m pip install --quiet "$($whl.FullName)" | |
| # Same staged smoke script as the other host/container paths. | |
| venv\Scripts\python.exe "$env:RUNNER_TEMP\smoke_import.py" | |
| publish-pypi: | |
| needs: [collect-dist, smoke-import-wheels] | |
| # X2: skip on pull_request — dry-run only verifies build+smoke, | |
| # never publishes. The other two predicates remain (workflow_dispatch | |
| # dry_run, vars-level skip). | |
| if: github.event_name != 'pull_request' && github.event.inputs.dry_run != 'true' && vars.PYPI_SKIP != 'true' | |
| environment: pypi # NOTE: environment name must be a literal; update here if the GitHub environment name changes | |
| runs-on: ubuntu-latest | |
| permissions: | |
| id-token: write # Required for OIDC trusted publishing | |
| steps: | |
| - name: Download merged dist artifact (sdist + cross-platform wheels) | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: dist | |
| path: dist/ | |
| - name: Publish ${{ env.PYPI_PACKAGE }} to PyPI | |
| id: pypi-publish | |
| uses: pypa/gh-action-pypi-publish@v1.13.0 | |
| with: | |
| # Idempotent re-runs: when a wheel filename for the computed | |
| # version is already on PyPI (e.g. a previous push to main | |
| # already published this version, or this run's computed | |
| # semver hasn't bumped past the last release), treat the | |
| # existing file as a no-op instead of a hard failure. PyPA's | |
| # recommended pattern for release workflows that may run | |
| # multiple times against the same version. | |
| skip-existing: true | |
| attestations: false | |
| publish-npm: | |
| needs: [detect-version, build] | |
| if: github.event_name != 'pull_request' && github.event.inputs.dry_run != 'true' && vars.NPM_SKIP != 'true' | |
| runs-on: ubuntu-latest | |
| # Publishes to npmjs.org via NPM_TOKEN secret; no GITHUB_TOKEN | |
| # write permissions needed. | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "20" | |
| registry-url: ${{ env.NPM_REGISTRY_URL }} | |
| # No artifact download required: the publish steps below pack and | |
| # publish directly from the checked-out source tree (sdk/typescript | |
| # and plugins/openclaw) via `npm pack` + `npm publish`. The | |
| # `dist` artifact is a Python-distribution aggregate produced by | |
| # `collect-dist`; it has no npm content. Earlier versions of | |
| # this workflow downloaded it speculatively, which now fails as | |
| # "Artifact not found" because publish-npm is not gated on | |
| # collect-dist. Removing the dead step is the right fix. | |
| - name: Publish ${{ env.NPM_SDK_PACKAGE }} (TypeScript SDK) to npmjs.org | |
| id: npm-sdk-publish | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: | | |
| version="${{ needs.detect-version.outputs.npm_version }}" | |
| cd sdk/typescript | |
| npm ci | |
| npm run build | |
| npm version "$version" --no-git-tag-version --allow-same-version | |
| npm publish --access public | |
| continue-on-error: true | |
| - name: Publish ${{ env.NPM_OPENCLAW_PACKAGE }} to npmjs.org | |
| id: npm-openclaw-publish | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: | | |
| version="${{ needs.detect-version.outputs.npm_version }}" | |
| cd plugins/openclaw | |
| npm ci | |
| npm run build | |
| npm version "$version" --no-git-tag-version --allow-same-version | |
| HEADROOM_NPM_VERSION="$version" node <<'EOF' | |
| const fs = require("fs"); | |
| const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); | |
| pkg.dependencies = pkg.dependencies || {}; | |
| pkg.dependencies["headroom-ai"] = `^${process.env.HEADROOM_NPM_VERSION}`; | |
| fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`); | |
| EOF | |
| node prepare-dist.mjs | |
| npm publish --access public | |
| continue-on-error: true | |
| - name: Publish ${{ env.NPM_OPENCODE_PACKAGE }} to npmjs.org | |
| id: npm-opencode-publish | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: | | |
| version="${{ needs.detect-version.outputs.npm_version }}" | |
| cd plugins/opencode | |
| npm ci | |
| npm run build | |
| npm version "$version" --no-git-tag-version --allow-same-version | |
| HEADROOM_NPM_VERSION="$version" node <<'EOF' | |
| const fs = require("fs"); | |
| const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); | |
| pkg.dependencies = pkg.dependencies || {}; | |
| pkg.dependencies["headroom-ai"] = `^${process.env.HEADROOM_NPM_VERSION}`; | |
| fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`); | |
| EOF | |
| npm publish --access public | |
| continue-on-error: true | |
| - name: npm publish notice | |
| if: steps.npm-sdk-publish.outcome == 'failure' || steps.npm-openclaw-publish.outcome == 'failure' || steps.npm-opencode-publish.outcome == 'failure' | |
| run: | | |
| echo "::notice::One or more npm publishes failed. Set NPM_SKIP=true in repo Variables to skip npm publishes if tokens are not configured." | |
| publish-github-packages: | |
| needs: [detect-version, build] | |
| if: github.event_name != 'pull_request' && github.event.inputs.dry_run != 'true' && vars.GH_PACKAGES_SKIP != 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Compute GitHub Packages scope | |
| id: gh-scope | |
| run: | | |
| scope="$(printf '%s' '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" | |
| printf 'scope=%s\n' "$scope" >> "$GITHUB_OUTPUT" | |
| - name: Set up Node.js for GitHub Package Registry | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: "20" | |
| registry-url: ${{ env.GITHUB_PACKAGES_REGISTRY_URL }} | |
| # No artifact download required: the publish-github-packages flow | |
| # below `npm pack`s its own scoped tarball into a workdir and | |
| # publishes that. Same reasoning as publish-npm — the speculative | |
| # `dist` download was failing "Artifact not found" because this | |
| # job is not gated on `collect-dist`. | |
| - name: Publish ${{ env.NPM_SDK_PACKAGE }} to GitHub Package Registry | |
| id: gpr-sdk-publish | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GITHUB_PACKAGES_SCOPE: ${{ steps.gh-scope.outputs.scope }} | |
| run: | | |
| workdir="$(mktemp -d)" | |
| assets_dir="$workdir/release-assets" | |
| mkdir -p "$assets_dir" | |
| cp -R sdk/typescript "$workdir/sdk" | |
| cd "$workdir/sdk" | |
| npm install | |
| npm run build | |
| npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version | |
| unscoped_sdk_tarball="$(npm pack --pack-destination "$assets_dir" | tail -n 1)" | |
| node <<'EOF' | |
| const fs = require("fs"); | |
| const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); | |
| pkg.name = `@${process.env.GITHUB_PACKAGES_SCOPE}/${pkg.name}`; | |
| pkg.publishConfig = { | |
| ...(pkg.publishConfig || {}), | |
| registry: process.env.GITHUB_PACKAGES_REGISTRY_URL, | |
| }; | |
| fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`); | |
| EOF | |
| sdk_tarball="$(npm pack --pack-destination "$assets_dir" | tail -n 1)" | |
| printf 'unscoped_sdk_tarball=%s\n' "$assets_dir/$unscoped_sdk_tarball" >> "$GITHUB_OUTPUT" | |
| printf 'sdk_tarball=%s\n' "$assets_dir/$sdk_tarball" >> "$GITHUB_OUTPUT" | |
| npm publish --access public --registry ${{ env.GITHUB_PACKAGES_REGISTRY_URL }} | |
| continue-on-error: true | |
| - name: Publish ${{ env.NPM_OPENCLAW_PACKAGE }} to GitHub Package Registry | |
| id: gpr-openclaw-publish | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GITHUB_PACKAGES_SCOPE: ${{ steps.gh-scope.outputs.scope }} | |
| SDK_TARBALL: ${{ steps.gpr-sdk-publish.outputs.unscoped_sdk_tarball }} | |
| run: | | |
| workdir="$(mktemp -d)" | |
| cp -R plugins/openclaw "$workdir/openclaw" | |
| cd "$workdir/openclaw" | |
| node <<'EOF' | |
| const fs = require("fs"); | |
| const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); | |
| pkg.dependencies = pkg.dependencies || {}; | |
| delete pkg.dependencies["headroom-ai"]; | |
| fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`); | |
| EOF | |
| npm install | |
| npm install --no-save "$SDK_TARBALL" | |
| npm run build | |
| npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version | |
| node <<'EOF' | |
| const fs = require("fs"); | |
| const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); | |
| const scopedSdk = `@${process.env.GITHUB_PACKAGES_SCOPE}/headroom-ai`; | |
| pkg.name = `@${process.env.GITHUB_PACKAGES_SCOPE}/${pkg.name}`; | |
| pkg.dependencies = pkg.dependencies || {}; | |
| delete pkg.dependencies["headroom-ai"]; | |
| pkg.dependencies[scopedSdk] = `^${pkg.version}`; | |
| pkg.publishConfig = { | |
| ...(pkg.publishConfig || {}), | |
| registry: process.env.GITHUB_PACKAGES_REGISTRY_URL, | |
| }; | |
| fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`); | |
| EOF | |
| npm publish --access public --registry ${{ env.GITHUB_PACKAGES_REGISTRY_URL }} | |
| continue-on-error: true | |
| - name: GPR publish notice | |
| if: steps.gpr-sdk-publish.outcome == 'failure' || steps.gpr-openclaw-publish.outcome == 'failure' | |
| run: | | |
| echo "::notice::One or more GitHub Package Registry publishes failed. Check GITHUB_TOKEN permissions and package scope/repository settings. Set GH_PACKAGES_SKIP=true to skip." | |
| publish-docker: | |
| # Wait for the smoke-import gate. The docker images bundle the | |
| # same wheels we publish to PyPI; a wheel that can't import | |
| # cleanly on the manylinux floor will also fail the docker | |
| # image's `pip install` step. Failing here ~3 minutes earlier | |
| # than docker-build saves the matrix's wall-clock budget. | |
| needs: [detect-version, smoke-import-wheels] | |
| if: github.event_name != 'pull_request' && github.event.inputs.dry_run != 'true' | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| uses: ./.github/workflows/docker.yml | |
| with: | |
| version: ${{ needs.detect-version.outputs.version }} | |
| enable_ref_tags: false | |
| create-release: | |
| needs: [detect-version, build, build-wheels, collect-dist, smoke-import-wheels, publish-pypi, publish-npm, publish-github-packages, publish-docker] | |
| if: >- | |
| ${{ | |
| always() && | |
| github.event_name != 'pull_request' && | |
| github.event.inputs.dry_run != 'true' && | |
| needs.detect-version.result == 'success' && | |
| needs.build.result == 'success' && | |
| needs.build-wheels.result == 'success' && | |
| needs.collect-dist.result == 'success' && | |
| needs.smoke-import-wheels.result == 'success' && | |
| (vars.PYPI_SKIP == 'true' || needs.publish-pypi.result == 'success') | |
| }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Download changelog artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: changelog | |
| path: /tmp | |
| - name: Download merged release assets (npm tarballs + wheels + sdist) | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: release-assets-merged | |
| path: release-assets | |
| - name: Show changelog | |
| run: | | |
| ls -la /tmp/changelog-backup.md | |
| cp /tmp/changelog-backup.md .changelog.md | |
| cat .changelog.md | |
| - name: Show release assets | |
| run: | | |
| ls -la release-assets | |
| - name: Create or update GitHub Release | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| TAG="v${{ needs.detect-version.outputs.version }}" | |
| TITLE="Release v${{ needs.detect-version.outputs.version }}" | |
| if gh release view "$TAG" > /dev/null 2>&1; then | |
| # Release already exists — typically created by | |
| # release-please on release-PR merge with an auto- | |
| # generated changelog body. Don't overwrite its notes | |
| # (--notes-file would clobber them); only sync the | |
| # title in case detect-version's canonical form differs | |
| # from what the bot set. | |
| gh release edit "$TAG" --title "$TITLE" | |
| else | |
| gh release create "$TAG" --title "$TITLE" --notes-file .changelog.md | |
| fi | |
| - name: Publish ${{ env.PYPI_PACKAGE }} Python distributions to GitHub Release | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| TAG="v${{ needs.detect-version.outputs.version }}" | |
| gh release upload "$TAG" release-assets/*.whl release-assets/*.tar.gz --clobber | |
| - name: Publish Node package tarballs to GitHub Release | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| TAG="v${{ needs.detect-version.outputs.version }}" | |
| gh release upload "$TAG" release-assets/*.tgz --clobber |