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:
40
src/dist.sx
40
src/dist.sx
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user