@starci/hfs 4.12.2 → 4.13.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.
- package/CHANGELOG.md +50 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +97 -7
- package/runtime/knowledge/hfs/slots.yaml +20 -8
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +33 -0
- package/stack/ci-key.d.mts +11 -0
- package/stack/ci-key.mjs +44 -0
- package/stack/cli.d.mts +7 -1
- package/stack/cli.mjs +22 -13
- package/stack/config.d.mts +15 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +22 -5
- package/stack/deploy.mjs +242 -72
- package/stack/journal.d.mts +146 -0
- package/stack/journal.mjs +191 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/notify.d.mts +9 -0
- package/stack/notify.mjs +32 -0
- package/stack/release-store.d.mts +49 -1
- package/stack/release-store.mjs +102 -16
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +32 -11
- package/stack/rollback-guard.d.mts +10 -0
- package/stack/rollback-guard.mjs +41 -0
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/ssh-gate.d.mts +35 -0
- package/stack/ssh-gate.mjs +145 -0
- package/stack/status.d.mts +51 -0
- package/stack/status.mjs +115 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
package/sync/format.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// format.mjs - HFS_FORMAT (R19): Prettier is the only formatter, and every tracked file it formats is formatted.
|
|
2
2
|
// The check is `prettier --check` over the tracked files through the repository's OWN prettier (resolved from the repository,
|
|
3
3
|
// with the repository's prettier config and .prettierignore), so it judges what the repository's pre-commit and CI judge. A
|
|
4
|
-
// repository with no installed prettier is a refusal (HFS_FORMAT_TOOL_MISSING), never a pass.
|
|
4
|
+
// repository with no installed prettier is a refusal (HFS_FORMAT_TOOL_MISSING), never a pass. Prettier runs in batches: the files are cut into
|
|
5
|
+
// batches of FORMAT_BATCH, and a long list is spread over worker threads that each load the repository's prettier once (one process or one
|
|
6
|
+
// config lookup per file was 16 of the 35 minutes of a full app check). Configuration is resolved once per directory, not once per file.
|
|
7
|
+
import os from 'node:os';
|
|
5
8
|
import { createRequire } from 'node:module';
|
|
9
|
+
import { Worker } from 'node:worker_threads';
|
|
6
10
|
import fs from 'node:fs';
|
|
7
11
|
import path from 'node:path';
|
|
8
12
|
import { SyncError } from './index.mjs';
|
|
@@ -20,14 +24,21 @@ export function loadPrettier(repoRoot) {
|
|
|
20
24
|
throw new SyncError('HFS_FORMAT_TOOL_MISSING', `prettier is not installed under ${repoRoot}; install the version knowledge/hfs/canon-pins.yaml pins, so the format check judges what the repository's own gates judge`);
|
|
21
25
|
}
|
|
22
26
|
}
|
|
27
|
+
/** Files per batch: one message to a worker, one progress step. */
|
|
28
|
+
export const FORMAT_BATCH = 64;
|
|
29
|
+
/** Fewer files than this run in this thread: starting workers would cost more than it saves. */
|
|
30
|
+
const WORKER_MIN_FILES = 256;
|
|
31
|
+
/** The formattable files of `files`: lockfiles are written by npm and never formatted. */
|
|
32
|
+
export const formattable = (files) => files.filter((f) => !LOCKFILES.has(path.posix.basename(f)));
|
|
23
33
|
/**
|
|
24
|
-
* The HFS_FORMAT findings
|
|
25
|
-
*
|
|
34
|
+
* The HFS_FORMAT findings of one batch of `files`, in this thread. The ignore file is read by prettier once per call and the configuration is
|
|
35
|
+
* resolved with prettier's own cache, so a batch costs one lookup per directory.
|
|
26
36
|
*/
|
|
27
|
-
export async function
|
|
37
|
+
export async function checkBatch({ repoRoot, files, prettier }) {
|
|
28
38
|
const ignorePath = path.join(repoRoot, IGNORE_FILE);
|
|
39
|
+
const ignoreFile = fs.existsSync(ignorePath) ? ignorePath : undefined;
|
|
29
40
|
const findings = [];
|
|
30
|
-
for (const file of files
|
|
41
|
+
for (const file of formattable(files)) {
|
|
31
42
|
const absolute = path.join(repoRoot, file);
|
|
32
43
|
let text;
|
|
33
44
|
try {
|
|
@@ -36,10 +47,10 @@ export async function formatFindings({ repoRoot, files, prettier = loadPrettier(
|
|
|
36
47
|
catch {
|
|
37
48
|
continue;
|
|
38
49
|
}
|
|
39
|
-
const info = await prettier.getFileInfo(absolute, { ignorePath:
|
|
50
|
+
const info = await prettier.getFileInfo(absolute, { ignorePath: ignoreFile, resolveConfig: true });
|
|
40
51
|
if (info.ignored || !info.inferredParser)
|
|
41
52
|
continue;
|
|
42
|
-
const options = (await prettier.resolveConfig(absolute, { editorconfig: true, useCache:
|
|
53
|
+
const options = (await prettier.resolveConfig(absolute, { editorconfig: true, useCache: true })) ?? {};
|
|
43
54
|
try {
|
|
44
55
|
if (!(await prettier.check(text, { ...options, filepath: absolute })))
|
|
45
56
|
findings.push({ code: FORMAT, level: 'error', path: file, message: `${file} is not formatted by prettier; run \`npm run format\`` });
|
|
@@ -50,3 +61,84 @@ export async function formatFindings({ repoRoot, files, prettier = loadPrettier(
|
|
|
50
61
|
}
|
|
51
62
|
return findings;
|
|
52
63
|
}
|
|
64
|
+
/** Cuts `files` into batches of `size`. */
|
|
65
|
+
export function batches(files, size = FORMAT_BATCH) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (let i = 0; i < files.length; i += size)
|
|
68
|
+
out.push(files.slice(i, i + size));
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** How many worker threads a list of `count` files gets: none below WORKER_MIN_FILES, else up to the cores left over, at most 8. */
|
|
72
|
+
export function workerCount(count, cores = os.availableParallelism()) {
|
|
73
|
+
if (count < WORKER_MIN_FILES)
|
|
74
|
+
return 0;
|
|
75
|
+
return Math.max(0, Math.min(8, cores - 1, Math.ceil(count / (FORMAT_BATCH * 2))));
|
|
76
|
+
}
|
|
77
|
+
function inWorkers({ repoRoot, files, workers, onProgress }) {
|
|
78
|
+
const queue = batches(files);
|
|
79
|
+
const findings = [];
|
|
80
|
+
let done = 0;
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
let running = 0;
|
|
83
|
+
let failed = false;
|
|
84
|
+
const pool = [];
|
|
85
|
+
const next = (worker) => {
|
|
86
|
+
const batch = queue.shift();
|
|
87
|
+
if (batch === undefined) {
|
|
88
|
+
void worker.terminate();
|
|
89
|
+
if (running === 0 && !failed)
|
|
90
|
+
resolve(findings);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
running += 1;
|
|
94
|
+
worker.postMessage({ repoRoot, files: batch });
|
|
95
|
+
};
|
|
96
|
+
const fail = (error) => {
|
|
97
|
+
if (failed)
|
|
98
|
+
return;
|
|
99
|
+
failed = true;
|
|
100
|
+
for (const worker of pool)
|
|
101
|
+
void worker.terminate();
|
|
102
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
103
|
+
};
|
|
104
|
+
for (let i = 0; i < workers; i += 1) {
|
|
105
|
+
const worker = new Worker(new URL('./format-worker.mjs', import.meta.url));
|
|
106
|
+
pool.push(worker);
|
|
107
|
+
worker.on('message', (message) => {
|
|
108
|
+
if (message.error !== undefined) {
|
|
109
|
+
fail(message.code === 'HFS_FORMAT_TOOL_MISSING' ? new SyncError('HFS_FORMAT_TOOL_MISSING', message.error) : new Error(message.error));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
running -= 1;
|
|
113
|
+
findings.push(...(message.findings ?? []));
|
|
114
|
+
done += message.count ?? 0;
|
|
115
|
+
onProgress?.(done);
|
|
116
|
+
next(worker);
|
|
117
|
+
});
|
|
118
|
+
worker.on('error', fail);
|
|
119
|
+
next(worker);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The HFS_FORMAT findings over the tracked `files` of `repoRoot`: one per file prettier would change, or cannot parse, sorted by path.
|
|
125
|
+
* `prettier` is a seam for specs (and keeps the run in this thread); the default is the repository's own, in worker threads for a long list.
|
|
126
|
+
*/
|
|
127
|
+
export async function formatFindings({ repoRoot, files, prettier, onProgress }) {
|
|
128
|
+
const list = formattable(files);
|
|
129
|
+
const mine = prettier ?? loadPrettier(repoRoot);
|
|
130
|
+
const workers = prettier === undefined ? workerCount(list.length) : 0;
|
|
131
|
+
let findings;
|
|
132
|
+
if (workers > 0)
|
|
133
|
+
findings = await inWorkers({ repoRoot, files: list, workers, onProgress });
|
|
134
|
+
else {
|
|
135
|
+
findings = [];
|
|
136
|
+
let done = 0;
|
|
137
|
+
for (const batch of batches(list)) {
|
|
138
|
+
findings.push(...await checkBatch({ repoRoot, files: batch, prettier: mine }));
|
|
139
|
+
done += batch.length;
|
|
140
|
+
onProgress?.(done);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return findings.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
144
|
+
}
|
package/sync/index.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type RepoView } from '../runtime/scripts/hfs/slots.mjs';
|
|
2
2
|
import type { AppRepo } from '../runtime/scripts/hfs/slot-app-view.mjs';
|
|
3
3
|
import type { Slot, SlotManifest } from '../runtime/scripts/hfs/manifest-shape.mjs';
|
|
4
|
+
import type { ImageApp } from './stack-images.mjs';
|
|
4
5
|
export declare const TEMPLATES_DIR: string;
|
|
5
6
|
/** The scopes a target is rendered for: the app root, then each side. */
|
|
6
7
|
export declare const SCOPES: readonly string[];
|
|
@@ -180,12 +181,8 @@ export declare function jestCoverageSource(coverage: {
|
|
|
180
181
|
export declare function appScripts(app: ResolvedApp, source: Partial<Pick<AppSourceFacts, 'hasImage'>> | undefined): string;
|
|
181
182
|
/** The stylesheets of the fe side the CSS canon judges, relative to fe/: every app and every workspace package (slot fe.route, fe.package.*). */
|
|
182
183
|
export declare const STYLE_GLOB = "{apps,packages}/*/src/**/*.css";
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
* runs on the release tag, so every image is built there; no path filter narrows it. A YAML block mapping per entry, indented for
|
|
186
|
-
* `strategy.matrix.include`.
|
|
187
|
-
*/
|
|
188
|
-
export declare function imageMatrix(app: ResolvedApp, source: Partial<Pick<AppSourceFacts, 'hasImage'>> | undefined): string;
|
|
184
|
+
/** The apps images.yml builds (one matrix entry per declared app): name and Dockerfile, be then fe, then a lite app's db image. */
|
|
185
|
+
export declare function imageApps(app: ResolvedApp, source: Partial<Pick<AppSourceFacts, 'hasImage'>> | undefined): ImageApp[];
|
|
189
186
|
/**
|
|
190
187
|
* The Dockerfile of every declared app, [{ path, content }] (app-relative): written once by starci app scaffold from templates/<side>/image/<kind>
|
|
191
188
|
* and then the app's own, judged by the docker rules (R187-R191). A Next image copies the fe packages only when the fe side opts into
|
package/sync/index.mjs
CHANGED
|
@@ -335,15 +335,10 @@ function imageEntries(app, source) {
|
|
|
335
335
|
const frontend = app.sides.fe.apps.filter(entry => app.edition !== 'lite' || source?.hasImage?.(dockerfilePath('fe', entry.name)));
|
|
336
336
|
return [...app.sides.be.apps.map((entry) => ({ side: 'be', entry })), ...frontend.map((entry) => ({ side: 'fe', entry }))];
|
|
337
337
|
}
|
|
338
|
-
/**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
*/
|
|
343
|
-
export function imageMatrix(app, source) {
|
|
344
|
-
const entry = (side, item) => ` - name: ${item.name}\n file: ${dockerfilePath(side, item.name)}`;
|
|
345
|
-
const db = app.edition === 'lite' ? [` - name: ${DB_IMAGE_APP}\n file: ${DB_DOCKERFILE}`] : [];
|
|
346
|
-
return [...imageEntries(app, source).map(image => entry(image.side, image.entry)), ...db].join('\n');
|
|
338
|
+
/** The apps images.yml builds (one matrix entry per declared app): name and Dockerfile, be then fe, then a lite app's db image. */
|
|
339
|
+
export function imageApps(app, source) {
|
|
340
|
+
const db = app.edition === 'lite' ? [{ name: DB_IMAGE_APP, file: DB_DOCKERFILE }] : [];
|
|
341
|
+
return [...imageEntries(app, source).map(image => ({ name: image.entry.name, file: dockerfilePath(image.side, image.entry.name) })), ...db];
|
|
347
342
|
}
|
|
348
343
|
/** The Dockerfile of the app a deploy image names (be first, then fe, then a lite app's db image); an app hfs.json does not declare cannot be built by the managed workflow. */
|
|
349
344
|
function stackDockerfile(app, name) {
|
|
@@ -354,6 +349,12 @@ function stackDockerfile(app, name) {
|
|
|
354
349
|
throw new SyncError('HFS_SYNC_STACK_IMAGE', `deploy.images names the app ${name}, which hfs.json does not declare under be or fe`);
|
|
355
350
|
return dockerfilePath(side, name);
|
|
356
351
|
}
|
|
352
|
+
/** The apps images.yml builds; every deploy image must name one of them (an app hfs.json does not declare cannot be built by the managed workflow). */
|
|
353
|
+
function deployableApps(app, source, stack) {
|
|
354
|
+
for (const image of stack.images)
|
|
355
|
+
stackDockerfile(app, image.app);
|
|
356
|
+
return imageApps(app, source);
|
|
357
|
+
}
|
|
357
358
|
const IMAGE_TEMPLATE = Object.freeze({ api: 'be/image/api', worker: 'be/image/worker', cli: 'be/image/cli', next: 'fe/image/next' });
|
|
358
359
|
/**
|
|
359
360
|
* The Dockerfile of every declared app, [{ path, content }] (app-relative): written once by starci app scaffold from templates/<side>/image/<kind>
|
|
@@ -427,9 +428,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
427
428
|
styleGlob: STYLE_GLOB,
|
|
428
429
|
grammarLintImport: grammarLint?.package ? `import { ${grammarLint.factory} } from "${grammarLint.package}"` : '',
|
|
429
430
|
grammarLintExport: grammarLint?.package ? `export default [...starciFeConfig({ hfs }), ...${grammarLint.factory}({ hfs })]` : 'export default starciFeConfig({ hfs })',
|
|
430
|
-
|
|
431
|
-
imageProject: app.project,
|
|
432
|
-
...stackWorkflowVariables(stack, app.project, (name) => stackDockerfile(app, name)),
|
|
431
|
+
...stackWorkflowVariables(stack, app.project, deployableApps(app, source, stack), String(NODE_MAJOR)),
|
|
433
432
|
};
|
|
434
433
|
}
|
|
435
434
|
// The scripts of a package.json as one comparable text: a line per script, sorted by name (order is not drift).
|
|
@@ -600,6 +599,7 @@ const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
|
|
|
600
599
|
/** The files a lite app's sync retires, each with the reason the dry run prints. */
|
|
601
600
|
const LITE_RETIRED_FILES = Object.freeze({
|
|
602
601
|
[RETIRED_DB_DOCKERFILE]: `the db image moved to ${DB_DOCKERFILE} (ruling lite-db-image-in-stacks)`,
|
|
602
|
+
'.github/workflows/db-deploy.yml': 'the stack deploy migrates the database (a verified backup, the expand migrations before the services, the contract migrations after the accept; ruling app-cicd-auto-deploy)',
|
|
603
603
|
});
|
|
604
604
|
/** Why a retired file goes: a retired file's own reason, else the folder's. */
|
|
605
605
|
const retiredReason = (file) => LITE_RETIRED_FILES[file] ?? 'the lite edition generates no database types';
|
package/sync/stack-images.d.mts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
type Declaration = any;
|
|
2
2
|
type ParseYaml = (text: string) => Declaration;
|
|
3
|
-
/** One image of the declaration: the service, its app
|
|
3
|
+
/** One image of the declaration: the service, its app, whether it carries build arguments and whether those open a sealed member. */
|
|
4
4
|
export interface StackImage {
|
|
5
5
|
service: string;
|
|
6
6
|
app: string;
|
|
7
7
|
buildArgs: boolean;
|
|
8
|
+
sealed?: boolean;
|
|
8
9
|
}
|
|
9
|
-
/**
|
|
10
|
+
/** One environment that deploys: the deploy.auto, deploy.approval and deploy.platforms keys of its deploy block. */
|
|
11
|
+
export interface DeployEnvironment {
|
|
12
|
+
name: string;
|
|
13
|
+
auto: boolean;
|
|
14
|
+
approval: 'none' | 'reviewers';
|
|
15
|
+
platforms: string[];
|
|
16
|
+
}
|
|
17
|
+
/** What the managed workflows render from the declaration. `environments` are the deploying ones in declared order; `imageCache` is ci.imageCache. */
|
|
10
18
|
export interface DeclaredStack {
|
|
11
19
|
images: StackImage[];
|
|
12
20
|
passEnv: string[];
|
|
13
21
|
imageEnvironments?: Record<string, string[]>;
|
|
22
|
+
environments?: DeployEnvironment[];
|
|
23
|
+
imageCache?: 'gha' | 'registry';
|
|
14
24
|
}
|
|
15
25
|
/**
|
|
16
26
|
* What the managed workflows render from the declaration at `root`: { images, passEnv, imageEnvironments } (empty when the app has no
|
|
@@ -21,16 +31,21 @@ export declare function readDeclaredStack(root: string, { parseYaml }?: {
|
|
|
21
31
|
}): DeclaredStack;
|
|
22
32
|
/** Parsed declarations share the exact image, environment and pass-env projection used by sync. */
|
|
23
33
|
export declare function declaredStack(declaration: Declaration): DeclaredStack;
|
|
34
|
+
/** An app the images workflow builds: its Dockerfile. */
|
|
35
|
+
export interface ImageApp {
|
|
36
|
+
name: string;
|
|
37
|
+
file: string;
|
|
38
|
+
}
|
|
24
39
|
/**
|
|
25
|
-
* The text blocks the
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* stackImageSteps per image: the optional build-args step and the build-and-push step
|
|
30
|
-
* stackImageEnv the env entries of the deploy step: IMAGE_<SERVICE> = repository@digest
|
|
40
|
+
* The text blocks the managed workflows place at the end of a line or on a line of their own (empty when there is nothing to place):
|
|
41
|
+
* stackGuard the first step of the deploy job when the app declares no image (it fails with the reason)
|
|
42
|
+
* stackImageList the `<service> <app>` lines the image step of stack-deploy.yml reads (the registry holds sha-<sha> of each)
|
|
43
|
+
* stackImageEnv the env entries of the deploy step: IMAGE_<SERVICE> = repository@digest, from the image step
|
|
31
44
|
* stackImageArgs the `--image "<service>=$IMAGE_<SERVICE>" \` continuation lines of the deploy command
|
|
32
45
|
* stackPassEnv the env entries of the deploy step for the declared host variables (vars.<NAME>)
|
|
33
|
-
*
|
|
46
|
+
* imageJobs, imageWorkflowOutputs the build and collect jobs and the output of images.yml
|
|
47
|
+
* ciDeployJobs the deploy jobs of ci.yml (one per environment with deploy.auto)
|
|
48
|
+
* `apps` are the apps images.yml builds, each with its Dockerfile (the sync module knows the layout).
|
|
34
49
|
*/
|
|
35
|
-
export declare function stackWorkflowVariables({ images, passEnv, imageEnvironments: environments }: DeclaredStack, project: string,
|
|
50
|
+
export declare function stackWorkflowVariables({ images, passEnv, imageEnvironments: environments, environments: deploying, imageCache }: DeclaredStack, project: string, apps?: readonly ImageApp[], nodeMajor?: string): Record<string, string>;
|
|
36
51
|
export {};
|
package/sync/stack-images.mjs
CHANGED
|
@@ -17,13 +17,15 @@ const CONTINUE = '\\';
|
|
|
17
17
|
const NO_IMAGES_MESSAGE = 'no image is declared: declare environments.<env>.deploy (with images) in .starcistacks/application-stacks.yaml before running this workflow';
|
|
18
18
|
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); // reason: a YAML mapping
|
|
19
19
|
const hasBuildArgs = (args) => record(args) && (Object.keys(record(args.values) ? args.values : {}).length > 0 || record(args.sealed));
|
|
20
|
+
const PLATFORM = /^linux\/(?:amd64|arm64)$/u;
|
|
21
|
+
const DEFAULT_PLATFORMS = ['linux/amd64'];
|
|
20
22
|
function environmentImages(environment) {
|
|
21
23
|
const images = environment?.deploy?.images;
|
|
22
24
|
if (!record(images))
|
|
23
25
|
return [];
|
|
24
26
|
return Object.entries(images)
|
|
25
27
|
.filter(([service, entry]) => NAME.test(service) && record(entry) && NAME.test(String(entry.app)))
|
|
26
|
-
.map(([service, entry]) => ({ service, app: entry.app, buildArgs: hasBuildArgs(entry.buildArgs) }));
|
|
28
|
+
.map(([service, entry]) => ({ service, app: entry.app, buildArgs: hasBuildArgs(entry.buildArgs), sealed: record(entry.buildArgs) && record(entry.buildArgs.sealed) }));
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
29
31
|
* The images of a parsed declaration, [{ service, app, buildArgs }] sorted by service: the union over its environments (the first
|
|
@@ -34,7 +36,7 @@ function declaredStackImages(declaration) {
|
|
|
34
36
|
for (const environment of Object.values(record(declaration?.environments) ? declaration.environments : {})) {
|
|
35
37
|
for (const image of environmentImages(environment)) {
|
|
36
38
|
const known = byService.get(image.service);
|
|
37
|
-
byService.set(image.service, known ? { ...known, buildArgs: known.buildArgs || image.buildArgs } : image);
|
|
39
|
+
byService.set(image.service, known ? { ...known, buildArgs: known.buildArgs || image.buildArgs, sealed: Boolean(known.sealed || image.sealed) } : image);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
return [...byService.values()].sort((a, b) => byCodeUnit(a.service, b.service));
|
|
@@ -52,6 +54,16 @@ function imageEnvironments(declaration) {
|
|
|
52
54
|
.filter(([name, environment]) => NAME.test(name) && environmentImages(environment).length > 0)
|
|
53
55
|
.map(([name, environment]) => [name, environmentImages(environment).map(image => image.app)]));
|
|
54
56
|
}
|
|
57
|
+
/** The environments that carry a deploy block, in declared order, with the auto, approval and platforms keys (defaults: manual, no reviewers, linux/amd64). */
|
|
58
|
+
function deployEnvironments(declaration) {
|
|
59
|
+
return Object.entries(record(declaration?.environments) ? declaration.environments : {})
|
|
60
|
+
.filter(([name, environment]) => NAME.test(name) && record(environment?.deploy))
|
|
61
|
+
.map(([name, environment]) => {
|
|
62
|
+
const deploy = environment.deploy;
|
|
63
|
+
const platforms = Array.isArray(deploy.platforms) ? deploy.platforms.filter((value) => typeof value === 'string' && PLATFORM.test(value)) : [];
|
|
64
|
+
return { name, auto: deploy.auto === true, approval: deploy.approval === 'reviewers' ? 'reviewers' : 'none', platforms: platforms.length ? platforms : DEFAULT_PLATFORMS };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
55
67
|
/**
|
|
56
68
|
* What the managed workflows render from the declaration at `root`: { images, passEnv, imageEnvironments } (empty when the app has no
|
|
57
69
|
* declaration, no deploy block or a declaration that does not parse; `starci app check` judges those). `parseYaml(text)` is injected.
|
|
@@ -71,78 +83,227 @@ export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
|
|
|
71
83
|
}
|
|
72
84
|
/** Parsed declarations share the exact image, environment and pass-env projection used by sync. */
|
|
73
85
|
export function declaredStack(declaration) {
|
|
74
|
-
|
|
86
|
+
const cache = declaration?.ci?.imageCache;
|
|
87
|
+
return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration), environments: deployEnvironments(declaration), imageCache: cache === 'registry' ? 'registry' : 'gha' };
|
|
75
88
|
}
|
|
76
89
|
const envName = (service) => `IMAGE_${service.toUpperCase().replaceAll('-', '_')}`;
|
|
77
90
|
/** Lines placed at the end of a template line: each one starts with a newline, so nothing declared leaves nothing, not even a blank line. */
|
|
78
91
|
const block = (items, indent = INDENT) => items.map(item => `\n${indent}${item}`).join('');
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
/** Lines of a block placed on a line of their own, each indented (an empty list is the empty string). */
|
|
93
|
+
const lines = (items, indent) => items.map(item => (item === '' ? '' : `${indent}${item}`)).join('\n');
|
|
94
|
+
const CHECKOUT = 'actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0';
|
|
95
|
+
const SETUP_NODE = 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0';
|
|
96
|
+
const SETUP_BUILDX = 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0';
|
|
97
|
+
const SETUP_QEMU = 'docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0';
|
|
98
|
+
const DOCKER_LOGIN = 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0';
|
|
99
|
+
const BUILD_PUSH = 'docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2';
|
|
100
|
+
const INSTALL_SOPS = 'mdgreenwald/mozilla-sops-action@bb2d96a13ef438ad122e5827a57ebd75d6ff118d # v2.1.0';
|
|
101
|
+
const TAG_MATCHES_VERSION = 'node -e "const version=require(\'./package.json\').version;if(process.env.GITHUB_REF_NAME!==\'v\'+version)process.exit(1)"';
|
|
102
|
+
/**
|
|
103
|
+
* Which of the app's images need build values, and from where. The values of the release environment (the sole image environment, or production
|
|
104
|
+
* when several deploy images) are the ones an image is built with; an app that environment does not deploy keeps its Dockerfile defaults.
|
|
105
|
+
*/
|
|
106
|
+
function imagePlans(apps, { images, imageEnvironments: environments = {} }) {
|
|
107
|
+
const selected = new Set(environments[releaseEnvironment(environments)] ?? []);
|
|
108
|
+
return apps.map((app) => {
|
|
109
|
+
const own = images.filter(image => image.app === app.name);
|
|
110
|
+
const args = selected.has(app.name) && own.some(image => image.buildArgs);
|
|
111
|
+
return { ...app, deployed: own.length > 0, args, sealed: args && own.some(image => image.sealed === true), services: own.map(image => image.service) };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const matrixEntries = (plans) => plans.flatMap(plan => [`- name: ${plan.name}`, ` file: ${plan.file}`, ` deployed: ${plan.deployed}`, ` args: ${plan.args}`]);
|
|
115
|
+
/** The platforms every deploying environment needs, the union in a stable order (default linux/amd64: the VPS's architecture). */
|
|
116
|
+
function platformsOf(environments) {
|
|
117
|
+
const all = new Set(environments.flatMap(environment => environment.platforms));
|
|
118
|
+
return all.size ? [...all].sort(byCodeUnit) : DEFAULT_PLATFORMS;
|
|
119
|
+
}
|
|
120
|
+
function imageCacheLines(cache) {
|
|
121
|
+
if (cache === 'registry')
|
|
122
|
+
return [
|
|
123
|
+
'cache-from: type=registry,ref=${{ steps.names.outputs.repo }}:buildcache',
|
|
124
|
+
"cache-to: ${{ inputs.push && format('type=registry,ref={0}:buildcache,mode=max', steps.names.outputs.repo) || '' }}",
|
|
125
|
+
];
|
|
126
|
+
return ['cache-from: type=gha,scope=${{ matrix.name }}', 'cache-to: type=gha,scope=${{ matrix.name }},mode=max'];
|
|
87
127
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
128
|
+
/** One build job of images.yml: `image` builds with public values and no environment, `image-sealed` opens the sealed build values of the release environment. */
|
|
129
|
+
function imageJob(id, plans, { project, nodeMajor, environment, platforms, cache }) {
|
|
130
|
+
const sealed = id === 'image-sealed';
|
|
131
|
+
const qemu = platforms.some(platform => platform !== 'linux/amd64');
|
|
132
|
+
const head = [
|
|
133
|
+
`${id}:`,
|
|
134
|
+
' timeout-minutes: 30',
|
|
135
|
+
' runs-on: ubuntu-latest',
|
|
136
|
+
...(sealed ? [' if: ${{ inputs.release }}', ` environment: ${environment}`] : []),
|
|
137
|
+
' permissions:', ' contents: read', ' packages: write',
|
|
138
|
+
' strategy:', ' fail-fast: false', ' matrix:', ' include:',
|
|
139
|
+
...matrixEntries(plans).map(line => ` ${line}`),
|
|
140
|
+
' steps:',
|
|
141
|
+
];
|
|
142
|
+
const steps = [
|
|
143
|
+
`- uses: ${CHECKOUT}`,
|
|
144
|
+
'- name: release tag matches package version',
|
|
145
|
+
' if: ${{ inputs.release }}',
|
|
146
|
+
' shell: bash',
|
|
147
|
+
' run: |',
|
|
148
|
+
' [[ "$GITHUB_REF" == refs/tags/v* ]] || exit 1',
|
|
149
|
+
` ${TAG_MATCHES_VERSION}`,
|
|
150
|
+
'- name: image repository',
|
|
151
|
+
' id: names',
|
|
152
|
+
' shell: bash',
|
|
153
|
+
' run: |',
|
|
154
|
+
' owner="${GITHUB_REPOSITORY_OWNER,,}"',
|
|
155
|
+
` echo "repo=ghcr.io/\${owner}/${project}-\${{ matrix.name }}" >> "$GITHUB_OUTPUT"`,
|
|
156
|
+
...(qemu ? [`- uses: ${SETUP_QEMU}`] : []),
|
|
157
|
+
`- uses: ${SETUP_BUILDX}`,
|
|
158
|
+
`- uses: ${DOCKER_LOGIN}`,
|
|
159
|
+
' if: ${{ inputs.push }}',
|
|
160
|
+
' with:', ' registry: ghcr.io', ' username: ${{ github.actor }}', ' password: ${{ github.token }}',
|
|
161
|
+
`- uses: ${SETUP_NODE}`,
|
|
162
|
+
' if: ${{ matrix.args }}',
|
|
163
|
+
' with:', ` node-version: ${nodeMajor}`, ' cache: npm',
|
|
164
|
+
'- run: npm ci --ignore-scripts',
|
|
165
|
+
' if: ${{ matrix.args }}',
|
|
166
|
+
...(sealed ? ['- name: install SOPS', ' if: ${{ matrix.args }}', ` uses: ${INSTALL_SOPS}`, ' with:', ' version: v3.13.2'] : []),
|
|
167
|
+
'- name: public build values',
|
|
168
|
+
' id: args',
|
|
169
|
+
' if: ${{ matrix.args }}',
|
|
170
|
+
' env:',
|
|
171
|
+
...(sealed ? [' SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}'] : []),
|
|
172
|
+
' IMAGE_APP: ${{ matrix.name }}',
|
|
173
|
+
` run: npx --no-install starci app stack build-args --env ${environment} --app "$IMAGE_APP"`,
|
|
174
|
+
`- uses: ${BUILD_PUSH}`,
|
|
93
175
|
' with:',
|
|
94
176
|
' context: .',
|
|
95
|
-
|
|
96
|
-
'
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
177
|
+
' file: ${{ matrix.file }}',
|
|
178
|
+
' build-args: ${{ steps.args.outputs.args }}',
|
|
179
|
+
` platforms: ${platforms.join(',')}`,
|
|
180
|
+
' push: ${{ inputs.push && matrix.deployed }}',
|
|
181
|
+
// Immutable and per commit; the release name v<version> is added below without a rebuild, so the digest the checks proved is the one that ships.
|
|
182
|
+
' tags: ${{ steps.names.outputs.repo }}:sha-${{ github.sha }}',
|
|
183
|
+
// The SBOM and the provenance are attached to the pushed digest (OCI attestations); a build that is not pushed is only a proof that the Dockerfile builds.
|
|
184
|
+
' sbom: true',
|
|
185
|
+
' provenance: mode=max',
|
|
186
|
+
...imageCacheLines(cache).map(line => ` ${line}`),
|
|
187
|
+
'- name: tag the release',
|
|
188
|
+
' if: ${{ inputs.release && inputs.push && matrix.deployed }}',
|
|
189
|
+
' shell: bash',
|
|
190
|
+
' env:',
|
|
191
|
+
' REPO: ${{ steps.names.outputs.repo }}',
|
|
192
|
+
' run: docker buildx imagetools create --tag "$REPO:$GITHUB_REF_NAME" "$REPO:sha-$GITHUB_SHA"',
|
|
102
193
|
];
|
|
194
|
+
return lines([...head, ...steps.map(step => ` ${step}`)], ' ');
|
|
195
|
+
}
|
|
196
|
+
/** The collect job: the digests this run pushed under sha-<sha>, as the JSON the deploy takes. The images of sealed build values exist only on a release run. */
|
|
197
|
+
function collectJob(plans, jobs, project) {
|
|
198
|
+
const waits = jobs.map(job => (job === 'image' ? "needs.image.result == 'success'" : "(needs.image-sealed.result == 'success' || needs.image-sealed.result == 'skipped')"));
|
|
199
|
+
const list = plans.filter(plan => plan.deployed).flatMap(plan => plan.services.map(service => `${service} ${plan.name} ${plan.sealed}`));
|
|
200
|
+
return lines([
|
|
201
|
+
'collect:',
|
|
202
|
+
` needs: [${jobs.join(', ')}]`,
|
|
203
|
+
` if: \${{ !cancelled() && inputs.push && ${waits.join(' && ')} }}`,
|
|
204
|
+
' timeout-minutes: 10',
|
|
205
|
+
' runs-on: ubuntu-latest',
|
|
206
|
+
' permissions:', ' contents: read', ' packages: read',
|
|
207
|
+
' outputs:', ' refs: ${{ steps.refs.outputs.refs }}',
|
|
208
|
+
' steps:',
|
|
209
|
+
` - uses: ${SETUP_BUILDX}`,
|
|
210
|
+
` - uses: ${DOCKER_LOGIN}`,
|
|
211
|
+
' with:', ' registry: ghcr.io', ' username: ${{ github.actor }}', ' password: ${{ github.token }}',
|
|
212
|
+
' - name: pushed digests',
|
|
213
|
+
' id: refs',
|
|
214
|
+
' shell: bash',
|
|
215
|
+
' env:', ' RELEASE: ${{ inputs.release }}',
|
|
216
|
+
' run: |',
|
|
217
|
+
' set -euo pipefail',
|
|
218
|
+
' owner="${GITHUB_REPOSITORY_OWNER,,}"',
|
|
219
|
+
" refs='{}'",
|
|
220
|
+
' while read -r service app sealed; do',
|
|
221
|
+
' if [[ "$sealed" == true && "$RELEASE" != true ]]; then continue; fi',
|
|
222
|
+
` repo="ghcr.io/\${owner}/${project}-\${app}"`,
|
|
223
|
+
" digest=$(docker buildx imagetools inspect \"$repo:sha-$GITHUB_SHA\" --format '{{json .Manifest}}' | jq -r .digest)",
|
|
224
|
+
' [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::no pushed digest for the image of ${service}"; exit 1; }',
|
|
225
|
+
" refs=$(jq -c --arg service \"$service\" --arg ref \"$repo@$digest\" '. + {($service): $ref}' <<<\"$refs\")",
|
|
226
|
+
" done <<'LIST'",
|
|
227
|
+
...list.map(entry => ` ${entry}`),
|
|
228
|
+
' LIST',
|
|
229
|
+
' echo "refs=$refs" >> "$GITHUB_OUTPUT"',
|
|
230
|
+
], ' ');
|
|
103
231
|
}
|
|
104
232
|
/**
|
|
105
|
-
* The text blocks
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
233
|
+
* The text blocks of images.yml, the reusable workflow ci.yml calls. imageJobs holds the build jobs (public values; sealed values bound to the
|
|
234
|
+
* release environment, release runs only) and the collect job when an image is deployed; imageWorkflowOutputs the `refs` output of the call.
|
|
235
|
+
*/
|
|
236
|
+
function imageWorkflowVariables(stack, project, apps, nodeMajor, environment) {
|
|
237
|
+
const plans = imagePlans(apps, stack);
|
|
238
|
+
const publicPlans = plans.filter(plan => !plan.sealed);
|
|
239
|
+
const sealedPlans = plans.filter(plan => plan.sealed);
|
|
240
|
+
const options = { project, nodeMajor, environment, platforms: platformsOf(stack.environments ?? []), cache: stack.imageCache ?? 'gha' };
|
|
241
|
+
const jobs = [...(publicPlans.length ? ['image'] : []), ...(sealedPlans.length ? ['image-sealed'] : [])];
|
|
242
|
+
const collect = plans.some(plan => plan.deployed) && jobs.length > 0;
|
|
243
|
+
return {
|
|
244
|
+
imageJobs: [
|
|
245
|
+
...(publicPlans.length ? [imageJob('image', publicPlans, options)] : []),
|
|
246
|
+
...(sealedPlans.length ? [imageJob('image-sealed', sealedPlans, options)] : []),
|
|
247
|
+
...(collect ? [collectJob(plans, jobs, project)] : []),
|
|
248
|
+
].join('\n\n'),
|
|
249
|
+
imageWorkflowOutputs: collect ? lines(['outputs:', ' refs:', ' description: JSON object, service -> repository@sha256:digest of the images this run pushed', ' value: ${{ jobs.collect.outputs.refs }}'], ' ') : '',
|
|
250
|
+
imagesCollected: collect ? 'true' : '',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** The deploy jobs of ci.yml: one call of the reusable stack-deploy.yml per environment with deploy.auto, in declared order, each after the one before it. */
|
|
254
|
+
function ciDeployVariables(stack, collected) {
|
|
255
|
+
const auto = (stack.environments ?? []).filter(environment => environment.auto);
|
|
256
|
+
const jobs = auto.map((environment, index) => {
|
|
257
|
+
const previous = index > 0 ? `, deploy-${auto[index - 1].name}` : '';
|
|
258
|
+
return lines([
|
|
259
|
+
`deploy-${environment.name}:`,
|
|
260
|
+
` needs: [plan, verdict${collected ? ', images' : ''}${previous}]`,
|
|
261
|
+
" if: ${{ needs.plan.outputs.release == 'true' }}",
|
|
262
|
+
' permissions:', ' contents: read', ' packages: read', ' actions: read',
|
|
263
|
+
' concurrency:', ` group: deploy-${environment.name}`, ' cancel-in-progress: false',
|
|
264
|
+
' uses: ./.github/workflows/stack-deploy.yml',
|
|
265
|
+
' with:',
|
|
266
|
+
` environment: ${environment.name}`,
|
|
267
|
+
' operation: deploy',
|
|
268
|
+
' auto: true',
|
|
269
|
+
...(collected ? [' images: ${{ needs.images.outputs.refs }}'] : []),
|
|
270
|
+
' ci_run: ${{ needs.plan.outputs.ci_run }}',
|
|
271
|
+
], ' ');
|
|
272
|
+
});
|
|
273
|
+
return { ciDeployJobs: jobs.length ? `\n${jobs.join('\n\n')}\n` : '' };
|
|
274
|
+
}
|
|
275
|
+
/** The release environment: the sole image environment, or production when several deploy images. */
|
|
276
|
+
function releaseEnvironment(environments) {
|
|
277
|
+
const names = Object.keys(environments);
|
|
278
|
+
return names.length > 1 ? 'production' : names[0] ?? 'production';
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The text blocks the managed workflows place at the end of a line or on a line of their own (empty when there is nothing to place):
|
|
282
|
+
* stackGuard the first step of the deploy job when the app declares no image (it fails with the reason)
|
|
283
|
+
* stackImageList the `<service> <app>` lines the image step of stack-deploy.yml reads (the registry holds sha-<sha> of each)
|
|
284
|
+
* stackImageEnv the env entries of the deploy step: IMAGE_<SERVICE> = repository@digest, from the image step
|
|
111
285
|
* stackImageArgs the `--image "<service>=$IMAGE_<SERVICE>" \` continuation lines of the deploy command
|
|
112
286
|
* stackPassEnv the env entries of the deploy step for the declared host variables (vars.<NAME>)
|
|
113
|
-
*
|
|
287
|
+
* imageJobs, imageWorkflowOutputs the build and collect jobs and the output of images.yml
|
|
288
|
+
* ciDeployJobs the deploy jobs of ci.yml (one per environment with deploy.auto)
|
|
289
|
+
* `apps` are the apps images.yml builds, each with its Dockerfile (the sync module knows the layout).
|
|
114
290
|
*/
|
|
115
|
-
export function stackWorkflowVariables({ images = [], passEnv = [], imageEnvironments: environments = {} }, project,
|
|
291
|
+
export function stackWorkflowVariables({ images = [], passEnv = [], imageEnvironments: environments = {}, environments: deploying = [], imageCache = 'gha' }, project, apps = [], nodeMajor = '22') {
|
|
116
292
|
const guard = images.length ? '' : block([
|
|
117
293
|
'- name: require a declared image',
|
|
118
294
|
' shell: bash',
|
|
119
295
|
` run: 'echo "::error::${NO_IMAGES_MESSAGE}"; exit 1'`,
|
|
120
296
|
]);
|
|
121
|
-
const
|
|
297
|
+
const stack = { images, passEnv, imageEnvironments: environments, environments: deploying, imageCache };
|
|
298
|
+
const image = imageWorkflowVariables(stack, project, apps, nodeMajor, releaseEnvironment(environments));
|
|
122
299
|
return {
|
|
123
|
-
|
|
300
|
+
imageEnvironment: releaseEnvironment(environments),
|
|
301
|
+
...image,
|
|
302
|
+
...ciDeployVariables(stack, image.imagesCollected === 'true'),
|
|
124
303
|
stackGuard: guard,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
stackImageArgs: block(images.map(image => `--image "${image.service}=$${envName(image.service)}" ${CONTINUE}`), ' '.repeat(12)),
|
|
304
|
+
stackImageList: images.map(entry => `${entry.service} ${entry.app}`).join('\n '),
|
|
305
|
+
stackImageEnv: block(images.map(entry => `${envName(entry.service)}: \${{ steps.images.outputs.${entry.service} }}`), ' '.repeat(10)),
|
|
306
|
+
stackImageArgs: block(images.map(entry => `--image "${entry.service}=$${envName(entry.service)}" ${CONTINUE}`), ' '.repeat(12)),
|
|
129
307
|
stackPassEnv: block(passEnv.map(name => `${name}: \${{ vars.${name} }}`), ' '.repeat(10)),
|
|
130
308
|
};
|
|
131
309
|
}
|
|
132
|
-
/** The matrix uses the deploy verb only for apps the selected environment deploys; undeployed images retain Dockerfile defaults. */
|
|
133
|
-
function releaseImageVariables(environments) {
|
|
134
|
-
const names = Object.keys(environments);
|
|
135
|
-
const selectedApp = names.map(name => `(env.STACK_ENVIRONMENT == '${name}' && contains(fromJSON('${JSON.stringify(environments[name])}'), matrix.name))`).join(' || ') || 'false';
|
|
136
|
-
const multiple = names.length > 1;
|
|
137
|
-
const environment = multiple ? 'production' : names[0] ?? 'production';
|
|
138
|
-
return {
|
|
139
|
-
imageEnvironment: environment,
|
|
140
|
-
imageJobEnvironment: names.length ? `\n environment: ${environment}` : '',
|
|
141
|
-
imageEnvironmentGuard: multiple ? block([
|
|
142
|
-
'- name: require a declared image environment',
|
|
143
|
-
` if: \${{ !contains(fromJSON('${JSON.stringify(names)}'), env.STACK_ENVIRONMENT) }}`,
|
|
144
|
-
' run: \'echo "::error::STACKS_DEPLOY_ENV_UNKNOWN: selected environment declares no deploy images"; exit 1\'',
|
|
145
|
-
]) : '',
|
|
146
|
-
imageBuildArgsIf: `\${{ ${selectedApp} }}`,
|
|
147
|
-
};
|
|
148
|
-
}
|