// Pinned acceptance for P4.5 — `dist ci publish --server --token`, the // PLAN.md CI contract in remote mode. // // Mints a token in a fresh store, starts the BUILT `build/dist server // run` over it, then drives the BUILT CLI against the live server: // // * happy path: exit 0; stdout is the publish JSON (status published, // release id, per-artifact sha256 equal to an independent digest of // the fixture); the SERVER's store gained objects/, the release, // the channel pointer, and `token:` audit actors (store state // queried from `/dist.db` via the SQLite bindings). // * duplicate version: exit 1, the server's transaction.integrity code // passed through verbatim. // * wrong secret: exit 1, auth.unknown_token. // * https:// target: exit 1, server.tls_unsupported (TLS terminates at // a reverse proxy by design). // * malformed --server: exit 1, server.bad_url. // * unreachable server: exit 1, server.unreachable. // * --local-store together with --server: usage error (exit 64). #import "modules/std.sx"; #import "modules/std/json.sx"; process :: #import "modules/std/process.sx"; fs :: #import "modules/std/fs.sx"; hash :: #import "modules/std/hash.sx"; sq :: #import "vendors/sqlite/sqlite.sx"; STORE :: ".sx-tmp/remote_publish"; MDIR :: ".sx-tmp/remote_publish_m"; PORT :: "18794"; SERVER :: "http://127.0.0.1:18794"; FIXTURE :: "examples/fixtures/acme-1.2.3-android.apk"; MANIFEST :: "{\"app\":\"acme-app\",\"version\":\"2.0.0\",\"channel\":\"beta\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; 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; } parse_body :: (body: string, what: string, scratch: Allocator) -> Object { v, e := parse(body, scratch); if e { process.assert(false, concat("must be valid JSON: ", what)); dummy : Object = .{}; return dummy; } return v.object; } // One-row scalar queries over `/dist.db` ("" = unbound binding). db_open_ro :: () -> sq.Sqlite { c, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY); process.assert(!oe, "dist.db must open as a SQLite database"); c.busy_timeout(2000); return c; } q_text :: (sql: string, p1: string) -> string { c := db_open_ro(); st, pe := c.prepare(sql); process.assert(!pe, concat("prepare must succeed: ", sql)); if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; } rc, se := st.step(); process.assert(!se, concat("step must succeed: ", sql)); process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql)); out := st.column_text(0); st.finalize(); c.close(); return out; } q_int :: (sql: string, p1: string) -> i64 { c := db_open_ro(); st, pe := c.prepare(sql); process.assert(!pe, concat("prepare must succeed: ", sql)); if p1.len > 0 { st.bind_text(1, p1) catch { process.assert(false, "bind 1 failed"); }; } rc, se := st.step(); process.assert(!se, concat("step must succeed: ", sql)); process.assert(rc == sq.SQLITE_ROW, concat("query must return a row: ", sql)); out := st.column_int64(0); st.finalize(); c.close(); return out; } // Run a remote publish with `server` and `token`; returns the run result. remote_publish :: (server: string, token: string) -> ?process.ProcessResult { cmd := concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json")); cmd = concat(cmd, concat(" --server ", server)); cmd = concat(cmd, concat(" --token ", token)); return process.run(concat(cmd, " --json 2>/dev/null")); } // Assert `r` failed (exit 1) with the given JSON error code. assert_fails_with :: (r: ?process.ProcessResult, code: string, what: string, scratch: Allocator) { process.assert(r != null, concat("spawn failed: ", what)); process.assert(r!.exit_code == 1, concat("must exit 1: ", what)); o := parse_body(r!.stdout, what, scratch); process.assert(get_str(o, "status") == "error", concat("json status error: ", what)); process.assert(get_str(get_obj(o, "error"), "code") == code, concat(concat("error code must be ", code), concat(": ", what))); } main :: () -> i32 { gpa := GPA.init(); arena := Arena.init(xx gpa, 1 << 20); defer arena.deinit(); process.run("pkill -f 'dist server run --local-store .sx-tmp/remote_publish' 2>/dev/null"); process.run(concat("rm -rf ", STORE)); process.run(concat("rm -rf ", MDIR)); process.run(concat("mkdir -p ", MDIR)); process.run(concat(concat(concat("printf '%s' '", MANIFEST), "' > "), path_join(MDIR, "m.json"))); // ── token + server ───────────────────────────────────────────────── tc := process.run("build/dist token create --name ci-remote --local-store .sx-tmp/remote_publish --json 2>/dev/null"); process.assert(tc != null and tc!.exit_code == 0, "token create must exit 0"); secret := get_str(get_obj(parse_body(tc!.stdout, "token create", xx arena), "token"), "secret"); sp := process.run(concat(concat(concat("sh -c 'build/dist server run --local-store ", STORE), concat(concat(" --port ", PORT), " >/dev/null 2>&1 & echo $!'")), "")); process.assert(sp != null, "server spawn failed"); pid := sp!.stdout; ready := false; tries := 0; while tries < 50 { c := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", SERVER), "/healthz")); if c != null { if c!.stdout == "200" { ready = true; break; } } process.run("sleep 0.2"); tries += 1; } process.assert(ready, "server must answer /healthz within 10s"); print(" server up\n"); // ── happy path ───────────────────────────────────────────────────── fixture_bytes := fs.read_file(FIXTURE); process.assert(fixture_bytes != null, "fixture must be readable"); d := hash.sha256_hex(fixture_bytes!); expect_sha := string.{ ptr = @d[0], len = 64 }; hp := remote_publish(SERVER, secret); process.assert(hp != null, "remote publish spawn failed"); process.assert(hp!.exit_code == 0, "remote publish must exit 0"); o := parse_body(hp!.stdout, "remote publish stdout", xx arena); process.assert(get_str(o, "status") == "published", "json status published"); process.assert(get_str(get_obj(o, "release"), "id") == "rel-acme-app-2.0.0", "json names the release"); arts := get_arr(o, "artifacts"); process.assert(arts.len == 1, "json lists one artifact"); process.assert(get_str(arts.items[0].object, "sha256") == expect_sha, "artifact sha256 equals an independent digest of the fixture"); process.assert(fs.exists(path_join(STORE, concat("objects/", expect_sha))), "the SERVER's store holds the uploaded object"); process.assert(q_int("SELECT COUNT(*) FROM releases", "") == 1, "server db records the release"); process.assert(q_text("SELECT created_by FROM releases", "") == "token:ci-remote", "release created_by carries the token actor"); process.assert(q_int("SELECT COUNT(*) FROM channels", "") == 1, "server db records the channel"); process.assert(q_text("SELECT current_release_id FROM channels", "") == "rel-acme-app-2.0.0", "beta points at the published release"); print(" remote publish: CI contract round trip ok\n"); // ── failure paths ────────────────────────────────────────────────── assert_fails_with(remote_publish(SERVER, secret), "transaction.integrity", "duplicate version", xx arena); assert_fails_with(remote_publish(SERVER, "dist_wrong"), "auth.unknown_token", "wrong secret", xx arena); assert_fails_with(remote_publish("https://127.0.0.1:18794", secret), "server.tls_unsupported", "https target", xx arena); assert_fails_with(remote_publish("http://127.0.0.1", secret), "server.bad_url", "url without port", xx arena); assert_fails_with(remote_publish("http://127.0.0.1:18790", secret), "server.unreachable", "unreachable server", xx arena); both := process.run(concat(concat("build/dist ci publish --manifest ", path_join(MDIR, "m.json")), concat(concat(" --server ", SERVER), " --token x --local-store .sx-tmp/remote_publish --json 2>/dev/null"))); process.assert(both != null and both!.exit_code == 64, "--server with --local-store is a usage error"); print(" failure paths: server codes passed through, usage gated\n"); // ── teardown ─────────────────────────────────────────────────────── process.run(concat("kill ", pid)); process.run(concat("rm -rf ", STORE)); process.run(concat("rm -rf ", MDIR)); print("remote_publish: ALL CASES PASS\n"); return 0; }