@moku-labs/ci 1.1.3 → 1.2.2

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 +425 -165
  3. package/package.json +4 -4
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 | Changes nothing in the project; it only runs `git fetch --tags` first. 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
  *
@@ -719,16 +774,34 @@ function parseManifest(text) {
719
774
  async function readManifest(files) {
720
775
  return parseManifest(await files.read(MANIFEST_PATH));
721
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
+ }
722
790
  /**
723
791
  * Render a manifest the way npm itself writes one: two-space JSON with a trailing newline.
792
+ * A source file that was ASCII-only stays ASCII-only, so an escaped dash the package chose
793
+ * does not show up as a changed line in the diff.
724
794
  *
725
795
  * @param manifest - The manifest to serialize.
796
+ * @param source - The file contents the manifest was read from, when there were any.
726
797
  * @returns The file contents to write.
727
798
  * @example
728
- * await files.write("package.json", formatManifest(manifest));
799
+ * await files.write("package.json", formatManifest(manifest, source));
729
800
  */
730
- function formatManifest(manifest) {
731
- return `${JSON.stringify(manifest, void 0, 2)}\n`;
801
+ function formatManifest(manifest, source) {
802
+ const text = `${JSON.stringify(manifest, void 0, 2)}\n`;
803
+ if (source === void 0 || !isAsciiOnly(source)) return text;
804
+ return text.replaceAll(new RegExp(NON_ASCII, "g"), (character) => String.raw`\u${character.codePointAt(0)?.toString(16).padStart(4, "0")}`);
732
805
  }
733
806
  /**
734
807
  * The repository URL a manifest declares, in either the object or shorthand string form.
@@ -894,6 +967,143 @@ function skip(detail) {
894
967
  };
895
968
  }
896
969
  //#endregion
970
+ //#region src/templates/central.ts
971
+ /**
972
+ * @file `moku-release` — read a central definition from this package's own files.
973
+ *
974
+ * The CLI ships in the same package as `examples/` and `rulesets/`, so a template is read
975
+ * from disk instead of being copied into a string. One file, one version, nothing to drift.
976
+ */
977
+ /** A file that only exists at the package root, used to find it from `src/` and from `dist/`. */
978
+ const ROOT_MARKER = path.join("rulesets", "main.json");
979
+ /**
980
+ * The package root: the closest parent directory that holds `rulesets/main.json`.
981
+ *
982
+ * @returns The absolute path of the package root.
983
+ * @throws {Error} When no parent directory holds the marker, which means a broken install.
984
+ * @example
985
+ * path.join(packageRoot(), "examples");
986
+ */
987
+ function packageRoot() {
988
+ let directory = path.dirname(fileURLToPath(import.meta.url));
989
+ while (!existsSync(path.join(directory, ROOT_MARKER))) {
990
+ const parent = path.dirname(directory);
991
+ if (parent === directory) throw new Error(`moku-release: ${ROOT_MARKER} not found above ${import.meta.url}`);
992
+ directory = parent;
993
+ }
994
+ return directory;
995
+ }
996
+ /**
997
+ * Read one central definition, for example `examples/package/ci.yml`.
998
+ *
999
+ * @param relativePath - The path inside the package.
1000
+ * @returns The file contents.
1001
+ * @example
1002
+ * readCentral("rulesets/main.json");
1003
+ */
1004
+ function readCentral(relativePath) {
1005
+ return readFileSync(path.join(packageRoot(), relativePath), "utf8");
1006
+ }
1007
+ //#endregion
1008
+ //#region src/templates/ci.ts
1009
+ /**
1010
+ * @file `moku-release` — the `.github/workflows/ci.yml` template.
1011
+ *
1012
+ * Read from this package's own `examples/package/ci.yml`. It is a thin caller: the
1013
+ * whole check matrix lives once in the central reusable workflow, so a pipeline change is
1014
+ * one PR there instead of one per repository. Two details are load-bearing and must not be
1015
+ * "tidied": the caller job id is `ci` (GitHub prefixes the reused jobs with it, so the
1016
+ * required checks are `ci / lint`, `ci / types`, `ci / test`, `ci / build`), and there is
1017
+ * deliberately NO concurrency block — the called workflow already groups by
1018
+ * `github.workflow`, and a caller group with the same value deadlocks against its own child.
1019
+ */
1020
+ /** Repo-relative path this template is written to. */
1021
+ const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
1022
+ /** The reusable workflow ref `doctor` recognizes a migrated `ci.yml` by. */
1023
+ const CI_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-ci.yml@v1";
1024
+ /** The `ci.yml` body, read from `examples/package/ci.yml`. */
1025
+ const CI_WORKFLOW = readCentral("examples/package/ci.yml");
1026
+ //#endregion
1027
+ //#region src/templates/publish.ts
1028
+ /**
1029
+ * @file `moku-release` — the `.github/workflows/publish.yml` template.
1030
+ *
1031
+ * Read from this package's own `examples/package/publish.yml`. The FILE NAME is the
1032
+ * contract: npm Trusted Publishing validates the CALLING workflow's filename, so this file
1033
+ * must stay `publish.yml` even though the publish itself happens inside the central
1034
+ * reusable workflow. There is no `NPM_TOKEN` anywhere — `id-token: write` is the whole
1035
+ * credential.
1036
+ */
1037
+ /** Repo-relative path this template is written to. Registered with npm — never rename it. */
1038
+ const PUBLISH_WORKFLOW_PATH = ".github/workflows/publish.yml";
1039
+ /**
1040
+ * The reusable workflow ref `doctor` recognizes a migrated `publish.yml` by. The
1041
+ * `publish.local-publish.yml` fallback variant calls the same ref, so it is recognized too.
1042
+ */
1043
+ const PUBLISH_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-release.yml@v1";
1044
+ /** The `publish.yml` body, read from `examples/package/publish.yml`. */
1045
+ const PUBLISH_WORKFLOW = readCentral("examples/package/publish.yml");
1046
+ //#endregion
1047
+ //#region src/templates/ruleset.ts
1048
+ /**
1049
+ * @file `moku-release` — the branch ruleset `setup` applies to `main`.
1050
+ *
1051
+ * Read from this package's own `rulesets/main.json`: PR-only main, no deletion, no
1052
+ * force-push, and the four required checks the thin `ci.yml` produces (`ci / lint`,
1053
+ * `ci / types`, `ci / test`, `ci / build`). Tags are deliberately NOT restricted — the
1054
+ * release workflow pushes `v*` tags, and a tag ruleset would block every release.
1055
+ */
1056
+ /** The ruleset payload for `gh api repos/{owner}/{repo}/rulesets --input -`, read from `rulesets/main.json`. */
1057
+ const MAIN_RULESET_JSON = readCentral("rulesets/main.json");
1058
+ //#endregion
1059
+ //#region src/lib/templates.ts
1060
+ /**
1061
+ * @file `moku-release` — the workflow template surface: what to write, where, and how to
1062
+ * tell a migrated workflow from a hand-written legacy one.
1063
+ *
1064
+ * `setup` writes what {@link workflowTemplates} lists; `doctor` reads the same list back
1065
+ * and asks {@link isThinWorkflow} whether the file on disk still calls the pinned central
1066
+ * workflow. One list, both directions — a template can never drift from its check.
1067
+ *
1068
+ * The bodies are read from this package's own `examples/package/*.yml` and
1069
+ * `rulesets/main.json`: this CLI distributes the central definitions, it does not copy them.
1070
+ */
1071
+ /** Both generated workflows, in the order `setup` writes and `doctor` reports them. */
1072
+ const workflowTemplates = [{
1073
+ path: CI_WORKFLOW_PATH,
1074
+ content: CI_WORKFLOW,
1075
+ ref: CI_WORKFLOW_REF
1076
+ }, {
1077
+ path: PUBLISH_WORKFLOW_PATH,
1078
+ content: PUBLISH_WORKFLOW,
1079
+ ref: PUBLISH_WORKFLOW_REF
1080
+ }];
1081
+ /**
1082
+ * Whether a workflow file on disk is the thin caller — i.e. it delegates to the pinned
1083
+ * central reusable workflow. A file that does not is a legacy hand-written pipeline.
1084
+ * Matching on the `@v1` ref rather than on the whole body is deliberate: the
1085
+ * `publish.local-publish.yml` fallback variant calls the same ref and must also pass.
1086
+ *
1087
+ * @param content - The file contents read from disk.
1088
+ * @param template - The template the file is expected to match.
1089
+ * @returns `true` when the file calls the pinned reusable workflow.
1090
+ * @example
1091
+ * isThinWorkflow(onDisk, workflowTemplates[0]);
1092
+ */
1093
+ function isThinWorkflow(content, template) {
1094
+ return content.includes(template.ref);
1095
+ }
1096
+ /**
1097
+ * The branch-ruleset payload `gh api … --input -` reads — the central definition verbatim.
1098
+ *
1099
+ * @returns The ruleset JSON.
1100
+ * @example
1101
+ * const body = renderMainRuleset();
1102
+ */
1103
+ function renderMainRuleset() {
1104
+ return MAIN_RULESET_JSON;
1105
+ }
1106
+ //#endregion
897
1107
  //#region src/checks/branch-ruleset.ts
898
1108
  /**
899
1109
  * @file `moku-release` — check: a branch ruleset protects the default branch.
@@ -901,15 +1111,40 @@ function skip(detail) {
901
1111
  * Advisory. The release pipeline works without it; what it buys is that main can only
902
1112
  * move through a PR, so a release always describes a reviewed state.
903
1113
  */
904
- /** Verifies `gh api repos/{owner}/{repo}/rulesets` lists an active branch ruleset. */
1114
+ /**
1115
+ * Find the first active branch ruleset whose required checks are not the central ones. A
1116
+ * legacy ruleset requires `lint`; the thin caller reports `ci / lint`, so no PR can merge.
1117
+ *
1118
+ * @param ctx - The injected ports.
1119
+ * @param ownerRepo - The `owner/repo` slug.
1120
+ * @param listing - Output of `gh api repos/{owner}/{repo}/rulesets`.
1121
+ * @returns The stale ruleset, or `undefined` when every ruleset is on the central checks.
1122
+ * @example
1123
+ * const stale = await findStaleRuleset(ctx, "moku-labs/system", listing.stdout);
1124
+ */
1125
+ async function findStaleRuleset(ctx, ownerRepo, listing) {
1126
+ const central = new Set(requiredCheckContexts(renderMainRuleset()));
1127
+ for (const id of activeBranchRulesetIds(listing)) {
1128
+ const detail = await ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets/${id}`]);
1129
+ if (detail.code !== 0) continue;
1130
+ const legacy = requiredCheckContexts(detail.stdout).filter((context) => !central.has(context));
1131
+ if (legacy.length > 0) return {
1132
+ id,
1133
+ body: detail.stdout,
1134
+ legacy
1135
+ };
1136
+ }
1137
+ }
1138
+ /** Verifies `gh api repos/{owner}/{repo}/rulesets` lists an active, up-to-date branch ruleset. */
905
1139
  const branchRulesetCheck = {
906
1140
  id: "branch-ruleset",
907
1141
  title: "branch ruleset on main",
908
1142
  /**
909
- * List the repository's rulesets and look for an active branch-targeting one.
1143
+ * List the repository's rulesets, look for an active branch-targeting one, and make sure
1144
+ * it does not require legacy check names.
910
1145
  *
911
1146
  * @param ctx - The injected ports.
912
- * @returns Pass when one exists, warn when none does.
1147
+ * @returns Pass when one exists on the central checks, warn otherwise.
913
1148
  * @example
914
1149
  * await branchRulesetCheck.run(ctx);
915
1150
  */
@@ -921,6 +1156,8 @@ const branchRulesetCheck = {
921
1156
  const rulesets = await ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets`]);
922
1157
  if (rulesets.code !== 0) return skip("`gh api` could not read the rulesets");
923
1158
  if (!hasMainBranchRuleset(rulesets.stdout)) return warn("main has no branch ruleset", "moku-release setup");
1159
+ const stale = await findStaleRuleset(ctx, ownerRepo, rulesets.stdout);
1160
+ if (stale !== void 0) return warn(`ruleset requires legacy checks: ${stale.legacy.join(", ")}`, "moku-release setup");
924
1161
  return pass("active branch ruleset present");
925
1162
  }
926
1163
  };
@@ -1216,6 +1453,59 @@ const packageContractCheck = {
1216
1453
  }
1217
1454
  };
1218
1455
  //#endregion
1456
+ //#region src/checks/preview-deps.ts
1457
+ /**
1458
+ * @file `moku-release` — check: no dependency points at a pkg.pr.new preview build.
1459
+ *
1460
+ * A preview URL is for testing an unreleased upstream change. It has no provenance and dies
1461
+ * with its pull request, so a release must never be cut with one in the manifest. The `lint`
1462
+ * job of package-ci.yml refuses the same thing on every PR; this is the local view of it.
1463
+ */
1464
+ /** Host every pkg.pr.new install URL contains. */
1465
+ const PREVIEW_HOST = "pkg.pr.new";
1466
+ /** The manifest tables a preview URL can sit in. */
1467
+ const DEPENDENCY_FIELDS = [
1468
+ "dependencies",
1469
+ "devDependencies",
1470
+ "peerDependencies",
1471
+ "optionalDependencies"
1472
+ ];
1473
+ /**
1474
+ * Names of every dependency whose range is a pkg.pr.new URL.
1475
+ *
1476
+ * @param manifest - The manifest to scan.
1477
+ * @returns The dependency names, in manifest order.
1478
+ * @example
1479
+ * previewDependencies({ dependencies: { "@moku-labs/core": "https://pkg.pr.new/@moku-labs/core@42" } });
1480
+ */
1481
+ function previewDependencies(manifest) {
1482
+ return DEPENDENCY_FIELDS.flatMap((field) => {
1483
+ const table = manifest[field];
1484
+ if (typeof table !== "object" || table === null) return [];
1485
+ return Object.entries(table).filter(([, range]) => typeof range === "string" && range.includes(PREVIEW_HOST)).map(([name]) => name);
1486
+ });
1487
+ }
1488
+ /** Verifies no dependency is installed from a pkg.pr.new preview. */
1489
+ const previewDepsCheck = {
1490
+ id: "preview-deps",
1491
+ title: "no pkg.pr.new preview dependencies",
1492
+ /**
1493
+ * Scan the dependency tables for preview URLs.
1494
+ *
1495
+ * @param ctx - The injected ports.
1496
+ * @returns Pass when none is found, otherwise every preview dependency by name.
1497
+ * @example
1498
+ * await previewDepsCheck.run(ctx);
1499
+ */
1500
+ async run(ctx) {
1501
+ const manifest = await readManifest(ctx.files);
1502
+ if (!manifest) return skip("package.json is missing or malformed");
1503
+ const names = previewDependencies(manifest);
1504
+ if (names.length > 0) return fail(`preview build of ${names.join(", ")}`, `release upstream, then: bun add ${names.join(" ")}`);
1505
+ return pass("every dependency comes from the registry");
1506
+ }
1507
+ };
1508
+ //#endregion
1219
1509
  //#region src/checks/repository-url.ts
1220
1510
  /**
1221
1511
  * @file `moku-release` — check: `repository.url` points at the real origin.
@@ -1296,64 +1586,6 @@ const tagSyncCheck = {
1296
1586
  }
1297
1587
  };
1298
1588
  //#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
1589
  //#region src/checks/trusted-publisher.ts
1358
1590
  /**
1359
1591
  * @file `moku-release` — check: npm knows this repo's `publish.yml` as a trusted publisher.
@@ -1387,6 +1619,18 @@ function isUnauthorized(output) {
1387
1619
  return /E401|ENEEDAUTH|401 Unauthorized/i.test(output);
1388
1620
  }
1389
1621
  /**
1622
+ * Whether npm refused the listing because the account needs a one-time password. Output is
1623
+ * captured here, so npm cannot prompt: the answer is unknown, not "missing".
1624
+ *
1625
+ * @param output - The combined stdout/stderr npm produced.
1626
+ * @returns `true` when npm asked for an OTP.
1627
+ * @example
1628
+ * isOtpRequired("npm error code EOTP");
1629
+ */
1630
+ function isOtpRequired(output) {
1631
+ return /EOTP|one-time password/i.test(output);
1632
+ }
1633
+ /**
1390
1634
  * The exact registration command for this package and repository.
1391
1635
  *
1392
1636
  * @param name - The package name.
@@ -1396,7 +1640,7 @@ function isUnauthorized(output) {
1396
1640
  * trustCommand("@moku-labs/common", "moku-labs/common");
1397
1641
  */
1398
1642
  function trustCommand(name, ownerRepo) {
1399
- return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$1} --repo ${ownerRepo} --yes`;
1643
+ return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$1} --repo ${ownerRepo} --allow-publish --yes`;
1400
1644
  }
1401
1645
  /** Verifies a trusted publisher is registered for the package. */
1402
1646
  const trustedPublisherCheck = {
@@ -1423,90 +1667,12 @@ const trustedPublisherCheck = {
1423
1667
  ]);
1424
1668
  if (isUnknownCommand(`${listing.stdout}${listing.stderr}`)) return warn("this npm has no `trust` command", "upgrade npm");
1425
1669
  if (isUnauthorized(`${listing.stdout}${listing.stderr}`)) return skip("cannot list trusted publishers without `npm login`");
1670
+ if (isOtpRequired(`${listing.stdout}${listing.stderr}`)) return warn("npm asks for an OTP, cannot verify from here", `npm trust list ${manifest.name}`);
1426
1671
  if (listing.code !== 0 || !listing.stdout.includes(PUBLISH_WORKFLOW_FILE$1)) return fail("no trusted publisher registered", trustCommand(manifest.name, ownerRepo));
1427
1672
  return pass(`${ownerRepo} · ${PUBLISH_WORKFLOW_FILE$1}`);
1428
1673
  }
1429
1674
  };
1430
1675
  //#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
1676
  //#region src/checks/workflows.ts
1511
1677
  /**
1512
1678
  * @file `moku-release` — check: both workflows exist and are the thin callers.
@@ -1576,6 +1742,7 @@ const allChecks = [
1576
1742
  npmAuthCheck,
1577
1743
  npmVersionCheck,
1578
1744
  packageContractCheck,
1745
+ previewDepsCheck,
1579
1746
  repositoryUrlCheck,
1580
1747
  workflowsCheck,
1581
1748
  npmPackageCheck,
@@ -1948,9 +2115,55 @@ async function ensurePrerequisites(setup) {
1948
2115
  return true;
1949
2116
  }
1950
2117
  /**
2118
+ * Whether git already holds this exact file: tracked, and with no uncommitted change.
2119
+ *
2120
+ * @param setup - The wizard state.
2121
+ * @param path - Repo-relative path of the file.
2122
+ * @returns `true` when `git checkout -- <path>` would bring the current content back.
2123
+ * @example
2124
+ * await isCommittedUnchanged(setup, ".github/workflows/ci.yml");
2125
+ */
2126
+ async function isCommittedUnchanged(setup, path) {
2127
+ if ((await setup.ctx.exec.capture("git", [
2128
+ "ls-files",
2129
+ "--error-unmatch",
2130
+ path
2131
+ ])).code !== 0) return false;
2132
+ const status = await setup.ctx.exec.capture("git", [
2133
+ "status",
2134
+ "--porcelain",
2135
+ "--",
2136
+ path
2137
+ ]);
2138
+ return status.code === 0 && status.stdout.trim() === "";
2139
+ }
2140
+ /**
2141
+ * Ask before replacing an existing workflow, and keep the original: in git when it is
2142
+ * committed and unmodified, in a `.bak` copy otherwise.
2143
+ *
2144
+ * @param setup - The wizard state.
2145
+ * @param template - The central workflow about to be written.
2146
+ * @param existing - The current contents of the file.
2147
+ * @returns `true` when the caller may write the template now.
2148
+ * @example
2149
+ * if (!(await clearExistingWorkflow(setup, template, existing))) continue;
2150
+ */
2151
+ async function clearExistingWorkflow(setup, template, existing) {
2152
+ const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
2153
+ const committed = await isCommittedUnchanged(setup, template.path);
2154
+ const safety = committed ? "git keeps the original" : "a .bak copy is kept";
2155
+ if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (${safety})?`)) {
2156
+ setup.ui.check(false, `${template.path} left unchanged`);
2157
+ return false;
2158
+ }
2159
+ if (deferred(setup, `${committed ? "replace" : "back up and replace"} ${template.path}`)) return false;
2160
+ if (!committed) await setup.ctx.files.backup(template.path);
2161
+ return true;
2162
+ }
2163
+ /**
1951
2164
  * Write the two thin workflows. A file that already calls the pinned central workflow is
1952
2165
  * left alone; a differing file is only replaced after an explicit confirm, and a `.bak`
1953
- * copy is kept.
2166
+ * copy is kept unless git already holds the original.
1954
2167
  *
1955
2168
  * @param setup - The wizard state.
1956
2169
  * @returns Nothing.
@@ -1965,15 +2178,7 @@ async function writeWorkflows(setup) {
1965
2178
  setup.ui.check(true, `${template.path} up to date`);
1966
2179
  continue;
1967
2180
  }
1968
- if (existing !== void 0) {
1969
- const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
1970
- if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (a .bak copy is kept)?`)) {
1971
- setup.ui.check(false, `${template.path} left unchanged`);
1972
- continue;
1973
- }
1974
- if (deferred(setup, `back up and replace ${template.path}`)) continue;
1975
- await setup.ctx.files.backup(template.path);
1976
- } else if (deferred(setup, `write ${template.path}`)) continue;
2181
+ if (!(existing === void 0 ? !deferred(setup, `write ${template.path}`) : await clearExistingWorkflow(setup, template, existing))) continue;
1977
2182
  await setup.ctx.files.write(template.path, template.content);
1978
2183
  setup.ui.check(true, `${template.path} written`);
1979
2184
  }
@@ -2006,7 +2211,8 @@ async function normalizeContract(setup, manifest) {
2006
2211
  return;
2007
2212
  }
2008
2213
  if (deferred(setup, `write package.json`)) return;
2009
- await setup.ctx.files.write(MANIFEST_PATH, formatManifest(next));
2214
+ const source = await setup.ctx.files.read(MANIFEST_PATH);
2215
+ await setup.ctx.files.write(MANIFEST_PATH, formatManifest(next, source));
2010
2216
  setup.ui.check(true, `${MANIFEST_PATH} normalized`);
2011
2217
  }
2012
2218
  /**
@@ -2060,6 +2266,11 @@ async function firstPublish(setup, name, version) {
2060
2266
  */
2061
2267
  async function pushVersionTag(setup, version) {
2062
2268
  setup.ui.heading("Tag");
2269
+ const latest = latestVersionTag((await setup.ctx.exec.capture("git", [...LATEST_TAG_ARGS])).stdout);
2270
+ if (latest !== void 0) {
2271
+ setup.ui.check(true, `release tags exist, latest is ${latest}`);
2272
+ return;
2273
+ }
2063
2274
  const tag = `v${version}`;
2064
2275
  if ((await setup.ctx.exec.capture("git", [
2065
2276
  "tag",
@@ -2122,6 +2333,21 @@ async function applyBranchRuleset(setup, ownerRepo) {
2122
2333
  setup.ui.check(true, result.detail);
2123
2334
  return;
2124
2335
  }
2336
+ const listing = await setup.ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets`]);
2337
+ const stale = await findStaleRuleset(setup.ctx, ownerRepo, listing.stdout);
2338
+ if (stale !== void 0) {
2339
+ if (deferred(setup, `move ruleset ${stale.id} on ${ownerRepo} to the ci / … checks`)) return;
2340
+ const updated = await setup.ctx.exec.capture("gh", [
2341
+ "api",
2342
+ `repos/${ownerRepo}/rulesets/${stale.id}`,
2343
+ "--method",
2344
+ "PUT",
2345
+ "--input",
2346
+ "-"
2347
+ ], { input: withCentralRequiredChecks(stale.body, renderMainRuleset()) });
2348
+ setup.ui.check(updated.code === 0, "branch ruleset updated", updated.stderr.trim() || void 0);
2349
+ return;
2350
+ }
2125
2351
  if (deferred(setup, `create the PR-only branch ruleset on ${ownerRepo}`)) return;
2126
2352
  const created = await setup.ctx.exec.capture("gh", [
2127
2353
  "api",
@@ -2181,7 +2407,7 @@ async function runSetup(options) {
2181
2407
  * dispatcher.
2182
2408
  *
2183
2409
  * 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
2410
+ * implies the `release` command) plus four flags. Anything unrecognized resolves to
2185
2411
  * `help` with an `error` set — the CLI never guesses what an operator meant.
2186
2412
  */
2187
2413
  /** The semver bumps `moku-release <type>` accepts, in menu order. */
@@ -2191,6 +2417,13 @@ const RELEASE_TYPES = [
2191
2417
  "major",
2192
2418
  "prerelease"
2193
2419
  ];
2420
+ /** Every flag the CLI accepts, besides `--help`. */
2421
+ const KNOWN_FLAGS = /* @__PURE__ */ new Set([
2422
+ "--json",
2423
+ "--dry-run",
2424
+ "--yes",
2425
+ "-y"
2426
+ ]);
2194
2427
  /**
2195
2428
  * Whether a positional argument is one of the semver bumps.
2196
2429
  *
@@ -2215,6 +2448,7 @@ function rejected(error) {
2215
2448
  command: "help",
2216
2449
  json: false,
2217
2450
  dryRun: false,
2451
+ yes: false,
2218
2452
  error
2219
2453
  };
2220
2454
  }
@@ -2225,7 +2459,7 @@ function rejected(error) {
2225
2459
  * @returns What to run, and with which flags.
2226
2460
  * @example
2227
2461
  * parseArgv(["patch", "--dry-run"]);
2228
- * // { command: "release", releaseType: "patch", json: false, dryRun: true }
2462
+ * // { command: "release", releaseType: "patch", json: false, dryRun: true, yes: false }
2229
2463
  */
2230
2464
  function parseArgv(argv) {
2231
2465
  const flags = argv.filter((argument) => argument.startsWith("-"));
@@ -2233,34 +2467,40 @@ function parseArgv(argv) {
2233
2467
  if (flags.includes("--help") || flags.includes("-h")) return {
2234
2468
  command: "help",
2235
2469
  json: false,
2236
- dryRun: false
2470
+ dryRun: false,
2471
+ yes: false
2237
2472
  };
2238
- const unknownFlag = flags.find((flag) => flag !== "--json" && flag !== "--dry-run");
2473
+ const unknownFlag = flags.find((flag) => !KNOWN_FLAGS.has(flag));
2239
2474
  if (unknownFlag) return rejected(`unknown flag \`${unknownFlag}\``);
2240
2475
  if (positionals.length > 1) return rejected(`unexpected argument \`${positionals[1]}\``);
2241
2476
  const json = flags.includes("--json");
2242
2477
  const dryRun = flags.includes("--dry-run");
2478
+ const yes = flags.includes("--yes") || flags.includes("-y");
2243
2479
  const [first] = positionals;
2244
2480
  if (first === void 0) return {
2245
2481
  command: "help",
2246
2482
  json,
2247
- dryRun
2483
+ dryRun,
2484
+ yes
2248
2485
  };
2249
2486
  if (first === "doctor" || first === "setup") return {
2250
2487
  command: first,
2251
2488
  json,
2252
- dryRun
2489
+ dryRun,
2490
+ yes
2253
2491
  };
2254
2492
  if (first === "help") return {
2255
2493
  command: "help",
2256
2494
  json,
2257
- dryRun
2495
+ dryRun,
2496
+ yes
2258
2497
  };
2259
2498
  if (isReleaseType(first)) return {
2260
2499
  command: "release",
2261
2500
  releaseType: first,
2262
2501
  json,
2263
- dryRun
2502
+ dryRun,
2503
+ yes
2264
2504
  };
2265
2505
  return rejected(`unknown command \`${first}\``);
2266
2506
  }
@@ -2445,12 +2685,32 @@ const USAGE = [
2445
2685
  " moku-release doctor [--json] read-only diagnosis of the release setup",
2446
2686
  ` moku-release <${RELEASE_TYPES.join("|")}>`,
2447
2687
  "",
2448
- " --dry-run print every action, mutate nothing",
2688
+ " --dry-run print every action, mutate nothing, ask nothing",
2689
+ " --yes, -y answer every setup confirmation with yes",
2449
2690
  "",
2450
2691
  " Two steps are yours alone — this CLI never handles a credential:",
2451
2692
  " gh auth login",
2452
2693
  " npm login"
2453
2694
  ].join("\n");
2695
+ /** Prompts that never read stdin: every confirmation is a yes, every menu its first entry. */
2696
+ const ASSENTING_PROMPTS = {
2697
+ /**
2698
+ * Answer a confirmation with yes.
2699
+ *
2700
+ * @returns Always `true`.
2701
+ * @example
2702
+ * await ASSENTING_PROMPTS.confirm("Replace ci.yml?"); // true
2703
+ */
2704
+ confirm: async () => true,
2705
+ /**
2706
+ * Pick the first entry of a menu.
2707
+ *
2708
+ * @returns Always `0`.
2709
+ * @example
2710
+ * await ASSENTING_PROMPTS.select("Bump?", ["patch", "minor"]); // 0
2711
+ */
2712
+ select: async () => 0
2713
+ };
2454
2714
  /**
2455
2715
  * Dispatch a parsed invocation to its command.
2456
2716
  *
@@ -2476,7 +2736,7 @@ async function dispatch(parsed, ctx, ui) {
2476
2736
  if (parsed.command === "setup") return runSetup({
2477
2737
  ctx,
2478
2738
  ui,
2479
- prompts: createBrandPrompts(),
2739
+ prompts: parsed.yes || parsed.dryRun ? ASSENTING_PROMPTS : createBrandPrompts(),
2480
2740
  dryRun: parsed.dryRun
2481
2741
  });
2482
2742
  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.3",
3
+ "version": "1.2.2",
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": "moku-release setup",
60
- "release:doctor": "moku-release doctor",
61
- "release": "moku-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
  }