@moku-labs/ci 1.2.1 → 1.2.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 (2) hide show
  1. package/dist/release.mjs +76 -14
  2. package/package.json +4 -4
package/dist/release.mjs CHANGED
@@ -774,16 +774,34 @@ function parseManifest(text) {
774
774
  async function readManifest(files) {
775
775
  return parseManifest(await files.read(MANIFEST_PATH));
776
776
  }
777
+ /** Any character outside printable ASCII and the JSON whitespace. */
778
+ const NON_ASCII = /[^\t\n\r -~]/;
779
+ /**
780
+ * Whether a text holds only ASCII characters, i.e. every other character is `\uXXXX`-escaped.
781
+ *
782
+ * @param text - The file contents to test.
783
+ * @returns `true` when no raw non-ASCII character is present.
784
+ * @example
785
+ * isAsciiOnly('{"description":"a \\u2014 b"}'); // true
786
+ */
787
+ function isAsciiOnly(text) {
788
+ return !NON_ASCII.test(text);
789
+ }
777
790
  /**
778
791
  * Render a manifest the way npm itself writes one: two-space JSON with a trailing newline.
792
+ * A source file that was ASCII-only stays ASCII-only, so an escaped dash the package chose
793
+ * does not show up as a changed line in the diff.
779
794
  *
780
795
  * @param manifest - The manifest to serialize.
796
+ * @param source - The file contents the manifest was read from, when there were any.
781
797
  * @returns The file contents to write.
782
798
  * @example
783
- * await files.write("package.json", formatManifest(manifest));
799
+ * await files.write("package.json", formatManifest(manifest, source));
784
800
  */
785
- function formatManifest(manifest) {
786
- return `${JSON.stringify(manifest, void 0, 2)}\n`;
801
+ function formatManifest(manifest, source) {
802
+ const text = `${JSON.stringify(manifest, void 0, 2)}\n`;
803
+ if (source === void 0 || !isAsciiOnly(source)) return text;
804
+ return text.replaceAll(new RegExp(NON_ASCII, "g"), (character) => String.raw`\u${character.codePointAt(0)?.toString(16).padStart(4, "0")}`);
787
805
  }
788
806
  /**
789
807
  * The repository URL a manifest declares, in either the object or shorthand string form.
@@ -2097,9 +2115,55 @@ async function ensurePrerequisites(setup) {
2097
2115
  return true;
2098
2116
  }
2099
2117
  /**
2118
+ * Whether git already holds this exact file: tracked, and with no uncommitted change.
2119
+ *
2120
+ * @param setup - The wizard state.
2121
+ * @param path - Repo-relative path of the file.
2122
+ * @returns `true` when `git checkout -- <path>` would bring the current content back.
2123
+ * @example
2124
+ * await isCommittedUnchanged(setup, ".github/workflows/ci.yml");
2125
+ */
2126
+ async function isCommittedUnchanged(setup, path) {
2127
+ if ((await setup.ctx.exec.capture("git", [
2128
+ "ls-files",
2129
+ "--error-unmatch",
2130
+ path
2131
+ ])).code !== 0) return false;
2132
+ const status = await setup.ctx.exec.capture("git", [
2133
+ "status",
2134
+ "--porcelain",
2135
+ "--",
2136
+ path
2137
+ ]);
2138
+ return status.code === 0 && status.stdout.trim() === "";
2139
+ }
2140
+ /**
2141
+ * Ask before replacing an existing workflow, and keep the original: in git when it is
2142
+ * committed and unmodified, in a `.bak` copy otherwise.
2143
+ *
2144
+ * @param setup - The wizard state.
2145
+ * @param template - The central workflow about to be written.
2146
+ * @param existing - The current contents of the file.
2147
+ * @returns `true` when the caller may write the template now.
2148
+ * @example
2149
+ * if (!(await clearExistingWorkflow(setup, template, existing))) continue;
2150
+ */
2151
+ async function clearExistingWorkflow(setup, template, existing) {
2152
+ const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
2153
+ const committed = await isCommittedUnchanged(setup, template.path);
2154
+ const safety = committed ? "git keeps the original" : "a .bak copy is kept";
2155
+ if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (${safety})?`)) {
2156
+ setup.ui.check(false, `${template.path} left unchanged`);
2157
+ return false;
2158
+ }
2159
+ if (deferred(setup, `${committed ? "replace" : "back up and replace"} ${template.path}`)) return false;
2160
+ if (!committed) await setup.ctx.files.backup(template.path);
2161
+ return true;
2162
+ }
2163
+ /**
2100
2164
  * Write the two thin workflows. A file that already calls the pinned central workflow is
2101
2165
  * left alone; a differing file is only replaced after an explicit confirm, and a `.bak`
2102
- * copy is kept.
2166
+ * copy is kept unless git already holds the original.
2103
2167
  *
2104
2168
  * @param setup - The wizard state.
2105
2169
  * @returns Nothing.
@@ -2114,15 +2178,7 @@ async function writeWorkflows(setup) {
2114
2178
  setup.ui.check(true, `${template.path} up to date`);
2115
2179
  continue;
2116
2180
  }
2117
- if (existing !== void 0) {
2118
- const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
2119
- if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (a .bak copy is kept)?`)) {
2120
- setup.ui.check(false, `${template.path} left unchanged`);
2121
- continue;
2122
- }
2123
- if (deferred(setup, `back up and replace ${template.path}`)) continue;
2124
- await setup.ctx.files.backup(template.path);
2125
- } else if (deferred(setup, `write ${template.path}`)) continue;
2181
+ if (!(existing === void 0 ? !deferred(setup, `write ${template.path}`) : await clearExistingWorkflow(setup, template, existing))) continue;
2126
2182
  await setup.ctx.files.write(template.path, template.content);
2127
2183
  setup.ui.check(true, `${template.path} written`);
2128
2184
  }
@@ -2155,7 +2211,8 @@ async function normalizeContract(setup, manifest) {
2155
2211
  return;
2156
2212
  }
2157
2213
  if (deferred(setup, `write package.json`)) return;
2158
- await setup.ctx.files.write(MANIFEST_PATH, formatManifest(next));
2214
+ const source = await setup.ctx.files.read(MANIFEST_PATH);
2215
+ await setup.ctx.files.write(MANIFEST_PATH, formatManifest(next, source));
2159
2216
  setup.ui.check(true, `${MANIFEST_PATH} normalized`);
2160
2217
  }
2161
2218
  /**
@@ -2209,6 +2266,11 @@ async function firstPublish(setup, name, version) {
2209
2266
  */
2210
2267
  async function pushVersionTag(setup, version) {
2211
2268
  setup.ui.heading("Tag");
2269
+ const latest = latestVersionTag((await setup.ctx.exec.capture("git", [...LATEST_TAG_ARGS])).stdout);
2270
+ if (latest !== void 0) {
2271
+ setup.ui.check(true, `release tags exist, latest is ${latest}`);
2272
+ return;
2273
+ }
2212
2274
  const tag = `v${version}`;
2213
2275
  if ((await setup.ctx.exec.capture("git", [
2214
2276
  "tag",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moku-labs/ci",
3
- "version": "1.2.1",
3
+ "version": "1.2.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
6
  "sideEffects": [
@@ -56,8 +56,8 @@
56
56
  "lint:fix": "biome check --write . && eslint --fix .",
57
57
  "format": "biome format --write .",
58
58
  "test": "vitest run",
59
- "release:setup": "moku-release setup",
60
- "release:doctor": "moku-release doctor",
61
- "release": "moku-release"
59
+ "release:setup": "bun run build && node dist/release.mjs setup",
60
+ "release:doctor": "bun run build && node dist/release.mjs doctor",
61
+ "release": "bun run build && node dist/release.mjs"
62
62
  }
63
63
  }