@entrinsik/vite-plugin-informer 2.6.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 +158 -0
- package/bin/publish.js +34 -109
- package/package.json +2 -1
- package/src/assemble.js +11 -3
- package/src/fan-out.js +129 -0
- package/src/github-oidc.js +58 -0
- package/src/marketplaces.js +87 -0
- package/src/publish-payload.js +173 -0
- package/src/publish.js +25 -1
package/bin/ci.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* informer-ci — publish one build to every configured marketplace, using
|
|
5
|
+
* GitHub Actions OIDC instead of a stored publish key.
|
|
6
|
+
*
|
|
7
|
+
* The workflow step is deliberately inert:
|
|
8
|
+
*
|
|
9
|
+
* permissions:
|
|
10
|
+
* id-token: write
|
|
11
|
+
* ...
|
|
12
|
+
* - run: npx informer-ci
|
|
13
|
+
* env:
|
|
14
|
+
* INFORMER_MARKETPLACES: ${{ vars.INFORMER_MARKETPLACES }}
|
|
15
|
+
*
|
|
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. 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.
|
|
20
|
+
*
|
|
21
|
+
* For a single marketplace with a `lmpub_` key — locally, or on CI that is not
|
|
22
|
+
* GitHub Actions — use `informer-publish` instead. This command needs the OIDC
|
|
23
|
+
* endpoint only GitHub's runner provides.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { appendFile } from 'node:fs/promises';
|
|
27
|
+
import { resolve } from 'node:path';
|
|
28
|
+
import { publish } from '../src/publish.js';
|
|
29
|
+
import { preparePublish, tagVersion, isVersionTag, looksLikeVersion } from '../src/publish-payload.js';
|
|
30
|
+
import { resolveTargets, fanOut, summaryTable, failedTargets } from '../src/fan-out.js';
|
|
31
|
+
import { canMintIdToken, mintIdToken } from '../src/github-oidc.js';
|
|
32
|
+
import { loadEnv, parseModeArg } from '../src/env.js';
|
|
33
|
+
|
|
34
|
+
const mode = parseModeArg(process.argv);
|
|
35
|
+
loadEnv({ mode });
|
|
36
|
+
|
|
37
|
+
const projectRoot = resolve('.');
|
|
38
|
+
|
|
39
|
+
let targets;
|
|
40
|
+
try {
|
|
41
|
+
targets = resolveTargets({
|
|
42
|
+
explicit: argValues('--marketplace'),
|
|
43
|
+
configured: process.env.INFORMER_MARKETPLACES
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
fail(err.message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!targets.length) {
|
|
50
|
+
fail(
|
|
51
|
+
'No marketplaces configured. Set the INFORMER_MARKETPLACES repo variable to one cloud-api URL per line, e.g.\n' +
|
|
52
|
+
' https://lm.example.com/cloud-api\n' +
|
|
53
|
+
' or pass --marketplace <url>.'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!canMintIdToken()) {
|
|
58
|
+
fail(
|
|
59
|
+
'informer-ci needs GitHub Actions OIDC, which is not available here.\n' +
|
|
60
|
+
' In a workflow, give the job `permissions: id-token: write`.\n' +
|
|
61
|
+
' Outside GitHub Actions, publish with a key instead: informer-publish.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
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. ---
|
|
84
|
+
let payload;
|
|
85
|
+
try {
|
|
86
|
+
payload = await preparePublish({ projectRoot, version: explicitVersion || refVersion });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
fail(err.message);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const w of payload.warnings || []) console.warn(` warning: ${w}`);
|
|
92
|
+
|
|
93
|
+
const { name, slug, version, channel, files, icon, screenshots } = payload;
|
|
94
|
+
console.log(
|
|
95
|
+
`Publishing ${name} v${version} (${channel}) to ${targets.length} marketplace${targets.length === 1 ? '' : 's'} — ` +
|
|
96
|
+
`${files.length} files${screenshots.length ? `, ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}` : ''}`
|
|
97
|
+
);
|
|
98
|
+
|
|
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) => {
|
|
105
|
+
for (const w of result.warnings || []) console.warn(` warning (${label}): ${w}`);
|
|
106
|
+
console.log(` ✓ ${label} — published ${slug} v${version} (${result.channel || channel})`);
|
|
107
|
+
},
|
|
108
|
+
onFailure: (label, message) => {
|
|
109
|
+
console.error(` ✗ ${label} — ${message}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await writeSummary(results);
|
|
114
|
+
|
|
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);
|
|
123
|
+
if (failed.length) {
|
|
124
|
+
console.error(`\n${failed.length} of ${results.length} marketplaces failed.`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- helpers ---
|
|
129
|
+
|
|
130
|
+
function argValue(flag) {
|
|
131
|
+
const i = process.argv.indexOf(flag);
|
|
132
|
+
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Every occurrence of a repeatable flag, e.g. --marketplace a --marketplace b. */
|
|
136
|
+
function argValues(flag) {
|
|
137
|
+
const out = [];
|
|
138
|
+
process.argv.forEach((arg, i) => {
|
|
139
|
+
if (arg === flag && process.argv[i + 1]) out.push(process.argv[i + 1]);
|
|
140
|
+
});
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function writeSummary(rows) {
|
|
145
|
+
const path = process.env.GITHUB_STEP_SUMMARY;
|
|
146
|
+
if (!path || !rows.length) return;
|
|
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}`);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function fail(message) {
|
|
156
|
+
console.error(message);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
package/bin/publish.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
/**
|
|
4
|
+
* informer-publish — publish this app to ONE marketplace using a vendor
|
|
5
|
+
* publish key. Works anywhere: locally, GitHub Actions, or any other CI.
|
|
6
|
+
*
|
|
7
|
+
* For fanning one build out to several marketplaces from GitHub Actions
|
|
8
|
+
* without storing a key, see `informer-ci` (OIDC trusted publishing).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolve } from 'node:path';
|
|
8
12
|
import { publish } from '../src/publish.js';
|
|
13
|
+
import { preparePublish, tagVersion, isVersionTag } from '../src/publish-payload.js';
|
|
9
14
|
import { loadEnv, parseModeArg } from '../src/env.js';
|
|
10
15
|
|
|
11
16
|
const mode = parseModeArg(process.argv);
|
|
@@ -20,87 +25,48 @@ if (!marketplaceUrl || !token) {
|
|
|
20
25
|
console.error('Missing required environment variables.');
|
|
21
26
|
console.error(' INFORMER_MARKETPLACE_URL=https://<license-manager-host>/cloud-api');
|
|
22
27
|
console.error(' INFORMER_PUBLISH_TOKEN=lmpub_<vendor publish key>');
|
|
28
|
+
if (process.env.INFORMER_MARKETPLACES) {
|
|
29
|
+
console.error('\nINFORMER_MARKETPLACES is set — for a multi-marketplace CI publish, run `informer-ci` instead.');
|
|
30
|
+
}
|
|
23
31
|
process.exit(1);
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8'));
|
|
31
|
-
} catch {
|
|
32
|
-
console.error('Could not read package.json in current directory.');
|
|
33
|
-
process.exit(1);
|
|
34
|
+
// A repo mid-migration to trusted publishing can have both configured. Publish
|
|
35
|
+
// what it was asked to publish, but say that only one target is being used.
|
|
36
|
+
if (process.env.INFORMER_MARKETPLACES && process.env.GITHUB_ACTIONS) {
|
|
37
|
+
console.warn('Note: INFORMER_MARKETPLACES is set but ignored here — `informer-ci` is the multi-marketplace command.');
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
const
|
|
37
|
-
const name = inf.name || pkg.name;
|
|
38
|
-
const slug = inf.slug || defaultSlug(name);
|
|
39
|
-
|
|
40
|
-
// Version: prefer the git tag (CI: GITHUB_REF_NAME=vX.Y.Z), then --version, then package.json.
|
|
41
|
-
const version = argValue('--version') || tagVersion(process.env.GITHUB_REF_NAME) || pkg.version;
|
|
40
|
+
const projectRoot = resolve('.');
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.`);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
const changeNotes = await extractNotes(join(projectRoot, 'CHANGELOG.md'), version);
|
|
50
|
-
|
|
51
|
-
// Provenance (GitHub Actions). All optional — best-effort audit trail.
|
|
52
|
-
const sourceRepo = process.env.GITHUB_REPOSITORY || undefined;
|
|
53
|
-
const sourceCommit = process.env.GITHUB_SHA || undefined;
|
|
54
|
-
const ciRun = process.env.GITHUB_RUN_ID
|
|
55
|
-
? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
56
|
-
: undefined;
|
|
57
|
-
|
|
58
|
-
// Assemble the same file set a deploy produces.
|
|
59
|
-
let files;
|
|
51
|
+
let payload;
|
|
60
52
|
try {
|
|
61
|
-
|
|
53
|
+
payload = await preparePublish({
|
|
54
|
+
version: argValue('--version') || (refIsTag ? tagVersion(process.env.GITHUB_REF_NAME) : undefined),
|
|
55
|
+
projectRoot
|
|
56
|
+
});
|
|
62
57
|
} catch (err) {
|
|
63
58
|
console.error(err.message);
|
|
64
59
|
process.exit(1);
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
// local handlers (see openapi-local.js) and ship it in the archive as root
|
|
69
|
-
// openapi.json — the License Manager extracts it (like informer.yaml), stores
|
|
70
|
-
// it per version, and diffs the public surface against the previous version.
|
|
71
|
-
const { spec: apiSpec, warnings: apiWarnings } = await buildLocalOpenApi({ projectRoot, name, slug, version });
|
|
72
|
-
for (const w of apiWarnings) console.warn(` warning: ${w}`);
|
|
73
|
-
if (apiSpec) files.push({ rel: 'openapi.json', content: JSON.stringify(apiSpec, null, 2) });
|
|
62
|
+
for (const w of payload.warnings || []) console.warn(` warning: ${w}`);
|
|
74
63
|
|
|
75
|
-
const
|
|
76
|
-
const screenshots = await resolveScreenshots(projectRoot, inf.screenshots);
|
|
77
|
-
const channel = version.includes('-') ? 'beta' : 'stable';
|
|
64
|
+
const { name, slug, version, channel, files, icon, screenshots, fields } = payload;
|
|
78
65
|
|
|
79
|
-
console.log(`Publishing ${name} v${version} (${channel}) — ${files.length} files${screenshots.length ? `, ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}` : ''}${changeNotes ? '' : ' — no release notes found'}`);
|
|
66
|
+
console.log(`Publishing ${name} v${version} (${channel}) — ${files.length} files${screenshots.length ? `, ${screenshots.length} screenshot${screenshots.length === 1 ? '' : 's'}` : ''}${fields.changeNotes ? '' : ' — no release notes found'}`);
|
|
80
67
|
|
|
81
68
|
try {
|
|
82
|
-
const result = await publish({
|
|
83
|
-
marketplaceUrl,
|
|
84
|
-
token,
|
|
85
|
-
files,
|
|
86
|
-
icon,
|
|
87
|
-
screenshots,
|
|
88
|
-
fields: {
|
|
89
|
-
name,
|
|
90
|
-
slug,
|
|
91
|
-
version,
|
|
92
|
-
shortDescription: inf.shortDescription,
|
|
93
|
-
description: inf.description || pkg.description,
|
|
94
|
-
categories: inf.categories,
|
|
95
|
-
documentationUrl: inf.documentationUrl,
|
|
96
|
-
requires: inf.requires,
|
|
97
|
-
metadata: inf.metadata,
|
|
98
|
-
changeNotes,
|
|
99
|
-
sourceRepo,
|
|
100
|
-
sourceCommit,
|
|
101
|
-
ciRun
|
|
102
|
-
}
|
|
103
|
-
});
|
|
69
|
+
const result = await publish({ marketplaceUrl, token, files, icon, screenshots, fields });
|
|
104
70
|
console.log(`Published ${slug} v${version} (${result.channel || channel}).`);
|
|
105
71
|
// Server-side advisories (e.g. "public API changed without a major bump").
|
|
106
72
|
for (const w of result.warnings || []) console.warn(` warning: ${w}`);
|
|
@@ -115,44 +81,3 @@ function argValue(flag) {
|
|
|
115
81
|
const i = process.argv.indexOf(flag);
|
|
116
82
|
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
|
|
117
83
|
}
|
|
118
|
-
|
|
119
|
-
function tagVersion(ref) {
|
|
120
|
-
return ref ? ref.replace(/^v/, '') : undefined;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function defaultSlug(value) {
|
|
124
|
-
if (!value) return undefined;
|
|
125
|
-
const base = value.startsWith('@') && value.includes('/') ? value.split('/')[1] : value;
|
|
126
|
-
return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async function resolveIcon(root, configured) {
|
|
130
|
-
const candidates = [configured, 'favicon.svg', join('public', 'favicon.svg'), join('dist', 'favicon.svg')].filter(Boolean);
|
|
131
|
-
for (const rel of candidates) {
|
|
132
|
-
const abs = resolve(root, rel);
|
|
133
|
-
try {
|
|
134
|
-
await access(abs);
|
|
135
|
-
return { abs, filename: rel.split(/[/\\]/).pop() };
|
|
136
|
-
} catch {
|
|
137
|
-
// try next candidate
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
return undefined;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Collect listing screenshots from a `screenshots/` directory (override via
|
|
144
|
-
// package.json informer.screenshots), ordered by filename. Returns
|
|
145
|
-
// [{abs, filename}] for the publisher to upload; empty when none exist.
|
|
146
|
-
async function resolveScreenshots(root, configuredDir) {
|
|
147
|
-
const dir = resolve(root, configuredDir || 'screenshots');
|
|
148
|
-
let names;
|
|
149
|
-
try {
|
|
150
|
-
names = await readdir(dir);
|
|
151
|
-
} catch {
|
|
152
|
-
return []; // no screenshots directory — nothing to upload
|
|
153
|
-
}
|
|
154
|
-
return names
|
|
155
|
-
.filter(n => /\.(png|jpe?g|webp|gif)$/i.test(n))
|
|
156
|
-
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
157
|
-
.map(n => ({ abs: join(dir, n), filename: n }));
|
|
158
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrinsik/vite-plugin-informer",
|
|
3
|
-
"version": "2.
|
|
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"
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"bin": {
|
|
31
31
|
"informer-deploy": "./bin/deploy.js",
|
|
32
32
|
"informer-publish": "./bin/publish.js",
|
|
33
|
+
"informer-ci": "./bin/ci.js",
|
|
33
34
|
"informer-init": "./bin/init.js",
|
|
34
35
|
"informer-workspace": "./bin/workspace.js",
|
|
35
36
|
"create-magic-report": "./bin/init.js"
|
package/src/assemble.js
CHANGED
|
@@ -17,7 +17,11 @@ import { join, relative, posix } from 'node:path';
|
|
|
17
17
|
// API.md is the author-written integration guide: deployed to the library root
|
|
18
18
|
// (the live openapi.json route folds it into info.description) and tarred into
|
|
19
19
|
// the published archive for the marketplace listing's Integrate surface.
|
|
20
|
-
|
|
20
|
+
// README.md is the docs fallback the gallery/app-menu Documentation surfaces
|
|
21
|
+
// already honor server-side (docs.html is preferred — ship that via public/ so
|
|
22
|
+
// the Vite build lands it at the library root). Without README.md here, a
|
|
23
|
+
// CLI-deployed app could never light those surfaces up via its README.
|
|
24
|
+
const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml', 'API.md', 'README.md'];
|
|
21
25
|
const SOURCE_DIRS = ['migrations', 'tools', 'mcp', 'server', 'webhooks', 'lib', 'shared'];
|
|
22
26
|
|
|
23
27
|
// Entries never worth shipping in an app's server-side library: OS/editor
|
|
@@ -32,8 +36,12 @@ async function exists(path) {
|
|
|
32
36
|
try {
|
|
33
37
|
await access(path);
|
|
34
38
|
return true;
|
|
35
|
-
} catch {
|
|
36
|
-
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// Only genuine absence is a quiet skip. Anything else (EACCES, EIO)
|
|
41
|
+
// means the file is there but unreadable — silently dropping it would
|
|
42
|
+
// publish an incomplete archive with no signal, so fail the build.
|
|
43
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
|
|
44
|
+
throw new Error(`Cannot read "${path}" (${err.code}) — fix permissions or remove the file.`);
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
47
|
|
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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions OIDC — "trusted publishing".
|
|
3
|
+
*
|
|
4
|
+
* The runner exposes a token-minting endpoint to jobs that declare
|
|
5
|
+
* `permissions: id-token: write`. The token it returns asserts which repository
|
|
6
|
+
* and workflow asked for it, signed by GitHub; the License Manager verifies it
|
|
7
|
+
* and maps the `repository` claim to a vendor account. Nothing long-lived is
|
|
8
|
+
* stored in the customer's repo, which is the whole point.
|
|
9
|
+
*
|
|
10
|
+
* The audience is requested PER MARKETPLACE, so a token minted for one License
|
|
11
|
+
* Manager cannot be replayed against another.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const REQUEST_URL = 'ACTIONS_ID_TOKEN_REQUEST_URL';
|
|
15
|
+
const REQUEST_TOKEN = 'ACTIONS_ID_TOKEN_REQUEST_TOKEN';
|
|
16
|
+
|
|
17
|
+
/** True when the two runner-injected OIDC variables are present. */
|
|
18
|
+
export function canMintIdToken() {
|
|
19
|
+
return Boolean(process.env[REQUEST_URL] && process.env[REQUEST_TOKEN]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mint an OIDC token for one marketplace.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} audience the marketplace URL this token may be spent at
|
|
26
|
+
* @returns {Promise<string>} the JWT
|
|
27
|
+
*/
|
|
28
|
+
export async function mintIdToken(audience) {
|
|
29
|
+
const base = process.env[REQUEST_URL];
|
|
30
|
+
const requestToken = process.env[REQUEST_TOKEN];
|
|
31
|
+
|
|
32
|
+
if (!base || !requestToken) {
|
|
33
|
+
// Nearly always a missing permissions block: the variables are injected
|
|
34
|
+
// only for jobs that asked for the capability, so name the fix.
|
|
35
|
+
throw new Error(
|
|
36
|
+
'No GitHub OIDC token endpoint available. Add to the job:\n' +
|
|
37
|
+
' permissions:\n' +
|
|
38
|
+
' id-token: write\n' +
|
|
39
|
+
' (or publish with a key using `informer-publish`).'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const url = `${base}&audience=${encodeURIComponent(audience)}`;
|
|
44
|
+
let res;
|
|
45
|
+
try {
|
|
46
|
+
res = await fetch(url, { headers: { Authorization: `Bearer ${requestToken}` } });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(`Could not reach the GitHub OIDC token endpoint: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new Error(`GitHub refused to mint an OIDC token (${res.status} ${res.statusText}).`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const body = await res.json().catch(() => ({}));
|
|
56
|
+
if (!body.value) throw new Error('GitHub OIDC token response contained no token.');
|
|
57
|
+
return body.value;
|
|
58
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list of marketplaces a CI publish fans out to.
|
|
3
|
+
*
|
|
4
|
+
* Fan-out lives in the CLI rather than the workflow, so the value never has to
|
|
5
|
+
* survive a YAML `fromJSON()` — which means it does not have to BE json. People
|
|
6
|
+
* edit this by hand in a repo-variable textarea, so accept the forms they
|
|
7
|
+
* actually type: a json array, or one URL per line, or comma-separated.
|
|
8
|
+
*
|
|
9
|
+
* Bare URLs on purpose, no labels. Nothing that writes this list (Studio, a
|
|
10
|
+
* human) knows whether an instance is dev, qa, or prod — only the person
|
|
11
|
+
* reading the hostname does.
|
|
12
|
+
*/
|
|
13
|
+
export function parseMarketplaces(raw) {
|
|
14
|
+
if (!raw) return [];
|
|
15
|
+
|
|
16
|
+
const trimmed = String(raw).trim();
|
|
17
|
+
let values;
|
|
18
|
+
|
|
19
|
+
if (trimmed.startsWith('[')) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(trimmed);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new Error(`INFORMER_MARKETPLACES looks like JSON but does not parse: ${err.message}`);
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(parsed)) throw new Error('INFORMER_MARKETPLACES must be an array of marketplace URLs.');
|
|
27
|
+
values = parsed;
|
|
28
|
+
} else {
|
|
29
|
+
values = trimmed.split(/[\s,]+/);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const urls = values.map(v => String(v).trim()).filter(Boolean).map(normalizeMarketplaceUrl);
|
|
33
|
+
|
|
34
|
+
// A duplicate would publish the same version twice to one marketplace, and
|
|
35
|
+
// the second attempt 409s — turning a working config into a red build.
|
|
36
|
+
return [...new Set(urls)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Canonical form for comparison and display.
|
|
41
|
+
*
|
|
42
|
+
* A trailing slash makes two spellings of one marketplace look like two
|
|
43
|
+
* marketplaces — which matters most to whatever is checking "am I already in
|
|
44
|
+
* this list?" before appending.
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeMarketplaceUrl(value) {
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(String(value).trim());
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`Not a valid marketplace URL: ${value}`);
|
|
52
|
+
}
|
|
53
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
54
|
+
throw new Error(`Marketplace URL must be http(s): ${value}`);
|
|
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
|
+
}
|
|
65
|
+
url.hostname = url.hostname.toLowerCase();
|
|
66
|
+
url.hash = '';
|
|
67
|
+
url.search = '';
|
|
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);
|
|
87
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFile, access, readdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { collectAppFiles } from './assemble.js';
|
|
4
|
+
import { extractNotes } from './changelog.js';
|
|
5
|
+
import { buildLocalOpenApi } from './openapi-local.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Everything a publish needs, assembled once.
|
|
9
|
+
*
|
|
10
|
+
* Split out of bin/publish.js so `informer-ci` can build the payload a single
|
|
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.)
|
|
16
|
+
*
|
|
17
|
+
* Throws on anything that should stop a publish; the bins print and exit.
|
|
18
|
+
*/
|
|
19
|
+
export async function preparePublish({ projectRoot = resolve('.'), version: versionOverride } = {}) {
|
|
20
|
+
let pkg;
|
|
21
|
+
try {
|
|
22
|
+
pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error('Could not read package.json in current directory.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const inf = pkg.informer || {};
|
|
28
|
+
const name = inf.name || pkg.name;
|
|
29
|
+
const slug = inf.slug || defaultSlug(name);
|
|
30
|
+
const version = versionOverride || pkg.version;
|
|
31
|
+
|
|
32
|
+
if (!name || !slug || !version) {
|
|
33
|
+
throw new Error('Need a name, slug, and version. Set package.json "informer.name"/"informer.slug" and tag a release (vX.Y.Z) or pass --version.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Release notes: the section for this version, else the cumulative Unreleased block.
|
|
37
|
+
const changeNotes = await extractNotes(join(projectRoot, 'CHANGELOG.md'), version);
|
|
38
|
+
|
|
39
|
+
// Assemble the same file set a deploy produces.
|
|
40
|
+
const files = await collectAppFiles({ distDir: join(projectRoot, 'dist'), projectRoot });
|
|
41
|
+
|
|
42
|
+
// Freeze the API contract with this version: build the OpenAPI doc from the
|
|
43
|
+
// local handlers and ship it in the archive as root openapi.json — the
|
|
44
|
+
// License Manager extracts it, stores it per version, and diffs the public
|
|
45
|
+
// surface against the previous version.
|
|
46
|
+
const { spec: apiSpec, warnings } = await buildLocalOpenApi({ projectRoot, name, slug, version });
|
|
47
|
+
if (apiSpec) files.push({ rel: 'openapi.json', content: JSON.stringify(apiSpec, null, 2) });
|
|
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.
|
|
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
|
+
}
|
|
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
|
+
}
|
|
64
|
+
|
|
65
|
+
// Provenance (GitHub Actions). All optional — best-effort audit trail.
|
|
66
|
+
const sourceRepo = process.env.GITHUB_REPOSITORY || undefined;
|
|
67
|
+
const sourceCommit = process.env.GITHUB_SHA || undefined;
|
|
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}`
|
|
75
|
+
: undefined;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
name,
|
|
79
|
+
slug,
|
|
80
|
+
version,
|
|
81
|
+
// The server derives the channel too; this copy is for local reporting.
|
|
82
|
+
channel: version.includes('-') ? 'beta' : 'stable',
|
|
83
|
+
files,
|
|
84
|
+
icon,
|
|
85
|
+
screenshots,
|
|
86
|
+
warnings,
|
|
87
|
+
fields: {
|
|
88
|
+
name,
|
|
89
|
+
slug,
|
|
90
|
+
version,
|
|
91
|
+
shortDescription: inf.shortDescription,
|
|
92
|
+
description: inf.description || pkg.description,
|
|
93
|
+
categories: inf.categories,
|
|
94
|
+
documentationUrl: inf.documentationUrl,
|
|
95
|
+
requires: inf.requires,
|
|
96
|
+
metadata: inf.metadata,
|
|
97
|
+
changeNotes,
|
|
98
|
+
sourceRepo,
|
|
99
|
+
sourceCommit,
|
|
100
|
+
ciRun
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
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
|
+
*/
|
|
113
|
+
export function tagVersion(ref) {
|
|
114
|
+
return ref ? ref.replace(/^v/, '') : undefined;
|
|
115
|
+
}
|
|
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
|
+
|
|
135
|
+
export function defaultSlug(value) {
|
|
136
|
+
if (!value) return undefined;
|
|
137
|
+
const base = value.startsWith('@') && value.includes('/') ? value.split('/')[1] : value;
|
|
138
|
+
return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function resolveIcon(root, configured) {
|
|
142
|
+
const candidates = [configured, 'favicon.svg', join('public', 'favicon.svg'), join('dist', 'favicon.svg')].filter(Boolean);
|
|
143
|
+
for (const rel of candidates) {
|
|
144
|
+
const abs = resolve(root, rel);
|
|
145
|
+
try {
|
|
146
|
+
await access(abs);
|
|
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() };
|
|
151
|
+
} catch {
|
|
152
|
+
// try next candidate
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Collect listing screenshots from a `screenshots/` directory (override via
|
|
159
|
+
// package.json informer.screenshots), ordered by filename. Returns
|
|
160
|
+
// [{abs, filename}] for the publisher to upload; empty when none exist.
|
|
161
|
+
async function resolveScreenshots(root, configuredDir) {
|
|
162
|
+
const dir = resolve(root, configuredDir || 'screenshots');
|
|
163
|
+
let names;
|
|
164
|
+
try {
|
|
165
|
+
names = await readdir(dir);
|
|
166
|
+
} catch {
|
|
167
|
+
return []; // no screenshots directory — nothing to upload
|
|
168
|
+
}
|
|
169
|
+
return names
|
|
170
|
+
.filter(n => /\.(png|jpe?g|webp|gif)$/i.test(n))
|
|
171
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
172
|
+
.map(n => ({ abs: join(dir, n), filename: n }));
|
|
173
|
+
}
|
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
|
-
|
|
126
|
+
try {
|
|
127
|
+
return (await res.json()) ?? {};
|
|
128
|
+
} catch {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
107
131
|
}
|