@moku-labs/ci 1.1.2 → 1.2.1

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.
Files changed (3) hide show
  1. package/README.md +20 -2
  2. package/dist/release.mjs +354 -156
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,6 +22,7 @@ everything else lives here, once. Not a build tool and not a framework — it ca
22
22
  [Install](#install) ·
23
23
  [How it works](#how-it-works) ·
24
24
  [Workflows](#workflows) ·
25
+ [PR previews](#pr-previews) ·
25
26
  [CLI](#cli) ·
26
27
  [The contract](#the-contract) ·
27
28
  [Versioning](#versioning) ·
@@ -93,7 +94,7 @@ flowchart LR
93
94
 
94
95
  | Workflow | Called from | Jobs | Inputs (all optional) |
95
96
  |---|---|---|---|
96
- | [`package-ci.yml`](.github/workflows/package-ci.yml) | [`examples/package/ci.yml`](examples/package/ci.yml) | `lint` · `types` · `test` · `build` | `runs_on`, `bun_version`, `validate` |
97
+ | [`package-ci.yml`](.github/workflows/package-ci.yml) | [`examples/package/ci.yml`](examples/package/ci.yml) | `lint` · `types` · `test` · `build` · `preview` | `runs_on`, `bun_version`, `validate`, `preview` |
97
98
  | [`package-release.yml`](.github/workflows/package-release.yml) | [`examples/package/publish.yml`](examples/package/publish.yml) | `check` → `release` → `package` → `publish` | `release_type`, `publish`, `runs_on`, `bun_version`, `node_version`, `artifact_name`, `validate` |
98
99
  | [`app-deploy.yml`](.github/workflows/app-deploy.yml) | [`examples/app/ci.yml`](examples/app/ci.yml) | `validate` → `deploy` to Cloudflare | script names (`lint_script`, `build_script`, `deploy_script`, …) and two required secrets |
99
100
  | [`self-test.yml`](.github/workflows/self-test.yml) | this repo only | `actionlint` over workflows and examples | — |
@@ -109,12 +110,29 @@ flowchart LR
109
110
  > A Layer-3 app copies `examples/app/ci.yml` by hand and needs a `deploy` script. The CLI
110
111
  > sets up packages only.
111
112
 
113
+ ## PR previews
114
+
115
+ Every pull request commit is published to [pkg.pr.new](https://pkg.pr.new) by the `preview`
116
+ job. Nothing reaches npm and no token is in scope. The bot comments the install command on
117
+ the PR.
118
+
119
+ ```sh
120
+ bun add https://pkg.pr.new/@moku-labs/core@42 # 42 = PR number, a commit sha works too
121
+ ```
122
+
123
+ | Rule | Where |
124
+ |---|---|
125
+ | The [pkg.pr.new GitHub App](https://github.com/apps/pkg-pr-new) must be installed on the repo. | once per org |
126
+ | The package repo must be public. The consuming project may be private. | pkg.pr.new |
127
+ | A preview URL never reaches `main`: the `lint` job and `doctor` both refuse it. | `package-ci.yml`, `preview-deps` |
128
+ | `preview` is not a required check. Turn it off with `with: { preview: false }`. | caller `ci.yml` |
129
+
112
130
  ## CLI
113
131
 
114
132
  | Command | When | What it does |
115
133
  |---|---|---|
116
134
  | `bun run release:setup` | once per project | Idempotent wizard: workflows, script contract, first publish, first tag, trusted publisher, branch ruleset, then `doctor`. `--dry-run` prints every action and changes nothing. |
117
- | `bun run release:doctor` | any time | Read-only. Eleven checks, one line each, and the exact `fix:` command for every red line. `--json` for machines. |
135
+ | `bun run release:doctor` | any time | Changes nothing in the project; it only runs `git fetch --tags` first. Twelve checks, one line each, and the exact `fix:` command for every red line. `--json` for machines. |
118
136
  | `bun run release <patch\|minor\|major\|prerelease>` | each release | Refuses unless the tree is clean and `HEAD == origin/main`. Dispatches `publish.yml`, watches the run, verifies the version and dist-tag on npm. |
119
137
 
120
138
  The scripts are plain aliases of the `moku-release` bin. Internals and the list of checks:
package/dist/release.mjs CHANGED
@@ -644,6 +644,61 @@ function parseJsonArray(stdout) {
644
644
  function hasMainBranchRuleset(stdout) {
645
645
  return parseJsonArray(stdout).some((ruleset) => ruleset.target === BRANCH_TARGET && ruleset.enforcement !== "disabled");
646
646
  }
647
+ /** Rule type GitHub uses for the required status checks of a ruleset. */
648
+ const REQUIRED_CHECKS_RULE = "required_status_checks";
649
+ /**
650
+ * Parse the `rules` of one full ruleset, tolerating error text and non-object payloads.
651
+ *
652
+ * @param stdout - Output of `gh api repos/{owner}/{repo}/rulesets/{id}`, or a ruleset template.
653
+ * @returns The rules, or an empty array when the payload has none.
654
+ * @example
655
+ * parseRules('{"rules":[{"type":"deletion"}]}');
656
+ */
657
+ function parseRules(stdout) {
658
+ try {
659
+ const parsed = JSON.parse(stdout);
660
+ return Array.isArray(parsed?.rules) ? parsed.rules : [];
661
+ } catch {
662
+ return [];
663
+ }
664
+ }
665
+ /**
666
+ * The ids of every active BRANCH ruleset, in listing order.
667
+ *
668
+ * @param stdout - Output of `gh api repos/{owner}/{repo}/rulesets`.
669
+ * @returns The ruleset ids; a ruleset without an id is left out.
670
+ * @example
671
+ * activeBranchRulesetIds('[{"id":7,"target":"branch","enforcement":"active"}]'); // [7]
672
+ */
673
+ function activeBranchRulesetIds(stdout) {
674
+ return parseJsonArray(stdout).filter((ruleset) => ruleset.target === BRANCH_TARGET && ruleset.enforcement !== "disabled").flatMap((ruleset) => ruleset.id === void 0 ? [] : [ruleset.id]);
675
+ }
676
+ /**
677
+ * The status check contexts one full ruleset requires.
678
+ *
679
+ * @param stdout - One full ruleset as JSON.
680
+ * @returns The required contexts, e.g. `["ci / lint"]`.
681
+ * @example
682
+ * requiredCheckContexts(renderMainRuleset()); // ["ci / lint", "ci / types", …]
683
+ */
684
+ function requiredCheckContexts(stdout) {
685
+ return parseRules(stdout).filter((rule) => rule.type === REQUIRED_CHECKS_RULE).flatMap((rule) => rule.parameters?.required_status_checks ?? []).flatMap((check) => check.context === void 0 ? [] : [check.context]);
686
+ }
687
+ /**
688
+ * The body that moves an existing ruleset onto the central required checks. Every other
689
+ * rule of the existing ruleset is kept as it is.
690
+ *
691
+ * @param existing - The full existing ruleset as JSON.
692
+ * @param template - The central ruleset template as JSON.
693
+ * @returns The `PUT` body: the existing rules with the template's required checks.
694
+ * @example
695
+ * const body = withCentralRequiredChecks(existingJson, renderMainRuleset());
696
+ */
697
+ function withCentralRequiredChecks(existing, template) {
698
+ const kept = parseRules(existing).filter((rule) => rule.type !== REQUIRED_CHECKS_RULE);
699
+ const central = parseRules(template).filter((rule) => rule.type === REQUIRED_CHECKS_RULE);
700
+ return JSON.stringify({ rules: [...kept, ...central] });
701
+ }
647
702
  /**
648
703
  * The id of the newest workflow run from `gh run list --json databaseId`.
649
704
  *
@@ -894,6 +949,143 @@ function skip(detail) {
894
949
  };
895
950
  }
896
951
  //#endregion
952
+ //#region src/templates/central.ts
953
+ /**
954
+ * @file `moku-release` — read a central definition from this package's own files.
955
+ *
956
+ * The CLI ships in the same package as `examples/` and `rulesets/`, so a template is read
957
+ * from disk instead of being copied into a string. One file, one version, nothing to drift.
958
+ */
959
+ /** A file that only exists at the package root, used to find it from `src/` and from `dist/`. */
960
+ const ROOT_MARKER = path.join("rulesets", "main.json");
961
+ /**
962
+ * The package root: the closest parent directory that holds `rulesets/main.json`.
963
+ *
964
+ * @returns The absolute path of the package root.
965
+ * @throws {Error} When no parent directory holds the marker, which means a broken install.
966
+ * @example
967
+ * path.join(packageRoot(), "examples");
968
+ */
969
+ function packageRoot() {
970
+ let directory = path.dirname(fileURLToPath(import.meta.url));
971
+ while (!existsSync(path.join(directory, ROOT_MARKER))) {
972
+ const parent = path.dirname(directory);
973
+ if (parent === directory) throw new Error(`moku-release: ${ROOT_MARKER} not found above ${import.meta.url}`);
974
+ directory = parent;
975
+ }
976
+ return directory;
977
+ }
978
+ /**
979
+ * Read one central definition, for example `examples/package/ci.yml`.
980
+ *
981
+ * @param relativePath - The path inside the package.
982
+ * @returns The file contents.
983
+ * @example
984
+ * readCentral("rulesets/main.json");
985
+ */
986
+ function readCentral(relativePath) {
987
+ return readFileSync(path.join(packageRoot(), relativePath), "utf8");
988
+ }
989
+ //#endregion
990
+ //#region src/templates/ci.ts
991
+ /**
992
+ * @file `moku-release` — the `.github/workflows/ci.yml` template.
993
+ *
994
+ * Read from this package's own `examples/package/ci.yml`. It is a thin caller: the
995
+ * whole check matrix lives once in the central reusable workflow, so a pipeline change is
996
+ * one PR there instead of one per repository. Two details are load-bearing and must not be
997
+ * "tidied": the caller job id is `ci` (GitHub prefixes the reused jobs with it, so the
998
+ * required checks are `ci / lint`, `ci / types`, `ci / test`, `ci / build`), and there is
999
+ * deliberately NO concurrency block — the called workflow already groups by
1000
+ * `github.workflow`, and a caller group with the same value deadlocks against its own child.
1001
+ */
1002
+ /** Repo-relative path this template is written to. */
1003
+ const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
1004
+ /** The reusable workflow ref `doctor` recognizes a migrated `ci.yml` by. */
1005
+ const CI_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-ci.yml@v1";
1006
+ /** The `ci.yml` body, read from `examples/package/ci.yml`. */
1007
+ const CI_WORKFLOW = readCentral("examples/package/ci.yml");
1008
+ //#endregion
1009
+ //#region src/templates/publish.ts
1010
+ /**
1011
+ * @file `moku-release` — the `.github/workflows/publish.yml` template.
1012
+ *
1013
+ * Read from this package's own `examples/package/publish.yml`. The FILE NAME is the
1014
+ * contract: npm Trusted Publishing validates the CALLING workflow's filename, so this file
1015
+ * must stay `publish.yml` even though the publish itself happens inside the central
1016
+ * reusable workflow. There is no `NPM_TOKEN` anywhere — `id-token: write` is the whole
1017
+ * credential.
1018
+ */
1019
+ /** Repo-relative path this template is written to. Registered with npm — never rename it. */
1020
+ const PUBLISH_WORKFLOW_PATH = ".github/workflows/publish.yml";
1021
+ /**
1022
+ * The reusable workflow ref `doctor` recognizes a migrated `publish.yml` by. The
1023
+ * `publish.local-publish.yml` fallback variant calls the same ref, so it is recognized too.
1024
+ */
1025
+ const PUBLISH_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-release.yml@v1";
1026
+ /** The `publish.yml` body, read from `examples/package/publish.yml`. */
1027
+ const PUBLISH_WORKFLOW = readCentral("examples/package/publish.yml");
1028
+ //#endregion
1029
+ //#region src/templates/ruleset.ts
1030
+ /**
1031
+ * @file `moku-release` — the branch ruleset `setup` applies to `main`.
1032
+ *
1033
+ * Read from this package's own `rulesets/main.json`: PR-only main, no deletion, no
1034
+ * force-push, and the four required checks the thin `ci.yml` produces (`ci / lint`,
1035
+ * `ci / types`, `ci / test`, `ci / build`). Tags are deliberately NOT restricted — the
1036
+ * release workflow pushes `v*` tags, and a tag ruleset would block every release.
1037
+ */
1038
+ /** The ruleset payload for `gh api repos/{owner}/{repo}/rulesets --input -`, read from `rulesets/main.json`. */
1039
+ const MAIN_RULESET_JSON = readCentral("rulesets/main.json");
1040
+ //#endregion
1041
+ //#region src/lib/templates.ts
1042
+ /**
1043
+ * @file `moku-release` — the workflow template surface: what to write, where, and how to
1044
+ * tell a migrated workflow from a hand-written legacy one.
1045
+ *
1046
+ * `setup` writes what {@link workflowTemplates} lists; `doctor` reads the same list back
1047
+ * and asks {@link isThinWorkflow} whether the file on disk still calls the pinned central
1048
+ * workflow. One list, both directions — a template can never drift from its check.
1049
+ *
1050
+ * The bodies are read from this package's own `examples/package/*.yml` and
1051
+ * `rulesets/main.json`: this CLI distributes the central definitions, it does not copy them.
1052
+ */
1053
+ /** Both generated workflows, in the order `setup` writes and `doctor` reports them. */
1054
+ const workflowTemplates = [{
1055
+ path: CI_WORKFLOW_PATH,
1056
+ content: CI_WORKFLOW,
1057
+ ref: CI_WORKFLOW_REF
1058
+ }, {
1059
+ path: PUBLISH_WORKFLOW_PATH,
1060
+ content: PUBLISH_WORKFLOW,
1061
+ ref: PUBLISH_WORKFLOW_REF
1062
+ }];
1063
+ /**
1064
+ * Whether a workflow file on disk is the thin caller — i.e. it delegates to the pinned
1065
+ * central reusable workflow. A file that does not is a legacy hand-written pipeline.
1066
+ * Matching on the `@v1` ref rather than on the whole body is deliberate: the
1067
+ * `publish.local-publish.yml` fallback variant calls the same ref and must also pass.
1068
+ *
1069
+ * @param content - The file contents read from disk.
1070
+ * @param template - The template the file is expected to match.
1071
+ * @returns `true` when the file calls the pinned reusable workflow.
1072
+ * @example
1073
+ * isThinWorkflow(onDisk, workflowTemplates[0]);
1074
+ */
1075
+ function isThinWorkflow(content, template) {
1076
+ return content.includes(template.ref);
1077
+ }
1078
+ /**
1079
+ * The branch-ruleset payload `gh api … --input -` reads — the central definition verbatim.
1080
+ *
1081
+ * @returns The ruleset JSON.
1082
+ * @example
1083
+ * const body = renderMainRuleset();
1084
+ */
1085
+ function renderMainRuleset() {
1086
+ return MAIN_RULESET_JSON;
1087
+ }
1088
+ //#endregion
897
1089
  //#region src/checks/branch-ruleset.ts
898
1090
  /**
899
1091
  * @file `moku-release` — check: a branch ruleset protects the default branch.
@@ -901,15 +1093,40 @@ function skip(detail) {
901
1093
  * Advisory. The release pipeline works without it; what it buys is that main can only
902
1094
  * move through a PR, so a release always describes a reviewed state.
903
1095
  */
904
- /** Verifies `gh api repos/{owner}/{repo}/rulesets` lists an active branch ruleset. */
1096
+ /**
1097
+ * Find the first active branch ruleset whose required checks are not the central ones. A
1098
+ * legacy ruleset requires `lint`; the thin caller reports `ci / lint`, so no PR can merge.
1099
+ *
1100
+ * @param ctx - The injected ports.
1101
+ * @param ownerRepo - The `owner/repo` slug.
1102
+ * @param listing - Output of `gh api repos/{owner}/{repo}/rulesets`.
1103
+ * @returns The stale ruleset, or `undefined` when every ruleset is on the central checks.
1104
+ * @example
1105
+ * const stale = await findStaleRuleset(ctx, "moku-labs/system", listing.stdout);
1106
+ */
1107
+ async function findStaleRuleset(ctx, ownerRepo, listing) {
1108
+ const central = new Set(requiredCheckContexts(renderMainRuleset()));
1109
+ for (const id of activeBranchRulesetIds(listing)) {
1110
+ const detail = await ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets/${id}`]);
1111
+ if (detail.code !== 0) continue;
1112
+ const legacy = requiredCheckContexts(detail.stdout).filter((context) => !central.has(context));
1113
+ if (legacy.length > 0) return {
1114
+ id,
1115
+ body: detail.stdout,
1116
+ legacy
1117
+ };
1118
+ }
1119
+ }
1120
+ /** Verifies `gh api repos/{owner}/{repo}/rulesets` lists an active, up-to-date branch ruleset. */
905
1121
  const branchRulesetCheck = {
906
1122
  id: "branch-ruleset",
907
1123
  title: "branch ruleset on main",
908
1124
  /**
909
- * List the repository's rulesets and look for an active branch-targeting one.
1125
+ * List the repository's rulesets, look for an active branch-targeting one, and make sure
1126
+ * it does not require legacy check names.
910
1127
  *
911
1128
  * @param ctx - The injected ports.
912
- * @returns Pass when one exists, warn when none does.
1129
+ * @returns Pass when one exists on the central checks, warn otherwise.
913
1130
  * @example
914
1131
  * await branchRulesetCheck.run(ctx);
915
1132
  */
@@ -921,6 +1138,8 @@ const branchRulesetCheck = {
921
1138
  const rulesets = await ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets`]);
922
1139
  if (rulesets.code !== 0) return skip("`gh api` could not read the rulesets");
923
1140
  if (!hasMainBranchRuleset(rulesets.stdout)) return warn("main has no branch ruleset", "moku-release setup");
1141
+ const stale = await findStaleRuleset(ctx, ownerRepo, rulesets.stdout);
1142
+ if (stale !== void 0) return warn(`ruleset requires legacy checks: ${stale.legacy.join(", ")}`, "moku-release setup");
924
1143
  return pass("active branch ruleset present");
925
1144
  }
926
1145
  };
@@ -1216,6 +1435,59 @@ const packageContractCheck = {
1216
1435
  }
1217
1436
  };
1218
1437
  //#endregion
1438
+ //#region src/checks/preview-deps.ts
1439
+ /**
1440
+ * @file `moku-release` — check: no dependency points at a pkg.pr.new preview build.
1441
+ *
1442
+ * A preview URL is for testing an unreleased upstream change. It has no provenance and dies
1443
+ * with its pull request, so a release must never be cut with one in the manifest. The `lint`
1444
+ * job of package-ci.yml refuses the same thing on every PR; this is the local view of it.
1445
+ */
1446
+ /** Host every pkg.pr.new install URL contains. */
1447
+ const PREVIEW_HOST = "pkg.pr.new";
1448
+ /** The manifest tables a preview URL can sit in. */
1449
+ const DEPENDENCY_FIELDS = [
1450
+ "dependencies",
1451
+ "devDependencies",
1452
+ "peerDependencies",
1453
+ "optionalDependencies"
1454
+ ];
1455
+ /**
1456
+ * Names of every dependency whose range is a pkg.pr.new URL.
1457
+ *
1458
+ * @param manifest - The manifest to scan.
1459
+ * @returns The dependency names, in manifest order.
1460
+ * @example
1461
+ * previewDependencies({ dependencies: { "@moku-labs/core": "https://pkg.pr.new/@moku-labs/core@42" } });
1462
+ */
1463
+ function previewDependencies(manifest) {
1464
+ return DEPENDENCY_FIELDS.flatMap((field) => {
1465
+ const table = manifest[field];
1466
+ if (typeof table !== "object" || table === null) return [];
1467
+ return Object.entries(table).filter(([, range]) => typeof range === "string" && range.includes(PREVIEW_HOST)).map(([name]) => name);
1468
+ });
1469
+ }
1470
+ /** Verifies no dependency is installed from a pkg.pr.new preview. */
1471
+ const previewDepsCheck = {
1472
+ id: "preview-deps",
1473
+ title: "no pkg.pr.new preview dependencies",
1474
+ /**
1475
+ * Scan the dependency tables for preview URLs.
1476
+ *
1477
+ * @param ctx - The injected ports.
1478
+ * @returns Pass when none is found, otherwise every preview dependency by name.
1479
+ * @example
1480
+ * await previewDepsCheck.run(ctx);
1481
+ */
1482
+ async run(ctx) {
1483
+ const manifest = await readManifest(ctx.files);
1484
+ if (!manifest) return skip("package.json is missing or malformed");
1485
+ const names = previewDependencies(manifest);
1486
+ if (names.length > 0) return fail(`preview build of ${names.join(", ")}`, `release upstream, then: bun add ${names.join(" ")}`);
1487
+ return pass("every dependency comes from the registry");
1488
+ }
1489
+ };
1490
+ //#endregion
1219
1491
  //#region src/checks/repository-url.ts
1220
1492
  /**
1221
1493
  * @file `moku-release` — check: `repository.url` points at the real origin.
@@ -1296,64 +1568,6 @@ const tagSyncCheck = {
1296
1568
  }
1297
1569
  };
1298
1570
  //#endregion
1299
- //#region src/templates/central.ts
1300
- /**
1301
- * @file `moku-release` — read a central definition from this package's own files.
1302
- *
1303
- * The CLI ships in the same package as `examples/` and `rulesets/`, so a template is read
1304
- * from disk instead of being copied into a string. One file, one version, nothing to drift.
1305
- */
1306
- /** A file that only exists at the package root, used to find it from `src/` and from `dist/`. */
1307
- const ROOT_MARKER = path.join("rulesets", "main.json");
1308
- /**
1309
- * The package root: the closest parent directory that holds `rulesets/main.json`.
1310
- *
1311
- * @returns The absolute path of the package root.
1312
- * @throws {Error} When no parent directory holds the marker, which means a broken install.
1313
- * @example
1314
- * path.join(packageRoot(), "examples");
1315
- */
1316
- function packageRoot() {
1317
- let directory = path.dirname(fileURLToPath(import.meta.url));
1318
- while (!existsSync(path.join(directory, ROOT_MARKER))) {
1319
- const parent = path.dirname(directory);
1320
- if (parent === directory) throw new Error(`moku-release: ${ROOT_MARKER} not found above ${import.meta.url}`);
1321
- directory = parent;
1322
- }
1323
- return directory;
1324
- }
1325
- /**
1326
- * Read one central definition, for example `examples/package/ci.yml`.
1327
- *
1328
- * @param relativePath - The path inside the package.
1329
- * @returns The file contents.
1330
- * @example
1331
- * readCentral("rulesets/main.json");
1332
- */
1333
- function readCentral(relativePath) {
1334
- return readFileSync(path.join(packageRoot(), relativePath), "utf8");
1335
- }
1336
- //#endregion
1337
- //#region src/templates/publish.ts
1338
- /**
1339
- * @file `moku-release` — the `.github/workflows/publish.yml` template.
1340
- *
1341
- * Read from this package's own `examples/package/publish.yml`. The FILE NAME is the
1342
- * contract: npm Trusted Publishing validates the CALLING workflow's filename, so this file
1343
- * must stay `publish.yml` even though the publish itself happens inside the central
1344
- * reusable workflow. There is no `NPM_TOKEN` anywhere — `id-token: write` is the whole
1345
- * credential.
1346
- */
1347
- /** Repo-relative path this template is written to. Registered with npm — never rename it. */
1348
- const PUBLISH_WORKFLOW_PATH = ".github/workflows/publish.yml";
1349
- /**
1350
- * The reusable workflow ref `doctor` recognizes a migrated `publish.yml` by. The
1351
- * `publish.local-publish.yml` fallback variant calls the same ref, so it is recognized too.
1352
- */
1353
- const PUBLISH_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-release.yml@v1";
1354
- /** The `publish.yml` body, read from `examples/package/publish.yml`. */
1355
- const PUBLISH_WORKFLOW = readCentral("examples/package/publish.yml");
1356
- //#endregion
1357
1571
  //#region src/checks/trusted-publisher.ts
1358
1572
  /**
1359
1573
  * @file `moku-release` — check: npm knows this repo's `publish.yml` as a trusted publisher.
@@ -1387,6 +1601,18 @@ function isUnauthorized(output) {
1387
1601
  return /E401|ENEEDAUTH|401 Unauthorized/i.test(output);
1388
1602
  }
1389
1603
  /**
1604
+ * Whether npm refused the listing because the account needs a one-time password. Output is
1605
+ * captured here, so npm cannot prompt: the answer is unknown, not "missing".
1606
+ *
1607
+ * @param output - The combined stdout/stderr npm produced.
1608
+ * @returns `true` when npm asked for an OTP.
1609
+ * @example
1610
+ * isOtpRequired("npm error code EOTP");
1611
+ */
1612
+ function isOtpRequired(output) {
1613
+ return /EOTP|one-time password/i.test(output);
1614
+ }
1615
+ /**
1390
1616
  * The exact registration command for this package and repository.
1391
1617
  *
1392
1618
  * @param name - The package name.
@@ -1396,7 +1622,7 @@ function isUnauthorized(output) {
1396
1622
  * trustCommand("@moku-labs/common", "moku-labs/common");
1397
1623
  */
1398
1624
  function trustCommand(name, ownerRepo) {
1399
- return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$1} --repo ${ownerRepo} --yes`;
1625
+ return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$1} --repo ${ownerRepo} --allow-publish --yes`;
1400
1626
  }
1401
1627
  /** Verifies a trusted publisher is registered for the package. */
1402
1628
  const trustedPublisherCheck = {
@@ -1423,90 +1649,12 @@ const trustedPublisherCheck = {
1423
1649
  ]);
1424
1650
  if (isUnknownCommand(`${listing.stdout}${listing.stderr}`)) return warn("this npm has no `trust` command", "upgrade npm");
1425
1651
  if (isUnauthorized(`${listing.stdout}${listing.stderr}`)) return skip("cannot list trusted publishers without `npm login`");
1652
+ if (isOtpRequired(`${listing.stdout}${listing.stderr}`)) return warn("npm asks for an OTP, cannot verify from here", `npm trust list ${manifest.name}`);
1426
1653
  if (listing.code !== 0 || !listing.stdout.includes(PUBLISH_WORKFLOW_FILE$1)) return fail("no trusted publisher registered", trustCommand(manifest.name, ownerRepo));
1427
1654
  return pass(`${ownerRepo} · ${PUBLISH_WORKFLOW_FILE$1}`);
1428
1655
  }
1429
1656
  };
1430
1657
  //#endregion
1431
- //#region src/templates/ci.ts
1432
- /**
1433
- * @file `moku-release` — the `.github/workflows/ci.yml` template.
1434
- *
1435
- * Read from this package's own `examples/package/ci.yml`. It is a thin caller: the
1436
- * whole check matrix lives once in the central reusable workflow, so a pipeline change is
1437
- * one PR there instead of one per repository. Two details are load-bearing and must not be
1438
- * "tidied": the caller job id is `ci` (GitHub prefixes the reused jobs with it, so the
1439
- * required checks are `ci / lint`, `ci / types`, `ci / test`, `ci / build`), and there is
1440
- * deliberately NO concurrency block — the called workflow already groups by
1441
- * `github.workflow`, and a caller group with the same value deadlocks against its own child.
1442
- */
1443
- /** Repo-relative path this template is written to. */
1444
- const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
1445
- /** The reusable workflow ref `doctor` recognizes a migrated `ci.yml` by. */
1446
- const CI_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-ci.yml@v1";
1447
- /** The `ci.yml` body, read from `examples/package/ci.yml`. */
1448
- const CI_WORKFLOW = readCentral("examples/package/ci.yml");
1449
- //#endregion
1450
- //#region src/templates/ruleset.ts
1451
- /**
1452
- * @file `moku-release` — the branch ruleset `setup` applies to `main`.
1453
- *
1454
- * Read from this package's own `rulesets/main.json`: PR-only main, no deletion, no
1455
- * force-push, and the four required checks the thin `ci.yml` produces (`ci / lint`,
1456
- * `ci / types`, `ci / test`, `ci / build`). Tags are deliberately NOT restricted — the
1457
- * release workflow pushes `v*` tags, and a tag ruleset would block every release.
1458
- */
1459
- /** The ruleset payload for `gh api repos/{owner}/{repo}/rulesets --input -`, read from `rulesets/main.json`. */
1460
- const MAIN_RULESET_JSON = readCentral("rulesets/main.json");
1461
- //#endregion
1462
- //#region src/lib/templates.ts
1463
- /**
1464
- * @file `moku-release` — the workflow template surface: what to write, where, and how to
1465
- * tell a migrated workflow from a hand-written legacy one.
1466
- *
1467
- * `setup` writes what {@link workflowTemplates} lists; `doctor` reads the same list back
1468
- * and asks {@link isThinWorkflow} whether the file on disk still calls the pinned central
1469
- * workflow. One list, both directions — a template can never drift from its check.
1470
- *
1471
- * The bodies are read from this package's own `examples/package/*.yml` and
1472
- * `rulesets/main.json`: this CLI distributes the central definitions, it does not copy them.
1473
- */
1474
- /** Both generated workflows, in the order `setup` writes and `doctor` reports them. */
1475
- const workflowTemplates = [{
1476
- path: CI_WORKFLOW_PATH,
1477
- content: CI_WORKFLOW,
1478
- ref: CI_WORKFLOW_REF
1479
- }, {
1480
- path: PUBLISH_WORKFLOW_PATH,
1481
- content: PUBLISH_WORKFLOW,
1482
- ref: PUBLISH_WORKFLOW_REF
1483
- }];
1484
- /**
1485
- * Whether a workflow file on disk is the thin caller — i.e. it delegates to the pinned
1486
- * central reusable workflow. A file that does not is a legacy hand-written pipeline.
1487
- * Matching on the `@v1` ref rather than on the whole body is deliberate: the
1488
- * `publish.local-publish.yml` fallback variant calls the same ref and must also pass.
1489
- *
1490
- * @param content - The file contents read from disk.
1491
- * @param template - The template the file is expected to match.
1492
- * @returns `true` when the file calls the pinned reusable workflow.
1493
- * @example
1494
- * isThinWorkflow(onDisk, workflowTemplates[0]);
1495
- */
1496
- function isThinWorkflow(content, template) {
1497
- return content.includes(template.ref);
1498
- }
1499
- /**
1500
- * The branch-ruleset payload `gh api … --input -` reads — the central definition verbatim.
1501
- *
1502
- * @returns The ruleset JSON.
1503
- * @example
1504
- * const body = renderMainRuleset();
1505
- */
1506
- function renderMainRuleset() {
1507
- return MAIN_RULESET_JSON;
1508
- }
1509
- //#endregion
1510
1658
  //#region src/checks/workflows.ts
1511
1659
  /**
1512
1660
  * @file `moku-release` — check: both workflows exist and are the thin callers.
@@ -1576,6 +1724,7 @@ const allChecks = [
1576
1724
  npmAuthCheck,
1577
1725
  npmVersionCheck,
1578
1726
  packageContractCheck,
1727
+ previewDepsCheck,
1579
1728
  repositoryUrlCheck,
1580
1729
  workflowsCheck,
1581
1730
  npmPackageCheck,
@@ -1671,6 +1820,11 @@ async function evaluate(check, ctx) {
1671
1820
  async function runDoctor(options) {
1672
1821
  const { ctx, ui, json = false, checks = allChecks } = options;
1673
1822
  const entries = [];
1823
+ await ctx.exec.capture("git", [
1824
+ "fetch",
1825
+ "--tags",
1826
+ "--prune"
1827
+ ]);
1674
1828
  for (const check of checks) entries.push(await evaluate(check, ctx));
1675
1829
  const failed = entries.some((entry) => entry.status === "fail");
1676
1830
  if (json) {
@@ -1848,11 +2002,6 @@ async function runRelease(options) {
1848
2002
  ui.error("package.json is missing, malformed, or has no `name`");
1849
2003
  return 1;
1850
2004
  }
1851
- await ctx.exec.capture("git", [
1852
- "fetch",
1853
- "--tags",
1854
- "--prune"
1855
- ]);
1856
2005
  if (!await preflight(ctx, ui)) return 1;
1857
2006
  const declared = repositoryUrlOf(manifest);
1858
2007
  const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
@@ -2122,6 +2271,21 @@ async function applyBranchRuleset(setup, ownerRepo) {
2122
2271
  setup.ui.check(true, result.detail);
2123
2272
  return;
2124
2273
  }
2274
+ const listing = await setup.ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets`]);
2275
+ const stale = await findStaleRuleset(setup.ctx, ownerRepo, listing.stdout);
2276
+ if (stale !== void 0) {
2277
+ if (deferred(setup, `move ruleset ${stale.id} on ${ownerRepo} to the ci / … checks`)) return;
2278
+ const updated = await setup.ctx.exec.capture("gh", [
2279
+ "api",
2280
+ `repos/${ownerRepo}/rulesets/${stale.id}`,
2281
+ "--method",
2282
+ "PUT",
2283
+ "--input",
2284
+ "-"
2285
+ ], { input: withCentralRequiredChecks(stale.body, renderMainRuleset()) });
2286
+ setup.ui.check(updated.code === 0, "branch ruleset updated", updated.stderr.trim() || void 0);
2287
+ return;
2288
+ }
2125
2289
  if (deferred(setup, `create the PR-only branch ruleset on ${ownerRepo}`)) return;
2126
2290
  const created = await setup.ctx.exec.capture("gh", [
2127
2291
  "api",
@@ -2181,7 +2345,7 @@ async function runSetup(options) {
2181
2345
  * dispatcher.
2182
2346
  *
2183
2347
  * The grammar is deliberately tiny: one positional (a command name, or a semver bump that
2184
- * implies the `release` command) plus three flags. Anything unrecognized resolves to
2348
+ * implies the `release` command) plus four flags. Anything unrecognized resolves to
2185
2349
  * `help` with an `error` set — the CLI never guesses what an operator meant.
2186
2350
  */
2187
2351
  /** The semver bumps `moku-release <type>` accepts, in menu order. */
@@ -2191,6 +2355,13 @@ const RELEASE_TYPES = [
2191
2355
  "major",
2192
2356
  "prerelease"
2193
2357
  ];
2358
+ /** Every flag the CLI accepts, besides `--help`. */
2359
+ const KNOWN_FLAGS = /* @__PURE__ */ new Set([
2360
+ "--json",
2361
+ "--dry-run",
2362
+ "--yes",
2363
+ "-y"
2364
+ ]);
2194
2365
  /**
2195
2366
  * Whether a positional argument is one of the semver bumps.
2196
2367
  *
@@ -2215,6 +2386,7 @@ function rejected(error) {
2215
2386
  command: "help",
2216
2387
  json: false,
2217
2388
  dryRun: false,
2389
+ yes: false,
2218
2390
  error
2219
2391
  };
2220
2392
  }
@@ -2225,7 +2397,7 @@ function rejected(error) {
2225
2397
  * @returns What to run, and with which flags.
2226
2398
  * @example
2227
2399
  * parseArgv(["patch", "--dry-run"]);
2228
- * // { command: "release", releaseType: "patch", json: false, dryRun: true }
2400
+ * // { command: "release", releaseType: "patch", json: false, dryRun: true, yes: false }
2229
2401
  */
2230
2402
  function parseArgv(argv) {
2231
2403
  const flags = argv.filter((argument) => argument.startsWith("-"));
@@ -2233,34 +2405,40 @@ function parseArgv(argv) {
2233
2405
  if (flags.includes("--help") || flags.includes("-h")) return {
2234
2406
  command: "help",
2235
2407
  json: false,
2236
- dryRun: false
2408
+ dryRun: false,
2409
+ yes: false
2237
2410
  };
2238
- const unknownFlag = flags.find((flag) => flag !== "--json" && flag !== "--dry-run");
2411
+ const unknownFlag = flags.find((flag) => !KNOWN_FLAGS.has(flag));
2239
2412
  if (unknownFlag) return rejected(`unknown flag \`${unknownFlag}\``);
2240
2413
  if (positionals.length > 1) return rejected(`unexpected argument \`${positionals[1]}\``);
2241
2414
  const json = flags.includes("--json");
2242
2415
  const dryRun = flags.includes("--dry-run");
2416
+ const yes = flags.includes("--yes") || flags.includes("-y");
2243
2417
  const [first] = positionals;
2244
2418
  if (first === void 0) return {
2245
2419
  command: "help",
2246
2420
  json,
2247
- dryRun
2421
+ dryRun,
2422
+ yes
2248
2423
  };
2249
2424
  if (first === "doctor" || first === "setup") return {
2250
2425
  command: first,
2251
2426
  json,
2252
- dryRun
2427
+ dryRun,
2428
+ yes
2253
2429
  };
2254
2430
  if (first === "help") return {
2255
2431
  command: "help",
2256
2432
  json,
2257
- dryRun
2433
+ dryRun,
2434
+ yes
2258
2435
  };
2259
2436
  if (isReleaseType(first)) return {
2260
2437
  command: "release",
2261
2438
  releaseType: first,
2262
2439
  json,
2263
- dryRun
2440
+ dryRun,
2441
+ yes
2264
2442
  };
2265
2443
  return rejected(`unknown command \`${first}\``);
2266
2444
  }
@@ -2445,12 +2623,32 @@ const USAGE = [
2445
2623
  " moku-release doctor [--json] read-only diagnosis of the release setup",
2446
2624
  ` moku-release <${RELEASE_TYPES.join("|")}>`,
2447
2625
  "",
2448
- " --dry-run print every action, mutate nothing",
2626
+ " --dry-run print every action, mutate nothing, ask nothing",
2627
+ " --yes, -y answer every setup confirmation with yes",
2449
2628
  "",
2450
2629
  " Two steps are yours alone — this CLI never handles a credential:",
2451
2630
  " gh auth login",
2452
2631
  " npm login"
2453
2632
  ].join("\n");
2633
+ /** Prompts that never read stdin: every confirmation is a yes, every menu its first entry. */
2634
+ const ASSENTING_PROMPTS = {
2635
+ /**
2636
+ * Answer a confirmation with yes.
2637
+ *
2638
+ * @returns Always `true`.
2639
+ * @example
2640
+ * await ASSENTING_PROMPTS.confirm("Replace ci.yml?"); // true
2641
+ */
2642
+ confirm: async () => true,
2643
+ /**
2644
+ * Pick the first entry of a menu.
2645
+ *
2646
+ * @returns Always `0`.
2647
+ * @example
2648
+ * await ASSENTING_PROMPTS.select("Bump?", ["patch", "minor"]); // 0
2649
+ */
2650
+ select: async () => 0
2651
+ };
2454
2652
  /**
2455
2653
  * Dispatch a parsed invocation to its command.
2456
2654
  *
@@ -2476,7 +2674,7 @@ async function dispatch(parsed, ctx, ui) {
2476
2674
  if (parsed.command === "setup") return runSetup({
2477
2675
  ctx,
2478
2676
  ui,
2479
- prompts: createBrandPrompts(),
2677
+ prompts: parsed.yes || parsed.dryRun ? ASSENTING_PROMPTS : createBrandPrompts(),
2480
2678
  dryRun: parsed.dryRun
2481
2679
  });
2482
2680
  if (parsed.command === "release" && parsed.releaseType !== void 0) return runRelease({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moku-labs/ci",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
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": [