From 1b274512ae35f086dc42c4863fc978464268dc95 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 24 Sep 2026 12:28:50 +0300 Subject: [PATCH] fix(publish): rich republish keeps the listing's categories, license and source_url publish-rich-from-r2.sh built the catalogue entry from top-level .categories, .license, .source_url and .display_name. Every rich submission in the repo (18 of them) keeps those under .listing, so the script fell back to categories: [], license: "" and the app-template submission path for source_url. A republish of any rich app would have replaced its live entry's fields with those. Dry run for io.pilot.plainweb 1.0.1 against its candidate bundles: before: "categories": [], "license": "", "source_url": ".../app-template/tree/main/submissions/io.pilot.plainweb" after: "categories": ["web","content","markdown"], "license": "Proprietary", "source_url": "https://github.com/pilot-protocol/plainweb" The script now falls back to .listing.; a top-level field, where present, still wins. The same values feed the synthesised metadata.json for an app that has none yet. TestPublishRichFromR2ReadsListingFields runs the script in DRY_RUN against a fake R2 and checks the entry. It fails on main (all four fields) and passes with this change, on macOS and in golang:1.25.13-bookworm (linux/arm64). Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/publish/rich_from_r2_listing_test.go | 161 ++++++++++++++++++ scripts/publish-rich-from-r2.sh | 12 +- 2 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 internal/publish/rich_from_r2_listing_test.go diff --git a/internal/publish/rich_from_r2_listing_test.go b/internal/publish/rich_from_r2_listing_test.go new file mode 100644 index 0000000..99529fe --- /dev/null +++ b/internal/publish/rich_from_r2_listing_test.go @@ -0,0 +1,161 @@ +package publish + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// TestPublishRichFromR2ReadsListingFields runs scripts/publish-rich-from-r2.sh +// in DRY_RUN mode against a fake R2 and checks the catalogue entry it would +// publish. Rich submissions keep their store fields under .listing; the script +// read only the top level, so republishing any rich app (plainweb 1.0.1, for +// one) replaced the live entry's categories with [], its license with "" and +// its source_url with the app-template submission path. +func TestPublishRichFromR2ReadsListingFields(t *testing.T) { + for _, tool := range []string{"bash", "jq", "curl", "shasum", "tar"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not available", tool) + } + } + + const id, version = "io.pilot.listingx", "0.2.0" + listing := map[string]any{ + "display_name": "ListingX", + "license": "Proprietary", + "source_url": "https://github.com/pilot-protocol/listingx", + "categories": []string{"web", "content"}, + } + + cases := []struct { + name string + topLevel map[string]any // extra top-level submission fields + want map[string]any // expected entry fields + }{ + { + name: "listing only", + want: map[string]any{ + "display_name": "ListingX", + "license": "Proprietary", + "source_url": "https://github.com/pilot-protocol/listingx", + "categories": []any{"web", "content"}, + }, + }, + { + name: "top level wins", + topLevel: map[string]any{"license": "MIT", "categories": []string{"search"}}, + want: map[string]any{ + "display_name": "ListingX", + "license": "MIT", + "source_url": "https://github.com/pilot-protocol/listingx", + "categories": []any{"search"}, + }, + }, + } + + bundle := fakeRichBundle(t, `{"store":{"publisher":"ed25519:TESTONLY"}}`) + r2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/bundles/"+id+"/"+version+"/"+id+"-"+version+"-linux-amd64.tar.gz" { + _, _ = w.Write(bundle) + return + } + http.NotFound(w, r) + })) + defer r2.Close() + + script, err := filepath.Abs(filepath.Join("..", "..", "scripts", "publish-rich-from-r2.sh")) + if err != nil { + t.Fatal(err) + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sub := map[string]any{ + "id": id, + "version": version, + "description": "A rich app with its store fields under listing.", + "backend": map[string]any{"type": "http", "base_url": "https://listingx.invalid"}, + "methods": []any{map[string]any{"name": "listingx.get", "description": "get"}}, + "vendor": map[string]any{"name": "Pilot Protocol"}, + "listing": listing, + } + for k, v := range tc.topLevel { + sub[k] = v + } + dir := filepath.Join(t.TempDir(), id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(sub) + if err := os.WriteFile(filepath.Join(dir, "submission.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("bash", script, dir) + cmd.Env = append(os.Environ(), + "DRY_RUN=1", + "R2_PUBLIC_BASE="+r2.URL, + "PILOT_APP_BIN="+filepath.Join(t.TempDir(), "no-pilot-app"), // skip the verify gate + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("publish-rich-from-r2.sh: %v\n%s", err, out) + } + entry := dryRunEntry(t, string(out)) + for k, want := range tc.want { + if got := entry[k]; !reflect.DeepEqual(got, want) { + t.Errorf("entry.%s = %#v, want %#v", k, got, want) + } + } + }) + } +} + +// dryRunEntry extracts the catalogue entry JSON the script prints in DRY_RUN. +func dryRunEntry(t *testing.T, out string) map[string]any { + t.Helper() + const start, end = "── DRY RUN: catalogue entry", "── DRY RUN: metadata.json" + i := strings.Index(out, start) + j := strings.Index(out, end) + if i < 0 || j < i { + t.Fatalf("no DRY RUN entry in output:\n%s", out) + } + body := out[i:j] + body = body[strings.Index(body, "\n")+1:] + var entry map[string]any + if err := json.Unmarshal([]byte(body), &entry); err != nil { + t.Fatalf("parse entry: %v\n%s", err, body) + } + return entry +} + +// fakeRichBundle is a tarball holding only ./manifest.json, which is all the +// script reads from a bundle (the publisher pin) once the verify gate is off. +func fakeRichBundle(t *testing.T, manifest string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: "./manifest.json", Mode: 0o644, Size: int64(len(manifest))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(manifest)); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} diff --git a/scripts/publish-rich-from-r2.sh b/scripts/publish-rich-from-r2.sh index 61c71ae..e6a6cac 100755 --- a/scripts/publish-rich-from-r2.sh +++ b/scripts/publish-rich-from-r2.sh @@ -121,11 +121,15 @@ BUNDLES_JSON="$( # NB: vendor.name is the PUBLISHER ("Pilot Protocol" for wrapped tools), not the # app, so it is a poor display name. Prefer an explicit display_name, else derive # from the id's last dotted segment (title-cased) — predictable and app-specific. -DISPLAY="$(jq -r '.display_name // (.id | split(".") | last | (.[0:1]|ascii_upcase) + .[1:])' "$META")" +# Rich submissions carry these store fields under .listing (the pilot-app +# submission shape); a top-level field, where present, still wins. Reading only +# the top level turned every rich republish's catalogue entry into +# categories: [], license: "" and an app-template source_url. +DISPLAY="$(jq -r '.display_name // .listing.display_name // (.id | split(".") | last | (.[0:1]|ascii_upcase) + .[1:])' "$META")" VENDOR="$(jq -r '.vendor.name // ""' "$META")" -LICENSE="$(jq -r '.license // ""' "$META")" -SOURCE="$(jq -r '.source_url // "https://github.com/pilot-protocol/app-template/tree/main/submissions/'"$ID"'"' "$META")" -CATEGORIES_JSON="$(jq -c '.categories // []' "$META")" +LICENSE="$(jq -r '.license // .listing.license // ""' "$META")" +SOURCE="$(jq -r '.source_url // .listing.source_url // "https://github.com/pilot-protocol/app-template/tree/main/submissions/'"$ID"'"' "$META")" +CATEGORIES_JSON="$(jq -c '.categories // .listing.categories // []' "$META")" # NB: jq's object-construction value grammar is stricter than a full pipe, and # some jq builds reject `key: (A) + {..}` / `key: A // {..}` inline. Precompute