Skip to content

Commit 89f1daa

Browse files
committed
dist-apple: the bundle directory is the terminal artifact
mcpp run --format <fmt> hands the runner the request's terminal artifact: the output of an introduced action no other introduced action consumes. dist-apple's plan submits info-plist, layout, icon and codesign as parallel steps -- each writes a file inside the bundle and none consumes another's output -- so a plan with more than one of them has that many terminal artifacts and mcpp run has no single operand. This is the iOS-row failure: "produced 4 distributables ... needs exactly one". The distributable of --format app is the bundle directory itself, not any one file inside it. Added a final step, id mcpp.dist.apple.bundle, whose inputs are every previous step's own output (codesign's stamp included, so the bundle is not the terminal until signing has happened) and whose single output is the bundle directory, produced by /usr/bin/touch -- a directory is an acceptable action output (the engine accepts is_regular_file || is_directory), and touch's refreshed mtime is what lets ninja record the edge as run. The same plan_for code path serves both the Contents/-shaped macOS layout and the flat iOS one, so both rows get the fix together. tests/ios-app-consumer/check-ios-plan.sh now asserts the property, not just the step's presence: for every row (simulator, simulator with an empty stage dir, device, macOS), every other planned step's output must be named among the bundle step's own inputs, and the bundle step's own output must be named in no step's inputs anywhere in the plan -- the property that makes it the plan's sole terminal. The check reads only the declared "inputs" field, not the whole action line, because ditto's own argv legitimately names the bundle directory as a copy destination on every row and that is not a graph dependency. Also asserts no bundle step is planned on the icon-refused path, where no steps are submitted at all. tests/app-consumer carries no separate plan-check script; its CI step execs the bundle directly rather than going through mcpp run --format, so it was not exposed to this failure. Verified against the released engine (2026.9.12.3): reverting the dist/apple.cppm change makes the updated script fail with "no bundle step planned" on every row; with the change, all five checks pass.
1 parent 4438977 commit 89f1daa

2 files changed

Lines changed: 116 additions & 6 deletions

File tree

dist/apple.cppm

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,34 @@
4343
// and no `options::tool` the way `wix` does in `dist/wix.cppm` -- there is
4444
// exactly one `ditto`, at a fixed path, on every Mac this can run on.
4545
//
46-
// HOW MANY ACTIONS, AND WHY EACH IS SEPARATE. Up to four:
46+
// HOW MANY ACTIONS, AND WHY EACH IS SEPARATE. Up to five:
4747
//
4848
// 1. install `Info.plist` (always)
4949
// 2. lay out the staged tree (always)
5050
// 3. install the icon (only when `options::icon` is set)
5151
// 4. codesign the bundle (only when `options::identity` is set)
52+
// 5. the bundle itself (always, last)
5253
//
5354
// 1 and 3 are separate from 2 because they have different INPUTS: `Info.plist`
5455
// is regenerated whenever package metadata changes, the icon only when the
5556
// project's icon file changes, and the staged tree only when the program or
5657
// its closure changes. One action for all three would make every one of those
57-
// changes re-run the multi-hundred-megabyte copy. 4 is last and depends on
58-
// the OUTPUTS of whichever of 1 to 3 actually ran, because a code signature
59-
// covers the bundle's content at signing time -- signing before the content
60-
// is in place is either a failure (an incomplete bundle) or a signature that
61-
// the next file added invalidates.
58+
// changes re-run the multi-hundred-megabyte copy. 4 is last of the CONTENT
59+
// steps and depends on the OUTPUTS of whichever of 1 to 3 actually ran,
60+
// because a code signature covers the bundle's content at signing time --
61+
// signing before the content is in place is either a failure (an incomplete
62+
// bundle) or a signature that the next file added invalidates.
63+
//
64+
// 5 EXISTS BECAUSE 1 THROUGH 4 ARE PARALLEL, AND `mcpp run` NEEDS ONE
65+
// OPERAND. Each of them writes a file inside the bundle and consumes none of
66+
// the others' outputs, so a request that submits only this plan has as many
67+
// terminal artifacts (outputs nothing else consumes) as steps actually ran --
68+
// up to four, never one. `mcpp run --format app` resolves to THE terminal
69+
// artifact, so a plan with more than one has none it can hand the runner.
70+
// Step 5's output is the bundle DIRECTORY -- the actual distributable of this
71+
// format -- and its inputs are every other step's output, so it is always
72+
// the plan's sole terminal, in both the `Contents/`-shaped and flat-iOS
73+
// layouts.
6274
//
6375
// `Info.plist` IS WRITTEN AT PLAN TIME, BUT NOT DIRECTLY TO ITS FINAL PATH,
6476
// AND THE DIFFERENCE MATTERS. It is configuration, so `write_if_different`
@@ -772,8 +784,39 @@ inline plan plan_for(options opt = {}) {
772784
// something measured here -- codesign does not run on Linux.
773785
sign.outputs = { (isIos ? bundlePath : bundlePath + "/Contents") + "/_CodeSignature/CodeResources" };
774786
p.steps.push_back(sign);
787+
assembled.push_back(sign.outputs.front());
775788
}
776789

790+
// THE BUNDLE DIRECTORY IS THIS PLAN'S OWN TERMINAL ARTIFACT.
791+
//
792+
// `mcpp run --format <fmt>` hands the runner the request's TERMINAL
793+
// ARTIFACT -- the output of an introduced action no other introduced
794+
// action consumes. Every step above writes a file INSIDE the bundle
795+
// (`Info.plist`, the executable, an icon, codesign's own stamp), and
796+
// none of those files is an input of any of the others in a chain that
797+
// ends in one: `info`, `layout`, `icon` and `sign` are four parallel
798+
// steps, so without this one the plan has four terminals and `mcpp run`
799+
// has no single operand to pass on -- exactly the failure `dist-apple`'s
800+
// iOS row hit (#622: "produced 4 distributables ... needs exactly one").
801+
//
802+
// The distributable of `--format app` is the BUNDLE, not any one file in
803+
// it, so this step's own output is the bundle directory itself, and its
804+
// inputs are every other step's output declared so far -- codesign's
805+
// stamp included, when it ran, so the bundle is not the terminal until
806+
// signing (the last thing that can still fail) has happened. A directory
807+
// is an acceptable action output (the engine verifies `is_regular_file
808+
// || is_directory`); `touch` has nothing to write, only a mtime to
809+
// refresh, and refreshing it is what makes ninja record the edge as run
810+
// rather than replay a stale one.
811+
step bundle;
812+
bundle.id = "mcpp.dist.apple.bundle";
813+
bundle.role = "artifact";
814+
bundle.description = "APP BUNDLE";
815+
bundle.argv = { "/usr/bin/touch", bundlePath };
816+
bundle.inputs = assembled;
817+
bundle.outputs = { bundlePath };
818+
p.steps.push_back(bundle);
819+
777820
p.applies = true;
778821
return p;
779822
}

tests/ios-app-consumer/check-ios-plan.sh

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,67 @@ set -e
1818
MCPP="${MCPP:-mcpp}"
1919
fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; }
2020

21+
# THE BUNDLE STEP IS THE PLAN'S SOLE TERMINAL ARTIFACT (#622: "mcpp run
22+
# --format app produced 4 distributables ... needs exactly one"). `info`,
23+
# `layout`, `icon` and `codesign` are parallel -- none consumes another's
24+
# output -- so without a step naming the bundle directory itself as ITS
25+
# output, `mcpp run --format app` has as many terminal artifacts as steps ran
26+
# and no single operand to hand the runner. This checks the property, not
27+
# just the id's presence: every OTHER planned step's own output must be named
28+
# among `bundle`'s own inputs, and `bundle`'s own output (the `.app`
29+
# directory) must be named in no step's inputs anywhere in the plan -- the
30+
# one thing left that nothing consumes.
31+
# Both restricted to the named FIELD, never the whole JSON line: `command`
32+
# (ditto's own argv) legitimately names the bundle directory as a
33+
# destination on every row, and matching against the whole line would read
34+
# that as "consumes", which it is not -- only `inputs` decides the graph's
35+
# edges.
36+
quoted_outputs() { # $1 = one action's JSON line
37+
echo "$1" | grep -oP '(?<="outputs":\[)[^]]*' | grep -oP '"[^"]*"'
38+
}
39+
quoted_inputs() { # $1 = one action's JSON line
40+
echo "$1" | grep -oP '(?<="inputs":\[)[^]]*' | grep -oP '"[^"]*"'
41+
}
42+
check_terminal_bundle() {
43+
local log="$1" bundle_line bundle_out other_id other_line q found_any=0
44+
bundle_line=$(grep -o '"id":"mcpp\.dist\.apple\.bundle"[^}]*}' "$log") \
45+
|| fail "no bundle step planned" "$log"
46+
[ -n "$bundle_line" ] || fail "no bundle step planned" "$log"
47+
bundle_out=$(quoted_outputs "$bundle_line")
48+
[ "$(echo "$bundle_out" | wc -l)" -eq 1 ] \
49+
|| fail "the bundle step does not declare exactly one output" "$log"
50+
echo "$bundle_out" | grep -q '\.app"$' \
51+
|| fail "the bundle step's own output is not the .app directory" "$log"
52+
local bundle_inputs
53+
bundle_inputs=$(quoted_inputs "$bundle_line")
54+
for other_id in mcpp.dist.apple.info-plist mcpp.dist.apple.layout \
55+
mcpp.dist.apple.icon mcpp.dist.apple.codesign; do
56+
other_line=$(grep -o "\"id\":\"$other_id\"[^}]*}" "$log") || true
57+
[ -n "$other_line" ] || continue
58+
found_any=1
59+
while IFS= read -r q; do
60+
[ -n "$q" ] || continue
61+
grep -qF "$q" <<<"$bundle_inputs" \
62+
|| fail "the bundle step does not depend on $other_id's own output ($q)" "$log"
63+
done <<<"$(quoted_outputs "$other_line")"
64+
# And the reverse: no OTHER step may already name the bundle
65+
# directory among its OWN inputs -- that would make it, not
66+
# `bundle`, the one this plan's earlier steps are ordered against.
67+
grep -qF "$bundle_out" <<<"$(quoted_inputs "$other_line")" \
68+
&& fail "$other_id names the bundle directory among its own inputs" "$log"
69+
done
70+
[ "$found_any" -eq 1 ] || fail "no content step (info/layout/icon/codesign) was planned to check against" "$log"
71+
# Nothing anywhere in the plan consumes the bundle step's own output (as
72+
# an INPUT, never as an argv destination) -- the property that makes it
73+
# the sole terminal artifact.
74+
while IFS= read -r other_line; do
75+
[ -n "$other_line" ] || continue
76+
grep -qF "$bundle_out" <<<"$(quoted_inputs "$other_line")" \
77+
&& fail "something in the plan still consumes the bundle directory as an input; it is not the sole terminal" "$log"
78+
done <<<"$(grep -o '"id":"mcpp\.dist\.apple\.[a-z-]*"[^}]*}' "$log" | grep -v '"id":"mcpp\.dist\.apple\.bundle"')"
79+
echo "ok: mcpp.dist.apple.bundle is the plan's sole terminal artifact ($bundle_out)"
80+
}
81+
2182
rm -rf target
2283
"$MCPP" build > build.log 2>&1 || fail "the host build failed to compile build.mcpp" build.log
2384
BIN=target/.build-mcpp/build.mcpp.bin
@@ -89,6 +150,7 @@ grep 'mcpp.dist.apple.layout' "$log" | grep -q '\${mcpp\.stage_dir}' \
89150
grep -q 'holds only' "$log" \
90151
&& fail "a non-empty staged tree still produced the 0-byte staged-tree warning" "$log"
91152
echo "ok: flat layout, no codesign, a named warning, and every iOS-only plist key"
153+
check_terminal_bundle "$log"
92154

93155
echo "== iOS Simulator row, an empty pack_stage_dir (#622 B2 defect 1) =="
94156
run_row simnostage ios sim /tmp/ios-plan-sim-nostage.log EMPTY > /dev/null
@@ -101,6 +163,7 @@ grep 'mcpp.dist.apple.layout' "$log" | grep -q '\${mcpp\.stage_dir}' \
101163
grep -q 'holds only' "$log" \
102164
&& fail "an empty pack_stage_dir produced the misleading 0-byte staged-tree warning" "$log"
103165
echo "ok: an empty pack_stage_dir takes the single-binary path, named through \${mcpp.target_file:...}, with no staged-tree warning"
166+
check_terminal_bundle "$log"
104167

105168
echo "== iOS Simulator row, a manifest directory with no ios-icons/ (#622 B2 defect 2) =="
106169
badmanifest=$(mktemp -d)
@@ -111,6 +174,8 @@ grep -qF "$badmanifest/ios-icons" "$log" \
111174
|| fail "the missing-icon refusal did not resolve options::icon against MCPP_MANIFEST_DIR" "$log"
112175
grep -q 'mcpp.dist.apple.layout' "$log" \
113176
&& fail "a layout step was planned even though the icon directory was refused" "$log"
177+
grep -q 'mcpp.dist.apple.bundle' "$log" \
178+
&& fail "a bundle step was planned even though the icon directory was refused" "$log"
114179
echo "ok: a relative options::icon resolves against MCPP_MANIFEST_DIR, and a missing directory is refused at plan time, naming options::icon and the path"
115180

116181
echo "== iOS device row =="
@@ -123,6 +188,7 @@ grep -q 'is ignored on the iOS Simulator row' "$log" \
123188
grep -A2 '<key>CFBundleSupportedPlatforms</key>' "$plist" | grep -q 'iPhoneOS' \
124189
|| fail "CFBundleSupportedPlatforms does not name iPhoneOS on the device row" "$plist"
125190
echo "ok: codesign is planned, and CFBundleSupportedPlatforms names iPhoneOS"
191+
check_terminal_bundle "$log"
126192

127193
echo "== macOS row is unaffected =="
128194
outdir=$(run_row macos macos "" /tmp/ios-plan-macos.log)
@@ -137,5 +203,6 @@ for key in CFBundleSupportedPlatforms UIDeviceFamily LSRequiresIPhoneOS MinimumO
137203
done
138204
grep -q '<key>NSHighResolutionCapable</key>' "$plist" || fail "the macOS plist lost NSHighResolutionCapable" "$plist"
139205
echo "ok: the macOS row's steps and plist are unchanged by the iOS branch"
206+
check_terminal_bundle "$log"
140207

141208
echo "PASS: dist-apple's iOS row, at the plan level"

0 commit comments

Comments
 (0)