@starci/hfs 4.5.0 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -0
- package/package.json +1 -1
- package/release/gate.mjs +50 -19
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/modules/schemas/application-stacks.schema.yaml +3 -0
- package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
- package/sync/ci-commands.mjs +109 -7
- package/sync/index.mjs +2 -1
- package/templates/app/ci-workflows/github/workflows/ci.yml +7 -1
- package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
- package/templates/app/ci-workflows/github/workflows/images.yml +1 -0
- package/templates/app/ci-workflows/install-steps.yml +2 -0
- package/templates/app/ci-workflows/jest-cache.yml +8 -0
- package/templates/app/ci-workflows/lint-base.yml +16 -0
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +8 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +1 -0
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +1 -0
- package/templates/app/gitignore +2 -0
- package/templates/app/gitignore-lite +2 -0
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +1 -0
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.5.1 - 2026-10-18
|
|
4
|
+
- Fixed: managed CI base refs, public build values, caching and timeouts; optional quality service handling, README formatting and Supabase migration-history parsing.
|
|
5
|
+
|
|
3
6
|
## 4.5.0 - 2026-10-17
|
|
4
7
|
- Added: consumer-class CI declarations, `app ci` custody verbs and R296-R298; manifest-driven quality files and split cached CI with immutable action pins.
|
|
5
8
|
- Added: release audit/local Sonar and main-CI protection; encrypted outside-repository DB dump and armed linked reset with backup/auth/storage validation.
|
package/package.json
CHANGED
package/release/gate.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and
|
|
1
|
+
// gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and when all required gates pass it
|
|
2
2
|
// writes the L4 record <git-common-dir>/starci-release/<HEAD sha>.l4.json (head, tag, per-gate log path and sha256) and creates the annotated tag.
|
|
3
3
|
// The canon pre-push hook lets main and v* tags through only for a HEAD with that tag and that record. Nothing is pushed here (see push.mjs).
|
|
4
4
|
// The gate list comes from the optional `release:` block of .starcistacks/application-stacks.yaml, else from the app's managed package scripts.
|
|
@@ -13,6 +13,7 @@ import { failure, refusal } from '../stack/errors.mjs';
|
|
|
13
13
|
import { latestDefaultBranchCi, requireGreenCi } from './ci-red.mjs';
|
|
14
14
|
|
|
15
15
|
export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
|
|
16
|
+
const SONAR_UNREACHABLE = 'SONAR_UNREACHABLE';
|
|
16
17
|
|
|
17
18
|
const FIELDS = new Set(['gates', 'tagPrefix', 'defaultBranch', 'tagMatchesVersion']);
|
|
18
19
|
const GATE_FIELDS = new Set(['name', 'command', 'summary', 'env']);
|
|
@@ -63,14 +64,15 @@ function auditGate(audit) {
|
|
|
63
64
|
|
|
64
65
|
/** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
|
|
65
66
|
function sonarGate(services, env) {
|
|
66
|
-
const
|
|
67
|
+
const configured = services?.sonar;
|
|
68
|
+
const sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: 'https://sonarcloud.io' } : configured;
|
|
67
69
|
const tokenName = sonar?.token;
|
|
68
70
|
if (!sonar?.host || !ENV_KEY.test(tokenName) || !env[tokenName]) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and set its ci.token environment variable');
|
|
69
71
|
const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
|
|
70
72
|
const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
|
|
71
73
|
if (organization) command.push(`-Dsonar.organization=${organization}`);
|
|
72
74
|
if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
|
|
73
|
-
return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] } };
|
|
75
|
+
return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar };
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
function gateProblems(entry, at, seen) {
|
|
@@ -209,6 +211,32 @@ export function launch(argv, platform) {
|
|
|
209
211
|
return [argv[0], argv.slice(1)];
|
|
210
212
|
}
|
|
211
213
|
|
|
214
|
+
/** Sonar availability is public and bounded; HTTP uses the existing injected fetch seam. */
|
|
215
|
+
async function sonarAvailability(ctx, gate) {
|
|
216
|
+
const sonar = gate.provider;
|
|
217
|
+
if (!sonar) return null;
|
|
218
|
+
let up = false;
|
|
219
|
+
try {
|
|
220
|
+
const response = await ctx.fetch(sonar.host.replace(/\/$/u, '') + '/api/system/status', { signal: AbortSignal.timeout(10000) });
|
|
221
|
+
up = response.ok && (await response.json()).status === 'UP';
|
|
222
|
+
} catch { up = false; }
|
|
223
|
+
if (up) return null;
|
|
224
|
+
const detail = sonar.optional
|
|
225
|
+
? `notice: sonar skipped: ${sonar.host} not reachable (services.sonar.optional)`
|
|
226
|
+
: `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds`;
|
|
227
|
+
return { skipped: sonar.optional === true, pass: false, detail };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Unreachable optional providers remain explicit skipped rows, with a real log and no scan invocation. */
|
|
231
|
+
async function runAvailableGate(ctx, gate, options) {
|
|
232
|
+
const availability = await sonarAvailability(ctx, gate);
|
|
233
|
+
if (!availability) return runGate(ctx, gate, options);
|
|
234
|
+
const log = path.join(options.dir, `${gate.name}.log`);
|
|
235
|
+
fs.writeFileSync(log, availability.detail + '\n');
|
|
236
|
+
return { name: gate.name, command: gate.command.join(' '), ...availability, seconds: 0,
|
|
237
|
+
summary: null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env) };
|
|
238
|
+
}
|
|
239
|
+
|
|
212
240
|
function runGate(ctx, gate, { dir, platform }) {
|
|
213
241
|
const log = path.join(dir, `${gate.name}.log`);
|
|
214
242
|
const started = ctx.now();
|
|
@@ -270,7 +298,7 @@ function inspect(ctx, config, options) {
|
|
|
270
298
|
}
|
|
271
299
|
|
|
272
300
|
function planLines(config, facts, dir, recordPath) {
|
|
273
|
-
const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (
|
|
301
|
+
const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (required gates must pass, optional provider outages are skipped, in this order):'];
|
|
274
302
|
for (const gate of config.gates) {
|
|
275
303
|
const summary = gate.summary ? ` (summary: ${gate.summary})` : '';
|
|
276
304
|
out.push(` ${gate.name.padEnd(17)} ${gate.command.join(' ')}${summary}`);
|
|
@@ -289,28 +317,31 @@ function assertUnmoved(ctx, head) {
|
|
|
289
317
|
function writeRecord(ctx, { head, tag, results, recordPath }) {
|
|
290
318
|
const record = {
|
|
291
319
|
schema: 'starci-app-l4-record/1', head, tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release gate',
|
|
292
|
-
note: '
|
|
320
|
+
note: 'Required gates passed on a clean HEAD; optional provider skips are explicit and every entry links its log and sha256.',
|
|
293
321
|
gates: results,
|
|
294
322
|
};
|
|
295
323
|
fs.mkdirSync(path.dirname(recordPath), { recursive: true });
|
|
296
324
|
fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
297
325
|
}
|
|
298
326
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
results.
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
return results;
|
|
327
|
+
/** Gates run sequentially because each failure prevents subsequent commands and record creation. */
|
|
328
|
+
async function runAll(ctx, config, options) {
|
|
329
|
+
fs.mkdirSync(options.dir, { recursive: true });
|
|
330
|
+
return config.gates.reduce(async (pending, gate) => {
|
|
331
|
+
const results = await pending;
|
|
332
|
+
if (results.some((result) => !result.pass && !result.skipped)) return results;
|
|
333
|
+
return [...results, await runAvailableGate(ctx, gate, options)];
|
|
334
|
+
}, Promise.resolve([]));
|
|
308
335
|
}
|
|
309
336
|
|
|
310
|
-
|
|
337
|
+
function summaryOf(result) {
|
|
338
|
+
let status = result.pass ? 'PASS' : 'FAIL';
|
|
339
|
+
if (result.skipped) status = 'SKIPPED';
|
|
340
|
+
return `${status} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
|
|
341
|
+
}
|
|
311
342
|
|
|
312
343
|
/**
|
|
313
|
-
* `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if
|
|
344
|
+
* `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if required gates pass,
|
|
314
345
|
* writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
|
|
315
346
|
*/
|
|
316
347
|
export async function releaseGate(ctx, config, options = {}) {
|
|
@@ -325,14 +356,14 @@ export async function releaseGate(ctx, config, options = {}) {
|
|
|
325
356
|
const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name), ci: { state: ci.state, notices: ciLines } };
|
|
326
357
|
if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
|
|
327
358
|
if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
|
|
328
|
-
const results = runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
|
|
359
|
+
const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
|
|
329
360
|
const report = results.map(summaryOf);
|
|
330
|
-
const failed = results.filter((result) => !result.pass);
|
|
361
|
+
const failed = results.filter((result) => !result.pass && !result.skipped);
|
|
331
362
|
if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
|
|
332
363
|
assertUnmoved(ctx, facts.head);
|
|
333
364
|
writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath });
|
|
334
365
|
const created = facts.chosen.state !== 'on-head';
|
|
335
|
-
if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: gates
|
|
366
|
+
if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: required gates passed on ${facts.head.slice(0, 12)} (L4 record ${path.basename(recordPath)})`, facts.head]);
|
|
336
367
|
const tagged = created ? `annotated tag ${facts.chosen.tag} created on ${facts.head.slice(0, 10)}` : `annotated tag ${facts.chosen.tag} already on ${facts.head.slice(0, 10)}`;
|
|
337
368
|
return { ok: true, lines: [...plan, ...report, `L4 record written: ${recordPath}`, tagged], data: { ...base, executed: true, results, tagCreated: created } };
|
|
338
369
|
}
|
package/release/push.mjs
CHANGED
|
@@ -14,7 +14,7 @@ function readRecord(file) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const greenRecord = (record, head, tag) => record?.head === head && record?.tag === tag
|
|
17
|
-
&& Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true);
|
|
17
|
+
&& Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true || (gate?.name === 'sonar' && gate?.skipped === true));
|
|
18
18
|
|
|
19
19
|
/** The release gate as the hook reads it: the annotated tag on HEAD whose L4 record names head and tag. Returns { tag } or { reason }. */
|
|
20
20
|
function gateState(ctx, head, prefix) {
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.3
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.3
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -62,7 +62,7 @@ pins:
|
|
|
62
62
|
side: both
|
|
63
63
|
source: packages/prettier-config/package.json
|
|
64
64
|
'@starci/jest-preset':
|
|
65
|
-
version: 2.3.
|
|
65
|
+
version: 2.3.1
|
|
66
66
|
group: starci
|
|
67
67
|
install: registry
|
|
68
68
|
side: be
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.5.
|
|
79
|
+
version: 1.5.1
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.5.
|
|
86
|
+
version: 4.5.1
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -149,6 +149,9 @@ $defs:
|
|
|
149
149
|
target: {type: number, minimum: 0, maximum: 100}
|
|
150
150
|
components: {enum: [auto]}
|
|
151
151
|
required: {type: boolean}
|
|
152
|
+
optional:
|
|
153
|
+
description: Skip an unreachable quality provider with a notice; SonarQube defaults true, SonarCloud and Codecov false.
|
|
154
|
+
type: boolean
|
|
152
155
|
ecosystems: {type: array, items: {enum: [npm, docker, github-actions]}}
|
|
153
156
|
schedule:
|
|
154
157
|
type: object
|
|
@@ -33,13 +33,13 @@ export function qualityServices(doc, { example = false } = {}) {
|
|
|
33
33
|
const dependabot = services.dependabot ?? {}, audit = services.audit ?? {};
|
|
34
34
|
return {
|
|
35
35
|
sonar: !sonar || sonar.mode === 'disabled' ? null : {
|
|
36
|
-
provider: sonar.provider, mode: sonar.mode, host: sonar.host?.public || sonar.host?.local || null,
|
|
36
|
+
provider: sonar.provider, mode: sonar.mode, optional: sonar.optional ?? sonar.provider === 'sonarqube', host: sonar.host?.public || sonar.host?.local || null,
|
|
37
37
|
organization: sonar.organization ?? null,
|
|
38
38
|
organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null), projectKey: sonar.projects?.[0]?.key ?? null,
|
|
39
39
|
qualityGate: sonar.qualityGate ?? (example ? 'starci-quality-lite' : 'starci-quality'), wiring: sonar.ci?.wiring ?? 'required',
|
|
40
40
|
token: sonar.ci?.token || nameOf(sonar.ci?.secrets?.[0]) || 'SONAR_TOKEN', hostVar: sonar.ci?.hostVar || 'SONAR_HOST_URL',
|
|
41
41
|
},
|
|
42
|
-
codecov: { enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
|
|
42
|
+
codecov: { optional: codecov.optional ?? false, enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
|
|
43
43
|
flag: codecov.flag ?? null, target: codecov.target ?? 100, components: codecov.components ?? 'auto', required: codecov.required ?? true, wiring: codecov.ci?.wiring ?? 'required' },
|
|
44
44
|
dependabot: { ecosystems: [...(dependabot.ecosystems ?? ['npm', 'github-actions', 'docker'])],
|
|
45
45
|
schedule: { interval: 'weekly', ...dependabot.schedule }, groups: { minorPatch: 'grouped', major: 'per-package', ...dependabot.groups },
|
package/sync/ci-commands.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
3
|
+
import { refusal } from '../stack/errors.mjs';
|
|
4
|
+
import { byCodeUnit } from '../report/order.mjs';
|
|
2
5
|
|
|
3
6
|
// One command vocabulary for managed CI rendering and the runtime coverage gate.
|
|
4
7
|
const exampleCommand = (source, command) => source?.runtimeExample
|
|
@@ -9,56 +12,116 @@ const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f
|
|
|
9
12
|
const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
|
|
10
13
|
const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
|
|
11
14
|
|
|
15
|
+
/** Optional uploads require reachable upload and authentication endpoints without exposing identity tokens. */
|
|
16
|
+
function codecovProbe(codecov, condition) {
|
|
17
|
+
if (!codecov.optional) return [];
|
|
18
|
+
return [
|
|
19
|
+
' - name: Codecov availability',
|
|
20
|
+
' id: codecov-availability',
|
|
21
|
+
` if: ${condition}`,
|
|
22
|
+
' env:',
|
|
23
|
+
` CODECOV_AUTH: ${codecov.auth}`,
|
|
24
|
+
' run: |',
|
|
25
|
+
" node --input-type=module <<'NODE'",
|
|
26
|
+
" import fs from 'node:fs';",
|
|
27
|
+
' let available = false;',
|
|
28
|
+
' try {',
|
|
29
|
+
" const upload = await fetch('https://ingest.codecov.io/upload/v4', { signal: AbortSignal.timeout(10000) });",
|
|
30
|
+
' available = upload.status < 500;',
|
|
31
|
+
" if (available && process.env.CODECOV_AUTH === 'oidc') {",
|
|
32
|
+
" const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);",
|
|
33
|
+
" url.searchParams.set('audience', 'https://codecov.io');",
|
|
34
|
+
" const identity = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }, signal: AbortSignal.timeout(10000) });",
|
|
35
|
+
' available = identity.ok && Boolean((await identity.json()).value);',
|
|
36
|
+
' }',
|
|
37
|
+
' } catch { available = false; }',
|
|
38
|
+
" fs.appendFileSync(process.env.GITHUB_OUTPUT, 'up=' + available + '\\n');",
|
|
39
|
+
" if (!available) console.log('::notice::codecov upload skipped: upload endpoint or OIDC token unavailable (services.codecov.optional)');",
|
|
40
|
+
' NODE',
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Optional Sonar only runs analysis when the provider reports UP within ten seconds. */
|
|
45
|
+
function sonarProbe(sonar) {
|
|
46
|
+
if (!sonar.optional) return [];
|
|
47
|
+
return [
|
|
48
|
+
' - name: Sonar availability',
|
|
49
|
+
' id: sonar-availability',
|
|
50
|
+
` if: ${SONAR_CONDITION}`,
|
|
51
|
+
' run: |',
|
|
52
|
+
` if curl -fsS --max-time 10 "$SONAR_HOST_URL/api/system/status" | node -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{try{process.exit(JSON.parse(s).status==='UP'?0:1)}catch{process.exit(1)}})"; then`,
|
|
53
|
+
' echo "up=true" >> "$GITHUB_OUTPUT"',
|
|
54
|
+
' else',
|
|
55
|
+
' echo "up=false" >> "$GITHUB_OUTPUT"',
|
|
56
|
+
' echo "::notice::sonar skipped: $SONAR_HOST_URL not reachable (services.sonar.optional)"',
|
|
57
|
+
' fi',
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
12
61
|
/** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
|
|
13
62
|
function codecovSteps(lcovReport, codecov) {
|
|
14
63
|
if (codecov.enabled === false || codecov.wiring === 'not-used') return [
|
|
15
64
|
' - name: coverage upload',
|
|
16
65
|
' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
|
|
17
66
|
].join('\n');
|
|
67
|
+
const condition = `\${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`;
|
|
68
|
+
const uploadCondition = codecov.optional ? condition.replace(' }}', " && steps.codecov-availability.outputs.up == 'true' }}") : condition;
|
|
18
69
|
return [
|
|
19
70
|
' - name: coverage upload skipped',
|
|
20
71
|
` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
|
|
21
72
|
' run: |',
|
|
22
73
|
` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
|
|
23
74
|
' exit 0',
|
|
75
|
+
...codecovProbe(codecov, condition),
|
|
24
76
|
' - name: coverage upload',
|
|
77
|
+
...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
|
|
25
78
|
' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
|
|
26
|
-
` if:
|
|
79
|
+
` if: ${uploadCondition}`,
|
|
27
80
|
' with:',
|
|
28
81
|
codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
|
|
29
82
|
` files: ${lcovReport}`,
|
|
30
83
|
' disable_search: true',
|
|
31
|
-
|
|
84
|
+
` fail_ci_if_error: ${!codecov.optional}`,
|
|
85
|
+
...(codecov.optional ? [
|
|
86
|
+
' - name: Codecov upload unavailable',
|
|
87
|
+
" if: ${{ !cancelled() && steps.codecov-upload.outcome == 'failure' }}",
|
|
88
|
+
' run: |',
|
|
89
|
+
' echo "::notice::codecov upload skipped: upload unavailable (services.codecov.optional)"',
|
|
90
|
+
] : []),
|
|
32
91
|
].join('\n');
|
|
33
92
|
}
|
|
34
93
|
|
|
35
94
|
/** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
|
|
36
95
|
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
|
|
37
96
|
if (!sonar) return '';
|
|
97
|
+
const condition = sonar.optional ? SONAR_CONDITION.replace(' }}', " && steps.sonar-availability.outputs.up == 'true' }}") : SONAR_CONDITION;
|
|
98
|
+
const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
|
|
38
99
|
const header = [
|
|
39
100
|
' sonar:',
|
|
40
101
|
' needs: [fast, test]',
|
|
102
|
+
' timeout-minutes: 20',
|
|
41
103
|
' runs-on: ubuntu-latest',
|
|
42
104
|
' env:',
|
|
43
105
|
` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
|
|
44
|
-
` SONAR_HOST_URL:
|
|
106
|
+
` SONAR_HOST_URL: ${host}`,
|
|
45
107
|
' steps:',
|
|
46
108
|
].join('\n');
|
|
47
109
|
const steps = [
|
|
110
|
+
...sonarProbe(sonar),
|
|
48
111
|
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
49
|
-
` if: ${
|
|
112
|
+
` if: ${condition}`,
|
|
50
113
|
' with:',
|
|
51
114
|
` name: ${lcovArtifact}`,
|
|
52
115
|
` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
|
|
53
116
|
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
54
|
-
` if: ${
|
|
117
|
+
` if: ${condition}`,
|
|
55
118
|
' with:',
|
|
56
119
|
' name: sonar-lint',
|
|
57
120
|
' path: reports',
|
|
58
121
|
' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
|
|
59
|
-
` if: ${
|
|
122
|
+
` if: ${condition}`,
|
|
60
123
|
' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
|
|
61
|
-
` if: ${
|
|
124
|
+
` if: ${condition}`,
|
|
62
125
|
' timeout-minutes: 10',
|
|
63
126
|
].join('\n');
|
|
64
127
|
// Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
|
|
@@ -71,6 +134,44 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
|
|
|
71
134
|
.trimEnd();
|
|
72
135
|
}
|
|
73
136
|
|
|
137
|
+
/** The app source reader is be-relative; the authored manifest lives one directory above that side. */
|
|
138
|
+
function buildDeclaration(source) {
|
|
139
|
+
if (!source?.read) return {};
|
|
140
|
+
try {
|
|
141
|
+
return parseYaml(source.read('../.starcistacks/application-stacks.yaml')) ?? {};
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error.code === 'ENOENT') return {};
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Static CI values are strings with valid environment names; expressions cannot turn public config into runtime secret reads. */
|
|
149
|
+
function mergeBuildValues(values, entries) {
|
|
150
|
+
for (const [key, value] of Object.entries(entries ?? {})) {
|
|
151
|
+
if (!/^[A-Z][A-Z0-9_]*$/u.test(key) || typeof value !== 'string' || value.includes('${{')) {
|
|
152
|
+
throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values must map environment names to literal strings');
|
|
153
|
+
}
|
|
154
|
+
if (values.has(key) && values.get(key) !== value) {
|
|
155
|
+
throw refusal('STACK_BUILD_ARG_INVALID', 'CI build values disagree for ' + key);
|
|
156
|
+
}
|
|
157
|
+
values.set(key, value);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** CI builds render only plain values from the images.yml environment plus the explicit build-fe gate; sealed members stay unopened. */
|
|
162
|
+
function buildEnv(source) {
|
|
163
|
+
const declaration = buildDeclaration(source);
|
|
164
|
+
const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
|
|
165
|
+
const environment = environments.length > 1 ? 'production' : environments[0];
|
|
166
|
+
const images = declaration.environments?.[environment]?.deploy?.images ?? {};
|
|
167
|
+
const values = new Map();
|
|
168
|
+
for (const image of Object.values(images)) mergeBuildValues(values, image?.buildArgs?.values);
|
|
169
|
+
const gates = declaration.release?.gates ?? [];
|
|
170
|
+
for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(values, gate.env);
|
|
171
|
+
if (!values.size) return '';
|
|
172
|
+
return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
74
175
|
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
75
176
|
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22' } = {}) {
|
|
76
177
|
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
@@ -79,6 +180,7 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
79
180
|
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
80
181
|
const lcovArtifact = 'coverage-lcov';
|
|
81
182
|
return {
|
|
183
|
+
ciBuildEnv: buildEnv(source),
|
|
82
184
|
ciLint: exampleCommand(source, 'npm run lint'),
|
|
83
185
|
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
84
186
|
ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
package/sync/index.mjs
CHANGED
|
@@ -379,7 +379,8 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
379
379
|
return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
|
|
380
380
|
}
|
|
381
381
|
const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
|
|
382
|
-
const
|
|
382
|
+
const spacing = target.markdown ? '\n' : '';
|
|
383
|
+
const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
|
|
383
384
|
return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
|
|
384
385
|
});
|
|
385
386
|
});
|
|
@@ -16,10 +16,12 @@ concurrency:
|
|
|
16
16
|
|
|
17
17
|
jobs:
|
|
18
18
|
fast:
|
|
19
|
+
timeout-minutes: 20
|
|
19
20
|
runs-on: ubuntu-latest
|
|
20
21
|
{{sonarEnv}}
|
|
21
22
|
steps:
|
|
22
23
|
{{> app/ci-workflows/install-steps.yml}}
|
|
24
|
+
{{> app/ci-workflows/lint-base.yml}}
|
|
23
25
|
- name: production dependency audit
|
|
24
26
|
run: {{auditCommand}}
|
|
25
27
|
{{ciLintSteps}}
|
|
@@ -29,6 +31,7 @@ jobs:
|
|
|
29
31
|
run: npm run typecheck
|
|
30
32
|
|
|
31
33
|
test:
|
|
34
|
+
timeout-minutes: 45
|
|
32
35
|
runs-on: ubuntu-latest
|
|
33
36
|
# Codecov OIDC authentication requires a job-scoped identity token.
|
|
34
37
|
permissions:
|
|
@@ -36,8 +39,9 @@ jobs:
|
|
|
36
39
|
id-token: write
|
|
37
40
|
steps:
|
|
38
41
|
{{> app/ci-workflows/install-steps.yml}}
|
|
42
|
+
{{> app/ci-workflows/jest-cache.yml}}
|
|
39
43
|
- name: unit
|
|
40
|
-
run: npm test -- --ci
|
|
44
|
+
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
|
|
41
45
|
- name: coverage proof
|
|
42
46
|
run: {{ciCoverageProof}}
|
|
43
47
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
@@ -48,6 +52,7 @@ jobs:
|
|
|
48
52
|
{{codecovSteps}}
|
|
49
53
|
|
|
50
54
|
build:
|
|
55
|
+
timeout-minutes: 30
|
|
51
56
|
runs-on: ubuntu-latest
|
|
52
57
|
steps:
|
|
53
58
|
{{> app/ci-workflows/install-steps.yml}}
|
|
@@ -55,5 +60,6 @@ jobs:
|
|
|
55
60
|
run: npm run build:be
|
|
56
61
|
- name: build fe
|
|
57
62
|
run: npm run build:fe
|
|
63
|
+
{{ciBuildEnv}}
|
|
58
64
|
|
|
59
65
|
{{sonarJob}}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
- name: Jest cache
|
|
2
|
+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
3
|
+
with:
|
|
4
|
+
path: .jest-cache
|
|
5
|
+
key: ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
|
|
6
|
+
restore-keys: |
|
|
7
|
+
${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-
|
|
8
|
+
${{ runner.os }}-node{{nodeMajor}}-jest-
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
- name: lint baseline
|
|
2
|
+
shell: bash
|
|
3
|
+
env:
|
|
4
|
+
BASE_BRANCH: ${{ github.base_ref }}
|
|
5
|
+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
6
|
+
BEFORE: ${{ github.event.before }}
|
|
7
|
+
run: |
|
|
8
|
+
base="origin/$DEFAULT_BRANCH"
|
|
9
|
+
if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then
|
|
10
|
+
base="origin/$BASE_BRANCH"
|
|
11
|
+
elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
|
12
|
+
base=$(git describe --tags --abbrev=0 "$GITHUB_REF^" 2>/dev/null) || base="origin/$DEFAULT_BRANCH"
|
|
13
|
+
elif [[ "$GITHUB_EVENT_NAME" == push && -n "$BEFORE" && ! "$BEFORE" =~ ^0+$ ]]; then
|
|
14
|
+
base="$BEFORE"
|
|
15
|
+
fi
|
|
16
|
+
echo "STARCI_BASE_REF=$base" >> "$GITHUB_ENV"
|
|
@@ -16,10 +16,12 @@ concurrency:
|
|
|
16
16
|
|
|
17
17
|
jobs:
|
|
18
18
|
fast:
|
|
19
|
+
timeout-minutes: 20
|
|
19
20
|
runs-on: ubuntu-latest
|
|
20
21
|
{{sonarEnv}}
|
|
21
22
|
steps:
|
|
22
23
|
{{> app/ci-workflows/install-steps.yml}}
|
|
24
|
+
{{> app/ci-workflows/lint-base.yml}}
|
|
23
25
|
- name: production dependency audit
|
|
24
26
|
run: {{auditCommand}}
|
|
25
27
|
{{ciLintSteps}}
|
|
@@ -29,6 +31,7 @@ jobs:
|
|
|
29
31
|
run: npm run typecheck
|
|
30
32
|
|
|
31
33
|
test:
|
|
34
|
+
timeout-minutes: 45
|
|
32
35
|
runs-on: ubuntu-latest
|
|
33
36
|
# Codecov OIDC authentication requires a job-scoped identity token.
|
|
34
37
|
permissions:
|
|
@@ -36,8 +39,9 @@ jobs:
|
|
|
36
39
|
id-token: write
|
|
37
40
|
steps:
|
|
38
41
|
{{> app/ci-workflows/install-steps.yml}}
|
|
42
|
+
{{> app/ci-workflows/jest-cache.yml}}
|
|
39
43
|
- name: unit
|
|
40
|
-
run: npm test -- --ci
|
|
44
|
+
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
|
|
41
45
|
- name: coverage proof
|
|
42
46
|
run: {{ciCoverageProof}}
|
|
43
47
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
@@ -48,6 +52,7 @@ jobs:
|
|
|
48
52
|
{{codecovSteps}}
|
|
49
53
|
|
|
50
54
|
build:
|
|
55
|
+
timeout-minutes: 30
|
|
51
56
|
runs-on: ubuntu-latest
|
|
52
57
|
steps:
|
|
53
58
|
{{> app/ci-workflows/install-steps.yml}}
|
|
@@ -55,12 +60,14 @@ jobs:
|
|
|
55
60
|
run: npm run build:be
|
|
56
61
|
- name: build fe
|
|
57
62
|
run: npm run build:fe
|
|
63
|
+
{{ciBuildEnv}}
|
|
58
64
|
- name: database migration checks
|
|
59
65
|
run: {{ciDatabaseLint}}
|
|
60
66
|
|
|
61
67
|
{{sonarJob}}
|
|
62
68
|
|
|
63
69
|
db-types:
|
|
70
|
+
timeout-minutes: 30
|
|
64
71
|
runs-on: ubuntu-latest
|
|
65
72
|
steps:
|
|
66
73
|
{{> app/ci-workflows/install-steps.yml}}
|
package/templates/app/gitignore
CHANGED