@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +20 -8
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +33 -0
  107. package/stack/ci-key.d.mts +11 -0
  108. package/stack/ci-key.mjs +44 -0
  109. package/stack/cli.d.mts +7 -1
  110. package/stack/cli.mjs +22 -13
  111. package/stack/config.d.mts +15 -0
  112. package/stack/converge.d.mts +2 -0
  113. package/stack/converge.mjs +1 -1
  114. package/stack/db-phases.d.mts +185 -0
  115. package/stack/db-phases.mjs +346 -0
  116. package/stack/deploy.d.mts +22 -5
  117. package/stack/deploy.mjs +242 -72
  118. package/stack/journal.d.mts +146 -0
  119. package/stack/journal.mjs +191 -0
  120. package/stack/manager-backup.d.mts +54 -0
  121. package/stack/manager-backup.mjs +136 -0
  122. package/stack/notify.d.mts +9 -0
  123. package/stack/notify.mjs +32 -0
  124. package/stack/release-store.d.mts +49 -1
  125. package/stack/release-store.mjs +102 -16
  126. package/stack/remote.d.mts +2 -0
  127. package/stack/remote.mjs +32 -11
  128. package/stack/rollback-guard.d.mts +10 -0
  129. package/stack/rollback-guard.mjs +41 -0
  130. package/stack/server-key.d.mts +2 -0
  131. package/stack/server-key.mjs +1 -1
  132. package/stack/ssh-gate.d.mts +35 -0
  133. package/stack/ssh-gate.mjs +145 -0
  134. package/stack/status.d.mts +51 -0
  135. package/stack/status.mjs +115 -0
  136. package/stack/urgent-floor.d.mts +10 -61
  137. package/stack/urgent-floor.mjs +28 -123
  138. package/stack/verify.mjs +26 -0
  139. package/sync/ci-commands.mjs +10 -5
  140. package/sync/format-worker.d.mts +1 -0
  141. package/sync/format-worker.mjs +17 -0
  142. package/sync/format.d.mts +21 -3
  143. package/sync/format.mjs +99 -7
  144. package/sync/index.d.mts +3 -6
  145. package/sync/index.mjs +12 -12
  146. package/sync/stack-images.d.mts +25 -10
  147. package/sync/stack-images.mjs +215 -54
  148. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  149. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  150. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  151. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  152. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  153. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  154. package/templates/app/gitignore +1 -0
  155. package/templates/app/gitignore-lite +1 -0
  156. package/templates/app/package-scripts/db.json +1 -1
  157. package/templates/app/skeleton/.gitattributes +2 -0
  158. package/templates/app/skeleton-lite/.gitattributes +2 -0
  159. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  160. 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. It is slow and is not part of `--fast`.
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 over the tracked `files` of `repoRoot`: one per file prettier would change, or cannot parse.
25
- * `prettier` is a seam for specs; the default is the repository's own.
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 formatFindings({ repoRoot, files, prettier = loadPrettier(repoRoot) }) {
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.filter((f) => !LOCKFILES.has(path.posix.basename(f)))) {
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: fs.existsSync(ignorePath) ? ignorePath : undefined, resolveConfig: true });
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: false })) ?? {};
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
- * The build matrix of the images workflow: one entry per declared app (be then fe, then a lite app's db image), each with the image name and its Dockerfile. The workflow
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
- * The build matrix of the images workflow: one entry per declared app (be then fe, then a lite app's db image), each with the image name and its Dockerfile. The workflow
340
- * runs on the release tag, so every image is built there; no path filter narrows it. A YAML block mapping per entry, indented for
341
- * `strategy.matrix.include`.
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
- imageMatrix: imageMatrix(app, source),
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';
@@ -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 and whether it carries build arguments. */
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
- /** What the managed workflows render from the declaration. */
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 stack-deploy.yml template places at the end of a line (each already indented for its position, every line led by
26
- * a newline; empty when there is nothing to place):
27
- * stackGuard the first step of the release job when the app declares no image (it fails with the reason)
28
- * stackNames the `names` step lines assigning each image repository (ghcr.io/<lowercase owner>/<project>-<app>)
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
- * `dockerfileOf(app)` names the Dockerfile of an app (the sync module knows the layout).
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, dockerfileOf: (app: string) => string): Record<string, 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 {};
@@ -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
- return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration) };
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
- function buildArgsStep(image) {
80
- return [
81
- `- name: public build values of ${image.service}`,
82
- ` id: args-${image.service}`,
83
- ' env:',
84
- ' SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}',
85
- ` run: npx --no-install starci app stack build-args --env "$STACK_ENVIRONMENT" --app ${image.app}`,
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
- function buildPushStep(image, dockerfile) {
89
- return [
90
- `- name: build and push ${image.service}`,
91
- ` id: build-${image.service}`,
92
- ' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2',
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
- ` file: ${dockerfile}`,
96
- ' push: true',
97
- ' provenance: false',
98
- ` tags: \${{ steps.names.outputs.${image.service} }}:\${{ github.sha }}`,
99
- ...(image.buildArgs ? [` build-args: \${{ steps.args-${image.service}.outputs.args }}`] : []),
100
- ` cache-from: type=gha,scope=${image.service}`,
101
- ` cache-to: type=gha,scope=${image.service},mode=max`,
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 the stack-deploy.yml template places at the end of a line (each already indented for its position, every line led by
106
- * a newline; empty when there is nothing to place):
107
- * stackGuard the first step of the release job when the app declares no image (it fails with the reason)
108
- * stackNames the `names` step lines assigning each image repository (ghcr.io/<lowercase owner>/<project>-<app>)
109
- * stackImageSteps per image: the optional build-args step and the build-and-push step
110
- * stackImageEnv the env entries of the deploy step: IMAGE_<SERVICE> = repository@digest
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
- * `dockerfileOf(app)` names the Dockerfile of an app (the sync module knows the layout).
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, dockerfileOf) {
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 names = images.map(image => `echo "${image.service}=ghcr.io/\${owner}/${project}-${image.app}" >> "$GITHUB_OUTPUT"`);
297
+ const stack = { images, passEnv, imageEnvironments: environments, environments: deploying, imageCache };
298
+ const image = imageWorkflowVariables(stack, project, apps, nodeMajor, releaseEnvironment(environments));
122
299
  return {
123
- ...releaseImageVariables(environments),
300
+ imageEnvironment: releaseEnvironment(environments),
301
+ ...image,
302
+ ...ciDeployVariables(stack, image.imagesCollected === 'true'),
124
303
  stackGuard: guard,
125
- stackNames: block(names, ' '.repeat(10)),
126
- stackImageSteps: images.map(image => block([...(image.buildArgs ? buildArgsStep(image) : []), ...buildPushStep(image, dockerfileOf(image.app))])).join(''),
127
- stackImageEnv: block(images.map(image => `${envName(image.service)}: \${{ steps.names.outputs.${image.service} }}@\${{ steps.build-${image.service}.outputs.digest }}`), ' '.repeat(10)),
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
- }