@starci/hfs 4.6.0 → 4.7.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 +9 -0
- package/ci/cli.mjs +2 -0
- package/ci/local.mjs +148 -0
- package/coverage/cli.mjs +49 -0
- package/package.json +2 -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 +123 -30
- package/runtime/scripts/hfs/lcov-merge.mjs +134 -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 +42 -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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// ci-standard.mjs - R301 CI_STANDARD: the one StarCi CI standard (knowledge/hfs/ci-standard.yaml, OWNER RULING ci-standard 2026-10-18: ".claude defines ONE standard CI
|
|
2
|
+
// ruleset, its own CI and the examples follow it, and apps inherit it"). This file checks the items the standard marks `check: ci-standard`:
|
|
3
|
+
// job-shape the managed ci.yml holds exactly the edition's jobs; the unit run is in `test`, the proof and the Codecov upload in `coverage` (the one OIDC job)
|
|
4
|
+
// full-history-base-ref every actions/checkout fetches the full history; a job that lints writes STARCI_BASE_REF before it lints
|
|
5
|
+
// heap-per-step every heavy step (heap.steps) declares NODE_OPTIONS --max-old-space-size >= heap.minMiB on the step
|
|
6
|
+
// sonar-lint-per-side a lint with --sonar names --side
|
|
7
|
+
// optional-providers an optional (continue-on-error) Codecov or Sonar step follows its availability probe in the same job
|
|
8
|
+
// test-sharding the test job is a shard matrix; each shard runs --shard=i/N without the per-file threshold; the coverage job merges, then proves
|
|
9
|
+
// lint-parallel the lint job is a matrix over side [be, fe]
|
|
10
|
+
// cache-saved-always no cache keyed by the commit sha; actions/cache/save runs under if: always()
|
|
11
|
+
// timeouts every job declares timeout-minutes
|
|
12
|
+
// sha-pinned-actions every remote `uses:` names a 40-hex commit sha (docker:// a sha256 digest; ./ local actions allowed)
|
|
13
|
+
// `ciStandardFindings({doc, file, scope})` is the pure core: scope `app-ci` (an app's managed .github/workflows/ci.yml: the example apps and every app through the
|
|
14
|
+
// app check) or `runtime` (the runtime repository's own workflows, scripts/hfs/runtime-rules/runtime-ci-standard.mjs), each checking the items whose `covers` names it.
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
17
|
+
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
18
|
+
import { found } from './read.mjs';
|
|
19
|
+
import { appWorkflowFindings } from './app-workflow-findings.mjs';
|
|
20
|
+
import { isPlainObject as record } from '../../../engine/plain-object.mjs';
|
|
21
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
22
|
+
|
|
23
|
+
export const CI_STANDARD = 'CI_STANDARD';
|
|
24
|
+
const STANDARD_FILE = new URL('../../../knowledge/hfs/ci-standard.yaml', import.meta.url);
|
|
25
|
+
const APP_CI = '.github/workflows/ci.yml';
|
|
26
|
+
const SHA_PIN = /@[0-9a-f]{40}$/;
|
|
27
|
+
const DIGEST_PIN = /@sha256:[0-9a-f]{64}$/;
|
|
28
|
+
|
|
29
|
+
let cached = null;
|
|
30
|
+
/** The standard (knowledge/hfs/ci-standard.yaml), read once. */
|
|
31
|
+
function ciStandard() {
|
|
32
|
+
if (!cached) {
|
|
33
|
+
const doc = parseYaml(fs.readFileSync(STANDARD_FILE, 'utf8'));
|
|
34
|
+
if (doc?.schema !== 'starci/ci-standard@1' || !Array.isArray(doc.items)) throw new Error('knowledge/hfs/ci-standard.yaml is not a starci/ci-standard@1 standard');
|
|
35
|
+
cached = doc;
|
|
36
|
+
}
|
|
37
|
+
return cached;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const jobsOf = (doc) => (record(doc?.jobs) ? Object.entries(doc.jobs).filter(([, job]) => record(job)) : []);
|
|
41
|
+
const stepsOf = (job) => (Array.isArray(job.steps) ? job.steps.filter(record) : []);
|
|
42
|
+
const usesOf = (step) => String(step.uses ?? '').trim();
|
|
43
|
+
const runOf = (step) => (typeof step.run === 'string' ? step.run : '');
|
|
44
|
+
|
|
45
|
+
/** The items of the standard this file checks for `scope`: their ids. */
|
|
46
|
+
const checkedItems = (standard, scope) => new Set(standard.items.filter((item) => item.check === 'ci-standard' && item.covers.includes(scope)).map((item) => item.id));
|
|
47
|
+
|
|
48
|
+
function jobShape(doc, edition, standard, say) {
|
|
49
|
+
const shape = standard.jobs[edition] ?? standard.jobs.full;
|
|
50
|
+
const ids = jobsOf(doc).map(([id]) => id);
|
|
51
|
+
const out = [];
|
|
52
|
+
const missing = shape.required.filter((id) => !ids.includes(id));
|
|
53
|
+
const extra = ids.filter((id) => !shape.required.includes(id) && !shape.optional.includes(id));
|
|
54
|
+
const gaps = [missing.length ? 'missing ' + missing.join(', ') : '', extra.length ? 'not in the standard ' + extra.join(', ') : ''].filter(Boolean).join('; ');
|
|
55
|
+
if (gaps) out.push(say('job-shape', `the jobs must be ${shape.required.join(', ')} (optional ${shape.optional.join(', ')}); ${gaps}`));
|
|
56
|
+
const withStep = (match) => jobsOf(doc).filter(([, job]) => stepsOf(job).some((step) => match(step))).map(([id]) => id);
|
|
57
|
+
const unit = withStep((step) => step.name === 'unit');
|
|
58
|
+
const upload = withStep((step) => usesOf(step).startsWith('codecov/codecov-action@'));
|
|
59
|
+
const proof = withStep((step) => /app check --coverage-from/.test(runOf(step)));
|
|
60
|
+
if (unit.join() !== 'test') out.push(say('job-shape', `the unit run must be a step of the test job alone, not of ${unit.join(', ') || 'no job'}`));
|
|
61
|
+
if (proof.join() !== 'coverage') out.push(say('job-shape', `the coverage proof must be a step of the coverage job alone, not of ${proof.join(', ') || 'no job'}`));
|
|
62
|
+
if (upload.length && upload.join() !== 'coverage') out.push(say('job-shape', `the Codecov upload must be a step of the coverage job alone, not of ${upload.join(', ')}`));
|
|
63
|
+
const oidc = jobsOf(doc).filter(([, job]) => job.permissions?.['id-token'] === 'write').map(([id]) => id);
|
|
64
|
+
if (upload.length && oidc.join() !== 'coverage') out.push(say('job-shape', `only the coverage job holds the OIDC id-token (id-token: write), not ${oidc.join(', ') || 'no job'}`));
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function testSharding(doc, say) {
|
|
69
|
+
const test = record(doc.jobs?.test) ? doc.jobs.test : null;
|
|
70
|
+
if (!test) return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
const shards = test.strategy?.matrix?.shard;
|
|
73
|
+
if (!Array.isArray(shards) || !shards.length) out.push(say('test-sharding', 'the test job must be a matrix over shard (ci.jobs.test.shards)'));
|
|
74
|
+
const unit = stepsOf(test).find((step) => step.name === 'unit');
|
|
75
|
+
const total = /--shard=\$\{\{\s*matrix\.shard\s*\}\}\/(\d+)/.exec(unit ? runOf(unit) : '');
|
|
76
|
+
if (unit && !total) out.push(say('test-sharding', 'the unit run must pass --shard=${{ matrix.shard }}/<N>'));
|
|
77
|
+
if (total && Array.isArray(shards) && Number(total[1]) !== shards.length) out.push(say('test-sharding', `the unit run's shard total ${total[1]} must equal the ${shards.length} matrix shards`));
|
|
78
|
+
if (unit && !runOf(unit).includes('--coverageThreshold={}')) out.push(say('test-sharding', 'a shard measures without the per-file threshold (--coverageThreshold={}); the coverage job proves it on the merged report'));
|
|
79
|
+
const coverage = record(doc.jobs?.coverage) ? stepsOf(doc.jobs.coverage) : [];
|
|
80
|
+
const merge = coverage.findIndex((step) => /app coverage merge\b/.test(runOf(step)));
|
|
81
|
+
const proof = coverage.findIndex((step) => /app check --coverage-from/.test(runOf(step)));
|
|
82
|
+
if (merge < 0 || (proof >= 0 && merge > proof)) out.push(say('test-sharding', 'the coverage job must merge the shard reports (starci app coverage merge) before the coverage proof'));
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function lintParallel(doc, say) {
|
|
87
|
+
const sides = record(doc.jobs?.lint) ? doc.jobs.lint.strategy?.matrix?.side : null;
|
|
88
|
+
return Array.isArray(sides) && sides.map(String).sort(byCodeUnit).join() === 'be,fe' ? [] : [say('lint-parallel', 'the lint job must be a matrix over side [be, fe]')];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cacheSavedAlways(doc, say) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
94
|
+
for (const step of stepsOf(job)) {
|
|
95
|
+
const uses = usesOf(step);
|
|
96
|
+
if (/^actions\/cache(?:\/restore)?@/.test(uses) && String(step.with?.key ?? '').includes('github.sha')) out.push(say('cache-saved-always', `job ${id}: a cache keyed by the commit sha never warms; key it by the lockfile hash (and the run) with restore-keys`));
|
|
97
|
+
if (uses.startsWith('actions/cache/save@') && !/^(?:\$\{\{\s*)?always\(\)/.test(String(step.if ?? '').trim())) out.push(say('cache-saved-always', `job ${id}: actions/cache/save runs under if: always(), so a red run still warms the cache`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fullHistory(doc, say) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
106
|
+
for (const step of stepsOf(job)) {
|
|
107
|
+
if (usesOf(step).startsWith('actions/checkout@') && String(step.with?.['fetch-depth']) !== '0') out.push(say('full-history-base-ref', `job ${id}: actions/checkout must fetch the full history (fetch-depth: 0)`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
111
|
+
const steps = stepsOf(job);
|
|
112
|
+
const base = steps.findIndex((step) => /STARCI_BASE_REF=.*>>\s*"?\$GITHUB_ENV"?/.test(runOf(step)));
|
|
113
|
+
const lint = steps.findIndex((step) => /\bnpm run lint\b|\bstarci app lint\b/.test(runOf(step)));
|
|
114
|
+
if (lint >= 0 && (base < 0 || base > lint)) out.push(say('full-history-base-ref', `job ${id} must write STARCI_BASE_REF to GITHUB_ENV before its first lint step`));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function heapPerStep(doc, standard, say) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
122
|
+
for (const step of stepsOf(job)) {
|
|
123
|
+
const run = runOf(step);
|
|
124
|
+
if (!standard.heap.steps.some((needle) => run.includes(needle))) continue;
|
|
125
|
+
const heap = /--max-old-space-size=(\d+)/.exec(String(step.env?.NODE_OPTIONS ?? ''));
|
|
126
|
+
if (!heap || Number(heap[1]) < standard.heap.minMiB) out.push(say('heap-per-step', `job ${id}, step ${step.name ?? run.split('\n')[0].slice(0, 60)}: a heavy step declares NODE_OPTIONS --max-old-space-size=${standard.heap.minMiB} (or more) on the step`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sonarLintPerSide(doc, say) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
135
|
+
for (const step of stepsOf(job)) {
|
|
136
|
+
const run = runOf(step);
|
|
137
|
+
if (/\b(?:npm run lint|starci(?:\.mjs)?["']?\s+app lint|app lint)\b/.test(run) && /\s--sonar\b/.test(run) && !/\s--side\s+(?:be\b|fe\b|\$\{\{\s*matrix\.side\s*\}\})/.test(run)) {
|
|
138
|
+
out.push(say('sonar-lint-per-side', `job ${id}, step ${step.name ?? 'lint'}: a lint with a Sonar import must name its side (--side be|fe) and the reports are merged afterwards; a combined lint with Sonar breaks the standard`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function optionalProviders(doc, say) {
|
|
146
|
+
const out = [];
|
|
147
|
+
const probes = [['codecov/codecov-action@', 'codecov-availability'], ['SonarSource/sonarqube-scan-action@', 'sonar-availability']];
|
|
148
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
149
|
+
const steps = stepsOf(job);
|
|
150
|
+
steps.forEach((step, index) => {
|
|
151
|
+
const probe = probes.find(([prefix]) => usesOf(step).startsWith(prefix));
|
|
152
|
+
if (!probe || step['continue-on-error'] !== true) return;
|
|
153
|
+
if (!steps.slice(0, index).some((earlier) => earlier.id === probe[1])) out.push(say('optional-providers', `job ${id}: the optional ${usesOf(step).split('@')[0]} step must follow its availability probe (id ${probe[1]})`));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function timeouts(doc, say) {
|
|
160
|
+
return jobsOf(doc).filter(([, job]) => !Number.isFinite(Number(job['timeout-minutes'])) || job['timeout-minutes'] === null || job['timeout-minutes'] === '')
|
|
161
|
+
.filter(([, job]) => job.uses === undefined).map(([id]) => say('timeouts', `job ${id} must declare timeout-minutes`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function shaPinned(doc, say) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const [id, job] of jobsOf(doc)) {
|
|
167
|
+
for (const uses of [job.uses, ...stepsOf(job).map((step) => step.uses)].filter((value) => typeof value === 'string')) {
|
|
168
|
+
const ref = uses.trim();
|
|
169
|
+
if (ref.startsWith('./')) continue;
|
|
170
|
+
const pinned = ref.startsWith('docker://') ? DIGEST_PIN.test(ref) : SHA_PIN.test(ref);
|
|
171
|
+
if (!pinned) out.push(say('sha-pinned-actions', `job ${id}: ${ref} must be pinned to a commit sha (a docker:// action to a sha256 digest)`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The R301 findings of one workflow document: scope `app-ci` (with the app's edition) or `runtime`. Pure over the document and the standard. */
|
|
178
|
+
export function ciStandardFindings({ doc, file, scope, edition = 'full', standard = ciStandard() }) {
|
|
179
|
+
if (!record(doc)) return [];
|
|
180
|
+
const items = checkedItems(standard, scope);
|
|
181
|
+
const say = (item, why) => found(CI_STANDARD, file, `${CI_STANDARD} ${file}: [${item}] ${why} (knowledge/hfs/ci-standard.yaml)`, { item });
|
|
182
|
+
const checks = {
|
|
183
|
+
'job-shape': () => jobShape(doc, edition, standard, say),
|
|
184
|
+
'full-history-base-ref': () => fullHistory(doc, say),
|
|
185
|
+
'heap-per-step': () => heapPerStep(doc, standard, say),
|
|
186
|
+
'sonar-lint-per-side': () => sonarLintPerSide(doc, say),
|
|
187
|
+
'optional-providers': () => optionalProviders(doc, say),
|
|
188
|
+
'test-sharding': () => testSharding(doc, say),
|
|
189
|
+
'lint-parallel': () => lintParallel(doc, say),
|
|
190
|
+
'cache-saved-always': () => cacheSavedAlways(doc, say),
|
|
191
|
+
timeouts: () => timeouts(doc, say),
|
|
192
|
+
'sha-pinned-actions': () => shaPinned(doc, say),
|
|
193
|
+
};
|
|
194
|
+
return Object.entries(checks).filter(([id]) => items.has(id)).flatMap(([, check]) => check());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** R301 over an app: its managed .github/workflows/ci.yml (the app check, and the runtime check over every example app). */
|
|
198
|
+
export function ciStandardAppFindings(ctx) {
|
|
199
|
+
const edition = ctx.repo?.edition === 'lite' ? 'lite' : 'full';
|
|
200
|
+
return appWorkflowFindings({ ...ctx, inScope: (file) => file === APP_CI && (ctx.inScope ?? (() => true))(file) },
|
|
201
|
+
({ path: file, text }) => ciStandardFindings({ doc: parseWorkflow(text), file, scope: 'app-ci', edition }), { dedupe: true });
|
|
202
|
+
}
|
|
@@ -18,7 +18,7 @@ const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(va
|
|
|
18
18
|
const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
|
|
19
19
|
|
|
20
20
|
const emptyRecord = (source) => ({ source, lines: new Map(), functions: new Map(), branches: new Map(), summary: {}, problems: [] });
|
|
21
|
-
const addHits = (map, key, hits) => map.set(key, Math.min(Number.MAX_SAFE_INTEGER, (map.get(key) ?? 0) + hits));
|
|
21
|
+
export const addHits = (map, key, hits) => map.set(key, Math.min(Number.MAX_SAFE_INTEGER, (map.get(key) ?? 0) + hits));
|
|
22
22
|
const parseProblem = (record, line, tag) => record.problems.push(`invalid ${tag} at lcov line ${line}`);
|
|
23
23
|
|
|
24
24
|
function lineData(record, value, line) {
|
|
@@ -68,18 +68,27 @@ function hasAgeIdentity(job, doc) {
|
|
|
68
68
|
return steps.some((step) => strings(step.with).some((value) => reference.test(value)) || (typeof step.run === 'string' && reference.test(executable(step.run))));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** The release candidate branches (OWNER RULING candidate-ci, 2026-10-18): `starci app release push` waits for the ci.yml run of the release sha there. */
|
|
72
|
+
const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
73
|
+
const pushFilterValid = (options) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
74
|
+
&& (options.tags === undefined || (Array.isArray(options.tags) && options.tags.length === 1 && options.tags[0] === 'v*'))
|
|
75
|
+
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length === 1 && options.branches[0] === CANDIDATE_BRANCHES));
|
|
76
|
+
|
|
77
|
+
function triggerFindings(doc, refuse, file = '') {
|
|
72
78
|
const on = workflowEventOptions(doc.on);
|
|
73
79
|
if (!record(on) || !Object.keys(on).length) return [refuse(TRIGGER, 'declare release tags [v*] and/or workflow_dispatch')];
|
|
74
80
|
const findings = [];
|
|
81
|
+
if (/(^|\/)ci\.ya?ml$/.test(file) && !(record(on.push) && Array.isArray(on.push.branches) && on.push.branches.includes(CANDIDATE_BRANCHES))) {
|
|
82
|
+
findings.push(refuse(TRIGGER, `the release CI must run on push of branches ['${CANDIDATE_BRANCHES}']: starci app release push waits for that run (starci app sync renders it)`));
|
|
83
|
+
}
|
|
75
84
|
for (const [event, options] of Object.entries(on)) {
|
|
76
85
|
if (event === 'workflow_dispatch') continue;
|
|
77
86
|
if (event !== 'push') {
|
|
78
|
-
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*] or workflow_dispatch`));
|
|
87
|
+
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches or workflow_dispatch`));
|
|
79
88
|
continue;
|
|
80
89
|
}
|
|
81
|
-
if (!
|
|
82
|
-
findings.push(refuse(TRIGGER,
|
|
90
|
+
if (!pushFilterValid(options)) {
|
|
91
|
+
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; other branch and path filters are forbidden`));
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
return findings;
|
|
@@ -197,7 +206,7 @@ function perWorkflow({ path: file, text }) {
|
|
|
197
206
|
const refuse = (code, message) => found(code, file, `${code} ${file}: ${message}`);
|
|
198
207
|
const doc = parseWorkflow(text);
|
|
199
208
|
if (!record(doc)) return [refuse(TRIGGER, 'the workflow must be valid YAML with a release-only on block'), ...(deployName(file) ? [refuse(DEPLOY, 'the deploy workflow must be valid YAML')] : [])];
|
|
200
|
-
return [...triggerFindings(doc, refuse), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
209
|
+
return [...triggerFindings(doc, refuse, file), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
201
210
|
}
|
|
202
211
|
|
|
203
212
|
/** Synchronous R251 findings over the tracked workflows of a lite/full app. */
|
|
@@ -75,7 +75,7 @@ function expressions(value) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** Source markers travel inside parsed scalar expressions, preserving flow maps, quoted keys and repeated values without matching comments. */
|
|
78
|
-
function workflowContextFindings({ path: file, text }) {
|
|
78
|
+
export function workflowContextFindings({ path: file, text }) {
|
|
79
79
|
const marked = text.replace(/\$\{\{/g, (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
|
|
80
80
|
const findings = [];
|
|
81
81
|
function inspect(value, keys) {
|
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,29 @@ 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
|
+
// Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
|
|
262
|
+
" if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
|
|
246
263
|
' with:',
|
|
247
|
-
|
|
248
|
-
|
|
264
|
+
` name: sonar-lint-${side}`,
|
|
265
|
+
` path: reports/lint.${side}.sonar.json`,
|
|
249
266
|
] : []),
|
|
250
267
|
].join('\n'),
|
|
251
268
|
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
252
|
-
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
|
|
269
|
+
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
|
|
253
270
|
lcovArtifact,
|
|
254
271
|
};
|
|
255
272
|
}
|
|
@@ -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-
|