Files
distribution/tests/remote_publish.sx
agra 0a6fa65c58 P4.5: remote dist ci publish --server --token
The PLAN.md CI contract in remote mode. client/http_client.sx is the
temporary in-repo HTTP client shim over std.socket (connect(2) via libc
FFI), targeting http://<ipv4-or-localhost>:<port> — DNS and TLS are
loud v0 boundaries (https:// is refused; the deployment story
terminates TLS at a reverse proxy).

publish/remote.sx drives the same manifest front half as local publish,
uploads each artifact through POST /api/upload (verifying a
manifest-declared sha256 against the server-computed digest), then
publishes via POST /api/apps/<slug>/releases. The 200 response is
parsed back into a PublishOutcome so --json/human rendering reuse the
local paths; non-2xx responses pass the server's JSON error code
through report_failure. ci publish takes exactly one of --local-store
or --server (+--token); anything else is a usage error.

make test 18/18 (new: remote_publish.sx pinned acceptance against a
live distd).
2026-06-12 11:28:34 +03:00

161 lines
8.2 KiB
Plaintext

// 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/<sha>, the release,
// the channel pointer, and `token:<name>` audit actors.
// * 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";
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;
}
// 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");
dbo := parse_body(fs.read_file(path_join(STORE, "db.json"))!, "db.json", xx arena);
rels := get_arr(dbo, "releases");
process.assert(rels.len == 1, "server db records the release");
process.assert(get_str(rels.items[0].object, "created_by") == "token:ci-remote",
"release created_by carries the token actor");
chans := get_arr(dbo, "channels");
process.assert(chans.len == 1, "server db records the channel");
process.assert(get_str(chans.items[0].object, "current_release_id") == "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;
}