@starci/hfs 4.4.1 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/ci/cli.mjs +64 -0
- package/ci/github.mjs +41 -0
- package/ci/secret.mjs +28 -0
- package/ci/vars.mjs +33 -0
- package/ci/verify.mjs +36 -0
- package/db/backup-receipt.mjs +129 -0
- package/db/dump.mjs +66 -0
- package/db/migrate.mjs +4 -1
- package/db/reset.mjs +125 -0
- package/emit/db-types.mjs +98 -5
- package/lint/run.mjs +28 -6
- package/package.json +2 -1
- package/release/ci-red.mjs +70 -0
- package/release/gate.mjs +98 -27
- package/release/index.mjs +6 -4
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +95 -4
- package/runtime/modules/kernel/failure-codes.yaml +60 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +134 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +243 -20
- package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +9 -0
- package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
- package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
- package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/src/main.mjs +17 -6
- package/stack/context.mjs +13 -1
- package/stack/docker-transport.mjs +93 -15
- package/stack/release-store.mjs +12 -7
- package/stack/remote.mjs +14 -6
- package/stack/ssh-session.mjs +178 -0
- package/sync/ci-commands.mjs +205 -1
- package/sync/index.mjs +42 -16
- package/sync/quality-services.mjs +61 -0
- package/sync/readme-badges.mjs +44 -0
- package/sync/stack-images.mjs +29 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +33 -25
- package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
- package/templates/app/ci-workflows/github/workflows/images.yml +25 -2
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/ci-workflows/install-steps.yml +23 -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/sonar-steps.yml +3 -9
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +36 -36
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +25 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/gitignore +2 -0
- package/templates/app/gitignore-lite +2 -0
- package/templates/app/quality-config/codecov.yml +6 -6
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/readme-badges/README.md +1 -0
- package/templates/app/skeleton/.github/dependabot.yml +1 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +62 -3
- package/templates/app/skeleton/README.md +6 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +58 -3
- package/templates/app/skeleton-lite/README.md +6 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +53 -1
package/sync/ci-commands.mjs
CHANGED
|
@@ -1,13 +1,217 @@
|
|
|
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';
|
|
5
|
+
|
|
1
6
|
// One command vocabulary for managed CI rendering and the runtime coverage gate.
|
|
2
7
|
const exampleCommand = (source, command) => source?.runtimeExample
|
|
3
8
|
? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
|
|
4
9
|
: command;
|
|
5
10
|
|
|
6
|
-
|
|
11
|
+
const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
|
|
12
|
+
const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
|
|
13
|
+
const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
|
|
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
|
+
|
|
61
|
+
/** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
|
|
62
|
+
function codecovSteps(lcovReport, codecov) {
|
|
63
|
+
if (codecov.enabled === false || codecov.wiring === 'not-used') return [
|
|
64
|
+
' - name: coverage upload',
|
|
65
|
+
' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
|
|
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;
|
|
69
|
+
return [
|
|
70
|
+
' - name: coverage upload skipped',
|
|
71
|
+
` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
|
|
72
|
+
' run: |',
|
|
73
|
+
` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
|
|
74
|
+
' exit 0',
|
|
75
|
+
...codecovProbe(codecov, condition),
|
|
76
|
+
' - name: coverage upload',
|
|
77
|
+
...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
|
|
78
|
+
' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
|
|
79
|
+
` if: ${uploadCondition}`,
|
|
80
|
+
' with:',
|
|
81
|
+
codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
|
|
82
|
+
` files: ${lcovReport}`,
|
|
83
|
+
' disable_search: true',
|
|
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
|
+
] : []),
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
|
|
95
|
+
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
|
|
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} }}`;
|
|
99
|
+
const header = [
|
|
100
|
+
' sonar:',
|
|
101
|
+
' needs: [fast, test]',
|
|
102
|
+
' timeout-minutes: 20',
|
|
103
|
+
' runs-on: ubuntu-latest',
|
|
104
|
+
' env:',
|
|
105
|
+
` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
|
|
106
|
+
` SONAR_HOST_URL: ${host}`,
|
|
107
|
+
' steps:',
|
|
108
|
+
].join('\n');
|
|
109
|
+
const steps = [
|
|
110
|
+
...sonarProbe(sonar),
|
|
111
|
+
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
112
|
+
` if: ${condition}`,
|
|
113
|
+
' with:',
|
|
114
|
+
` name: ${lcovArtifact}`,
|
|
115
|
+
` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
|
|
116
|
+
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
117
|
+
` if: ${condition}`,
|
|
118
|
+
' with:',
|
|
119
|
+
' name: sonar-lint',
|
|
120
|
+
' path: reports',
|
|
121
|
+
' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
|
|
122
|
+
` if: ${condition}`,
|
|
123
|
+
' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
|
|
124
|
+
` if: ${condition}`,
|
|
125
|
+
' timeout-minutes: 10',
|
|
126
|
+
].join('\n');
|
|
127
|
+
// Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
|
|
128
|
+
const read = name => fs.readFileSync(new URL('../templates/app/ci-workflows/' + name, import.meta.url), 'utf8').replaceAll('\r\n', '\n');
|
|
129
|
+
return read('sonar-steps.yml')
|
|
130
|
+
.replace('{{> app/ci-workflows/install-steps.yml}}', () => read('install-steps.yml').trimEnd())
|
|
131
|
+
.replaceAll('{{nodeMajor}}', nodeMajor)
|
|
132
|
+
.replace('{{sonarJobHeader}}', () => header)
|
|
133
|
+
.replace('{{sonarJobSteps}}', () => steps)
|
|
134
|
+
.trimEnd();
|
|
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
|
+
/** CI builds render only plain values from the images.yml environment plus the explicit build-fe gate; sealed members stay unopened. */
|
|
162
|
+
function buildEnv(source) {
|
|
163
|
+
const declaration = buildDeclaration(source);
|
|
164
|
+
const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
|
|
165
|
+
const environment = environments.length > 1 ? 'production' : environments[0];
|
|
166
|
+
const images = declaration.environments?.[environment]?.deploy?.images ?? {};
|
|
167
|
+
const values = new Map();
|
|
168
|
+
for (const image of Object.values(images)) mergeBuildValues(values, image?.buildArgs?.values);
|
|
169
|
+
const gates = declaration.release?.gates ?? [];
|
|
170
|
+
for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(values, gate.env);
|
|
171
|
+
if (!values.size) return '';
|
|
172
|
+
return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 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' } = {}) {
|
|
177
|
+
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
178
|
+
const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
|
|
179
|
+
const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
|
|
180
|
+
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
181
|
+
const lcovArtifact = 'coverage-lcov';
|
|
7
182
|
return {
|
|
183
|
+
ciBuildEnv: buildEnv(source),
|
|
8
184
|
ciLint: exampleCommand(source, 'npm run lint'),
|
|
9
185
|
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
10
186
|
ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
11
187
|
ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
|
|
188
|
+
auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
|
|
189
|
+
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
190
|
+
sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
|
|
191
|
+
sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
|
|
192
|
+
ciLintSteps: [
|
|
193
|
+
...['be', 'fe'].flatMap((side) => [
|
|
194
|
+
' - name: lint ' + side,
|
|
195
|
+
...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
|
|
196
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
|
|
197
|
+
' env:',
|
|
198
|
+
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
199
|
+
]),
|
|
200
|
+
...(sonar ? [
|
|
201
|
+
' - name: lint with Sonar import',
|
|
202
|
+
" if: env.SONAR_TOKEN != ''",
|
|
203
|
+
` run: ${exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json')}`,
|
|
204
|
+
' env:',
|
|
205
|
+
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
206
|
+
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
207
|
+
" if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
|
|
208
|
+
' with:',
|
|
209
|
+
' name: sonar-lint',
|
|
210
|
+
' path: reports/lint.sonar.json',
|
|
211
|
+
] : []),
|
|
212
|
+
].join('\n'),
|
|
213
|
+
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
214
|
+
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
|
|
215
|
+
lcovArtifact,
|
|
12
216
|
};
|
|
13
217
|
}
|
package/sync/index.mjs
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
// templates/<scope or common>/<managedBy>/<file path with each leading dot dropped>, with dotted skeleton paths tried when no
|
|
8
8
|
// undotted template exists. The scope is app for a root slot and the
|
|
9
9
|
// side (be, fe) for a side slot, whose files land under that side's folder. Two targets are not whole files of a managedBy slot
|
|
10
|
-
// and are listed in this module: the marked
|
|
10
|
+
// and are listed in this module: the marked blocks of the root .gitignore and README.md, and .starciwork/.gitignore, which lives inside the
|
|
11
11
|
// .starciwork directory slot. Every file is rendered with the app's hfs.json (both sides and their apps) and, for the Sonar
|
|
12
12
|
// exclusions, the jest preset the app installs for its be side and the one coverage scope derived from the slot manifest
|
|
13
13
|
// (scripts/hfs/coverage-scope.mjs: the be/jest.config.js scope, sonar.coverage.exclusions and the codecov.yml paths and components). `--check` compares the sha256 of the rendered content with the
|
|
14
|
-
// file on disk and fails on any drift; `--write` rewrites the drifted files. `.gitignore`
|
|
14
|
+
// file on disk and fails on any drift; `--write` rewrites the drifted files. `.gitignore` and README.md are shared files: only the marked
|
|
15
15
|
// block is managed and the app's own lines around it are left alone. The root package.json is managed by its `scripts` block
|
|
16
16
|
// only (mode scripts, compared as parsed JSON): the rest of the file (dependencies, the npm workspaces fe/apps/* and fe/packages/*,
|
|
17
17
|
// HFS_MONO_WORKSPACES) is the app's. The fe side runs through turbo (turbo.json, app.task-graph): each fe app and package is a workspace.
|
|
@@ -31,6 +31,8 @@ import { readDeclaredStack, stackWorkflowVariables } from './stack-images.mjs';
|
|
|
31
31
|
import { byCodeUnit } from '../report/order.mjs';
|
|
32
32
|
import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
|
|
33
33
|
import { ciWorkflowCommands } from './ci-commands.mjs';
|
|
34
|
+
import { dependabotUpdates, hasDeclaredServices, readDeclaredQuality, readDeclaredBadges, sonarHostLine } from './quality-services.mjs';
|
|
35
|
+
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
34
36
|
|
|
35
37
|
export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
|
|
36
38
|
const NODE_MAJOR = 22;
|
|
@@ -167,7 +169,7 @@ const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
|
|
|
167
169
|
* lite brand tree exists, so sync can preserve its absence. `hasImage` identifies authored optional Dockerfiles without reading
|
|
168
170
|
* their content. An app with no be folder has no source files.
|
|
169
171
|
*/
|
|
170
|
-
export function appSource(root) {
|
|
172
|
+
export function appSource(root, { parseYaml } = {}) {
|
|
171
173
|
const files = [];
|
|
172
174
|
const walk = dir => {
|
|
173
175
|
let entries;
|
|
@@ -180,7 +182,9 @@ export function appSource(root) {
|
|
|
180
182
|
for (const dir of ['apps', 'src']) walk(dir);
|
|
181
183
|
return { runtimeExample: isRuntimeExampleApp(root), files: files.sort(byCodeUnit), read: file => fs.readFileSync(path.join(root, 'be', file), 'utf8'),
|
|
182
184
|
workPresent: fs.existsSync(path.join(root, '.starciwork')), hasImage: file => isFile(path.join(root, file), { followLinks: false }),
|
|
183
|
-
stack: readDeclaredStack(root) }
|
|
185
|
+
stack: readDeclaredStack(root, { parseYaml }), quality: readDeclaredQuality(root, { parseYaml }),
|
|
186
|
+
badges: readDeclaredBadges(root, { parseYaml }),
|
|
187
|
+
qualityDeclared: hasDeclaredServices(root, { parseYaml }) };
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
/**
|
|
@@ -299,8 +303,10 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
299
303
|
const packages = opensPackages(fe);
|
|
300
304
|
const lite = app.edition === 'lite';
|
|
301
305
|
const providers = (app.sides.be.connections ?? []).map(connection => connection.provider);
|
|
306
|
+
const quality = source.quality ?? qualityServices({});
|
|
302
307
|
return {
|
|
303
308
|
header: HEADER(scope),
|
|
309
|
+
readmeBadges: source.badges ?? '',
|
|
304
310
|
project: app.project,
|
|
305
311
|
appBinary: APP_BINARY,
|
|
306
312
|
appScripts: appScripts(app, source),
|
|
@@ -313,15 +319,18 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
313
319
|
lintPrefix: 'npm run codegen --silent && ',
|
|
314
320
|
nodeMajor: String(NODE_MAJOR),
|
|
315
321
|
sonarKey: sonarKey ?? app.project,
|
|
322
|
+
sonarHostLine: sonarHostLine(quality.sonar),
|
|
316
323
|
sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
|
|
317
324
|
sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
|
|
318
325
|
lcovReport: LCOV_REPORT,
|
|
319
|
-
...ciWorkflowCommands(source, LCOV_REPORT),
|
|
326
|
+
...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality),
|
|
320
327
|
// 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).
|
|
321
328
|
jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
|
|
322
329
|
coverageExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
|
|
323
330
|
codecovPaths: scope === APP_SCOPE ? codecovPaths(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
|
|
324
331
|
codecovComponents: scope === APP_SCOPE ? componentsYaml(coverageComponents(manifest, { ...source, apps: app.sides.be.apps, providers })) : '',
|
|
332
|
+
codecovTarget: quality.codecov.target,
|
|
333
|
+
dependabotUpdates: dependabotUpdates(quality.dependabot),
|
|
325
334
|
tsconfigPaths: ['be/tsconfig.json', ...fe.apps.map(entry => `fe/apps/${entry.name}/tsconfig.json`), ...(packages ? ['fe/packages/*/tsconfig.json'] : [])].join(','),
|
|
326
335
|
styleGlob: STYLE_GLOB,
|
|
327
336
|
imageMatrix: imageMatrix(app, source),
|
|
@@ -353,10 +362,15 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
353
362
|
const hasTemplate = name => { try { return readTemplate(name) !== undefined; } catch { return false; } };
|
|
354
363
|
return SCOPES.flatMap(scope => {
|
|
355
364
|
const vars = variables(app, scope, presets, sonarKey, manifest, source);
|
|
356
|
-
const
|
|
365
|
+
const declaredTargets = targetsOf(scope, { edition: app.edition, manifest, hasTemplate });
|
|
366
|
+
if (scope === APP_SCOPE && source?.badges) declaredTargets.push({ path: 'README.md', mode: 'block', markdown: true, template: 'app/readme-badges/README.md' });
|
|
367
|
+
const targets = declaredTargets
|
|
357
368
|
.filter(target => !(app.edition === 'lite' && source?.workPresent === false && target.path === '.starciwork/.gitignore'));
|
|
358
369
|
return targets.map(target => {
|
|
359
|
-
const
|
|
370
|
+
const template = target.path === 'codecov.yml' && source?.quality?.codecov.enabled === false
|
|
371
|
+
? '{{header}}\n# Codecov is disabled in the manifest.\ncodecov:\n require_ci_to_pass: false\n'
|
|
372
|
+
: readTemplate(target.template);
|
|
373
|
+
const body = render(template, vars, readTemplate);
|
|
360
374
|
const where = onSide(scope, target.path);
|
|
361
375
|
if (target.mode === 'scripts') {
|
|
362
376
|
const scripts = parseScripts(body);
|
|
@@ -364,21 +378,23 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
364
378
|
const content = scriptsText(scripts);
|
|
365
379
|
return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
|
|
366
380
|
}
|
|
367
|
-
const
|
|
368
|
-
|
|
381
|
+
const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
|
|
382
|
+
const spacing = target.markdown ? '\n' : '';
|
|
383
|
+
const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
|
|
384
|
+
return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
|
|
369
385
|
});
|
|
370
386
|
});
|
|
371
387
|
}
|
|
372
388
|
|
|
373
389
|
// The part of a file sync manages: all of it, the marked block, or the scripts block as text.
|
|
374
|
-
function managedPart(mode, text) {
|
|
390
|
+
function managedPart(mode, text, markers = { begin: BLOCK_BEGIN, end: BLOCK_END }) {
|
|
375
391
|
if (mode === 'file') return text;
|
|
376
392
|
if (mode === 'scripts') {
|
|
377
393
|
const scripts = parseScripts(text);
|
|
378
394
|
return scripts ? scriptsText(scripts) : null;
|
|
379
395
|
}
|
|
380
|
-
const start = text.indexOf(
|
|
381
|
-
const end = text.indexOf(
|
|
396
|
+
const start = text.indexOf(markers.begin);
|
|
397
|
+
const end = text.indexOf(markers.end);
|
|
382
398
|
if (start < 0 || end < start) return null;
|
|
383
399
|
const stop = text.indexOf('\n', end);
|
|
384
400
|
return text.slice(start, stop < 0 ? text.length : stop + 1);
|
|
@@ -397,7 +413,7 @@ export function checkTargets(root, targets) {
|
|
|
397
413
|
return targets.map(target => {
|
|
398
414
|
const file = path.join(root, target.path);
|
|
399
415
|
if (!fs.existsSync(file)) return { path: target.path, status: 'missing', expectedHash: target.hash };
|
|
400
|
-
const part = managedPart(target.mode, lf(fs.readFileSync(file, 'utf8')));
|
|
416
|
+
const part = managedPart(target.mode, lf(fs.readFileSync(file, 'utf8')), target.markers);
|
|
401
417
|
if (part === null) return { path: target.path, status: 'missing', expectedHash: target.hash };
|
|
402
418
|
const actualHash = hashOf(part);
|
|
403
419
|
if (actualHash === target.hash) return { path: target.path, status: 'ok', expectedHash: target.hash, actualHash };
|
|
@@ -405,6 +421,14 @@ export function checkTargets(root, targets) {
|
|
|
405
421
|
});
|
|
406
422
|
}
|
|
407
423
|
|
|
424
|
+
function insertAfterTitle(existing, content) {
|
|
425
|
+
const title = /^# [^\n]*(?:\n|$)/m.exec(existing);
|
|
426
|
+
if (!title) return `${content}\n${existing}`;
|
|
427
|
+
const end = title.index + title[0].length;
|
|
428
|
+
const heading = trailingNewline(existing.slice(0, end));
|
|
429
|
+
return `${heading}\n${content}${existing.slice(end)}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
408
432
|
/** Rewrite the missing or drifted targets; returns the paths written. */
|
|
409
433
|
function writtenContent(target, existing) {
|
|
410
434
|
if (target.mode === 'scripts') {
|
|
@@ -415,8 +439,10 @@ function writtenContent(target, existing) {
|
|
|
415
439
|
return `${JSON.stringify({ ...pkg, scripts: target.scripts }, null, 2)}\n`;
|
|
416
440
|
}
|
|
417
441
|
if (target.mode === 'block' && existing) {
|
|
418
|
-
const current = managedPart('block', existing);
|
|
419
|
-
|
|
442
|
+
const current = managedPart('block', existing, target.markers);
|
|
443
|
+
if (current !== null) return existing.replace(current, () => target.content);
|
|
444
|
+
if (target.markdown) return insertAfterTitle(existing, target.content);
|
|
445
|
+
return `${target.content}\n${existing}`;
|
|
420
446
|
}
|
|
421
447
|
return target.content;
|
|
422
448
|
}
|
|
@@ -454,7 +480,7 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
|
|
|
454
480
|
export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
|
|
455
481
|
const hfs = loadHfs(root, manifest);
|
|
456
482
|
const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
|
|
457
|
-
return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source:
|
|
483
|
+
return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml }) }) };
|
|
458
484
|
}
|
|
459
485
|
|
|
460
486
|
/** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
|
|
4
|
+
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
5
|
+
import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
|
|
6
|
+
import { renderReadmeBadges } from './readme-badges.mjs';
|
|
7
|
+
import { DECLARATION } from './sonar-key.mjs';
|
|
8
|
+
|
|
9
|
+
/** Apps without any service block retain the legacy CI defaults until they declare their services. */
|
|
10
|
+
export function hasDeclaredServices(root, { parseYaml = bundledParseYaml } = {}) {
|
|
11
|
+
try {
|
|
12
|
+
const doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8'));
|
|
13
|
+
return Object.keys(doc?.services ?? {}).length > 0;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Missing or unreadable manifests preserve the shared quality defaults; example apps use the runtime example policy. */
|
|
20
|
+
export function readDeclaredQuality(root, { parseYaml = bundledParseYaml } = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8'));
|
|
23
|
+
return qualityServices(doc, { example: isRuntimeExampleApp(root) });
|
|
24
|
+
} catch {
|
|
25
|
+
return qualityServices({});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** SonarCloud names its organization and public host; SonarQube uses the manifest host. */
|
|
30
|
+
export function sonarHostLine(sonar) {
|
|
31
|
+
if (!sonar) return '';
|
|
32
|
+
if (sonar.provider === 'sonarcloud') return [sonar.organization ? `sonar.organization=${sonar.organization}` : '', 'sonar.host.url=https://sonarcloud.io'].filter(Boolean).join('\n');
|
|
33
|
+
return sonar.host ? `sonar.host.url=${sonar.host}` : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Dependabot groups minor and patch updates while majors remain per package; security updates have their own immediate group. */
|
|
37
|
+
export function dependabotUpdates(dependabot) {
|
|
38
|
+
if (!dependabot.ecosystems.length) return ' []';
|
|
39
|
+
return dependabot.ecosystems.map(ecosystem => [
|
|
40
|
+
` - package-ecosystem: ${ecosystem}`,
|
|
41
|
+
...(ecosystem === 'docker' ? [' directories:', ' - /be/apps/*', ' - /fe/apps/*'] : [' directory: /']),
|
|
42
|
+
' schedule:',
|
|
43
|
+
` interval: ${dependabot.schedule.interval}`,
|
|
44
|
+
...(dependabot.schedule.day ? [` day: ${dependabot.schedule.day}`] : []),
|
|
45
|
+
...(dependabot.schedule.time ? [` time: ${JSON.stringify(dependabot.schedule.time)}`] : []),
|
|
46
|
+
' groups:',
|
|
47
|
+
...(ecosystem === 'github-actions' ? [' actions:', ' patterns: ["*"]'] : [
|
|
48
|
+
...(dependabot.groups.minorPatch === 'grouped' ? [' minor-and-patch:', ' update-types: [minor, patch]'] : []),
|
|
49
|
+
...(dependabot.security === 'immediate' ? [' security:', ' applies-to: security-updates', ' patterns: ["*"]', ' update-types: [minor, patch]'] : []),
|
|
50
|
+
]),
|
|
51
|
+
...(ecosystem === 'npm' && dependabot.ignore.length ? [' ignore:', ...dependabot.ignore.map(name => ` - dependency-name: ${JSON.stringify(name)}`)] : []),
|
|
52
|
+
].join('\n')).join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** README badges use only public manifest fields and the package repository fallback. */
|
|
56
|
+
export function readDeclaredBadges(root, { parseYaml = bundledParseYaml } = {}) {
|
|
57
|
+
let doc = {}, pkg = {};
|
|
58
|
+
try { doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8')); } catch { /* No declaration. */ }
|
|
59
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* No repository fallback. */ }
|
|
60
|
+
return renderReadmeBadges(doc, { repository: pkg.repository, example: isRuntimeExampleApp(root) });
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
2
|
+
|
|
3
|
+
/** Package repository fallbacks must name a GitHub HTTPS or Git transport URL. */
|
|
4
|
+
function githubRepository(repository) {
|
|
5
|
+
const url = typeof repository === 'string' ? repository : repository?.url;
|
|
6
|
+
const match = /^(?:git\+)?https:\/\/github\.com\/([^/]+\/[^/#?]+)|^git@github\.com:([^/]+\/[^/#?]+)/.exec(url ?? '');
|
|
7
|
+
return (match?.[1] ?? match?.[2] ?? '').replace(/\.git$/, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const badge = (name, image, link) => `[](${link})`;
|
|
11
|
+
|
|
12
|
+
/** Render live CI, coverage, quality gate and Dependabot badges only when a GitHub repository is declared or inferable; no network is used. */
|
|
13
|
+
export function renderReadmeBadges(doc, { repository, example = false } = {}) {
|
|
14
|
+
const github = doc?.ci?.github ?? {};
|
|
15
|
+
const repo = github.repository ?? githubRepository(repository);
|
|
16
|
+
if (!repo) return '';
|
|
17
|
+
const branch = encodeURIComponent(github.defaultBranch ?? 'main');
|
|
18
|
+
const workflow = encodeURIComponent(github.workflow ?? 'ci.yml');
|
|
19
|
+
const base = `https://github.com/${repo}`;
|
|
20
|
+
const quality = qualityServices(doc, { example });
|
|
21
|
+
const lines = [badge('CI', `${base}/actions/workflows/${workflow}/badge.svg?branch=${branch}`, `${base}/actions/workflows/${workflow}`)];
|
|
22
|
+
if (quality.codecov.enabled) {
|
|
23
|
+
const flag = quality.codecov.flag ? `?flag=${encodeURIComponent(quality.codecov.flag)}` : '';
|
|
24
|
+
lines.push(badge('Codecov', `https://codecov.io/gh/${repo}/branch/${branch}/graph/badge.svg${flag}`, `https://codecov.io/gh/${repo}`));
|
|
25
|
+
}
|
|
26
|
+
const sonar = sonarBadge(doc, quality.sonar, example);
|
|
27
|
+
if (sonar) lines.push(sonar);
|
|
28
|
+
if (quality.dependabot.ecosystems.length) {
|
|
29
|
+
const updates = `${base}/actions/workflows/dependabot/dependabot-updates`;
|
|
30
|
+
lines.push(badge('Dependabot', `${updates}/badge.svg`, updates));
|
|
31
|
+
}
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runtime examples use the organization-qualified key passed by examples.yml; products declare their complete project key. */
|
|
36
|
+
function sonarBadge(doc, sonar, example) {
|
|
37
|
+
if (!sonar?.projectKey) return '';
|
|
38
|
+
const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : sonar.host?.replace(/\/$/, '');
|
|
39
|
+
if (!host) return '';
|
|
40
|
+
const organization = sonar.organization ?? doc?.ci?.vars?.find(entry => entry.name === sonar.organizationVar)?.value;
|
|
41
|
+
const prefix = example && organization ? `${organization}_` : '';
|
|
42
|
+
const key = encodeURIComponent(prefix + sonar.projectKey);
|
|
43
|
+
return badge('Quality Gate', `${host}/api/project_badges/measure?project=${key}&metric=alert_status`, `${host}/dashboard?id=${key}`);
|
|
44
|
+
}
|
package/sync/stack-images.mjs
CHANGED
|
@@ -51,8 +51,15 @@ function declaredPassEnv(declaration) {
|
|
|
51
51
|
return [...new Set(names)].sort(byCodeUnit);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Release builds select the sole image environment, or production when several environments deploy images. */
|
|
55
|
+
function imageEnvironments(declaration) {
|
|
56
|
+
return Object.fromEntries(Object.entries(declaration?.environments ?? {})
|
|
57
|
+
.filter(([name, environment]) => NAME.test(name) && environmentImages(environment).length > 0)
|
|
58
|
+
.map(([name, environment]) => [name, environmentImages(environment).map(image => image.app)]));
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
/**
|
|
55
|
-
* What the managed
|
|
62
|
+
* What the managed workflows render from the declaration at `root`: { images, passEnv, imageEnvironments } (empty when the app has no
|
|
56
63
|
* declaration, no deploy block or a declaration that does not parse; `starci app check` judges those). `parseYaml(text)` is injected.
|
|
57
64
|
*/
|
|
58
65
|
export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
|
|
@@ -64,7 +71,7 @@ export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
|
|
|
64
71
|
} catch {
|
|
65
72
|
return { images: [], passEnv: [] };
|
|
66
73
|
}
|
|
67
|
-
return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration) };
|
|
74
|
+
return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration) };
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
const envName = service => `IMAGE_${service.toUpperCase().replaceAll('-', '_')}`;
|
|
@@ -109,7 +116,7 @@ function buildPushStep(image, dockerfile) {
|
|
|
109
116
|
* stackPassEnv the env entries of the deploy step for the declared host variables (vars.<NAME>)
|
|
110
117
|
* `dockerfileOf(app)` names the Dockerfile of an app (the sync module knows the layout).
|
|
111
118
|
*/
|
|
112
|
-
export function stackWorkflowVariables({ images = [], passEnv = [] }, project, dockerfileOf) {
|
|
119
|
+
export function stackWorkflowVariables({ images = [], passEnv = [], imageEnvironments: environments = {} }, project, dockerfileOf) {
|
|
113
120
|
const guard = images.length ? '' : block([
|
|
114
121
|
'- name: require a declared image',
|
|
115
122
|
' shell: bash',
|
|
@@ -117,6 +124,7 @@ export function stackWorkflowVariables({ images = [], passEnv = [] }, project, d
|
|
|
117
124
|
]);
|
|
118
125
|
const names = images.map(image => `echo "${image.service}=ghcr.io/\${owner}/${project}-${image.app}" >> "$GITHUB_OUTPUT"`);
|
|
119
126
|
return {
|
|
127
|
+
...releaseImageVariables(environments),
|
|
120
128
|
stackGuard: guard,
|
|
121
129
|
stackNames: block(names, ' '.repeat(10)),
|
|
122
130
|
stackImageSteps: images.map(image => block([...(image.buildArgs ? buildArgsStep(image) : []), ...buildPushStep(image, dockerfileOf(image.app))])).join(''),
|
|
@@ -125,3 +133,21 @@ export function stackWorkflowVariables({ images = [], passEnv = [] }, project, d
|
|
|
125
133
|
stackPassEnv: block(passEnv.map(name => `${name}: \${{ vars.${name} }}`), ' '.repeat(10)),
|
|
126
134
|
};
|
|
127
135
|
}
|
|
136
|
+
|
|
137
|
+
/** The matrix uses the deploy verb only for apps the selected environment deploys; undeployed images retain Dockerfile defaults. */
|
|
138
|
+
function releaseImageVariables(environments) {
|
|
139
|
+
const names = Object.keys(environments);
|
|
140
|
+
const selectedApp = names.map(name => `(env.STACK_ENVIRONMENT == '${name}' && contains(fromJSON('${JSON.stringify(environments[name])}'), matrix.name))`).join(' || ') || 'false';
|
|
141
|
+
const multiple = names.length > 1;
|
|
142
|
+
const environment = multiple ? 'production' : names[0] ?? 'production';
|
|
143
|
+
return {
|
|
144
|
+
imageEnvironment: environment,
|
|
145
|
+
imageJobEnvironment: names.length ? `\n environment: ${environment}` : '',
|
|
146
|
+
imageEnvironmentGuard: multiple ? block([
|
|
147
|
+
'- name: require a declared image environment',
|
|
148
|
+
` if: \${{ !contains(fromJSON('${JSON.stringify(names)}'), env.STACK_ENVIRONMENT) }}`,
|
|
149
|
+
' run: \'echo "::error::STACKS_DEPLOY_ENV_UNKNOWN: selected environment declares no deploy images"; exit 1\'',
|
|
150
|
+
]) : '',
|
|
151
|
+
imageBuildArgsIf: `\${{ ${selectedApp} }}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -15,43 +15,51 @@ concurrency:
|
|
|
15
15
|
cancel-in-progress: false
|
|
16
16
|
|
|
17
17
|
jobs:
|
|
18
|
-
|
|
18
|
+
fast:
|
|
19
|
+
timeout-minutes: 20
|
|
19
20
|
runs-on: ubuntu-latest
|
|
20
|
-
|
|
21
|
-
permissions:
|
|
22
|
-
contents: read
|
|
23
|
-
id-token: write
|
|
24
|
-
env:
|
|
25
|
-
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
21
|
+
{{sonarEnv}}
|
|
26
22
|
steps:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
with:
|
|
30
|
-
node-version: {{nodeMajor}}
|
|
31
|
-
cache: npm
|
|
32
|
-
- run: npm ci --ignore-scripts
|
|
23
|
+
{{> app/ci-workflows/install-steps.yml}}
|
|
24
|
+
{{> app/ci-workflows/lint-base.yml}}
|
|
33
25
|
- name: production dependency audit
|
|
34
|
-
run:
|
|
35
|
-
|
|
36
|
-
run: {{ciLintSonar}}
|
|
26
|
+
run: {{auditCommand}}
|
|
27
|
+
{{ciLintSteps}}
|
|
37
28
|
- name: format
|
|
38
29
|
run: npm run format:check
|
|
39
30
|
- name: typecheck
|
|
40
31
|
run: npm run typecheck
|
|
32
|
+
|
|
33
|
+
test:
|
|
34
|
+
timeout-minutes: 45
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
# Codecov OIDC authentication requires a job-scoped identity token.
|
|
37
|
+
permissions:
|
|
38
|
+
contents: read
|
|
39
|
+
id-token: write
|
|
40
|
+
steps:
|
|
41
|
+
{{> app/ci-workflows/install-steps.yml}}
|
|
42
|
+
{{> app/ci-workflows/jest-cache.yml}}
|
|
41
43
|
- name: unit
|
|
42
|
-
run: npm test -- --ci
|
|
44
|
+
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache
|
|
43
45
|
- name: coverage proof
|
|
44
46
|
run: {{ciCoverageProof}}
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}
|
|
47
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
48
|
+
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
48
49
|
with:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
name: {{lcovArtifact}}
|
|
51
|
+
path: {{lcovReport}}
|
|
52
|
+
{{codecovSteps}}
|
|
53
|
+
|
|
54
|
+
build:
|
|
55
|
+
timeout-minutes: 30
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
steps:
|
|
58
|
+
{{> app/ci-workflows/install-steps.yml}}
|
|
53
59
|
- name: build be
|
|
54
60
|
run: npm run build:be
|
|
55
61
|
- name: build fe
|
|
56
62
|
run: npm run build:fe
|
|
57
|
-
{{
|
|
63
|
+
{{ciBuildEnv}}
|
|
64
|
+
|
|
65
|
+
{{sonarJob}}
|