@starci/hfs 4.6.0 → 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 +6 -0
- package/ci/cli.mjs +2 -0
- package/ci/local.mjs +146 -0
- 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/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +24 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +113 -30
- 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/workflow-contexts.mjs +1 -1
- package/scaffold/app.mjs +3 -2
- package/src/main.mjs +4 -2
- package/sync/ci-commands.mjs +41 -25
- package/templates/app/ci-workflows/build-cache-save.yml +8 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +49 -9
- 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 +50 -10
- package/templates/app/hooks/husky/pre-push +4 -2
- package/release/ci-red.mjs +0 -102
package/scaffold/app.mjs
CHANGED
|
@@ -300,9 +300,10 @@ export function peerDependencies(pkg, pins, pairs = parseYaml(fs.readFileSync(PE
|
|
|
300
300
|
/**
|
|
301
301
|
* The fe-stack core packages one version of which the whole install resolves (npm `overrides`, at their canon pins): a
|
|
302
302
|
* transitive dependency otherwise brings its own (zustand 4 under @xyflow/react, dayjs under typeorm, a hoisted newer zod
|
|
303
|
-
* that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version.
|
|
303
|
+
* that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version. @graphql-tools/utils is the one security override: @nestjs/graphql 13.x
|
|
304
|
+
* pulls <=12.0.0 (GHSA-7mx3-vvmw-hjmv), and the pin's fixed 12.x closes the advisory until @nestjs/graphql 14 is adopted (then drop it; canon-pins.yaml says why).
|
|
304
305
|
*/
|
|
305
|
-
const ROOT_OVERRIDES = Object.freeze(['@xyflow/react', 'dayjs', 'zod', 'zustand']);
|
|
306
|
+
const ROOT_OVERRIDES = Object.freeze(['@graphql-tools/utils', '@xyflow/react', 'dayjs', 'zod', 'zustand']);
|
|
306
307
|
|
|
307
308
|
/**
|
|
308
309
|
* The root package.json of a new app, the monorepo root: name, workspaces, npm, its edition's dependencies with their runtime
|
package/src/main.mjs
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
// starci app sync (--check | --write) [--cwd <dir>] the managed files of the root (scripts, husky, CI, .gitignore block, sonar, prettier) and
|
|
30
30
|
// of each side (tsconfig, eslint, jest, stylelint); sync/cli.mjs
|
|
31
31
|
// starci app hygiene the pre-commit guard: staged .starciwork and .starcistacks paths, and the secrets guard over every staged file (read from the index); sync/cli.mjs
|
|
32
|
+
// starci app coverage merge --out <lcov> <lcov-or-dir>... the lcov reports of the sharded unit run merged into one (coverage/cli.mjs; the managed CI coverage job)
|
|
32
33
|
// starci app new service <dir> <name> [--inject <Decorator>=<module>:<Type> | <Class>=<module>]... [--cwd <dir>]
|
|
33
34
|
// a back-end `<name>.service.ts` and its `<name>.service.spec.ts` skeleton (scaffold/service.mjs; <dir> is
|
|
34
35
|
// relative to be/): the spec is built
|
|
@@ -89,7 +90,7 @@ import { dbPostCutoverMain } from '../db/post-cutover.mjs';
|
|
|
89
90
|
import { dbDumpMain } from '../db/dump.mjs';
|
|
90
91
|
import { dbResetMain } from '../db/reset.mjs';
|
|
91
92
|
|
|
92
|
-
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene']);
|
|
93
|
+
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage']);
|
|
93
94
|
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
|
|
94
95
|
|
|
95
96
|
const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
|
|
@@ -128,6 +129,7 @@ starci app ci var set <NAME> [--env E] [--value v] [--repo owner/name] [--cwd <d
|
|
|
128
129
|
starci app ci var sync [--execute] [--repo owner/name] [--cwd <dir>]
|
|
129
130
|
starci app ci verify [--json] [--repo owner/name] [--cwd <dir>]
|
|
130
131
|
starci app hygiene [--cwd <dir>]
|
|
132
|
+
starci app coverage merge --out <lcov> <lcov-or-dir>... [--cwd <dir>]
|
|
131
133
|
`;
|
|
132
134
|
const PER_CODE_LIMIT = 25;
|
|
133
135
|
const VALUE_FLAGS = new Set(['--base', '--inject', '--into', '--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--coverage-from', '--registry-visibility']);
|
|
@@ -419,7 +421,7 @@ async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr
|
|
|
419
421
|
return run(argv, { cwd, out, err });
|
|
420
422
|
}
|
|
421
423
|
|
|
422
|
-
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain]]);
|
|
424
|
+
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)]]);
|
|
423
425
|
const PARSED_VERBS = new Map([['check', checkMain], ['emit', emitMain], ['new', newMain], ['add', addMain], ['scaffold', scaffoldMain], ['explain', explainMain]]);
|
|
424
426
|
|
|
425
427
|
function dispatchMain(verb, argv, io) {
|
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',
|
|
@@ -187,11 +189,11 @@ function frontendBuild(source, app) {
|
|
|
187
189
|
return { env: singleEnv, steps };
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
/**
|
|
191
|
-
function sonarMergeSteps() {
|
|
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) {
|
|
192
194
|
return [
|
|
193
195
|
' - name: merge Sonar lint reports',
|
|
194
|
-
|
|
196
|
+
` if: ${condition}`,
|
|
195
197
|
' run: |',
|
|
196
198
|
" node --input-type=module <<'NODE'",
|
|
197
199
|
" import fs from 'node:fs';",
|
|
@@ -205,6 +207,20 @@ function sonarMergeSteps() {
|
|
|
205
207
|
];
|
|
206
208
|
}
|
|
207
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
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
208
224
|
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
209
225
|
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app } = {}) {
|
|
210
226
|
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
@@ -213,7 +229,11 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
213
229
|
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
214
230
|
const lcovArtifact = 'coverage-lcov';
|
|
215
231
|
const build = frontendBuild(source, app);
|
|
232
|
+
const settings = ciJobSettings(buildDeclaration(source));
|
|
233
|
+
const side = '${{ matrix.side }}';
|
|
216
234
|
return {
|
|
235
|
+
...settings,
|
|
236
|
+
lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
|
|
217
237
|
ciBuildEnv: build.env,
|
|
218
238
|
ciBuildSteps: build.steps,
|
|
219
239
|
ciLint: exampleCommand(source, 'npm run lint'),
|
|
@@ -224,32 +244,28 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
224
244
|
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
225
245
|
sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
|
|
226
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.
|
|
227
248
|
ciLintSteps: [
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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',
|
|
254
|
+
...(sonar ? [
|
|
255
|
+
' - name: lint with Sonar import',
|
|
256
|
+
" if: env.SONAR_TOKEN != ''",
|
|
257
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
|
|
232
258
|
' env:',
|
|
233
259
|
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
234
|
-
]),
|
|
235
|
-
...(sonar ? [
|
|
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(),
|
|
244
260
|
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
245
|
-
|
|
261
|
+
` if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.${side}.sonar.json') != ''`,
|
|
246
262
|
' with:',
|
|
247
|
-
|
|
248
|
-
|
|
263
|
+
` name: sonar-lint-${side}`,
|
|
264
|
+
` path: reports/lint.${side}.sonar.json`,
|
|
249
265
|
] : []),
|
|
250
266
|
].join('\n'),
|
|
251
267
|
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
252
|
-
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
|
|
268
|
+
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
|
|
253
269
|
lcovArtifact,
|
|
254
270
|
};
|
|
255
271
|
}
|
|
@@ -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,9 +75,12 @@ 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}}
|
|
47
86
|
env:
|
|
@@ -54,12 +93,13 @@ jobs:
|
|
|
54
93
|
{{codecovSteps}}
|
|
55
94
|
|
|
56
95
|
build:
|
|
57
|
-
timeout-minutes:
|
|
96
|
+
timeout-minutes: {{timeoutBuild}}
|
|
58
97
|
runs-on: ubuntu-latest
|
|
59
98
|
steps:
|
|
60
99
|
{{> app/ci-workflows/install-steps.yml}}
|
|
61
100
|
- name: build be
|
|
62
101
|
run: npm run build:be
|
|
63
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,9 +75,12 @@ 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}}
|
|
47
86
|
env:
|
|
@@ -54,19 +93,20 @@ jobs:
|
|
|
54
93
|
{{codecovSteps}}
|
|
55
94
|
|
|
56
95
|
build:
|
|
57
|
-
timeout-minutes:
|
|
96
|
+
timeout-minutes: {{timeoutBuild}}
|
|
58
97
|
runs-on: ubuntu-latest
|
|
59
98
|
steps:
|
|
60
99
|
{{> app/ci-workflows/install-steps.yml}}
|
|
61
100
|
- name: build be
|
|
62
101
|
run: npm run build:be
|
|
63
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
108
|
db-types:
|
|
69
|
-
timeout-minutes:
|
|
109
|
+
timeout-minutes: {{timeoutDbTypes}}
|
|
70
110
|
runs-on: ubuntu-latest
|
|
71
111
|
steps:
|
|
72
112
|
{{> app/ci-workflows/install-steps.yml}}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
# refs/backup/* allowed: the coordinator backup namespace
|
|
5
5
|
# refs/heads/main, refs/tags/v* allowed only when HEAD carries an annotated tag v[0-9]* and the release cut recorded the
|
|
6
6
|
# full suite of HEAD: <git common dir>/starci-release/<HEAD sha>.l4.json names that head and tag
|
|
7
|
+
# refs/heads/release/candidate-* the release candidate (starci app release push sends the release sha there first and moves main
|
|
8
|
+
# and the tag only when its ci.yml run is green): allowed under the same release gate, never deleted
|
|
7
9
|
# any other ref, a delete refused
|
|
8
10
|
common=$(git rev-parse --git-common-dir)
|
|
9
11
|
case "$common" in /* | [A-Za-z]:*) ;; *) common="$(pwd)/$common" ;; esac
|
|
@@ -12,7 +14,7 @@ record="$common/starci-release/$head.l4.json"
|
|
|
12
14
|
status=0
|
|
13
15
|
gate=""
|
|
14
16
|
refuse() {
|
|
15
|
-
echo "RIGHTS_PUSH_NOT_RELEASE: $1 - the allowed path is the release cut (main
|
|
17
|
+
echo "RIGHTS_PUSH_NOT_RELEASE: $1 - the allowed path is the release cut (main, v* tags and the release/candidate-* ref) or refs/backup/* (backups)" >&2
|
|
16
18
|
status=1
|
|
17
19
|
}
|
|
18
20
|
# gate: the verdict of the release gate for HEAD, computed once: "ok" or the reason it is closed.
|
|
@@ -33,7 +35,7 @@ release_gate() {
|
|
|
33
35
|
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
34
36
|
case "$remote_ref" in
|
|
35
37
|
refs/backup/*) ;;
|
|
36
|
-
refs/heads/main | refs/tags/v*)
|
|
38
|
+
refs/heads/main | refs/tags/v* | refs/heads/release/candidate-*)
|
|
37
39
|
case "$local_sha" in
|
|
38
40
|
*[!0]*) ;;
|
|
39
41
|
*) refuse "refusing to delete $remote_ref"; continue ;;
|
package/release/ci-red.mjs
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
// ci-red.mjs - the latest completed default-branch CI is a release precondition; unreadable GitHub warns, a red run needs the owner's SHA-bound override.
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { refusal } from '../stack/errors.mjs';
|
|
5
|
-
|
|
6
|
-
const FIELDS = 'databaseId,headSha,status,conclusion,url,event,createdAt';
|
|
7
|
-
const SHA = /^[a-f0-9]{40}$/u;
|
|
8
|
-
const RED = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'action_required']);
|
|
9
|
-
|
|
10
|
-
/** Every process uses the supplied seam; an unavailable executable is an unreadable result. */
|
|
11
|
-
function run(spawn, cwd, command, args) {
|
|
12
|
-
try { return spawn(command, args, { cwd }); } catch { return { status: null }; }
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function runsOf(result) {
|
|
16
|
-
if (result.error || result.status !== 0) return null;
|
|
17
|
-
try {
|
|
18
|
-
const rows = JSON.parse(result.stdout);
|
|
19
|
-
return Array.isArray(rows) ? rows : null;
|
|
20
|
-
} catch { return null; }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Resolve each available default-branch reference once, including HEAD in detached release worktrees. */
|
|
24
|
-
function referenceTips(spawn, cwd, defaultBranch) {
|
|
25
|
-
const refs = [`origin/${defaultBranch}`, defaultBranch, 'HEAD'];
|
|
26
|
-
return refs.flatMap((name) => {
|
|
27
|
-
const result = run(spawn, cwd, 'git', ['rev-parse', '--verify', '--quiet', `${name}^{commit}`]);
|
|
28
|
-
const tip = String(result.stdout ?? '').trim();
|
|
29
|
-
return !result.error && result.status === 0 && SHA.test(tip) ? [{ name, tip }] : [];
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Equality proves reachability directly; ancestry is unknown only when every available probe errors. */
|
|
34
|
-
function pushAncestry(spawn, cwd, row, refs) {
|
|
35
|
-
if (refs.some((ref) => ref.tip === row.headSha)) return { candidate: true, unknown: false };
|
|
36
|
-
let known = false;
|
|
37
|
-
for (const ref of refs) {
|
|
38
|
-
const result = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, ref.tip]);
|
|
39
|
-
if (!result.error && result.status === 0) return { candidate: true, unknown: false };
|
|
40
|
-
if (!result.error && result.status === 1) known = true;
|
|
41
|
-
}
|
|
42
|
-
return { candidate: false, unknown: !known };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Branch rows remain candidates; push rows must reach a resolved reference, and each databaseId counts once. */
|
|
46
|
-
function candidateRuns(spawn, cwd, branch, pushes, refs) {
|
|
47
|
-
const candidates = branch.filter((row) => row && SHA.test(row.headSha));
|
|
48
|
-
let ancestryUnknown = refs.length === 0;
|
|
49
|
-
for (const row of pushes) {
|
|
50
|
-
if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
|
|
51
|
-
const ancestry = pushAncestry(spawn, cwd, row, refs);
|
|
52
|
-
if (ancestry.candidate) candidates.push(row);
|
|
53
|
-
if (ancestry.unknown) ancestryUnknown = true;
|
|
54
|
-
}
|
|
55
|
-
const unique = new Map();
|
|
56
|
-
for (const row of candidates) if (!unique.has(row.databaseId)) unique.set(row.databaseId, row);
|
|
57
|
-
return { candidates: [...unique.values()], ancestryUnknown };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Reads branch runs plus push/tag runs whose commits are reachable from the default branch; the newest completed run decides, even if a newer run is pending. */
|
|
61
|
-
export function latestDefaultBranchCi({ spawn, cwd, defaultBranch = 'main' }) {
|
|
62
|
-
const refs = referenceTips(spawn, cwd, defaultBranch);
|
|
63
|
-
const unknown = (why) => ({ state: 'unknown', why: `${why} (refs: ${refs.map((ref) => ref.name).join(', ') || 'none'})` });
|
|
64
|
-
const args = ['run', 'list', '--workflow', 'ci.yml'];
|
|
65
|
-
const tail = ['--limit', '20', '--json', FIELDS];
|
|
66
|
-
const branch = runsOf(run(spawn, cwd, 'gh', [...args, '--branch', defaultBranch, ...tail]));
|
|
67
|
-
if (!branch) return unknown('gh branch runs unavailable (missing gh, authentication, or unreadable response)');
|
|
68
|
-
const pushes = runsOf(run(spawn, cwd, 'gh', [...args, '--event', 'push', ...tail]));
|
|
69
|
-
if (!pushes) return unknown('gh push runs unavailable (missing gh, authentication, or unreadable response)');
|
|
70
|
-
const { candidates, ancestryUnknown } = candidateRuns(spawn, cwd, branch, pushes, refs);
|
|
71
|
-
const completed = candidates.filter((row) => row.status === 'completed' && Number.isFinite(Date.parse(row.createdAt)));
|
|
72
|
-
completed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || Number(b.databaseId) - Number(a.databaseId));
|
|
73
|
-
const latest = completed[0];
|
|
74
|
-
if (!latest) return unknown(ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch');
|
|
75
|
-
if (latest.conclusion === 'success') return { state: 'green', run: latest };
|
|
76
|
-
if (RED.has(latest.conclusion)) return { state: 'red', run: latest };
|
|
77
|
-
return { ...unknown('latest completed CI run has no success or red conclusion'), run: latest };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Only a complete starci/ci-override@1 record for this exact commit can excuse a red run. */
|
|
81
|
-
function overrideOf(ctx, sha) {
|
|
82
|
-
const common = run(ctx.spawn, ctx.cwd, 'git', ['rev-parse', '--git-common-dir']);
|
|
83
|
-
if (common.error || common.status !== 0 || !String(common.stdout ?? '').trim()) return null;
|
|
84
|
-
const file = path.join(path.resolve(ctx.cwd, String(common.stdout).trim()), 'starci-release', `${sha}.ci-override.json`);
|
|
85
|
-
try {
|
|
86
|
-
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
87
|
-
if (record.schema !== 'starci/ci-override@1' || record.sha !== sha || typeof record.reason !== 'string' || !record.reason.trim() || typeof record.by !== 'string' || !record.by.trim() || typeof record.at !== 'string' || !Number.isFinite(Date.parse(record.at))) return null;
|
|
88
|
-
return record;
|
|
89
|
-
} catch { return null; }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Warns on unknown CI and refuses red CI unless the owner left a valid override beside the release records. */
|
|
93
|
-
export function requireGreenCi(ctx, ci) {
|
|
94
|
-
if (ci.state === 'unknown') {
|
|
95
|
-
ctx.stderr(`RELEASE_CI_UNKNOWN: ${ci.why}\n`);
|
|
96
|
-
return [];
|
|
97
|
-
}
|
|
98
|
-
if (ci.state !== 'red') return [];
|
|
99
|
-
const record = overrideOf(ctx, ci.run.headSha);
|
|
100
|
-
if (record) return [`RELEASE_CI_RED overridden by the owner: ${record.reason}`];
|
|
101
|
-
throw refusal('RELEASE_CI_RED', `CI run ${ci.run.url} for ${ci.run.headSha} concluded ${ci.run.conclusion}; the owner must use starci release ci-override --sha ${ci.run.headSha} --reason <text>`);
|
|
102
|
-
}
|