headroom/.github/workflows/release.yml
chopratejas 7fe2d1e5b6 fix(ci): stage smoke-import script as host file (broke main post-#387)
The X1 smoke-import job (PR #387) embedded the smoke check as a
`<<PY ... PY` heredoc inside `bash -ec '...'`. The outer bash
single-quote preserves whitespace, so the heredoc body and the
closing `PY` retained their YAML indentation (column 14 inside
`bash -ec`). Bash never found a column-0 `PY` and read past EOF:

  bash: line 71: warning: here-document at line 65 delimited by
        end-of-file (wanted `PY')
  IndentationError: unexpected indent
  Process completed with exit code 1

Caught immediately on the post-merge release run on main,
manylinux_2_28_x86_64 / Python 3.11 (glibc 2.28-floor):
https://github.com/chopratejas/headroom/actions/runs/25361396712/job/74362427755

`python -c "<f-string>"` was the obvious next try but reintroduces
single-quote nesting (Python f-strings need quote chars; outer
`bash -ec '...'` cannot contain unescaped single quotes).

Fix: write the smoke script to `${RUNNER_TEMP}/smoke_import.py`
in a new "Stage smoke-import script" step (one heredoc at YAML
`run: |` level — uniform indent strip works fine). Linux job
mounts it via `-v ${RUNNER_TEMP}/smoke_import.py:/smoke_import.py:ro`
and runs `python /smoke_import.py`. macOS host runs the same
file directly. No quoting drift between paths.

Locally validated:
- actionlint clean
- 20/20 tests in test_release_workflows.py pass
- hand-execution of the heredoc + script roundtrip works

This is the second X1 follow-up after PR #387's shellcheck fix.
The original X1 design's gap: no PR-time release dry-run that
would have caught the heredoc on PR #387 itself. X2 (PR-time
dry-run) is the structural fix.
2026-05-04 23:52:52 -07:00

800 lines
35 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
# 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:
push:
branches: [main]
paths-ignore:
- "docs/**"
- ".github/workflows/ci.yml"
- ".github/workflows/publish.yml"
- "scripts/**"
- ".commitlintrc.json"
- ".actrc"
- ".actrc.local.example"
- ".env.act.example"
- ".github/act/**"
workflow_dispatch:
inputs:
version:
description: "Manual version override"
required: false
dry_run:
description: "Skip publish"
type: boolean
default: false
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
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@v4
with:
fetch-depth: 0
- name: Compute semantic version from canonical + release history
id: ver
run: |
python headroom/release_version.py
env:
MANUAL_VER: ${{ github.event.inputs.version }}
# 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@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Sync version to package files
run: |
python scripts/version-sync.py --version ${{ needs.detect-version.outputs.npm_version }}
- 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@v4
with:
name: changelog
path: /tmp/changelog-backup.md
if-no-files-found: error
- name: Build npm release packages
run: |
mkdir -p release-assets
cd sdk/typescript
npm install
npm run build
npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version
npm pack --pack-destination ../../release-assets
cd ../../plugins/openclaw
npm install ../../release-assets/headroom-ai-${{ needs.detect-version.outputs.npm_version }}.tgz
npm install
npm run build
npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version
npm pack --pack-destination ../../release-assets
- name: Upload release assets artifact
uses: actions/upload-artifact@v4
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 ~5060 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
# NOTE: `macos-15-intel` (x86_64-apple-darwin) is intentionally
# NOT in the matrix. `ort-sys 2.0.0-rc.12` (transitive via our
# ML compression backend) does not provide prebuilt ONNX Runtime
# binaries for `x86_64-apple-darwin`, and building ORT from
# source would add CMake + ~5 minutes per build. Apple Silicon
# macOS is fully covered below; Intel-mac users install the
# platform-independent sdist (also produced by this matrix).
# Tracked as a follow-up: switch to `ort-tract` or upstream a
# request for x86_64 macOS prebuilts.
- os: macos-14
target: aarch64-apple-darwin
manylinux: ""
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
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: 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
# 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@v4
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@v4
with:
pattern: wheels-*
path: wheels-tmp/
merge-multiple: true
- name: Download release assets
uses: actions/download-artifact@v4
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@v4
with:
name: dist
path: dist/
- name: Upload merged release-assets artifact
uses: actions/upload-artifact@v4
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. `ubuntu:20.04` (glibc 2.31)
# covers older still-supported LTS.
- { 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" }
- { runner: ubuntu-24.04, image: "ubuntu:20.04", python: "3.10", wheel_target: x86_64-unknown-linux-gnu, wheel_artifact: wheels-ubuntu-24.04-x86_64-unknown-linux-gnu, glibc_label: "2.31" }
# 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. Apple Silicon is the only macOS target we
# ship today (Intel macOS dropped in PR #371 — ort-sys
# has no x86_64-apple-darwin prebuilts).
- { runner: macos-14, image: "", python: "3.13", wheel_target: aarch64-apple-darwin, wheel_artifact: wheels-macos-14-aarch64-apple-darwin, glibc_label: "" }
steps:
- name: Download wheels artifact for this target
uses: actions/download-artifact@v4
with:
name: ${{ matrix.wheel_artifact }}
path: dist/
- 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.
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-get 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-get install -y -qq --no-install-recommends ca-certificates software-properties-common >/dev/null
add-apt-repository -y ppa:deadsnakes/ppa >/dev/null 2>&1 || true
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
"python$PYTHON_VERSION" \
"python$PYTHON_VERSION-venv" \
"python$PYTHON_VERSION-distutils" >/dev/null 2>&1 \
|| apt-get install -y -qq --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
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 == ''
env:
PYTHON_VERSION: ${{ matrix.python }}
run: |
set -e
py_tag=cp$(echo "$PYTHON_VERSION" | tr -d .)
# macOS wheels are tagged macosx_*_arm64 (Apple Silicon only).
whl=$(find dist -maxdepth 1 -type f -name "headroom_ai-*-${py_tag}-${py_tag}-macosx_*_arm64.whl" -print -quit)
if [ -z "$whl" ]; then
echo "ERROR: no macOS wheel matching python=$PYTHON_VERSION"
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"
publish-pypi:
needs: [collect-dist, smoke-import-wheels]
if: 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@v4
with:
name: dist
path: dist/
- name: Publish ${{ env.PYPI_PACKAGE }} to PyPI
id: pypi-publish
uses: pypa/gh-action-pypi-publish@release/v1
continue-on-error: true
- name: PyPI publish notice
if: steps.pypi-publish.outcome == 'failure'
run: |
echo "::notice::PyPI publish skipped — OIDC trusted publisher not configured for this repo. See: https://pypi.org/trusted-publishers/ — Set PYPI_SKIP=true in repo Variables to suppress this notice."
publish-npm:
needs: [detect-version, build]
if: 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@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
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: |
cd sdk/typescript
npm install
npm run build
npm version ${{ needs.detect-version.outputs.npm_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: |
cd plugins/openclaw
npm install
npm run build
npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version
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'
run: |
echo "::notice::One or more npm publishes failed. Set NPM_SKIP=true in repo Variables to skip both npm publishes if tokens are not configured."
publish-github-packages:
needs: [detect-version, build]
if: github.event.inputs.dry_run != 'true' && vars.GH_PACKAGES_SKIP != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
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@v4
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.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.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'
}}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download changelog artifact
uses: actions/download-artifact@v4
with:
name: changelog
path: /tmp
- name: Download merged release assets (npm tarballs + wheels + sdist)
uses: actions/download-artifact@v4
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
gh release edit "$TAG" --title "$TITLE" --notes-file .changelog.md
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