@starci/hfs 4.5.1 → 4.6.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 +5 -0
- package/ci/cli.mjs +6 -4
- package/ci/vars.mjs +22 -5
- package/ci/verify.mjs +16 -2
- package/lint/run.mjs +3 -4
- package/package.json +1 -1
- package/release/ci-red.mjs +44 -12
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/sonar-gate.yaml +2 -2
- package/runtime/modules/schemas/application-stacks.schema.yaml +32 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +9 -7
- package/runtime/scripts/hfs/ci-declaration.mjs +10 -1
- package/runtime/scripts/hfs/rules/secrets.mjs +8 -10
- package/runtime/scripts/lib/secret-patterns.mjs +77 -0
- package/scaffold/app.mjs +2 -2
- package/sync/ci-commands.mjs +54 -16
- package/sync/hygiene.mjs +4 -1
- package/sync/index.mjs +14 -11
- package/sync/quality-services.mjs +7 -5
- package/sync/readme-badges.mjs +18 -2
- package/sync/stack-images.mjs +5 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +3 -4
- package/templates/app/skeleton/.github/dependabot.yml +1 -2
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.6.0 - 2026-10-18
|
|
4
|
+
- Added: `services.codecov|sonar.badgeToken` README badge tokens and `ci.github.visibility` with a private-repository notice (sync, ci verify); `ci.vars` `ownerValue: true` (var sync skips, var set reads stdin, verify checks the name).
|
|
5
|
+
- Fixed: the app release gate counts tag-push CI runs reachable from origin/main, main or HEAD; the secret scans exempt only declared badge tokens.
|
|
6
|
+
- Fixed: empty placeholder lines vanish so every managed file is prettier-stable; CI coverage proof heap, per-side Sonar lint reports merged for the import, per-fe-app build env; `app lint --side` combines with `--sonar`.
|
|
7
|
+
|
|
3
8
|
## 4.5.1 - 2026-10-18
|
|
4
9
|
- Fixed: managed CI base refs, public build values, caching and timeouts; optional quality service handling, README formatting and Supabase migration-history parsing.
|
|
5
10
|
|
package/ci/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { readCiDeclaration, sealedKeyNames } from '../runtime/scripts/hfs/ci-dec
|
|
|
5
5
|
import { github, refuse } from './github.mjs';
|
|
6
6
|
import { setSecret } from './secret.mjs';
|
|
7
7
|
import { setVariable, syncVariables } from './vars.mjs';
|
|
8
|
-
import { verifyCi } from './verify.mjs';
|
|
8
|
+
import { badgeVisibilityNotices, verifyCi } from './verify.mjs';
|
|
9
9
|
|
|
10
10
|
const COMMANDS = {
|
|
11
11
|
'secret set': ['env', 'dependabot', 'repo'],
|
|
@@ -46,16 +46,18 @@ function parse(argv) {
|
|
|
46
46
|
export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
|
|
47
47
|
try {
|
|
48
48
|
const parsed = parse(argv);
|
|
49
|
-
let declaration, sealed;
|
|
49
|
+
let doc, declaration, sealed;
|
|
50
50
|
try {
|
|
51
|
-
|
|
51
|
+
doc = parseYaml(fs.readFileSync(path.join(cwd, '.starcistacks/application-stacks.yaml'), 'utf8'));
|
|
52
|
+
declaration = readCiDeclaration(doc);
|
|
52
53
|
sealed = sealedKeyNames(cwd, { fs });
|
|
53
54
|
} catch { throw refuse('CI_DECLARATION_INVALID', 'the manifest or sealed key names could not be read'); }
|
|
54
55
|
if (declaration.problems.length) throw refuse('CI_DECLARATION_INVALID', 'correct the CI declaration before using GitHub');
|
|
55
56
|
const context = { ...parsed, declaration, sealed, gh: github({ cwd, env, spawn: tools.spawn }), stdin, stdout };
|
|
56
57
|
if (parsed.command === 'secret set') return await setSecret(context);
|
|
57
|
-
if (parsed.command === 'var set') return setVariable(context);
|
|
58
|
+
if (parsed.command === 'var set') return await setVariable(context);
|
|
58
59
|
if (parsed.command === 'var sync') return syncVariables(context);
|
|
60
|
+
context.notices = badgeVisibilityNotices({ doc, gh: context.gh, repo: context.gh.repository(parsed.options.repo) });
|
|
59
61
|
return verifyCi(context);
|
|
60
62
|
} catch (error) {
|
|
61
63
|
stderr(`${error.code ? error.message : 'CI_USAGE: CI command could not complete'}\n`);
|
package/ci/vars.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import { refuse, scopeArgs, scopeName } from './github.mjs';
|
|
2
3
|
import { declaredEntry } from './secret.mjs';
|
|
3
4
|
|
|
@@ -15,18 +16,34 @@ function writeVariable(gh, repo, entry, value, options, stdout) {
|
|
|
15
16
|
stdout(`set variable ${entry.name} (${scopeName(options)})\n`);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
/** Owner-supplied input is read once from stdin, with one trailing newline removed and no value echoed. */
|
|
20
|
+
async function ownerValueOf(options, stdin) {
|
|
21
|
+
if (Object.hasOwn(options, 'value')) throw refuse('CI_VALUE_ARGV', 'an owner-supplied variable takes its value on stdin');
|
|
22
|
+
let value;
|
|
23
|
+
if (stdin === undefined) {
|
|
24
|
+
if (process.stdin.isTTY) throw refuse('CI_VALUE_EMPTY', 'an owner-supplied variable value is required on stdin');
|
|
25
|
+
value = fs.readFileSync(0, 'utf8');
|
|
26
|
+
} else value = typeof stdin === 'function' ? await stdin() : stdin;
|
|
27
|
+
value = String(value ?? '').replace(/\r?\n$/, '');
|
|
28
|
+
if (!value.length) throw refuse('CI_VALUE_EMPTY', 'an owner-supplied variable value is required on stdin');
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Owner-supplied variables accept stdin only; other public variables retain manifest and argv agreement. */
|
|
33
|
+
export async function setVariable({ declaration, gh, options, name, stdin, stdout }) {
|
|
34
|
+
const entry = declaredEntry(declaration.vars, name, options);
|
|
35
|
+
const value = entry.ownerValue ? await ownerValueOf(options, stdin) : valueOf(entry, options.value);
|
|
20
36
|
writeVariable(gh, gh.repository(options.repo), entry, value, options, stdout);
|
|
21
37
|
return 0;
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
/** Sync validates the whole plan first and performs no GitHub access until execution is requested. */
|
|
25
41
|
export function syncVariables({ declaration, gh, options, stdout }) {
|
|
26
|
-
const plan = declaration.vars.map((entry) => ({ entry, value: valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
|
|
27
|
-
const repo = options.execute && plan.
|
|
42
|
+
const plan = declaration.vars.map((entry) => ({ entry, value: entry.ownerValue ? undefined : valueOf(entry), options: { env: entry.scope === 'environment' ? entry.environment : undefined } }));
|
|
43
|
+
const repo = options.execute && plan.some((item) => !item.entry.ownerValue) ? gh.repository(options.repo) : undefined;
|
|
28
44
|
for (const item of plan) {
|
|
29
|
-
if (
|
|
45
|
+
if (item.entry.ownerValue) stdout(`skip variable ${item.entry.name} (${scopeName(item.options)}): owner-supplied value; the owner runs starci app ci var set ${item.entry.name} [--env ENV] with the value on stdin\n`);
|
|
46
|
+
else if (options.execute) writeVariable(gh, repo, item.entry, item.value, item.options, stdout);
|
|
30
47
|
else stdout(`would set variable ${item.entry.name} (${scopeName(item.options)})\n`);
|
|
31
48
|
}
|
|
32
49
|
return 0;
|
package/ci/verify.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { badgeTokenNotices } from '../sync/readme-badges.mjs';
|
|
1
2
|
import { refuse, scopeArgs, scopeName } from './github.mjs';
|
|
2
3
|
|
|
3
4
|
/** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
|
|
4
|
-
export function verifyCi({ declaration, sealed, gh, options, stdout }) {
|
|
5
|
+
export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [] }) {
|
|
5
6
|
const repo = gh.repository(options.repo), stores = [];
|
|
6
7
|
const environments = [...new Set([...declaration.secrets, ...declaration.vars].filter((item) => item.scope === 'environment').map((item) => item.environment))].sort();
|
|
7
8
|
for (const scope of [{}, ...environments.map((env) => ({ env })), { dependabot: true }]) {
|
|
@@ -29,8 +30,21 @@ export function verifyCi({ declaration, sealed, gh, options, stdout }) {
|
|
|
29
30
|
add(sameKind.some((entry) => entry.name === row.name) ? 'CI_SCOPE_MISMATCH' : store.kind === 'secret' ? 'CI_SECRET_UNDECLARED' : 'CI_VAR_UNDECLARED', row.name, store.scope);
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
|
-
if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings }, null, 2)}\n`);
|
|
33
|
+
if (options.json) stdout(`${JSON.stringify({ ok: findings.length === 0, findings, notices }, null, 2)}\n`);
|
|
33
34
|
else if (!findings.length) stdout('CI verification passed\n');
|
|
34
35
|
else for (const finding of findings) stdout(`${finding.code}: ${finding.name} (${finding.scope})\n`);
|
|
36
|
+
if (!options.json) for (const notice of notices) stdout(`${notice}\n`);
|
|
35
37
|
return findings.length ? 1 : 0;
|
|
36
38
|
}
|
|
39
|
+
|
|
40
|
+
/** Manifest visibility wins; optional GitHub visibility discovery never changes verification findings or errors. */
|
|
41
|
+
export function badgeVisibilityNotices({ doc, gh, repo }) {
|
|
42
|
+
let visibility = doc?.ci?.github?.visibility;
|
|
43
|
+
if (visibility === undefined) {
|
|
44
|
+
try {
|
|
45
|
+
const remote = gh.json(['repo', 'view', '--json', 'visibility', '--repo', repo]);
|
|
46
|
+
visibility = ['PRIVATE', 'INTERNAL'].includes(remote?.visibility) ? 'private' : 'public';
|
|
47
|
+
} catch { return []; }
|
|
48
|
+
}
|
|
49
|
+
return badgeTokenNotices({ ...doc, ci: { ...doc?.ci, github: { ...doc?.ci?.github, visibility } } }, { repository: `https://github.com/${repo}` });
|
|
50
|
+
}
|
package/lint/run.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { STYLE_GLOB } from '../sync/index.mjs';
|
|
|
26
26
|
import { byCodeUnit } from '../report/order.mjs';
|
|
27
27
|
import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
|
|
28
28
|
|
|
29
|
-
// --side judges only that side; root HFS findings belong to be,
|
|
29
|
+
// --side judges only that side; root HFS findings belong to be, its Sonar report stays on that side.
|
|
30
30
|
export const LINT_SCHEMA = 'starci/lint@1';
|
|
31
31
|
/** The side whose stylesheets stylelint judges. */
|
|
32
32
|
const STYLE_SIDE = 'fe';
|
|
@@ -42,11 +42,10 @@ export function workspaceOf(workspace) {
|
|
|
42
42
|
return rel;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** Side scopes partition the full lint; Sonar
|
|
45
|
+
/** Side scopes partition the full lint; Sonar reports share that scope and a frontend workspace cannot belong to be. */
|
|
46
46
|
function validateSide(opts) {
|
|
47
47
|
if (opts.side === undefined) return;
|
|
48
48
|
if (!SIDES.includes(opts.side)) throw new Error('LINT_SIDE_INVALID: --side is be or fe');
|
|
49
|
-
if (opts.sonar !== undefined) throw new Error('LINT_SIDE_SONAR_CONFLICT: --side and --sonar do not combine');
|
|
50
49
|
if (opts.side === 'be' && opts.workspace !== undefined) throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
|
|
51
50
|
}
|
|
52
51
|
|
|
@@ -312,7 +311,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
312
311
|
|
|
313
312
|
let properties = '';
|
|
314
313
|
try { properties = fs.readFileSync(path.join(repoRoot, 'sonar-project.properties'), 'utf8'); } catch { /* no properties: every finding keeps its own path */ }
|
|
315
|
-
const sourceRoots = sourceRootsOf(properties);
|
|
314
|
+
const sourceRoots = sourceRootsOf(properties).filter((root) => findingOnSide({ path: root }, opts.side));
|
|
316
315
|
const tracked = sourceRoots.length ? trackedFiles(repoRoot) : [];
|
|
317
316
|
const sonar = mergeReports([
|
|
318
317
|
sonarReport(kept, { sourceRoots, tracked }),
|
package/package.json
CHANGED
package/release/ci-red.mjs
CHANGED
|
@@ -20,29 +20,61 @@ function runsOf(result) {
|
|
|
20
20
|
} catch { return null; }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Resolve each available default-branch reference once, including HEAD in detached release worktrees. */
|
|
24
|
+
function referenceTips(spawn, cwd, defaultBranch) {
|
|
25
|
+
const refs = [`origin/${defaultBranch}`, defaultBranch, 'HEAD'];
|
|
26
|
+
return refs.flatMap((name) => {
|
|
27
|
+
const result = run(spawn, cwd, 'git', ['rev-parse', '--verify', '--quiet', `${name}^{commit}`]);
|
|
28
|
+
const tip = String(result.stdout ?? '').trim();
|
|
29
|
+
return !result.error && result.status === 0 && SHA.test(tip) ? [{ name, tip }] : [];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Equality proves reachability directly; ancestry is unknown only when every available probe errors. */
|
|
34
|
+
function pushAncestry(spawn, cwd, row, refs) {
|
|
35
|
+
if (refs.some((ref) => ref.tip === row.headSha)) return { candidate: true, unknown: false };
|
|
36
|
+
let known = false;
|
|
37
|
+
for (const ref of refs) {
|
|
38
|
+
const result = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, ref.tip]);
|
|
39
|
+
if (!result.error && result.status === 0) return { candidate: true, unknown: false };
|
|
40
|
+
if (!result.error && result.status === 1) known = true;
|
|
41
|
+
}
|
|
42
|
+
return { candidate: false, unknown: !known };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Branch rows remain candidates; push rows must reach a resolved reference, and each databaseId counts once. */
|
|
46
|
+
function candidateRuns(spawn, cwd, branch, pushes, refs) {
|
|
47
|
+
const candidates = branch.filter((row) => row && SHA.test(row.headSha));
|
|
48
|
+
let ancestryUnknown = refs.length === 0;
|
|
49
|
+
for (const row of pushes) {
|
|
50
|
+
if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
|
|
51
|
+
const ancestry = pushAncestry(spawn, cwd, row, refs);
|
|
52
|
+
if (ancestry.candidate) candidates.push(row);
|
|
53
|
+
if (ancestry.unknown) ancestryUnknown = true;
|
|
54
|
+
}
|
|
55
|
+
const unique = new Map();
|
|
56
|
+
for (const row of candidates) if (!unique.has(row.databaseId)) unique.set(row.databaseId, row);
|
|
57
|
+
return { candidates: [...unique.values()], ancestryUnknown };
|
|
58
|
+
}
|
|
59
|
+
|
|
23
60
|
/** Reads branch runs plus push/tag runs whose commits are reachable from the default branch; the newest completed run decides, even if a newer run is pending. */
|
|
24
61
|
export function latestDefaultBranchCi({ spawn, cwd, defaultBranch = 'main' }) {
|
|
62
|
+
const refs = referenceTips(spawn, cwd, defaultBranch);
|
|
63
|
+
const unknown = (why) => ({ state: 'unknown', why: `${why} (refs: ${refs.map((ref) => ref.name).join(', ') || 'none'})` });
|
|
25
64
|
const args = ['run', 'list', '--workflow', 'ci.yml'];
|
|
26
65
|
const tail = ['--limit', '20', '--json', FIELDS];
|
|
27
66
|
const branch = runsOf(run(spawn, cwd, 'gh', [...args, '--branch', defaultBranch, ...tail]));
|
|
28
|
-
if (!branch) return
|
|
67
|
+
if (!branch) return unknown('gh branch runs unavailable (missing gh, authentication, or unreadable response)');
|
|
29
68
|
const pushes = runsOf(run(spawn, cwd, 'gh', [...args, '--event', 'push', ...tail]));
|
|
30
|
-
if (!pushes) return
|
|
31
|
-
const candidates =
|
|
32
|
-
let ancestryUnknown = false;
|
|
33
|
-
for (const row of pushes) {
|
|
34
|
-
if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
|
|
35
|
-
const ancestor = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, defaultBranch]);
|
|
36
|
-
if (!ancestor.error && ancestor.status === 0) candidates.push(row);
|
|
37
|
-
else if (ancestor.error || ancestor.status !== 1) ancestryUnknown = true;
|
|
38
|
-
}
|
|
69
|
+
if (!pushes) return unknown('gh push runs unavailable (missing gh, authentication, or unreadable response)');
|
|
70
|
+
const { candidates, ancestryUnknown } = candidateRuns(spawn, cwd, branch, pushes, refs);
|
|
39
71
|
const completed = candidates.filter((row) => row.status === 'completed' && Number.isFinite(Date.parse(row.createdAt)));
|
|
40
72
|
completed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || Number(b.databaseId) - Number(a.databaseId));
|
|
41
73
|
const latest = completed[0];
|
|
42
|
-
if (!latest) return
|
|
74
|
+
if (!latest) return unknown(ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch');
|
|
43
75
|
if (latest.conclusion === 'success') return { state: 'green', run: latest };
|
|
44
76
|
if (RED.has(latest.conclusion)) return { state: 'red', run: latest };
|
|
45
|
-
return {
|
|
77
|
+
return { ...unknown('latest completed CI run has no success or red conclusion'), run: latest };
|
|
46
78
|
}
|
|
47
79
|
|
|
48
80
|
/** Only a complete starci/ci-override@1 record for this exact commit can excuse a red run. */
|
|
@@ -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.4
|
|
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.4
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -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.
|
|
79
|
+
version: 1.6.0
|
|
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.
|
|
86
|
+
version: 4.6.0
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -41,8 +41,8 @@ newCode:
|
|
|
41
41
|
minReviewedPercent: 100
|
|
42
42
|
unreviewedMax: 0
|
|
43
43
|
# The whole code, not only the new code. Every finding of the HFS canon reaches Sonar through ONE entry, `starci app lint`
|
|
44
|
-
# (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry)
|
|
45
|
-
#
|
|
44
|
+
# (packages/hfs, contract changes hfs-sonar-import and hfs-lint-entry). CI runs it per side with --side and --sonar,
|
|
45
|
+
# then merges rules by id and joins issues into ONE file, reports/lint.sonar.json, named by sonar.externalIssuesReportPaths; the gate holds the open-issue count of
|
|
46
46
|
# the whole project at zero. The file carries three engines:
|
|
47
47
|
# starci-hfs the repository findings of `starci app check` (managed files, tree, pins, CI, contracts; the rule id is the finding code)
|
|
48
48
|
# eslint ESLint's json over the repository (the BE and FE canon plugins alike, per-file and project-graph rules)
|
|
@@ -152,6 +152,13 @@ $defs:
|
|
|
152
152
|
optional:
|
|
153
153
|
description: Skip an unreachable quality provider with a notice; SonarQube defaults true, SonarCloud and Codecov false.
|
|
154
154
|
type: boolean
|
|
155
|
+
badgeToken:
|
|
156
|
+
description: >-
|
|
157
|
+
codecov and sonar only: a read-only, public-safe badge token (Codecov graphing token, Sonar project badge token)
|
|
158
|
+
that the README badge of a private repository needs. It is public configuration rendered into README.md, never a
|
|
159
|
+
secret; a CI token or any write credential never goes here.
|
|
160
|
+
type: string
|
|
161
|
+
pattern: '^[A-Za-z0-9_-]{6,128}$'
|
|
155
162
|
ecosystems: {type: array, items: {enum: [npm, docker, github-actions]}}
|
|
156
163
|
schedule:
|
|
157
164
|
type: object
|
|
@@ -275,6 +282,9 @@ $defs:
|
|
|
275
282
|
repository: {type: string, pattern: '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'}
|
|
276
283
|
defaultBranch: {type: string, minLength: 1, default: main}
|
|
277
284
|
workflow: {type: string, pattern: '^[A-Za-z0-9_.-]+\.ya?ml$', default: ci.yml}
|
|
285
|
+
visibility:
|
|
286
|
+
description: The GitHub repository visibility; private READMEs need services.<id>.badgeToken for live badges. Omitted means unknown.
|
|
287
|
+
enum: [public, private]
|
|
278
288
|
secrets: {type: array, items: {$ref: '#/$defs/ciSecret'}}
|
|
279
289
|
vars: {type: array, items: {$ref: '#/$defs/ciVariable'}}
|
|
280
290
|
dependabot:
|
|
@@ -310,6 +320,9 @@ $defs:
|
|
|
310
320
|
purpose: {type: string}
|
|
311
321
|
optional: {type: boolean}
|
|
312
322
|
ciVariable:
|
|
323
|
+
description: >-
|
|
324
|
+
A public CI variable either carries its literal value, or declares ownerValue true (no value) when only the owner
|
|
325
|
+
may supply it; var sync skips an owner-supplied value and ci verify checks that it exists by name.
|
|
313
326
|
anyOf:
|
|
314
327
|
- type: object
|
|
315
328
|
additionalProperties: false
|
|
@@ -330,6 +343,25 @@ $defs:
|
|
|
330
343
|
value: {type: string}
|
|
331
344
|
purpose: {type: string}
|
|
332
345
|
optional: {type: boolean}
|
|
346
|
+
- type: object
|
|
347
|
+
additionalProperties: false
|
|
348
|
+
required: [name, scope, ownerValue]
|
|
349
|
+
properties:
|
|
350
|
+
name: {$ref: '#/$defs/ciName'}
|
|
351
|
+
scope: {const: repository}
|
|
352
|
+
ownerValue: {const: true}
|
|
353
|
+
purpose: {type: string}
|
|
354
|
+
optional: {type: boolean}
|
|
355
|
+
- type: object
|
|
356
|
+
additionalProperties: false
|
|
357
|
+
required: [name, scope, ownerValue, environment]
|
|
358
|
+
properties:
|
|
359
|
+
name: {$ref: '#/$defs/ciName'}
|
|
360
|
+
scope: {const: environment}
|
|
361
|
+
environment: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
|
|
362
|
+
ownerValue: {const: true}
|
|
363
|
+
purpose: {type: string}
|
|
364
|
+
optional: {type: boolean}
|
|
333
365
|
serviceStack:
|
|
334
366
|
description: >-
|
|
335
367
|
Which stack defines a local service - either a project-owned .starcistacks stack
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED by scripts/cli/gen-catalog.mjs — do not edit
|
|
2
2
|
export const CATALOG = {
|
|
3
3
|
"schema": "starci/cli-catalog@1",
|
|
4
|
-
"hash": "
|
|
4
|
+
"hash": "93fd9007c93fe7430043d8b6682fd46dd0e1d5f9f9b7094fc94ffad2ccdcceeb",
|
|
5
5
|
"global": [
|
|
6
6
|
{
|
|
7
7
|
"name": "json",
|
|
@@ -224,12 +224,12 @@ export const CATALOG = {
|
|
|
224
224
|
{
|
|
225
225
|
"name": "value",
|
|
226
226
|
"type": "string",
|
|
227
|
-
"summary": "public variable value that must agree with the manifest"
|
|
227
|
+
"summary": "public variable value that must agree with the manifest; var set ownerValue requires stdin instead"
|
|
228
228
|
},
|
|
229
229
|
{
|
|
230
230
|
"name": "execute",
|
|
231
231
|
"type": "boolean",
|
|
232
|
-
"summary": "apply the variable sync plan"
|
|
232
|
+
"summary": "apply the variable sync plan; var sync skips ownerValue variables"
|
|
233
233
|
},
|
|
234
234
|
{
|
|
235
235
|
"name": "json",
|
|
@@ -7317,12 +7317,12 @@ export const CATALOG = {
|
|
|
7317
7317
|
{
|
|
7318
7318
|
"name": "dev",
|
|
7319
7319
|
"type": "boolean",
|
|
7320
|
-
"summary": "save the packages as development dependencies"
|
|
7320
|
+
"summary": "save the CLI and explicit packages as development dependencies"
|
|
7321
7321
|
},
|
|
7322
7322
|
{
|
|
7323
7323
|
"name": "canon",
|
|
7324
7324
|
"type": "boolean",
|
|
7325
|
-
"summary": "install the target CLI and
|
|
7325
|
+
"summary": "install the target CLI and move its declared exact StarCi canon pins"
|
|
7326
7326
|
}
|
|
7327
7327
|
],
|
|
7328
7328
|
"exit": {
|
|
@@ -7353,10 +7353,12 @@ export const CATALOG = {
|
|
|
7353
7353
|
"bare package names resolve only from knowledge/hfs/canon-pins.yaml and never through a registry lookup",
|
|
7354
7354
|
"explicit versions use exact x.y.z form and must agree with the canon pin when one exists, except the target CLI version on a canon upgrade",
|
|
7355
7355
|
"naming @starci/cli or passing --canon installs the exact target CLI first,",
|
|
7356
|
-
"then reads its installed HFS runtime canon-pins.yaml and
|
|
7356
|
+
"then reads its installed HFS runtime canon-pins.yaml and, in the same host lock,",
|
|
7357
|
+
"moves each other pinned @starci package only where a root or workspace package.json declares it, in its own section",
|
|
7357
7358
|
"--canon without a CLI positional uses the running runtime's exact CLI pin as the target",
|
|
7358
7359
|
"target pins never fall back to the running runtime when the target pin file is missing or inconsistent",
|
|
7359
|
-
"each target StarCi pin is reported as moved
|
|
7360
|
+
"each target StarCi pin is reported as moved (with its workspace), skipped when no package.json declares it, or refused with a reason",
|
|
7361
|
+
"a failed canon phase exits nonzero even when the first CLI install succeeded",
|
|
7360
7362
|
"installs always use --save-exact and report the package.json and package-lock.json diff stat"
|
|
7361
7363
|
],
|
|
7362
7364
|
"runtimeSide": "host"
|
|
@@ -4,6 +4,14 @@ import { parseYaml } from '../../engine/yaml.mjs';
|
|
|
4
4
|
|
|
5
5
|
const nameOf = (item) => typeof item === 'string' ? item : item?.name;
|
|
6
6
|
|
|
7
|
+
/** A public CI variable declares either a manifest value or an owner-supplied value, never both. */
|
|
8
|
+
function variableProblems(item, at, invalid) {
|
|
9
|
+
const hasValue = item != null && Object.hasOwn(item, 'value');
|
|
10
|
+
const hasOwnerValue = item != null && Object.hasOwn(item, 'ownerValue');
|
|
11
|
+
if (hasValue && hasOwnerValue) invalid(at, 'a variable has either a value or ownerValue');
|
|
12
|
+
else if (!hasValue && item?.ownerValue !== true) invalid(at, 'a variable needs a value or ownerValue: true');
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
/** Each CI name belongs to one class and scope; declarations contain names and public configuration only. */
|
|
8
16
|
export function readCiDeclaration(doc) {
|
|
9
17
|
const problems = [], seen = new Set();
|
|
@@ -17,8 +25,9 @@ export function readCiDeclaration(doc) {
|
|
|
17
25
|
if (!['repository', 'environment'].includes(scope)) invalid(`${where}.scope`, 'scope must be repository or environment');
|
|
18
26
|
if (scope === 'environment' && (typeof item?.environment !== 'string' || !/^[a-z][a-z0-9-]*$/.test(item.environment))) invalid(`${where}.environment`, 'environment scope requires an environment name');
|
|
19
27
|
if ((dependabot || scope !== 'environment') && item && Object.hasOwn(item, 'environment')) invalid(`${where}.environment`, 'only environment scope may name an environment');
|
|
28
|
+
if (variable) variableProblems(item, where, invalid);
|
|
20
29
|
return { name, scope, environment: item?.environment ?? null, purpose: item?.purpose ?? null,
|
|
21
|
-
optional: item?.optional === true, ...(variable ? { value: item?.value } : {}) };
|
|
30
|
+
optional: item?.optional === true, ...(variable ? { value: item?.value, ownerValue: item?.ownerValue === true } : {}) };
|
|
22
31
|
});
|
|
23
32
|
const ci = doc?.ci;
|
|
24
33
|
const secrets = normalize(ci?.secrets, 'ci.secrets');
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// A finding names the file, the line and the pattern, never the value.
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { FORBIDDEN_FILES,
|
|
10
|
+
import { FORBIDDEN_FILES, secretHitsInFile } from '../../lib/secret-patterns.mjs';
|
|
11
11
|
import { isSopsEnvelope } from '../../lib/sops-envelope.mjs';
|
|
12
12
|
import { found, readText } from './read.mjs';
|
|
13
13
|
|
|
@@ -34,20 +34,17 @@ export function untrackedSecretFindings({ repoRoot, files, repo }) {
|
|
|
34
34
|
* no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
|
|
35
35
|
* large, absent). The whole-tree check reads the work tree and the commit hook (`starci app hygiene`) reads the staged blob: one judgement.
|
|
36
36
|
*/
|
|
37
|
-
export function secretFileFindings({ file, text }) {
|
|
37
|
+
export function secretFileFindings({ file, text, manifestText }) {
|
|
38
38
|
const being = FORBIDDEN_FILES.find((rule) => rule.test(file));
|
|
39
39
|
if (being) return [found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret by being (${being.name}); a secret is committed only as a sops envelope at .starcistacks/<env>/secrets/<slug>.enc`, { pattern: being.name })];
|
|
40
40
|
if (text === null || text === undefined || NOT_SCANNED.test(file)) return [];
|
|
41
41
|
if (file.endsWith('.enc')) return isSopsEnvelope(text) ? [] : [found(PLAINTEXT_SECRET, file, `${file} is named like a sealed secret but is not a sops envelope; encrypt the value with sops and commit only the envelope`, { pattern: 'sops-not-envelope' })];
|
|
42
42
|
const findings = [];
|
|
43
|
-
const lines = text.split(/\r?\n/);
|
|
44
43
|
const seen = new Set();
|
|
45
|
-
for (const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
findings.push(found(PLAINTEXT_SECRET, file, `${file}:${index + 1} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line: index + 1, pattern }));
|
|
50
|
-
}
|
|
44
|
+
for (const { line, pattern } of secretHitsInFile(file, text, { manifestText })) {
|
|
45
|
+
if (seen.has(pattern)) continue;
|
|
46
|
+
seen.add(pattern);
|
|
47
|
+
findings.push(found(PLAINTEXT_SECRET, file, `${file}:${line} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line, pattern }));
|
|
51
48
|
}
|
|
52
49
|
return findings;
|
|
53
50
|
}
|
|
@@ -62,7 +59,8 @@ export function secretFindings({ repoRoot, files, resolver }) {
|
|
|
62
59
|
findings.push(found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret file tracked in ${slot.slot}${destination}`, { slot: slot.slot, goesTo: slot.goesTo }));
|
|
63
60
|
continue;
|
|
64
61
|
}
|
|
65
|
-
|
|
62
|
+
const manifestText = /(?:^|\/)README\.md$/.test(file) ? readText(repoRoot, path.posix.join(path.posix.dirname(file), '.starcistacks/application-stacks.yaml')) : undefined;
|
|
63
|
+
findings.push(...secretFileFindings({ file, text: readText(repoRoot, file), manifestText }));
|
|
66
64
|
}
|
|
67
65
|
return findings;
|
|
68
66
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
2
|
+
|
|
1
3
|
// secret-patterns.mjs — the one list of secret shapes the runtime refuses to carry: the push secret scan
|
|
2
4
|
// (scripts/supervisor/push-mains.mjs scanDiff) refuses an outgoing range that adds one, and the typed-log
|
|
3
5
|
// redaction (scripts/kernel/typed-logs.mjs redactText) blanks the value before a log row is stored.
|
|
@@ -102,3 +104,78 @@ export function secretHits(file, text) {
|
|
|
102
104
|
}
|
|
103
105
|
return names;
|
|
104
106
|
}
|
|
107
|
+
|
|
108
|
+
const BADGE_BEGIN = '<!-- >>> starci app sync (managed block; do not edit) >>> -->';
|
|
109
|
+
const BADGE_END = '<!-- <<< starci app sync <<< -->';
|
|
110
|
+
|
|
111
|
+
/** Provider-shaped credentials remain refused even where the keyword heuristic admits public badge tokens. */
|
|
112
|
+
function blankBadgeValue(value) {
|
|
113
|
+
return secretHits('badge.mjs', value).length ? value : ' '.repeat(value.length);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Only scalar values at the two block-mapping service paths qualify; every other YAML substring remains scanned. */
|
|
117
|
+
function manifestBadgeText(text) {
|
|
118
|
+
try { parseYaml(text); } catch { return text; }
|
|
119
|
+
const parents = [];
|
|
120
|
+
return text.split('\n').map(line => {
|
|
121
|
+
if (/^\s*(?:#.*)?$/.test(line)) return line;
|
|
122
|
+
const indent = /^ */.exec(line)[0].length;
|
|
123
|
+
while (parents.length && parents.at(-1).indent >= indent) parents.pop();
|
|
124
|
+
const match = /^( *)(?:([A-Za-z0-9_-]+)|"([A-Za-z0-9_-]+)"|'([A-Za-z0-9_-]+)'):(.*)$/.exec(line);
|
|
125
|
+
if (!match) { parents.push({ indent, key: null }); return line; }
|
|
126
|
+
match[5] = match[5].trimStart();
|
|
127
|
+
const key = match[2] ?? match[3] ?? match[4];
|
|
128
|
+
const keys = [...parents.map(parent => parent.key), key].join('.');
|
|
129
|
+
const container = /^(?:#.*)?$/.test(match[5].trim());
|
|
130
|
+
const nestedScalar = parents.some(parent => parent.container === false);
|
|
131
|
+
parents.push({ indent, key, container });
|
|
132
|
+
if (nestedScalar) return line;
|
|
133
|
+
if (!['services.codecov.badgeToken', 'services.sonar.badgeToken'].includes(keys)) return line;
|
|
134
|
+
const scalar = /^(?:"([A-Za-z0-9_-]{6,128})"|'([A-Za-z0-9_-]{6,128})'|([A-Za-z0-9_-]{6,128}))(?=\s*(?:#|$))/.exec(match[5]);
|
|
135
|
+
if (!scalar) return line;
|
|
136
|
+
const start = line.length - match[5].length;
|
|
137
|
+
return line.slice(0, start) + blankBadgeValue(scalar[0]) + match[5].slice(scalar[0].length);
|
|
138
|
+
}).join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Unreadable manifests admit only the default badge hosts; readable Sonar declarations select their own hosts. */
|
|
142
|
+
function badgeHosts(manifestText) {
|
|
143
|
+
const hosts = new Set(['codecov.io']);
|
|
144
|
+
let doc;
|
|
145
|
+
try { doc = parseYaml(manifestText); } catch { hosts.add('sonarcloud.io'); return hosts; }
|
|
146
|
+
const sonar = doc?.services?.sonar;
|
|
147
|
+
if (sonar?.provider === 'sonarcloud') hosts.add('sonarcloud.io');
|
|
148
|
+
else for (const host of [sonar?.host?.public, sonar?.host?.local]) {
|
|
149
|
+
try { hosts.add(new URL(host).host); } catch { /* Invalid hosts admit nothing. */ }
|
|
150
|
+
}
|
|
151
|
+
return hosts;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Only token query values in managed Markdown badge images on declared hosts are blanked; links and other parameters remain scanned. */
|
|
155
|
+
function readmeBadgeText(text, manifestText) {
|
|
156
|
+
const begin = text.indexOf(BADGE_BEGIN), end = text.indexOf(BADGE_END, begin + BADGE_BEGIN.length);
|
|
157
|
+
if (begin < 0 || end < 0) return text;
|
|
158
|
+
const hosts = badgeHosts(manifestText);
|
|
159
|
+
const start = begin + BADGE_BEGIN.length;
|
|
160
|
+
const block = text.slice(start, end).replace(/!\[[^\]\n]*\]\((https?:\/\/[^\s)]+)\)/g, (image, address) => {
|
|
161
|
+
let url;
|
|
162
|
+
try { url = new URL(address); } catch { return image; }
|
|
163
|
+
if (!hosts.has(url.host) || url.username || url.password) return image;
|
|
164
|
+
const badgePath = url.host === 'codecov.io' ? /\/graph\/badge\.svg$/.test(url.pathname) : url.pathname === '/api/project_badges/measure';
|
|
165
|
+
if (!badgePath) return image;
|
|
166
|
+
const fragment = address.indexOf('#');
|
|
167
|
+
const queryEnd = fragment < 0 ? address.length : fragment;
|
|
168
|
+
const safe = address.slice(0, queryEnd).replace(/([?&]token=)([^&#)\s]*)/g, (_, prefix, value) => prefix + blankBadgeValue(value));
|
|
169
|
+
return image.replace(address, () => safe + address.slice(queryEnd));
|
|
170
|
+
});
|
|
171
|
+
return text.slice(0, start) + block + text.slice(end);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whole-file secret judgement exempts only declared public badge values and preserves original line numbers and every provider shape. */
|
|
175
|
+
export function secretHitsInFile(file, fullText, { manifestText } = {}) {
|
|
176
|
+
const normalized = file.replace(/\\/g, '/');
|
|
177
|
+
let text = fullText;
|
|
178
|
+
if (/(?:^|\/)\.starcistacks\/application-stacks\.yaml$/.test(normalized)) text = manifestBadgeText(text);
|
|
179
|
+
if (/(?:^|\/)README\.md$/.test(normalized)) text = readmeBadgeText(text, manifestText);
|
|
180
|
+
return text.split(/\r?\n/).flatMap((line, index) => secretHits(file, line).map(pattern => ({ line: index + 1, pattern })));
|
|
181
|
+
}
|
package/scaffold/app.mjs
CHANGED
|
@@ -222,7 +222,7 @@ const FE_PACKAGES = Object.freeze({
|
|
|
222
222
|
const PACKAGE_WORKSPACE_SCRIPTS = Object.freeze({ build: 'tsc -p tsconfig.build.json', lint: WORKSPACE_LINT, typecheck: 'tsc --noEmit -p tsconfig.json' });
|
|
223
223
|
|
|
224
224
|
/** The app hfs.json of a new app called `name`; full remains the byte-for-byte default declaration. */
|
|
225
|
-
const starterDeclaration = (name, manifest = loadSlotManifest(), edition = 'full') => ({
|
|
225
|
+
export const starterDeclaration = (name, manifest = loadSlotManifest(), edition = 'full') => ({
|
|
226
226
|
hfs: manifest.major,
|
|
227
227
|
kind: 'app',
|
|
228
228
|
...(edition === 'full' ? {} : { edition }),
|
|
@@ -493,7 +493,7 @@ export function scaffoldWorkSeeds(project) {
|
|
|
493
493
|
* be side (once per product app of the fe side, coreFeApps), the be `apps/cli/` folder once when the cli app is declared, an fe `apps/<name>/` folder
|
|
494
494
|
* once for the declared fe app <name> (none other), `__project__` in a path replaced by the project name.
|
|
495
495
|
*/
|
|
496
|
-
function skeletonOf(scope, app, vars) {
|
|
496
|
+
export function skeletonOf(scope, app, vars) {
|
|
497
497
|
const files = app.edition === 'lite'
|
|
498
498
|
? [...skeletonDirectory(scope, 'skeleton', app, vars, rel => liteBaseFile(scope, rel)), ...skeletonDirectory(scope, 'skeleton-lite', app, vars, rel => liteOverlayFile(scope, rel))]
|
|
499
499
|
: skeletonDirectory(scope, 'skeleton', app, vars);
|
package/sync/ci-commands.mjs
CHANGED
|
@@ -131,7 +131,7 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
|
|
|
131
131
|
.replaceAll('{{nodeMajor}}', nodeMajor)
|
|
132
132
|
.replace('{{sonarJobHeader}}', () => header)
|
|
133
133
|
.replace('{{sonarJobSteps}}', () => steps)
|
|
134
|
-
.trimEnd();
|
|
134
|
+
.trimEnd() + '\n';
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/** The app source reader is be-relative; the authored manifest lives one directory above that side. */
|
|
@@ -158,29 +158,64 @@ function mergeBuildValues(values, entries) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
/**
|
|
162
|
-
function
|
|
161
|
+
/** Image values own their keys; the build-fe gate supplies only missing keys, and sealed members stay unopened. */
|
|
162
|
+
function imageBuildEnv(image, gates) {
|
|
163
|
+
const values = new Map();
|
|
164
|
+
mergeBuildValues(values, image?.buildArgs?.values);
|
|
165
|
+
const fallback = new Map();
|
|
166
|
+
for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(fallback, gate.env);
|
|
167
|
+
for (const [key, value] of fallback) if (!values.has(key)) values.set(key, value);
|
|
168
|
+
if (!values.size) return '';
|
|
169
|
+
return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Multiple deployed front ends build separately through the installed turbo (a run step has no node_modules/.bin on PATH), so each fe/apps/<app> and its dependencies see its own public values. */
|
|
173
|
+
function frontendBuild(source, app) {
|
|
163
174
|
const declaration = buildDeclaration(source);
|
|
164
175
|
const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
|
|
165
176
|
const environment = environments.length > 1 ? 'production' : environments[0];
|
|
166
|
-
const images = declaration.environments?.[environment]?.deploy?.images ?? {};
|
|
167
|
-
const
|
|
168
|
-
for (const image of Object.values(images)) mergeBuildValues(values, image?.buildArgs?.values);
|
|
177
|
+
const images = Object.values(declaration.environments?.[environment]?.deploy?.images ?? {});
|
|
178
|
+
const frontend = images.filter(image => app?.sides.fe.apps.some(entry => entry.name === image.app));
|
|
169
179
|
const gates = declaration.release?.gates ?? [];
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
180
|
+
const singleEnv = imageBuildEnv(frontend[0], gates);
|
|
181
|
+
if (frontend.length <= 1) return { env: singleEnv, steps: [' - name: build fe', ' run: npm run build:fe', singleEnv].filter(Boolean).join('\n') };
|
|
182
|
+
const steps = frontend.map(image => [
|
|
183
|
+
' - name: build fe ' + image.app,
|
|
184
|
+
' run: npm run codegen --silent && npx --no-install turbo run build --filter=./fe/apps/' + image.app,
|
|
185
|
+
imageBuildEnv(image, gates),
|
|
186
|
+
].filter(Boolean).join('\n')).join('\n');
|
|
187
|
+
return { env: singleEnv, steps };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Side lint reports join into the single Sonar artifact even when one side could not write its report. */
|
|
191
|
+
function sonarMergeSteps() {
|
|
192
|
+
return [
|
|
193
|
+
' - name: merge Sonar lint reports',
|
|
194
|
+
" if: always() && env.SONAR_TOKEN != ''",
|
|
195
|
+
' run: |',
|
|
196
|
+
" node --input-type=module <<'NODE'",
|
|
197
|
+
" import fs from 'node:fs';",
|
|
198
|
+
" const files = ['reports/lint.be.sonar.json', 'reports/lint.fe.sonar.json'];",
|
|
199
|
+
" const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
|
|
200
|
+
' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
|
|
201
|
+
' const issues = reports.flatMap(report => report.issues);',
|
|
202
|
+
" fs.mkdirSync('reports', { recursive: true });",
|
|
203
|
+
" fs.writeFileSync('reports/lint.sonar.json', JSON.stringify({ rules, issues }, null, 2) + '\\n');",
|
|
204
|
+
' NODE',
|
|
205
|
+
];
|
|
173
206
|
}
|
|
174
207
|
|
|
175
208
|
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
176
|
-
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22' } = {}) {
|
|
209
|
+
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
|
|
177
210
|
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
178
211
|
const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
|
|
179
212
|
const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
|
|
180
213
|
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
181
214
|
const lcovArtifact = 'coverage-lcov';
|
|
215
|
+
const build = frontendBuild(source, app);
|
|
182
216
|
return {
|
|
183
|
-
ciBuildEnv:
|
|
217
|
+
ciBuildEnv: build.env,
|
|
218
|
+
ciBuildSteps: build.steps,
|
|
184
219
|
ciLint: exampleCommand(source, 'npm run lint'),
|
|
185
220
|
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
186
221
|
ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
@@ -198,11 +233,14 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
198
233
|
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
199
234
|
]),
|
|
200
235
|
...(sonar ? [
|
|
201
|
-
'
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
236
|
+
...['be', 'fe'].flatMap((side) => [
|
|
237
|
+
' - name: lint ' + side + ' with Sonar import',
|
|
238
|
+
" if: env.SONAR_TOKEN != ''",
|
|
239
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
|
|
240
|
+
' env:',
|
|
241
|
+
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
242
|
+
]),
|
|
243
|
+
...sonarMergeSteps(),
|
|
206
244
|
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
207
245
|
" if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
|
|
208
246
|
' with:',
|
package/sync/hygiene.mjs
CHANGED
|
@@ -79,7 +79,10 @@ function stagedText(cwd, file) {
|
|
|
79
79
|
|
|
80
80
|
/** The secret findings of `files` read from the index, as {file, code, message}; a file the name check already refused is not reported twice. */
|
|
81
81
|
function secretGuardFindings(cwd, files, alreadyRefused = new Set()) {
|
|
82
|
-
return files.filter(file => !alreadyRefused.has(file)).flatMap(file =>
|
|
82
|
+
return files.filter(file => !alreadyRefused.has(file)).flatMap(file => {
|
|
83
|
+
const manifestText = /(?:^|\/)README\.md$/.test(file) ? stagedText(cwd, path.posix.join(path.posix.dirname(file), '.starcistacks/application-stacks.yaml')) ?? undefined : undefined;
|
|
84
|
+
return secretFileFindings({ file, text: stagedText(cwd, file), manifestText }).map(finding => ({ file, code: finding.code, message: finding.message }));
|
|
85
|
+
});
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
/** Findings for `files`: the guarded trees, then the secret guard over every file. */
|
package/sync/index.mjs
CHANGED
|
@@ -128,11 +128,12 @@ function readBundled(name) {
|
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
130
|
* Fill `{{name}}` from vars and expand `{{> partial}}` lines. A partial line may be written as a line comment (`// {{> partial}}`)
|
|
131
|
-
* so a JavaScript template stays valid source before rendering.
|
|
131
|
+
* so a JavaScript template stays valid source before rendering. Empty variable-only lines disappear; an unknown name is an error.
|
|
132
132
|
*/
|
|
133
133
|
export function render(text, vars, readTemplate = readBundled) {
|
|
134
134
|
const withPartials = text.replace(/^(?:\/\/ )?\{\{> ([\w./-]+)\}\}\n/gm, (_, name) => trailingNewline(render(readTemplate(name), vars, readTemplate)));
|
|
135
|
-
|
|
135
|
+
const presentLines = withPartials.replace(/^[ \t]*\{\{([A-Za-z]\w*)\}\}[ \t]*(?:\n|$)/gm, (line, key) => vars[key] === '' ? '' : line);
|
|
136
|
+
return presentLines.replace(/\{\{([A-Za-z]\w*)\}\}/g, (_, key) => {
|
|
136
137
|
if (!Object.hasOwn(vars, key)) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `template names {{${key}}}, which sync does not provide`);
|
|
137
138
|
return vars[key];
|
|
138
139
|
});
|
|
@@ -169,7 +170,7 @@ const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
|
|
|
169
170
|
* lite brand tree exists, so sync can preserve its absence. `hasImage` identifies authored optional Dockerfiles without reading
|
|
170
171
|
* their content. An app with no be folder has no source files.
|
|
171
172
|
*/
|
|
172
|
-
export function appSource(root, { parseYaml } = {}) {
|
|
173
|
+
export function appSource(root, { parseYaml, notices } = {}) {
|
|
173
174
|
const files = [];
|
|
174
175
|
const walk = dir => {
|
|
175
176
|
let entries;
|
|
@@ -183,7 +184,7 @@ export function appSource(root, { parseYaml } = {}) {
|
|
|
183
184
|
return { runtimeExample: isRuntimeExampleApp(root), files: files.sort(byCodeUnit), read: file => fs.readFileSync(path.join(root, 'be', file), 'utf8'),
|
|
184
185
|
workPresent: fs.existsSync(path.join(root, '.starciwork')), hasImage: file => isFile(path.join(root, file), { followLinks: false }),
|
|
185
186
|
stack: readDeclaredStack(root, { parseYaml }), quality: readDeclaredQuality(root, { parseYaml }),
|
|
186
|
-
badges: readDeclaredBadges(root, { parseYaml }),
|
|
187
|
+
badges: readDeclaredBadges(root, { parseYaml, notices }),
|
|
187
188
|
qualityDeclared: hasDeclaredServices(root, { parseYaml }) };
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -323,7 +324,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
323
324
|
sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
|
|
324
325
|
sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
|
|
325
326
|
lcovReport: LCOV_REPORT,
|
|
326
|
-
...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality),
|
|
327
|
+
...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality, { app }),
|
|
327
328
|
// The coverage scope is the same in both editions: lite measures exactly what full measures (ruling:R212/case-1, amended by ruling:R212/case-4).
|
|
328
329
|
jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
|
|
329
330
|
coverageExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
|
|
@@ -370,7 +371,7 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
370
371
|
const template = target.path === 'codecov.yml' && source?.quality?.codecov.enabled === false
|
|
371
372
|
? '{{header}}\n# Codecov is disabled in the manifest.\ncodecov:\n require_ci_to_pass: false\n'
|
|
372
373
|
: readTemplate(target.template);
|
|
373
|
-
const body = render(template, vars, readTemplate);
|
|
374
|
+
const body = trailingNewline(render(template, vars, readTemplate));
|
|
374
375
|
const where = onSide(scope, target.path);
|
|
375
376
|
if (target.mode === 'scripts') {
|
|
376
377
|
const scripts = parseScripts(body);
|
|
@@ -429,8 +430,8 @@ function insertAfterTitle(existing, content) {
|
|
|
429
430
|
return `${heading}\n${content}${existing.slice(end)}`;
|
|
430
431
|
}
|
|
431
432
|
|
|
432
|
-
/**
|
|
433
|
-
function writtenContent(target, existing) {
|
|
433
|
+
/** Apply exactly one target to its whole file while preserving content outside the managed block or scripts. */
|
|
434
|
+
export function writtenContent(target, existing) {
|
|
434
435
|
if (target.mode === 'scripts') {
|
|
435
436
|
let pkg = {};
|
|
436
437
|
if (existing) {
|
|
@@ -477,10 +478,10 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
|
|
|
477
478
|
}
|
|
478
479
|
|
|
479
480
|
/** The rendered targets of the app at `root`: its hfs.json, the Sonar key the be side's stack declaration names, the presets it installs. */
|
|
480
|
-
export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
|
|
481
|
+
export async function renderRepo(root, { presets, parseYaml, notices, manifest = loadSlotManifest() } = {}) {
|
|
481
482
|
const hfs = loadHfs(root, manifest);
|
|
482
483
|
const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
|
|
483
|
-
return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml }) }) };
|
|
484
|
+
return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml, notices }) }) };
|
|
484
485
|
}
|
|
485
486
|
|
|
486
487
|
/** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
|
|
@@ -516,7 +517,9 @@ export async function runSync(argv, { cwd = process.cwd(), out = line => process
|
|
|
516
517
|
const { write, cwdAt } = options;
|
|
517
518
|
const root = cwdAt >= 0 ? path.resolve(cwd, argv[cwdAt + 1]) : cwd;
|
|
518
519
|
try {
|
|
519
|
-
const
|
|
520
|
+
const notices = [];
|
|
521
|
+
const { targets } = await renderRepo(root, { presets, parseYaml, notices });
|
|
522
|
+
if (targets.some(target => target.path === 'README.md')) for (const notice of new Set(notices)) out(notice);
|
|
520
523
|
if (write) {
|
|
521
524
|
const written = writeTargets(root, targets);
|
|
522
525
|
for (const file of written) out(`wrote ${file}`);
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
|
|
4
4
|
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
5
5
|
import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
|
|
6
|
-
import { renderReadmeBadges } from './readme-badges.mjs';
|
|
6
|
+
import { badgeTokenNotices, renderReadmeBadges } from './readme-badges.mjs';
|
|
7
7
|
import { DECLARATION } from './sonar-key.mjs';
|
|
8
8
|
|
|
9
9
|
/** Apps without any service block retain the legacy CI defaults until they declare their services. */
|
|
@@ -35,8 +35,8 @@ export function sonarHostLine(sonar) {
|
|
|
35
35
|
|
|
36
36
|
/** Dependabot groups minor and patch updates while majors remain per package; security updates have their own immediate group. */
|
|
37
37
|
export function dependabotUpdates(dependabot) {
|
|
38
|
-
if (!dependabot.ecosystems.length) return '
|
|
39
|
-
return dependabot.ecosystems.map(ecosystem => [
|
|
38
|
+
if (!dependabot.ecosystems.length) return ' []';
|
|
39
|
+
return '\n' + dependabot.ecosystems.map(ecosystem => [
|
|
40
40
|
` - package-ecosystem: ${ecosystem}`,
|
|
41
41
|
...(ecosystem === 'docker' ? [' directories:', ' - /be/apps/*', ' - /fe/apps/*'] : [' directory: /']),
|
|
42
42
|
' schedule:',
|
|
@@ -53,9 +53,11 @@ export function dependabotUpdates(dependabot) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/** README badges use only public manifest fields and the package repository fallback. */
|
|
56
|
-
export function readDeclaredBadges(root, { parseYaml = bundledParseYaml } = {}) {
|
|
56
|
+
export function readDeclaredBadges(root, { parseYaml = bundledParseYaml, notices } = {}) {
|
|
57
57
|
let doc = {}, pkg = {};
|
|
58
58
|
try { doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8')); } catch { /* No declaration. */ }
|
|
59
59
|
try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* No repository fallback. */ }
|
|
60
|
-
|
|
60
|
+
const options = { repository: pkg.repository, example: isRuntimeExampleApp(root) };
|
|
61
|
+
notices?.push(...badgeTokenNotices(doc, options));
|
|
62
|
+
return renderReadmeBadges(doc, options);
|
|
61
63
|
}
|
package/sync/readme-badges.mjs
CHANGED
|
@@ -7,6 +7,9 @@ function githubRepository(repository) {
|
|
|
7
7
|
return (match?.[1] ?? match?.[2] ?? '').replace(/\.git$/, '');
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** Notice code (never an error) of a private repository badge rendered without its read-only badge token. */
|
|
11
|
+
const BADGE_TOKEN_NOTICE = 'README_BADGE_TOKEN_MISSING';
|
|
12
|
+
|
|
10
13
|
const badge = (name, image, link) => `[](${link})`;
|
|
11
14
|
|
|
12
15
|
/** Render live CI, coverage, quality gate and Dependabot badges only when a GitHub repository is declared or inferable; no network is used. */
|
|
@@ -20,7 +23,10 @@ export function renderReadmeBadges(doc, { repository, example = false } = {}) {
|
|
|
20
23
|
const quality = qualityServices(doc, { example });
|
|
21
24
|
const lines = [badge('CI', `${base}/actions/workflows/${workflow}/badge.svg?branch=${branch}`, `${base}/actions/workflows/${workflow}`)];
|
|
22
25
|
if (quality.codecov.enabled) {
|
|
23
|
-
const
|
|
26
|
+
const params = [];
|
|
27
|
+
if (quality.codecov.flag) params.push(`flag=${encodeURIComponent(quality.codecov.flag)}`);
|
|
28
|
+
if (doc?.services?.codecov?.badgeToken) params.push(`token=${encodeURIComponent(doc.services.codecov.badgeToken)}`);
|
|
29
|
+
const flag = params.length ? `?${params.join('&')}` : '';
|
|
24
30
|
lines.push(badge('Codecov', `https://codecov.io/gh/${repo}/branch/${branch}/graph/badge.svg${flag}`, `https://codecov.io/gh/${repo}`));
|
|
25
31
|
}
|
|
26
32
|
const sonar = sonarBadge(doc, quality.sonar, example);
|
|
@@ -40,5 +46,15 @@ function sonarBadge(doc, sonar, example) {
|
|
|
40
46
|
const organization = sonar.organization ?? doc?.ci?.vars?.find(entry => entry.name === sonar.organizationVar)?.value;
|
|
41
47
|
const prefix = example && organization ? `${organization}_` : '';
|
|
42
48
|
const key = encodeURIComponent(prefix + sonar.projectKey);
|
|
43
|
-
|
|
49
|
+
const token = doc?.services?.sonar?.badgeToken ? `&token=${encodeURIComponent(doc.services.sonar.badgeToken)}` : '';
|
|
50
|
+
return badge('Quality Gate', `${host}/api/project_badges/measure?project=${key}&metric=alert_status${token}`, `${host}/dashboard?id=${key}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Private repositories need public-safe badge tokens only for badges the renderer actually emits; examples never receive notices. */
|
|
54
|
+
export function badgeTokenNotices(doc, { example = false, repository } = {}) {
|
|
55
|
+
if (example || doc?.ci?.github?.visibility !== 'private') return [];
|
|
56
|
+
const rendered = renderReadmeBadges(doc, { example, repository });
|
|
57
|
+
return [['codecov', 'Codecov', 'Codecov'], ['sonar', 'Sonar', 'Quality Gate']]
|
|
58
|
+
.filter(([id, , label]) => !doc?.services?.[id]?.badgeToken && rendered.includes(`[)
|
|
59
|
+
.map(([id, name]) => `${BADGE_TOKEN_NOTICE} notice: the ${name} badge of a private repository renders "unknown" without services.${id}.badgeToken (a read-only badge token, not a CI secret)`);
|
|
44
60
|
}
|
package/sync/stack-images.mjs
CHANGED
|
@@ -71,6 +71,11 @@ export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
|
|
|
71
71
|
} catch {
|
|
72
72
|
return { images: [], passEnv: [] };
|
|
73
73
|
}
|
|
74
|
+
return declaredStack(declaration);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parsed declarations share the exact image, environment and pass-env projection used by sync. */
|
|
78
|
+
export function declaredStack(declaration) {
|
|
74
79
|
return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration) };
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -44,6 +44,8 @@ jobs:
|
|
|
44
44
|
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
|
|
45
45
|
- name: coverage proof
|
|
46
46
|
run: {{ciCoverageProof}}
|
|
47
|
+
env:
|
|
48
|
+
NODE_OPTIONS: --max-old-space-size=6144
|
|
47
49
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
48
50
|
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
49
51
|
with:
|
|
@@ -58,8 +60,6 @@ jobs:
|
|
|
58
60
|
{{> app/ci-workflows/install-steps.yml}}
|
|
59
61
|
- name: build be
|
|
60
62
|
run: npm run build:be
|
|
61
|
-
|
|
62
|
-
run: npm run build:fe
|
|
63
|
-
{{ciBuildEnv}}
|
|
63
|
+
{{ciBuildSteps}}
|
|
64
64
|
|
|
65
65
|
{{sonarJob}}
|
|
@@ -44,6 +44,8 @@ jobs:
|
|
|
44
44
|
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
|
|
45
45
|
- name: coverage proof
|
|
46
46
|
run: {{ciCoverageProof}}
|
|
47
|
+
env:
|
|
48
|
+
NODE_OPTIONS: --max-old-space-size=6144
|
|
47
49
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
48
50
|
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
49
51
|
with:
|
|
@@ -58,14 +60,11 @@ jobs:
|
|
|
58
60
|
{{> app/ci-workflows/install-steps.yml}}
|
|
59
61
|
- name: build be
|
|
60
62
|
run: npm run build:be
|
|
61
|
-
|
|
62
|
-
run: npm run build:fe
|
|
63
|
-
{{ciBuildEnv}}
|
|
63
|
+
{{ciBuildSteps}}
|
|
64
64
|
- name: database migration checks
|
|
65
65
|
run: {{ciDatabaseLint}}
|
|
66
66
|
|
|
67
67
|
{{sonarJob}}
|
|
68
|
-
|
|
69
68
|
db-types:
|
|
70
69
|
timeout-minutes: 30
|
|
71
70
|
runs-on: ubuntu-latest
|