@starci/hfs 4.12.0 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +43 -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 +19 -7
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +26 -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 +6 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -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 +31 -0
  107. package/stack/cli.d.mts +3 -1
  108. package/stack/cli.mjs +17 -11
  109. package/stack/config.d.mts +6 -0
  110. package/stack/converge.d.mts +2 -0
  111. package/stack/converge.mjs +1 -1
  112. package/stack/db-phases.d.mts +185 -0
  113. package/stack/db-phases.mjs +346 -0
  114. package/stack/deploy.d.mts +18 -4
  115. package/stack/deploy.mjs +235 -70
  116. package/stack/journal.d.mts +127 -0
  117. package/stack/journal.mjs +182 -0
  118. package/stack/manager-backup.d.mts +54 -0
  119. package/stack/manager-backup.mjs +136 -0
  120. package/stack/release-store.d.mts +44 -0
  121. package/stack/release-store.mjs +89 -6
  122. package/stack/remote.d.mts +2 -0
  123. package/stack/remote.mjs +31 -11
  124. package/stack/server-key.d.mts +2 -0
  125. package/stack/server-key.mjs +1 -1
  126. package/stack/status.d.mts +49 -0
  127. package/stack/status.mjs +114 -0
  128. package/stack/urgent-floor.d.mts +10 -61
  129. package/stack/urgent-floor.mjs +28 -123
  130. package/stack/verify.mjs +26 -0
  131. package/sync/ci-commands.mjs +10 -5
  132. package/sync/format-worker.d.mts +1 -0
  133. package/sync/format-worker.mjs +17 -0
  134. package/sync/format.d.mts +21 -3
  135. package/sync/format.mjs +99 -7
  136. package/sync/index.d.mts +3 -6
  137. package/sync/index.mjs +12 -12
  138. package/sync/stack-images.d.mts +25 -10
  139. package/sync/stack-images.mjs +215 -54
  140. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  141. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  142. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  143. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  144. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  145. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  146. package/templates/app/gitignore +1 -0
  147. package/templates/app/gitignore-lite +1 -0
  148. package/templates/app/package-scripts/db.json +1 -1
  149. package/templates/app/skeleton/.gitattributes +2 -0
  150. package/templates/app/skeleton-lite/.gitattributes +2 -0
  151. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  152. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
package/stack/verify.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  // verify.mjs - functional verification after convergence: the declared HTTP journeys (config.verify.http) and the declared checks inside a
2
2
  // running task (config.verify.exec). Probes are bounded and read-only; a POST body comes only from the declared config and nothing here
3
3
  // mutates business data. HTTP goes through ctx.fetch, docker through ctx.spawn, waits through ctx.sleep and time through ctx.now.
4
+ import fs from 'node:fs';
4
5
  import { isDeepStrictEqual } from 'node:util';
5
6
  import { eachInOrder, repeatInOrder } from '../runtime/scripts/lib/in-order.mjs';
6
7
  import { lines as splitLines, tool } from './context.mjs';
7
8
  import { edgeAttachmentsOf, verifyEdgeAttachment } from './edge-alias.mjs';
9
+ import { compareWithRelease, digestOf, observedServices, stackImages } from './status.mjs';
8
10
  const RETRY_MS = 6000;
9
11
  const HTTP_TIMEOUT_MS = 8000;
10
12
  function expect(condition, message) {
@@ -87,6 +89,28 @@ async function verifyEdge(ctx, config, results, file) {
87
89
  }
88
90
  await eachInOrder(attachments, (item) => runCheck(ctx, results, `edge aliases of ${item.service} on ${item.network} are exactly the declared ones`, () => verifyEdgeAttachment(ctx, config, item)));
89
91
  }
92
+ /**
93
+ * Drift: when the stack file pins its images by digest (a rendered release), every service it names runs exactly that digest and none was rolled back by
94
+ * Swarm, so a release that Swarm quietly reverted never reads as verified. A stack file without pinned images (the source) has nothing to compare.
95
+ */
96
+ async function verifyDrift(ctx, config, results, file) {
97
+ let stackYaml;
98
+ try {
99
+ stackYaml = fs.readFileSync(file, 'utf8');
100
+ if (!Object.values(stackImages(stackYaml)).some((image) => digestOf(image) !== null))
101
+ return;
102
+ }
103
+ catch (error) {
104
+ results.push({ name: 'drift: the stack file is readable', ok: false, detail: String(error.message) });
105
+ return;
106
+ }
107
+ await runCheck(ctx, results, 'drift: the services run the digests of the stack file', () => {
108
+ const live = observedServices(ctx, config);
109
+ const drift = compareWithRelease({ stackYaml, release: {} }, live).filter((entry) => entry.kind !== 'not in the release');
110
+ expect(drift.length === 0, drift.map((entry) => `${entry.service}: ${entry.kind}`).join('; '));
111
+ return `${live.length} service(s)`;
112
+ });
113
+ }
90
114
  const verifyExec = (ctx, config, results) => eachInOrder([...new Set(config.verify.exec.map((check) => check.service))], (service) => verifyService(ctx, config, results, service));
91
115
  /**
92
116
  * Runs the declared verification. Returns { ok, lines, data: { checks: [{ name, ok }] } }; the lines read `PASS|FAIL name detail`.
@@ -99,6 +123,8 @@ export async function verifyStack(ctx, config, { retrySeconds = 180, skipPublic
99
123
  await eachInOrder(config.verify.http, (check) => runCheck(ctx, results, check.name, () => probeHttp(ctx, check), { retry: check.retry, retrySeconds }));
100
124
  if (!skipInternal && stackFile)
101
125
  await verifyEdge(ctx, config, results, stackFile);
126
+ if (!skipInternal && stackFile)
127
+ await verifyDrift(ctx, config, results, stackFile);
102
128
  if (!skipInternal)
103
129
  await verifyExec(ctx, config, results);
104
130
  return {
@@ -9,8 +9,10 @@ const exampleCommand = (source, command) => source?.runtimeExample
9
9
  : command;
10
10
  const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
11
11
  const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
12
- const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}";
13
- /** Codecov uploads on release tags with its required authentication; a missing report, an unavailable endpoint or a rejected upload fails the job. */
12
+ // Sonar and the Codecov upload run on every full run (a release tag or a candidate), so a release is analysed before it ships; the plan job decides.
13
+ const FULL_RUN = "needs.plan.outputs.full == 'true'";
14
+ const SONAR_CONDITION = `\${{ !cancelled() && ${FULL_RUN} }}`;
15
+ /** Codecov uploads on every full run with its required authentication; a missing report, an unavailable endpoint or a rejected upload fails the job. */
14
16
  function codecovSteps(lcovReport, codecov) {
15
17
  if (codecov.enabled === false || codecov.wiring === 'not-used')
16
18
  return [
@@ -20,7 +22,7 @@ function codecovSteps(lcovReport, codecov) {
20
22
  return [
21
23
  ' - name: coverage upload',
22
24
  ' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
23
- " if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}",
25
+ ` if: ${SONAR_CONDITION}`,
24
26
  ' with:',
25
27
  codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
26
28
  ` files: ${lcovReport}`,
@@ -28,7 +30,7 @@ function codecovSteps(lcovReport, codecov) {
28
30
  ' fail_ci_if_error: true',
29
31
  ].join('\n');
30
32
  }
31
- /** Sonar is required: on release tags it consumes the test coverage and lint artifacts, and a missing token fails the job with a clear refusal. */
33
+ /** Sonar is required: on every full run it consumes the test coverage and lint artifacts, and a missing token fails the job with a clear refusal. */
32
34
  function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
33
35
  if (!sonar)
34
36
  return '';
@@ -36,7 +38,8 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
36
38
  const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
37
39
  const header = [
38
40
  ' sonar:',
39
- ' needs: [lint, coverage]',
41
+ ' needs: [plan, lint, coverage]',
42
+ ` if: \${{ ${FULL_RUN} }}`,
40
43
  ` timeout-minutes: ${timeout}`,
41
44
  ' runs-on: ubuntu-latest',
42
45
  ' env:',
@@ -219,6 +222,8 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
219
222
  ` NODE_OPTIONS: ${settings.nodeOptions}`,
220
223
  ]).join('\n'),
221
224
  codecovSteps: codecovSteps(lcovReport, codecov),
225
+ verdictSonarNeed: sonar ? ', sonar' : '',
226
+ verdictSonarCheck: sonar ? `\n if [[ "$FULL" == true && "$CHECKS" == true ]]; then expect sonar success; else expect sonar idle; fi` : '',
222
227
  sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
223
228
  lcovArtifact,
224
229
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ // format-worker.mts - a worker thread of sync/format.mts: loads the repository's own prettier once and checks the batches it is sent.
2
+ import { parentPort } from 'node:worker_threads';
3
+ import { checkBatch, loadPrettier } from './format.mjs';
4
+ let loaded;
5
+ parentPort?.on('message', (message) => {
6
+ void (async () => {
7
+ try {
8
+ if (loaded?.repoRoot !== message.repoRoot)
9
+ loaded = { repoRoot: message.repoRoot, prettier: loadPrettier(message.repoRoot) };
10
+ const findings = await checkBatch({ repoRoot: message.repoRoot, files: message.files, prettier: loaded.prettier });
11
+ parentPort?.postMessage({ findings, count: message.files.length });
12
+ }
13
+ catch (error) {
14
+ parentPort?.postMessage({ error: String(error.message ?? error), code: error.code });
15
+ }
16
+ })();
17
+ });
package/sync/format.d.mts CHANGED
@@ -27,12 +27,30 @@ export type FormatFinding = {
27
27
  };
28
28
  /** The repository's own prettier, or a SyncError when it is not installed. */
29
29
  export declare function loadPrettier(repoRoot: string): Prettier;
30
+ /** Files per batch: one message to a worker, one progress step. */
31
+ export declare const FORMAT_BATCH = 64;
32
+ /** The formattable files of `files`: lockfiles are written by npm and never formatted. */
33
+ export declare const formattable: (files: readonly string[]) => string[];
30
34
  /**
31
- * The HFS_FORMAT findings over the tracked `files` of `repoRoot`: one per file prettier would change, or cannot parse.
32
- * `prettier` is a seam for specs; the default is the repository's own.
35
+ * 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
36
+ * resolved with prettier's own cache, so a batch costs one lookup per directory.
33
37
  */
34
- export declare function formatFindings({ repoRoot, files, prettier }: {
38
+ export declare function checkBatch({ repoRoot, files, prettier }: {
39
+ repoRoot: string;
40
+ files: readonly string[];
41
+ prettier: Prettier;
42
+ }): Promise<FormatFinding[]>;
43
+ /** Cuts `files` into batches of `size`. */
44
+ export declare function batches<T>(files: readonly T[], size?: number): T[][];
45
+ /** 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. */
46
+ export declare function workerCount(count: number, cores?: number): number;
47
+ /**
48
+ * The HFS_FORMAT findings over the tracked `files` of `repoRoot`: one per file prettier would change, or cannot parse, sorted by path.
49
+ * `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.
50
+ */
51
+ export declare function formatFindings({ repoRoot, files, prettier, onProgress }: {
35
52
  repoRoot: string;
36
53
  files: readonly string[];
37
54
  prettier?: Prettier | undefined;
55
+ onProgress?: ((done: number) => void) | undefined;
38
56
  }): Promise<FormatFinding[]>;
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 {};