@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
|
@@ -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
|
-
}
|