@moku-labs/ci 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,14 +40,16 @@ bun add -d @moku-labs/ci
40
40
  bun run release:setup
41
41
  ```
42
42
 
43
- `setup` writes the two workflow files, adds the missing scripts, does the first publish,
44
- registers the trusted publisher and applies the branch ruleset. Run it again any time — it
45
- only does what is still missing.
43
+ `setup` writes the two workflow files and the Dependabot files, adds the missing scripts,
44
+ does the first publish, registers the trusted publisher, applies the branch ruleset and
45
+ allows auto-merge. Run it again any time — it only does what is still missing.
46
46
 
47
47
  > [!NOTE]
48
- > **Status: `1.x`, early.** `package-ci.yml`, `package-release.yml` and `moku-release release`
49
- > ship this package itself: `1.1.1` was the first live release, tokenless and with provenance.
50
- > `moku-release setup` has passed dry-run and unit tests only.
48
+ > **Status: `1.x`, in use.** All eight moku packages — `core`, `common`, `web`, `worker`, `room`,
49
+ > `system`, `ai`, `native` — run `package-ci.yml` and `package-release.yml` from `@v1`, and each
50
+ > has shipped a tokenless release with provenance through them. `moku-release setup` did every
51
+ > one of those migrations live, including two first publishes behind 2FA.
52
+ > `app-deploy.yml` has no live user yet.
51
53
 
52
54
  > [!IMPORTANT]
53
55
  > Two steps are yours alone. The CLI never handles a credential, and there is no `NPM_TOKEN`
@@ -60,7 +62,7 @@ only does what is still missing.
60
62
 
61
63
  ## Why @moku-labs/ci
62
64
 
63
- - **One copy of the pipeline.** Seven repos had seven different `ci.yml` and six different
65
+ - **One copy of the pipeline.** Eight packages had eight different `ci.yml` and eight different
64
66
  `publish.yml`. A fix now lands here and reaches every project through the `@v1` tag.
65
67
  - **Scripts are the interface, not tools.** The workflows call `bun run lint`, never `biome`.
66
68
  A project with special needs changes its own script, not the central YAML.
@@ -106,10 +108,13 @@ flowchart LR
106
108
  |---|---|
107
109
  | [`examples/package/publish.local-publish.yml`](examples/package/publish.local-publish.yml) | Fallback: publish from the project's own job. Use it only if the central publish fails npm auth. |
108
110
  | [`rulesets/main.json`](rulesets/main.json) | Branch ruleset for `main`: PR only, no force-push, the four `ci / …` checks required. |
111
+ | [`examples/dependabot.yml`](examples/dependabot.yml) | Dependabot config: one grouped PR a day for `@moku-labs/*` only. `setup` writes it to `.github/dependabot.yml`. |
112
+ | [`examples/package/dependabot-automerge.yml`](examples/package/dependabot-automerge.yml) | Turns on auto-merge for a Dependabot PR; the ruleset checks stay the gate. `setup` writes it and allows auto-merge on the repo. |
109
113
 
110
114
  > [!TIP]
111
- > A Layer-3 app copies `examples/app/ci.yml` by hand and needs a `deploy` script. The CLI
112
- > sets up packages only.
115
+ > A Layer-3 app copies `examples/app/ci.yml` and `examples/dependabot.yml` by hand and needs
116
+ > a `deploy` script. An app merges Dependabot PRs by hand, because the merge runs the deploy.
117
+ > The CLI sets up packages only.
113
118
 
114
119
  ## Project checks
115
120
 
@@ -156,7 +161,7 @@ bun add https://pkg.pr.new/@moku-labs/core@42 # 42 = PR number, a commit sha w
156
161
 
157
162
  | Command | When | What it does |
158
163
  |---|---|---|
159
- | `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. |
164
+ | `bun run release:setup` | once per project | Idempotent wizard: workflows, Dependabot, script contract, first publish, first tag, trusted publisher, branch ruleset, auto-merge, then `doctor`. `--dry-run` prints every action, changes nothing and asks nothing. `--yes` answers every confirmation for an agent or CI; a step that needs your npm OTP is then printed, not run. |
160
165
  | `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. |
161
166
  | `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. |
162
167
 
package/dist/release.mjs CHANGED
@@ -685,19 +685,48 @@ function requiredCheckContexts(stdout) {
685
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
686
  }
687
687
  /**
688
+ * What is wrong with a ruleset's required checks, measured against the central ones. A
689
+ * legacy check is a central one without the caller prefix (`lint` for `ci / lint`): the thin
690
+ * caller never reports it. Any other context is the project's own (`ci-pass` of a matrix
691
+ * workflow) and is none of our business.
692
+ *
693
+ * @param required - The contexts the ruleset requires.
694
+ * @param central - The central contexts (`ci / lint`, …).
695
+ * @returns The legacy contexts to drop and the central ones still missing.
696
+ * @example
697
+ * requiredChecksDrift(["lint", "ci-pass"], ["ci / lint"]); // { legacy: ["lint"], missing: ["ci / lint"] }
698
+ */
699
+ function requiredChecksDrift(required, central) {
700
+ const bare = new Set(central.map((context) => context.slice(5)));
701
+ return {
702
+ legacy: required.filter((context) => bare.has(context)),
703
+ missing: central.filter((context) => !required.includes(context))
704
+ };
705
+ }
706
+ /**
688
707
  * 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.
708
+ * rule is kept as it is, and so is every required check the project added itself.
690
709
  *
691
710
  * @param existing - The full existing ruleset as JSON.
692
711
  * @param template - The central ruleset template as JSON.
693
- * @returns The `PUT` body: the existing rules with the template's required checks.
712
+ * @returns The `PUT` body: the existing rules with the central required checks merged in.
694
713
  * @example
695
714
  * const body = withCentralRequiredChecks(existingJson, renderMainRuleset());
696
715
  */
697
716
  function withCentralRequiredChecks(existing, template) {
698
717
  const kept = parseRules(existing).filter((rule) => rule.type !== REQUIRED_CHECKS_RULE);
699
718
  const central = parseRules(template).filter((rule) => rule.type === REQUIRED_CHECKS_RULE);
700
- return JSON.stringify({ rules: [...kept, ...central] });
719
+ const centralContexts = requiredCheckContexts(template);
720
+ const { legacy } = requiredChecksDrift(requiredCheckContexts(existing), centralContexts);
721
+ const own = requiredCheckContexts(existing).filter((context) => !legacy.includes(context) && !centralContexts.includes(context));
722
+ const merged = central.map((rule) => ({
723
+ ...rule,
724
+ parameters: {
725
+ ...rule.parameters,
726
+ required_status_checks: [...rule.parameters?.required_status_checks ?? [], ...own.map((context) => ({ context }))]
727
+ }
728
+ }));
729
+ return JSON.stringify({ rules: [...kept, ...merged] });
701
730
  }
702
731
  /**
703
732
  * The id of the newest workflow run from `gh run list --json databaseId`.
@@ -1080,6 +1109,25 @@ const CI_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-ci.yml@v1";
1080
1109
  /** The `ci.yml` body, read from `examples/package/ci.yml`. */
1081
1110
  const CI_WORKFLOW = readCentral("examples/package/ci.yml");
1082
1111
  //#endregion
1112
+ //#region src/templates/dependabot.ts
1113
+ /**
1114
+ * @file `moku-release` — the Dependabot templates.
1115
+ *
1116
+ * Read from this package's own `examples/dependabot.yml` and
1117
+ * `examples/package/dependabot-automerge.yml`. Dependabot opens one grouped PR a day when a
1118
+ * new `@moku-labs/*` version is out; the automerge workflow merges it once the required
1119
+ * `ci / …` checks pass. The config sits outside `examples/package/` on purpose: it is not a
1120
+ * workflow, and the self-test runs actionlint on every `.yml` one folder below `examples/`.
1121
+ */
1122
+ /** Repo-relative path the Dependabot config is written to. */
1123
+ const DEPENDABOT_CONFIG_PATH = ".github/dependabot.yml";
1124
+ /** The Dependabot config body, read from `examples/dependabot.yml`. */
1125
+ const DEPENDABOT_CONFIG = readCentral("examples/dependabot.yml");
1126
+ /** Repo-relative path the automerge workflow is written to. */
1127
+ const DEPENDABOT_AUTOMERGE_PATH = ".github/workflows/dependabot-automerge.yml";
1128
+ /** The automerge workflow body, read from `examples/package/dependabot-automerge.yml`. */
1129
+ const DEPENDABOT_AUTOMERGE = readCentral("examples/package/dependabot-automerge.yml");
1130
+ //#endregion
1083
1131
  //#region src/templates/publish.ts
1084
1132
  /**
1085
1133
  * @file `moku-release` — the `.github/workflows/publish.yml` template.
@@ -1134,6 +1182,14 @@ const workflowTemplates = [{
1134
1182
  content: PUBLISH_WORKFLOW,
1135
1183
  ref: PUBLISH_WORKFLOW_REF
1136
1184
  }];
1185
+ /** The Dependabot config and its automerge workflow, in the order `setup` writes them. */
1186
+ const dependabotTemplates = [{
1187
+ path: DEPENDABOT_CONFIG_PATH,
1188
+ content: DEPENDABOT_CONFIG
1189
+ }, {
1190
+ path: DEPENDABOT_AUTOMERGE_PATH,
1191
+ content: DEPENDABOT_AUTOMERGE
1192
+ }];
1137
1193
  /**
1138
1194
  * Whether a workflow file on disk is the thin caller — i.e. it delegates to the pinned
1139
1195
  * central reusable workflow. A file that does not is a legacy hand-written pipeline.
@@ -1179,11 +1235,11 @@ function renderMainRuleset() {
1179
1235
  * const stale = await findStaleRuleset(ctx, "moku-labs/system", listing.stdout);
1180
1236
  */
1181
1237
  async function findStaleRuleset(ctx, ownerRepo, listing) {
1182
- const central = new Set(requiredCheckContexts(renderMainRuleset()));
1238
+ const central = requiredCheckContexts(renderMainRuleset());
1183
1239
  for (const id of activeBranchRulesetIds(listing)) {
1184
1240
  const detail = await ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets/${id}`]);
1185
1241
  if (detail.code !== 0) continue;
1186
- const legacy = requiredCheckContexts(detail.stdout).filter((context) => !central.has(context));
1242
+ const { legacy } = requiredChecksDrift(requiredCheckContexts(detail.stdout), central);
1187
1243
  if (legacy.length > 0) return {
1188
1244
  id,
1189
1245
  body: detail.stdout,
@@ -2261,6 +2317,32 @@ async function writeWorkflows(setup) {
2261
2317
  }
2262
2318
  }
2263
2319
  /**
2320
+ * Write the Dependabot config and its automerge workflow. A missing file is written; a
2321
+ * file that differs is the project's own config and is left alone, without asking.
2322
+ *
2323
+ * @param setup - The wizard state.
2324
+ * @returns Nothing.
2325
+ * @example
2326
+ * await writeDependabot(setup);
2327
+ */
2328
+ async function writeDependabot(setup) {
2329
+ setup.ui.heading("Dependabot");
2330
+ for (const template of dependabotTemplates) {
2331
+ const existing = await setup.ctx.files.read(template.path);
2332
+ if (existing === template.content) {
2333
+ setup.ui.check(true, `${template.path} up to date`);
2334
+ continue;
2335
+ }
2336
+ if (existing !== void 0) {
2337
+ setup.ui.check(true, `${template.path} is the project's own, left as is`);
2338
+ continue;
2339
+ }
2340
+ if (deferred(setup, `write ${template.path}`)) continue;
2341
+ await setup.ctx.files.write(template.path, template.content);
2342
+ setup.ui.check(true, `${template.path} written`);
2343
+ }
2344
+ }
2345
+ /**
2264
2346
  * Bring `package.json` onto the release contract. The change summary is printed first and
2265
2347
  * applied only after a confirm; an already-compliant manifest is a checkmark.
2266
2348
  *
@@ -2481,6 +2563,39 @@ async function applyBranchRuleset(setup, ownerRepo) {
2481
2563
  setup.ui.check(created.code === 0, "branch ruleset applied", created.stderr.trim() || void 0);
2482
2564
  }
2483
2565
  /**
2566
+ * Turn on the repo setting the automerge workflow needs. Without it `gh pr merge --auto`
2567
+ * fails and every Dependabot PR waits for a manual merge.
2568
+ *
2569
+ * @param setup - The wizard state.
2570
+ * @param ownerRepo - The `owner/repo` slug.
2571
+ * @returns Nothing.
2572
+ * @example
2573
+ * await enableAutoMerge(setup, "moku-labs/common");
2574
+ */
2575
+ async function enableAutoMerge(setup, ownerRepo) {
2576
+ setup.ui.heading("Auto-merge");
2577
+ const current = await setup.ctx.exec.capture("gh", [
2578
+ "api",
2579
+ `repos/${ownerRepo}`,
2580
+ "--jq",
2581
+ ".allow_auto_merge"
2582
+ ]);
2583
+ if (current.code === 0 && current.stdout.trim() === "true") {
2584
+ setup.ui.check(true, "auto-merge already allowed");
2585
+ return;
2586
+ }
2587
+ if (deferred(setup, `allow auto-merge on ${ownerRepo}`)) return;
2588
+ const updated = await setup.ctx.exec.capture("gh", [
2589
+ "api",
2590
+ `repos/${ownerRepo}`,
2591
+ "--method",
2592
+ "PATCH",
2593
+ "-F",
2594
+ "allow_auto_merge=true"
2595
+ ]);
2596
+ setup.ui.check(updated.code === 0, "auto-merge allowed", updated.stderr.trim() || void 0);
2597
+ }
2598
+ /**
2484
2599
  * Run the whole wizard. Every step is skippable, idempotent, and re-runnable; the last one
2485
2600
  * is always `doctor`, so the wizard's own verdict is the same report the operator gets
2486
2601
  * from `release:doctor`.
@@ -2508,6 +2623,7 @@ async function runSetup(options) {
2508
2623
  return 1;
2509
2624
  }
2510
2625
  await writeWorkflows(setup);
2626
+ await writeDependabot(setup);
2511
2627
  await normalizeContract(setup, manifest);
2512
2628
  const published = await firstPublish(setup, manifest.name, manifest.version);
2513
2629
  if (published === "absent") {
@@ -2517,8 +2633,11 @@ async function runSetup(options) {
2517
2633
  await registerTrustedPublisher(setup);
2518
2634
  const declared = repositoryUrlOf(manifest);
2519
2635
  const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
2520
- if (ownerRepo === void 0) ui.warn("no GitHub owner/repo — skipping the branch ruleset");
2521
- else await applyBranchRuleset(setup, ownerRepo);
2636
+ if (ownerRepo === void 0) ui.warn("no GitHub owner/repo — skipping the branch ruleset and auto-merge");
2637
+ else {
2638
+ await applyBranchRuleset(setup, ownerRepo);
2639
+ await enableAutoMerge(setup, ownerRepo);
2640
+ }
2522
2641
  ui.heading("Doctor");
2523
2642
  const report = await runDoctor({
2524
2643
  ctx: setup.ctx,
@@ -0,0 +1,15 @@
1
+ # Dependabot for a moku-family repo. Only @moku-labs/* is watched, grouped into one PR a day.
2
+ # A package also gets examples/package/dependabot-automerge.yml, so the PR merges itself
3
+ # once the required ci / … checks pass. An app merges it by hand: that merge runs the deploy.
4
+ version: 2
5
+ updates:
6
+ - package-ecosystem: "bun"
7
+ directory: "/"
8
+ schedule:
9
+ interval: "daily"
10
+ allow:
11
+ - dependency-name: "@moku-labs/*"
12
+ groups:
13
+ moku:
14
+ patterns:
15
+ - "@moku-labs/*"
@@ -0,0 +1,21 @@
1
+ name: Dependabot automerge
2
+
3
+ # Turns on auto-merge for a Dependabot PR. The branch ruleset stays the gate: the merge
4
+ # waits for ci / lint, ci / types, ci / test and ci / build. Needs the repo setting
5
+ # allow_auto_merge, which `moku-release setup` turns on.
6
+ on: pull_request
7
+
8
+ permissions:
9
+ contents: write
10
+ pull-requests: write
11
+
12
+ jobs:
13
+ automerge:
14
+ if: github.event.pull_request.user.login == 'dependabot[bot]'
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - name: Merge once required checks pass
18
+ run: gh pr merge --auto --squash "$PR_URL"
19
+ env:
20
+ PR_URL: ${{ github.event.pull_request.html_url }}
21
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moku-labs/ci",
3
- "version": "1.2.5",
3
+ "version": "1.3.0",
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": [