quackage 1.1.3 → 1.2.1
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
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Building. Testing. Quacking. Reloading.",
|
|
5
5
|
"main": "source/Quackage-CLIProgram.js",
|
|
6
6
|
"scripts": {
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"mocha": "10.4.0",
|
|
66
66
|
"npm-check-updates": "^18.0.1",
|
|
67
67
|
"nyc": "^15.1.0",
|
|
68
|
+
"pict-provider-theme": "^0.0.1",
|
|
68
69
|
"pict-service-commandlineutility": "^1.0.19",
|
|
69
70
|
"retold-harness": "^1.1.10",
|
|
70
71
|
"vinyl-buffer": "^1.0.1",
|
|
@@ -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'),
|
|
@@ -57,7 +63,10 @@ let _Pict = new libCLIProgram(
|
|
|
57
63
|
// HTML example application building and serving
|
|
58
64
|
require('./commands/html_example_serving/Quackage-Command-ExamplesBuild.js'),
|
|
59
65
|
require('./commands/html_example_serving/Quackage-Command-ExamplesServe.js'),
|
|
60
|
-
require('./commands/html_example_serving/Quackage-Command-Examples.js')
|
|
66
|
+
require('./commands/html_example_serving/Quackage-Command-Examples.js'),
|
|
67
|
+
|
|
68
|
+
// Theme bundle compilation (pict-provider-theme)
|
|
69
|
+
require('pict-provider-theme/source/cli/Quackage-Command-ThemeBuild.js')
|
|
61
70
|
]);
|
|
62
71
|
|
|
63
72
|
// Instantiate the file persistence service
|
|
@@ -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;
|