@moku-labs/ci 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,7 @@ everything else lives here, once. Not a build tool and not a framework — it ca
24
24
  [Workflows](#workflows) ·
25
25
  [Project checks](#project-checks) ·
26
26
  [PR previews](#pr-previews) ·
27
+ [Demos](#demos) ·
27
28
  [CLI](#cli) ·
28
29
  [The contract](#the-contract) ·
29
30
  [Versioning](#versioning) ·
@@ -90,8 +91,17 @@ flowchart LR
90
91
  1. `release:setup` writes two thin callers into `.github/workflows/`.
91
92
  2. Every PR runs `package-ci.yml`: four jobs, reported as `ci / lint`, `ci / types`,
92
93
  `ci / test`, `ci / build`.
93
- 3. `release patch` dispatches `publish.yml`, which calls `package-release.yml`:
94
- 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 |
95
105
 
96
106
  ## Workflows
97
107
 
@@ -109,7 +119,7 @@ flowchart LR
109
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. |
110
120
  | [`rulesets/main.json`](rulesets/main.json) | Branch ruleset for `main`: PR only, no force-push, the four `ci / …` checks required. |
111
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`. |
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. |
122
+ | [`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. |
113
123
 
114
124
  > [!TIP]
115
125
  > A Layer-3 app copies `examples/app/ci.yml` and `examples/dependabot.yml` by hand and needs
@@ -157,13 +167,48 @@ bun add https://pkg.pr.new/@moku-labs/core@42 # 42 = PR number, a commit sha w
157
167
  | A preview URL never reaches `main`: the `lint` job and `doctor` both refuse it. | `package-ci.yml`, `preview-deps` |
158
168
  | `preview` is not a required check. Turn it off with `with: { preview: false }`. | caller `ci.yml` |
159
169
 
170
+ ## Demos
171
+
172
+ `demos.yml` runs the moku demos (`moku-labs/demos`) against what a pull request builds, so an
173
+ engine or editor change that breaks a real game fails on its own PR.
174
+
175
+ ```yaml
176
+ demos:
177
+ needs: ci
178
+ if: github.event_name == 'pull_request'
179
+ uses: moku-labs/ci/.github/workflows/demos.yml@v1
180
+ with:
181
+ game: https://pkg.pr.new/@moku-labs/game@${{ github.event.pull_request.head.sha }}
182
+ tier: ${{ contains(github.event.pull_request.labels.*.name, 'demos:full') && 'full' || 'fast' }}
183
+ ```
184
+
185
+ | Input | Default | Meaning |
186
+ |---|---|---|
187
+ | `game`, `editor` | `""` | A version, a pkg.pr.new URL, or empty for each demo's own pin |
188
+ | `tier` | `fast` | `fast`: `bun run test` on ubuntu. `full`: plus `test:visual` on macos-latest and `test:editor` |
189
+ | `demos_ref` | `main` | demos branch when no paired branch exists |
190
+ | `paired_ref` | PR branch | demos branch with this name wins, for an intended break |
191
+ | `only` | `[]` | JSON array of demo folders; empty = all |
192
+
193
+ | Rule | Where |
194
+ |---|---|
195
+ | A demo is a folder whose `package.json` has `"moku": { "demo": true }`. | demos repo |
196
+ | The workflow only calls the demo's scripts with `--engine` / `--editor`; the demo's runner picks the package, as it does locally. | demo runner |
197
+ | A tracked file with `/Users/`, `/home/…` or `?? "../` fails the run. Paths are parameters. | `discover` |
198
+ | Checks to require: `demos / fast`; `demos / full` reports only on the full tier. | rulesets |
199
+ | Pixels run on macOS: on Linux runners WebGPU loses its device. | `visual-run` |
200
+
201
+ Examples: `examples/package/ci-with-demos.yml` (label `demos:full`) and
202
+ `examples/package/demos-comment.yml` (PR comment `/demos full`).
203
+
160
204
  ## CLI
161
205
 
162
206
  | Command | When | What it does |
163
207
  |---|---|---|
164
208
  | `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. |
165
209
  | `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. |
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. |
210
+ | `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. |
211
+ | `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. |
167
212
 
168
213
  The scripts are plain aliases of the `moku-release` bin. Internals and the list of checks:
169
214
  [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.
@@ -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
  };
@@ -1978,9 +2037,9 @@ async function runDoctor(options) {
1978
2037
  //#endregion
1979
2038
  //#region src/commands/release.ts
1980
2039
  /** 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";
2040
+ const PUBLISH_WORKFLOW_FILE$1 = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
1982
2041
  /** How long to keep asking the registry for the new version before giving up. */
1983
- const REGISTRY_POLL_ATTEMPTS = 60;
2042
+ const REGISTRY_POLL_ATTEMPTS$1 = 60;
1984
2043
  /** Gap between registry polls — 60 × 10s = ten minutes. The first live release needed three. */
1985
2044
  const REGISTRY_POLL_INTERVAL_MS = 1e4;
1986
2045
  /** How many times to look for the dispatched run before concluding it never started. */
@@ -1995,7 +2054,7 @@ const RUN_LOOKUP_INTERVAL_MS = 3e3;
1995
2054
  * @example
1996
2055
  * await defaultSleep(500);
1997
2056
  */
1998
- const defaultSleep = (ms) => new Promise((resolve) => {
2057
+ const defaultSleep$1 = (ms) => new Promise((resolve) => {
1999
2058
  setTimeout(resolve, ms);
2000
2059
  });
2001
2060
  /**
@@ -2026,7 +2085,7 @@ const LATEST_RUN_ARGS = [
2026
2085
  "run",
2027
2086
  "list",
2028
2087
  "--workflow",
2029
- PUBLISH_WORKFLOW_FILE,
2088
+ PUBLISH_WORKFLOW_FILE$1,
2030
2089
  "--branch",
2031
2090
  "main",
2032
2091
  "--limit",
@@ -2081,7 +2140,7 @@ async function findDispatchedRun(ctx, previous, sleep) {
2081
2140
  * await awaitPublishedVersion(ctx, "@moku-labs/common", "latest", "1.2.2", defaultSleep);
2082
2141
  */
2083
2142
  async function awaitPublishedVersion(ctx, name, tag, before, sleep) {
2084
- for (let attempt = 0; attempt < REGISTRY_POLL_ATTEMPTS; attempt += 1) {
2143
+ for (let attempt = 0; attempt < REGISTRY_POLL_ATTEMPTS$1; attempt += 1) {
2085
2144
  const view = await ctx.exec.capture("npm", [
2086
2145
  "view",
2087
2146
  name,
@@ -2124,7 +2183,7 @@ function renderSummary(ui, name, version, ownerRepo) {
2124
2183
  * const code = await runRelease({ ctx, ui, releaseType: "patch" });
2125
2184
  */
2126
2185
  async function runRelease(options) {
2127
- const { ctx, ui, releaseType, dryRun = false, sleep = defaultSleep } = options;
2186
+ const { ctx, ui, releaseType, dryRun = false, sleep = defaultSleep$1 } = options;
2128
2187
  ui.lockup({
2129
2188
  wordmark: "moku release",
2130
2189
  label: dryRun ? `${releaseType} · dry-run` : releaseType
@@ -2146,7 +2205,7 @@ async function runRelease(options) {
2146
2205
  ])).stdout)?.[tag];
2147
2206
  if (dryRun) {
2148
2207
  ui.heading("Plan");
2149
- ui.info(`gh workflow run ${PUBLISH_WORKFLOW_FILE} -f release_type=${releaseType} --ref main`);
2208
+ ui.info(`gh workflow run ${PUBLISH_WORKFLOW_FILE$1} -f release_type=${releaseType} --ref main`);
2150
2209
  ui.info(`watch the run, then wait for npm dist-tag \`${tag}\` to move from ${before ?? "—"}`);
2151
2210
  return 0;
2152
2211
  }
@@ -2155,7 +2214,7 @@ async function runRelease(options) {
2155
2214
  const dispatched = await ctx.exec.capture("gh", [
2156
2215
  "workflow",
2157
2216
  "run",
2158
- PUBLISH_WORKFLOW_FILE,
2217
+ PUBLISH_WORKFLOW_FILE$1,
2159
2218
  "-f",
2160
2219
  `release_type=${releaseType}`,
2161
2220
  "--ref",
@@ -2165,7 +2224,7 @@ async function runRelease(options) {
2165
2224
  ui.error("could not dispatch the workflow", dispatched.stderr.trim());
2166
2225
  return 1;
2167
2226
  }
2168
- ui.check(true, `${PUBLISH_WORKFLOW_FILE} dispatched (${releaseType})`);
2227
+ ui.check(true, `${PUBLISH_WORKFLOW_FILE$1} dispatched (${releaseType})`);
2169
2228
  const runId = await findDispatchedRun(ctx, previousRun, sleep);
2170
2229
  if (runId === void 0) {
2171
2230
  ui.error("the dispatched run never appeared — check GitHub Actions");
@@ -2647,6 +2706,193 @@ async function runSetup(options) {
2647
2706
  return report.failed ? 1 : 0;
2648
2707
  }
2649
2708
  //#endregion
2709
+ //#region src/commands/wait.ts
2710
+ /** Workflow file whose runs are read — the one that releases on a push to main. */
2711
+ const PUBLISH_WORKFLOW_FILE = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
2712
+ /** How long to wait for a tag that holds the commit — 120 × 10s = twenty minutes. */
2713
+ const TAG_POLL_ATTEMPTS = 120;
2714
+ /** How long to wait for a run to start before saying none will — 12 × 10s = two minutes. */
2715
+ const RUN_START_ATTEMPTS = 12;
2716
+ /** How long to keep asking the registry for the version — 60 × 10s = ten minutes. */
2717
+ const REGISTRY_POLL_ATTEMPTS = 60;
2718
+ /** Gap between polls of every loop here. */
2719
+ const POLL_INTERVAL_MS = 1e4;
2720
+ /** The `gh run list` call that reads the newest `publish.yml` runs on main. */
2721
+ const RECENT_RUNS_ARGS = [
2722
+ "run",
2723
+ "list",
2724
+ "--workflow",
2725
+ PUBLISH_WORKFLOW_FILE,
2726
+ "--branch",
2727
+ "main",
2728
+ "--limit",
2729
+ "5",
2730
+ "--json",
2731
+ "databaseId,headSha,status,conclusion"
2732
+ ];
2733
+ /** Argv for the release tags that hold a commit, oldest first: the first one shipped it. */
2734
+ const TAGS_WITH_HEAD_ARGS = [
2735
+ "-c",
2736
+ "versionsort.suffix=-",
2737
+ "tag",
2738
+ "--list",
2739
+ "v*",
2740
+ "--contains",
2741
+ "HEAD",
2742
+ "--sort=v:refname"
2743
+ ];
2744
+ /**
2745
+ * The default delay helper.
2746
+ *
2747
+ * @param ms - Milliseconds to wait.
2748
+ * @returns Resolves after the delay.
2749
+ * @example
2750
+ * await defaultSleep(500);
2751
+ */
2752
+ const defaultSleep = (ms) => new Promise((resolve) => {
2753
+ setTimeout(resolve, ms);
2754
+ });
2755
+ /**
2756
+ * Whether `HEAD` is an ancestor of a ref, i.e. the ref holds the commit.
2757
+ *
2758
+ * @param ctx - The ports and flags.
2759
+ * @param ref - A branch, tag or sha.
2760
+ * @returns `true` when the ref holds `HEAD`.
2761
+ * @example
2762
+ * await holdsHead(ctx, "origin/main");
2763
+ */
2764
+ async function holdsHead(ctx, ref) {
2765
+ return (await ctx.exec.capture("git", [
2766
+ "merge-base",
2767
+ "--is-ancestor",
2768
+ "HEAD",
2769
+ ref
2770
+ ])).code === 0;
2771
+ }
2772
+ /**
2773
+ * The newest `publish.yml` run whose commit holds `HEAD`. A run of a later merge counts:
2774
+ * its version carries the commit too.
2775
+ *
2776
+ * @param ctx - The ports and flags.
2777
+ * @returns The run, or `undefined` when no run covers the commit yet.
2778
+ * @example
2779
+ * const run = await coveringRun(ctx);
2780
+ */
2781
+ async function coveringRun(ctx) {
2782
+ const listing = await ctx.exec.capture("gh", RECENT_RUNS_ARGS);
2783
+ if (listing.code !== 0) return void 0;
2784
+ for (const run of parseRuns(listing.stdout)) if (await holdsHead(ctx, run.headSha)) return run;
2785
+ }
2786
+ /**
2787
+ * Wait until a release tag holds `HEAD`. Stops early when the covering run failed, or when
2788
+ * no run started at all.
2789
+ *
2790
+ * @param ctx - The ports and flags.
2791
+ * @param ui - The branded console.
2792
+ * @param sleep - The delay helper.
2793
+ * @returns The tag, or `undefined` after the reason was printed.
2794
+ * @example
2795
+ * const tag = await awaitTag(ctx, ui, defaultSleep);
2796
+ */
2797
+ async function awaitTag(ctx, ui, sleep) {
2798
+ for (let attempt = 0; attempt < TAG_POLL_ATTEMPTS; attempt += 1) {
2799
+ await ctx.exec.capture("git", [
2800
+ "fetch",
2801
+ "--tags",
2802
+ "--prune"
2803
+ ]);
2804
+ const tags = await ctx.exec.capture("git", TAGS_WITH_HEAD_ARGS);
2805
+ const tag = tags.code === 0 ? latestVersionTag(tags.stdout) : void 0;
2806
+ if (tag !== void 0) return tag;
2807
+ const run = await coveringRun(ctx);
2808
+ if (run === void 0 && attempt >= RUN_START_ATTEMPTS) {
2809
+ 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`);
2810
+ return;
2811
+ }
2812
+ if (run?.status === "completed" && run.conclusion !== "success") {
2813
+ ui.error(`run ${run.databaseId} ended as ${run.conclusion}`, `gh run view ${run.databaseId} --log-failed`);
2814
+ return;
2815
+ }
2816
+ await sleep(POLL_INTERVAL_MS);
2817
+ }
2818
+ ui.error("no release tag holds this commit after twenty minutes");
2819
+ }
2820
+ /**
2821
+ * Poll the registry until it serves one exact version. The registry lags behind a
2822
+ * successful publish, so "not there yet" is expected for a while.
2823
+ *
2824
+ * @param ctx - The ports and flags.
2825
+ * @param name - The package name.
2826
+ * @param version - The version the tag names.
2827
+ * @param sleep - The delay helper.
2828
+ * @returns `true` when the registry serves the version.
2829
+ * @example
2830
+ * await awaitVersion(ctx, "@moku-labs/common", "1.2.4", defaultSleep);
2831
+ */
2832
+ async function awaitVersion(ctx, name, version, sleep) {
2833
+ for (let attempt = 0; attempt < REGISTRY_POLL_ATTEMPTS; attempt += 1) {
2834
+ const view = await ctx.exec.capture("npm", [
2835
+ "view",
2836
+ `${name}@${version}`,
2837
+ "version"
2838
+ ]);
2839
+ if (view.code === 0 && view.stdout.trim() === version) return true;
2840
+ await sleep(POLL_INTERVAL_MS);
2841
+ }
2842
+ return false;
2843
+ }
2844
+ /**
2845
+ * Wait for the auto-release of `HEAD`: the commit is on `origin/main`, a release tag holds
2846
+ * it, and npm serves that version.
2847
+ *
2848
+ * @param options - The ports, console and delay helper.
2849
+ * @returns The process exit code.
2850
+ * @example
2851
+ * const code = await runWait({ ctx, ui });
2852
+ */
2853
+ async function runWait(options) {
2854
+ const { ctx, ui, sleep = defaultSleep } = options;
2855
+ ui.lockup({
2856
+ wordmark: "moku release",
2857
+ label: "wait"
2858
+ });
2859
+ const manifest = await readManifest(ctx.files);
2860
+ if (!manifest?.name) {
2861
+ ui.error("package.json is missing, malformed, or has no `name`");
2862
+ return 1;
2863
+ }
2864
+ await ctx.exec.capture("git", [
2865
+ "fetch",
2866
+ "--tags",
2867
+ "--prune"
2868
+ ]);
2869
+ if (!await holdsHead(ctx, "origin/main")) {
2870
+ ui.error("HEAD is not on origin/main", "merge the PR, then: git checkout main && git pull");
2871
+ return 1;
2872
+ }
2873
+ ui.heading("Tag");
2874
+ const tag = await awaitTag(ctx, ui, sleep);
2875
+ if (tag === void 0) return 1;
2876
+ ui.check(true, `${tag} holds this commit`);
2877
+ ui.heading("Registry");
2878
+ const version = tag.replace(/^v/, "");
2879
+ if (!await awaitVersion(ctx, manifest.name, version, sleep)) {
2880
+ ui.error(`npm does not serve ${manifest.name}@${version} after ten minutes`);
2881
+ return 1;
2882
+ }
2883
+ const declared = repositoryUrlOf(manifest);
2884
+ const ownerRepo = declared === void 0 ? void 0 : ownerRepoFrom(declared);
2885
+ const lines = [
2886
+ ui.railLine(manifest.name, version, 48),
2887
+ ui.railLine("tag", tag, 48),
2888
+ ui.railLine("npm", npmPackageUrl(manifest.name, version), 48)
2889
+ ];
2890
+ if (ownerRepo !== void 0) lines.push(ui.railLine("release", releaseUrl(ownerRepo, tag), 48));
2891
+ ui.heading("Released");
2892
+ ui.box(lines);
2893
+ return 0;
2894
+ }
2895
+ //#endregion
2650
2896
  //#region src/lib/argv.ts
2651
2897
  /**
2652
2898
  * @file `moku-release` — argv parsing, kept out of the entry so the entry stays a
@@ -2729,7 +2975,7 @@ function parseArgv(argv) {
2729
2975
  dryRun,
2730
2976
  yes
2731
2977
  };
2732
- if (first === "doctor" || first === "setup") return {
2978
+ if (first === "doctor" || first === "setup" || first === "wait") return {
2733
2979
  command: first,
2734
2980
  json,
2735
2981
  dryRun,
@@ -2929,8 +3175,12 @@ function createFileStore(root) {
2929
3175
  const USAGE = [
2930
3176
  " moku-release setup one-time wizard: workflows, contract, first publish",
2931
3177
  " moku-release doctor [--json] read-only diagnosis of the release setup",
3178
+ " moku-release wait wait for the auto-release of HEAD, verify it on npm",
2932
3179
  ` moku-release <${RELEASE_TYPES.join("|")}>`,
2933
3180
  "",
3181
+ " A merge to main releases a patch by itself. A person releases minor,",
3182
+ " or major for a milestone.",
3183
+ "",
2934
3184
  " --dry-run print every action, mutate nothing, ask nothing",
2935
3185
  " --yes, -y answer every setup confirmation with yes",
2936
3186
  "",
@@ -2986,6 +3236,10 @@ async function dispatch(parsed, ctx, ui) {
2986
3236
  dryRun: parsed.dryRun,
2987
3237
  unattended: parsed.yes
2988
3238
  });
3239
+ if (parsed.command === "wait") return runWait({
3240
+ ctx,
3241
+ ui
3242
+ });
2989
3243
  if (parsed.command === "release" && parsed.releaseType !== void 0) return runRelease({
2990
3244
  ctx,
2991
3245
  ui,
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ # ci.yml of a package whose PRs must run the moku demos (the engine, the editor).
4
+ # Same thin caller as ci.yml, plus a `demos` job after the preview is published.
5
+ # Required checks: "ci / lint", "ci / types", "ci / test", "ci / build" and "demos / fast".
6
+ # The label `demos:full` on the PR adds pixels (macOS) and editor scenarios: "demos / full".
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ pull_request:
11
+ types: [opened, synchronize, reopened, labeled]
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ ci:
18
+ uses: moku-labs/ci/.github/workflows/package-ci.yml@v1
19
+
20
+ demos:
21
+ needs: ci
22
+ if: github.event_name == 'pull_request'
23
+ uses: moku-labs/ci/.github/workflows/demos.yml@v1
24
+ with:
25
+ # The preview job of `ci` published this commit; a full sha resolves on pkg.pr.new.
26
+ # In the editor's repo, pass `editor:` instead of `game:`.
27
+ game: https://pkg.pr.new/@moku-labs/game@${{ github.event.pull_request.head.sha }}
28
+ tier: ${{ contains(github.event.pull_request.labels.*.name, 'demos:full') && 'full' || 'fast' }}
@@ -0,0 +1,41 @@
1
+ name: Demos on comment
2
+
3
+ # A PR comment `/demos full` runs the full demos tier against the PR's preview, without a label.
4
+ # Only members and collaborators can trigger it.
5
+ on:
6
+ issue_comment:
7
+ types: [created]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ head:
14
+ if: >-
15
+ github.event.issue.pull_request &&
16
+ startsWith(github.event.comment.body, '/demos full') &&
17
+ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
18
+ runs-on: ubuntu-latest
19
+ permissions:
20
+ pull-requests: read
21
+ outputs:
22
+ sha: ${{ steps.pr.outputs.sha }}
23
+ ref: ${{ steps.pr.outputs.ref }}
24
+ steps:
25
+ - id: pr
26
+ env:
27
+ GH_TOKEN: ${{ github.token }}
28
+ PR: ${{ github.event.issue.number }}
29
+ REPO: ${{ github.repository }}
30
+ run: |
31
+ set -euo pipefail
32
+ gh api "repos/$REPO/pulls/$PR" --jq '"sha=\(.head.sha)\nref=\(.head.ref)"' >> "$GITHUB_OUTPUT"
33
+
34
+ demos:
35
+ needs: head
36
+ uses: moku-labs/ci/.github/workflows/demos.yml@v1
37
+ with:
38
+ game: https://pkg.pr.new/@moku-labs/game@${{ needs.head.outputs.sha }}
39
+ # issue_comment has no head_ref: name the branch to pair with explicitly.
40
+ paired_ref: ${{ needs.head.outputs.ref }}
41
+ tier: full
@@ -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.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Central CI and release for the moku family: reusable workflows, caller examples and the moku-release CLI.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@biomejs/biome": "2.4.16",
35
- "@moku-labs/common": "0.3.0",
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",