@entrinsik/vite-plugin-informer 2.7.0-beta.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ci.js CHANGED
@@ -14,7 +14,9 @@
14
14
  * INFORMER_MARKETPLACES: ${{ vars.INFORMER_MARKETPLACES }}
15
15
  *
16
16
  * Everything interesting lives here rather than in YAML, so fixing it is an
17
- * npm release rather than a pull request into every customer's repo.
17
+ * npm release rather than a pull request into every customer's repo. And
18
+ * everything testable lives in src/fan-out.js rather than here — this file
19
+ * is only argv/env handling, console output, and the exit code.
18
20
  *
19
21
  * For a single marketplace with a `lmpub_` key — locally, or on CI that is not
20
22
  * GitHub Actions — use `informer-publish` instead. This command needs the OIDC
@@ -24,8 +26,8 @@
24
26
  import { appendFile } from 'node:fs/promises';
25
27
  import { resolve } from 'node:path';
26
28
  import { publish } from '../src/publish.js';
27
- import { preparePublish, tagVersion } from '../src/publish-payload.js';
28
- import { parseMarketplaces, normalizeMarketplaceUrl } from '../src/marketplaces.js';
29
+ import { preparePublish, tagVersion, isVersionTag, looksLikeVersion } from '../src/publish-payload.js';
30
+ import { resolveTargets, fanOut, summaryTable, failedTargets } from '../src/fan-out.js';
29
31
  import { canMintIdToken, mintIdToken } from '../src/github-oidc.js';
30
32
  import { loadEnv, parseModeArg } from '../src/env.js';
31
33
 
@@ -34,12 +36,12 @@ loadEnv({ mode });
34
36
 
35
37
  const projectRoot = resolve('.');
36
38
 
37
- // --- Targets: explicit flags replace the configured list entirely, so a
38
- // workflow_dispatch can re-publish to a subset without editing config. ---
39
39
  let targets;
40
40
  try {
41
- const explicit = argValues('--marketplace').map(normalizeMarketplaceUrl);
42
- targets = explicit.length ? [...new Set(explicit)] : parseMarketplaces(process.env.INFORMER_MARKETPLACES);
41
+ targets = resolveTargets({
42
+ explicit: argValues('--marketplace'),
43
+ configured: process.env.INFORMER_MARKETPLACES
44
+ });
43
45
  } catch (err) {
44
46
  fail(err.message);
45
47
  }
@@ -60,13 +62,28 @@ if (!canMintIdToken()) {
60
62
  );
61
63
  }
62
64
 
63
- // --- Build the payload ONCE. Every marketplace gets identical bytes. ---
65
+ // The version comes from the tag that triggered the run. On a branch push or
66
+ // a workflow_dispatch against a branch, GITHUB_REF_NAME is the branch name —
67
+ // and without this guard the run would publish a version literally called
68
+ // "main", on the STABLE channel (no prerelease dash), to every marketplace.
69
+ // The predicate is shared with informer-publish (src/publish-payload.js).
70
+ const explicitVersion = argValue('--version');
71
+ if (explicitVersion && !looksLikeVersion(explicitVersion)) {
72
+ fail(`--version "${explicitVersion}" doesn't look like a version (x.y.z, or x.y.z-beta.n).`);
73
+ }
74
+ if (!explicitVersion && !isVersionTag(process.env.GITHUB_REF_TYPE, process.env.GITHUB_REF_NAME)) {
75
+ fail(
76
+ `informer-ci publishes the version named by the tag that triggered the run, ` +
77
+ `but this run's ref is ${process.env.GITHUB_REF_TYPE || 'ref'} "${process.env.GITHUB_REF_NAME || '(unset)'}" — not a version tag.\n` +
78
+ ' Run the workflow against a vX.Y.Z tag, or pass --version <x.y.z>.'
79
+ );
80
+ }
81
+ const refVersion = tagVersion(process.env.GITHUB_REF_NAME);
82
+
83
+ // --- Build the payload once: every marketplace gets the same file set. ---
64
84
  let payload;
65
85
  try {
66
- payload = await preparePublish({
67
- projectRoot,
68
- version: argValue('--version') || tagVersion(process.env.GITHUB_REF_NAME)
69
- });
86
+ payload = await preparePublish({ projectRoot, version: explicitVersion || refVersion });
70
87
  } catch (err) {
71
88
  fail(err.message);
72
89
  }
@@ -79,28 +96,30 @@ console.log(
79
96
  `${files.length} files${screenshots.length ? `, ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}` : ''}`
80
97
  );
81
98
 
82
- // --- Fan out. One target failing must not stop the others: a marketplace that
83
- // is down should not withhold the release from the ones that are up. ---
84
- const results = [];
85
- for (const marketplaceUrl of targets) {
86
- const label = hostOf(marketplaceUrl);
87
- try {
88
- // Audience is the marketplace itself, so a token minted for one
89
- // License Manager is not replayable at another.
90
- const token = await mintIdToken(marketplaceUrl);
91
- const result = await publish({ marketplaceUrl, token, files, icon, screenshots, fields: payload.fields });
99
+ const results = await fanOut({
100
+ targets,
101
+ mint: mintIdToken,
102
+ publishOne: ({ marketplaceUrl, token }) =>
103
+ publish({ marketplaceUrl, token, files, icon, screenshots, fields: payload.fields }),
104
+ onSuccess: (label, result) => {
92
105
  for (const w of result.warnings || []) console.warn(` warning (${label}): ${w}`);
93
106
  console.log(` ✓ ${label} — published ${slug} v${version} (${result.channel || channel})`);
94
- results.push({ label, ok: true });
95
- } catch (err) {
96
- console.error(` ✗ ${label} — ${err.message}`);
97
- results.push({ label, ok: false, error: err.message });
107
+ },
108
+ onFailure: (label, message) => {
109
+ console.error(` ✗ ${label} — ${message}`);
98
110
  }
99
- }
111
+ });
100
112
 
101
113
  await writeSummary(results);
102
114
 
103
- const failed = results.filter(r => !r.ok);
115
+ // A row that published but whose reporting threw: the ✓ line above is missing
116
+ // for it, so say what happened rather than leaving a silent gap. Not a failure
117
+ // — the release is live, and exiting 1 here invites a re-run that 409s.
118
+ for (const r of results.filter(r => r.reportError)) {
119
+ console.warn(` warning (${r.label}): published, but reporting failed — ${r.reportError}`);
120
+ }
121
+
122
+ const failed = failedTargets(results);
104
123
  if (failed.length) {
105
124
  console.error(`\n${failed.length} of ${results.length} marketplaces failed.`);
106
125
  process.exit(1);
@@ -122,32 +141,14 @@ function argValues(flag) {
122
141
  return out;
123
142
  }
124
143
 
125
- function hostOf(url) {
126
- try {
127
- return new URL(url).host;
128
- } catch {
129
- return url;
130
- }
131
- }
132
-
133
- /**
134
- * A per-target line in the job summary. Without this the Actions UI shows one
135
- * opaque step for what is really N independent publishes — the observability
136
- * the old matrix-of-jobs gave up when fan-out moved in here.
137
- */
138
144
  async function writeSummary(rows) {
139
145
  const path = process.env.GITHUB_STEP_SUMMARY;
140
146
  if (!path || !rows.length) return;
141
- const body = [
142
- `### ${name} v${version} (${channel})`,
143
- '',
144
- '| Marketplace | Result |',
145
- '| --- | --- |',
146
- ...rows.map(r => `| ${r.label} | ${r.ok ? 'published' : `failed — ${r.error.replace(/\|/g, '\\|')}`} |`),
147
- ''
148
- ].join('\n');
149
- await appendFile(path, body).catch(() => {
150
- // A summary is a nicety; never fail a good publish over it.
147
+ const body = summaryTable({ title: `${name} v${version} (${channel})`, rows });
148
+ await appendFile(path, body).catch(err => {
149
+ // A summary is a nicety; never fail a good publish over it — but say
150
+ // why it's missing rather than leaving its absence a mystery.
151
+ console.warn(` warning: could not write the job summary: ${err.message}`);
151
152
  });
152
153
  }
153
154
 
package/bin/publish.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { resolve } from 'node:path';
12
12
  import { publish } from '../src/publish.js';
13
- import { preparePublish, tagVersion } from '../src/publish-payload.js';
13
+ import { preparePublish, tagVersion, isVersionTag } from '../src/publish-payload.js';
14
14
  import { loadEnv, parseModeArg } from '../src/env.js';
15
15
 
16
16
  const mode = parseModeArg(process.argv);
@@ -39,12 +39,19 @@ if (process.env.INFORMER_MARKETPLACES && process.env.GITHUB_ACTIONS) {
39
39
 
40
40
  const projectRoot = resolve('.');
41
41
 
42
+ // Prefer an explicit --version, then the git tag (CI: GITHUB_REF_NAME=vX.Y.Z),
43
+ // then package.json. A branch ref is NOT a version tag: without the gate, a
44
+ // branch push on Actions published a version literally called "main" — so a
45
+ // non-tag ref falls through to package.json, and says so.
46
+ const refIsTag = isVersionTag(process.env.GITHUB_REF_TYPE, process.env.GITHUB_REF_NAME);
47
+ if (process.env.GITHUB_REF_NAME && !refIsTag && !argValue('--version')) {
48
+ console.warn(`Note: ref "${process.env.GITHUB_REF_NAME}" is not a version tag — using the package.json version.`);
49
+ }
50
+
42
51
  let payload;
43
52
  try {
44
53
  payload = await preparePublish({
45
- // Prefer an explicit --version, then the git tag (CI: GITHUB_REF_NAME=vX.Y.Z),
46
- // then package.json.
47
- version: argValue('--version') || tagVersion(process.env.GITHUB_REF_NAME),
54
+ version: argValue('--version') || (refIsTag ? tagVersion(process.env.GITHUB_REF_NAME) : undefined),
48
55
  projectRoot
49
56
  });
50
57
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.7.0-beta.0",
3
+ "version": "2.7.0",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
5
  "scripts": {
6
6
  "test": "node --test"
package/src/fan-out.js ADDED
@@ -0,0 +1,129 @@
1
+ import { normalizeMarketplaceUrl, parseMarketplaces } from './marketplaces.js';
2
+
3
+ /*
4
+ * The informer-ci fan-out, extracted from the bin so it can be tested. A bin
5
+ * script with top-level await and process.exit is unreachable to a test
6
+ * runner, and the contracts here are exactly the ones a refactor would break
7
+ * silently — see fanOut's doc comment.
8
+ */
9
+
10
+ /**
11
+ * Which marketplaces this run publishes to. Explicit flags replace the
12
+ * configured list entirely — a workflow_dispatch re-publishing to one target
13
+ * must not fan back out to everything — and both forms are normalized and
14
+ * deduped the same way.
15
+ */
16
+ export function resolveTargets({ explicit = [], configured } = {}) {
17
+ const flags = explicit.map(normalizeMarketplaceUrl);
18
+ return flags.length ? [...new Set(flags)] : parseMarketplaces(configured);
19
+ }
20
+
21
+ /** Display label for a target: the host, or the raw value when it won't parse. */
22
+ export function targetLabel(url) {
23
+ try {
24
+ return new URL(url).host;
25
+ } catch {
26
+ return url;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Publish one prepared payload to every target. Returns per-target rows:
32
+ * `{ label, ok }` or `{ label, ok: false, error }`.
33
+ *
34
+ * Three load-bearing contracts:
35
+ *
36
+ * - One target failing must not stop the others — a marketplace that is down
37
+ * should not withhold the release from the ones that are up. Any failed row
38
+ * still fails the run afterwards (the bin exits 1 on any `ok: false`).
39
+ * - `mint` runs INSIDE the loop, once per target, with that target's URL as
40
+ * the token audience — a token minted for one License Manager must not be
41
+ * replayable at another. Hoisting the mint out as loop-invariant would
42
+ * break that property without failing a single publish against a lax
43
+ * verifier, which is why the test suite pins it.
44
+ * - Reporting cannot unland a publish. The release exists the moment
45
+ * `publishOne` resolves; everything after that is narration. `onSuccess`
46
+ * therefore gets its own try/catch, and its failure is recorded as
47
+ * `reportError` on a row that stays `ok: true`. Letting it reach the outer
48
+ * catch would print `✗ <host>` and exit 1 for a version the marketplace had
49
+ * already stored — and the re-run would 409 "version already exists",
50
+ * sending someone off to debug a publish that succeeded.
51
+ *
52
+ * @param {string[]} targets - normalized marketplace URLs
53
+ * @param {(url: string) => Promise<string>} mint - audience-scoped token minter
54
+ * @param {(opts: { marketplaceUrl: string, token: string }) => Promise<Object>} publishOne
55
+ * @param {(label: string, result: Object) => void|Promise<void>} [onSuccess]
56
+ * @param {(label: string, message: string) => void} [onFailure]
57
+ */
58
+ export async function fanOut({ targets, mint, publishOne, onSuccess = () => {}, onFailure = () => {} }) {
59
+ const results = [];
60
+ for (const marketplaceUrl of targets) {
61
+ const label = targetLabel(marketplaceUrl);
62
+ try {
63
+ const token = await mint(marketplaceUrl);
64
+ const result = await publishOne({ marketplaceUrl, token });
65
+ // Past this line the publish has landed, so no path may produce an
66
+ // `ok: false` row for this target. Awaited so an async reporter's
67
+ // rejection lands here too rather than escaping unhandled.
68
+ let reportError;
69
+ try {
70
+ await onSuccess(label, result);
71
+ } catch (err) {
72
+ reportError = String((err && err.message) ?? err);
73
+ }
74
+ results.push(reportError ? { label, ok: true, reportError } : { label, ok: true });
75
+ } catch (err) {
76
+ // String() rather than err.message: a non-Error thrown per-target
77
+ // must land in the results row, not crash the summary writer
78
+ // after the fact and mask the real publish failure.
79
+ const message = String((err && err.message) ?? err);
80
+ onFailure(label, message);
81
+ results.push({ label, ok: false, error: message });
82
+ }
83
+ }
84
+ return results;
85
+ }
86
+
87
+ /**
88
+ * The GitHub job summary table. Without it the Actions UI shows one opaque
89
+ * step for what is really N independent publishes.
90
+ *
91
+ * Failure text is flattened to one line and truncated: PublishError messages
92
+ * embed multi-line JSON response bodies, and a newline inside a Markdown
93
+ * table cell breaks the row on exactly the runs where the summary matters.
94
+ */
95
+ export function summaryTable({ title, rows }) {
96
+ const cell = s => {
97
+ const flat = String(s).replace(/\s+/g, ' ').trim();
98
+ // The ellipsis marks the cut — a message truncated without one reads
99
+ // as if it were complete.
100
+ return (flat.length > 300 ? `${flat.slice(0, 300)}…` : flat).replace(/\|/g, '\\|');
101
+ };
102
+ return [
103
+ `### ${title}`,
104
+ '',
105
+ '| Marketplace | Result |',
106
+ '| --- | --- |',
107
+ ...rows.map(r => `| ${r.label} | ${resultCell(r, cell)} |`),
108
+ ''
109
+ ].join('\n');
110
+ }
111
+
112
+ /** One row's Result cell. A reporting failure annotates a publish, never negates it. */
113
+ function resultCell(row, cell) {
114
+ if (!row.ok) return `failed — ${cell(row.error)}`;
115
+ return row.reportError ? `published (reporting failed — ${cell(row.reportError)})` : 'published';
116
+ }
117
+
118
+ /**
119
+ * Whether the run failed, i.e. whether the bin exits 1. This is the whole CI
120
+ * contract — a green run means every target has the version — so it lives here
121
+ * as a predicate the suite can pin rather than as an inline `.filter` in a bin
122
+ * that no test runner can reach.
123
+ *
124
+ * A row that published but could not be reported is NOT a failure: the release
125
+ * is live, and exiting 1 would invite a re-run that 409s.
126
+ */
127
+ export function failedTargets(rows) {
128
+ return rows.filter(r => !r.ok);
129
+ }
@@ -53,9 +53,35 @@ export function normalizeMarketplaceUrl(value) {
53
53
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
54
54
  throw new Error(`Marketplace URL must be http(s): ${value}`);
55
55
  }
56
+ // This URL is both the OIDC audience and the endpoint that receives
57
+ // `Authorization: Bearer <jwt>`. The audience binding stops a token minted
58
+ // for one marketplace being replayed at another, but does nothing about an
59
+ // on-path observer at the target itself — over http: a vendor-publishing
60
+ // token crosses the wire in cleartext. Loopback is exempt so an on-prem or
61
+ // local LM can still be developed against.
62
+ if (url.protocol === 'http:' && !isLoopback(url.hostname)) {
63
+ throw new Error(`Marketplace URL must be https (a publish token is sent to it): ${value}`);
64
+ }
56
65
  url.hostname = url.hostname.toLowerCase();
57
- url.pathname = url.pathname.replace(/\/+$/, '');
58
66
  url.hash = '';
59
67
  url.search = '';
60
- return url.toString();
68
+ // Strip trailing slashes on the serialized string, NOT via the pathname
69
+ // setter — WHATWG re-normalizes an empty path back to '/', so a root-path
70
+ // URL like https://api.informer.cloud would keep a slash that the publish
71
+ // URL strips. This string is also the OIDC audience, and the License
72
+ // Manager compares `aud` as an exact string: one character of drift fails
73
+ // verification on any LM that enforces its audience.
74
+ return url.toString().replace(/\/+$/, '');
75
+ }
76
+
77
+ /**
78
+ * Whether a hostname is this machine. WHATWG keeps IPv6 hosts bracketed
79
+ * (`[::1]`), and 127.0.0.0/8 is loopback in full, not just 127.0.0.1.
80
+ */
81
+ function isLoopback(hostname) {
82
+ const host = String(hostname).toLowerCase();
83
+ return host === 'localhost'
84
+ || host === '[::1]'
85
+ || host === '::1'
86
+ || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
61
87
  }
@@ -8,9 +8,11 @@ import { buildLocalOpenApi } from './openapi-local.js';
8
8
  * Everything a publish needs, assembled once.
9
9
  *
10
10
  * Split out of bin/publish.js so `informer-ci` can build the payload a single
11
- * time and hand the SAME files to several marketplaces — a version has to be
12
- * byte-identical everywhere it lands, and re-assembling per target would only
13
- * create opportunities for it not to be.
11
+ * time and hand the SAME file set to several marketplaces — a version's
12
+ * contents have to be identical everywhere it lands, and re-assembling per
13
+ * target would only create opportunities for them not to be. (The upload
14
+ * archive itself is still built per target inside publish(), so archive
15
+ * bytes — tar mtimes — differ; the files inside do not.)
14
16
  *
15
17
  * Throws on anything that should stop a publish; the bins print and exit.
16
18
  */
@@ -44,14 +46,32 @@ export async function preparePublish({ projectRoot = resolve('.'), version: vers
44
46
  const { spec: apiSpec, warnings } = await buildLocalOpenApi({ projectRoot, name, slug, version });
45
47
  if (apiSpec) files.push({ rel: 'openapi.json', content: JSON.stringify(apiSpec, null, 2) });
46
48
 
49
+ // An explicitly configured icon or screenshots dir that doesn't resolve
50
+ // must not degrade silently: the publish "succeeds", the listing ships
51
+ // visually wrong to every marketplace in a fan-out, and a human notices
52
+ // weeks later. Speculative defaults may miss quietly; config may not.
47
53
  const icon = await resolveIcon(projectRoot, inf.icon);
54
+ if (inf.icon && (!icon || icon.abs !== resolve(projectRoot, inf.icon))) {
55
+ warnings.push(
56
+ `informer.icon "${inf.icon}" was not found — ` +
57
+ (icon ? `falling back to ${icon.rel}.` : 'publishing without a listing icon.')
58
+ );
59
+ }
48
60
  const screenshots = await resolveScreenshots(projectRoot, inf.screenshots);
61
+ if (inf.screenshots && !screenshots.length) {
62
+ warnings.push(`informer.screenshots "${inf.screenshots}" has no readable screenshots — the listing will have none.`);
63
+ }
49
64
 
50
65
  // Provenance (GitHub Actions). All optional — best-effort audit trail.
51
66
  const sourceRepo = process.env.GITHUB_REPOSITORY || undefined;
52
67
  const sourceCommit = process.env.GITHUB_SHA || undefined;
53
- const ciRun = process.env.GITHUB_RUN_ID
54
- ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
68
+ // Both halves must be present: the URL interpolates the repo as well as the
69
+ // run id, so guarding only the run id records
70
+ // `https://github.com/undefined/actions/runs/123` as the audit trail — a
71
+ // link that resolves nowhere, in the one field whose entire job is to be
72
+ // followable later.
73
+ const ciRun = sourceRepo && process.env.GITHUB_RUN_ID
74
+ ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${sourceRepo}/actions/runs/${process.env.GITHUB_RUN_ID}`
55
75
  : undefined;
56
76
 
57
77
  return {
@@ -82,11 +102,36 @@ export async function preparePublish({ projectRoot = resolve('.'), version: vers
82
102
  };
83
103
  }
84
104
 
85
- /** `vX.Y.Z` (GITHUB_REF_NAME) → `X.Y.Z`. */
105
+ /**
106
+ * `vX.Y.Z` (GITHUB_REF_NAME) → `X.Y.Z`.
107
+ *
108
+ * A non-tag ref passes through unchanged — `main` stays `main` — so a caller
109
+ * feeding this from CI must gate on isVersionTag() before publishing with the
110
+ * result (informer-ci refuses outright; informer-publish falls back to the
111
+ * package.json version).
112
+ */
86
113
  export function tagVersion(ref) {
87
114
  return ref ? ref.replace(/^v/, '') : undefined;
88
115
  }
89
116
 
117
+ /** Semver-ish: x.y.z with optional prerelease/build. What the LM will accept. */
118
+ export function looksLikeVersion(value) {
119
+ return typeof value === 'string' && /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(value);
120
+ }
121
+
122
+ /**
123
+ * Does this CI ref name a publishable version? Only a tag-shaped ref counts:
124
+ * GITHUB_REF_TYPE must not say otherwise, and the version the ref yields must
125
+ * look like a version. Without this gate, a workflow_dispatch against a
126
+ * branch has GITHUB_REF_NAME=main — publishing a version literally called
127
+ * "main", on the STABLE channel (no prerelease dash). Both bins share this
128
+ * predicate so they can't drift.
129
+ */
130
+ export function isVersionTag(refType, refName) {
131
+ if (refType && refType !== 'tag') return false;
132
+ return looksLikeVersion(tagVersion(refName));
133
+ }
134
+
90
135
  export function defaultSlug(value) {
91
136
  if (!value) return undefined;
92
137
  const base = value.startsWith('@') && value.includes('/') ? value.split('/')[1] : value;
@@ -99,7 +144,10 @@ async function resolveIcon(root, configured) {
99
144
  const abs = resolve(root, rel);
100
145
  try {
101
146
  await access(abs);
102
- return { abs, filename: rel.split(/[/\\]/).pop() };
147
+ // `rel` rides along for warnings: every default candidate shares
148
+ // the basename favicon.svg, so filename alone can't say WHICH
149
+ // file was picked.
150
+ return { abs, rel, filename: rel.split(/[/\\]/).pop() };
103
151
  } catch {
104
152
  // try next candidate
105
153
  }
package/src/publish.js CHANGED
@@ -93,6 +93,26 @@ export async function publish({ marketplaceUrl, token, files, fields, icon, scre
93
93
  body: form
94
94
  });
95
95
 
96
+ return await readPublishResponse(res, url);
97
+ }
98
+
99
+ /**
100
+ * Turn the `/packs/publish` response into a result object, or throw
101
+ * PublishError. Exported so the parse contract is testable without staging an
102
+ * archive and stubbing fetch.
103
+ *
104
+ * Both directions tolerate a body they can't read, and for the same reason:
105
+ * whether the version landed is decided by the status, never by our ability to
106
+ * parse what came back. An unguarded `res.json()` here meant a 204 or an empty
107
+ * 2xx threw SyntaxError *after* the marketplace had stored the version, which
108
+ * the caller could only read as a publish failure — CI exits 1, the re-run
109
+ * 409s "version already exists", and someone debugs a publish that succeeded.
110
+ *
111
+ * `{}` (rather than null/undefined) keeps the shape callers destructure —
112
+ * `result.warnings`, `result.channel` — which is also why a literal `null`
113
+ * body, valid JSON that parses to null, is normalized away.
114
+ */
115
+ export async function readPublishResponse(res, url) {
96
116
  if (!res.ok) {
97
117
  let body = '';
98
118
  try {
@@ -103,5 +123,9 @@ export async function publish({ marketplaceUrl, token, files, fields, icon, scre
103
123
  throw new PublishError(res.status, body, url);
104
124
  }
105
125
 
106
- return await res.json();
126
+ try {
127
+ return (await res.json()) ?? {};
128
+ } catch {
129
+ return {};
130
+ }
107
131
  }