@starci/hfs 4.5.0 → 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 +8 -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/release/gate.mjs +50 -19
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/knowledge/sonar-gate.yaml +2 -2
- package/runtime/modules/schemas/application-stacks.schema.yaml +35 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +9 -7
- package/runtime/scripts/hfs/ci-declaration.mjs +12 -3
- 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 +154 -14
- package/sync/hygiene.mjs +4 -1
- package/sync/index.mjs +16 -12
- 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 +9 -3
- 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 +10 -4
- 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/.github/dependabot.yml +1 -2
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +1 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -2
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +1 -0
|
@@ -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
|
@@ -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.
|
|
@@ -68,17 +131,91 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
|
|
|
68
131
|
.replaceAll('{{nodeMajor}}', nodeMajor)
|
|
69
132
|
.replace('{{sonarJobHeader}}', () => header)
|
|
70
133
|
.replace('{{sonarJobSteps}}', () => steps)
|
|
71
|
-
.trimEnd();
|
|
134
|
+
.trimEnd() + '\n';
|
|
135
|
+
}
|
|
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
|
+
/** 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) {
|
|
174
|
+
const declaration = buildDeclaration(source);
|
|
175
|
+
const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
|
|
176
|
+
const environment = environments.length > 1 ? 'production' : environments[0];
|
|
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));
|
|
179
|
+
const gates = declaration.release?.gates ?? [];
|
|
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
|
+
];
|
|
72
206
|
}
|
|
73
207
|
|
|
74
208
|
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
75
|
-
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 } = {}) {
|
|
76
210
|
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
77
211
|
const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
|
|
78
212
|
const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
|
|
79
213
|
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
80
214
|
const lcovArtifact = 'coverage-lcov';
|
|
215
|
+
const build = frontendBuild(source, app);
|
|
81
216
|
return {
|
|
217
|
+
ciBuildEnv: build.env,
|
|
218
|
+
ciBuildSteps: build.steps,
|
|
82
219
|
ciLint: exampleCommand(source, 'npm run lint'),
|
|
83
220
|
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
84
221
|
ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
@@ -96,11 +233,14 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
96
233
|
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
97
234
|
]),
|
|
98
235
|
...(sonar ? [
|
|
99
|
-
'
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(),
|
|
104
244
|
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
105
245
|
" if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
|
|
106
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);
|
|
@@ -379,7 +380,8 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
379
380
|
return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
|
|
380
381
|
}
|
|
381
382
|
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
|
|
383
|
+
const spacing = target.markdown ? '\n' : '';
|
|
384
|
+
const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
|
|
383
385
|
return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
|
|
384
386
|
});
|
|
385
387
|
});
|
|
@@ -428,8 +430,8 @@ function insertAfterTitle(existing, content) {
|
|
|
428
430
|
return `${heading}\n${content}${existing.slice(end)}`;
|
|
429
431
|
}
|
|
430
432
|
|
|
431
|
-
/**
|
|
432
|
-
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) {
|
|
433
435
|
if (target.mode === 'scripts') {
|
|
434
436
|
let pkg = {};
|
|
435
437
|
if (existing) {
|
|
@@ -476,10 +478,10 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
|
|
|
476
478
|
}
|
|
477
479
|
|
|
478
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. */
|
|
479
|
-
export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
|
|
481
|
+
export async function renderRepo(root, { presets, parseYaml, notices, manifest = loadSlotManifest() } = {}) {
|
|
480
482
|
const hfs = loadHfs(root, manifest);
|
|
481
483
|
const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
|
|
482
|
-
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 }) }) };
|
|
483
485
|
}
|
|
484
486
|
|
|
485
487
|
/** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
|
|
@@ -515,7 +517,9 @@ export async function runSync(argv, { cwd = process.cwd(), out = line => process
|
|
|
515
517
|
const { write, cwdAt } = options;
|
|
516
518
|
const root = cwdAt >= 0 ? path.resolve(cwd, argv[cwdAt + 1]) : cwd;
|
|
517
519
|
try {
|
|
518
|
-
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);
|
|
519
523
|
if (write) {
|
|
520
524
|
const written = writeTargets(root, targets);
|
|
521
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
|
|
|
@@ -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,10 +39,13 @@ 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}}
|
|
47
|
+
env:
|
|
48
|
+
NODE_OPTIONS: --max-old-space-size=6144
|
|
43
49
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
44
50
|
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
45
51
|
with:
|
|
@@ -48,12 +54,12 @@ jobs:
|
|
|
48
54
|
{{codecovSteps}}
|
|
49
55
|
|
|
50
56
|
build:
|
|
57
|
+
timeout-minutes: 30
|
|
51
58
|
runs-on: ubuntu-latest
|
|
52
59
|
steps:
|
|
53
60
|
{{> app/ci-workflows/install-steps.yml}}
|
|
54
61
|
- name: build be
|
|
55
62
|
run: npm run build:be
|
|
56
|
-
|
|
57
|
-
run: npm run build:fe
|
|
63
|
+
{{ciBuildSteps}}
|
|
58
64
|
|
|
59
65
|
{{sonarJob}}
|