@moku-labs/ci 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +18 -6
  2. package/dist/release.mjs +53 -34
  3. package/package.json +12 -3
package/README.md CHANGED
@@ -43,9 +43,9 @@ registers the trusted publisher and applies the branch ruleset. Run it again any
43
43
  only does what is still missing.
44
44
 
45
45
  > [!NOTE]
46
- > **Status: `1.x`, early.** `package-ci.yml` runs on this repo's own PRs. `package-release.yml`
47
- > and the CLI's `setup` and `release` have passed dry-run and unit tests only; they have not
48
- > yet released a live package.
46
+ > **Status: `1.x`, early.** `package-ci.yml`, `package-release.yml` and `moku-release release`
47
+ > ship this package itself: `1.1.1` was the first live release, tokenless and with provenance.
48
+ > `moku-release setup` has passed dry-run and unit tests only.
49
49
 
50
50
  > [!IMPORTANT]
51
51
  > Two steps are yours alone. The CLI never handles a credential, and there is no `NPM_TOKEN`
@@ -152,14 +152,26 @@ YAML stays the same for everyone.
152
152
  | `@v1.x.y` | Immutable tag on every change. Pin it to freeze a project. |
153
153
  | `@v2` | Any breaking change: a removed input, a changed default, a renamed job. `v1` stays where it was. |
154
154
 
155
+ > [!IMPORTANT]
156
+ > `v1` must be a **lightweight** tag. `package-release.yml` calls `./.github/workflows/package-ci.yml`
157
+ > from inside itself, and GitHub cannot resolve that relative call through an annotated tag: every
158
+ > release dies with `startup_failure` and "workflow was not found". Immutable `v1.x.y` tags may be
159
+ > annotated.
160
+ >
161
+ > ```sh
162
+ > git tag -a v1.2.0 -m "v1.2.0"
163
+ > git tag -f v1 v1.2.0^{commit} # no -a, no -m
164
+ > git push origin v1.2.0 && git push -f origin v1
165
+ > ```
166
+
155
167
  A change here runs in every moku repo with `contents: write` and `id-token: write`. Review it
156
168
  like release engineering, not like config.
157
169
 
158
170
  ## When a release fails
159
171
 
160
- One risk is still open: npm may reject a publish that runs inside a workflow owned by another
161
- repository. It is unverified until the first live release. The fallback and sixteen other
162
- traps the workflows already handle are in [docs/release-notes.md](docs/release-notes.md).
172
+ The cross-repo OIDC publish was the open risk; npm accepted it on the first live release
173
+ (`@moku-labs/ci@1.1.1`). The fallback, should npm ever change that, and sixteen traps the
174
+ workflows already handle are in [docs/release-notes.md](docs/release-notes.md).
163
175
 
164
176
  ## Scripts
165
177
 
package/dist/release.mjs CHANGED
@@ -1694,11 +1694,11 @@ async function runDoctor(options) {
1694
1694
  /** Workflow file dispatched by name — the same name npm's trusted publisher is bound to. */
1695
1695
  const PUBLISH_WORKFLOW_FILE = ".github/workflows/publish.yml".split("/").pop() ?? "publish.yml";
1696
1696
  /** How long to keep asking the registry for the new version before giving up. */
1697
- const REGISTRY_POLL_ATTEMPTS = 24;
1698
- /** Gap between registry polls — 24 × 5s two minutes of registry lag tolerated. */
1699
- const REGISTRY_POLL_INTERVAL_MS = 5e3;
1697
+ const REGISTRY_POLL_ATTEMPTS = 60;
1698
+ /** Gap between registry polls — 60 × 10s = ten minutes. The first live release needed three. */
1699
+ const REGISTRY_POLL_INTERVAL_MS = 1e4;
1700
1700
  /** How many times to look for the dispatched run before concluding it never started. */
1701
- const RUN_LOOKUP_ATTEMPTS = 10;
1701
+ const RUN_LOOKUP_ATTEMPTS = 20;
1702
1702
  /** Gap between run lookups — GitHub takes a moment to materialize a dispatched run. */
1703
1703
  const RUN_LOOKUP_INTERVAL_MS = 3e3;
1704
1704
  /**
@@ -1735,32 +1735,48 @@ async function preflight(ctx, ui) {
1735
1735
  if (report.failed) ui.error("preflight failed — nothing was dispatched");
1736
1736
  return !report.failed;
1737
1737
  }
1738
+ /** The `gh run list` call that names the newest `publish.yml` run on main. */
1739
+ const LATEST_RUN_ARGS = [
1740
+ "run",
1741
+ "list",
1742
+ "--workflow",
1743
+ PUBLISH_WORKFLOW_FILE,
1744
+ "--branch",
1745
+ "main",
1746
+ "--limit",
1747
+ "1",
1748
+ "--json",
1749
+ "databaseId"
1750
+ ];
1751
+ /**
1752
+ * The newest `publish.yml` run right now. Read BEFORE the dispatch, it is the run the
1753
+ * dispatch must not be confused with.
1754
+ *
1755
+ * @param ctx - The ports and flags.
1756
+ * @returns The run id, or `undefined` when the workflow never ran.
1757
+ * @example
1758
+ * const previous = await newestRun(ctx);
1759
+ */
1760
+ async function newestRun(ctx) {
1761
+ const listing = await ctx.exec.capture("gh", LATEST_RUN_ARGS);
1762
+ return listing.code === 0 ? latestRunId(listing.stdout) : void 0;
1763
+ }
1738
1764
  /**
1739
- * Find the run the dispatch just created, retrying while GitHub materializes it.
1765
+ * Find the run the dispatch just created, retrying while GitHub materializes it. The
1766
+ * newest run is only ours once it differs from `previous`: asked too early, GitHub still
1767
+ * answers with the run of the release before.
1740
1768
  *
1741
1769
  * @param ctx - The ports and flags.
1770
+ * @param previous - The newest run id from before the dispatch.
1742
1771
  * @param sleep - The delay helper.
1743
- * @returns The run id, or `undefined` when no run appeared.
1772
+ * @returns The run id, or `undefined` when no new run appeared.
1744
1773
  * @example
1745
- * const runId = await findDispatchedRun(ctx, defaultSleep);
1774
+ * const runId = await findDispatchedRun(ctx, previous, defaultSleep);
1746
1775
  */
1747
- async function findDispatchedRun(ctx, sleep) {
1748
- const args = [
1749
- "run",
1750
- "list",
1751
- "--workflow",
1752
- PUBLISH_WORKFLOW_FILE,
1753
- "--branch",
1754
- "main",
1755
- "--limit",
1756
- "1",
1757
- "--json",
1758
- "databaseId"
1759
- ];
1776
+ async function findDispatchedRun(ctx, previous, sleep) {
1760
1777
  for (let attempt = 0; attempt < RUN_LOOKUP_ATTEMPTS; attempt += 1) {
1761
- const listing = await ctx.exec.capture("gh", args);
1762
- const runId = listing.code === 0 ? latestRunId(listing.stdout) : void 0;
1763
- if (runId !== void 0) return runId;
1778
+ const runId = await newestRun(ctx);
1779
+ if (runId !== void 0 && runId !== previous) return runId;
1764
1780
  await sleep(RUN_LOOKUP_INTERVAL_MS);
1765
1781
  }
1766
1782
  }
@@ -1854,6 +1870,7 @@ async function runRelease(options) {
1854
1870
  return 0;
1855
1871
  }
1856
1872
  ui.heading("Dispatch");
1873
+ const previousRun = await newestRun(ctx);
1857
1874
  const dispatched = await ctx.exec.capture("gh", [
1858
1875
  "workflow",
1859
1876
  "run",
@@ -1868,7 +1885,7 @@ async function runRelease(options) {
1868
1885
  return 1;
1869
1886
  }
1870
1887
  ui.check(true, `${PUBLISH_WORKFLOW_FILE} dispatched (${releaseType})`);
1871
- const runId = await findDispatchedRun(ctx, sleep);
1888
+ const runId = await findDispatchedRun(ctx, previousRun, sleep);
1872
1889
  if (runId === void 0) {
1873
1890
  ui.error("the dispatched run never appeared — check GitHub Actions");
1874
1891
  return 1;
@@ -1886,7 +1903,7 @@ async function runRelease(options) {
1886
1903
  ui.heading("Registry");
1887
1904
  const version = await awaitPublishedVersion(ctx, manifest.name, tag, before, sleep);
1888
1905
  if (version === void 0) {
1889
- ui.error(`npm dist-tag \`${tag}\` did not move — the run passed but nothing was published`);
1906
+ ui.error(`npm dist-tag \`${tag}\` did not move in ten minutes — the run passed, so check \`npm view ${manifest.name} dist-tags\` before releasing again`);
1890
1907
  return 1;
1891
1908
  }
1892
1909
  renderSummary(ui, manifest.name, version, ownerRepo);
@@ -2261,21 +2278,23 @@ function parseArgv(argv) {
2261
2278
  /** Exit code POSIX shells use for "command not found" — what a missing binary reports. */
2262
2279
  const COMMAND_NOT_FOUND = 127;
2263
2280
  /**
2264
- * Normalize whatever `execFile` rejected with into a {@link CommandOutput}. A non-zero exit
2265
- * carries `code` plus both streams; a missing binary carries an `ENOENT`-style string code
2266
- * and no exit status at all.
2281
+ * Normalize a failed `execFile` call into a {@link CommandOutput}. A non-zero exit carries
2282
+ * `code` and both streams; a missing binary carries an `ENOENT`-style string code, no exit
2283
+ * status and no output, so the error text stands in for stderr.
2267
2284
  *
2268
- * @param error - The rejection value from `execFile`.
2285
+ * @param error - The error `execFile` passed to its callback.
2286
+ * @param stdout - What the child wrote to stdout before it failed.
2287
+ * @param stderr - What the child wrote to stderr before it failed.
2269
2288
  * @returns The equivalent captured output.
2270
2289
  * @example
2271
- * fromExecError({ code: 1, stdout: "", stderr: "not logged in" });
2290
+ * fromExecError({ code: 1 }, "", "not logged in");
2272
2291
  */
2273
- function fromExecError(error) {
2292
+ function fromExecError(error, stdout, stderr) {
2274
2293
  const shape = error;
2275
2294
  return {
2276
2295
  code: typeof shape.code === "number" ? shape.code : COMMAND_NOT_FOUND,
2277
- stdout: shape.stdout ?? "",
2278
- stderr: shape.stderr ?? String(error)
2296
+ stdout,
2297
+ stderr: stderr === "" && stdout === "" ? String(error) : stderr
2279
2298
  };
2280
2299
  }
2281
2300
  /**
@@ -2300,7 +2319,7 @@ function createExecutor(cwd) {
2300
2319
  */
2301
2320
  const capture = (command, args, options = {}) => new Promise((resolve) => {
2302
2321
  const child = execFile(command, [...args], { cwd: options.cwd ?? cwd }, (error, stdout, stderr) => {
2303
- if (error) return resolve(fromExecError(error));
2322
+ if (error) return resolve(fromExecError(error, stdout, stderr));
2304
2323
  resolve({
2305
2324
  code: 0,
2306
2325
  stdout,
package/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "@moku-labs/ci",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
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
- "sideEffects": ["./dist/release.mjs", "./src/index.ts"],
6
+ "sideEffects": [
7
+ "./dist/release.mjs",
8
+ "./src/index.ts"
9
+ ],
7
10
  "bin": {
8
11
  "moku-release": "./dist/release.mjs"
9
12
  },
10
- "files": ["dist", "examples", "rulesets", "LICENSE", "README.md"],
13
+ "files": [
14
+ "dist",
15
+ "examples",
16
+ "rulesets",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
11
20
  "engines": {
12
21
  "node": ">=24.0.0",
13
22
  "bun": ">=1.3.14"