quackage 1.1.2 → 1.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 1.2.0
4
+
5
+ ### Added
6
+
7
+ - **`quack release` command** — release-pipeline helpers used as npm
8
+ script hooks and one-shot release shortcuts. Centralizes the
9
+ postversion / postpublish git-tag-and-push logic that would otherwise
10
+ be duplicated across every dockerized module's `package.json`.
11
+ Subcommands: `postversion`, `postpublish`, `publish`, `patch`,
12
+ `minor`, `major`. The `--image` flag opts the release into rebuilding
13
+ the GHCR image (sets `BUILD_DOCKER=1` for `npm publish`, which makes
14
+ the `postpublish` hook tag-and-push the version).
15
+ - **`quack docker-init` command** — scaffolds the GHCR publish pipeline
16
+ for a module: creates `.github/workflows/publish-image.yml`,
17
+ `BUILDING-AND-PUBLISHING.md`, and idempotently patches the standard
18
+ release scripts into `package.json`. Flags: `--shape service|job`
19
+ (controls the lifecycle note in the doc; default `service`),
20
+ `--force` (overwrite existing scaffolded files). Deliberately does
21
+ not generate a Dockerfile — that's per-module.
22
+
23
+ ### Convention introduced
24
+
25
+ `BUILD_DOCKER=1` env-var opt-in for triggering the GHCR build during
26
+ `npm publish`. With the var unset (the new default), `npm publish`
27
+ ships to npm only and skips the multi-arch docker rebuild. With it
28
+ set, `postpublish` tags the version and pushes the tag, which fires
29
+ the GHCR workflow. Lets module maintainers be deliberate about when
30
+ they spend the multi-minute build cost vs. shipping a doc-only or
31
+ internal-only patch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quackage",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Building. Testing. Quacking. Reloading.",
5
5
  "main": "source/Quackage-CLIProgram.js",
6
6
  "scripts": {
@@ -66,7 +66,7 @@
66
66
  "npm-check-updates": "^18.0.1",
67
67
  "nyc": "^15.1.0",
68
68
  "pict-service-commandlineutility": "^1.0.19",
69
- "retold-harness": "^1.1.9",
69
+ "retold-harness": "^1.1.10",
70
70
  "vinyl-buffer": "^1.0.1",
71
71
  "vinyl-source-stream": "^2.0.0"
72
72
  },
@@ -42,6 +42,12 @@ let _Pict = new libCLIProgram(
42
42
  require('./commands/Quackage-Command-ListTemplates.js'),
43
43
  require('./commands/Quackage-Command-BuildTemplates.js'),
44
44
 
45
+ // npm + GHCR release pipeline (postversion / postpublish hooks
46
+ // + one-shot release shortcuts). See Quackage-Command-Release.js
47
+ // for the BUILD_DOCKER opt-in convention.
48
+ require('./commands/Quackage-Command-Release.js'),
49
+ require('./commands/Quackage-Command-DockerInit.js'),
50
+
45
51
  // Stricture
46
52
  require('./commands/stricture//Quackage-Command-Stricture-Compile.js'),
47
53
  require('./commands/stricture/Quackage-Command-StrictureLegacy.js'),
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Quackage docker-init — scaffold the GHCR publish pipeline for a module.
3
+ *
4
+ * Generates the bits that are byte-identical across modules (or only
5
+ * differ in straightforward substitutions):
6
+ * - .github/workflows/publish-image.yml (image name from package.json)
7
+ * - BUILDING-AND-PUBLISHING.md (with module-specific notes)
8
+ * - the release scripts in package.json (idempotent, only adds if missing)
9
+ *
10
+ * Deliberately does NOT generate a Dockerfile — that's per-module
11
+ * (entry point, port, build steps, healthcheck) and writing a generic
12
+ * one would just be a thing that gets thrown away.
13
+ *
14
+ * Usage:
15
+ * npx quack docker-init Scaffold for the current module
16
+ * npx quack docker-init --force Overwrite existing scaffolded files
17
+ * npx quack docker-init --shape job Mark as one-shot job (no healthcheck
18
+ * guidance in the doc); default is
19
+ * long-running service shape
20
+ */
21
+
22
+ const libCommandLineCommand = require('pict-service-commandlineutility').ServiceCommandLineCommand;
23
+ const libFs = require('fs');
24
+ const libPath = require('path');
25
+
26
+ const VALID_SHAPES = ['service', 'job'];
27
+
28
+ class QuackageCommandDockerInit extends libCommandLineCommand
29
+ {
30
+ constructor(pFable, pManifest, pServiceHash)
31
+ {
32
+ super(pFable, pManifest, pServiceHash);
33
+
34
+ this.options.CommandKeyword = 'docker-init';
35
+ this.options.Description = 'Scaffold the GHCR publish pipeline (workflow + docs + release scripts) for the current module.';
36
+
37
+ this.options.CommandOptions.push({ Name: '--shape [shape]', Description: 'Lifecycle shape for the doc (service | job). Default: service.', Default: 'service' });
38
+ this.options.CommandOptions.push({ Name: '--force', Description: 'Overwrite existing scaffolded files.', Default: false });
39
+
40
+ this.addCommand();
41
+ }
42
+
43
+ onRunAsync(fCallback)
44
+ {
45
+ let tmpShape = String(this.CommandOptions.shape || 'service').toLowerCase();
46
+ let tmpForce = !!this.CommandOptions.force;
47
+
48
+ if (VALID_SHAPES.indexOf(tmpShape) < 0)
49
+ {
50
+ this.log.error(`Invalid --shape [${tmpShape}]; expected one of: ${VALID_SHAPES.join(', ')}.`);
51
+ return fCallback(new Error('Invalid shape'));
52
+ }
53
+
54
+ let tmpPkg = this.fable.AppData.Package;
55
+ let tmpCWD = this.fable.AppData.CWD;
56
+ if (!tmpPkg || !tmpPkg.name)
57
+ {
58
+ this.log.error('docker-init: no package.json with a `name` field in the current directory.');
59
+ return fCallback(new Error('No package'));
60
+ }
61
+
62
+ let tmpImageName = tmpPkg.name;
63
+ let tmpOwner = this._inferGithubOwner(tmpPkg) || 'stevenvelozo';
64
+ let tmpVersion = tmpPkg.version || '0.0.0';
65
+
66
+ let tmpSubstitutions =
67
+ {
68
+ PackageName: tmpImageName,
69
+ Owner: tmpOwner,
70
+ Version: tmpVersion,
71
+ Shape: tmpShape
72
+ };
73
+
74
+ this.log.info(`docker-init: scaffolding for ${tmpImageName} (shape=${tmpShape}, owner=${tmpOwner})`);
75
+
76
+ let tmpResults = [];
77
+
78
+ // 1. .github/workflows/publish-image.yml
79
+ let tmpWorkflowPath = libPath.join(tmpCWD, '.github', 'workflows', 'publish-image.yml');
80
+ tmpResults.push(this._writeFile(tmpWorkflowPath, this._renderWorkflow(tmpSubstitutions), tmpForce));
81
+
82
+ // 2. BUILDING-AND-PUBLISHING.md
83
+ let tmpDocPath = libPath.join(tmpCWD, 'BUILDING-AND-PUBLISHING.md');
84
+ tmpResults.push(this._writeFile(tmpDocPath, this._renderDoc(tmpSubstitutions), tmpForce));
85
+
86
+ // 3. package.json release scripts (idempotent)
87
+ let tmpPackagePath = libPath.join(tmpCWD, 'package.json');
88
+ tmpResults.push(this._patchPackageJson(tmpPackagePath, tmpForce));
89
+
90
+ this.log.info('');
91
+ this.log.info('docker-init: results:');
92
+ for (let i = 0; i < tmpResults.length; i++)
93
+ {
94
+ this.log.info(` ${tmpResults[i]}`);
95
+ }
96
+ this.log.info('');
97
+ this.log.info('Next steps:');
98
+ this.log.info(` 1. Write or audit a Dockerfile in this module's root.`);
99
+ this.log.info(` 2. Set GHCR package visibility to public after first push:`);
100
+ this.log.info(` https://github.com/${tmpOwner}/${tmpImageName}/pkgs/container/${tmpImageName}`);
101
+ this.log.info(` 3. Read BUILDING-AND-PUBLISHING.md for the release flow.`);
102
+
103
+ return fCallback();
104
+ }
105
+
106
+ // ── File ops ────────────────────────────────────────────────────
107
+
108
+ _writeFile(pPath, pContent, pForce)
109
+ {
110
+ let tmpExists = false;
111
+ try { tmpExists = libFs.statSync(pPath).isFile(); }
112
+ catch (pErr) { /* doesn't exist, fine */ }
113
+
114
+ if (tmpExists && !pForce)
115
+ {
116
+ return `[skip] ${pPath} (exists; pass --force to overwrite)`;
117
+ }
118
+ try
119
+ {
120
+ libFs.mkdirSync(libPath.dirname(pPath), { recursive: true });
121
+ libFs.writeFileSync(pPath, pContent);
122
+ return `[${tmpExists ? 'overwrite' : 'create'}] ${pPath}`;
123
+ }
124
+ catch (pErr)
125
+ {
126
+ return `[error] ${pPath}: ${pErr.message}`;
127
+ }
128
+ }
129
+
130
+ _patchPackageJson(pPath, pForce)
131
+ {
132
+ let tmpPkg;
133
+ try { tmpPkg = JSON.parse(libFs.readFileSync(pPath, 'utf8')); }
134
+ catch (pErr) { return `[error] ${pPath}: ${pErr.message}`; }
135
+
136
+ if (!tmpPkg.scripts) { tmpPkg.scripts = {}; }
137
+
138
+ let tmpDesired =
139
+ {
140
+ prepublishOnly: 'npm test',
141
+ postversion: 'npx quack release postversion',
142
+ postpublish: 'npx quack release postpublish',
143
+ 'publish:docker': 'npx quack release publish --image',
144
+ 'release:patch': 'npx quack release patch',
145
+ 'release:minor': 'npx quack release minor',
146
+ 'release:major': 'npx quack release major',
147
+ 'release:patch:image': 'npx quack release patch --image',
148
+ 'release:minor:image': 'npx quack release minor --image',
149
+ 'release:major:image': 'npx quack release major --image'
150
+ };
151
+
152
+ let tmpAdded = [];
153
+ let tmpSkipped = [];
154
+ let tmpKeys = Object.keys(tmpDesired);
155
+ for (let i = 0; i < tmpKeys.length; i++)
156
+ {
157
+ let tmpK = tmpKeys[i];
158
+ if (tmpPkg.scripts.hasOwnProperty(tmpK) && !pForce)
159
+ {
160
+ tmpSkipped.push(tmpK);
161
+ continue;
162
+ }
163
+ tmpPkg.scripts[tmpK] = tmpDesired[tmpK];
164
+ tmpAdded.push(tmpK);
165
+ }
166
+
167
+ if (tmpAdded.length === 0)
168
+ {
169
+ return `[skip] ${pPath} (all scripts already present; pass --force to overwrite)`;
170
+ }
171
+
172
+ // Detect existing indentation (tabs vs 4-space) so we don't
173
+ // reformat the file gratuitously.
174
+ let tmpRaw = libFs.readFileSync(pPath, 'utf8');
175
+ let tmpIndent = (tmpRaw.indexOf('\n\t') >= 0) ? '\t' : ' ';
176
+ try { libFs.writeFileSync(pPath, JSON.stringify(tmpPkg, null, tmpIndent) + '\n'); }
177
+ catch (pErr) { return `[error] ${pPath}: ${pErr.message}`; }
178
+ return `[patch] ${pPath} (added: ${tmpAdded.join(', ')}; kept: ${tmpSkipped.join(', ') || 'none'})`;
179
+ }
180
+
181
+ // ── Helpers ─────────────────────────────────────────────────────
182
+
183
+ _inferGithubOwner(pPkg)
184
+ {
185
+ let tmpRepo = pPkg.repository;
186
+ if (!tmpRepo) return null;
187
+ let tmpUrl = (typeof tmpRepo === 'string') ? tmpRepo : tmpRepo.url;
188
+ if (!tmpUrl) return null;
189
+ // Match github.com/<owner>/... in either https or ssh form.
190
+ let tmpMatch = tmpUrl.match(/github\.com[:/]+([^/]+)\//);
191
+ return tmpMatch ? tmpMatch[1] : null;
192
+ }
193
+
194
+ // ── Templates ───────────────────────────────────────────────────
195
+
196
+ _renderWorkflow(pSubs)
197
+ {
198
+ return `# Publish a container image to GitHub Container Registry on every
199
+ # version tag push (e.g. \`v1.2.3\`). Generated by \`quack docker-init\`.
200
+ #
201
+ # Image lands at:
202
+ # ghcr.io/${pSubs.Owner}/${pSubs.PackageName}:<version>
203
+ # ghcr.io/${pSubs.Owner}/${pSubs.PackageName}:latest (only on stable tags)
204
+
205
+ name: Publish container image
206
+
207
+ on:
208
+ push:
209
+ tags:
210
+ - 'v*.*.*'
211
+ workflow_dispatch:
212
+ inputs:
213
+ tag:
214
+ description: 'Tag to apply (e.g. dev or 1.2.3-test). \`latest\` is reserved for stable tag pushes.'
215
+ required: true
216
+ default: 'dev'
217
+
218
+ permissions:
219
+ contents: read
220
+ packages: write
221
+
222
+ jobs:
223
+ build-and-push:
224
+ runs-on: ubuntu-latest
225
+ steps:
226
+ - name: Checkout
227
+ uses: actions/checkout@v4
228
+
229
+ - name: Set up QEMU (multi-arch support)
230
+ uses: docker/setup-qemu-action@v3
231
+
232
+ - name: Set up Docker Buildx
233
+ uses: docker/setup-buildx-action@v3
234
+
235
+ - name: Log in to GHCR
236
+ uses: docker/login-action@v3
237
+ with:
238
+ registry: ghcr.io
239
+ username: \${{ github.actor }}
240
+ password: \${{ secrets.GITHUB_TOKEN }}
241
+
242
+ - name: Compute image tags
243
+ id: meta
244
+ uses: docker/metadata-action@v5
245
+ with:
246
+ images: ghcr.io/\${{ github.repository_owner }}/${pSubs.PackageName}
247
+ tags: |
248
+ type=semver,pattern={{version}}
249
+ type=semver,pattern={{major}}.{{minor}}
250
+ type=semver,pattern={{major}}
251
+ type=raw,value=\${{ github.event.inputs.tag }},enable=\${{ github.event_name == 'workflow_dispatch' }}
252
+
253
+ - name: Build and push
254
+ uses: docker/build-push-action@v5
255
+ with:
256
+ context: .
257
+ file: ./Dockerfile
258
+ platforms: linux/amd64,linux/arm64
259
+ push: true
260
+ tags: \${{ steps.meta.outputs.tags }}
261
+ labels: \${{ steps.meta.outputs.labels }}
262
+ cache-from: type=gha
263
+ cache-to: type=gha,mode=max
264
+ `;
265
+ }
266
+
267
+ _renderDoc(pSubs)
268
+ {
269
+ let tmpShapeNote = (pSubs.Shape === 'job')
270
+ ? `\`${pSubs.PackageName}\` is a **one-shot job** rather than a long-running service: the container runs to completion and exits. Restart policy in compose / k8s should be \`no\` or \`OnFailure\`. The Dockerfile should NOT declare a HEALTHCHECK; compose / k8s evaluate success via the exit code.`
271
+ : `\`${pSubs.PackageName}\` is a **long-running service**. Restart policy in compose / k8s should be \`unless-stopped\` (or equivalent). The Dockerfile should declare a HEALTHCHECK against the service's health endpoint.`;
272
+
273
+ return `# Building and Publishing
274
+
275
+ How to ship \`${pSubs.PackageName}\` to npm and to GitHub Container Registry
276
+ (GHCR). Generated by \`quack docker-init\`; the structure matches the
277
+ shared template across all dockerized retold tools.
278
+
279
+ ${tmpShapeNote}
280
+
281
+ ---
282
+
283
+ ## TL;DR
284
+
285
+ \`\`\`bash
286
+ # npm-only release (the default — most common case)
287
+ npm run release:patch
288
+
289
+ # npm release that ALSO rebuilds the GHCR image
290
+ npm run release:patch:image
291
+ \`\`\`
292
+
293
+ The default release is npm-only. Docker images are deliberate, opt-in
294
+ artifacts because each multi-arch build burns several minutes of CI
295
+ time. Use \`:image\` (or set \`BUILD_DOCKER=1\`) when runtime code,
296
+ dependencies, env-var contract, or the Dockerfile changed.
297
+
298
+ ---
299
+
300
+ ## Prerequisites (one-time setup)
301
+
302
+ - **npm login** — \`npm whoami\` should print your username.
303
+ - **Git remote configured** — \`git remote get-url origin\` should print
304
+ \`git@github.com:${pSubs.Owner}/${pSubs.PackageName}.git\` (or the
305
+ HTTPS equivalent).
306
+ - **Push access to the repo** — required so \`postversion\` /
307
+ \`postpublish\` hooks can push commits and tags.
308
+ - **Docker** (only if you want to test the image locally before tag).
309
+
310
+ ---
311
+
312
+ ## Ecosystem convention: lockfiles are gitignored
313
+
314
+ \`package-lock.json\` is in this repo's \`.gitignore\` (Quackage convention
315
+ shared across the retold ecosystem). The Dockerfile must use \`npm install\`,
316
+ not \`npm ci\` — \`npm ci\` requires the lockfile to be in the build context
317
+ and CI runners only check out what's in git. If you see EUSAGE errors in
318
+ GHCR build logs, change \`RUN npm ci\` to \`RUN npm install\` in the
319
+ Dockerfile.
320
+
321
+ ---
322
+
323
+ ## Releasing
324
+
325
+ | Command | npm registry | GHCR image rebuild |
326
+ |--------------------------------------|--------------|--------------------|
327
+ | \`npm run release:patch\` | yes | no |
328
+ | \`npm run release:patch:image\` | yes | yes |
329
+ | \`npm run release:minor\` | yes | no |
330
+ | \`npm run release:minor:image\` | yes | yes |
331
+ | \`npm run release:major\` | yes | no |
332
+ | \`npm run release:major:image\` | yes | yes |
333
+
334
+ The non-\`:image\` variants are the default because most patch releases
335
+ don't change runtime behavior. The \`:image\` variants tell the pipeline
336
+ "this release does change runtime — build me a new image."
337
+
338
+ ### Direct CLI (also works)
339
+
340
+ \`\`\`bash
341
+ npm publish # npm only
342
+ npm run publish:docker # npm + docker (sets BUILD_DOCKER=1)
343
+ \`\`\`
344
+
345
+ ### From \`retold-manager\` TUI
346
+
347
+ - \`[!]\` Publish — npm only
348
+ - \`[D]\` Publish with docker image — npm + GHCR build
349
+
350
+ ### Promoting a previous npm release to docker later
351
+
352
+ If you released \`v<x>\` to npm only, then later decide you do want a
353
+ docker image:
354
+
355
+ \`\`\`bash
356
+ git push origin v<x> # pushes the local tag → GHCR fires
357
+ \`\`\`
358
+
359
+ The local tag is still sitting there from the original \`npm version\`
360
+ step. Pushing it triggers the workflow without touching npm.
361
+
362
+ ---
363
+
364
+ ## The chain
365
+
366
+ The lifecycle hooks all live in \`package.json\` and delegate to
367
+ \`npx quack release …\`. Default path (\`BUILD_DOCKER\` unset):
368
+
369
+ \`\`\`
370
+ npm publish
371
+ → prepublishOnly: npm test ← test gate
372
+ → publish to npm
373
+ → postpublish: BUILD_DOCKER unset → no-op ← image NOT triggered
374
+ \`\`\`
375
+
376
+ Docker-included path (\`BUILD_DOCKER=1\`):
377
+
378
+ \`\`\`
379
+ BUILD_DOCKER=1 npm publish (or: npm run publish:docker)
380
+ → prepublishOnly: npm test
381
+ → publish to npm
382
+ → postpublish: BUILD_DOCKER=1 → tag + push ← image trigger
383
+ → .github/workflows/publish-image.yml fires
384
+ → docker buildx build linux/amd64,linux/arm64
385
+ → docker push ghcr.io/${pSubs.Owner}/${pSubs.PackageName}:<version>
386
+ \`\`\`
387
+
388
+ ---
389
+
390
+ ## Verifying a release
391
+
392
+ 1. **npm**: \`npm view ${pSubs.PackageName} version\`
393
+ 2. **Workflow**: \`https://github.com/${pSubs.Owner}/${pSubs.PackageName}/actions\`
394
+ 3. **Image**: \`docker pull ghcr.io/${pSubs.Owner}/${pSubs.PackageName}:latest\`
395
+
396
+ If the first \`docker pull\` returns \`denied\`, the package is private by
397
+ default — flip visibility to public via Package Settings → Danger Zone
398
+ on the package page.
399
+
400
+ ---
401
+
402
+ ## Image consumption
403
+
404
+ \`\`\`bash
405
+ docker pull ghcr.io/${pSubs.Owner}/${pSubs.PackageName}:latest
406
+ docker run --rm ghcr.io/${pSubs.Owner}/${pSubs.PackageName}:latest
407
+ \`\`\`
408
+
409
+ Configuration via env vars: see this module's README for the supported
410
+ \`<MODULE>_*\` variables. Any secret-bearing var also accepts \`<NAME>_FILE\`
411
+ pointing at a file whose contents become the value (mysql/postgres
412
+ convention; works with docker secrets and k8s Secrets).
413
+ `;
414
+ }
415
+ }
416
+
417
+ module.exports = QuackageCommandDockerInit;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Quackage Release — npm + GHCR release pipeline helpers.
3
+ *
4
+ * Centralizes the release-pipeline shell logic that would otherwise be
5
+ * duplicated across every dockerized retold module. Each module's
6
+ * package.json `scripts` becomes a small block of `npx quack release …`
7
+ * delegations:
8
+ *
9
+ * "prepublishOnly": "npm test",
10
+ * "postversion": "npx quack release postversion",
11
+ * "postpublish": "npx quack release postpublish",
12
+ * "release:patch": "npx quack release patch",
13
+ * "release:patch:image": "npx quack release patch --image"
14
+ *
15
+ * Subcommands:
16
+ *
17
+ * postversion Push the bump commit (NOT the tag — the tag push is
18
+ * gated by postpublish + BUILD_DOCKER so docker
19
+ * rebuilds are deliberate).
20
+ *
21
+ * postpublish When BUILD_DOCKER=1 is set in the env: tag
22
+ * v<package_version> (no-op if tag already exists)
23
+ * and push the tag to origin (which fires the GHCR
24
+ * workflow). When BUILD_DOCKER is unset: no-op. This
25
+ * inversion makes docker an explicit, opt-in artifact
26
+ * — most patch releases ship to npm only and skip the
27
+ * multi-arch build cost.
28
+ *
29
+ * publish `npm publish` directly. With --image, sets
30
+ * BUILD_DOCKER=1 in the env so postpublish fires.
31
+ *
32
+ * patch | minor | major
33
+ * One-shot release: `npm version <bump>` then
34
+ * `npm publish`. With --image, the publish step gets
35
+ * BUILD_DOCKER=1.
36
+ *
37
+ * Convention:
38
+ * Image tag is `v<package.json version>`. The GHCR workflow at
39
+ * `.github/workflows/publish-image.yml` triggers on `v*.*.*` tag
40
+ * pushes and builds + pushes to
41
+ * `ghcr.io/<owner>/<package-name>:<version>`.
42
+ */
43
+
44
+ const libCommandLineCommand = require('pict-service-commandlineutility').ServiceCommandLineCommand;
45
+ const libChildProcess = require('child_process');
46
+
47
+ const VALID_SUBCOMMANDS = ['postversion', 'postpublish', 'publish', 'patch', 'minor', 'major'];
48
+
49
+ class QuackageCommandRelease extends libCommandLineCommand
50
+ {
51
+ constructor(pFable, pManifest, pServiceHash)
52
+ {
53
+ super(pFable, pManifest, pServiceHash);
54
+
55
+ this.options.CommandKeyword = 'release';
56
+ this.options.Description = 'Release-pipeline helpers (postversion / postpublish hooks + one-shot release shortcuts).';
57
+
58
+ this.options.CommandArguments.push({ Name: '<subcommand>', Description: 'postversion | postpublish | publish | patch | minor | major' });
59
+
60
+ this.options.CommandOptions.push({ Name: '--image', Description: 'Include the GHCR docker image build (sets BUILD_DOCKER=1).', Default: false });
61
+
62
+ this.addCommand();
63
+ }
64
+
65
+ onRunAsync(fCallback)
66
+ {
67
+ let tmpSub = (this.ArgumentString || '').trim();
68
+ let tmpWithImage = !!this.CommandOptions.image;
69
+
70
+ if (VALID_SUBCOMMANDS.indexOf(tmpSub) < 0)
71
+ {
72
+ this.log.error(`Unknown release subcommand: [${tmpSub || '(none)'}]. Expected one of: ${VALID_SUBCOMMANDS.join(', ')}.`);
73
+ return fCallback(new Error('Unknown release subcommand'));
74
+ }
75
+
76
+ switch (tmpSub)
77
+ {
78
+ case 'postversion':
79
+ return this._runPostversion(fCallback);
80
+ case 'postpublish':
81
+ return this._runPostpublish(fCallback);
82
+ case 'publish':
83
+ return this._runPublish(tmpWithImage, fCallback);
84
+ case 'patch':
85
+ case 'minor':
86
+ case 'major':
87
+ return this._runVersion(tmpSub, tmpWithImage, fCallback);
88
+ }
89
+ }
90
+
91
+ // ── Hook implementations ──────────────────────────────────────────
92
+
93
+ _runPostversion(fCallback)
94
+ {
95
+ // Push the bump commit only. The local tag npm version created
96
+ // stays local; postpublish (gated by BUILD_DOCKER) is the only
97
+ // thing that pushes tags to remote.
98
+ return this._spawn('git', ['push'], fCallback);
99
+ }
100
+
101
+ _runPostpublish(fCallback)
102
+ {
103
+ if (process.env.BUILD_DOCKER !== '1')
104
+ {
105
+ this.log.info('postpublish: BUILD_DOCKER unset — skipping docker tag/push.');
106
+ return fCallback();
107
+ }
108
+
109
+ let tmpVersion = this.fable.AppData.Package && this.fable.AppData.Package.version;
110
+ if (!tmpVersion)
111
+ {
112
+ this.log.error('postpublish: cannot read package version from AppData.Package.version');
113
+ return fCallback(new Error('No package version'));
114
+ }
115
+ let tmpTag = 'v' + tmpVersion;
116
+
117
+ // Idempotent tag — fails silently if it already exists.
118
+ let tmpTagResult = libChildProcess.spawnSync('git', ['tag', tmpTag],
119
+ { stdio: ['ignore', 'pipe', 'pipe'] });
120
+ if (tmpTagResult.status === 0)
121
+ {
122
+ this.log.info(`postpublish: created git tag ${tmpTag}.`);
123
+ }
124
+ else
125
+ {
126
+ this.log.info(`postpublish: git tag ${tmpTag} already exists locally — pushing existing.`);
127
+ }
128
+
129
+ // Push the tag. If already on remote, push is a no-op; we
130
+ // swallow any non-zero exit to keep the npm publish flow
131
+ // from looking failed.
132
+ let tmpPushResult = libChildProcess.spawnSync('git', ['push', 'origin', tmpTag],
133
+ { stdio: ['ignore', 'pipe', 'pipe'] });
134
+ if (tmpPushResult.status === 0)
135
+ {
136
+ this.log.info(`postpublish: pushed git tag ${tmpTag} to origin → GHCR build will start.`);
137
+ }
138
+ else
139
+ {
140
+ this.log.warn(`postpublish: git push origin ${tmpTag} returned non-zero (likely already on remote); continuing.`);
141
+ }
142
+ return fCallback();
143
+ }
144
+
145
+ // ── User-invoked subcommands ──────────────────────────────────────
146
+
147
+ _runPublish(pWithImage, fCallback)
148
+ {
149
+ let tmpEnv = Object.assign({}, process.env);
150
+ if (pWithImage)
151
+ {
152
+ tmpEnv.BUILD_DOCKER = '1';
153
+ this.log.info('release publish --image: BUILD_DOCKER=1 will trigger postpublish to push the version tag.');
154
+ }
155
+ else
156
+ {
157
+ this.log.info('release publish: npm only (no docker rebuild).');
158
+ }
159
+ let tmpResult = libChildProcess.spawnSync('npm', ['publish'],
160
+ { stdio: 'inherit', env: tmpEnv });
161
+ return fCallback(tmpResult.status === 0
162
+ ? null
163
+ : new Error(`npm publish exited ${tmpResult.status}`));
164
+ }
165
+
166
+ _runVersion(pBump, pWithImage, fCallback)
167
+ {
168
+ // `npm version <bump>` bumps + commits + creates LOCAL tag.
169
+ // Module's `postversion` hook should call
170
+ // `npx quack release postversion`, which pushes the commit
171
+ // only.
172
+ let tmpBumpResult = libChildProcess.spawnSync('npm', ['version', pBump],
173
+ { stdio: 'inherit' });
174
+ if (tmpBumpResult.status !== 0)
175
+ {
176
+ return fCallback(new Error(`npm version ${pBump} exited ${tmpBumpResult.status}`));
177
+ }
178
+
179
+ // Then publish, optionally with docker.
180
+ return this._runPublish(pWithImage, fCallback);
181
+ }
182
+
183
+ // ── Helpers ───────────────────────────────────────────────────────
184
+
185
+ _spawn(pCmd, pArgs, fCallback)
186
+ {
187
+ let tmpResult = libChildProcess.spawnSync(pCmd, pArgs, { stdio: 'inherit' });
188
+ return fCallback(tmpResult.status === 0
189
+ ? null
190
+ : new Error(`${pCmd} ${pArgs.join(' ')} exited ${tmpResult.status}`));
191
+ }
192
+ }
193
+
194
+ module.exports = QuackageCommandRelease;