Skip to content

Commit 3da8b6a

Browse files
committed
pypi: publish the release binary as mcpp-bin (pip install mcpp-bin)
scripts/pypi/build_wheels.py builds one wheel per payload named in the release's mcpp-release.json, checking each payload's sha256 against it. A wheel carries bin/mcpp and the bundled xlings, and a launcher behind the `mcpp` console script that pins MCPP_HOME to ~/.mcpp and MCPP_VENDORED_XLINGS to the bundled xlings, as the AUR launcher does. pypi-publish.yml runs downstream of release, pip-installs the wheels on Linux x86_64/aarch64, macOS arm64 and Windows x86_64, and uploads through PyPI Trusted Publishing. No secret is stored. Automatic uploads are armed by the PYPI_AUTOPUBLISH repository variable; a pull request never publishes. Both READMEs list pip as installation option 4.
1 parent fc9aca0 commit 3da8b6a

9 files changed

Lines changed: 783 additions & 4 deletions

File tree

.github/workflows/pypi-publish.yml

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
name: pypi-publish
2+
3+
# Publish the `mcpp-bin` wheels to PyPI (`pip install mcpp-bin`).
4+
#
5+
# Downstream of `release`, like aur-publish.yml and homebrew-publish.yml: the
6+
# wheels are built from the release's own mcpp-release.json and payloads, so
7+
# this runs only once the release workflow has completed.
8+
#
9+
# CREDENTIALS: none stored. Publishing uses PyPI Trusted Publishing (OIDC):
10+
# PyPI trusts this repository + workflow file + the `pypi` environment, and
11+
# the job exchanges its GitHub OIDC token for a short-lived upload token.
12+
# One-time setup is in scripts/pypi/README.md.
13+
#
14+
# ARMING: the automatic trigger builds, verifies and reports, and publishes
15+
# only when the repository variable PYPI_AUTOPUBLISH is `true`, for the reason
16+
# aur-publish.yml gives: an unattended push to a third-party service must be
17+
# armed by a human who has watched one publish succeed, not inherited from a
18+
# merge. `workflow_dispatch` carries its own explicit `publish` switch.
19+
20+
on:
21+
workflow_run:
22+
workflows: [release]
23+
types: [completed]
24+
# Changes to the packaging itself build and pip-install the wheels of the
25+
# latest release on every platform. A pull request never publishes.
26+
pull_request:
27+
paths:
28+
- scripts/pypi/**
29+
- tests/scripts/test_pypi_wheels.py
30+
- .github/workflows/pypi-publish.yml
31+
workflow_dispatch:
32+
inputs:
33+
publish:
34+
description: 'Upload to PyPI (false builds and verifies only)'
35+
type: boolean
36+
required: true
37+
default: false
38+
tag:
39+
description: 'Release tag, e.g. v2026.9.21.3 (default: the latest release)'
40+
type: string
41+
required: false
42+
43+
concurrency:
44+
group: pypi-mcpp-bin
45+
cancel-in-progress: false
46+
47+
permissions:
48+
contents: read
49+
50+
jobs:
51+
build:
52+
name: build wheels
53+
if: >-
54+
github.event_name != 'workflow_run' ||
55+
github.event.workflow_run.conclusion == 'success'
56+
runs-on: ubuntu-24.04
57+
timeout-minutes: 20
58+
outputs:
59+
version: ${{ steps.resolve.outputs.version }}
60+
publish: ${{ steps.resolve.outputs.publish }}
61+
env:
62+
GH_TOKEN: ${{ github.token }}
63+
steps:
64+
- uses: actions/checkout@v4
65+
with:
66+
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
67+
68+
- uses: actions/setup-python@v5
69+
with:
70+
python-version: '3.12'
71+
72+
- name: Builder contract tests
73+
run: python3 tests/scripts/test_pypi_wheels.py
74+
75+
- name: Resolve the release and whether to publish
76+
id: resolve
77+
env:
78+
TRIGGER: ${{ github.event_name }}
79+
INPUT_TAG: ${{ inputs.tag }}
80+
MANUAL_PUBLISH: ${{ inputs.publish }}
81+
AUTOPUBLISH: ${{ vars.PYPI_AUTOPUBLISH }}
82+
run: |
83+
set -euo pipefail
84+
if [[ -n "${INPUT_TAG:-}" ]]; then
85+
tag="$INPUT_TAG"
86+
elif [[ "$TRIGGER" == "workflow_run" ]]; then
87+
# The released commit's mcpp.toml carries the released version.
88+
tag="v$(grep -m1 -E '^\s*version\s*=' mcpp.toml | sed -E 's/.*"([^"]+)".*/\1/')"
89+
else
90+
tag="$(gh release view -R "$GITHUB_REPOSITORY" --json tagName --jq .tagName)"
91+
fi
92+
version="${tag#v}"
93+
echo "tag=$tag" >> "$GITHUB_OUTPUT"
94+
echo "version=$version" >> "$GITHUB_OUTPUT"
95+
96+
# PyPI never accepts the same file twice, so an existing version is
97+
# a finished job rather than something to retry.
98+
code=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-all-errors \
99+
"https://pypi.org/pypi/mcpp-bin/$version/json")
100+
if [[ "$code" == "200" ]]; then
101+
echo "::notice::mcpp-bin $version is already on PyPI; nothing to publish."
102+
publish=false
103+
elif [[ "$TRIGGER" == "workflow_run" ]]; then
104+
if [[ "${AUTOPUBLISH:-}" == "true" ]]; then
105+
publish=true
106+
else
107+
publish=false
108+
echo "::notice::PYPI_AUTOPUBLISH is not set — building and verifying $tag without publishing."
109+
fi
110+
elif [[ "$TRIGGER" == "workflow_dispatch" ]]; then
111+
publish="${MANUAL_PUBLISH:-false}"
112+
else
113+
publish=false
114+
fi
115+
echo "publish=$publish" >> "$GITHUB_OUTPUT"
116+
echo "mcpp-bin $version from $tag; publish=$publish" >> "$GITHUB_STEP_SUMMARY"
117+
118+
- name: Build wheels from the release manifest
119+
run: python3 scripts/pypi/build_wheels.py --tag "${{ steps.resolve.outputs.tag }}" --out dist
120+
121+
- name: Check metadata
122+
run: |
123+
python3 -m pip install --quiet twine
124+
python3 -m twine check --strict dist/*.whl
125+
126+
- uses: actions/upload-artifact@v4
127+
with:
128+
name: mcpp-bin-wheels
129+
path: dist/*.whl
130+
if-no-files-found: error
131+
132+
# pip, not this workflow, picks the wheel: each runner installs from the
133+
# directory of all four, so a wrong platform tag fails here rather than on a
134+
# user's machine. The run then checks the two properties the launcher exists
135+
# for: the per-user home is outside the Python environment, and the bundled
136+
# xlings is the one seeded into it.
137+
smoke:
138+
name: pip install (${{ matrix.os }})
139+
needs: build
140+
strategy:
141+
fail-fast: false
142+
matrix:
143+
os: [ubuntu-24.04, ubuntu-24.04-arm, macos-14, windows-latest]
144+
runs-on: ${{ matrix.os }}
145+
timeout-minutes: 20
146+
steps:
147+
- uses: actions/setup-python@v5
148+
with:
149+
python-version: '3.12'
150+
- uses: actions/download-artifact@v4
151+
with:
152+
name: mcpp-bin-wheels
153+
path: dist
154+
- name: Install and run
155+
shell: bash
156+
env:
157+
VERSION: ${{ needs.build.outputs.version }}
158+
run: |
159+
set -euo pipefail
160+
python -m venv venv
161+
if [[ -x venv/Scripts/python.exe ]]; then py=venv/Scripts/python.exe; bin=venv/Scripts; else py=venv/bin/python; bin=venv/bin; fi
162+
"$py" -m pip install --quiet --no-index --find-links dist mcpp-bin
163+
home="$RUNNER_TEMP/home"; mkdir -p "$home"
164+
export HOME="$home" USERPROFILE="$home"
165+
unset MCPP_HOME MCPP_VENDORED_XLINGS
166+
out="$("$bin/mcpp" --version)"
167+
echo "$out"
168+
[[ "$out" == *"$VERSION"* ]] || { echo "::error::expected $VERSION, got: $out"; exit 1; }
169+
"$bin/mcpp" self env | tee env.txt
170+
grep -F "MCPP_HOME" env.txt | grep -F ".mcpp" \
171+
|| { echo "::error::MCPP_HOME is not the per-user home"; exit 1; }
172+
if grep -F "MCPP_HOME" env.txt | grep -qF "site-packages"; then
173+
echo "::error::MCPP_HOME resolved into the Python environment"; exit 1
174+
fi
175+
# On Windows mcpp runs the vendored xlings in place (src/config.cppm,
176+
# make_xlings_env), so the seeded copy is checked on POSIX only.
177+
if [[ "$RUNNER_OS" != "Windows" ]]; then
178+
ls "$home/.mcpp/registry/bin/" | grep -q '^xlings' \
179+
|| { echo "::error::the bundled xlings was not seeded into the home"; exit 1; }
180+
fi
181+
182+
publish:
183+
name: publish to PyPI
184+
needs: [build, smoke]
185+
if: needs.build.outputs.publish == 'true'
186+
runs-on: ubuntu-24.04
187+
timeout-minutes: 15
188+
environment:
189+
name: pypi
190+
url: https://pypi.org/project/mcpp-bin/${{ needs.build.outputs.version }}/
191+
permissions:
192+
id-token: write
193+
steps:
194+
- uses: actions/download-artifact@v4
195+
with:
196+
name: mcpp-bin-wheels
197+
path: dist
198+
- uses: pypa/gh-action-pypi-publish@release/v1
199+
with:
200+
packages-dir: dist/

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,23 @@ remain manually maintained and may intentionally lag.
166166
</details>
167167

168168
<details>
169-
<summary><b>Option 4</b> — let an AI assistant install it for you</summary>
169+
<summary><b>Option 4</b> — pip (PyPI)</summary>
170+
171+
```bash
172+
pip install mcpp-bin
173+
```
174+
175+
Installs the `mcpp` command into the active Python environment; `pipx install
176+
mcpp-bin` gives it an environment of its own. The wheels carry the same
177+
prebuilt release binary for Linux x86_64 / aarch64, macOS 14+ on Apple silicon
178+
and Windows x86_64. Per-user data still lives in `~/.mcpp/`, outside the Python
179+
environment. On PyPI the name `mcpp` belongs to an unrelated project, hence
180+
`mcpp-bin` (see [`scripts/pypi/`](scripts/pypi/)).
181+
182+
</details>
183+
184+
<details>
185+
<summary><b>Option 5</b> — let an AI assistant install it for you</summary>
170186

171187
Copy the following prompt to your AI coding assistant (Claude Code / Cursor / Copilot, etc.):
172188

README.zh-CN.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,18 @@ yay -S mcpp-m # 或源码构建(用 mcpp-bin 自举)
151151
</details>
152152

153153
<details>
154-
<summary><b>方式 4</b>:由 AI 助手安装</summary>
154+
<summary><b>方式 4</b>:pip(PyPI)</summary>
155+
156+
```bash
157+
pip install mcpp-bin
158+
```
159+
160+
`mcpp` 命令安装到当前的 Python 环境中;使用 `pipx install mcpp-bin` 则为它单独创建一个环境。wheel 中是同一份预编译 release 二进制,支持 Linux x86_64 / aarch64、Apple 芯片上的 macOS 14+ 与 Windows x86_64。每个用户的数据仍在各自的 `~/.mcpp/` 中,不在 Python 环境内。PyPI 上 `mcpp` 这个名字属于一个无关的项目,因此包名为 `mcpp-bin`(见 [`scripts/pypi/`](scripts/pypi/))。
161+
162+
</details>
163+
164+
<details>
165+
<summary><b>方式 5</b>:由 AI 助手安装</summary>
155166

156167
将以下提示词发给 AI 编码助手(Claude Code、Cursor、Copilot 等):
157168

docs/92-release.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ The workflow then downloads the public release again, regenerates the manifest,
8989
and requires a byte-for-byte match. A workflow rerun accepts an existing
9090
manifest only when it is already byte-identical; it never overwrites different
9191
bytes for the same tag. Downstream release consumers (notably the `mcpp-bin`
92-
AUR reconciler) must consume this manifest instead of guessing completeness
92+
AUR reconciler and the `mcpp-bin` PyPI wheel builder in `scripts/pypi/`) must consume this manifest instead of guessing completeness
9393
from a moving workspace or from a subset of release assets.
9494

9595
Two steps are **not** automated:

docs/zh/92-release.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Linux aarch64、macOS ARM64、Windows x86_64 四项是硬性要求。无版本
8484
workflow 随后会再次下载公开 release、重新生成 manifest,并要求逐字节
8585
一致。重跑 workflow 时,已有 manifest 只在字节完全相同时才会被接受;同一
8686
tag 下绝不以不同内容覆盖。下游发布消费者(尤其 `mcpp-bin` AUR
87-
reconciler)必须消费该 manifest,不能从会变化的工作区或部分 release
87+
reconciler`scripts/pypi/` 中的 `mcpp-bin` PyPI wheel 构建脚本)必须消费该 manifest,不能从会变化的工作区或部分 release
8888
资产猜测发布是否完整。
8989

9090
两步**没有**自动化:

scripts/pypi/README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# PyPI packaging (`mcpp-bin`)
2+
3+
`pip install mcpp-bin` installs the prebuilt release binary and puts the `mcpp`
4+
command on the environment's `PATH`. The PyPI name is `mcpp-bin` because `mcpp`
5+
on PyPI is an unrelated project, which is the same situation as on the AUR and
6+
Homebrew.
7+
8+
## Wheels
9+
10+
One wheel per payload in the release's `mcpp-release.json`:
11+
12+
| Payload | Wheel platform tag |
13+
| --- | --- |
14+
| `linux-x86_64` | `manylinux_2_17_x86_64.manylinux2014_x86_64.musllinux_1_1_x86_64` |
15+
| `linux-aarch64` | `manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64` |
16+
| `macosx-arm64` | `macosx_14_0_arm64` |
17+
| `windows-x86_64` | `win_amd64` |
18+
19+
The Linux payloads are fully static musl binaries, so one wheel carries both
20+
the glibc and the musl tag. The macOS floor is the one the Homebrew formula
21+
states. There is no sdist; on any other platform, pip reports that no
22+
distribution matches.
23+
24+
Each wheel holds the same two binaries the AUR package ships, plus a launcher:
25+
26+
| Path in site-packages | Contents |
27+
| --- | --- |
28+
| `mcpp_bin/bin/mcpp[.exe]` | the release binary |
29+
| `mcpp_bin/registry/bin/xlings[.exe]` | the bundled xlings |
30+
| `mcpp_bin/__init__.py` | the launcher behind the `mcpp` console script |
31+
32+
## The launcher
33+
34+
mcpp writes its registry sandbox, caches and toolchains into `MCPP_HOME`, and
35+
it resolves that home from the real path of its binary. Resolved from
36+
site-packages, the home would sit inside the Python environment, which may be
37+
shared or read-only and is removed by `pip uninstall`. The launcher therefore
38+
sets `MCPP_HOME=~/.mcpp` (`%USERPROFILE%\.mcpp` on Windows) and
39+
`MCPP_VENDORED_XLINGS=<site-packages>/mcpp_bin/registry/bin/xlings`, the same
40+
two variables `scripts/aur/mcpp-bin/mcpp.sh` sets. A value the user already
41+
exported is kept. On POSIX the launcher `exec`s the binary; on Windows it runs
42+
the binary as a child process and exits with its status.
43+
44+
## Publishing
45+
46+
[`.github/workflows/pypi-publish.yml`](../../.github/workflows/pypi-publish.yml):
47+
48+
1. runs [`tests/scripts/test_pypi_wheels.py`](../../tests/scripts/test_pypi_wheels.py);
49+
2. builds the four wheels with [`build_wheels.py`](build_wheels.py) from the
50+
release manifest, checking each payload's sha256 against it;
51+
3. runs `twine check --strict`;
52+
4. installs from the directory of all four wheels on Linux x86_64, Linux
53+
aarch64, macOS arm64 and Windows x86_64, so pip picks the wheel. It then
54+
checks the version, that `MCPP_HOME` is outside the Python environment, and,
55+
on POSIX, that the bundled xlings was seeded into the home;
56+
5. uploads to PyPI through Trusted Publishing, when publishing is enabled.
57+
58+
| Trigger | Publishes |
59+
| --- | --- |
60+
| `release` completed | only if the repository variable `PYPI_AUTOPUBLISH` is `true` |
61+
| `workflow_dispatch` | only if its `publish` input is checked |
62+
| `pull_request` touching the packaging | never; builds and installs the latest release |
63+
64+
A version already on PyPI is never uploaded again. PyPI refuses to replace a
65+
file, so such a run reports "already on PyPI" and succeeds.
66+
67+
To build locally without uploading:
68+
69+
```bash
70+
python3 scripts/pypi/build_wheels.py --tag v2026.9.21.3 --out dist/
71+
python3 -m venv /tmp/v && /tmp/v/bin/pip install --no-index --find-links dist mcpp-bin
72+
```
73+
74+
## One-time setup
75+
76+
No token or secret is stored in the repository.
77+
78+
1. **PyPI.** Signed in to PyPI, open
79+
<https://pypi.org/manage/account/publishing/> and add a *pending publisher*:
80+
81+
| Field | Value |
82+
| --- | --- |
83+
| PyPI Project Name | `mcpp-bin` |
84+
| Owner | `mcpp-community` |
85+
| Repository name | `mcpp` |
86+
| Workflow name | `pypi-publish.yml` |
87+
| Environment name | `pypi` |
88+
89+
The first successful upload creates the project, and the pending publisher
90+
becomes its trusted publisher.
91+
2. **GitHub.** In the repository's *Settings → Environments*, create an
92+
environment named `pypi`. Required reviewers are optional; with them, each
93+
upload waits for an approval.
94+
3. **First publish.** Run the workflow by hand with `publish` checked (*Actions →
95+
pypi-publish → Run workflow*), and confirm that
96+
<https://pypi.org/project/mcpp-bin/> shows the version.
97+
4. **Arming.** Set the repository variable `PYPI_AUTOPUBLISH` to `true`
98+
(*Settings → Secrets and variables → Actions → Variables*). From then on,
99+
every completed release publishes its wheels.

scripts/pypi/README.pypi.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# mcpp-bin
2+
3+
The prebuilt [mcpp](https://github.com/mcpp-community/mcpp) {version} release
4+
binary, packaged for `pip`. mcpp is a module-first build tool for modern C++:
5+
`import std`, module interface units and partitions, a package index,
6+
toolchain management and cross-compilation from one command.
7+
8+
```bash
9+
pip install mcpp-bin
10+
mcpp new hello && cd hello && mcpp build && mcpp run
11+
```
12+
13+
The package installs the `mcpp` command. The PyPI name is `mcpp-bin` because
14+
`mcpp` belongs to an unrelated project.
15+
16+
Wheels are published for Linux x86_64 and aarch64 (fully static, glibc or
17+
musl), macOS 14+ on Apple silicon, and Windows x86_64. Each wheel carries the
18+
same release payload as `install.sh`, Homebrew and the AUR `mcpp-bin` package.
19+
20+
mcpp keeps its registry sandbox, caches and downloaded toolchains in
21+
`~/.mcpp` (`%USERPROFILE%\.mcpp` on Windows), not in the Python environment.
22+
Set `MCPP_HOME` to use another directory. The first `mcpp build` initialises
23+
that directory and fetches a toolchain, which takes a while once per user.
24+
`pip uninstall mcpp-bin` removes the command; delete `~/.mcpp` to remove the
25+
per-user data as well.
26+
27+
Documentation: <https://github.com/mcpp-community/mcpp/tree/main/docs>

0 commit comments

Comments
 (0)