@moku-labs/ci 1.2.1 → 1.2.3
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/dist/release.mjs +181 -25
- package/package.json +4 -4
package/dist/release.mjs
CHANGED
|
@@ -774,16 +774,90 @@ function parseManifest(text) {
|
|
|
774
774
|
async function readManifest(files) {
|
|
775
775
|
return parseManifest(await files.read(MANIFEST_PATH));
|
|
776
776
|
}
|
|
777
|
+
/** Any character outside printable ASCII and the JSON whitespace. */
|
|
778
|
+
const NON_ASCII = /[^\t\n\r -~]/;
|
|
779
|
+
/**
|
|
780
|
+
* Whether a text holds only ASCII characters, i.e. every other character is `\uXXXX`-escaped.
|
|
781
|
+
*
|
|
782
|
+
* @param text - The file contents to test.
|
|
783
|
+
* @returns `true` when no raw non-ASCII character is present.
|
|
784
|
+
* @example
|
|
785
|
+
* isAsciiOnly('{"description":"a \\u2014 b"}'); // true
|
|
786
|
+
*/
|
|
787
|
+
function isAsciiOnly(text) {
|
|
788
|
+
return !NON_ASCII.test(text);
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* The one-line form a top-level value had in the source, e.g. `["dist", "LICENSE"]`.
|
|
792
|
+
*
|
|
793
|
+
* @param source - The original file contents.
|
|
794
|
+
* @param key - The top-level key to look up.
|
|
795
|
+
* @returns The inline text, or `undefined` when the value spanned several lines or is absent.
|
|
796
|
+
* @example
|
|
797
|
+
* inlineValueOf('{\n "files": ["dist"],\n}', "files"); // '["dist"]'
|
|
798
|
+
*/
|
|
799
|
+
function inlineValueOf(source, key) {
|
|
800
|
+
const prefix = ` ${JSON.stringify(key)}: `;
|
|
801
|
+
const line = source.split("\n").find((candidate) => candidate.startsWith(prefix));
|
|
802
|
+
if (line === void 0) return void 0;
|
|
803
|
+
const value = line.slice(prefix.length).replace(/,\s*$/, "");
|
|
804
|
+
return /^[[{].*[\]}]$/.test(value) ? value : void 0;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Put back the one-line objects and arrays the source had. Formatters such as biome keep a
|
|
808
|
+
* short `"files": ["dist"]` on one line; `JSON.stringify` expands it, and an untouched value
|
|
809
|
+
* then shows up as changed lines in the diff. Only top-level, unchanged values are restored.
|
|
810
|
+
*
|
|
811
|
+
* @param manifest - The manifest being written.
|
|
812
|
+
* @param expanded - Its `JSON.stringify` rendering.
|
|
813
|
+
* @param source - The original file contents.
|
|
814
|
+
* @returns The rendering with the untouched inline values restored.
|
|
815
|
+
* @example
|
|
816
|
+
* keepInlineValues({ files: ["dist"] }, '{\n "files": [\n "dist"\n ]\n}\n', source);
|
|
817
|
+
*/
|
|
818
|
+
function keepInlineValues(manifest, expanded, source) {
|
|
819
|
+
let text = expanded;
|
|
820
|
+
for (const [key, value] of Object.entries(manifest)) {
|
|
821
|
+
const inline = inlineValueOf(source, key);
|
|
822
|
+
if (inline === void 0 || parseManifestValue(inline) !== JSON.stringify(value)) continue;
|
|
823
|
+
const block = JSON.stringify(value, void 0, 2).replaceAll("\n", "\n ");
|
|
824
|
+
const label = ` ${JSON.stringify(key)}: `;
|
|
825
|
+
text = text.replace(`${label}${block}`, () => `${label}${inline}`);
|
|
826
|
+
}
|
|
827
|
+
return text;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* The canonical JSON of an inline value, for comparing it with the value being written.
|
|
831
|
+
*
|
|
832
|
+
* @param inline - The one-line JSON text.
|
|
833
|
+
* @returns Its compact re-serialization, or `undefined` when it does not parse.
|
|
834
|
+
* @example
|
|
835
|
+
* parseManifestValue('["dist", "LICENSE"]'); // '["dist","LICENSE"]'
|
|
836
|
+
*/
|
|
837
|
+
function parseManifestValue(inline) {
|
|
838
|
+
try {
|
|
839
|
+
return JSON.stringify(JSON.parse(inline));
|
|
840
|
+
} catch {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
777
844
|
/**
|
|
778
845
|
* Render a manifest the way npm itself writes one: two-space JSON with a trailing newline.
|
|
846
|
+
* A source file that was ASCII-only stays ASCII-only, so an escaped dash the package chose
|
|
847
|
+
* does not show up as a changed line in the diff.
|
|
779
848
|
*
|
|
780
849
|
* @param manifest - The manifest to serialize.
|
|
850
|
+
* @param source - The file contents the manifest was read from, when there were any.
|
|
781
851
|
* @returns The file contents to write.
|
|
782
852
|
* @example
|
|
783
|
-
* await files.write("package.json", formatManifest(manifest));
|
|
853
|
+
* await files.write("package.json", formatManifest(manifest, source));
|
|
784
854
|
*/
|
|
785
|
-
function formatManifest(manifest) {
|
|
786
|
-
|
|
855
|
+
function formatManifest(manifest, source) {
|
|
856
|
+
const expanded = `${JSON.stringify(manifest, void 0, 2)}\n`;
|
|
857
|
+
if (source === void 0) return expanded;
|
|
858
|
+
const text = keepInlineValues(manifest, expanded, source);
|
|
859
|
+
if (!isAsciiOnly(source)) return text;
|
|
860
|
+
return text.replaceAll(new RegExp(NON_ASCII, "g"), (character) => String.raw`\u${character.codePointAt(0)?.toString(16).padStart(4, "0")}`);
|
|
787
861
|
}
|
|
788
862
|
/**
|
|
789
863
|
* The repository URL a manifest declares, in either the object or shorthand string form.
|
|
@@ -1578,6 +1652,8 @@ const tagSyncCheck = {
|
|
|
1578
1652
|
*/
|
|
1579
1653
|
/** Basename npm registers the publisher against — the workflow file's name, not its path. */
|
|
1580
1654
|
const PUBLISH_WORKFLOW_FILE$1 = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
|
|
1655
|
+
/** Detail of the result when the listing sits behind 2FA; `setup` offers to register anyway. */
|
|
1656
|
+
const TRUST_NEEDS_OTP = "npm asks for an OTP, cannot verify from here";
|
|
1581
1657
|
/**
|
|
1582
1658
|
* Whether npm's output says the `trust` command itself does not exist.
|
|
1583
1659
|
*
|
|
@@ -1649,7 +1725,7 @@ const trustedPublisherCheck = {
|
|
|
1649
1725
|
]);
|
|
1650
1726
|
if (isUnknownCommand(`${listing.stdout}${listing.stderr}`)) return warn("this npm has no `trust` command", "upgrade npm");
|
|
1651
1727
|
if (isUnauthorized(`${listing.stdout}${listing.stderr}`)) return skip("cannot list trusted publishers without `npm login`");
|
|
1652
|
-
if (isOtpRequired(`${listing.stdout}${listing.stderr}`)) return warn(
|
|
1728
|
+
if (isOtpRequired(`${listing.stdout}${listing.stderr}`)) return warn(TRUST_NEEDS_OTP, `npm trust list ${manifest.name}`);
|
|
1653
1729
|
if (listing.code !== 0 || !listing.stdout.includes(PUBLISH_WORKFLOW_FILE$1)) return fail("no trusted publisher registered", trustCommand(manifest.name, ownerRepo));
|
|
1654
1730
|
return pass(`${ownerRepo} · ${PUBLISH_WORKFLOW_FILE$1}`);
|
|
1655
1731
|
}
|
|
@@ -2097,9 +2173,55 @@ async function ensurePrerequisites(setup) {
|
|
|
2097
2173
|
return true;
|
|
2098
2174
|
}
|
|
2099
2175
|
/**
|
|
2176
|
+
* Whether git already holds this exact file: tracked, and with no uncommitted change.
|
|
2177
|
+
*
|
|
2178
|
+
* @param setup - The wizard state.
|
|
2179
|
+
* @param path - Repo-relative path of the file.
|
|
2180
|
+
* @returns `true` when `git checkout -- <path>` would bring the current content back.
|
|
2181
|
+
* @example
|
|
2182
|
+
* await isCommittedUnchanged(setup, ".github/workflows/ci.yml");
|
|
2183
|
+
*/
|
|
2184
|
+
async function isCommittedUnchanged(setup, path) {
|
|
2185
|
+
if ((await setup.ctx.exec.capture("git", [
|
|
2186
|
+
"ls-files",
|
|
2187
|
+
"--error-unmatch",
|
|
2188
|
+
path
|
|
2189
|
+
])).code !== 0) return false;
|
|
2190
|
+
const status = await setup.ctx.exec.capture("git", [
|
|
2191
|
+
"status",
|
|
2192
|
+
"--porcelain",
|
|
2193
|
+
"--",
|
|
2194
|
+
path
|
|
2195
|
+
]);
|
|
2196
|
+
return status.code === 0 && status.stdout.trim() === "";
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Ask before replacing an existing workflow, and keep the original: in git when it is
|
|
2200
|
+
* committed and unmodified, in a `.bak` copy otherwise.
|
|
2201
|
+
*
|
|
2202
|
+
* @param setup - The wizard state.
|
|
2203
|
+
* @param template - The central workflow about to be written.
|
|
2204
|
+
* @param existing - The current contents of the file.
|
|
2205
|
+
* @returns `true` when the caller may write the template now.
|
|
2206
|
+
* @example
|
|
2207
|
+
* if (!(await clearExistingWorkflow(setup, template, existing))) continue;
|
|
2208
|
+
*/
|
|
2209
|
+
async function clearExistingWorkflow(setup, template, existing) {
|
|
2210
|
+
const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
|
|
2211
|
+
const committed = await isCommittedUnchanged(setup, template.path);
|
|
2212
|
+
const safety = committed ? "git keeps the original" : "a .bak copy is kept";
|
|
2213
|
+
if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (${safety})?`)) {
|
|
2214
|
+
setup.ui.check(false, `${template.path} left unchanged`);
|
|
2215
|
+
return false;
|
|
2216
|
+
}
|
|
2217
|
+
if (deferred(setup, `${committed ? "replace" : "back up and replace"} ${template.path}`)) return false;
|
|
2218
|
+
if (!committed) await setup.ctx.files.backup(template.path);
|
|
2219
|
+
return true;
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2100
2222
|
* Write the two thin workflows. A file that already calls the pinned central workflow is
|
|
2101
2223
|
* left alone; a differing file is only replaced after an explicit confirm, and a `.bak`
|
|
2102
|
-
* copy is kept.
|
|
2224
|
+
* copy is kept unless git already holds the original.
|
|
2103
2225
|
*
|
|
2104
2226
|
* @param setup - The wizard state.
|
|
2105
2227
|
* @returns Nothing.
|
|
@@ -2114,15 +2236,7 @@ async function writeWorkflows(setup) {
|
|
|
2114
2236
|
setup.ui.check(true, `${template.path} up to date`);
|
|
2115
2237
|
continue;
|
|
2116
2238
|
}
|
|
2117
|
-
if (existing
|
|
2118
|
-
const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
|
|
2119
|
-
if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (a .bak copy is kept)?`)) {
|
|
2120
|
-
setup.ui.check(false, `${template.path} left unchanged`);
|
|
2121
|
-
continue;
|
|
2122
|
-
}
|
|
2123
|
-
if (deferred(setup, `back up and replace ${template.path}`)) continue;
|
|
2124
|
-
await setup.ctx.files.backup(template.path);
|
|
2125
|
-
} else if (deferred(setup, `write ${template.path}`)) continue;
|
|
2239
|
+
if (!(existing === void 0 ? !deferred(setup, `write ${template.path}`) : await clearExistingWorkflow(setup, template, existing))) continue;
|
|
2126
2240
|
await setup.ctx.files.write(template.path, template.content);
|
|
2127
2241
|
setup.ui.check(true, `${template.path} written`);
|
|
2128
2242
|
}
|
|
@@ -2155,7 +2269,8 @@ async function normalizeContract(setup, manifest) {
|
|
|
2155
2269
|
return;
|
|
2156
2270
|
}
|
|
2157
2271
|
if (deferred(setup, `write package.json`)) return;
|
|
2158
|
-
await setup.ctx.files.
|
|
2272
|
+
const source = await setup.ctx.files.read(MANIFEST_PATH);
|
|
2273
|
+
await setup.ctx.files.write(MANIFEST_PATH, formatManifest(next, source));
|
|
2159
2274
|
setup.ui.check(true, `${MANIFEST_PATH} normalized`);
|
|
2160
2275
|
}
|
|
2161
2276
|
/**
|
|
@@ -2165,7 +2280,7 @@ async function normalizeContract(setup, manifest) {
|
|
|
2165
2280
|
* @param setup - The wizard state.
|
|
2166
2281
|
* @param name - The package name.
|
|
2167
2282
|
* @param version - The version about to be published.
|
|
2168
|
-
* @returns `true` when
|
|
2283
|
+
* @returns `true` when this run performed the first publish.
|
|
2169
2284
|
* @example
|
|
2170
2285
|
* await firstPublish(setup, "@moku-labs/common", "0.2.0");
|
|
2171
2286
|
*/
|
|
@@ -2178,7 +2293,7 @@ async function firstPublish(setup, name, version) {
|
|
|
2178
2293
|
]);
|
|
2179
2294
|
if (view.code === 0) {
|
|
2180
2295
|
setup.ui.check(true, `${name}@${view.stdout.trim()} already on npm`);
|
|
2181
|
-
return
|
|
2296
|
+
return false;
|
|
2182
2297
|
}
|
|
2183
2298
|
if (!await setup.prompts.confirm(`Publish ${name}@${version} to npm now?`)) {
|
|
2184
2299
|
setup.ui.check(false, "first publish skipped");
|
|
@@ -2209,6 +2324,11 @@ async function firstPublish(setup, name, version) {
|
|
|
2209
2324
|
*/
|
|
2210
2325
|
async function pushVersionTag(setup, version) {
|
|
2211
2326
|
setup.ui.heading("Tag");
|
|
2327
|
+
const latest = latestVersionTag((await setup.ctx.exec.capture("git", [...LATEST_TAG_ARGS])).stdout);
|
|
2328
|
+
if (latest !== void 0) {
|
|
2329
|
+
setup.ui.check(true, `release tags exist, latest is ${latest}`);
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2212
2332
|
const tag = `v${version}`;
|
|
2213
2333
|
if ((await setup.ctx.exec.capture("git", [
|
|
2214
2334
|
"tag",
|
|
@@ -2245,14 +2365,48 @@ async function pushVersionTag(setup, version) {
|
|
|
2245
2365
|
async function registerTrustedPublisher(setup) {
|
|
2246
2366
|
setup.ui.heading("Trusted publisher");
|
|
2247
2367
|
const result = await trustedPublisherCheck.run(setup.ctx);
|
|
2248
|
-
if (result.status
|
|
2249
|
-
setup.ui.check(
|
|
2368
|
+
if (result.status === "pass") {
|
|
2369
|
+
setup.ui.check(true, result.detail);
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
const registration = result.detail === "npm asks for an OTP, cannot verify from here" ? await registrationBehindOtp(setup) : failFix(result);
|
|
2373
|
+
if (registration === void 0) {
|
|
2374
|
+
setup.ui.check(false, result.detail, result.fix);
|
|
2250
2375
|
return;
|
|
2251
2376
|
}
|
|
2252
|
-
if (deferred(setup,
|
|
2253
|
-
const [command = "npm", ...args] =
|
|
2377
|
+
if (deferred(setup, registration)) return;
|
|
2378
|
+
const [command = "npm", ...args] = registration.split(" ");
|
|
2254
2379
|
const code = await setup.ctx.exec.inherit(command, args);
|
|
2255
|
-
|
|
2380
|
+
const hint = code === 0 ? void 0 : `verify: ${result.fix}`;
|
|
2381
|
+
setup.ui.check(code === 0, "trusted publisher registered", hint);
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* The fix of a failing result. A warn or a skip is not something the wizard may act on.
|
|
2385
|
+
*
|
|
2386
|
+
* @param result - The check result.
|
|
2387
|
+
* @returns The fix command when the result is a failure.
|
|
2388
|
+
* @example
|
|
2389
|
+
* failFix({ status: "fail", detail: "…", fix: "npm trust github …" });
|
|
2390
|
+
*/
|
|
2391
|
+
function failFix(result) {
|
|
2392
|
+
return result.status === "fail" ? result.fix : void 0;
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* The registration command for an account behind 2FA. There the listing needs an OTP, so
|
|
2396
|
+
* the check cannot tell "missing" from "registered"; the wizard asks, and npm — with stdio
|
|
2397
|
+
* inherited — prompts for the OTP itself and refuses a duplicate.
|
|
2398
|
+
*
|
|
2399
|
+
* @param setup - The wizard state.
|
|
2400
|
+
* @returns The command to run, or `undefined` when it cannot be built or was declined.
|
|
2401
|
+
* @example
|
|
2402
|
+
* const registration = await registrationBehindOtp(setup);
|
|
2403
|
+
*/
|
|
2404
|
+
async function registrationBehindOtp(setup) {
|
|
2405
|
+
const manifest = await readManifest(setup.ctx.files);
|
|
2406
|
+
const declared = manifest === void 0 ? void 0 : repositoryUrlOf(manifest);
|
|
2407
|
+
const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
|
|
2408
|
+
if (!manifest?.name || ownerRepo === void 0) return void 0;
|
|
2409
|
+
return await setup.prompts.confirm("npm needs an OTP to list trusted publishers. Register publish.yml now?") ? trustCommand(manifest.name, ownerRepo) : void 0;
|
|
2256
2410
|
}
|
|
2257
2411
|
/**
|
|
2258
2412
|
* Apply the PR-only branch ruleset to the default branch. Tags stay unrestricted — the
|
|
@@ -2325,7 +2479,7 @@ async function runSetup(options) {
|
|
|
2325
2479
|
}
|
|
2326
2480
|
await writeWorkflows(setup);
|
|
2327
2481
|
await normalizeContract(setup, manifest);
|
|
2328
|
-
await firstPublish(setup, manifest.name, manifest.version);
|
|
2482
|
+
const justPublished = await firstPublish(setup, manifest.name, manifest.version);
|
|
2329
2483
|
await pushVersionTag(setup, manifest.version);
|
|
2330
2484
|
await registerTrustedPublisher(setup);
|
|
2331
2485
|
const declared = repositoryUrlOf(manifest);
|
|
@@ -2333,10 +2487,12 @@ async function runSetup(options) {
|
|
|
2333
2487
|
if (ownerRepo === void 0) ui.warn("no GitHub owner/repo — skipping the branch ruleset");
|
|
2334
2488
|
else await applyBranchRuleset(setup, ownerRepo);
|
|
2335
2489
|
ui.heading("Doctor");
|
|
2336
|
-
|
|
2490
|
+
const report = await runDoctor({
|
|
2337
2491
|
ctx: setup.ctx,
|
|
2338
2492
|
ui
|
|
2339
|
-
})
|
|
2493
|
+
});
|
|
2494
|
+
if (justPublished) ui.info("published just now: npm can answer 404 for a few minutes, re-run release:doctor then");
|
|
2495
|
+
return report.failed ? 1 : 0;
|
|
2340
2496
|
}
|
|
2341
2497
|
//#endregion
|
|
2342
2498
|
//#region src/lib/argv.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moku-labs/ci",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Central CI and release for the moku family: reusable workflows, caller examples and the moku-release CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"lint:fix": "biome check --write . && eslint --fix .",
|
|
57
57
|
"format": "biome format --write .",
|
|
58
58
|
"test": "vitest run",
|
|
59
|
-
"release:setup": "
|
|
60
|
-
"release:doctor": "
|
|
61
|
-
"release": "
|
|
59
|
+
"release:setup": "bun run build && node dist/release.mjs setup",
|
|
60
|
+
"release:doctor": "bun run build && node dist/release.mjs doctor",
|
|
61
|
+
"release": "bun run build && node dist/release.mjs"
|
|
62
62
|
}
|
|
63
63
|
}
|