Skip to content

Commit c16a512

Browse files
committed
fix(pack): staging is a service to the provider, not a precondition for dispatch (2026.9.11.2)
`mcpp pack --format <name>` staged unconditionally before dispatching, and a staging failure failed the whole command. For `--format tar` and `--format dir` that is right -- the staged tree IS the product. For a DISPATCHED format it is an input the provider may or may not want, and treating it as a precondition made EVERY dispatched format unreachable on any target whose built-in bundling is refused. Measured on macos-15 with 2026.9.11.1: `mcpp pack --format app` never reached the dispatch at all, because `pack::run` refuses a Mach-O PROGRAM outright -- the built-in closure walk is `LD_TRACE_LOADED_OBJECTS`, which is glibc's, and dyld ignores it and simply runs the program. That refusal is correct about the built-in archive and says nothing about whether a `.app` bundler can work: a bundler that names one program needs no closure walk. THE ENGINE WAS ANSWERING A QUESTION THE PROVIDER HAD NOT BEEN ASKED. The failure is now reported and CARRIED rather than fatal or swallowed. The reason is printed as a warning, `pack_stage_dir` stays empty, and `${mcpp.stage_dir}` refuses at expansion naming that reason. A provider that reads the tree gets a precise diagnostic; one that does not proceeds. Nothing is silently degraded -- what changes is who decides. `BuildOverrides::pack_stage_reason` is that channel. Without it a build that is plainly packaging would read "this build is not packaging", which sends a member author looking in the wrong place -- the refusal has to distinguish "you did not ask for a format" from "no tree could be staged for this target". `638_pack_format_dispatch.sh` gains the case macOS found, held on every platform. Linux cannot reproduce the Mach-O refusal, so the leg asserts the property the fix rests on instead: a provider that reads no staged tree is dispatched and reported, and gains no dependency on the stage manifest. Nine cases now, each paired with the wrong answer it excludes. WHAT FOUND THIS IS WORTH RECORDING. `dist-apple` had only plan-level assertions behind it until CI ran it on a real macOS runner for the first time. A plan-level assertion says the gate is right and says nothing about whether the tool accepts what the member renders -- which is why the two platform-specific CI steps were added, and why the first thing they did was fail. 109 unit tests pass. `266_pack_refuses_a_macho_program` still fails on this machine and was A/B'd against released 2026.9.10.2: byte-identical logs, so this change does not touch it. Plain `mcpp pack` keeps the fatal refusal, since `opts.format != Dispatched` there.
1 parent 01f40c7 commit c16a512

6 files changed

Lines changed: 147 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@
55
66
## [Unreleased]
77

8+
## [2026.9.11.2] - 2026-09-11
9+
10+
### 暂存对被分派的格式是**服务**,不是前置条件
11+
12+
`mcpp pack --format <name>` 在分派之前无条件先暂存一次,而暂存失败就让整条命令失
13+
败。对 `--format tar` / `--format dir` 这是对的 —— 暂存树**就是**产物。对一个**
14+
分派**的格式,它是提供方可能要、也可能不要的一项输入,而把它当成前置条件,会让
15+
「内建打包被拒绝」的任何目标上,**所有**被分派的格式都变得不可达。
16+
17+
实测(macos-15,mcpp 2026.9.11.1):`mcpp pack --format app` 根本到不了分派 ——
18+
`pack::run` 会直接拒绝一个 Mach-O **程序**,因为内建的闭包走的是
19+
`LD_TRACE_LOADED_OBJECTS`,那是 glibc 的机制,dyld 不认它、而是**直接把程序跑起来**
20+
那条拒绝对内建归档是正确的,却对「一个 `.app` 打包器能不能工作」什么都没说 ——
21+
一个只点名一个程序的打包器根本不需要走闭包。**引擎在回答一个提供方没有被问到的
22+
问题。**
23+
24+
所以失败现在是**带着原因继续**而不是被吞掉:原因作为 warning 印出来,
25+
`pack_stage_dir` 保持为空,`${mcpp.stage_dir}` 于是在展开处带着那条原因拒绝。读树的
26+
提供方拿到精确诊断,不读树的照常走完。没有任何东西被静默降级 —— 变的是**由谁来做
27+
这个决定**
28+
29+
`BuildOverrides::pack_stage_reason` 是那条原因的通道。没有它,一个明明在打包的构建
30+
会读到「this build is not packaging」,而那句话会把成员作者引向错误的方向。
31+
32+
⚠️ 这条修复是由 CI 在 macOS 上第一次真的跑 `dist-apple` 才暴露出来的。在那之前它
33+
只有 plan 级断言撑着,而 plan 级断言说的是「闸对了」,对「工具接不接受成员渲染出来
34+
的东西」一个字都没说。
35+
36+
837
## [2026.9.11.1] - 2026-09-11
938

1039
### `mcpp pack --format <name>` 分派到包,而引擎里不再需要住进任何一种分发格式

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.9.11.1"
3+
version = "2026.9.11.2"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

modules/versioning/src/version.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ import std;
3131

3232
export namespace mcpp {
3333

34-
inline constexpr std::string_view MCPP_VERSION = "2026.9.11.1";
34+
inline constexpr std::string_view MCPP_VERSION = "2026.9.11.2";
3535

3636
} // namespace mcpp

src/build/prepare.cppm

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,16 @@ export struct BuildOverrides {
10171017
// already answered.
10181018
std::string pack_format;
10191019
std::filesystem::path pack_stage_dir;
1020+
// WHY THERE IS NO STAGED TREE, when there is none and a format was still
1021+
// requested. Empty otherwise.
1022+
//
1023+
// A dispatched format does not require the built-in bundling to have
1024+
// succeeded -- see the note in `mcpp.pack.pipeline`. When it did not, the
1025+
// reason travels here so `${mcpp.stage_dir}`'s refusal can name it instead
1026+
// of saying only that the placeholder is unavailable. A member author
1027+
// reading "this build is not packaging" for a build that plainly is would
1028+
// be sent looking in the wrong place.
1029+
std::string pack_stage_reason;
10201030
};
10211031

10221032
// ── git dependency helpers ──────────────────────────────────────────────────
@@ -10299,6 +10309,9 @@ prepare_build(bool print_fingerprint,
1029910309
// Section 2 of the design record measured that shape: a valid, empty,
1030010310
// 52 KB installer with nothing said about it.
1030110311
std::set<std::string> stageDirNoPass, stageDirWrongRole;
10312+
// Carried from the overrides so the refusal below can say WHY there is
10313+
// no tree, which is a different sentence from "you are not packaging".
10314+
std::string stageDirWhy;
1030210315
// WHETHER *THIS* ACTION REFERENCED THE STAGED TREE, and deliberately a
1030310316
// flag rather than a set keyed on the action's id: an id is unique
1030410317
// within the package that declared it and nothing more, so two packages
@@ -10329,6 +10342,7 @@ prepare_build(bool print_fingerprint,
1032910342
if (s.find("${mcpp.stage_dir}") != std::string::npos) {
1033010343
if (!stagePass) {
1033110344
stageDirNoPass.insert(actionId);
10345+
stageDirWhy = overrides.pack_stage_reason;
1033210346
} else if (role != mcpp::manifest::BuildAction::Role::Artifact) {
1033310347
stageDirWrongRole.insert(actionId);
1033410348
} else {
@@ -10408,6 +10422,18 @@ prepare_build(bool print_fingerprint,
1040810422
if (!stageDirNoPass.empty()) {
1040910423
std::string ids;
1041010424
for (auto const& n : stageDirNoPass) ids += (ids.empty() ? "" : ", ") + n;
10425+
if (!stageDirWhy.empty()) {
10426+
return std::unexpected(std::format(
10427+
"build.mcpp action(s) [{}] reference ${{mcpp.stage_dir}}, and no "
10428+
"tree could be staged for this target.\n"
10429+
" {}\n"
10430+
" The format was requested and the provider was reached; what is "
10431+
"missing is the staged\n"
10432+
" closure itself. A member that names a built file with "
10433+
"${{mcpp.target_file:<name>}} instead\n"
10434+
" of reading the tree is unaffected on this target.",
10435+
ids, stageDirWhy));
10436+
}
1041110437
return std::unexpected(std::format(
1041210438
"build.mcpp action(s) [{}] reference ${{mcpp.stage_dir}}, and this "
1041310439
"build is not packaging.\n"

src/pack/pipeline.cppm

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -286,17 +286,50 @@ export int build_and_pack(Options opts, bool modeFromUser,
286286
mcpp::pack::mode_cli_name(plan->opts.mode),
287287
plan->strip ? ", stripped" : ""));
288288

289-
auto r = mcpp::pack::run(*plan, *cfg);
290-
if (!r) {
291-
mcpp::ui::error(r.error().message);
292-
return 1;
289+
// STAGING IS A SERVICE TO THE PROVIDER, NOT A PRECONDITION FOR DISPATCH.
290+
//
291+
// For `--format tar` and `--format dir` the staged tree IS the product, so
292+
// a staging failure is the command failing. For a DISPATCHED format it is
293+
// an input the provider may or may not want, and treating it as a
294+
// precondition made every dispatched format unreachable on any target
295+
// whose built-in bundling is refused.
296+
//
297+
// Measured on macos-15 with mcpp 2026.9.11.1: `mcpp pack --format app`
298+
// never reached the dispatch, because `pack::run` refuses a Mach-O PROGRAM
299+
// outright -- the built-in closure walk is `LD_TRACE_LOADED_OBJECTS`, which
300+
// is glibc's, and dyld ignores it and runs the program instead. That
301+
// refusal is correct about the built-in archive and says nothing about
302+
// whether a `.app` bundler can work, since a bundler that names one
303+
// program needs no closure walk at all. The engine was answering a
304+
// question the provider had not been asked.
305+
//
306+
// So the failure is REPORTED AND CARRIED rather than swallowed: the reason
307+
// is printed as a warning, `pack_stage_dir` stays empty, and
308+
// `${mcpp.stage_dir}` then refuses at expansion naming that reason. A
309+
// provider that reads the tree gets a precise diagnostic; one that does not
310+
// proceeds. Nothing is silently degraded -- what changes is who decides.
311+
std::string stageFailure;
312+
if (auto r = mcpp::pack::run(*plan, *cfg); !r) {
313+
if (opts.format != mcpp::pack::Format::Dispatched) {
314+
mcpp::ui::error(r.error().message);
315+
return 1;
316+
}
317+
stageFailure = r.error().message;
318+
mcpp::ui::warning(std::format(
319+
"no staged tree for --format {}: {}\n"
320+
" A format that consumes ${{mcpp.stage_dir}} cannot be produced "
321+
"here; one that names a\n"
322+
" built file with ${{mcpp.target_file:<name>}} is unaffected.",
323+
opts.formatName, stageFailure));
293324
}
294325

295326
// The staged tree is now on disk and final -- past the closure, the
296327
// `$ORIGIN` rewriting, the strip and the debug split. Describe it, so an
297328
// action that consumes it has something whose CONTENT changes when the
298-
// staged set does. Best-effort: see write_stage_manifest.
299-
mcpp::pack::write_stage_manifest(plan->stagingRoot);
329+
// staged set does. Best-effort: see write_stage_manifest. Skipped when
330+
// staging did not happen, so no manifest describes a tree that is not
331+
// there.
332+
if (stageFailure.empty()) mcpp::pack::write_stage_manifest(plan->stagingRoot);
300333

301334
auto pathCtx = mcpp::fetcher::make_path_ctx(&*cfg, ctx->projectRoot);
302335

@@ -328,7 +361,12 @@ export int build_and_pack(Options opts, bool modeFromUser,
328361
preexistingArtifacts.emplace(a.packageName, a.id);
329362

330363
ov.pack_format = opts.formatName;
331-
ov.pack_stage_dir = plan->stagingRoot;
364+
// Empty when staging was refused, which is what makes
365+
// `${mcpp.stage_dir}` refuse with the reason attached rather than
366+
// expand to a directory that does not exist.
367+
ov.pack_stage_dir = stageFailure.empty() ? plan->stagingRoot
368+
: std::filesystem::path{};
369+
ov.pack_stage_reason = stageFailure;
332370
auto distCtx = mcpp::build::prepare_build(false, false, {}, ov);
333371
if (!distCtx) { mcpp::ui::error(distCtx.error()); return 2; }
334372

tests/e2e/638_pack_format_dispatch.sh

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,49 @@ grep -q "size.stamp" b8.log \
309309
[ -n "$(find target -name 'size.stamp' 2>/dev/null)" ] \
310310
|| { echo "FAIL: the ungated artifact action did not run at all"; exit 1; }
311311

312+
# ── 9. a format that names no staged tree survives a staging refusal ──────
313+
# THE CASE macOS FOUND, HELD ON EVERY PLATFORM.
314+
#
315+
# `mcpp pack` refuses to bundle a Mach-O PROGRAM: the built-in closure walk is
316+
# `LD_TRACE_LOADED_OBJECTS`, glibc's mechanism, and dyld ignores it and runs the
317+
# program instead. That refusal is right about the built-in archive and says
318+
# nothing about whether a `.app` bundler can work -- one that names a single
319+
# program needs no closure walk at all. Before this, the refusal happened before
320+
# the dispatch, so EVERY dispatched format was unreachable on that target.
321+
#
322+
# Staging is now a service to the provider rather than a precondition. This leg
323+
# cannot reproduce the Mach-O refusal on Linux, so it holds the property the fix
324+
# rests on instead: a provider that reads no staged tree is reported, and the
325+
# reason travels far enough to reach the placeholder's refusal.
326+
cd "$TMP"
327+
cp -r app nostage
328+
cd nostage
329+
cat > build.mcpp <<'EOF'
330+
import mcpp;
331+
#include <string>
332+
#include <string_view>
333+
int main() {
334+
mcpp::provides_pack_format("zap");
335+
if (std::string_view(mcpp::pack_format()) != "zap") return 0;
336+
// Reads NOTHING from the staged tree: the program arrives through
337+
// ${mcpp.target_file:...}, which is what an `.msi` of one program does.
338+
const std::string root = mcpp::manifest_dir();
339+
const std::string out = std::string(mcpp::out_dir()) + "/app.zap";
340+
mcpp::action a;
341+
a.id = "zap"; a.role = "artifact";
342+
a.arg((root + "/dist.sh").c_str()).arg("named").arg(root.c_str()).arg(out.c_str())
343+
.input("${mcpp.target_file:app}").output(out.c_str());
344+
a.submit();
345+
return 0;
346+
}
347+
EOF
348+
"$MCPP" pack --format zap > b9.log 2>&1 || { cat b9.log; echo "FAIL: a provider that reads no staged tree was refused"; exit 1; }
349+
grep -q "app.zap" b9.log \
350+
|| { cat b9.log; echo "FAIL: the provider was not reported as the package"; exit 1; }
351+
# It really did not consume the tree: no stage manifest edge was added, so the
352+
# action's inputs are the program alone.
353+
grep -q "stage-manifest" b9.log \
354+
&& { cat b9.log; echo "FAIL: a provider that names no tree gained a stage dependency"; exit 1; }
355+
echo "ok: a provider that reads no staged tree is dispatched and reported"
356+
312357
echo "PASS: 638_pack_format_dispatch"

0 commit comments

Comments
 (0)