@starci/hfs 4.5.1 → 4.7.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 +11 -0
- package/ci/cli.mjs +8 -4
- package/ci/local.mjs +146 -0
- 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/candidate-ci.mjs +111 -0
- package/release/gate.mjs +4 -5
- package/release/push.mjs +10 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +9 -4
- package/runtime/knowledge/hfs/ci-standard.yaml +103 -0
- package/runtime/knowledge/hfs/rules.yaml +19 -2
- package/runtime/knowledge/sonar-gate.yaml +2 -2
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +56 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +121 -36
- package/runtime/scripts/hfs/ci-declaration.mjs +10 -1
- package/runtime/scripts/hfs/lcov-merge.mjs +89 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +3 -0
- package/runtime/scripts/hfs/rules/ci-standard.mjs +202 -0
- package/runtime/scripts/hfs/rules/coverage-below.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +14 -5
- package/runtime/scripts/hfs/rules/secrets.mjs +8 -10
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +77 -0
- package/scaffold/app.mjs +5 -4
- package/src/main.mjs +4 -2
- package/sync/ci-commands.mjs +81 -27
- 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/build-cache-save.yml +8 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +52 -12
- package/templates/app/ci-workflows/install-steps.yml +3 -2
- package/templates/app/ci-workflows/jest-cache-save.yml +6 -0
- package/templates/app/ci-workflows/jest-cache.yml +4 -4
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +53 -14
- package/templates/app/hooks/husky/pre-push +4 -2
- package/templates/app/skeleton/.github/dependabot.yml +1 -2
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -2
- package/release/ci-red.mjs +0 -70
package/sync/ci-commands.mjs
CHANGED
|
@@ -92,14 +92,14 @@ function codecovSteps(lcovReport, codecov) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
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) {
|
|
95
|
+
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
96
96
|
if (!sonar) return '';
|
|
97
97
|
const condition = sonar.optional ? SONAR_CONDITION.replace(' }}', " && steps.sonar-availability.outputs.up == 'true' }}") : SONAR_CONDITION;
|
|
98
98
|
const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
|
|
99
99
|
const header = [
|
|
100
100
|
' sonar:',
|
|
101
|
-
' needs: [
|
|
102
|
-
|
|
101
|
+
' needs: [lint, coverage]',
|
|
102
|
+
` timeout-minutes: ${timeout}`,
|
|
103
103
|
' runs-on: ubuntu-latest',
|
|
104
104
|
' env:',
|
|
105
105
|
` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
|
|
@@ -116,8 +116,10 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
|
|
|
116
116
|
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
117
117
|
` if: ${condition}`,
|
|
118
118
|
' with:',
|
|
119
|
-
'
|
|
119
|
+
' pattern: sonar-lint-*',
|
|
120
120
|
' path: reports',
|
|
121
|
+
' merge-multiple: true',
|
|
122
|
+
...sonarMergeSteps(condition),
|
|
121
123
|
' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
|
|
122
124
|
` if: ${condition}`,
|
|
123
125
|
' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
|
|
@@ -131,7 +133,7 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
|
|
|
131
133
|
.replaceAll('{{nodeMajor}}', nodeMajor)
|
|
132
134
|
.replace('{{sonarJobHeader}}', () => header)
|
|
133
135
|
.replace('{{sonarJobSteps}}', () => steps)
|
|
134
|
-
.trimEnd();
|
|
136
|
+
.trimEnd() + '\n';
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
/** The app source reader is be-relative; the authored manifest lives one directory above that side. */
|
|
@@ -158,29 +160,82 @@ function mergeBuildValues(values, entries) {
|
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
/**
|
|
162
|
-
function
|
|
163
|
+
/** Image values own their keys; the build-fe gate supplies only missing keys, and sealed members stay unopened. */
|
|
164
|
+
function imageBuildEnv(image, gates) {
|
|
165
|
+
const values = new Map();
|
|
166
|
+
mergeBuildValues(values, image?.buildArgs?.values);
|
|
167
|
+
const fallback = new Map();
|
|
168
|
+
for (const gate of gates.filter(entry => entry.name === 'build-fe')) mergeBuildValues(fallback, gate.env);
|
|
169
|
+
for (const [key, value] of fallback) if (!values.has(key)) values.set(key, value);
|
|
170
|
+
if (!values.size) return '';
|
|
171
|
+
return [' env:', ...[...values.keys()].sort(byCodeUnit).map(key => ' ' + key + ': ' + JSON.stringify(values.get(key)))].join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 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. */
|
|
175
|
+
function frontendBuild(source, app) {
|
|
163
176
|
const declaration = buildDeclaration(source);
|
|
164
177
|
const environments = Object.keys(source?.stack?.imageEnvironments ?? {});
|
|
165
178
|
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);
|
|
179
|
+
const images = Object.values(declaration.environments?.[environment]?.deploy?.images ?? {});
|
|
180
|
+
const frontend = images.filter(image => app?.sides.fe.apps.some(entry => entry.name === image.app));
|
|
169
181
|
const gates = declaration.release?.gates ?? [];
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
182
|
+
const singleEnv = imageBuildEnv(frontend[0], gates);
|
|
183
|
+
if (frontend.length <= 1) return { env: singleEnv, steps: [' - name: build fe', ' run: npm run build:fe', singleEnv].filter(Boolean).join('\n') };
|
|
184
|
+
const steps = frontend.map(image => [
|
|
185
|
+
' - name: build fe ' + image.app,
|
|
186
|
+
' run: npm run codegen --silent && npx --no-install turbo run build --filter=./fe/apps/' + image.app,
|
|
187
|
+
imageBuildEnv(image, gates),
|
|
188
|
+
].filter(Boolean).join('\n')).join('\n');
|
|
189
|
+
return { env: singleEnv, steps };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The per-side lint reports (one artifact per lint matrix side) join into the one reports/lint.sonar.json the Sonar import reads, even when one side wrote none. */
|
|
193
|
+
function sonarMergeSteps(condition) {
|
|
194
|
+
return [
|
|
195
|
+
' - name: merge Sonar lint reports',
|
|
196
|
+
` if: ${condition}`,
|
|
197
|
+
' run: |',
|
|
198
|
+
" node --input-type=module <<'NODE'",
|
|
199
|
+
" import fs from 'node:fs';",
|
|
200
|
+
" const files = ['reports/lint.be.sonar.json', 'reports/lint.fe.sonar.json'];",
|
|
201
|
+
" const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
|
|
202
|
+
' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
|
|
203
|
+
' const issues = reports.flatMap(report => report.issues);',
|
|
204
|
+
" fs.mkdirSync('reports', { recursive: true });",
|
|
205
|
+
" fs.writeFileSync('reports/lint.sonar.json', JSON.stringify({ rules, issues }, null, 2) + '\\n');",
|
|
206
|
+
' NODE',
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const STANDARD = new URL('../runtime/knowledge/hfs/ci-standard.yaml', import.meta.url);
|
|
211
|
+
/** The per-job settings of the managed CI: ci.jobs.<job>.timeoutMinutes and ci.jobs.test.shards of the manifest over the defaults of the CI standard. */
|
|
212
|
+
function ciJobSettings(declaration) {
|
|
213
|
+
const defaults = parseYaml(fs.readFileSync(STANDARD, 'utf8')).defaults;
|
|
214
|
+
const jobs = declaration?.ci?.jobs ?? {};
|
|
215
|
+
const timeout = (job) => String(jobs[job]?.timeoutMinutes ?? defaults.timeoutMinutes[job]);
|
|
216
|
+
const shards = Number(jobs.test?.shards ?? defaults.shards);
|
|
217
|
+
return {
|
|
218
|
+
timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
|
|
219
|
+
timeoutBuild: timeout('build'), timeoutSonar: timeout('sonar'), timeoutDbTypes: timeout('db-types'),
|
|
220
|
+
testShards: `[${Array.from({ length: shards }, (_, index) => index + 1).join(', ')}]`, testShardTotal: String(shards),
|
|
221
|
+
};
|
|
173
222
|
}
|
|
174
223
|
|
|
175
224
|
/** 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' } = {}) {
|
|
225
|
+
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
|
|
177
226
|
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
178
227
|
const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
|
|
179
228
|
const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
|
|
180
229
|
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
181
230
|
const lcovArtifact = 'coverage-lcov';
|
|
231
|
+
const build = frontendBuild(source, app);
|
|
232
|
+
const settings = ciJobSettings(buildDeclaration(source));
|
|
233
|
+
const side = '${{ matrix.side }}';
|
|
182
234
|
return {
|
|
183
|
-
|
|
235
|
+
...settings,
|
|
236
|
+
lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
|
|
237
|
+
ciBuildEnv: build.env,
|
|
238
|
+
ciBuildSteps: build.steps,
|
|
184
239
|
ciLint: exampleCommand(source, 'npm run lint'),
|
|
185
240
|
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
186
241
|
ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
@@ -189,29 +244,28 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
189
244
|
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
190
245
|
sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
|
|
191
246
|
sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
|
|
247
|
+
// One lint job per side (the lint matrix): with a Sonar token each side writes its own report, uploaded as sonar-lint-<side> and merged by the sonar job.
|
|
192
248
|
ciLintSteps: [
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
199
|
-
]),
|
|
249
|
+
' - name: lint',
|
|
250
|
+
...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
|
|
251
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
|
|
252
|
+
' env:',
|
|
253
|
+
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
200
254
|
...(sonar ? [
|
|
201
255
|
' - name: lint with Sonar import',
|
|
202
256
|
" if: env.SONAR_TOKEN != ''",
|
|
203
|
-
` run: ${exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json')}`,
|
|
257
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
|
|
204
258
|
' env:',
|
|
205
259
|
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
206
260
|
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
207
|
-
|
|
261
|
+
` if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.${side}.sonar.json') != ''`,
|
|
208
262
|
' with:',
|
|
209
|
-
|
|
210
|
-
|
|
263
|
+
` name: sonar-lint-${side}`,
|
|
264
|
+
` path: reports/lint.${side}.sonar.json`,
|
|
211
265
|
] : []),
|
|
212
266
|
].join('\n'),
|
|
213
267
|
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
214
|
-
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
|
|
268
|
+
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
|
|
215
269
|
lcovArtifact,
|
|
216
270
|
};
|
|
217
271
|
}
|
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
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
- name: build cache save
|
|
2
|
+
if: always()
|
|
3
|
+
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
4
|
+
with:
|
|
5
|
+
path: |
|
|
6
|
+
.turbo
|
|
7
|
+
fe/apps/*/.next/cache
|
|
8
|
+
key: ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
name: ci
|
|
3
3
|
|
|
4
|
+
# The release candidate ref first (starci app release push waits for this run of the release sha before the branch and the tag move), then the tag.
|
|
4
5
|
on:
|
|
5
6
|
push:
|
|
7
|
+
branches: ["release/candidate-*"]
|
|
6
8
|
tags: ["v*"]
|
|
7
9
|
workflow_dispatch:
|
|
8
10
|
|
|
@@ -14,24 +16,58 @@ concurrency:
|
|
|
14
16
|
group: ci-${{ github.ref }}
|
|
15
17
|
cancel-in-progress: false
|
|
16
18
|
|
|
19
|
+
# The job shape of the StarCi CI standard (knowledge/hfs/ci-standard.yaml): fast, lint per side, the unit run in shards, one coverage merge, build,
|
|
20
|
+
# and sonar. Timeouts and the shard count come from ci.jobs of .starcistacks/application-stacks.yaml (defaults in the standard).
|
|
17
21
|
jobs:
|
|
18
22
|
fast:
|
|
19
|
-
timeout-minutes:
|
|
23
|
+
timeout-minutes: {{timeoutFast}}
|
|
20
24
|
runs-on: ubuntu-latest
|
|
21
|
-
{{sonarEnv}}
|
|
22
25
|
steps:
|
|
23
26
|
{{> app/ci-workflows/install-steps.yml}}
|
|
24
|
-
{{> app/ci-workflows/lint-base.yml}}
|
|
25
27
|
- name: production dependency audit
|
|
26
28
|
run: {{auditCommand}}
|
|
27
|
-
{{ciLintSteps}}
|
|
28
29
|
- name: format
|
|
29
30
|
run: npm run format:check
|
|
30
31
|
- name: typecheck
|
|
31
32
|
run: npm run typecheck
|
|
32
33
|
|
|
34
|
+
lint:
|
|
35
|
+
timeout-minutes: {{timeoutLint}}
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
{{sonarEnv}}
|
|
38
|
+
strategy:
|
|
39
|
+
fail-fast: false
|
|
40
|
+
matrix:
|
|
41
|
+
side: [be, fe]
|
|
42
|
+
steps:
|
|
43
|
+
{{> app/ci-workflows/install-steps.yml}}
|
|
44
|
+
{{> app/ci-workflows/lint-base.yml}}
|
|
45
|
+
{{ciLintSteps}}
|
|
46
|
+
|
|
33
47
|
test:
|
|
34
|
-
timeout-minutes:
|
|
48
|
+
timeout-minutes: {{timeoutTest}}
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
strategy:
|
|
51
|
+
fail-fast: false
|
|
52
|
+
matrix:
|
|
53
|
+
shard: {{testShards}}
|
|
54
|
+
steps:
|
|
55
|
+
{{> app/ci-workflows/install-steps.yml}}
|
|
56
|
+
{{> app/ci-workflows/jest-cache.yml}}
|
|
57
|
+
# Each shard measures without the per-file threshold (a shard loads only its share of the specs, and a small suite may leave a shard empty);
|
|
58
|
+
# the coverage job proves 100 per file on the merged report.
|
|
59
|
+
- name: unit
|
|
60
|
+
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
|
|
61
|
+
{{> app/ci-workflows/jest-cache-save.yml}}
|
|
62
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
63
|
+
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
64
|
+
with:
|
|
65
|
+
name: coverage-shard-${{ matrix.shard }}
|
|
66
|
+
path: {{lcovReport}}
|
|
67
|
+
|
|
68
|
+
coverage:
|
|
69
|
+
needs: test
|
|
70
|
+
timeout-minutes: {{timeoutCoverage}}
|
|
35
71
|
runs-on: ubuntu-latest
|
|
36
72
|
# Codecov OIDC authentication requires a job-scoped identity token.
|
|
37
73
|
permissions:
|
|
@@ -39,11 +75,16 @@ jobs:
|
|
|
39
75
|
id-token: write
|
|
40
76
|
steps:
|
|
41
77
|
{{> app/ci-workflows/install-steps.yml}}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
79
|
+
with:
|
|
80
|
+
pattern: coverage-shard-*
|
|
81
|
+
path: {{lcovShardsDir}}
|
|
82
|
+
- name: merge coverage
|
|
83
|
+
run: npx starci app coverage merge --out {{lcovReport}} {{lcovShardsDir}}
|
|
45
84
|
- name: coverage proof
|
|
46
85
|
run: {{ciCoverageProof}}
|
|
86
|
+
env:
|
|
87
|
+
NODE_OPTIONS: --max-old-space-size=6144
|
|
47
88
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
48
89
|
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
49
90
|
with:
|
|
@@ -52,14 +93,13 @@ jobs:
|
|
|
52
93
|
{{codecovSteps}}
|
|
53
94
|
|
|
54
95
|
build:
|
|
55
|
-
timeout-minutes:
|
|
96
|
+
timeout-minutes: {{timeoutBuild}}
|
|
56
97
|
runs-on: ubuntu-latest
|
|
57
98
|
steps:
|
|
58
99
|
{{> app/ci-workflows/install-steps.yml}}
|
|
59
100
|
- name: build be
|
|
60
101
|
run: npm run build:be
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
{{ciBuildEnv}}
|
|
102
|
+
{{ciBuildSteps}}
|
|
103
|
+
{{> app/ci-workflows/build-cache-save.yml}}
|
|
64
104
|
|
|
65
105
|
{{sonarJob}}
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
with:
|
|
11
11
|
path: node_modules
|
|
12
12
|
key: ${{ runner.os }}-node{{nodeMajor}}-modules-${{ hashFiles('package-lock.json') }}
|
|
13
|
-
|
|
13
|
+
# The build cache is restored in every job and saved by the build job always (build-cache-save.yml), keyed by the lockfile and the run.
|
|
14
|
+
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
14
15
|
with:
|
|
15
16
|
path: |
|
|
16
17
|
.turbo
|
|
17
18
|
fe/apps/*/.next/cache
|
|
18
|
-
key: ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-${{ github.
|
|
19
|
+
key: ${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-${{ github.run_id }}
|
|
19
20
|
restore-keys: |
|
|
20
21
|
${{ runner.os }}-node{{nodeMajor}}-build-${{ hashFiles('package-lock.json') }}-
|
|
21
22
|
${{ runner.os }}-node{{nodeMajor}}-build-
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
- name: Jest cache save
|
|
2
|
+
if: always()
|
|
3
|
+
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
4
|
+
with:
|
|
5
|
+
path: .jest-cache
|
|
6
|
+
key: ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-shard${{ matrix.shard }}-${{ github.run_id }}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
- name: Jest cache
|
|
2
|
-
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
1
|
+
- name: Jest cache restore
|
|
2
|
+
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
3
3
|
with:
|
|
4
4
|
path: .jest-cache
|
|
5
|
-
key: ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-${{ github.
|
|
5
|
+
key: ${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-shard${{ matrix.shard }}-${{ github.run_id }}
|
|
6
6
|
restore-keys: |
|
|
7
|
+
${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-shard${{ matrix.shard }}-
|
|
7
8
|
${{ runner.os }}-node{{nodeMajor}}-jest-${{ hashFiles('package-lock.json') }}-
|
|
8
|
-
${{ runner.os }}-node{{nodeMajor}}-jest-
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
name: ci
|
|
3
3
|
|
|
4
|
+
# The release candidate ref first (starci app release push waits for this run of the release sha before the branch and the tag move), then the tag.
|
|
4
5
|
on:
|
|
5
6
|
push:
|
|
7
|
+
branches: ["release/candidate-*"]
|
|
6
8
|
tags: ["v*"]
|
|
7
9
|
workflow_dispatch:
|
|
8
10
|
|
|
@@ -14,24 +16,58 @@ concurrency:
|
|
|
14
16
|
group: ci-${{ github.ref }}
|
|
15
17
|
cancel-in-progress: false
|
|
16
18
|
|
|
19
|
+
# The job shape of the StarCi CI standard (knowledge/hfs/ci-standard.yaml): fast, lint per side, the unit run in shards, one coverage merge, build,
|
|
20
|
+
# sonar, and the database types of the lite edition. Timeouts and the shard count come from ci.jobs of .starcistacks/application-stacks.yaml (defaults in the standard).
|
|
17
21
|
jobs:
|
|
18
22
|
fast:
|
|
19
|
-
timeout-minutes:
|
|
23
|
+
timeout-minutes: {{timeoutFast}}
|
|
20
24
|
runs-on: ubuntu-latest
|
|
21
|
-
{{sonarEnv}}
|
|
22
25
|
steps:
|
|
23
26
|
{{> app/ci-workflows/install-steps.yml}}
|
|
24
|
-
{{> app/ci-workflows/lint-base.yml}}
|
|
25
27
|
- name: production dependency audit
|
|
26
28
|
run: {{auditCommand}}
|
|
27
|
-
{{ciLintSteps}}
|
|
28
29
|
- name: format
|
|
29
30
|
run: npm run format:check
|
|
30
31
|
- name: typecheck
|
|
31
32
|
run: npm run typecheck
|
|
32
33
|
|
|
34
|
+
lint:
|
|
35
|
+
timeout-minutes: {{timeoutLint}}
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
{{sonarEnv}}
|
|
38
|
+
strategy:
|
|
39
|
+
fail-fast: false
|
|
40
|
+
matrix:
|
|
41
|
+
side: [be, fe]
|
|
42
|
+
steps:
|
|
43
|
+
{{> app/ci-workflows/install-steps.yml}}
|
|
44
|
+
{{> app/ci-workflows/lint-base.yml}}
|
|
45
|
+
{{ciLintSteps}}
|
|
46
|
+
|
|
33
47
|
test:
|
|
34
|
-
timeout-minutes:
|
|
48
|
+
timeout-minutes: {{timeoutTest}}
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
strategy:
|
|
51
|
+
fail-fast: false
|
|
52
|
+
matrix:
|
|
53
|
+
shard: {{testShards}}
|
|
54
|
+
steps:
|
|
55
|
+
{{> app/ci-workflows/install-steps.yml}}
|
|
56
|
+
{{> app/ci-workflows/jest-cache.yml}}
|
|
57
|
+
# Each shard measures without the per-file threshold (a shard loads only its share of the specs, and a small suite may leave a shard empty);
|
|
58
|
+
# the coverage job proves 100 per file on the merged report.
|
|
59
|
+
- name: unit
|
|
60
|
+
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}} --passWithNoTests --coverageThreshold={}
|
|
61
|
+
{{> app/ci-workflows/jest-cache-save.yml}}
|
|
62
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
63
|
+
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
64
|
+
with:
|
|
65
|
+
name: coverage-shard-${{ matrix.shard }}
|
|
66
|
+
path: {{lcovReport}}
|
|
67
|
+
|
|
68
|
+
coverage:
|
|
69
|
+
needs: test
|
|
70
|
+
timeout-minutes: {{timeoutCoverage}}
|
|
35
71
|
runs-on: ubuntu-latest
|
|
36
72
|
# Codecov OIDC authentication requires a job-scoped identity token.
|
|
37
73
|
permissions:
|
|
@@ -39,11 +75,16 @@ jobs:
|
|
|
39
75
|
id-token: write
|
|
40
76
|
steps:
|
|
41
77
|
{{> app/ci-workflows/install-steps.yml}}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
78
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
79
|
+
with:
|
|
80
|
+
pattern: coverage-shard-*
|
|
81
|
+
path: {{lcovShardsDir}}
|
|
82
|
+
- name: merge coverage
|
|
83
|
+
run: npx starci app coverage merge --out {{lcovReport}} {{lcovShardsDir}}
|
|
45
84
|
- name: coverage proof
|
|
46
85
|
run: {{ciCoverageProof}}
|
|
86
|
+
env:
|
|
87
|
+
NODE_OPTIONS: --max-old-space-size=6144
|
|
47
88
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
48
89
|
if: always() && hashFiles('{{lcovReport}}') != ''
|
|
49
90
|
with:
|
|
@@ -52,22 +93,20 @@ jobs:
|
|
|
52
93
|
{{codecovSteps}}
|
|
53
94
|
|
|
54
95
|
build:
|
|
55
|
-
timeout-minutes:
|
|
96
|
+
timeout-minutes: {{timeoutBuild}}
|
|
56
97
|
runs-on: ubuntu-latest
|
|
57
98
|
steps:
|
|
58
99
|
{{> app/ci-workflows/install-steps.yml}}
|
|
59
100
|
- name: build be
|
|
60
101
|
run: npm run build:be
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
{{ciBuildEnv}}
|
|
102
|
+
{{ciBuildSteps}}
|
|
103
|
+
{{> app/ci-workflows/build-cache-save.yml}}
|
|
64
104
|
- name: database migration checks
|
|
65
105
|
run: {{ciDatabaseLint}}
|
|
66
106
|
|
|
67
107
|
{{sonarJob}}
|
|
68
|
-
|
|
69
108
|
db-types:
|
|
70
|
-
timeout-minutes:
|
|
109
|
+
timeout-minutes: {{timeoutDbTypes}}
|
|
71
110
|
runs-on: ubuntu-latest
|
|
72
111
|
steps:
|
|
73
112
|
{{> app/ci-workflows/install-steps.yml}}
|