@moku-labs/ci 1.5.0 → 1.5.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.
- package/README.md +2 -0
- package/dist/release.mjs +484 -3
- package/examples/org/fleet.yml +51 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -119,6 +119,7 @@ flowchart LR
|
|
|
119
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. |
|
|
120
120
|
| [`rulesets/main.json`](rulesets/main.json) | Branch ruleset for `main`: PR only, no force-push, the four `ci / …` checks required. |
|
|
121
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`. |
|
|
122
|
+
| [`examples/org/fleet.yml`](examples/org/fleet.yml) | For the organization's `.github` repository. Every 15 minutes it runs `moku-release fleet --markdown` and puts the table between `<!-- fleet:start -->` and `<!-- fleet:end -->` of `profile/README.md`. |
|
|
122
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. |
|
|
123
124
|
|
|
124
125
|
> [!TIP]
|
|
@@ -208,6 +209,7 @@ Examples: `examples/package/ci-with-demos.yml` (label `demos:full`) and
|
|
|
208
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. |
|
|
209
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. |
|
|
210
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. |
|
|
211
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. |
|
|
212
214
|
|
|
213
215
|
The scripts are plain aliases of the `moku-release` bin. Internals and the list of checks:
|
package/dist/release.mjs
CHANGED
|
@@ -833,7 +833,7 @@ const REQUIRED_SCRIPTS = {
|
|
|
833
833
|
* @example
|
|
834
834
|
* parseManifest('{"name":"x"}');
|
|
835
835
|
*/
|
|
836
|
-
function parseManifest(text) {
|
|
836
|
+
function parseManifest$1(text) {
|
|
837
837
|
if (text === void 0) return void 0;
|
|
838
838
|
try {
|
|
839
839
|
const parsed = JSON.parse(text);
|
|
@@ -852,7 +852,7 @@ function parseManifest(text) {
|
|
|
852
852
|
* const manifest = await readManifest(ctx.files);
|
|
853
853
|
*/
|
|
854
854
|
async function readManifest(files) {
|
|
855
|
-
return parseManifest(await files.read(MANIFEST_PATH));
|
|
855
|
+
return parseManifest$1(await files.read(MANIFEST_PATH));
|
|
856
856
|
}
|
|
857
857
|
/** Any character outside printable ASCII and the JSON whitespace. */
|
|
858
858
|
const NON_ASCII = /[^\t\n\r -~]/;
|
|
@@ -2035,6 +2035,471 @@ async function runDoctor(options) {
|
|
|
2035
2035
|
};
|
|
2036
2036
|
}
|
|
2037
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
|
+
/** The organization's profile repository. It holds the table itself and is not a row of it. */
|
|
2054
|
+
const PROFILE_REPO = ".github";
|
|
2055
|
+
/** Matches a release tag, prereleases included: `v1.2.3`, `v1.2.3-rc.0`. Not the moving `v1`. */
|
|
2056
|
+
const RELEASE_TAG = /^v\d+\.\d+\.\d+/;
|
|
2057
|
+
/** Matches a dependency pinned to one exact version. A range is not a pin and is not judged. */
|
|
2058
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+/;
|
|
2059
|
+
/** The query `gh api graphql -F org=<org>` runs. One call answers the whole table. */
|
|
2060
|
+
const FLEET_QUERY = `query($org: String!) {
|
|
2061
|
+
organization(login: $org) {
|
|
2062
|
+
repositories(first: 100, isArchived: false, orderBy: { field: NAME, direction: ASC }) {
|
|
2063
|
+
nodes {
|
|
2064
|
+
name
|
|
2065
|
+
isPrivate
|
|
2066
|
+
manifest: object(expression: "HEAD:package.json") { ... on Blob { text } }
|
|
2067
|
+
publish: object(expression: "HEAD:.github/workflows/publish.yml") { ... on Blob { byteSize } }
|
|
2068
|
+
defaultBranchRef {
|
|
2069
|
+
target {
|
|
2070
|
+
... on Commit {
|
|
2071
|
+
oid
|
|
2072
|
+
history(first: 30) { nodes { oid } }
|
|
2073
|
+
statusCheckRollup { contexts(first: 100) { nodes { ... on CheckRun { name status conclusion } } } }
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
refs(refPrefix: "refs/tags/", first: 10, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {
|
|
2078
|
+
nodes {
|
|
2079
|
+
name
|
|
2080
|
+
target {
|
|
2081
|
+
... on Commit { parents(first: 1) { nodes { oid } } }
|
|
2082
|
+
... on Tag { target { ... on Commit { parents(first: 1) { nodes { oid } } } } }
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
pullRequests(states: OPEN, first: 50) {
|
|
2087
|
+
nodes {
|
|
2088
|
+
number
|
|
2089
|
+
isDraft
|
|
2090
|
+
author { login }
|
|
2091
|
+
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
}`;
|
|
2098
|
+
/**
|
|
2099
|
+
* Fold the checks of one pipeline into one state. Red wins over running, running over green.
|
|
2100
|
+
*
|
|
2101
|
+
* @param checks - The checks that belong to the pipeline.
|
|
2102
|
+
* @returns The state of the pipeline.
|
|
2103
|
+
* @example
|
|
2104
|
+
* pipelineState([{ status: "COMPLETED", conclusion: "FAILURE" }]); // "red"
|
|
2105
|
+
*/
|
|
2106
|
+
function pipelineState(checks) {
|
|
2107
|
+
if (checks.length === 0) return "none";
|
|
2108
|
+
const done = checks.filter((check) => check.status === "COMPLETED");
|
|
2109
|
+
const passed = /* @__PURE__ */ new Set([
|
|
2110
|
+
"SUCCESS",
|
|
2111
|
+
"SKIPPED",
|
|
2112
|
+
"NEUTRAL"
|
|
2113
|
+
]);
|
|
2114
|
+
if (done.some((check) => !passed.has(check.conclusion ?? ""))) return "red";
|
|
2115
|
+
return done.length === checks.length ? "green" : "running";
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* The state of a pull request from the rollup state GitHub computes for its newest commit.
|
|
2119
|
+
*
|
|
2120
|
+
* @param state - `SUCCESS`, `FAILURE`, `ERROR`, `PENDING`, `EXPECTED`, or nothing.
|
|
2121
|
+
* @returns The matching pipeline state.
|
|
2122
|
+
* @example
|
|
2123
|
+
* rollupState("FAILURE"); // "red"
|
|
2124
|
+
*/
|
|
2125
|
+
function rollupState(state) {
|
|
2126
|
+
if (state === "SUCCESS") return "green";
|
|
2127
|
+
if (state === "FAILURE" || state === "ERROR") return "red";
|
|
2128
|
+
return state === void 0 ? "none" : "running";
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Parse a manifest text, tolerating a missing or malformed file.
|
|
2132
|
+
*
|
|
2133
|
+
* @param text - The `package.json` text, when the repository has one.
|
|
2134
|
+
* @returns The manifest, or an empty one.
|
|
2135
|
+
* @example
|
|
2136
|
+
* parseManifest('{"name":"@moku-labs/core"}');
|
|
2137
|
+
*/
|
|
2138
|
+
function parseManifest(text) {
|
|
2139
|
+
try {
|
|
2140
|
+
const parsed = JSON.parse(text ?? "");
|
|
2141
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
2142
|
+
} catch {
|
|
2143
|
+
return {};
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* The newest release tag of a repository and the commit it released.
|
|
2148
|
+
*
|
|
2149
|
+
* @param raw - The repository, as the query returns it.
|
|
2150
|
+
* @returns The tag and the released commit, or nothing when no release tag exists.
|
|
2151
|
+
* @example
|
|
2152
|
+
* const newest = newestRelease(raw);
|
|
2153
|
+
*/
|
|
2154
|
+
function newestRelease(raw) {
|
|
2155
|
+
const [newest] = (raw.refs?.nodes ?? []).filter((node) => RELEASE_TAG.test(node.name ?? "")).toSorted((a, b) => compareSemver(b.name ?? "", a.name ?? ""));
|
|
2156
|
+
if (newest?.name === void 0) return void 0;
|
|
2157
|
+
const commit = newest.target?.parents === void 0 ? newest.target?.target : newest.target;
|
|
2158
|
+
return {
|
|
2159
|
+
tag: newest.name,
|
|
2160
|
+
released: commit?.parents?.nodes?.[0]?.oid
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* The exact family pins of a manifest, from `dependencies` and `devDependencies`.
|
|
2165
|
+
*
|
|
2166
|
+
* @param manifest - The parsed manifest.
|
|
2167
|
+
* @returns Dependency name to pinned version, for exact `@moku-labs/*` pins only.
|
|
2168
|
+
* @example
|
|
2169
|
+
* familyPins({ dependencies: { "@moku-labs/core": "1.7.1" } }); // { "@moku-labs/core": "1.7.1" }
|
|
2170
|
+
*/
|
|
2171
|
+
function familyPins(manifest) {
|
|
2172
|
+
const all = {
|
|
2173
|
+
...manifest.devDependencies,
|
|
2174
|
+
...manifest.dependencies
|
|
2175
|
+
};
|
|
2176
|
+
return Object.fromEntries(Object.entries(all).filter(([name, spec]) => name.startsWith(FAMILY_SCOPE) && EXACT_VERSION.test(spec)));
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Whether a check belongs to `publish.yml`.
|
|
2180
|
+
*
|
|
2181
|
+
* @param check - One check of a commit.
|
|
2182
|
+
* @returns `true` for a `release / …` check.
|
|
2183
|
+
* @example
|
|
2184
|
+
* isRelease({ name: "release / publish" }); // true
|
|
2185
|
+
*/
|
|
2186
|
+
function isRelease(check) {
|
|
2187
|
+
return (check.name ?? "").startsWith(RELEASE_PREFIX);
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* The GitHub URL of a repository, or of a page inside it.
|
|
2191
|
+
*
|
|
2192
|
+
* @param org - The organization login.
|
|
2193
|
+
* @param repo - The row.
|
|
2194
|
+
* @param path - A path inside the repository, with the leading slash.
|
|
2195
|
+
* @returns The URL.
|
|
2196
|
+
* @example
|
|
2197
|
+
* repoUrl("moku-labs", row, "/pulls");
|
|
2198
|
+
*/
|
|
2199
|
+
function repoUrl(org, repo, path = "") {
|
|
2200
|
+
return `https://github.com/${org}/${repo.name}${path}`;
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Turn one repository of the answer into a row. The pins are judged later, once the
|
|
2204
|
+
* newest version of every package is known.
|
|
2205
|
+
*
|
|
2206
|
+
* @param raw - The repository, as the query returns it.
|
|
2207
|
+
* @returns The row, with `pins` still empty and the manifest pins kept in `pinned`.
|
|
2208
|
+
* @example
|
|
2209
|
+
* const row = toRepo(raw);
|
|
2210
|
+
*/
|
|
2211
|
+
function toRepo(raw) {
|
|
2212
|
+
const manifest = parseManifest(raw.manifest?.text ?? void 0);
|
|
2213
|
+
const head = raw.defaultBranchRef?.target;
|
|
2214
|
+
const checks = (head?.statusCheckRollup?.contexts?.nodes ?? []).filter((check) => check?.name !== void 0);
|
|
2215
|
+
const publishes = raw.publish?.byteSize !== void 0 && manifest.private !== true && manifest.name !== void 0;
|
|
2216
|
+
const newest = publishes ? newestRelease(raw) : void 0;
|
|
2217
|
+
const distance = (head?.history?.nodes ?? []).findIndex((node) => node.oid === newest?.released);
|
|
2218
|
+
return {
|
|
2219
|
+
name: raw.name ?? "",
|
|
2220
|
+
isPrivate: raw.isPrivate === true,
|
|
2221
|
+
...publishes && manifest.name !== void 0 ? { packageName: manifest.name } : {},
|
|
2222
|
+
ci: pipelineState(checks.filter((check) => !isRelease(check))),
|
|
2223
|
+
release: pipelineState(checks.filter((check) => isRelease(check))),
|
|
2224
|
+
...newest === void 0 ? {} : {
|
|
2225
|
+
tag: newest.tag,
|
|
2226
|
+
unreleased: distance === -1 ? 30 : distance
|
|
2227
|
+
},
|
|
2228
|
+
pullRequests: (raw.pullRequests?.nodes ?? []).map((pr) => ({
|
|
2229
|
+
number: pr.number ?? 0,
|
|
2230
|
+
bot: (pr.author?.login ?? "").endsWith("[bot]") || pr.author?.login === "dependabot",
|
|
2231
|
+
draft: pr.isDraft === true,
|
|
2232
|
+
state: rollupState(pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state)
|
|
2233
|
+
})),
|
|
2234
|
+
pins: [],
|
|
2235
|
+
pinned: familyPins(manifest)
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* Parse the answer of {@link FLEET_QUERY} into rows, pins judged against the newest tags.
|
|
2240
|
+
*
|
|
2241
|
+
* @param stdout - The raw `gh api graphql` output.
|
|
2242
|
+
* @returns One row per repository, or `undefined` when the answer is not the expected shape.
|
|
2243
|
+
* @example
|
|
2244
|
+
* const rows = parseFleet(stdout);
|
|
2245
|
+
*/
|
|
2246
|
+
function parseFleet(stdout) {
|
|
2247
|
+
let nodes;
|
|
2248
|
+
try {
|
|
2249
|
+
nodes = JSON.parse(stdout).data?.organization?.repositories?.nodes;
|
|
2250
|
+
} catch {
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
if (!Array.isArray(nodes)) return void 0;
|
|
2254
|
+
const rows = nodes.filter((raw) => raw.name !== PROFILE_REPO).map((raw) => toRepo(raw));
|
|
2255
|
+
const newest = new Map(rows.flatMap((row) => row.packageName !== void 0 && row.tag !== void 0 ? [[row.packageName, row.tag.slice(1)]] : []));
|
|
2256
|
+
return rows.map(({ pinned = {}, ...row }) => ({
|
|
2257
|
+
...row,
|
|
2258
|
+
pins: Object.entries(pinned).flatMap(([name, version]) => {
|
|
2259
|
+
const latest = newest.get(name);
|
|
2260
|
+
return latest !== void 0 && compareSemver(version, latest) < 0 ? [{
|
|
2261
|
+
name,
|
|
2262
|
+
pinned: version,
|
|
2263
|
+
latest
|
|
2264
|
+
}] : [];
|
|
2265
|
+
})
|
|
2266
|
+
}));
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* What waits for a person in one repository, most urgent first. An empty list is a quiet row.
|
|
2270
|
+
*
|
|
2271
|
+
* @param repo - The row.
|
|
2272
|
+
* @returns One short line per thing to look at.
|
|
2273
|
+
* @example
|
|
2274
|
+
* attention({ ...row, ci: "red" }); // ["CI is red on main"]
|
|
2275
|
+
*/
|
|
2276
|
+
function attention(repo) {
|
|
2277
|
+
const lines = [];
|
|
2278
|
+
const version = repo.tag?.slice(1);
|
|
2279
|
+
if (repo.ci === "red") lines.push("CI is red on main");
|
|
2280
|
+
if (repo.release === "red") lines.push("the release of main failed");
|
|
2281
|
+
if ((repo.unreleased ?? 0) > 0 && repo.release !== "running" && repo.release !== "red") {
|
|
2282
|
+
const count = repo.unreleased === 30 ? `30+` : `${repo.unreleased}`;
|
|
2283
|
+
lines.push(`${count} commit(s) on main are not released`);
|
|
2284
|
+
}
|
|
2285
|
+
if (repo.npm !== void 0 && version !== void 0 && repo.npm !== version) lines.push(`npm serves ${repo.npm}, the tag is ${repo.tag}`);
|
|
2286
|
+
for (const pr of repo.pullRequests.filter((open) => !open.draft)) {
|
|
2287
|
+
if (pr.state === "red") lines.push(`PR #${pr.number} has red checks`);
|
|
2288
|
+
if (pr.state === "green") lines.push(`PR #${pr.number} is green and not merged`);
|
|
2289
|
+
}
|
|
2290
|
+
return lines;
|
|
2291
|
+
}
|
|
2292
|
+
/** The mark of each state in the Markdown table. */
|
|
2293
|
+
const MARKS = {
|
|
2294
|
+
green: "🟢",
|
|
2295
|
+
red: "🔴",
|
|
2296
|
+
running: "🟡",
|
|
2297
|
+
none: "—"
|
|
2298
|
+
};
|
|
2299
|
+
/**
|
|
2300
|
+
* The open pull requests of a row as one short cell: the count, then what stands out.
|
|
2301
|
+
*
|
|
2302
|
+
* @param repo - The row.
|
|
2303
|
+
* @returns E.g. `3 (1 red, 2 bot)`, or `0`.
|
|
2304
|
+
* @example
|
|
2305
|
+
* pullRequestCell(row); // "3 (1 red, 2 bot)"
|
|
2306
|
+
*/
|
|
2307
|
+
function pullRequestCell(repo) {
|
|
2308
|
+
const open = repo.pullRequests;
|
|
2309
|
+
const red = open.filter((pr) => pr.state === "red").length;
|
|
2310
|
+
const bot = open.filter((pr) => pr.bot).length;
|
|
2311
|
+
const notes = [red > 0 ? `${red} red` : "", bot > 0 ? `${bot} bot` : ""].filter(Boolean);
|
|
2312
|
+
return notes.length > 0 ? `${open.length} (${notes.join(", ")})` : `${open.length}`;
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* The "not released" cell of a row.
|
|
2316
|
+
*
|
|
2317
|
+
* @param repo - The row.
|
|
2318
|
+
* @returns The number of commits after the released one, or a dash for a repository without releases.
|
|
2319
|
+
* @example
|
|
2320
|
+
* unreleasedCell(row); // "0"
|
|
2321
|
+
*/
|
|
2322
|
+
function unreleasedCell(repo) {
|
|
2323
|
+
if (repo.unreleased === void 0) return "—";
|
|
2324
|
+
return repo.unreleased === 30 ? `30+` : `${repo.unreleased}`;
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* The "old pins" cell of a row: every family dependency pinned below its newest release.
|
|
2328
|
+
* It is a column and not a line of {@link attention}: Dependabot moves the pins by itself.
|
|
2329
|
+
*
|
|
2330
|
+
* @param repo - The row.
|
|
2331
|
+
* @returns E.g. `core 1.7.1, ci 1.3.0`, or a dash.
|
|
2332
|
+
* @example
|
|
2333
|
+
* pinsCell(row); // "core 1.7.1"
|
|
2334
|
+
*/
|
|
2335
|
+
function pinsCell(repo) {
|
|
2336
|
+
if (repo.pins.length === 0) return "—";
|
|
2337
|
+
return repo.pins.map((pin) => `${pin.name.slice(11)} ${pin.pinned}`).join(", ");
|
|
2338
|
+
}
|
|
2339
|
+
/**
|
|
2340
|
+
* Render the public rows as a Markdown table with links, and the list of what waits. A
|
|
2341
|
+
* private repository is left out: the page this lands on is public.
|
|
2342
|
+
*
|
|
2343
|
+
* @param org - The organization login, for the links.
|
|
2344
|
+
* @param repos - The rows.
|
|
2345
|
+
* @returns The Markdown block, without a trailing newline.
|
|
2346
|
+
* @example
|
|
2347
|
+
* renderFleetMarkdown("moku-labs", rows);
|
|
2348
|
+
*/
|
|
2349
|
+
function renderFleetMarkdown(org, repos) {
|
|
2350
|
+
const shown = repos.filter((repo) => !repo.isPrivate);
|
|
2351
|
+
const rows = shown.map((repo) => [
|
|
2352
|
+
`[${repo.name}](${repoUrl(org, repo)})`,
|
|
2353
|
+
`[${MARKS[repo.ci]}](${repoUrl(org, repo, "/actions")})`,
|
|
2354
|
+
repo.packageName === void 0 ? "—" : `[${MARKS[repo.release]}](${repoUrl(org, repo, "/actions/workflows/publish.yml")})`,
|
|
2355
|
+
repo.tag === void 0 ? "—" : `[${repo.tag}](${repoUrl(org, repo, "/releases/tag/")}${repo.tag})`,
|
|
2356
|
+
repo.npm ?? "—",
|
|
2357
|
+
unreleasedCell(repo),
|
|
2358
|
+
`[${pullRequestCell(repo)}](${repoUrl(org, repo, "/pulls")})`,
|
|
2359
|
+
pinsCell(repo)
|
|
2360
|
+
].join(" | "));
|
|
2361
|
+
const waiting = shown.flatMap((repo) => attention(repo).map((line) => `- [${repo.name}](${repoUrl(org, repo)}): ${line}`));
|
|
2362
|
+
return [
|
|
2363
|
+
"| Repo | CI | Release | Tag | npm | Not released | PRs | Old pins |",
|
|
2364
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
2365
|
+
...rows.map((row) => `| ${row} |`),
|
|
2366
|
+
"",
|
|
2367
|
+
waiting.length === 0 ? "Nothing waits." : "**Waits for a person**",
|
|
2368
|
+
...waiting.length === 0 ? [] : ["", ...waiting]
|
|
2369
|
+
].join("\n");
|
|
2370
|
+
}
|
|
2371
|
+
//#endregion
|
|
2372
|
+
//#region src/commands/fleet.ts
|
|
2373
|
+
/**
|
|
2374
|
+
* The organization the command reads: the owner of this checkout's remote, or of the
|
|
2375
|
+
* manifest's `repository` when there is no remote.
|
|
2376
|
+
*
|
|
2377
|
+
* @param ctx - The ports and flags.
|
|
2378
|
+
* @returns The organization login, or `undefined` when neither names a GitHub owner.
|
|
2379
|
+
* @example
|
|
2380
|
+
* const org = await organizationOf(ctx); // "moku-labs"
|
|
2381
|
+
*/
|
|
2382
|
+
async function organizationOf(ctx) {
|
|
2383
|
+
const remote = await ctx.exec.capture("git", [
|
|
2384
|
+
"remote",
|
|
2385
|
+
"get-url",
|
|
2386
|
+
"origin"
|
|
2387
|
+
]);
|
|
2388
|
+
const manifest = await readManifest(ctx.files);
|
|
2389
|
+
const declared = manifest === void 0 ? void 0 : repositoryUrlOf(manifest);
|
|
2390
|
+
const url = remote.code === 0 ? remote.stdout : declared;
|
|
2391
|
+
return url === void 0 ? void 0 : ownerRepoFrom(url)?.split("/")[0];
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Add the version npm serves as `latest` to every row that publishes a package.
|
|
2395
|
+
*
|
|
2396
|
+
* @param ctx - The ports and flags.
|
|
2397
|
+
* @param repos - The rows from the query.
|
|
2398
|
+
* @returns The rows, with `npm` filled where the registry answered.
|
|
2399
|
+
* @example
|
|
2400
|
+
* const rows = await withRegistry(ctx, parsed);
|
|
2401
|
+
*/
|
|
2402
|
+
async function withRegistry(ctx, repos) {
|
|
2403
|
+
return Promise.all(repos.map(async (repo) => {
|
|
2404
|
+
if (repo.packageName === void 0) return repo;
|
|
2405
|
+
const view = await ctx.exec.capture("npm", [
|
|
2406
|
+
"view",
|
|
2407
|
+
repo.packageName,
|
|
2408
|
+
"dist-tags",
|
|
2409
|
+
"--json"
|
|
2410
|
+
]);
|
|
2411
|
+
const latest = view.code === 0 ? parseDistTags(view.stdout)?.latest : void 0;
|
|
2412
|
+
return latest === void 0 ? repo : {
|
|
2413
|
+
...repo,
|
|
2414
|
+
npm: latest
|
|
2415
|
+
};
|
|
2416
|
+
}));
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* Lay one row of the terminal table out in fixed columns.
|
|
2420
|
+
*
|
|
2421
|
+
* @param row - The cells of the row.
|
|
2422
|
+
* @returns The padded line.
|
|
2423
|
+
* @example
|
|
2424
|
+
* cells(["core", "green"]);
|
|
2425
|
+
*/
|
|
2426
|
+
function cells(row) {
|
|
2427
|
+
return row.map((cell, index) => cell.padEnd(index === 0 ? 10 : 9)).join(" ");
|
|
2428
|
+
}
|
|
2429
|
+
/**
|
|
2430
|
+
* Print the table and the list of what waits, for a person at a terminal.
|
|
2431
|
+
*
|
|
2432
|
+
* @param ui - The branded console.
|
|
2433
|
+
* @param repos - The rows.
|
|
2434
|
+
* @example
|
|
2435
|
+
* renderTerminal(ui, rows);
|
|
2436
|
+
*/
|
|
2437
|
+
function renderTerminal(ui, repos) {
|
|
2438
|
+
ui.heading("Fleet");
|
|
2439
|
+
ui.line(cells([
|
|
2440
|
+
"repo",
|
|
2441
|
+
"ci",
|
|
2442
|
+
"release",
|
|
2443
|
+
"tag",
|
|
2444
|
+
"npm",
|
|
2445
|
+
"ahead",
|
|
2446
|
+
"prs",
|
|
2447
|
+
"old pins"
|
|
2448
|
+
]));
|
|
2449
|
+
for (const repo of repos) ui.line(cells([
|
|
2450
|
+
repo.name,
|
|
2451
|
+
repo.ci,
|
|
2452
|
+
repo.packageName === void 0 ? "—" : repo.release,
|
|
2453
|
+
repo.tag ?? "—",
|
|
2454
|
+
repo.npm ?? "—",
|
|
2455
|
+
unreleasedCell(repo),
|
|
2456
|
+
pullRequestCell(repo),
|
|
2457
|
+
pinsCell(repo)
|
|
2458
|
+
]));
|
|
2459
|
+
ui.heading("Waits for a person");
|
|
2460
|
+
const waiting = repos.flatMap((repo) => attention(repo).map((line) => `${repo.name}: ${line}`));
|
|
2461
|
+
if (waiting.length === 0) ui.check(true, "nothing waits");
|
|
2462
|
+
for (const line of waiting) ui.check(false, line);
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Read the state of every repository of the organization and print it.
|
|
2466
|
+
*
|
|
2467
|
+
* @param options - The ports, console and output flags.
|
|
2468
|
+
* @returns The process exit code: `1` only when the state could not be read.
|
|
2469
|
+
* @example
|
|
2470
|
+
* const code = await runFleet({ ctx, ui });
|
|
2471
|
+
*/
|
|
2472
|
+
async function runFleet(options) {
|
|
2473
|
+
const { ctx, ui, json = false, markdown = false } = options;
|
|
2474
|
+
if (!(json || markdown)) ui.lockup({
|
|
2475
|
+
wordmark: "moku release",
|
|
2476
|
+
label: "fleet"
|
|
2477
|
+
});
|
|
2478
|
+
const org = await organizationOf(ctx);
|
|
2479
|
+
if (org === void 0) {
|
|
2480
|
+
ui.error("no GitHub organization found", "run inside a checkout whose `origin` is on GitHub");
|
|
2481
|
+
return 1;
|
|
2482
|
+
}
|
|
2483
|
+
const answer = await ctx.exec.capture("gh", [
|
|
2484
|
+
"api",
|
|
2485
|
+
"graphql",
|
|
2486
|
+
"-F",
|
|
2487
|
+
`org=${org}`,
|
|
2488
|
+
"-f",
|
|
2489
|
+
`query=${FLEET_QUERY}`
|
|
2490
|
+
]);
|
|
2491
|
+
const parsed = answer.code === 0 ? parseFleet(answer.stdout) : void 0;
|
|
2492
|
+
if (parsed === void 0) {
|
|
2493
|
+
ui.error(`could not read the repositories of ${org}`, answer.stderr.trim() || "gh auth login");
|
|
2494
|
+
return 1;
|
|
2495
|
+
}
|
|
2496
|
+
const repos = await withRegistry(ctx, parsed);
|
|
2497
|
+
if (json) ui.line(JSON.stringify(repos, void 0, 2));
|
|
2498
|
+
else if (markdown) ui.line(renderFleetMarkdown(org, repos));
|
|
2499
|
+
else renderTerminal(ui, repos);
|
|
2500
|
+
return 0;
|
|
2501
|
+
}
|
|
2502
|
+
//#endregion
|
|
2038
2503
|
//#region src/commands/release.ts
|
|
2039
2504
|
/** Workflow file dispatched by name — the same name npm's trusted publisher is bound to. */
|
|
2040
2505
|
const PUBLISH_WORKFLOW_FILE$1 = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
|
|
@@ -2899,7 +3364,7 @@ async function runWait(options) {
|
|
|
2899
3364
|
* dispatcher.
|
|
2900
3365
|
*
|
|
2901
3366
|
* The grammar is deliberately tiny: one positional (a command name, or a semver bump that
|
|
2902
|
-
* implies the `release` command) plus
|
|
3367
|
+
* implies the `release` command) plus five flags. Anything unrecognized resolves to
|
|
2903
3368
|
* `help` with an `error` set — the CLI never guesses what an operator meant.
|
|
2904
3369
|
*/
|
|
2905
3370
|
/** The semver bumps `moku-release <type>` accepts, in menu order. */
|
|
@@ -2912,6 +3377,7 @@ const RELEASE_TYPES = [
|
|
|
2912
3377
|
/** Every flag the CLI accepts, besides `--help`. */
|
|
2913
3378
|
const KNOWN_FLAGS = /* @__PURE__ */ new Set([
|
|
2914
3379
|
"--json",
|
|
3380
|
+
"--markdown",
|
|
2915
3381
|
"--dry-run",
|
|
2916
3382
|
"--yes",
|
|
2917
3383
|
"-y"
|
|
@@ -2981,6 +3447,13 @@ function parseArgv(argv) {
|
|
|
2981
3447
|
dryRun,
|
|
2982
3448
|
yes
|
|
2983
3449
|
};
|
|
3450
|
+
if (first === "fleet") return {
|
|
3451
|
+
command: first,
|
|
3452
|
+
json,
|
|
3453
|
+
dryRun,
|
|
3454
|
+
yes,
|
|
3455
|
+
markdown: flags.includes("--markdown")
|
|
3456
|
+
};
|
|
2984
3457
|
if (first === "help") return {
|
|
2985
3458
|
command: "help",
|
|
2986
3459
|
json,
|
|
@@ -3176,6 +3649,8 @@ const USAGE = [
|
|
|
3176
3649
|
" moku-release setup one-time wizard: workflows, contract, first publish",
|
|
3177
3650
|
" moku-release doctor [--json] read-only diagnosis of the release setup",
|
|
3178
3651
|
" moku-release wait wait for the auto-release of HEAD, verify it on npm",
|
|
3652
|
+
" moku-release fleet [--json|--markdown]",
|
|
3653
|
+
" every repository of the organization in one table",
|
|
3179
3654
|
` moku-release <${RELEASE_TYPES.join("|")}>`,
|
|
3180
3655
|
"",
|
|
3181
3656
|
" A merge to main releases a patch by itself. A person releases minor,",
|
|
@@ -3240,6 +3715,12 @@ async function dispatch(parsed, ctx, ui) {
|
|
|
3240
3715
|
ctx,
|
|
3241
3716
|
ui
|
|
3242
3717
|
});
|
|
3718
|
+
if (parsed.command === "fleet") return runFleet({
|
|
3719
|
+
ctx,
|
|
3720
|
+
ui,
|
|
3721
|
+
json: parsed.json,
|
|
3722
|
+
markdown: parsed.markdown === true
|
|
3723
|
+
});
|
|
3243
3724
|
if (parsed.command === "release" && parsed.releaseType !== void 0) return runRelease({
|
|
3244
3725
|
ctx,
|
|
3245
3726
|
ui,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Fleet status
|
|
2
|
+
|
|
3
|
+
# For the organization's `.github` repository. Every 15 minutes 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: "*/15 * * * *"
|
|
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
|