@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 +15 -10
- package/dist/release.mjs +126 -7
- package/examples/dependabot.yml +15 -0
- package/examples/package/dependabot-automerge.yml +21 -0
- package/package.json +1 -1
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
|
|
44
|
-
registers the trusted publisher
|
|
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`,
|
|
49
|
-
>
|
|
50
|
-
> `moku-release setup`
|
|
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.**
|
|
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
|
|
112
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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
|
|
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 }}
|