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.
143 lines
7.9 KiB
Plaintext
143 lines
7.9 KiB
Plaintext
// 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;
|
|
}
|