P3.4b: loud machine-readable publish failure paths

Every abort site writes a CliFailure (stable dotted code + human message
naming the offending input); under --json the CLI emits a single
{"status":"error","error":{code,message}} object on stdout and exits 1 —
distinct from the parser's EX_USAGE 64. All aborts happen before db.save
and the repo transaction rolls back, so a failed publish never changes
db.json. Pinned test drives all five failure classes plus the
non-empty-store no-partial-state crux.
This commit is contained in:
agra
2026-06-12 00:20:41 +03:00
parent 7176e63503
commit 3c9a15ec80
4 changed files with 294 additions and 22 deletions

View File

@@ -11,8 +11,10 @@
// EXIT-CODE CONTRACT (sysexits, via std.cli): success ends with
// `exit_ok()` (EX_OK = 0); a no-command / unknown-or-missing
// group/command/flag ends with `exit_usage()` (EX_USAGE = 64). An
// explicit `-h`/`--help` is not an error and ends 0. A failed publish also
// ends `exit_usage()` for now (the failure/abort contract lands in P3.4b).
// explicit `-h`/`--help` is not an error and ends 0. A command that parsed
// correctly but FAILED in execution (publish abort) ends 1 — under `--json`
// the failure is also a single machine-readable error object on stdout
// (`{"status":"error","error":{code,message}}`).
//
// `--json` PURITY: every command accepts the reserved global `--json`
// flag (surfaced by the parser as `parsed.json`). In json mode stdout
@@ -40,7 +42,7 @@ emit_human :: (s: string, json_mode: bool) {
if json_mode { eputs(s); } else { out(s); }
}
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 64 usage error (no command, or an unknown/missing command or flag)\n";
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote promote a release onto a channel (stub)\n release rollback roll a channel back to a prior release (stub)\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted; JSON error under --json)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
// True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool {
@@ -64,20 +66,42 @@ error_phrase :: (e: CliError) -> string {
// ── Handlers ─────────────────────────────────────────────────────────
// A command that parsed correctly but failed in execution exits 1 —
// distinct from the parser's EX_USAGE (64), so CI can tell "you called it
// wrong" from "the publish itself aborted".
exit_command_failed :: () -> noreturn { process.exit(1); }
// Report a failed command and exit 1: the human sentence goes to stderr;
// under `--json` the machine-readable error object is the only thing on
// stdout (the purity contract holds on failure paths too).
report_failure :: (cmd: string, fail: jout.CliFailure, json_mode: bool) -> noreturn {
eputs(concat(concat(concat(concat("dist: ", cmd), " failed: "), fail.message), "\n"));
if json_mode {
raw : [4096]u8 = ---;
werr := false;
n := jout.write_error(fail, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
if !werr {
out(string.{ ptr = @raw[0], len = n });
out("\n");
}
}
exit_command_failed();
}
// `dist ci publish` — the real local publish pipeline (P3.4a). Runs the
// publish, then renders the outcome: in json mode a single stable JSON
// object on stdout with the human progress note on stderr (the `--json`
// purity contract); otherwise a readable summary on stdout. A failed
// publish prints a diagnostic to stderr and ends with EX_USAGE.
// publish reports through `report_failure` (stderr sentence, JSON error
// object under --json, exit 1).
handle_ci_publish :: (p: *Parsed, json_mode: bool) {
manifest_path := p.value_of("manifest");
store_dir := p.value_of("local-store");
o, e := pl.run_publish(manifest_path, store_dir);
fail : jout.CliFailure = .{};
o, e := pl.run_publish(manifest_path, store_dir, @fail);
if e {
tag : u32 = xx e;
eputs(concat(concat("dist: ci publish failed: ", error_tag_name(tag)), "\n"));
exit_usage();
report_failure("ci publish", fail, json_mode);
}
if !e {

View File

@@ -9,6 +9,33 @@
#import "modules/std.sx";
#import "modules/std/json.sx";
// A machine-readable command failure: a stable dotted `code` naming the
// failing stage and sub-reason (e.g. "validation.digest_mismatch") plus a
// human sentence. Pipelines fill one of these at the raise site, so the
// CLI can report precisely what failed without the error channel having to
// carry data.
CliFailure :: struct {
code: string = "";
message: string = "";
}
// Serialize a failure as the `--json` error object — `{ "status": "error",
// "error": { "code": <code>, "message": <message> } }` — into the
// caller-owned `dst`, returning the bytes written. Overflow surfaces on
// the error channel.
write_error :: (f: CliFailure, dst: []u8) -> (s64, !JsonError) {
gpa := GPA.init();
obj : Object = .{};
obj.put("status", .str("error"), xx gpa);
eo : Object = .{};
eo.put("code", .str(f.code), xx gpa);
eo.put("message", .str(f.message), xx gpa);
obj.put("error", .object(eo), xx gpa);
root : Value = .object(obj);
n := try write_to_buffer(root, dst);
return n;
}
// Serialize a stub command's machine result — `{ "command": <cmd>,
// "status": "ok", "stub": true }` — into the caller-owned `dst`, returning
// the number of bytes written. Overflow surfaces on the error channel.

View File

@@ -1,6 +1,6 @@
// =====================================================================
// publish.sx — the local `dist ci publish` SUCCESS pipeline (subplan 03,
// Slice 1). Wires the prior modules into one end-to-end publish:
// publish.sx — the local `dist ci publish` pipeline (subplan 03, Slice 1).
// Wires the prior modules into one end-to-end publish:
//
// manifest (P3.2) -> store (P2.2) -> common validation (P3.3) ->
// repository transaction + audit (P2.3) -> db.json persistence (P2.3)
@@ -20,9 +20,13 @@
// common validation pass checks the on-disk file against. When it does NOT
// (size == -1 / sha256 == ""), the expectation is DERIVED from the stored
// object — the size read off disk and the sha256 returned by the store — so
// a no-declaration manifest validates trivially. (P3.4b will declare a wrong
// size/sha256 to drive the abort path; the repo transaction's rollback is
// left intact for it.)
// a no-declaration manifest validates trivially.
//
// FAILURE CONTRACT (P3.4b): every abort happens BEFORE `db.save`, and the
// repo transaction rolls itself back, so a failed publish never changes
// db.json — no partially-published release, no moved channel pointer. Each
// raise site first writes a `jout.CliFailure` (stable dotted code + human
// message naming the offending input) for the CLI to report.
//
// LOCAL DOWNLOAD URL FORM: `file://<abs-store>/objects/<sha256>`, where
// <abs-store> is the `--local-store` directory resolved to an absolute path
@@ -47,6 +51,7 @@
#import "../validation/artifact_file.sx";
mani :: #import "../manifest/manifest.sx";
db :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
// libc handles the two facts the publish needs from the OS: the wall-clock
// time stamped onto the release / audit events, and the process cwd used to
@@ -124,14 +129,58 @@ default_content_type :: (p: Platform) -> string {
return "application/x-msdownload"; // windows
}
// ── failure detail ────────────────────────────────────────────────────
// The error channel carries only a tag; the precise reason (which manifest
// field, which validation check, which artifact) is written into the
// caller's `jout.CliFailure` at the raise site, as a stable dotted code +
// a human sentence naming the offending input where one exists.
manifest_code :: (e: mani.ManifestErr) -> string {
if e == error.BadJson { return "manifest.bad_json"; }
if e == error.WrongType { return "manifest.wrong_type"; }
if e == error.MissingField { return "manifest.missing_field"; }
if e == error.UnknownPlatform { return "manifest.unknown_platform"; }
if e == error.MissingArtifact { return "manifest.missing_artifact"; }
return "manifest.io";
}
manifest_message :: (e: mani.ManifestErr, path: string) -> string {
if e == error.BadJson { return concat("manifest is not valid JSON: ", path); }
if e == error.WrongType { return concat("manifest field has the wrong JSON type: ", path); }
if e == error.MissingField { return concat("manifest is missing a required field: ", path); }
if e == error.UnknownPlatform { return concat("manifest names an unknown platform id: ", path); }
if e == error.MissingArtifact { return concat("manifest references an artifact file that does not exist: ", path); }
return concat("manifest file could not be read: ", path);
}
validation_code :: (r: ValidationReason) -> string {
if r == .missing_file { return "validation.missing_file"; }
if r == .size_mismatch { return "validation.size_mismatch"; }
if r == .digest_mismatch { return "validation.digest_mismatch"; }
if r == .content_type_denied { return "validation.content_type_denied"; }
return "validation.extension_mismatch";
}
validation_message :: (r: ValidationReason, path: string) -> string {
if r == .missing_file { return concat("artifact file disappeared before validation: ", path); }
if r == .size_mismatch { return concat("artifact size does not match the declared size: ", path); }
if r == .digest_mismatch { return concat("artifact sha256 does not match the declared digest: ", path); }
if r == .content_type_denied { return concat("artifact content type is not on the allow-list: ", path); }
return concat("artifact extension does not match its platform: ", path);
}
// ── pipeline ──────────────────────────────────────────────────────────
run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !PublishError) {
run_publish :: (manifest_path: string, store_dir: string, fail_out: *jout.CliFailure) -> (PublishOutcome, !PublishError) {
alloc := context.allocator;
// 1. Validate the manifest (parse + on-disk artifact existence).
m, me := mani.load_manifest(manifest_path, alloc);
if me { raise error.Manifest; }
if me {
fail_out.code = manifest_code(me);
fail_out.message = manifest_message(me, manifest_path);
raise error.Manifest;
}
base_dir := dirname(manifest_path);
abs := abs_store(store_dir);
@@ -148,7 +197,11 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P
repo := Repo.init();
if exists(path_join(store_dir, "db.json")) {
loaded, le := db.load(store_dir);
if le { raise error.Persist; }
if le {
fail_out.code = "persist.load";
fail_out.message = concat("existing db.json under the store could not be loaded: ", store_dir);
raise error.Persist;
}
repo = loaded;
}
st := Store.init(store_dir);
@@ -192,12 +245,20 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P
// the bytes and hashing them in memory yields the identical
// `objects/<sha256>` key with no streaming hash.
sb := read_file(src);
if sb == null { raise error.Store; }
if sb == null {
fail_out.code = "store.read";
fail_out.message = concat("artifact file could not be read: ", src);
raise error.Store;
}
bytes := sb!;
actual_size := bytes.len;
key, se := st.put_bytes(bytes);
if se { raise error.Store; }
if se {
fail_out.code = "store.write";
fail_out.message = concat("artifact bytes could not be content-addressed into the store: ", src);
raise error.Store;
}
// Declared expectation when present; derived from the stored object
// otherwise (so a no-declaration manifest validates trivially).
@@ -206,7 +267,11 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P
ct := if ma.content_type.len > 0 then ma.content_type else default_content_type(ma.platform);
outcome := validate_artifact_file(src, exp_size, exp_sha, ma.platform, ct);
if outcome.status != .valid { raise error.Validation; }
if outcome.status != .valid {
fail_out.code = validation_code(outcome.reason);
fail_out.message = validation_message(outcome.reason, src);
raise error.Validation;
}
fname := if ma.filename.len > 0 then ma.filename else basename(src);
pname := db.platform_str(ma.platform);
@@ -236,8 +301,18 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P
policy = .manual, rollout_percent = 100,
};
pe := false;
repo.publish(rel, @arts, chan) catch { pe = true; };
if pe { raise error.Transaction; }
integ := false;
repo.publish(rel, @arts, chan) catch (e) { pe = true; integ = (e == error.Integrity); };
if pe {
if integ {
fail_out.code = "transaction.integrity";
fail_out.message = concat("publish rejected: the release/artifact/channel aggregate is identity-inconsistent (e.g. duplicate release id): ", release_id);
} else {
fail_out.code = "transaction.validation";
fail_out.message = concat("publish rejected: the release, an artifact, or the channel failed domain validation: ", release_id);
}
raise error.Transaction;
}
// Audit trail — only after a committed publish: one upload event per
// artifact, one publish event, one channel-promotion event.
@@ -265,7 +340,11 @@ run_publish :: (manifest_path: string, store_dir: string) -> (PublishOutcome, !P
// 6. Persist the whole model under the store.
persist_err := false;
db.save(repo, store_dir) catch { persist_err = true; };
if persist_err { raise error.Persist; }
if persist_err {
fail_out.code = "persist.save";
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
raise error.Persist;
}
return PublishOutcome.{
release_id = release_id, app_id = app_id,

142
tests/publish_fail.sx Normal file
View File

@@ -0,0 +1,142 @@
// Pinned acceptance for P3.4b — failure paths are loud and machine-readable.
//
// Drives the BUILT `build/dist` binary (via `process.run`, like
// publish_persist.sx) through every publish failure class the slice plan
// names — a malformed manifest, a missing artifact file, an unknown
// platform id, a declared-size mismatch, and a declared-sha256 mismatch —
// and asserts the P3.4b contract for each:
//
// * exit code 1 (command failed; NOT the parser's EX_USAGE 64),
// * stdout under `--json` is a SINGLE JSON object
// `{"status":"error","error":{"code":<dotted code>,"message":...}}`,
// * nothing is persisted: a fresh store gains no db.json.
//
// The no-partial-state crux is then asserted against a NON-EMPTY store:
// publish version A successfully, fail version B on a digest mismatch into
// the SAME store, and require db.json byte-state unchanged — one release,
// channel still pointing at A (no partially-published release, no moved
// channel pointer).
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
STORE :: ".sx-tmp/publish_fail";
MDIR :: ".sx-tmp/publish_fail_m";
// Manifests share the committed 5-byte fixtures; paths resolve relative to
// the manifest's own directory (MDIR), so ../../examples/fixtures reaches
// them.
GOOD_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
BAD_DIGEST :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"sha256\":\"0000000000000000000000000000000000000000000000000000000000000000\"}]}";
BAD_SIZE :: "{\"app\":\"acme-app\",\"version\":\"1.2.5\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\",\"size\":9999}]}";
BAD_PLATFORM:: "{\"app\":\"acme-app\",\"version\":\"1.2.6\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"playstation\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
NO_ARTIFACT :: "{\"app\":\"acme-app\",\"version\":\"1.2.7\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"no-such-file.apk\"}]}";
NOT_JSON :: "this is not a manifest";
get :: (o: Object, key: string) -> Value {
i := 0;
while i < o.len {
if o.items[i].key == key { return o.items[i].val; }
i += 1;
}
process.assert(false, concat("missing json key: ", key));
dummy : Value = .null_;
return dummy;
}
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
publish_cmd :: (mpath: string, store: string) -> string {
c := concat("build/dist ci publish --manifest ", mpath);
c = concat(c, concat(" --local-store ", store));
return concat(c, " --json 2>/dev/null");
}
// Write `body` to `path` via the shell (single-quoted, so the JSON's double
// quotes pass through literally).
write_file :: (path: string, body: string) {
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
process.run(cmd);
}
// Run one failing publish and assert the full failure contract: exit 1,
// stdout is exactly one JSON error object, and its error.code equals
// `want_code`. `store` distinguishes the per-case fresh stores.
assert_fails :: (label: string, mpath: string, store: string, want_code: string, scratch: Allocator) {
r := process.run(publish_cmd(mpath, store));
process.assert(r != null, concat("spawn failed: ", label));
res := r!;
process.assert(res.exit_code == 1, concat("must exit 1 (command failed): ", label));
v, e := parse(res.stdout, scratch);
if e { process.assert(false, concat("stdout must be one JSON object: ", label)); return; }
o := v.object;
process.assert(get_str(o, "status") == "error", concat("status must be \"error\": ", label));
eo := get_obj(o, "error");
process.assert(get_str(eo, "code") == want_code, concat("error.code mismatch: ", label));
process.assert(get_str(eo, "message").len > 0, concat("error.message must be non-empty: ", label));
out(concat(concat(" ", label), ": exit 1 + JSON error ok\n"));
}
main :: () -> s32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 1 << 20);
defer arena.deinit();
process.run(concat("rm -rf ", STORE));
process.run(concat("rm -rf ", MDIR));
process.run(concat("mkdir -p ", MDIR));
write_file(path_join(MDIR, "good_a.json"), GOOD_A);
write_file(path_join(MDIR, "bad_digest.json"), BAD_DIGEST);
write_file(path_join(MDIR, "bad_size.json"), BAD_SIZE);
write_file(path_join(MDIR, "bad_platform.json"), BAD_PLATFORM);
write_file(path_join(MDIR, "no_artifact.json"), NO_ARTIFACT);
write_file(path_join(MDIR, "not_json.json"), NOT_JSON);
// ── each failure class: exit 1 + the precise dotted code, into a
// fresh per-case store that must gain NO db.json ────────────────
assert_fails("digest mismatch", path_join(MDIR, "bad_digest.json"), concat(STORE, "-digest"), "validation.digest_mismatch", xx arena);
assert_fails("size mismatch", path_join(MDIR, "bad_size.json"), concat(STORE, "-size"), "validation.size_mismatch", xx arena);
assert_fails("unknown platform", path_join(MDIR, "bad_platform.json"), concat(STORE, "-platform"), "manifest.unknown_platform", xx arena);
assert_fails("missing artifact", path_join(MDIR, "no_artifact.json"), concat(STORE, "-missing"), "manifest.missing_artifact", xx arena);
assert_fails("malformed manifest",path_join(MDIR, "not_json.json"), concat(STORE, "-badjson"), "manifest.bad_json", xx arena);
process.assert(!fs.exists(concat(STORE, "-digest/db.json")),
"failed publish into a fresh store must not create db.json");
// ── no-partial-state against a NON-EMPTY store ────────────────────
// Publish A successfully, then fail B on a digest mismatch into the
// SAME store: db.json must be unchanged (one release, channel → A).
ra := process.run(publish_cmd(path_join(MDIR, "good_a.json"), STORE));
process.assert(ra != null, "spawn publish A failed");
process.assert(ra!.exit_code == 0, "publish A must exit 0");
rb := process.run(publish_cmd(path_join(MDIR, "bad_digest.json"), STORE));
process.assert(rb != null, "spawn failing publish B failed");
process.assert(rb!.exit_code == 1, "publish B must exit 1 (digest mismatch)");
db_bytes := fs.read_file(path_join(STORE, "db.json"));
process.assert(db_bytes != null, "db.json from publish A must still exist");
dv, de := parse(db_bytes!, xx arena);
if de { process.assert(false, "db.json must be valid JSON"); return 1; }
dbo := dv.object;
process.assert(get_arr(dbo, "releases").len == 1, "after failed B: db still has ONE release (A)");
chans := get_arr(dbo, "channels");
process.assert(chans.len == 1, "after failed B: one channel");
process.assert(get_str(chans.items[0].object, "current_release_id") == "rel-acme-app-1.2.3",
"after failed B: channel still points at A (no moved pointer)");
print(" non-empty store: failed publish left db.json unchanged\n");
process.run(concat("rm -rf ", concat(STORE, "-digest")));
process.run(concat("rm -rf ", concat(STORE, "-size")));
process.run(concat("rm -rf ", concat(STORE, "-platform")));
process.run(concat("rm -rf ", concat(STORE, "-missing")));
process.run(concat("rm -rf ", concat(STORE, "-badjson")));
process.run(concat("rm -rf ", STORE));
process.run(concat("rm -rf ", MDIR));
print("publish_fail: ALL CASES PASS\n");
return 0;
}