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,
|
||||
|
||||
142
tests/publish_fail.sx
Normal file
142
tests/publish_fail.sx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user