diff --git a/.github/tools/check_version_pins.sh b/.github/tools/check_version_pins.sh index 3fdb8a06..604cbcd4 100755 --- a/.github/tools/check_version_pins.sh +++ b/.github/tools/check_version_pins.sh @@ -104,32 +104,42 @@ v_src=$(grep -oE 'MCPP_VERSION[[:space:]]*=[[:space:]]*"[^"]+"' src/toolchain/fi | grep -oE '"[^"]+"' | tr -d '"' | head -1) v_xl=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' .xlings.json \ | grep -oE '"[^"]+"$' | tr -d '"' | head -1) -v_pin=$(grep -oE "MCPP_PIN:[[:space:]]*'[^']+'" .github/workflows/ci-fresh-install.yml \ - | grep -oE "'[^']+'" | tr -d "'" | head -1) - -note "mcpp version: building=$v_toml (fingerprint=$v_src) bootstrap pin=$v_xl (MCPP_PIN=$v_pin)" +note "mcpp version: building=$v_toml (fingerprint=$v_src) bootstrap pin=$v_xl" for n in "mcpp.toml:$v_toml" "src/toolchain/fingerprint.cppm:$v_src" \ - ".xlings.json:$v_xl" "ci-fresh-install.yml MCPP_PIN:$v_pin"; do + ".xlings.json:$v_xl"; do [ -n "${n##*:}" ] || bad "${n%:*} — could not read the mcpp version" done +# ci-fresh-install.yml used to carry a SECOND hand-edited copy of the bootstrap +# pin (MCPP_PIN), and this script enforced that the two stayed equal. They are +# not the same thing and never were: MCPP_PIN is the version UNDER TEST (always +# the newest published release), while .xlings.json is the version BOOTSTRAPPED +# FROM (any released mcpp that can build the current tree). Keeping them equal +# forced a manual edit on every release for a value the workflow could derive — +# and the workflow's own index guard was already deriving it from the releases +# API and discarding it. MCPP_PIN is now that derived value, so there is no +# second site left to agree with. Guard against a silent regression to a +# literal: +if grep -qE "MCPP_PIN:[[:space:]]*['\"]?[0-9]" .github/workflows/ci-fresh-install.yml; then + bad "ci-fresh-install.yml hardcodes MCPP_PIN again — it must stay derived from + wait-index's releases-API lookup, or the index guard and the install jobs + can once more disagree about which version is under test (#265)" +fi + # (a) The version being BUILT: mcpp.toml and the compiled-in constant are the # same number by definition — release.yml derives the tag from the former # and the smoke test greps the latter out of `mcpp --version`. [ -z "$v_src" ] || [ "$v_src" = "$v_toml" ] \ || bad "src/toolchain/fingerprint.cppm has '$v_src' but mcpp.toml has '$v_toml'" -# (b) The version BOOTSTRAPPED FROM: both sites name a mcpp that is already -# published, so they must agree with each other — but they are NOT required -# to equal the version being built. They deliberately lag, and are bumped in -# a separate commit AFTER the release exists in xim-pkgindex (see the -# MCPP_PIN comment in ci-fresh-install.yml). Requiring equality here is what -# an earlier revision of this script got wrong: it sent CI to install a +# (b) The version BOOTSTRAPPED FROM (.xlings.json) names a mcpp that is already +# published, and is NOT required to equal the version being built. It +# deliberately lags, and is bumped in a separate commit AFTER the release +# exists in xim-pkgindex (see docs/09-release.md). Requiring equality here is +# what an earlier revision of this script got wrong: it sent CI to install a # version that did not exist yet, and every job died with # `package 'mcpp@' not found`. -[ -z "$v_xl" ] || [ -z "$v_pin" ] || [ "$v_xl" = "$v_pin" ] \ - || bad ".xlings.json pins '$v_xl' but ci-fresh-install.yml MCPP_PIN is '$v_pin' — both bootstrap the same released mcpp" # (c) …and the bootstrap pin must never run AHEAD of the version being built. # Four-key numeric sort, so the date scheme orders correctly (a plain diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 2f816427..e33ab2de 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -31,16 +31,31 @@ concurrency: group: ci-fresh-install cancel-in-progress: false # use false to test in PRs, true to only test released mcpp -# Version under test. Bare `xlings install mcpp` resolves "newest in the -# runner's index copy", which is NOT the same source the wait-index job -# polls (raw.githubusercontent.com) — on 2026-07-21 the guard reported -# "index tracks 0.0.102" and the jobs still installed 0.0.100 ten seconds -# later, which then met an index whose floor was 0.0.101 (#265). Pinning -# makes the version explicit: if the index cannot serve it yet, the job -# fails with `version not found` instead of silently testing an older -# binary. Bump together with the .xlings.json workspace pin at release. -env: - MCPP_PIN: '2026.7.30.3' +# The version under test is DERIVED, once, by the resolve-version job below, +# and every install job consumes that one value. +# +# Two things had to hold, and a hardcoded pin only bought the first: +# +# 1. It must be an explicit version string. Bare `xlings install mcpp` +# resolves "newest in the runner's index copy", so a runner whose copy +# lags silently tests an OLDER binary and reports green. Naming the +# version makes a lagging index fail loudly with `version not found`. +# +# 2. The version the index guard waits for must be the version the jobs +# install. On 2026-07-21 they disagreed: the guard reported "index tracks +# 0.0.102" and the jobs installed 0.0.100 ten seconds later, which then met +# an index whose floor was 0.0.101 (#265). The guard already derived the +# real answer from the releases API and threw it away. +# +# Deriving once and feeding both satisfies (1) and makes (2) structurally +# impossible, instead of relying on a human to keep two hand-edited numbers in +# step with a third that moves on its own. `xlings install mcpp@` is +# every bit as explicit as `mcpp@`. +# +# NOT to be confused with the .xlings.json workspace pin, which this used to be +# kept equal to. That one is the BOOTSTRAP compiler for the self-host builds and +# has a different requirement — it must be a released mcpp that can build the +# CURRENT source tree — so it stays hand-maintained. See docs/09-release.md. jobs: # ────────────────────────────────────────────────────────────────── @@ -56,11 +71,31 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} timeout-minutes: 20 + outputs: + version: ${{ steps.resolve.outputs.version }} steps: + # Derive on EVERY trigger, not just post-release. "The newest published + # release" is the version under test whether we got here from the release + # pipeline, from cron, or by hand — only the WAIT below is specific to a + # post-release run, because only then can the index legitimately lag. + - name: Resolve the version under test (newest published release) + id: resolve + run: | + VER=$(curl -fsSL "https://api.github.com/repos/mcpp-community/mcpp/releases/latest" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'].lstrip('v'))") + # A blank version would silently degrade `mcpp@$VER` into bare `mcpp`, + # i.e. straight back to "newest in the runner's index copy" — the exact + # failure this job exists to prevent. Refuse instead. + case "$VER" in + ''|*[!0-9.]*) echo "::error::could not resolve a version from the releases API (got '$VER')"; exit 1 ;; + esac + echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "version under test: $VER" - name: Wait for xim-pkgindex to track the released mcpp if: ${{ github.event_name == 'workflow_run' }} + env: + VER: ${{ steps.resolve.outputs.version }} run: | - VER=$(curl -fsSL "https://api.github.com/repos/mcpp-community/mcpp/releases/latest" | python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'].lstrip('v'))") echo "released: $VER — waiting for index..." for i in $(seq 1 30); do if curl -fsSL "https://raw.githubusercontent.com/openxlings/xim-pkgindex/main/pkgs/m/mcpp.lua" | grep -q "\"$VER\""; then @@ -81,6 +116,9 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 60 env: + # The one derived value (see the header comment): every install job names + # the SAME version the index guard waited for, so the two cannot disagree. + MCPP_PIN: ${{ needs.wait-index.outputs.version }} # Verbose every mcpp invocation — fresh-install is the cold index/sandbox # bootstrap path, exactly where extra diagnostics matter (src/cli.cppm). MCPP_VERBOSE: "1" @@ -217,6 +255,9 @@ jobs: image: debian:11 setup: apt-get update && apt-get -y install curl bash tar gzip xz-utils git ca-certificates binutils findutils file env: + # The one derived value (see the header comment): every install job names + # the SAME version the index guard waited for, so the two cannot disagree. + MCPP_PIN: ${{ needs.wait-index.outputs.version }} XLINGS_NON_INTERACTIVE: '1' HOME: /root steps: @@ -277,6 +318,10 @@ jobs: # independence (this image has no sha256sum; macos-15 does). runs-on: macos-14 timeout-minutes: 30 + env: + # The one derived value (see the header comment): every install job names + # the SAME version the index guard waited for, so the two cannot disagree. + MCPP_PIN: ${{ needs.wait-index.outputs.version }} steps: - uses: actions/checkout@v4 @@ -337,6 +382,10 @@ jobs: if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} runs-on: windows-latest timeout-minutes: 30 + env: + # The one derived value (see the header comment): every install job names + # the SAME version the index guard waited for, so the two cannot disagree. + MCPP_PIN: ${{ needs.wait-index.outputs.version }} steps: - uses: actions/checkout@v4 diff --git a/docs/09-release.md b/docs/09-release.md new file mode 100644 index 00000000..26f3c328 --- /dev/null +++ b/docs/09-release.md @@ -0,0 +1,145 @@ +# 09 — Releasing mcpp + +How a release of **mcpp itself** reaches users. This is maintainer-facing; for +packaging *your own* project see [02 — Packaging for Release](02-pack-and-release.md). + +Until now this process lived only in commit messages and workflow comments. One +of those commit messages contains a misdiagnosis that is corrected in §5. + +## 1. The four version sites are two groups + +| Site | Group | Moves when | +|---|---|---| +| `mcpp.toml` `[package].version` | **being built** | you start work on a new version | +| `src/toolchain/fingerprint.cppm` `MCPP_VERSION` | **being built** | same commit as above (compiled-in copy) | +| `.xlings.json` `[workspace].mcpp` | **bootstrapped from** | separately, *after* a release is installable | +| `ci-fresh-install.yml` `MCPP_PIN` | ~~bootstrapped from~~ | **nothing — it is derived at run time** (§4) | + +`.github/tools/check_version_pins.sh` enforces what is left mechanically. The two +"being built" sites must be equal; the bootstrap pin must never be **newer** than +the version being built. + +The two groups are deliberately allowed to differ. Bumping them together is what +an earlier revision of the pin checker required, and it sent every CI job to +install a version that did not exist yet. + +## 2. The pipeline + +`release.yml` (tag push, or `workflow_dispatch` with no input, which derives the +tag from `mcpp.toml`) does all of this: + +``` +build ×4 (linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) + → GitHub Release v with tarballs + .sha256 sidecars + → mirror to xlings-res/mcpp on BOTH GitHub and GitCode + → open the version-bump PR against openxlings/xim-pkgindex + → workflow_run hook fires ci-fresh-install +``` + +Two steps are **not** automated: + +- **merging the xim-pkgindex bump PR** — a maintainer does it. Until it lands, + the released version is downloadable but not installable via `xlings install`. +- **bumping `.xlings.json`** — see §4. + +## 3. Verifying a release + +The mirror script verifies its own uploads, but the checks worth doing by hand +are the ones that do not trust a sidecar: + +```bash +V= +# both hosts serve every platform, byte-exact +for a in linux-x86_64.tar.gz linux-aarch64.tar.gz macosx-arm64.tar.gz windows-x86_64.zip; do + for h in github.com gitcode.com; do + curl -fsSL -o /dev/null -w "$h $a %{http_code} %{size_download}\n" \ + "https://$h/xlings-res/mcpp/releases/download/$V/mcpp-$V-$a" + done +done +# the index's sha256 values match the payloads (recompute; do not read the sidecar) +curl -fsSL -o /tmp/p.tgz "https://github.com/xlings-res/mcpp/releases/download/$V/mcpp-$V-linux-x86_64.tar.gz" +sha256sum /tmp/p.tgz # compare against pkgs/m/mcpp.lua in xim-pkgindex +``` + +Then a real install, in a **clean-room `XLINGS_HOME`** — never the machine's own +`~/.xlings`, which can mask a broken index with cached state: + +```bash +export XLINGS_HOME=$(mktemp -d) +xlings update +xlings install mcpp@$V -y +$(find "$XLINGS_HOME" -name mcpp -type f -path '*/bin/*' | head -1) --version +``` + +**Index propagation is not instant.** `xim-pkgindex` reaches clients as a CDN +artifact, not a git clone, so a freshly merged bump is invisible for a while +(measured at ~5 min on 2026-07-30; the documented worst case is ~40 min). A +clean-room that still reports the old `latest` has not failed — it has not caught +up. `ci-fresh-install`'s `wait-index` job encodes exactly this with a bounded +15-minute wait. + +## 4. The bootstrap pin: what it is, and when to bump it + +`.xlings.json`'s `[workspace].mcpp` is the **starting point of self-hosting** — +the released mcpp that `xlings install mcpp` puts in the workspace so CI can build +mcpp from source. Its only requirement is that it can build the **current** tree. + +**It does not have to move with every release.** The index retains every +published version (105 entries at the time of writing, back to the 0.0.x series), +so an older pin keeps resolving indefinitely — verified by installing a +two-releases-old version against the current index. + +Bumping it anyway is reasonable and is what this repository does in practice: a +green CI round on the bumped pin is a direct proof that the new release can build +mcpp itself on every platform. Treat it as a *useful check*, not a prerequisite. + +**The one hard constraint is direction**: the pin must never name a version that +is not yet installable. Bump it only after the release is published, mirrored, +**and merged into xim-pkgindex** — otherwise every CI job fails with +`package 'mcpp@' not found`. `check_version_pins.sh` enforces the +weaker "never newer than the version being built"; the index condition is on you. + +## 5. `MCPP_PIN` is derived, and why that matters + +`ci-fresh-install.yml` used to carry a second hand-edited copy of the pin. It was +never the same thing: `MCPP_PIN` is the version **under test** — always the newest +published release — while `.xlings.json` is the version **bootstrapped from**. + +It is now derived once, by the `wait-index` job, from the releases API, and every +install job consumes that single output. Two properties had to hold, and the +hardcoded literal only bought the first: + +1. **The version must be explicit.** Bare `xlings install mcpp` resolves "newest + in the runner's index copy", so a lagging runner silently tests an *older* + binary and reports green. Naming the version makes a lagging index fail loudly + with `version not found`. A derived string is every bit as explicit as a + literal one. +2. **The guard and the jobs must name the same version.** On 2026-07-21 they did + not: the index guard reported "index tracks 0.0.102" while the jobs installed + 0.0.100 ten seconds later, meeting an index whose floor was 0.0.101 (#265). + The guard was already deriving the right answer and throwing it away. Feeding + both from one value makes that disagreement structurally impossible. + +`check_version_pins.sh` fails if a literal `MCPP_PIN:` reappears. + +> **Correction.** Commit `3b1cb6b` ("bootstrap pin -> 2026.7.29.2") states *"the +> index no longer serves .1"* and quotes `version '2026.7.29.1' not found`. That +> diagnosis is wrong: `2026.7.29.1` installs fine from the current index. The real +> cause was a **stale local index copy** — the same propagation lag described in +> §3, seen from the other side. Nothing about a release removes older versions, +> and no reasoning should be built on the idea that it does. + +## 6. Checklist + +``` +[ ] version bumped in mcpp.toml + fingerprint.cppm (one commit) +[ ] CHANGELOG entry +[ ] bash .github/tools/check_version_pins.sh +[ ] merge to main, CI green +[ ] gh workflow run release.yml --ref main +[ ] release.yml green (4 builds + publish-ecosystem) +[ ] mirrors serve all four platforms on BOTH hosts, sha256 recomputed +[ ] merge the xim-pkgindex bump PR +[ ] clean-room XLINGS_HOME: xlings install mcpp@ succeeds +[ ] (optional) bump .xlings.json — only now, never earlier +``` diff --git a/docs/README.md b/docs/README.md index d68b45d1..f3d8ee53 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ - [06 - Workspaces](06-workspace.md) - [07 - build.mcpp Build Program](07-build-mcpp.md) - [08 - Toolchain Internals](08-toolchain-internals.md) +- [09 - Releasing mcpp](09-release.md) ## Specifications diff --git a/docs/zh/09-release.md b/docs/zh/09-release.md new file mode 100644 index 00000000..2c99275a --- /dev/null +++ b/docs/zh/09-release.md @@ -0,0 +1,131 @@ +# 09 — 发布 mcpp + +**mcpp 自身**的发布如何到达用户手上。这是给维护者看的;打包**你自己的项目**见 +[02 — 打包发布](02-pack-and-release.md)。 + +在此之前这套流程只活在 commit message 和 workflow 注释里,其中一条 commit message +里的诊断是错的,已在 §5 更正。 + +## 1. 四处版本号 = 两组 + +| 位置 | 组 | 何时变 | +|---|---|---| +| `mcpp.toml` `[package].version` | **正在构建的** | 开始做新版本时 | +| `src/toolchain/fingerprint.cppm` `MCPP_VERSION` | **正在构建的** | 与上一行同一个 commit(编译进二进制的副本) | +| `.xlings.json` `[workspace].mcpp` | **自举起点** | 单独地、在某个版本**已可安装之后** | +| `ci-fresh-install.yml` `MCPP_PIN` | ~~自举起点~~ | **不变 —— 运行时推导**(§4) | + +`.github/tools/check_version_pins.sh` 机器校验剩下的部分:两处"正在构建的"必须相等; +自举 pin 永远不得**新于**正在构建的版本。 + +两组刻意允许不同。把它们一起 bump 正是 pin 校验器早期版本要求过的做法, +结果是所有 CI 都去装一个还不存在的版本。 + +## 2. 发布管线 + +`release.yml`(推 tag,或 `workflow_dispatch` 不带输入时从 `mcpp.toml` 推导 tag)会做完这些: + +``` +四平台构建(linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) + → GitHub Release v,含 tarball 与 .sha256 边车文件 + → 镜像到 xlings-res/mcpp 的 GitHub 与 GitCode 双端 + → 向 openxlings/xim-pkgindex 开版本 bump PR + → workflow_run 钩子触发 ci-fresh-install +``` + +两步**没有**自动化: + +- **合并 xim-pkgindex 的 bump PR** —— 由维护者完成。在它落地之前,发布出来的版本 + 可以下载,但无法通过 `xlings install` 安装。 +- **bump `.xlings.json`** —— 见 §4。 + +## 3. 验证一次发布 + +镜像脚本会自校验上传,但真正值得手工做的是那些**不信任边车文件**的检查: + +```bash +V= +# 双端都服务全部四个平台,字节数一致 +for a in linux-x86_64.tar.gz linux-aarch64.tar.gz macosx-arm64.tar.gz windows-x86_64.zip; do + for h in github.com gitcode.com; do + curl -fsSL -o /dev/null -w "$h $a %{http_code} %{size_download}\n" \ + "https://$h/xlings-res/mcpp/releases/download/$V/mcpp-$V-$a" + done +done +# 索引里的 sha256 与载荷相符(**重新计算**,不要读边车文件) +curl -fsSL -o /tmp/p.tgz "https://github.com/xlings-res/mcpp/releases/download/$V/mcpp-$V-linux-x86_64.tar.gz" +sha256sum /tmp/p.tgz # 与 xim-pkgindex 的 pkgs/m/mcpp.lua 对照 +``` + +然后在 **clean-room `XLINGS_HOME`** 里真装一次 —— 绝不要用本机的 `~/.xlings`, +它的缓存状态会把一个坏掉的索引掩盖过去: + +```bash +export XLINGS_HOME=$(mktemp -d) +xlings update +xlings install mcpp@$V -y +$(find "$XLINGS_HOME" -name mcpp -type f -path '*/bin/*' | head -1) --version +``` + +**索引传播不是即时的。** `xim-pkgindex` 是以 CDN artifact 而非 git clone 的形式 +到达客户端的,所以刚合并的 bump 会有一段时间不可见(2026-07-30 实测 ~5 分钟, +记录在案的上限约 40 分钟)。clean-room 里仍然报旧的 `latest` 不是失败,是**还没追上**。 +`ci-fresh-install` 的 `wait-index` job 正是把这件事编码成了 15 分钟有界等待。 + +## 4. 自举 pin:它是什么,什么时候该 bump + +`.xlings.json` 的 `[workspace].mcpp` 是**自举的起点** —— 那个由 +`xlings install mcpp` 装进 workspace、供 CI 从源码构建 mcpp 的已发布 mcpp。 +它唯一的要求是:能构建**当前**这棵源码树。 + +**它不必每次发布都跟着动。** 索引保留每一个已发布版本(撰写时 105 个条目, +一直回溯到 0.0.x 系列),旧 pin 可以无限期继续解析 —— 这一点用「在当前索引下安装 +一个隔了两个版本的旧版」实测验证过。 + +跟着 bump 仍然是合理的,也是本仓库的实际做法:bump 后 CI 一轮全绿,直接证明了 +新发布能在每个平台上构建 mcpp 自己。把它当作**一项有用的检查**,而不是前置条件。 + +**唯一的硬约束是方向**:pin 绝不能指向一个尚不可安装的版本。只在发布已完成、 +已镜像、**且已合入 xim-pkgindex 之后**再 bump —— 否则所有 CI 会以 +`package 'mcpp@' not found` 失败。`check_version_pins.sh` 能卡住较弱的 +「不得新于正在构建的版本」;索引那个条件靠你自己把关。 + +## 5. `MCPP_PIN` 改为推导,以及这为什么重要 + +`ci-fresh-install.yml` 过去带着 pin 的第二份手工副本。它们从来就不是一回事: +`MCPP_PIN` 是**被测版本** —— 永远是最新的已发布版本;而 `.xlings.json` 是 +**自举来源**。 + +现在它由 `wait-index` job 从 releases API 推导一次,所有安装 job 消费同一个输出。 +有两条性质必须成立,而写死的字面量只买到了第一条: + +1. **版本必须是显式的。** 裸 `xlings install mcpp` 解析的是「runner 自己那份索引副本里 + 的最新」,于是副本落后的 runner 会**悄悄测一个旧二进制然后报绿**。写明版本能让落后的 + 索引以 `version not found` 响亮失败。推导出来的字符串与字面量一样显式。 +2. **守卫等的版本必须就是 job 装的版本。** 2026-07-21 它们不是:索引守卫报 + 「index tracks 0.0.102」,10 秒后 job 装的是 0.0.100,撞上 floor 为 0.0.101 的索引(#265)。 + 守卫本来就推导出了正确答案,然后把它扔掉了。让两者吃同一个值, + 使这种不一致在结构上不可能发生。 + +`check_version_pins.sh` 会在字面量 `MCPP_PIN:` 重新出现时报错。 + +> **更正。** commit `3b1cb6b`("bootstrap pin -> 2026.7.29.2")写着 +> *"the index no longer serves .1"* 并引用了 `version '2026.7.29.1' not found`。 +> **这个诊断是错的**:`2026.7.29.1` 在当前索引下能正常安装。真正的原因是 +> **本地索引副本陈旧** —— 就是 §3 描述的那个传播滞后,只是从另一侧看到的。 +> 发布不会移除任何旧版本,任何推理都不该建立在「会移除」这个前提上。 + +## 6. 检查清单 + +``` +[ ] mcpp.toml + fingerprint.cppm 版本号已 bump(同一个 commit) +[ ] CHANGELOG 条目 +[ ] bash .github/tools/check_version_pins.sh +[ ] 合入 main,CI 全绿 +[ ] gh workflow run release.yml --ref main +[ ] release.yml 全绿(4 个构建 + publish-ecosystem) +[ ] 双端都服务四个平台,sha256 重新算过 +[ ] 合并 xim-pkgindex 的 bump PR +[ ] clean-room XLINGS_HOME:xlings install mcpp@ 成功 +[ ] (可选)bump .xlings.json —— 只在此刻,绝不提前 +``` diff --git a/docs/zh/README.md b/docs/zh/README.md index e354f609..27518dda 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -11,3 +11,4 @@ - [06 - 工作空间](06-workspace.md) - [07 - build.mcpp 构建程序](07-build-mcpp.md) - [08 - 工具链机制内幕](08-toolchain-internals.md) +- [09 - 发布 mcpp](09-release.md)