@moku-labs/ci 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -91,8 +91,17 @@ flowchart LR
91
91
  1. `release:setup` writes two thin callers into `.github/workflows/`.
92
92
  2. Every PR runs `package-ci.yml`: four jobs, reported as `ci / lint`, `ci / types`,
93
93
  `ci / test`, `ci / build`.
94
- 3. `release patch` dispatches `publish.yml`, which calls `package-release.yml`:
95
- check → tag → pack → publish. The CLI watches the run and verifies the version on npm.
94
+ 3. Every merge to `main` runs `publish.yml`, which calls `package-release.yml`:
95
+ check → tag → pack → publish. The version is always a patch. `release wait` waits for
96
+ it and verifies the version on npm.
97
+ 4. A person runs `release minor`, or `release major` for a milestone. It dispatches the same
98
+ `publish.yml`.
99
+
100
+ | Who releases | Version |
101
+ |---|---|
102
+ | a merge to `main` | patch, always |
103
+ | a person | minor |
104
+ | a person, for a milestone | major |
96
105
 
97
106
  ## Workflows
98
107
 
@@ -110,7 +119,8 @@ flowchart LR
110
119
  | [`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. |
111
120
  | [`rulesets/main.json`](rulesets/main.json) | Branch ruleset for `main`: PR only, no force-push, the four `ci / …` checks required. |
112
121
  | [`examples/dependabot.yml`](examples/dependabot.yml) | Dependabot config: one grouped PR a day for `@moku-labs/*` only. `setup` writes it to `.github/dependabot.yml`. |
113
- | [`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. |
122
+ | [`examples/org/fleet.yml`](examples/org/fleet.yml) | For the organization's `.github` repository. Every hour it runs `moku-release fleet --markdown` and puts the table between `<!-- fleet:start -->` and `<!-- fleet:end -->` of `profile/README.md`. |
123
+ | [`examples/package/dependabot-automerge.yml`](examples/package/dependabot-automerge.yml) | Turns on auto-merge for a Dependabot PR; the ruleset checks stay the gate. After the merge it dispatches `publish.yml` with `patch`, because a merge made by `GITHUB_TOKEN` starts no push run. `setup` writes it and allows auto-merge on the repo. |
114
124
 
115
125
  > [!TIP]
116
126
  > A Layer-3 app copies `examples/app/ci.yml` and `examples/dependabot.yml` by hand and needs
@@ -198,7 +208,9 @@ Examples: `examples/package/ci-with-demos.yml` (label `demos:full`) and
198
208
  |---|---|---|
199
209
  | `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. |
200
210
  | `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. |
201
- | `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. |
211
+ | `bun run release wait` | after each merge to `main` | Refuses unless `origin/main` holds `HEAD`. Waits for the first release tag that holds the commit, then for npm to serve that version. Stops on a failed run and prints the log command. |
212
+ | `bun run release fleet` | any time | Read-only. One table for every repository of the organization: CI on `main`, the release of `main`, tag against npm, commits nobody released, open PRs, old `@moku-labs/*` pins. Then the list of what waits for a person. `--markdown` prints the public rows for the organization page, `--json` every row. |
213
+ | `bun run release <patch\|minor\|major\|prerelease>` | a release a person asks for | Refuses unless the tree is clean and `HEAD == origin/main`. Dispatches `publish.yml`, watches the run, verifies the version and dist-tag on npm. |
202
214
 
203
215
  The scripts are plain aliases of the `moku-release` bin. Internals and the list of checks:
204
216
  [src/README.md](src/README.md).
package/dist/release.mjs CHANGED
@@ -487,6 +487,57 @@ function createBrandPrompts(options = {}) {
487
487
  if (!color) return `${question} [1-${count}] `;
488
488
  return ` ${palette.dim(`pick 1–${count}`)} ${palette.cyan("›")} `;
489
489
  };
490
+ let readline;
491
+ let closed = false;
492
+ const lines = [];
493
+ const waiters = [];
494
+ /**
495
+ * Show `prompt` and resolve the next input line. Lines that arrive before a question are
496
+ * queued; once the input has ended, the question resolves `""` (the prompt's default).
497
+ *
498
+ * @param prompt - The readline prompt string.
499
+ * @returns Resolves the raw answer line.
500
+ * @example
501
+ * await ask("Deploy? [y/N] ");
502
+ */
503
+ const ask = (prompt) => {
504
+ if (closed && lines.length === 0) {
505
+ output.write(prompt);
506
+ return Promise.resolve("");
507
+ }
508
+ readline ??= openReadline();
509
+ readline.setPrompt(prompt);
510
+ readline.prompt();
511
+ const queued = lines.shift();
512
+ if (queued !== void 0) return Promise.resolve(queued);
513
+ readline.resume();
514
+ return new Promise((resolve) => waiters.push(resolve));
515
+ };
516
+ /**
517
+ * Create the shared interface: route each line to the oldest waiting question or the
518
+ * queue, pause while idle so the process can exit, and settle waiters on close.
519
+ *
520
+ * @returns The shared readline interface.
521
+ * @example
522
+ * readline ??= openReadline();
523
+ */
524
+ const openReadline = () => {
525
+ const created = createInterface({
526
+ input,
527
+ output
528
+ });
529
+ created.on("line", (line) => {
530
+ const waiter = waiters.shift();
531
+ if (waiter) waiter(line);
532
+ else lines.push(line);
533
+ if (waiters.length === 0) created.pause();
534
+ });
535
+ created.on("close", () => {
536
+ closed = true;
537
+ for (const waiter of waiters.splice(0)) waiter("");
538
+ });
539
+ return created;
540
+ };
490
541
  return {
491
542
  /**
492
543
  * Ask a yes/no question; resolves `true` only on an explicit `y`/`yes`.
@@ -497,16 +548,7 @@ function createBrandPrompts(options = {}) {
497
548
  * await prompts.confirm("Deploy?");
498
549
  */
499
550
  confirm(question) {
500
- return new Promise((resolve) => {
501
- const readline = createInterface({
502
- input,
503
- output
504
- });
505
- readline.question(confirmPrompt(question), (answer) => {
506
- readline.close();
507
- resolve(YES_PATTERN.test(answer.trim()));
508
- });
509
- });
551
+ return ask(confirmPrompt(question)).then((answer) => YES_PATTERN.test(answer.trim()));
510
552
  },
511
553
  /**
512
554
  * Present `choices` numbered from 1 and resolve the chosen zero-based index.
@@ -518,17 +560,10 @@ function createBrandPrompts(options = {}) {
518
560
  * await prompts.select("Pick", ["a", "b"]);
519
561
  */
520
562
  select(question, choices) {
521
- return new Promise((resolve) => {
522
- const readline = createInterface({
523
- input,
524
- output
525
- });
526
- write(choicesBlock(question, choices));
527
- readline.question(selectPrompt(question, choices.length), (answer) => {
528
- readline.close();
529
- const picked = Number.parseInt(answer.trim(), 10);
530
- resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
531
- });
563
+ write(choicesBlock(question, choices));
564
+ return ask(selectPrompt(question, choices.length)).then((answer) => {
565
+ const picked = Number.parseInt(answer.trim(), 10);
566
+ return Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0;
532
567
  });
533
568
  }
534
569
  };
@@ -742,6 +777,22 @@ function latestRunId(stdout) {
742
777
  return String(first.databaseId);
743
778
  }
744
779
  /**
780
+ * The runs from `gh run list --json databaseId,headSha,status,conclusion`, newest first.
781
+ *
782
+ * @param stdout - The raw `gh run list` JSON.
783
+ * @returns The runs that carry an id and a commit; anything else is left out.
784
+ * @example
785
+ * parseRuns('[{"databaseId":42,"headSha":"abc123","status":"queued","conclusion":""}]');
786
+ */
787
+ function parseRuns(stdout) {
788
+ return parseJsonArray(stdout).flatMap((run) => run.databaseId === void 0 || run.headSha === void 0 ? [] : [{
789
+ databaseId: run.databaseId,
790
+ headSha: run.headSha,
791
+ status: run.status ?? "",
792
+ conclusion: run.conclusion ?? ""
793
+ }]);
794
+ }
795
+ /**
745
796
  * The permalink of a GitHub release, for the final summary.
746
797
  *
747
798
  * @param ownerRepo - The `owner/repo` slug.
@@ -782,7 +833,7 @@ const REQUIRED_SCRIPTS = {
782
833
  * @example
783
834
  * parseManifest('{"name":"x"}');
784
835
  */
785
- function parseManifest(text) {
836
+ function parseManifest$1(text) {
786
837
  if (text === void 0) return void 0;
787
838
  try {
788
839
  const parsed = JSON.parse(text);
@@ -801,7 +852,7 @@ function parseManifest(text) {
801
852
  * const manifest = await readManifest(ctx.files);
802
853
  */
803
854
  async function readManifest(files) {
804
- return parseManifest(await files.read(MANIFEST_PATH));
855
+ return parseManifest$1(await files.read(MANIFEST_PATH));
805
856
  }
806
857
  /** Any character outside printable ASCII and the JSON whitespace. */
807
858
  const NON_ASCII = /[^\t\n\r -~]/;
@@ -1707,7 +1758,7 @@ const tagSyncCheck = {
1707
1758
  * `npm trust` warns instead — the answer there is an upgrade, not a registration.
1708
1759
  */
1709
1760
  /** Basename npm registers the publisher against — the workflow file's name, not its path. */
1710
- const PUBLISH_WORKFLOW_FILE$1 = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
1761
+ const PUBLISH_WORKFLOW_FILE$2 = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
1711
1762
  /** Detail of the result when the listing sits behind 2FA; `setup` offers to register anyway. */
1712
1763
  const TRUST_NEEDS_OTP = "npm asks for an OTP, cannot verify from here";
1713
1764
  /**
@@ -1754,7 +1805,7 @@ function isOtpRequired(output) {
1754
1805
  * trustCommand("@moku-labs/common", "moku-labs/common");
1755
1806
  */
1756
1807
  function trustCommand(name, ownerRepo) {
1757
- return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$1} --repo ${ownerRepo} --allow-publish --yes`;
1808
+ return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$2} --repo ${ownerRepo} --allow-publish --yes`;
1758
1809
  }
1759
1810
  /** Verifies a trusted publisher is registered for the package. */
1760
1811
  const trustedPublisherCheck = {
@@ -1782,8 +1833,8 @@ const trustedPublisherCheck = {
1782
1833
  if (isUnknownCommand(`${listing.stdout}${listing.stderr}`)) return warn("this npm has no `trust` command", "upgrade npm");
1783
1834
  if (isUnauthorized(`${listing.stdout}${listing.stderr}`)) return skip("cannot list trusted publishers without `npm login`");
1784
1835
  if (isOtpRequired(`${listing.stdout}${listing.stderr}`)) return warn(TRUST_NEEDS_OTP, `npm trust list ${manifest.name}`);
1785
- if (listing.code !== 0 || !listing.stdout.includes(PUBLISH_WORKFLOW_FILE$1)) return fail("no trusted publisher registered", trustCommand(manifest.name, ownerRepo));
1786
- return pass(`${ownerRepo} · ${PUBLISH_WORKFLOW_FILE$1}`);
1836
+ if (listing.code !== 0 || !listing.stdout.includes(PUBLISH_WORKFLOW_FILE$2)) return fail("no trusted publisher registered", trustCommand(manifest.name, ownerRepo));
1837
+ return pass(`${ownerRepo} · ${PUBLISH_WORKFLOW_FILE$2}`);
1787
1838
  }
1788
1839
  };
1789
1840
  //#endregion
@@ -1794,10 +1845,15 @@ const trustedPublisherCheck = {
1794
1845
  * Three outcomes, and the middle one matters most: a MISSING workflow fails, a
1795
1846
  * hand-written one warns ("legacy workflow, run release:setup to migrate") because the
1796
1847
  * repo still releases — just not through the shared pipeline — and a pinned thin caller
1797
- * passes.
1848
+ * passes. A thin `publish.yml` without the `push` trigger warns too: it releases only by
1849
+ * hand, and the family rule is a patch on every merge to main.
1798
1850
  */
1799
1851
  /** Advisory shown for a workflow that exists but does not call the central pipeline. */
1800
1852
  const LEGACY_FIX = "legacy workflow, run release:setup to migrate";
1853
+ /** Advisory shown for a thin `publish.yml` that does not release on a merge to main. */
1854
+ const AUTO_RELEASE_FIX = "add `push: branches: [main]` under `on:` in publish.yml";
1855
+ /** Matches the `push:` trigger of a workflow, at the indent `on:` gives its events. */
1856
+ const PUSH_TRIGGER = /^ {2}push:/m;
1801
1857
  /** Verifies `ci.yml` and `publish.yml` are the `@v1`-pinned thin callers. */
1802
1858
  const workflowsCheck = {
1803
1859
  id: "workflows",
@@ -1813,13 +1869,16 @@ const workflowsCheck = {
1813
1869
  async run(ctx) {
1814
1870
  const missing = [];
1815
1871
  const legacy = [];
1872
+ let autoRelease = true;
1816
1873
  for (const template of workflowTemplates) {
1817
1874
  const content = await ctx.files.read(template.path);
1818
1875
  if (content === void 0) missing.push(template.path);
1819
1876
  else if (!isThinWorkflow(content, template)) legacy.push(template.path);
1877
+ else if (template.path === ".github/workflows/publish.yml") autoRelease = PUSH_TRIGGER.test(content);
1820
1878
  }
1821
1879
  if (missing.length > 0) return fail(`missing ${missing.join(", ")}`, "moku-release setup");
1822
1880
  if (legacy.length > 0) return warn(`${legacy.join(", ")} not pinned to @v1`, LEGACY_FIX);
1881
+ if (!autoRelease) return warn("publish.yml does not release on a merge to main", AUTO_RELEASE_FIX);
1823
1882
  return pass("ci.yml + publish.yml pinned to @v1");
1824
1883
  }
1825
1884
  };
@@ -1976,11 +2035,474 @@ async function runDoctor(options) {
1976
2035
  };
1977
2036
  }
1978
2037
  //#endregion
2038
+ //#region src/lib/fleet.ts
2039
+ /**
2040
+ * @file `moku-release fleet` — one GraphQL query for every repository of the organization,
2041
+ * and the pure functions that turn its answer into a status table.
2042
+ *
2043
+ * Everything a row shows comes from that one answer, except the version npm serves. The
2044
+ * checks of the newest commit on the default branch hold both pipelines: a `release / …`
2045
+ * check belongs to `publish.yml`, every other check to CI. The newest `v*` tag sits on a
2046
+ * bump commit whose parent is the commit that was released, so the distance from that
2047
+ * parent to the head is the number of commits nobody released.
2048
+ */
2049
+ /** Prefix GitHub puts on the jobs of `publish.yml`: the caller's job id, `release`. */
2050
+ const RELEASE_PREFIX = "release / ";
2051
+ /** The scope every family package is published under. */
2052
+ const FAMILY_SCOPE = "@moku-labs/";
2053
+ /** Matches a release tag, prereleases included: `v1.2.3`, `v1.2.3-rc.0`. Not the moving `v1`. */
2054
+ const RELEASE_TAG = /^v\d+\.\d+\.\d+/;
2055
+ /** Matches a dependency pinned to one exact version. A range is not a pin and is not judged. */
2056
+ const EXACT_VERSION = /^\d+\.\d+\.\d+/;
2057
+ /** The query `gh api graphql -F org=<org>` runs. One call answers the whole table. */
2058
+ const FLEET_QUERY = `query($org: String!) {
2059
+ organization(login: $org) {
2060
+ repositories(first: 100, isArchived: false, orderBy: { field: NAME, direction: ASC }) {
2061
+ nodes {
2062
+ name
2063
+ isPrivate
2064
+ manifest: object(expression: "HEAD:package.json") { ... on Blob { text } }
2065
+ publish: object(expression: "HEAD:.github/workflows/publish.yml") { ... on Blob { byteSize } }
2066
+ defaultBranchRef {
2067
+ target {
2068
+ ... on Commit {
2069
+ oid
2070
+ history(first: 30) { nodes { oid } }
2071
+ statusCheckRollup { contexts(first: 100) { nodes { ... on CheckRun { name status conclusion } } } }
2072
+ }
2073
+ }
2074
+ }
2075
+ refs(refPrefix: "refs/tags/", first: 10, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {
2076
+ nodes {
2077
+ name
2078
+ target {
2079
+ ... on Commit { parents(first: 1) { nodes { oid } } }
2080
+ ... on Tag { target { ... on Commit { parents(first: 1) { nodes { oid } } } } }
2081
+ }
2082
+ }
2083
+ }
2084
+ pullRequests(states: OPEN, first: 50) {
2085
+ nodes {
2086
+ number
2087
+ isDraft
2088
+ author { login }
2089
+ commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
2090
+ }
2091
+ }
2092
+ }
2093
+ }
2094
+ }
2095
+ }`;
2096
+ /**
2097
+ * Fold the checks of one pipeline into one state. Red wins over running, running over green.
2098
+ *
2099
+ * @param checks - The checks that belong to the pipeline.
2100
+ * @returns The state of the pipeline.
2101
+ * @example
2102
+ * pipelineState([{ status: "COMPLETED", conclusion: "FAILURE" }]); // "red"
2103
+ */
2104
+ function pipelineState(checks) {
2105
+ if (checks.length === 0) return "none";
2106
+ const done = checks.filter((check) => check.status === "COMPLETED");
2107
+ const passed = /* @__PURE__ */ new Set([
2108
+ "SUCCESS",
2109
+ "SKIPPED",
2110
+ "NEUTRAL"
2111
+ ]);
2112
+ if (done.some((check) => !passed.has(check.conclusion ?? ""))) return "red";
2113
+ return done.length === checks.length ? "green" : "running";
2114
+ }
2115
+ /**
2116
+ * The state of a pull request from the rollup state GitHub computes for its newest commit.
2117
+ *
2118
+ * @param state - `SUCCESS`, `FAILURE`, `ERROR`, `PENDING`, `EXPECTED`, or nothing.
2119
+ * @returns The matching pipeline state.
2120
+ * @example
2121
+ * rollupState("FAILURE"); // "red"
2122
+ */
2123
+ function rollupState(state) {
2124
+ if (state === "SUCCESS") return "green";
2125
+ if (state === "FAILURE" || state === "ERROR") return "red";
2126
+ return state === void 0 ? "none" : "running";
2127
+ }
2128
+ /**
2129
+ * Parse a manifest text, tolerating a missing or malformed file.
2130
+ *
2131
+ * @param text - The `package.json` text, when the repository has one.
2132
+ * @returns The manifest, or an empty one.
2133
+ * @example
2134
+ * parseManifest('{"name":"@moku-labs/core"}');
2135
+ */
2136
+ function parseManifest(text) {
2137
+ try {
2138
+ const parsed = JSON.parse(text ?? "");
2139
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
2140
+ } catch {
2141
+ return {};
2142
+ }
2143
+ }
2144
+ /**
2145
+ * The newest release tag of a repository and the commit it released.
2146
+ *
2147
+ * @param raw - The repository, as the query returns it.
2148
+ * @returns The tag and the released commit, or nothing when no release tag exists.
2149
+ * @example
2150
+ * const newest = newestRelease(raw);
2151
+ */
2152
+ function newestRelease(raw) {
2153
+ const [newest] = (raw.refs?.nodes ?? []).filter((node) => RELEASE_TAG.test(node.name ?? "")).toSorted((a, b) => compareSemver(b.name ?? "", a.name ?? ""));
2154
+ if (newest?.name === void 0) return void 0;
2155
+ const commit = newest.target?.parents === void 0 ? newest.target?.target : newest.target;
2156
+ return {
2157
+ tag: newest.name,
2158
+ released: commit?.parents?.nodes?.[0]?.oid
2159
+ };
2160
+ }
2161
+ /**
2162
+ * The exact family pins of a manifest, from `dependencies` and `devDependencies`.
2163
+ *
2164
+ * @param manifest - The parsed manifest.
2165
+ * @returns Dependency name to pinned version, for exact `@moku-labs/*` pins only.
2166
+ * @example
2167
+ * familyPins({ dependencies: { "@moku-labs/core": "1.7.1" } }); // { "@moku-labs/core": "1.7.1" }
2168
+ */
2169
+ function familyPins(manifest) {
2170
+ const all = {
2171
+ ...manifest.devDependencies,
2172
+ ...manifest.dependencies
2173
+ };
2174
+ return Object.fromEntries(Object.entries(all).filter(([name, spec]) => name.startsWith(FAMILY_SCOPE) && EXACT_VERSION.test(spec)));
2175
+ }
2176
+ /**
2177
+ * Whether a check belongs to `publish.yml`.
2178
+ *
2179
+ * @param check - One check of a commit.
2180
+ * @returns `true` for a `release / …` check.
2181
+ * @example
2182
+ * isRelease({ name: "release / publish" }); // true
2183
+ */
2184
+ function isRelease(check) {
2185
+ return (check.name ?? "").startsWith(RELEASE_PREFIX);
2186
+ }
2187
+ /**
2188
+ * The GitHub URL of a repository, or of a page inside it.
2189
+ *
2190
+ * @param org - The organization login.
2191
+ * @param repo - The row.
2192
+ * @param path - A path inside the repository, with the leading slash.
2193
+ * @returns The URL.
2194
+ * @example
2195
+ * repoUrl("moku-labs", row, "/pulls");
2196
+ */
2197
+ function repoUrl(org, repo, path = "") {
2198
+ return `https://github.com/${org}/${repo.name}${path}`;
2199
+ }
2200
+ /**
2201
+ * Turn one repository of the answer into a row. The pins are judged later, once the
2202
+ * newest version of every package is known.
2203
+ *
2204
+ * @param raw - The repository, as the query returns it.
2205
+ * @returns The row, with `pins` still empty and the manifest pins kept in `pinned`.
2206
+ * @example
2207
+ * const row = toRepo(raw);
2208
+ */
2209
+ function toRepo(raw) {
2210
+ const manifest = parseManifest(raw.manifest?.text ?? void 0);
2211
+ const head = raw.defaultBranchRef?.target;
2212
+ const checks = (head?.statusCheckRollup?.contexts?.nodes ?? []).filter((check) => check?.name !== void 0);
2213
+ const publishes = raw.publish?.byteSize !== void 0 && manifest.private !== true && manifest.name !== void 0;
2214
+ const newest = publishes ? newestRelease(raw) : void 0;
2215
+ const distance = (head?.history?.nodes ?? []).findIndex((node) => node.oid === newest?.released);
2216
+ return {
2217
+ name: raw.name ?? "",
2218
+ isPrivate: raw.isPrivate === true,
2219
+ ...publishes && manifest.name !== void 0 ? { packageName: manifest.name } : {},
2220
+ ci: pipelineState(checks.filter((check) => !isRelease(check))),
2221
+ release: pipelineState(checks.filter((check) => isRelease(check))),
2222
+ ...newest === void 0 ? {} : {
2223
+ tag: newest.tag,
2224
+ unreleased: distance === -1 ? 30 : distance
2225
+ },
2226
+ pullRequests: (raw.pullRequests?.nodes ?? []).map((pr) => ({
2227
+ number: pr.number ?? 0,
2228
+ bot: (pr.author?.login ?? "").endsWith("[bot]") || pr.author?.login === "dependabot",
2229
+ draft: pr.isDraft === true,
2230
+ state: rollupState(pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
2231
+ })),
2232
+ pins: [],
2233
+ pinned: familyPins(manifest)
2234
+ };
2235
+ }
2236
+ /**
2237
+ * Parse the answer of {@link FLEET_QUERY} into rows, pins judged against the newest tags.
2238
+ *
2239
+ * @param stdout - The raw `gh api graphql` output.
2240
+ * @returns One row per repository, or `undefined` when the answer is not the expected shape.
2241
+ * @example
2242
+ * const rows = parseFleet(stdout);
2243
+ */
2244
+ function parseFleet(stdout) {
2245
+ let nodes;
2246
+ try {
2247
+ nodes = JSON.parse(stdout).data?.organization?.repositories?.nodes;
2248
+ } catch {
2249
+ return;
2250
+ }
2251
+ if (!Array.isArray(nodes)) return void 0;
2252
+ const rows = nodes.map((raw) => toRepo(raw));
2253
+ const newest = new Map(rows.flatMap((row) => row.packageName !== void 0 && row.tag !== void 0 ? [[row.packageName, row.tag.slice(1)]] : []));
2254
+ return rows.map(({ pinned = {}, ...row }) => ({
2255
+ ...row,
2256
+ pins: Object.entries(pinned).flatMap(([name, version]) => {
2257
+ const latest = newest.get(name);
2258
+ return latest !== void 0 && compareSemver(version, latest) < 0 ? [{
2259
+ name,
2260
+ pinned: version,
2261
+ latest
2262
+ }] : [];
2263
+ })
2264
+ }));
2265
+ }
2266
+ /**
2267
+ * What waits for a person in one repository, most urgent first. An empty list is a quiet row.
2268
+ *
2269
+ * @param repo - The row.
2270
+ * @returns One short line per thing to look at.
2271
+ * @example
2272
+ * attention({ ...row, ci: "red" }); // ["CI is red on main"]
2273
+ */
2274
+ function attention(repo) {
2275
+ const lines = [];
2276
+ const version = repo.tag?.slice(1);
2277
+ if (repo.ci === "red") lines.push("CI is red on main");
2278
+ if (repo.release === "red") lines.push("the release of main failed");
2279
+ if ((repo.unreleased ?? 0) > 0 && repo.release !== "running" && repo.release !== "red") {
2280
+ const count = repo.unreleased === 30 ? `30+` : `${repo.unreleased}`;
2281
+ lines.push(`${count} commit(s) on main are not released`);
2282
+ }
2283
+ if (repo.npm !== void 0 && version !== void 0 && repo.npm !== version) lines.push(`npm serves ${repo.npm}, the tag is ${repo.tag}`);
2284
+ for (const pr of repo.pullRequests.filter((open) => !open.draft)) {
2285
+ if (pr.state === "red") lines.push(`PR #${pr.number} has red checks`);
2286
+ if (pr.state === "green") lines.push(`PR #${pr.number} is green and not merged`);
2287
+ }
2288
+ return lines;
2289
+ }
2290
+ /** The mark of each state in the Markdown table. */
2291
+ const MARKS = {
2292
+ green: "🟢",
2293
+ red: "🔴",
2294
+ running: "🟡",
2295
+ none: "—"
2296
+ };
2297
+ /**
2298
+ * The open pull requests of a row as one short cell: the count, then what stands out.
2299
+ *
2300
+ * @param repo - The row.
2301
+ * @returns E.g. `3 (1 red, 2 bot)`, or `0`.
2302
+ * @example
2303
+ * pullRequestCell(row); // "3 (1 red, 2 bot)"
2304
+ */
2305
+ function pullRequestCell(repo) {
2306
+ const open = repo.pullRequests;
2307
+ const red = open.filter((pr) => pr.state === "red").length;
2308
+ const bot = open.filter((pr) => pr.bot).length;
2309
+ const notes = [red > 0 ? `${red} red` : "", bot > 0 ? `${bot} bot` : ""].filter(Boolean);
2310
+ return notes.length > 0 ? `${open.length} (${notes.join(", ")})` : `${open.length}`;
2311
+ }
2312
+ /**
2313
+ * The "not released" cell of a row.
2314
+ *
2315
+ * @param repo - The row.
2316
+ * @returns The number of commits after the released one, or a dash for a repository without releases.
2317
+ * @example
2318
+ * unreleasedCell(row); // "0"
2319
+ */
2320
+ function unreleasedCell(repo) {
2321
+ if (repo.unreleased === void 0) return "—";
2322
+ return repo.unreleased === 30 ? `30+` : `${repo.unreleased}`;
2323
+ }
2324
+ /**
2325
+ * The "old pins" cell of a row: every family dependency pinned below its newest release.
2326
+ * It is a column and not a line of {@link attention}: Dependabot moves the pins by itself.
2327
+ *
2328
+ * @param repo - The row.
2329
+ * @returns E.g. `core 1.7.1, ci 1.3.0`, or a dash.
2330
+ * @example
2331
+ * pinsCell(row); // "core 1.7.1"
2332
+ */
2333
+ function pinsCell(repo) {
2334
+ if (repo.pins.length === 0) return "—";
2335
+ return repo.pins.map((pin) => `${pin.name.slice(11)} ${pin.pinned}`).join(", ");
2336
+ }
2337
+ /**
2338
+ * Render the public rows as a Markdown table with links, and the list of what waits. A
2339
+ * private repository is left out: the page this lands on is public.
2340
+ *
2341
+ * @param org - The organization login, for the links.
2342
+ * @param repos - The rows.
2343
+ * @returns The Markdown block, without a trailing newline.
2344
+ * @example
2345
+ * renderFleetMarkdown("moku-labs", rows);
2346
+ */
2347
+ function renderFleetMarkdown(org, repos) {
2348
+ const shown = repos.filter((repo) => !repo.isPrivate);
2349
+ const rows = shown.map((repo) => [
2350
+ `[${repo.name}](${repoUrl(org, repo)})`,
2351
+ `[${MARKS[repo.ci]}](${repoUrl(org, repo, "/actions")})`,
2352
+ repo.packageName === void 0 ? "—" : `[${MARKS[repo.release]}](${repoUrl(org, repo, "/actions/workflows/publish.yml")})`,
2353
+ repo.tag === void 0 ? "—" : `[${repo.tag}](${repoUrl(org, repo, "/releases/tag/")}${repo.tag})`,
2354
+ repo.npm ?? "—",
2355
+ unreleasedCell(repo),
2356
+ `[${pullRequestCell(repo)}](${repoUrl(org, repo, "/pulls")})`,
2357
+ pinsCell(repo)
2358
+ ].join(" | "));
2359
+ const waiting = shown.flatMap((repo) => attention(repo).map((line) => `- [${repo.name}](${repoUrl(org, repo)}): ${line}`));
2360
+ return [
2361
+ "| Repo | CI | Release | Tag | npm | Not released | PRs | Old pins |",
2362
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
2363
+ ...rows.map((row) => `| ${row} |`),
2364
+ "",
2365
+ waiting.length === 0 ? "Nothing waits." : "**Waits for a person**",
2366
+ ...waiting.length === 0 ? [] : ["", ...waiting]
2367
+ ].join("\n");
2368
+ }
2369
+ //#endregion
2370
+ //#region src/commands/fleet.ts
2371
+ /**
2372
+ * The organization the command reads: the owner of this checkout's remote, or of the
2373
+ * manifest's `repository` when there is no remote.
2374
+ *
2375
+ * @param ctx - The ports and flags.
2376
+ * @returns The organization login, or `undefined` when neither names a GitHub owner.
2377
+ * @example
2378
+ * const org = await organizationOf(ctx); // "moku-labs"
2379
+ */
2380
+ async function organizationOf(ctx) {
2381
+ const remote = await ctx.exec.capture("git", [
2382
+ "remote",
2383
+ "get-url",
2384
+ "origin"
2385
+ ]);
2386
+ const manifest = await readManifest(ctx.files);
2387
+ const declared = manifest === void 0 ? void 0 : repositoryUrlOf(manifest);
2388
+ const url = remote.code === 0 ? remote.stdout : declared;
2389
+ return url === void 0 ? void 0 : ownerRepoFrom(url)?.split("/")[0];
2390
+ }
2391
+ /**
2392
+ * Add the version npm serves as `latest` to every row that publishes a package.
2393
+ *
2394
+ * @param ctx - The ports and flags.
2395
+ * @param repos - The rows from the query.
2396
+ * @returns The rows, with `npm` filled where the registry answered.
2397
+ * @example
2398
+ * const rows = await withRegistry(ctx, parsed);
2399
+ */
2400
+ async function withRegistry(ctx, repos) {
2401
+ return Promise.all(repos.map(async (repo) => {
2402
+ if (repo.packageName === void 0) return repo;
2403
+ const view = await ctx.exec.capture("npm", [
2404
+ "view",
2405
+ repo.packageName,
2406
+ "dist-tags",
2407
+ "--json"
2408
+ ]);
2409
+ const latest = view.code === 0 ? parseDistTags(view.stdout)?.latest : void 0;
2410
+ return latest === void 0 ? repo : {
2411
+ ...repo,
2412
+ npm: latest
2413
+ };
2414
+ }));
2415
+ }
2416
+ /**
2417
+ * Lay one row of the terminal table out in fixed columns.
2418
+ *
2419
+ * @param row - The cells of the row.
2420
+ * @returns The padded line.
2421
+ * @example
2422
+ * cells(["core", "green"]);
2423
+ */
2424
+ function cells(row) {
2425
+ return row.map((cell, index) => cell.padEnd(index === 0 ? 10 : 9)).join(" ");
2426
+ }
2427
+ /**
2428
+ * Print the table and the list of what waits, for a person at a terminal.
2429
+ *
2430
+ * @param ui - The branded console.
2431
+ * @param repos - The rows.
2432
+ * @example
2433
+ * renderTerminal(ui, rows);
2434
+ */
2435
+ function renderTerminal(ui, repos) {
2436
+ ui.heading("Fleet");
2437
+ ui.line(cells([
2438
+ "repo",
2439
+ "ci",
2440
+ "release",
2441
+ "tag",
2442
+ "npm",
2443
+ "ahead",
2444
+ "prs",
2445
+ "old pins"
2446
+ ]));
2447
+ for (const repo of repos) ui.line(cells([
2448
+ repo.name,
2449
+ repo.ci,
2450
+ repo.packageName === void 0 ? "—" : repo.release,
2451
+ repo.tag ?? "—",
2452
+ repo.npm ?? "—",
2453
+ unreleasedCell(repo),
2454
+ pullRequestCell(repo),
2455
+ pinsCell(repo)
2456
+ ]));
2457
+ ui.heading("Waits for a person");
2458
+ const waiting = repos.flatMap((repo) => attention(repo).map((line) => `${repo.name}: ${line}`));
2459
+ if (waiting.length === 0) ui.check(true, "nothing waits");
2460
+ for (const line of waiting) ui.check(false, line);
2461
+ }
2462
+ /**
2463
+ * Read the state of every repository of the organization and print it.
2464
+ *
2465
+ * @param options - The ports, console and output flags.
2466
+ * @returns The process exit code: `1` only when the state could not be read.
2467
+ * @example
2468
+ * const code = await runFleet({ ctx, ui });
2469
+ */
2470
+ async function runFleet(options) {
2471
+ const { ctx, ui, json = false, markdown = false } = options;
2472
+ if (!(json || markdown)) ui.lockup({
2473
+ wordmark: "moku release",
2474
+ label: "fleet"
2475
+ });
2476
+ const org = await organizationOf(ctx);
2477
+ if (org === void 0) {
2478
+ ui.error("no GitHub organization found", "run inside a checkout whose `origin` is on GitHub");
2479
+ return 1;
2480
+ }
2481
+ const answer = await ctx.exec.capture("gh", [
2482
+ "api",
2483
+ "graphql",
2484
+ "-F",
2485
+ `org=${org}`,
2486
+ "-f",
2487
+ `query=${FLEET_QUERY}`
2488
+ ]);
2489
+ const parsed = answer.code === 0 ? parseFleet(answer.stdout) : void 0;
2490
+ if (parsed === void 0) {
2491
+ ui.error(`could not read the repositories of ${org}`, answer.stderr.trim() || "gh auth login");
2492
+ return 1;
2493
+ }
2494
+ const repos = await withRegistry(ctx, parsed);
2495
+ if (json) ui.line(JSON.stringify(repos, void 0, 2));
2496
+ else if (markdown) ui.line(renderFleetMarkdown(org, repos));
2497
+ else renderTerminal(ui, repos);
2498
+ return 0;
2499
+ }
2500
+ //#endregion
1979
2501
  //#region src/commands/release.ts
1980
2502
  /** Workflow file dispatched by name — the same name npm's trusted publisher is bound to. */
1981
- const PUBLISH_WORKFLOW_FILE = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
2503
+ const PUBLISH_WORKFLOW_FILE$1 = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
1982
2504
  /** How long to keep asking the registry for the new version before giving up. */
1983
- const REGISTRY_POLL_ATTEMPTS = 60;
2505
+ const REGISTRY_POLL_ATTEMPTS$1 = 60;
1984
2506
  /** Gap between registry polls — 60 × 10s = ten minutes. The first live release needed three. */
1985
2507
  const REGISTRY_POLL_INTERVAL_MS = 1e4;
1986
2508
  /** How many times to look for the dispatched run before concluding it never started. */
@@ -1995,7 +2517,7 @@ const RUN_LOOKUP_INTERVAL_MS = 3e3;
1995
2517
  * @example
1996
2518
  * await defaultSleep(500);
1997
2519
  */
1998
- const defaultSleep = (ms) => new Promise((resolve) => {
2520
+ const defaultSleep$1 = (ms) => new Promise((resolve) => {
1999
2521
  setTimeout(resolve, ms);
2000
2522
  });
2001
2523
  /**
@@ -2026,7 +2548,7 @@ const LATEST_RUN_ARGS = [
2026
2548
  "run",
2027
2549
  "list",
2028
2550
  "--workflow",
2029
- PUBLISH_WORKFLOW_FILE,
2551
+ PUBLISH_WORKFLOW_FILE$1,
2030
2552
  "--branch",
2031
2553
  "main",
2032
2554
  "--limit",
@@ -2081,7 +2603,7 @@ async function findDispatchedRun(ctx, previous, sleep) {
2081
2603
  * await awaitPublishedVersion(ctx, "@moku-labs/common", "latest", "1.2.2", defaultSleep);
2082
2604
  */
2083
2605
  async function awaitPublishedVersion(ctx, name, tag, before, sleep) {
2084
- for (let attempt = 0; attempt < REGISTRY_POLL_ATTEMPTS; attempt += 1) {
2606
+ for (let attempt = 0; attempt < REGISTRY_POLL_ATTEMPTS$1; attempt += 1) {
2085
2607
  const view = await ctx.exec.capture("npm", [
2086
2608
  "view",
2087
2609
  name,
@@ -2124,7 +2646,7 @@ function renderSummary(ui, name, version, ownerRepo) {
2124
2646
  * const code = await runRelease({ ctx, ui, releaseType: "patch" });
2125
2647
  */
2126
2648
  async function runRelease(options) {
2127
- const { ctx, ui, releaseType, dryRun = false, sleep = defaultSleep } = options;
2649
+ const { ctx, ui, releaseType, dryRun = false, sleep = defaultSleep$1 } = options;
2128
2650
  ui.lockup({
2129
2651
  wordmark: "moku release",
2130
2652
  label: dryRun ? `${releaseType} · dry-run` : releaseType
@@ -2146,7 +2668,7 @@ async function runRelease(options) {
2146
2668
  ])).stdout)?.[tag];
2147
2669
  if (dryRun) {
2148
2670
  ui.heading("Plan");
2149
- ui.info(`gh workflow run ${PUBLISH_WORKFLOW_FILE} -f release_type=${releaseType} --ref main`);
2671
+ ui.info(`gh workflow run ${PUBLISH_WORKFLOW_FILE$1} -f release_type=${releaseType} --ref main`);
2150
2672
  ui.info(`watch the run, then wait for npm dist-tag \`${tag}\` to move from ${before ?? "—"}`);
2151
2673
  return 0;
2152
2674
  }
@@ -2155,7 +2677,7 @@ async function runRelease(options) {
2155
2677
  const dispatched = await ctx.exec.capture("gh", [
2156
2678
  "workflow",
2157
2679
  "run",
2158
- PUBLISH_WORKFLOW_FILE,
2680
+ PUBLISH_WORKFLOW_FILE$1,
2159
2681
  "-f",
2160
2682
  `release_type=${releaseType}`,
2161
2683
  "--ref",
@@ -2165,7 +2687,7 @@ async function runRelease(options) {
2165
2687
  ui.error("could not dispatch the workflow", dispatched.stderr.trim());
2166
2688
  return 1;
2167
2689
  }
2168
- ui.check(true, `${PUBLISH_WORKFLOW_FILE} dispatched (${releaseType})`);
2690
+ ui.check(true, `${PUBLISH_WORKFLOW_FILE$1} dispatched (${releaseType})`);
2169
2691
  const runId = await findDispatchedRun(ctx, previousRun, sleep);
2170
2692
  if (runId === void 0) {
2171
2693
  ui.error("the dispatched run never appeared — check GitHub Actions");
@@ -2647,13 +3169,200 @@ async function runSetup(options) {
2647
3169
  return report.failed ? 1 : 0;
2648
3170
  }
2649
3171
  //#endregion
3172
+ //#region src/commands/wait.ts
3173
+ /** Workflow file whose runs are read — the one that releases on a push to main. */
3174
+ const PUBLISH_WORKFLOW_FILE = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
3175
+ /** How long to wait for a tag that holds the commit — 120 × 10s = twenty minutes. */
3176
+ const TAG_POLL_ATTEMPTS = 120;
3177
+ /** How long to wait for a run to start before saying none will — 12 × 10s = two minutes. */
3178
+ const RUN_START_ATTEMPTS = 12;
3179
+ /** How long to keep asking the registry for the version — 60 × 10s = ten minutes. */
3180
+ const REGISTRY_POLL_ATTEMPTS = 60;
3181
+ /** Gap between polls of every loop here. */
3182
+ const POLL_INTERVAL_MS = 1e4;
3183
+ /** The `gh run list` call that reads the newest `publish.yml` runs on main. */
3184
+ const RECENT_RUNS_ARGS = [
3185
+ "run",
3186
+ "list",
3187
+ "--workflow",
3188
+ PUBLISH_WORKFLOW_FILE,
3189
+ "--branch",
3190
+ "main",
3191
+ "--limit",
3192
+ "5",
3193
+ "--json",
3194
+ "databaseId,headSha,status,conclusion"
3195
+ ];
3196
+ /** Argv for the release tags that hold a commit, oldest first: the first one shipped it. */
3197
+ const TAGS_WITH_HEAD_ARGS = [
3198
+ "-c",
3199
+ "versionsort.suffix=-",
3200
+ "tag",
3201
+ "--list",
3202
+ "v*",
3203
+ "--contains",
3204
+ "HEAD",
3205
+ "--sort=v:refname"
3206
+ ];
3207
+ /**
3208
+ * The default delay helper.
3209
+ *
3210
+ * @param ms - Milliseconds to wait.
3211
+ * @returns Resolves after the delay.
3212
+ * @example
3213
+ * await defaultSleep(500);
3214
+ */
3215
+ const defaultSleep = (ms) => new Promise((resolve) => {
3216
+ setTimeout(resolve, ms);
3217
+ });
3218
+ /**
3219
+ * Whether `HEAD` is an ancestor of a ref, i.e. the ref holds the commit.
3220
+ *
3221
+ * @param ctx - The ports and flags.
3222
+ * @param ref - A branch, tag or sha.
3223
+ * @returns `true` when the ref holds `HEAD`.
3224
+ * @example
3225
+ * await holdsHead(ctx, "origin/main");
3226
+ */
3227
+ async function holdsHead(ctx, ref) {
3228
+ return (await ctx.exec.capture("git", [
3229
+ "merge-base",
3230
+ "--is-ancestor",
3231
+ "HEAD",
3232
+ ref
3233
+ ])).code === 0;
3234
+ }
3235
+ /**
3236
+ * The newest `publish.yml` run whose commit holds `HEAD`. A run of a later merge counts:
3237
+ * its version carries the commit too.
3238
+ *
3239
+ * @param ctx - The ports and flags.
3240
+ * @returns The run, or `undefined` when no run covers the commit yet.
3241
+ * @example
3242
+ * const run = await coveringRun(ctx);
3243
+ */
3244
+ async function coveringRun(ctx) {
3245
+ const listing = await ctx.exec.capture("gh", RECENT_RUNS_ARGS);
3246
+ if (listing.code !== 0) return void 0;
3247
+ for (const run of parseRuns(listing.stdout)) if (await holdsHead(ctx, run.headSha)) return run;
3248
+ }
3249
+ /**
3250
+ * Wait until a release tag holds `HEAD`. Stops early when the covering run failed, or when
3251
+ * no run started at all.
3252
+ *
3253
+ * @param ctx - The ports and flags.
3254
+ * @param ui - The branded console.
3255
+ * @param sleep - The delay helper.
3256
+ * @returns The tag, or `undefined` after the reason was printed.
3257
+ * @example
3258
+ * const tag = await awaitTag(ctx, ui, defaultSleep);
3259
+ */
3260
+ async function awaitTag(ctx, ui, sleep) {
3261
+ for (let attempt = 0; attempt < TAG_POLL_ATTEMPTS; attempt += 1) {
3262
+ await ctx.exec.capture("git", [
3263
+ "fetch",
3264
+ "--tags",
3265
+ "--prune"
3266
+ ]);
3267
+ const tags = await ctx.exec.capture("git", TAGS_WITH_HEAD_ARGS);
3268
+ const tag = tags.code === 0 ? latestVersionTag(tags.stdout) : void 0;
3269
+ if (tag !== void 0) return tag;
3270
+ const run = await coveringRun(ctx);
3271
+ if (run === void 0 && attempt >= RUN_START_ATTEMPTS) {
3272
+ ui.error(`no ${PUBLISH_WORKFLOW_FILE} run started for this commit`, `${PUBLISH_WORKFLOW_FILE} needs \`push: branches: [main]\`; a Dependabot merge needs the dispatch step of dependabot-automerge.yml`);
3273
+ return;
3274
+ }
3275
+ if (run?.status === "completed" && run.conclusion !== "success") {
3276
+ ui.error(`run ${run.databaseId} ended as ${run.conclusion}`, `gh run view ${run.databaseId} --log-failed`);
3277
+ return;
3278
+ }
3279
+ await sleep(POLL_INTERVAL_MS);
3280
+ }
3281
+ ui.error("no release tag holds this commit after twenty minutes");
3282
+ }
3283
+ /**
3284
+ * Poll the registry until it serves one exact version. The registry lags behind a
3285
+ * successful publish, so "not there yet" is expected for a while.
3286
+ *
3287
+ * @param ctx - The ports and flags.
3288
+ * @param name - The package name.
3289
+ * @param version - The version the tag names.
3290
+ * @param sleep - The delay helper.
3291
+ * @returns `true` when the registry serves the version.
3292
+ * @example
3293
+ * await awaitVersion(ctx, "@moku-labs/common", "1.2.4", defaultSleep);
3294
+ */
3295
+ async function awaitVersion(ctx, name, version, sleep) {
3296
+ for (let attempt = 0; attempt < REGISTRY_POLL_ATTEMPTS; attempt += 1) {
3297
+ const view = await ctx.exec.capture("npm", [
3298
+ "view",
3299
+ `${name}@${version}`,
3300
+ "version"
3301
+ ]);
3302
+ if (view.code === 0 && view.stdout.trim() === version) return true;
3303
+ await sleep(POLL_INTERVAL_MS);
3304
+ }
3305
+ return false;
3306
+ }
3307
+ /**
3308
+ * Wait for the auto-release of `HEAD`: the commit is on `origin/main`, a release tag holds
3309
+ * it, and npm serves that version.
3310
+ *
3311
+ * @param options - The ports, console and delay helper.
3312
+ * @returns The process exit code.
3313
+ * @example
3314
+ * const code = await runWait({ ctx, ui });
3315
+ */
3316
+ async function runWait(options) {
3317
+ const { ctx, ui, sleep = defaultSleep } = options;
3318
+ ui.lockup({
3319
+ wordmark: "moku release",
3320
+ label: "wait"
3321
+ });
3322
+ const manifest = await readManifest(ctx.files);
3323
+ if (!manifest?.name) {
3324
+ ui.error("package.json is missing, malformed, or has no `name`");
3325
+ return 1;
3326
+ }
3327
+ await ctx.exec.capture("git", [
3328
+ "fetch",
3329
+ "--tags",
3330
+ "--prune"
3331
+ ]);
3332
+ if (!await holdsHead(ctx, "origin/main")) {
3333
+ ui.error("HEAD is not on origin/main", "merge the PR, then: git checkout main && git pull");
3334
+ return 1;
3335
+ }
3336
+ ui.heading("Tag");
3337
+ const tag = await awaitTag(ctx, ui, sleep);
3338
+ if (tag === void 0) return 1;
3339
+ ui.check(true, `${tag} holds this commit`);
3340
+ ui.heading("Registry");
3341
+ const version = tag.replace(/^v/, "");
3342
+ if (!await awaitVersion(ctx, manifest.name, version, sleep)) {
3343
+ ui.error(`npm does not serve ${manifest.name}@${version} after ten minutes`);
3344
+ return 1;
3345
+ }
3346
+ const declared = repositoryUrlOf(manifest);
3347
+ const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
3348
+ const lines = [
3349
+ ui.railLine(manifest.name, version, 48),
3350
+ ui.railLine("tag", tag, 48),
3351
+ ui.railLine("npm", npmPackageUrl(manifest.name, version), 48)
3352
+ ];
3353
+ if (ownerRepo !== void 0) lines.push(ui.railLine("release", releaseUrl(ownerRepo, tag), 48));
3354
+ ui.heading("Released");
3355
+ ui.box(lines);
3356
+ return 0;
3357
+ }
3358
+ //#endregion
2650
3359
  //#region src/lib/argv.ts
2651
3360
  /**
2652
3361
  * @file `moku-release` — argv parsing, kept out of the entry so the entry stays a
2653
3362
  * dispatcher.
2654
3363
  *
2655
3364
  * The grammar is deliberately tiny: one positional (a command name, or a semver bump that
2656
- * implies the `release` command) plus four flags. Anything unrecognized resolves to
3365
+ * implies the `release` command) plus five flags. Anything unrecognized resolves to
2657
3366
  * `help` with an `error` set — the CLI never guesses what an operator meant.
2658
3367
  */
2659
3368
  /** The semver bumps `moku-release <type>` accepts, in menu order. */
@@ -2666,6 +3375,7 @@ const RELEASE_TYPES = [
2666
3375
  /** Every flag the CLI accepts, besides `--help`. */
2667
3376
  const KNOWN_FLAGS = /* @__PURE__ */ new Set([
2668
3377
  "--json",
3378
+ "--markdown",
2669
3379
  "--dry-run",
2670
3380
  "--yes",
2671
3381
  "-y"
@@ -2729,12 +3439,19 @@ function parseArgv(argv) {
2729
3439
  dryRun,
2730
3440
  yes
2731
3441
  };
2732
- if (first === "doctor" || first === "setup") return {
3442
+ if (first === "doctor" || first === "setup" || first === "wait") return {
2733
3443
  command: first,
2734
3444
  json,
2735
3445
  dryRun,
2736
3446
  yes
2737
3447
  };
3448
+ if (first === "fleet") return {
3449
+ command: first,
3450
+ json,
3451
+ dryRun,
3452
+ yes,
3453
+ markdown: flags.includes("--markdown")
3454
+ };
2738
3455
  if (first === "help") return {
2739
3456
  command: "help",
2740
3457
  json,
@@ -2929,8 +3646,14 @@ function createFileStore(root) {
2929
3646
  const USAGE = [
2930
3647
  " moku-release setup one-time wizard: workflows, contract, first publish",
2931
3648
  " moku-release doctor [--json] read-only diagnosis of the release setup",
3649
+ " moku-release wait wait for the auto-release of HEAD, verify it on npm",
3650
+ " moku-release fleet [--json|--markdown]",
3651
+ " every repository of the organization in one table",
2932
3652
  ` moku-release <${RELEASE_TYPES.join("|")}>`,
2933
3653
  "",
3654
+ " A merge to main releases a patch by itself. A person releases minor,",
3655
+ " or major for a milestone.",
3656
+ "",
2934
3657
  " --dry-run print every action, mutate nothing, ask nothing",
2935
3658
  " --yes, -y answer every setup confirmation with yes",
2936
3659
  "",
@@ -2986,6 +3709,16 @@ async function dispatch(parsed, ctx, ui) {
2986
3709
  dryRun: parsed.dryRun,
2987
3710
  unattended: parsed.yes
2988
3711
  });
3712
+ if (parsed.command === "wait") return runWait({
3713
+ ctx,
3714
+ ui
3715
+ });
3716
+ if (parsed.command === "fleet") return runFleet({
3717
+ ctx,
3718
+ ui,
3719
+ json: parsed.json,
3720
+ markdown: parsed.markdown === true
3721
+ });
2989
3722
  if (parsed.command === "release" && parsed.releaseType !== void 0) return runRelease({
2990
3723
  ctx,
2991
3724
  ui,
@@ -0,0 +1,51 @@
1
+ name: Fleet status
2
+
3
+ # For the organization's `.github` repository. Every hour it reads the state of every public
4
+ # repository with `moku-release fleet` and puts the table into profile/README.md, the page
5
+ # GitHub shows on the organization. It commits only when the table changed.
6
+ #
7
+ # No token is added: GITHUB_TOKEN reads the public repositories and writes this one.
8
+ on:
9
+ schedule:
10
+ - cron: "17 * * * *"
11
+ workflow_dispatch:
12
+
13
+ concurrency:
14
+ group: fleet
15
+ cancel-in-progress: true
16
+
17
+ permissions:
18
+ contents: write # commit profile/README.md
19
+
20
+ jobs:
21
+ fleet:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
25
+ - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
26
+ with:
27
+ node-version: 24
28
+ - name: Read the fleet
29
+ env:
30
+ GH_TOKEN: ${{ github.token }}
31
+ run: npx --yes --package @moku-labs/ci@1 moku-release fleet --markdown > fleet.md
32
+ - name: Put the table between the markers of profile/README.md
33
+ run: |
34
+ set -euo pipefail
35
+ # An empty table means the read failed without an exit code; keep the old one.
36
+ [ -s fleet.md ] || { echo "fleet.md is empty"; exit 1; }
37
+ awk -v table=fleet.md '
38
+ /<!-- fleet:start -->/ { print; while ((getline line < table) > 0) print line; skip = 1; next }
39
+ /<!-- fleet:end -->/ { skip = 0 }
40
+ !skip
41
+ ' profile/README.md > README.next
42
+ mv README.next profile/README.md
43
+ rm fleet.md
44
+ - name: Commit when the table changed
45
+ run: |
46
+ set -euo pipefail
47
+ git diff --quiet && { echo "no change"; exit 0; }
48
+ git config user.name "github-actions[bot]"
49
+ git config user.email "github-actions[bot]@users.noreply.github.com"
50
+ git commit -qam "fleet: status"
51
+ git push
@@ -2,20 +2,47 @@ name: Dependabot automerge
2
2
 
3
3
  # Turns on auto-merge for a Dependabot PR. The branch ruleset stays the gate: the merge
4
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.
5
+ # allow_auto_merge, which `moku-release setup` turns on. After the merge it dispatches
6
+ # publish.yml, so a Dependabot merge releases a patch like any other merge.
6
7
  on: pull_request
7
8
 
9
+ # One waiter per PR: a rebase of the PR starts a new run and ends the old one.
10
+ concurrency:
11
+ group: dependabot-automerge-${{ github.event.pull_request.number }}
12
+ cancel-in-progress: true
13
+
8
14
  permissions:
9
15
  contents: write
10
16
  pull-requests: write
17
+ actions: write # dispatch publish.yml after the merge
11
18
 
12
19
  jobs:
13
20
  automerge:
14
21
  if: github.event.pull_request.user.login == 'dependabot[bot]'
15
22
  runs-on: ubuntu-latest
23
+ env:
24
+ PR_URL: ${{ github.event.pull_request.html_url }}
25
+ REPO: ${{ github.repository }}
26
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
16
27
  steps:
17
28
  - name: Merge once required checks pass
18
29
  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 }}
30
+ - name: Release the merge as a patch
31
+ # A merge made by GITHUB_TOKEN starts no push run, so the auto-release of publish.yml
32
+ # never sees it. workflow_dispatch is the one event GITHUB_TOKEN may start: wait for
33
+ # the merge, then dispatch the same patch a push would have cut.
34
+ run: |
35
+ set -euo pipefail
36
+ for _ in $(seq 1 90); do
37
+ state="$(gh pr view "$PR_URL" --json state --jq .state)"
38
+ case "$state" in
39
+ MERGED)
40
+ gh workflow run publish.yml --repo "$REPO" --ref main -f release_type=patch
41
+ exit 0 ;;
42
+ CLOSED)
43
+ echo "closed without a merge, nothing to release"
44
+ exit 0 ;;
45
+ esac
46
+ sleep 20
47
+ done
48
+ echo "::warning::not merged after 30 minutes, no release was dispatched"
@@ -8,6 +8,11 @@ name: Release
8
8
  # `npm publish` runs HERE, in this repo's own publish.yml, so the OIDC claim's
9
9
  # job_workflow_ref points at <owner>/<repo>/.github/workflows/publish.yml.
10
10
  on:
11
+ # Auto-release: every merge to main ships a PATCH. A person dispatches minor, or major for
12
+ # a milestone. A PR merged by GITHUB_TOKEN starts no push run: dependabot-automerge.yml
13
+ # dispatches the patch for its own merges.
14
+ push:
15
+ branches: [main]
11
16
  workflow_dispatch:
12
17
  inputs:
13
18
  release_type:
@@ -39,7 +44,7 @@ jobs:
39
44
  if: |
40
45
  always() && needs.release.outputs.artifact_name != '' &&
41
46
  (
42
- (github.event_name == 'workflow_dispatch' && needs.release.outputs.tag != '') ||
47
+ (github.event_name != 'release' && needs.release.outputs.tag != '') ||
43
48
  github.event_name == 'release'
44
49
  )
45
50
  runs-on: ubuntu-latest
@@ -49,7 +54,7 @@ jobs:
49
54
  steps:
50
55
  - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
51
56
  with:
52
- ref: ${{ github.event_name == 'workflow_dispatch' && needs.release.outputs.tag || github.ref }}
57
+ ref: ${{ github.event_name != 'release' && needs.release.outputs.tag || github.ref }}
53
58
  persist-credentials: false
54
59
  - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
55
60
  with:
@@ -75,7 +80,7 @@ jobs:
75
80
  run: |
76
81
  set -euo pipefail
77
82
  pkg="$(node -p "require('./package.json').version")"
78
- if [ "$EVENT" = "workflow_dispatch" ]; then want="$DISPATCH_VERSION"; else want="${REF_NAME#v}"; fi
83
+ if [ "$EVENT" = "release" ]; then want="${REF_NAME#v}"; else want="$DISPATCH_VERSION"; fi
79
84
  [ -n "$want" ] || { echo "empty version/ref — refusing to publish"; exit 1; }
80
85
  [ "$pkg" = "$want" ] || { echo "package.json $pkg != ref $want"; exit 1; }
81
86
  # Prereleases go to dist-tag 'next' so they never clobber 'latest'.
@@ -3,6 +3,11 @@ name: Release
3
3
  # Thin caller. The FILENAME is a contract: npm Trusted Publishing is registered against
4
4
  # "publish.yml" for this repo — renaming it breaks tokenless publishing.
5
5
  on:
6
+ # Auto-release: every merge to main ships a PATCH. A person dispatches minor, or major for
7
+ # a milestone. A PR merged by GITHUB_TOKEN starts no push run: dependabot-automerge.yml
8
+ # dispatches the patch for its own merges.
9
+ push:
10
+ branches: [main]
6
11
  workflow_dispatch:
7
12
  inputs:
8
13
  release_type:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moku-labs/ci",
3
- "version": "1.4.0",
3
+ "version": "1.5.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": [
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@biomejs/biome": "2.4.16",
35
- "@moku-labs/common": "0.3.3",
35
+ "@moku-labs/common": "0.3.4",
36
36
  "@types/bun": "1.3.14",
37
37
  "@vitest/coverage-istanbul": "4.0.18",
38
38
  "eslint": "9.39.3",