From 27e6c1ab1bfed090994efba35704edf96d4e191a Mon Sep 17 00:00:00 2001 From: Teo Calin Date: Thu, 8 Oct 2026 17:25:53 +0300 Subject: [PATCH] publish: pointer apps keep their store listing; PR text lists the platforms publish-submission.sh looked for the submission's metadata.json by a path relative to the app-template checkout, after cd'ing into the platform clone, so it never found it: every pointer app was published with a bare entry (no display name, vendor, categories, license, source or metadata page). Wallet 0.4.0's catalogue PR (pilotprotocol#529) lost all of them. The path is now absolute. Both publish scripts built the PR's platform list with escaped quotes inside $(...), a jq syntax error; the list is now computed first. TestPublishSubmissionKeepsTheStoreListing runs the real script with gh and git push faked. It needs bash 4+ (mapfile), so it skips on macOS's bash 3.2 and runs on CI. Co-Authored-By: Claude Opus 5.5 --- internal/publish/publish_submission_test.go | 192 ++++++++++++++++++++ scripts/publish-rich-from-r2.sh | 3 +- scripts/publish-submission.sh | 7 +- 3 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 internal/publish/publish_submission_test.go diff --git a/internal/publish/publish_submission_test.go b/internal/publish/publish_submission_test.go new file mode 100644 index 0000000..b8bd17b --- /dev/null +++ b/internal/publish/publish_submission_test.go @@ -0,0 +1,192 @@ +package publish + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// TestPublishSubmissionKeepsTheStoreListing runs scripts/publish-submission.sh +// on a pointer submission, as publish-on-merge does (relative dir, from the +// repo root), with gh and git push faked. The script looked for metadata.json +// by that relative path after cd'ing into the platform clone, never found it, +// and published the bare entry: wallet 0.4.0's catalogue PR lost its display +// name, vendor, categories, license, source and metadata page. The PR text's +// platform list was a jq syntax error. +func TestPublishSubmissionKeepsTheStoreListing(t *testing.T) { + for _, tool := range []string{"bash", "jq", "git", "tar", "shasum"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not available", tool) + } + } + if exec.Command("bash", "-c", "type mapfile").Run() != nil { + t.Skip("bash has no mapfile (bash 3); the script needs bash 4+, as on CI") + } + realGit, _ := exec.LookPath("git") + + const id, version = "io.pilot.metax", "0.2.0" + const publisher = "ed25519:TESTONLY" + root := t.TempDir() + + // The submission, under the app-template checkout the script runs from. + repo := filepath.Join(root, "app-template") + dir := filepath.Join(repo, "submissions", id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + bundle := fakeRichBundle(t, `{"store":{"publisher":"`+publisher+`"}}`) + bundles := map[string]any{} + for _, plat := range []string{"linux/amd64", "darwin/arm64"} { + file := id + "-" + version + "-" + strings.ReplaceAll(plat, "/", "-") + ".tar.gz" + if err := os.WriteFile(filepath.Join(dir, file), bundle, 0o644); err != nil { + t.Fatal(err) + } + bundles[plat] = map[string]string{"file": file, "sha256": sha256Hex(bundle)} + } + writeJSON(t, filepath.Join(dir, "submission.json"), map[string]any{ + "id": id, "version": version, "namespace": "metax", "description": "A pointer app.", + "bundle": id + "-" + version + "-linux-amd64.tar.gz", "bundle_sha256": sha256Hex(bundle), + "bundles": bundles, + }) + writeJSON(t, filepath.Join(dir, "metadata.json"), map[string]any{ + "id": id, "display_name": "MetaX", "vendor": map[string]any{"name": "Pilot Protocol"}, + "license": "AGPL-3.0-or-later", "source_url": "https://github.com/pilot-protocol/metax", + "categories": []string{"payments", "crypto"}, + }) + + // The platform repo the fake gh clones. + platform := filepath.Join(root, "platform") + if err := os.MkdirAll(filepath.Join(platform, "catalogue"), 0o755); err != nil { + t.Fatal(err) + } + writeJSON(t, filepath.Join(platform, "catalogue", "catalogue.json"), map[string]any{"version": 2, "apps": []any{}}) + for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "base"}} { + if out, err := exec.Command(realGit, append([]string{"-C", platform}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + + // Fakes: gh clones the fixture and records the PR; git push copies the + // branch as pushed; pilot-app passes every gate. + out := filepath.Join(root, "out") + bin := filepath.Join(root, "bin") + for _, d := range []string{out, bin} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + writeExec(t, filepath.Join(bin, "gh"), `#!/usr/bin/env bash +case "$1 $2" in + "release view") exit 1 ;; + "release create"|"release upload") exit 0 ;; + "repo clone") exec "$REAL_GIT" clone -q "$FIXTURE_PLATFORM" "$4" ;; + "pr create") printf '%s\n' "$@" > "$OUT/pr-args"; exit 0 ;; +esac +echo "fake gh: unexpected $*" >&2; exit 2 +`) + writeExec(t, filepath.Join(bin, "git"), `#!/usr/bin/env bash +if [ "$1" = push ]; then cp -R . "$OUT/pushed"; exit 0; fi +exec "$REAL_GIT" "$@" +`) + writeExec(t, filepath.Join(bin, "pilot-app"), "#!/bin/sh\nexit 0\n") + + script, err := filepath.Abs(filepath.Join("..", "..", "scripts", "publish-submission.sh")) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash", script, "submissions/"+id) + cmd.Dir = repo + cmd.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "REAL_GIT="+realGit, "FIXTURE_PLATFORM="+platform, "OUT="+out, + "GH_TOKEN=test", "CATALOG_SIGN_KEY=", + "PILOT_APP_BIN="+filepath.Join(bin, "pilot-app"), + ) + logs, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("publish-submission.sh: %v\n%s", err, logs) + } + if strings.Contains(string(logs), "jq: error") { + t.Errorf("the script hit a jq error:\n%s", logs) + } + + pushed := filepath.Join(out, "pushed") + var cat struct { + Apps []map[string]any `json:"apps"` + } + readJSON(t, filepath.Join(pushed, "catalogue", "catalogue.json"), &cat) + if len(cat.Apps) != 1 { + t.Fatalf("catalogue apps = %v, want the one entry", cat.Apps) + } + entry := cat.Apps[0] + md, err := os.ReadFile(filepath.Join(pushed, "catalogue", "apps", id, "metadata.json")) + if err != nil { + t.Fatalf("metadata.json was not published with the entry: %v\n%s", err, logs) + } + want := map[string]any{ + "display_name": "MetaX", + "vendor": "Pilot Protocol", + "license": "AGPL-3.0-or-later", + "source_url": "https://github.com/pilot-protocol/metax", + "categories": []any{"payments", "crypto"}, + "metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/" + id + "/metadata.json", + "metadata_sha256": sha256Hex(md), + "publisher": publisher, + } + for k, w := range want { + if got := entry[k]; !reflect.DeepEqual(got, w) { + t.Errorf("entry.%s = %#v, want %#v", k, got, w) + } + } + if b, _ := entry["bundles"].(map[string]any); len(b) != 2 { + t.Errorf("entry.bundles = %#v, want both platforms", entry["bundles"]) + } + + pr, err := os.ReadFile(filepath.Join(out, "pr-args")) + if err != nil { + t.Fatalf("no PR was opened: %v\n%s", err, logs) + } + if !strings.Contains(string(pr), "Platforms: darwin/arm64, linux/amd64.") { + t.Errorf("PR text does not list the platforms:\n%s", pr) + } +} + +func sha256Hex(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} + +func writeJSON(t *testing.T, path string, v any) { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } +} + +func readJSON(t *testing.T, path string, v any) { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, v); err != nil { + t.Fatalf("%s: %v", path, err) + } +} + +func writeExec(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/scripts/publish-rich-from-r2.sh b/scripts/publish-rich-from-r2.sh index e6a6cac..c91d82f 100755 --- a/scripts/publish-rich-from-r2.sh +++ b/scripts/publish-rich-from-r2.sh @@ -278,7 +278,8 @@ fi git commit -m "catalogue: ${ID} v${VERSION} (rich, from R2)" git push -u origin "$BRANCH" +PLATFORMS="$(jq -r 'keys | join(", ")' <<<"$BUNDLES_JSON")" gh pr create -R "$PLATFORM_REPO" --base main --head "$BRANCH" \ --title "catalogue: ${ID} v${VERSION}" \ - --body "Automated catalogue update for **${ID} v${VERSION}** (rich submission, bundles already on R2). Primary: ${BUNDLE_URL} (sha256 ${SHA}). Platforms: $(jq -r 'keys | join(\", \")' <<<\"$BUNDLES_JSON\"). Publisher: ${PUBLISHER}. catalogue.json re-signed with CATALOG_SIGN_KEY. CI verifies; human approves per APP-PUBLISHING-SPEC §7.2." + --body "Automated catalogue update for **${ID} v${VERSION}** (rich submission, bundles already on R2). Primary: ${BUNDLE_URL} (sha256 ${SHA}). Platforms: ${PLATFORMS}. Publisher: ${PUBLISHER}. catalogue.json re-signed with CATALOG_SIGN_KEY. CI verifies; human approves per APP-PUBLISHING-SPEC §7.2." echo "==> done: $ID v$VERSION ($found platform asset(s))" diff --git a/scripts/publish-submission.sh b/scripts/publish-submission.sh index 5445e69..d8a5af8 100755 --- a/scripts/publish-submission.sh +++ b/scripts/publish-submission.sh @@ -121,7 +121,9 @@ MANIFEST_MEMBER="$( PUBLISHER="$(tar -xzOf "$DIR/$PRIMARY_FILE" "$MANIFEST_MEMBER" | jq -r '.store.publisher // empty')" [ -n "$PUBLISHER" ] || echo "WARNING: no store.publisher in $PRIMARY_FILE manifest — catalogue entry will be UNPINNED (refused on v1.12.3+ hosts)" -MDSRC="$DIR/metadata.json" # the v2 store-page record, emitted by `pilot-app submit` +# The v2 store-page record, emitted by `pilot-app submit`. Absolute: it is read +# after the cd into the platform clone below, where $DIR does not resolve. +MDSRC="$(cd "$DIR" && pwd)/metadata.json" echo "==> updating catalogue (v$CATVER) on $PLATFORM_REPO via PR" WORK="$(mktemp -d)" @@ -225,7 +227,8 @@ fi git commit -m "catalogue: ${ID} v${VERSION}" git push -u origin "$BRANCH" +PLATFORMS="$(jq -r 'keys | join(", ")' <<<"$BUNDLES_JSON")" gh pr create -R "$PLATFORM_REPO" --base main --head "$BRANCH" \ --title "catalogue: ${ID} v${VERSION}" \ - --body "Automated catalogue update for ${ID} v${VERSION}. Primary bundle: ${BUNDLE_URL} (sha256 ${SHA}). Platforms: $(jq -r 'keys | join(\", \")' <<<\"$BUNDLES_JSON\"). CI verifies; human approves per APP-PUBLISHING-SPEC §7.2." + --body "Automated catalogue update for ${ID} v${VERSION}. Primary bundle: ${BUNDLE_URL} (sha256 ${SHA}). Platforms: ${PLATFORMS:-${PRIMARY_FILE} only}. CI verifies; human approves per APP-PUBLISHING-SPEC §7.2." echo "==> done: $ID v$VERSION ($CATVER, ${#FILES[@]} asset(s))"