@starci/hfs 4.12.2 → 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.
- package/CHANGELOG.md +35 -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 +74 -7
- package/runtime/knowledge/hfs/slots.yaml +19 -7
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +26 -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 +6 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -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 +31 -0
- package/stack/cli.d.mts +3 -1
- package/stack/cli.mjs +17 -11
- package/stack/config.d.mts +6 -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 +18 -4
- package/stack/deploy.mjs +235 -70
- package/stack/journal.d.mts +127 -0
- package/stack/journal.mjs +182 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/release-store.d.mts +44 -0
- package/stack/release-store.mjs +89 -6
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +31 -11
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/status.d.mts +49 -0
- package/stack/status.mjs +114 -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/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 {
|
package/sync/ci-commands.mjs
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
32
|
-
*
|
|
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
|
|
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.
|
|
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 {};
|