`dist ci publish` now seeds the Repo from a pre-existing <store>/db.json before find-or-create, so separate CLI invocations share state: a new version accumulates under the single found app, and re-publishing the same release id is rejected by the P2.3 integrity transaction (db.json left unchanged). An absent db.json still starts empty. The loaded model grows through its owning allocator (context.allocator), per the long-lived rule. Wiring db.load into the dist program (which already links manifest.sx) exposed two latent issues, both fixed: - db.sx's load-path helpers (dup_str/obj_find/req_obj/req_arr/ artifact_from_json) collided by name with manifest.sx's same-named helpers; sx resolves bare top-level names across the whole program, so load_into bound to manifest's versions and failed the LoadErr error-set check. Renamed db.sx's five helpers with a db_ prefix (load-path only; save path and public API untouched). - publish's `existing!.id` (only reachable once an app is found, i.e. never before this change) read garbage: sx miscompiles postfix-`!` chained with `.field`. Bound the unwrap to a local first, matching the codebase idiom. tests/publish_persist.sx drives build/dist twice into one store: publish A, then a different version B accumulates (two releases, one app, both objects), then re-publishing A's id fails and leaves db.json unchanged. Fails on the pre-fix write-only persistence, passes after.
186 lines
8.6 KiB
Plaintext
186 lines
8.6 KiB
Plaintext
// Regression for P3.4a-001 — `dist ci publish` must LOAD an existing
|
|
// `<store>/db.json` before publishing, so separate CLI invocations SHARE
|
|
// state through the store (not start from an empty Repo and clobber it).
|
|
//
|
|
// Drives the BUILT `build/dist` binary (via `process.run`, like
|
|
// publish_happy.sx) twice into ONE store and asserts cross-invocation
|
|
// persistence:
|
|
//
|
|
// 1. Publish version A (1.2.3) into a fresh store → db.json has the release.
|
|
// 2. Publish a DIFFERENT version B (1.2.4) of the SAME app into the SAME
|
|
// store → exit 0, and db.json now records BOTH releases under ONE app
|
|
// (the app is FOUND, not duplicated); the channel points at the latest
|
|
// release B; both content-addressed objects exist.
|
|
// 3. Re-publishing the SAME release id (A again) into the same store FAILS
|
|
// (exit != 0 — the P2.3 integrity transaction rejects the duplicate
|
|
// release id) and leaves db.json UNCHANGED (still two releases).
|
|
//
|
|
// FAIL-BEFORE / PASS-AFTER: against the pre-fix publish (which never reads
|
|
// db.json and so begins every invocation from an EMPTY Repo) step 2 CLOBBERS
|
|
// A — db.json ends with one release, not two — and step 3 "succeeds" (exit 0)
|
|
// and overwrites. Both assertions fail. After the load-then-merge fix they
|
|
// pass. Fresh store per run.
|
|
#import "modules/std.sx";
|
|
#import "modules/std/json.sx";
|
|
process :: #import "modules/process.sx";
|
|
fs :: #import "modules/fs.sx";
|
|
|
|
STORE :: ".sx-tmp/publish_persist";
|
|
MDIR :: ".sx-tmp/publish_persist_m";
|
|
A_PATH :: ".sx-tmp/publish_persist_m/a.json";
|
|
B_PATH :: ".sx-tmp/publish_persist_m/b.json";
|
|
|
|
// Two manifests for the SAME app/channel/fixtures, differing only in version.
|
|
// Artifact paths resolve relative to the manifest's own directory (MDIR), so
|
|
// `../../examples/fixtures/...` reaches the repo's committed fixtures.
|
|
MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"},{\"platform\":\"ios\",\"path\":\"../../examples/fixtures/acme-1.2.3-ios.ipa\"}]}";
|
|
MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"},{\"platform\":\"ios\",\"path\":\"../../examples/fixtures/acme-1.2.3-ios.ipa\"}]}";
|
|
|
|
// Fetch a member value by key, asserting presence (the publish/db output is a
|
|
// fixed shape, so an absent key is a hard failure).
|
|
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; }
|
|
|
|
// Count audit events whose "action" equals `action`.
|
|
count_action :: (events: Array, action: string) -> s64 {
|
|
c : s64 = 0;
|
|
i := 0;
|
|
while i < events.len {
|
|
eo := events.items[i].object;
|
|
if get_str(eo, "action") == action { c += 1; }
|
|
i += 1;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
// True iff `releases` (a db.json array) contains a release with id `id`.
|
|
has_release :: (releases: Array, id: string) -> bool {
|
|
i := 0;
|
|
while i < releases.len {
|
|
if get_str(releases.items[i].object, "id") == id { return true; }
|
|
i += 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// `build/dist ci publish` for `mpath` into the shared store, JSON mode,
|
|
// stderr suppressed so stdout stays pure for the parser.
|
|
publish_cmd :: (mpath: 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);
|
|
}
|
|
|
|
// Parse `<STORE>/db.json` into its root object (re-read fresh each call).
|
|
load_db :: (scratch: Allocator) -> Object {
|
|
db_bytes := fs.read_file(path_join(STORE, "db.json"));
|
|
process.assert(db_bytes != null, "db.json must exist under the store");
|
|
dv, de := parse(db_bytes!, scratch);
|
|
if de { process.assert(false, "db.json must be valid JSON"); dummy : Object = .{}; return dummy; }
|
|
return dv.object;
|
|
}
|
|
|
|
main :: () -> s32 {
|
|
gpa := GPA.init();
|
|
arena := Arena.init(xx gpa, 1 << 20);
|
|
defer arena.deinit();
|
|
|
|
// Fresh store + manifest dir, even after a crashed prior run.
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
process.run(concat("mkdir -p ", MDIR));
|
|
write_file(A_PATH, MANIFEST_A);
|
|
write_file(B_PATH, MANIFEST_B);
|
|
|
|
rel_a := "rel-acme-app-1.2.3";
|
|
rel_b := "rel-acme-app-1.2.4";
|
|
|
|
// ── 1. Publish version A into the fresh store ───────────────────────
|
|
ra := process.run(publish_cmd(A_PATH));
|
|
process.assert(ra != null, "spawn publish A failed");
|
|
res_a := ra!;
|
|
process.assert(res_a.exit_code == 0, "publish A must exit 0");
|
|
va, ea := parse(res_a.stdout, xx arena);
|
|
if ea { process.assert(false, "publish A stdout must be one JSON object"); return 1; }
|
|
process.assert(get_str(get_obj(va.object, "release"), "id") == rel_a, "A release id");
|
|
|
|
db1 := load_db(xx arena);
|
|
process.assert(get_arr(db1, "releases").len == 1, "after A: db has one release");
|
|
process.assert(get_arr(db1, "apps").len == 1, "after A: db has one app");
|
|
print(" A published; db has 1 release\n");
|
|
|
|
// ── 2. Publish a DIFFERENT version B into the SAME store ────────────
|
|
rb := process.run(publish_cmd(B_PATH));
|
|
process.assert(rb != null, "spawn publish B failed");
|
|
res_b := rb!;
|
|
process.assert(res_b.exit_code == 0, "publish B must exit 0 (accumulates)");
|
|
vb, eb := parse(res_b.stdout, xx arena);
|
|
if eb { process.assert(false, "publish B stdout must be one JSON object"); return 1; }
|
|
process.assert(get_str(get_obj(vb.object, "release"), "id") == rel_b, "B release id");
|
|
|
|
db2 := load_db(xx arena);
|
|
|
|
// The crux: BOTH releases under ONE app (app found, not duplicated).
|
|
db2_rels := get_arr(db2, "releases");
|
|
process.assert(db2_rels.len == 2, "after B: db records BOTH releases (no clobber)");
|
|
process.assert(has_release(db2_rels, rel_a), "after B: release A still present");
|
|
process.assert(has_release(db2_rels, rel_b), "after B: release B present");
|
|
process.assert(get_arr(db2, "apps").len == 1, "after B: still ONE app (found, not duplicated)");
|
|
|
|
// Four artifacts (two per release); channel promoted to the latest (B).
|
|
process.assert(get_arr(db2, "artifacts").len == 4, "after B: four artifacts (two per release)");
|
|
db2_chans := get_arr(db2, "channels");
|
|
process.assert(db2_chans.len == 1, "after B: one channel");
|
|
process.assert(get_str(db2_chans.items[0].object, "current_release_id") == rel_b,
|
|
"after B: channel promoted to the latest release");
|
|
|
|
// Each artifact's content-addressed object exists on disk.
|
|
db2_arts := get_arr(db2, "artifacts");
|
|
k := 0;
|
|
while k < db2_arts.len {
|
|
sha := get_str(db2_arts.items[k].object, "sha256");
|
|
process.assert(fs.exists(path_join(STORE, concat("objects/", sha))),
|
|
"after B: object exists at objects/<sha256>");
|
|
k += 1;
|
|
}
|
|
|
|
// Audit accumulated across both publishes (>= 2 publish events).
|
|
process.assert(count_action(get_arr(db2, "audit_events"), "release.publish") == 2,
|
|
"after B: one publish event per release");
|
|
print(" B accumulated; db has 2 releases under 1 app\n");
|
|
|
|
// ── 3. Re-publish the SAME release id (A) → duplicate is rejected ───
|
|
rdup := process.run(publish_cmd(A_PATH));
|
|
process.assert(rdup != null, "spawn duplicate publish failed");
|
|
res_dup := rdup!;
|
|
process.assert(res_dup.exit_code != 0, "re-publishing the same release id must FAIL (duplicate)");
|
|
|
|
db3 := load_db(xx arena);
|
|
process.assert(get_arr(db3, "releases").len == 2, "after duplicate: db UNCHANGED (still two releases)");
|
|
process.assert(get_arr(db3, "apps").len == 1, "after duplicate: still one app");
|
|
print(" duplicate release id rejected; db unchanged\n");
|
|
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
print("publish_persist: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|