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,