@starci/hfs 4.12.2 → 4.13.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 +35 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +74 -7
- package/runtime/knowledge/hfs/slots.yaml +19 -7
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +31 -0
- package/stack/cli.d.mts +3 -1
- package/stack/cli.mjs +17 -11
- package/stack/config.d.mts +6 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +18 -4
- package/stack/deploy.mjs +235 -70
- package/stack/journal.d.mts +127 -0
- package/stack/journal.mjs +182 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/release-store.d.mts +44 -0
- package/stack/release-store.mjs +89 -6
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +31 -11
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/status.d.mts +49 -0
- package/stack/status.mjs +114 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
|
@@ -288,6 +288,12 @@ $defs:
|
|
|
288
288
|
enum: [public, private]
|
|
289
289
|
secrets: {type: array, items: {$ref: '#/$defs/ciSecret'}}
|
|
290
290
|
vars: {type: array, items: {$ref: '#/$defs/ciVariable'}}
|
|
291
|
+
imageCache:
|
|
292
|
+
description: >-
|
|
293
|
+
Where the images job keeps its BuildKit layer cache: gha (default, the repository's GitHub Actions cache, one scope per image) or registry (a buildcache tag
|
|
294
|
+
beside the image in the registry, for images large enough to be evicted from the 10 GB Actions cache).
|
|
295
|
+
enum: [gha, registry]
|
|
296
|
+
default: gha
|
|
291
297
|
jobs:
|
|
292
298
|
description: >-
|
|
293
299
|
Per-job settings of the managed CI (the CI standard, knowledge/hfs/ci-standard.yaml defaults): timeoutMinutes per job and the number of unit-run shards.
|
|
@@ -598,6 +604,26 @@ $defs:
|
|
|
598
604
|
description: Host variable names interpolated into the rendered stack (names only).
|
|
599
605
|
type: array
|
|
600
606
|
items: {$ref: '#/$defs/deployKey'}
|
|
607
|
+
auto:
|
|
608
|
+
description: >-
|
|
609
|
+
true: every release tag whose CI is green deploys to this environment by itself, in the order the environments are declared (the owner's yes, given once by this setting;
|
|
610
|
+
ruling app-cicd-auto-deploy). The variable STARCI_DEPLOY_PAUSED=true on the GitHub Environment stops it without a code change. Default false (a manual dispatch deploys).
|
|
611
|
+
type: boolean
|
|
612
|
+
default: false
|
|
613
|
+
approval:
|
|
614
|
+
description: >-
|
|
615
|
+
none (default): the pipeline deploys without a human step. reviewers: the GitHub Environment has required reviewers (a public repository or Enterprise; Free, Pro and Team
|
|
616
|
+
private repositories cannot set them), which the owner configures on the environment.
|
|
617
|
+
enum: [none, reviewers]
|
|
618
|
+
default: none
|
|
619
|
+
platforms:
|
|
620
|
+
description: >-
|
|
621
|
+
The platforms the images are built for. Default [linux/amd64], the architecture of the VPS; add linux/arm64 only when an environment declares a node of that architecture
|
|
622
|
+
(the build then uses QEMU, and the release record keeps the index digest).
|
|
623
|
+
type: array
|
|
624
|
+
uniqueItems: true
|
|
625
|
+
minItems: 1
|
|
626
|
+
items: {enum: [linux/amd64, linux/arm64]}
|
|
601
627
|
verify: {$ref: '#/$defs/deployVerify'}
|
|
602
628
|
jobs: {$ref: '#/$defs/deployJobs'}
|
|
603
629
|
tasks: {$ref: '#/$defs/deployTasks'}
|
|
@@ -50,7 +50,7 @@ export declare function checkHfs(config: HfsConfig): {
|
|
|
50
50
|
violations: ArchitectureViolation[];
|
|
51
51
|
coverage: {
|
|
52
52
|
status: string;
|
|
53
|
-
source: "
|
|
53
|
+
source: "git" | "fs";
|
|
54
54
|
rootEntries: number;
|
|
55
55
|
apps: string[];
|
|
56
56
|
ruleIds: string[];
|
|
@@ -69,7 +69,7 @@ export declare function checkAppRoot({ root, resolver, tree }: {
|
|
|
69
69
|
violations: ArchitectureViolation[];
|
|
70
70
|
coverage: {
|
|
71
71
|
status: string;
|
|
72
|
-
source: "
|
|
72
|
+
source: "git" | "fs";
|
|
73
73
|
hooksPath: {
|
|
74
74
|
status: string;
|
|
75
75
|
reason: string;
|
|
@@ -80,7 +80,7 @@ export declare function checkAppRoot({ root, resolver, tree }: {
|
|
|
80
80
|
hooks?: never;
|
|
81
81
|
} | {
|
|
82
82
|
status: string;
|
|
83
|
-
hooks: "
|
|
83
|
+
hooks: "installed" | "missing" | "empty" | "unreadable";
|
|
84
84
|
reason?: never;
|
|
85
85
|
};
|
|
86
86
|
rootEntries: number;
|
|
@@ -100,7 +100,7 @@ export declare function checkHfsWithoutConfig(repositoryRoot: string): {
|
|
|
100
100
|
violations: ArchitectureViolation[];
|
|
101
101
|
coverage: {
|
|
102
102
|
status: string;
|
|
103
|
-
source: "
|
|
103
|
+
source: "git" | "fs";
|
|
104
104
|
rootEntries: number;
|
|
105
105
|
apps: string[];
|
|
106
106
|
ruleIds: string[];
|
|
@@ -109,7 +109,7 @@ export declare function checkHfsWithoutConfig(repositoryRoot: string): {
|
|
|
109
109
|
violations: ArchitectureViolation[];
|
|
110
110
|
coverage: {
|
|
111
111
|
status: string;
|
|
112
|
-
source: "
|
|
112
|
+
source: "git" | "fs";
|
|
113
113
|
hooksPath: {
|
|
114
114
|
status: string;
|
|
115
115
|
reason: string;
|
|
@@ -120,7 +120,7 @@ export declare function checkHfsWithoutConfig(repositoryRoot: string): {
|
|
|
120
120
|
hooks?: never;
|
|
121
121
|
} | {
|
|
122
122
|
status: string;
|
|
123
|
-
hooks: "
|
|
123
|
+
hooks: "installed" | "missing" | "empty" | "unreadable";
|
|
124
124
|
reason?: never;
|
|
125
125
|
};
|
|
126
126
|
rootEntries: number;
|
|
@@ -113,7 +113,7 @@ export const CHECK_CODES = Object.freeze([
|
|
|
113
113
|
'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'LITE_DB_MIGRATION',
|
|
114
114
|
'HFS_DOCKER_HEALTHCHECK_FAKE', 'DB_APP_ROLE', 'DB_FORCE_RLS_DEFAULT',
|
|
115
115
|
'DB_POLICY_TAUTOLOGY', 'DB_VIEW_UNSAFE', 'DB_STORAGE_PREFIX', 'DB_DEFINER_REVOKE',
|
|
116
|
-
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT',
|
|
116
|
+
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT', 'HFS_CHECK_CACHE_FORGED', 'HFS_CHECK_CACHE_STALE', 'HFS_CHECK_CACHE_MISSING',
|
|
117
117
|
'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
|
|
118
118
|
...A15_CODES,
|
|
119
119
|
...REFUSAL_CODES,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// gate-threshold.mjs - the verdict of `starci app check` and `starci app lint` (owner ruling gate-threshold-20, 2026-10-30): green
|
|
2
2
|
// while fewer than GATE_ERROR_LIMIT error findings remain and none of them is a hard blocker, so a release is not held back finding
|
|
3
|
-
// by finding (5, 4, 3, ...) by the last few structural nits. A plaintext secret, a failed boot proof and a broken migration block at
|
|
3
|
+
// by finding (5, 4, 3, ...) by the last few structural nits. A plaintext secret, a failed boot proof, a forged check cache record and a broken migration block at
|
|
4
4
|
// any count. Every finding is still printed; only the exit code changes. Typecheck, build and the tests are not judged here.
|
|
5
5
|
export const GATE_ERROR_LIMIT = 20;
|
|
6
6
|
/** The limit in force: policy/quality.yaml `gate.errorLimit`, handed in by the harness CLI as STARCI_GATE_ERROR_LIMIT, else 20. */
|
|
@@ -14,6 +14,7 @@ export const HARD_BLOCKERS = Object.freeze(new Set([
|
|
|
14
14
|
'HFS_PLAINTEXT_SECRET', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'RT_SECRET_TRACKED',
|
|
15
15
|
'STACKS_SECRET_ENV_LITERAL', 'APP_SECRET_IN_CI', 'BE_LOG_SECRET',
|
|
16
16
|
'BE_APP_BOOT_PROOF_MISSING',
|
|
17
|
+
'HFS_CHECK_CACHE_FORGED',
|
|
17
18
|
'DB_MIGRATION_SHAPE', 'DB_MIGRATION_DESTRUCTIVE', 'LITE_DB_MIGRATION', 'BE_MIGRATION_REVERSIBLE',
|
|
18
19
|
]));
|
|
19
20
|
export function gateVerdict(errors, limit = gateErrorLimit()) {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Repo } from './rule-types.mjs';
|
|
2
2
|
export declare const BE_APP_BOOT_PROOF_MISSING = "BE_APP_BOOT_PROOF_MISSING";
|
|
3
|
+
/**
|
|
4
|
+
* Runs `work` with the ordinary content finder's boot proof off. The app check cache runs the proof as its own scoped step (`--wiring`,
|
|
5
|
+
* keyed on the back-end side), so the always-run app pass must not compile the Nest graph a second time.
|
|
6
|
+
*/
|
|
7
|
+
export declare function withoutBootProof<T>(work: () => T): T;
|
|
3
8
|
/** Root scopes delegate to the be side, so each app is compiled once. */
|
|
4
9
|
export declare function bootProofFindings({ repoRoot, files, all, repo, isRoot, inScope }: {
|
|
5
10
|
repoRoot: string;
|
|
@@ -3,9 +3,23 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { appWiring } from '../app-wiring.mjs';
|
|
5
5
|
export const BE_APP_BOOT_PROOF_MISSING = 'BE_APP_BOOT_PROOF_MISSING';
|
|
6
|
+
let suppressed = 0;
|
|
7
|
+
/**
|
|
8
|
+
* Runs `work` with the ordinary content finder's boot proof off. The app check cache runs the proof as its own scoped step (`--wiring`,
|
|
9
|
+
* keyed on the back-end side), so the always-run app pass must not compile the Nest graph a second time.
|
|
10
|
+
*/
|
|
11
|
+
export function withoutBootProof(work) {
|
|
12
|
+
suppressed += 1;
|
|
13
|
+
try {
|
|
14
|
+
return work();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
suppressed -= 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
6
20
|
/** Root scopes delegate to the be side, so each app is compiled once. */
|
|
7
21
|
export function bootProofFindings({ repoRoot, files, all = files, repo, isRoot = Boolean(repo?.sides), inScope = () => true }) {
|
|
8
|
-
if (repo?.edition !== 'lite' || isRoot || repo?.profile === 'fe' || repo?.kind === 'fe')
|
|
22
|
+
if (suppressed > 0 || repo?.edition !== 'lite' || isRoot || repo?.profile === 'fe' || repo?.kind === 'fe')
|
|
9
23
|
return [];
|
|
10
24
|
// Dependencies not installed (a fresh scaffold, a lint-only checkout): the Nest graph cannot be compiled, so the proof is unavailable here; CI and every installed app judge it.
|
|
11
25
|
const installed = [repoRoot, path.dirname(repoRoot ?? '.')].some((dir) => dir && fs.existsSync(path.join(dir, 'node_modules')));
|
|
@@ -8,6 +8,8 @@ const DECLARATION = `${STACK_ROOT}/application-stacks.yaml`;
|
|
|
8
8
|
const named = (entry) => typeof entry.name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(entry.name);
|
|
9
9
|
const identity = (kind, name) => `${kind}:${name}`;
|
|
10
10
|
const atCi = (section) => `${DECLARATION}#ci.${section}`;
|
|
11
|
+
/** The variables the managed workflows themselves read; an app declares none of them. STARCI_DEPLOY_PAUSED is the brake of the automatic deploy, set per GitHub environment by the owner. */
|
|
12
|
+
const MANAGED_VARIABLES = ['STARCI_DEPLOY_PAUSED'];
|
|
11
13
|
/** A CI or Dependabot secret must never also live in a sealed app member. */
|
|
12
14
|
function sealedFindings(ci, sealed) {
|
|
13
15
|
const names = new Set([...ci.secrets, ...ci.dependabotSecrets].filter(named).map((entry) => entry.name));
|
|
@@ -44,7 +46,7 @@ function runtimeFindings(doc, ci, sealed) {
|
|
|
44
46
|
/** Workflow reads are class-sensitive; legacy lists remain readable, but an absent ci block always signals migration once per name. */
|
|
45
47
|
function workflowFindings(doc, declared, references) {
|
|
46
48
|
const hasCi = doc.ci !== undefined;
|
|
47
|
-
const allowed = new Set(declared.map(({ kind, name }) => identity(kind, name)));
|
|
49
|
+
const allowed = new Set([...declared.map(({ kind, name }) => identity(kind, name)), ...MANAGED_VARIABLES.map((name) => identity('vars', name))]);
|
|
48
50
|
if (hasCi) {
|
|
49
51
|
for (const entry of ciNamesOfServices(doc).filter(named)) {
|
|
50
52
|
const legacy = list(doc.services?.[entry.service]?.ci?.[`${entry.kind}s`]);
|
|
@@ -47,11 +47,14 @@ function jobShape(doc, edition, standard, say) {
|
|
|
47
47
|
const shape = standard.jobs[edition] ?? standard.jobs.full;
|
|
48
48
|
const ids = jobsOf(doc).map(([id]) => id);
|
|
49
49
|
const out = [];
|
|
50
|
-
const
|
|
51
|
-
const
|
|
50
|
+
const prefix = String(standard.deployJobPrefix ?? 'deploy-');
|
|
51
|
+
const deployIds = ids.filter((id) => id.startsWith(prefix));
|
|
52
|
+
const must = [...shape.required, ...(shape.pipeline ?? [])];
|
|
53
|
+
const missing = must.filter((id) => !ids.includes(id));
|
|
54
|
+
const extra = ids.filter((id) => !must.includes(id) && !shape.optional.includes(id) && !deployIds.includes(id));
|
|
52
55
|
const gaps = [missing.length ? 'missing ' + missing.join(', ') : '', extra.length ? 'not in the standard ' + extra.join(', ') : ''].filter(Boolean).join('; ');
|
|
53
56
|
if (gaps)
|
|
54
|
-
out.push(say('job-shape', `the jobs must be ${
|
|
57
|
+
out.push(say('job-shape', `the jobs must be ${must.join(', ')} (optional ${shape.optional.join(', ')}); ${gaps}`));
|
|
55
58
|
const withStep = (match) => jobsOf(doc).filter(([, job]) => stepsOf(job).some((step) => match(step))).map(([id]) => id);
|
|
56
59
|
const unit = withStep((step) => step.name === 'unit');
|
|
57
60
|
const upload = withStep((step) => usesOf(step).startsWith('codecov/codecov-action@'));
|
|
@@ -65,6 +68,32 @@ function jobShape(doc, edition, standard, say) {
|
|
|
65
68
|
const oidc = jobsOf(doc).filter(([, job]) => job.permissions?.['id-token'] === 'write').map(([id]) => id);
|
|
66
69
|
if (upload.length && oidc.join() !== 'coverage')
|
|
67
70
|
out.push(say('job-shape', `only the coverage job holds the OIDC id-token (id-token: write), not ${oidc.join(', ') || 'no job'}`));
|
|
71
|
+
out.push(...pipelineShape(doc, ids, deployIds, say));
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const needsOf = (job) => (Array.isArray(job?.needs) ? job.needs.map(String) : typeof job?.needs === 'string' ? [job.needs] : []);
|
|
75
|
+
/** The plan, images, verdict and deploy jobs: the verdict judges every other job and always runs, the images are the reusable images.yml, a deploy job calls stack-deploy.yml after the verdict. */
|
|
76
|
+
function pipelineShape(doc, ids, deployIds, say) {
|
|
77
|
+
const out = [];
|
|
78
|
+
const others = ids.filter((id) => id !== 'verdict' && !deployIds.includes(id));
|
|
79
|
+
const verdict = doc.jobs?.verdict;
|
|
80
|
+
if (record(verdict)) {
|
|
81
|
+
const judged = needsOf(verdict);
|
|
82
|
+
const unjudged = others.filter((id) => !judged.includes(id));
|
|
83
|
+
if (unjudged.length)
|
|
84
|
+
out.push(say('job-shape', `the verdict job must need every other job; it does not need ${unjudged.join(', ')}`));
|
|
85
|
+
if (!/^(?:\$\{\{\s*)?always\(\)/.test(String(verdict.if ?? '').trim()))
|
|
86
|
+
out.push(say('job-shape', 'the verdict job runs under if: always(), so a skipped or red job reaches it'));
|
|
87
|
+
}
|
|
88
|
+
if (record(doc.jobs?.images) && doc.jobs.images.uses !== './.github/workflows/images.yml')
|
|
89
|
+
out.push(say('job-shape', 'the images job calls the reusable ./.github/workflows/images.yml'));
|
|
90
|
+
for (const id of deployIds) {
|
|
91
|
+
const job = doc.jobs[id];
|
|
92
|
+
if (!needsOf(job).includes('verdict'))
|
|
93
|
+
out.push(say('job-shape', `job ${id} deploys, so it must need the verdict job`));
|
|
94
|
+
if (job.uses !== './.github/workflows/stack-deploy.yml')
|
|
95
|
+
out.push(say('job-shape', `job ${id} deploys through the reusable ./.github/workflows/stack-deploy.yml, never with steps of its own`));
|
|
96
|
+
}
|
|
68
97
|
return out;
|
|
69
98
|
}
|
|
70
99
|
function testSharding(doc, say) {
|
|
@@ -33,6 +33,20 @@ export declare function databaseSqlTokens(text: string): {
|
|
|
33
33
|
export declare function databaseSqlStatements(tokens: Parsed): any[][];
|
|
34
34
|
/** SQL and PL/pgSQL bodies are scanned; comments, data literals and other function languages are opaque. */
|
|
35
35
|
export declare function databaseExecutableTokens(text: string, depth?: number): any[];
|
|
36
|
+
/**
|
|
37
|
+
* A contract migration must not ship in the same release as the code change it depends on (cicd.md, Database migrations). The deploy applies
|
|
38
|
+
* a contract migration right after the accept, while the release it replaced is still the rollback target: if that release's code still names
|
|
39
|
+
* the destroyed object, a rollback after the contract would run code against a schema that no longer has it. The check judges exactly that
|
|
40
|
+
* with git: each contract migration that is new since the previous release tag (absent there) is read for the objects it destroys, and the
|
|
41
|
+
* be/src sources AS THEY WERE AT THE PREVIOUS RELEASE TAG must not reference them (the sources at HEAD are judged by destructiveMigrationFindings).
|
|
42
|
+
* The first release, a repository without a release tag, or an unreadable git history has no previous release and no finding. It reports
|
|
43
|
+
* DB_MIGRATION_DESTRUCTIVE (R259), a hard blocker: ship the code change in one release and its contract migration in the next.
|
|
44
|
+
*/
|
|
45
|
+
export declare function contractReleaseFindings({ repoRoot, migrations, git }: {
|
|
46
|
+
repoRoot: string;
|
|
47
|
+
migrations: Parsed;
|
|
48
|
+
git: Parsed;
|
|
49
|
+
}): Promise<any>;
|
|
36
50
|
/** Destructive changes require a contract migration and no remaining SQL/entity reference in be/src. */
|
|
37
51
|
export declare function destructiveMigrationFindings({ repoRoot, migrations, files }: {
|
|
38
52
|
repoRoot: string;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { lsTree } from '../../api/git/ls-tree.mjs';
|
|
4
4
|
import { mergeBase } from '../../api/git/merge-base.mjs';
|
|
5
5
|
import { show } from '../../api/git/show.mjs';
|
|
6
|
+
import { tag } from '../../api/git/tag.mjs';
|
|
6
7
|
import { withoutGitLocalEnv } from '../../lib/git.mjs';
|
|
7
8
|
import { eachInOrder, findInOrder } from '../../lib/in-order.mjs';
|
|
8
9
|
import { sameText } from '../../lib/same-text.mjs';
|
|
@@ -37,12 +38,21 @@ export function defaultGit(args, { cwd }) {
|
|
|
37
38
|
const sha = mergeBase(cwd, rest[0], rest[1]);
|
|
38
39
|
return { ok: sha !== null, stdout: sha ?? '' };
|
|
39
40
|
}
|
|
41
|
+
if (verb === 'previous-release')
|
|
42
|
+
return previousReleaseTag(cwd, env, rest[0]);
|
|
40
43
|
let call = show;
|
|
41
44
|
if (verb === 'ls-tree')
|
|
42
45
|
call = lsTree;
|
|
43
46
|
const result = call(rest, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
|
|
44
47
|
return { ok: !result.error && result.status === 0, stdout: result.stdout };
|
|
45
48
|
}
|
|
49
|
+
/** The nearest release tag before HEAD: the newest version among the tags HEAD contains that do not point at HEAD itself. `pattern` is a tag glob (v*). */
|
|
50
|
+
function previousReleaseTag(cwd, env, pattern) {
|
|
51
|
+
const listed = (args) => { const result = tag(args, { cwd, env }); return !result.error && result.status === 0 ? String(result.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean) : []; };
|
|
52
|
+
const here = new Set(listed(['--points-at', 'HEAD']));
|
|
53
|
+
const previous = listed(['--merged', 'HEAD', '--list', pattern, '--sort=-v:refname']).find((name) => !here.has(name));
|
|
54
|
+
return { ok: previous !== undefined, stdout: previous ?? '' };
|
|
55
|
+
}
|
|
46
56
|
const runGit = (git, repoRoot, args) => Promise.resolve(git(args, { cwd: repoRoot }))
|
|
47
57
|
.then((result) => ({ ok: result?.ok === true, stdout: String(result?.stdout ?? '') }));
|
|
48
58
|
async function baseShaOf(git, repoRoot, base) {
|
|
@@ -515,6 +525,51 @@ function destructiveFileFindings(file, text, references) {
|
|
|
515
525
|
return finding ? [finding] : [];
|
|
516
526
|
});
|
|
517
527
|
}
|
|
528
|
+
const BE_SQL_SOURCE = /^be\/src\/.+\.(?:sql|entity)\.[cm]?tsx?$/;
|
|
529
|
+
/** The tag glob of a release (release.tagPrefix defaults to v). */
|
|
530
|
+
const RELEASE_TAG_GLOB = 'v*';
|
|
531
|
+
/**
|
|
532
|
+
* A contract migration must not ship in the same release as the code change it depends on (cicd.md, Database migrations). The deploy applies
|
|
533
|
+
* a contract migration right after the accept, while the release it replaced is still the rollback target: if that release's code still names
|
|
534
|
+
* the destroyed object, a rollback after the contract would run code against a schema that no longer has it. The check judges exactly that
|
|
535
|
+
* with git: each contract migration that is new since the previous release tag (absent there) is read for the objects it destroys, and the
|
|
536
|
+
* be/src sources AS THEY WERE AT THE PREVIOUS RELEASE TAG must not reference them (the sources at HEAD are judged by destructiveMigrationFindings).
|
|
537
|
+
* The first release, a repository without a release tag, or an unreadable git history has no previous release and no finding. It reports
|
|
538
|
+
* DB_MIGRATION_DESTRUCTIVE (R259), a hard blocker: ship the code change in one release and its contract migration in the next.
|
|
539
|
+
*/
|
|
540
|
+
export async function contractReleaseFindings({ repoRoot, migrations, git }) {
|
|
541
|
+
const contracts = migrations.filter((file) => /_contract_[a-z0-9]+(?:[-_][a-z0-9]+)*\.sql$/.test(file));
|
|
542
|
+
if (!contracts.length)
|
|
543
|
+
return [];
|
|
544
|
+
const previous = await runGit(git, repoRoot, ['previous-release', RELEASE_TAG_GLOB]);
|
|
545
|
+
const release = previous.stdout.trim();
|
|
546
|
+
if (!previous.ok || !release)
|
|
547
|
+
return [];
|
|
548
|
+
const known = new Set(await migrationFilesAt(git, repoRoot, release));
|
|
549
|
+
const fresh = contracts.filter((file) => !known.has(file));
|
|
550
|
+
if (!fresh.length)
|
|
551
|
+
return [];
|
|
552
|
+
const listing = await runGit(git, repoRoot, ['ls-tree', '-r', '--name-only', '-z', release, '--', 'be/src']);
|
|
553
|
+
const sources = listing.ok ? listing.stdout.split('\0').filter((file) => BE_SQL_SOURCE.test(file)) : [];
|
|
554
|
+
if (!sources.length)
|
|
555
|
+
return [];
|
|
556
|
+
const ts = loadTypescript(repoRoot, HERE);
|
|
557
|
+
if (!ts)
|
|
558
|
+
return [found(DB_MIGRATION_DESTRUCTIVE, 'hfs.json', 'the contract-release check requires the TypeScript parser to read the be/src references of the previous release')];
|
|
559
|
+
const references = [];
|
|
560
|
+
await eachInOrder(sources, async (file) => {
|
|
561
|
+
references.push({ file, scopes: sourceNameScopes(ts, await baseFileText(git, repoRoot, release, file) ?? '', file) });
|
|
562
|
+
});
|
|
563
|
+
return fresh.flatMap((file) => {
|
|
564
|
+
const text = readText(repoRoot, file);
|
|
565
|
+
if (text === null)
|
|
566
|
+
return [];
|
|
567
|
+
return databaseSqlStatements(databaseExecutableTokens(text)).flatMap(destructiveObjects).flatMap((object) => {
|
|
568
|
+
const reference = references.find(({ scopes }) => referencesObject(scopes, object));
|
|
569
|
+
return reference ? [found(DB_MIGRATION_DESTRUCTIVE, file, `${file}:${object.line} destroys ${object.name}, which the previous release ${release} still references in ${reference.file}; the contract migration runs right after the accept while that release is the rollback target: ship the code change in one release and this contract migration in the next`, { line: object.line, object: object.name, release, reference: reference.file })] : [];
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
}
|
|
518
573
|
/** Destructive changes require a contract migration and no remaining SQL/entity reference in be/src. */
|
|
519
574
|
export function destructiveMigrationFindings({ repoRoot, migrations, files }) {
|
|
520
575
|
const sources = files.filter((file) => /^be\/src\/.+\.(?:sql|entity)\.[cm]?tsx?$/.test(file));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The database rules of a lite app's db/ tree (design 4.2, L02-L09). SQL migrations are parsed with libpg-query through
|
|
2
2
|
// database-sql.mjs; Git history and generated-type checks are split into bounded sibling modules.
|
|
3
3
|
import { found, readJson, readText } from './read.mjs';
|
|
4
|
-
import { defaultGit, migrationShapeFindings } from './database-migrations.mjs';
|
|
4
|
+
import { contractReleaseFindings, defaultGit, migrationShapeFindings } from './database-migrations.mjs';
|
|
5
5
|
import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
|
|
6
6
|
import { migrationSetDefinerFindings } from './database-plpgsql.mjs';
|
|
7
7
|
import { databaseAccessFindings } from './database-access.mjs';
|
|
@@ -66,6 +66,7 @@ export async function checkDatabase({ repoRoot, files, base, git, edition, db })
|
|
|
66
66
|
findings.push(...liteDbPathFindings(files));
|
|
67
67
|
const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
|
|
68
68
|
findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base }));
|
|
69
|
+
findings.push(...await contractReleaseFindings({ repoRoot, migrations, git: git ?? defaultGit }));
|
|
69
70
|
const analyses = await migrationAnalyses({ repoRoot, migrations, exposed: APP_SCHEMAS, forceRls, findings });
|
|
70
71
|
findings.push(...migrationSetPolicyFindings(analyses), ...migrationSetDefinerFindings(analyses, APP_SCHEMAS), ...await databaseAccessFindings({ analyses, exposed: APP_SCHEMAS, db: declared }));
|
|
71
72
|
return findings;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// R251: product workflow custody, immutable dependencies and release-only triggers, in both editions.
|
|
2
|
+
// A release deploys through the reusable stack-deploy.yml: its jobs hold the environment, the age key and the transport; the job of ci.yml that calls it
|
|
3
|
+
// (a deployment caller) holds permissions, concurrency and the environment name it passes. A pull_request run reads and never binds an environment.
|
|
2
4
|
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
3
5
|
import { found } from './read.mjs';
|
|
4
6
|
import { appWorkflowFindings } from './app-workflow-findings.mjs';
|
|
@@ -71,6 +73,7 @@ const deployName = (value) => !/authorize[\s_.-]+deploy[\s_.-]+key/i.test(value)
|
|
|
71
73
|
const materializeCommand = (script) => commands(script).some(({ text }) => /^starci\s+app\s+secret\s+materialize\b/.test(commandBody(text)));
|
|
72
74
|
const DEPLOYMENT_COMMANDS = [
|
|
73
75
|
/^starci\s+(?:app\s+)?(?:deploy|secret\s+materialize)\b/,
|
|
76
|
+
/^(?:npx\s+(?:--no-install\s+)?)?starci\s+app\s+stack\s+(?:deploy|rollback|accept)\b/,
|
|
74
77
|
/^docker\s+stack\s+deploy\b/,
|
|
75
78
|
/^(?:ba?sh\s+)?(?:\.\/)?deploy\//,
|
|
76
79
|
/^(?:ba?sh\s+)?(?:\.\/)?(?:scripts\/)?[\w.-]*deploy[\w.-]*\.sh\b/,
|
|
@@ -92,22 +95,46 @@ const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
|
92
95
|
const pushFilterValid = (options, ci) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
93
96
|
&& (options.tags === undefined || (Array.isArray(options.tags) && options.tags.length === 1 && options.tags[0] === 'v*'))
|
|
94
97
|
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length > 0 && new Set(options.branches).size === options.branches.length && options.branches.every((branch) => branch === CANDIDATE_BRANCHES || (ci && branch === 'main'))));
|
|
98
|
+
const isCi = (file) => /(^|\/)ci\.ya?ml$/.test(file);
|
|
99
|
+
/** A pull request to main in ci.yml: the checks of a change. Read-only workflow permissions, no environment on a job of the file itself (a call of a reusable workflow cannot have one). */
|
|
100
|
+
function pullRequestFindings(doc, options, refuse, file) {
|
|
101
|
+
if (!isCi(file))
|
|
102
|
+
return [refuse(TRIGGER, 'pull_request may start only ci.yml: no other app workflow runs the code of a pull request')];
|
|
103
|
+
const findings = [];
|
|
104
|
+
const filters = record(options) ? options : {};
|
|
105
|
+
const branches = filters.branches;
|
|
106
|
+
if (!Array.isArray(branches) || branches.length !== 1 || branches[0] !== 'main' || Object.keys(filters).some((key) => key !== 'branches'))
|
|
107
|
+
findings.push(refuse(TRIGGER, "pull_request may contain only branches: ['main']"));
|
|
108
|
+
const permissions = doc.permissions;
|
|
109
|
+
const readOnly = record(permissions) && Object.values(permissions).every((value) => value === 'read' || value === 'none');
|
|
110
|
+
if (!readOnly)
|
|
111
|
+
findings.push(refuse(TRIGGER, 'a workflow that runs on pull_request declares read-only workflow permissions (permissions: contents: read); a job that needs more asks for it itself'));
|
|
112
|
+
for (const [name, job] of Object.entries(record(doc.jobs) ? doc.jobs : {})) {
|
|
113
|
+
if (record(job) && job.environment !== undefined)
|
|
114
|
+
findings.push(refuse(TRIGGER, `job ${name} binds an environment in a workflow that runs on pull_request; a pull request run reads no environment secret`));
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
95
118
|
function triggerFindings(doc, refuse, file = '') {
|
|
96
119
|
const on = workflowEventOptions(doc.on);
|
|
97
120
|
if (!record(on) || !Object.keys(on).length)
|
|
98
121
|
return [refuse(TRIGGER, 'declare release tags [v*] and/or workflow_dispatch')];
|
|
99
122
|
const findings = [];
|
|
100
|
-
if (
|
|
123
|
+
if (isCi(file) && !(record(on.push) && Array.isArray(on.push.branches) && on.push.branches.includes(CANDIDATE_BRANCHES))) {
|
|
101
124
|
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)`));
|
|
102
125
|
}
|
|
103
126
|
for (const [event, options] of Object.entries(on)) {
|
|
104
|
-
if (event === 'workflow_dispatch')
|
|
127
|
+
if (event === 'workflow_dispatch' || event === 'workflow_call')
|
|
105
128
|
continue;
|
|
129
|
+
if (event === 'pull_request') {
|
|
130
|
+
findings.push(...pullRequestFindings(doc, options, refuse, file));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
106
133
|
if (event !== 'push') {
|
|
107
|
-
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches, main in ci.yml, or workflow_dispatch`));
|
|
134
|
+
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches, main and pull requests to main in ci.yml, workflow_call or workflow_dispatch`));
|
|
108
135
|
continue;
|
|
109
136
|
}
|
|
110
|
-
if (!pushFilterValid(options,
|
|
137
|
+
if (!pushFilterValid(options, isCi(file))) {
|
|
111
138
|
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; ci.yml may also name main; other branch and path filters are forbidden`));
|
|
112
139
|
}
|
|
113
140
|
}
|
|
@@ -158,6 +185,8 @@ function scriptWritesDotenv(value) {
|
|
|
158
185
|
function writesDotenv(doc) {
|
|
159
186
|
return entries(doc).some(({ key, value }) => key === 'env_file' || key === 'env-file' || (['run', 'script'].includes(key) && typeof value === 'string' && scriptWritesDotenv(value)));
|
|
160
187
|
}
|
|
188
|
+
/** The job of a workflow that calls a reusable workflow of the app (`uses: ./.github/workflows/<file>`): a deployment caller when the callee deploys. */
|
|
189
|
+
const localCall = (job) => typeof job.uses === 'string' && job.uses.startsWith('./.github/workflows/');
|
|
161
190
|
function deploymentJobs(doc, file) {
|
|
162
191
|
const jobs = record(doc.jobs) ? Object.entries(doc.jobs).filter(([, job]) => record(job)) : [];
|
|
163
192
|
const identified = jobs.filter(([name, job]) => deployName(name) || deployName(String(job.name ?? '')) || scripts(job).some(deploymentCommand) || (typeof job.uses === 'string' && deployName(job.uses)));
|
|
@@ -167,7 +196,23 @@ function deploymentJobs(doc, file) {
|
|
|
167
196
|
return jobs;
|
|
168
197
|
return [];
|
|
169
198
|
}
|
|
199
|
+
/** A deployment caller (ci.yml's deploy-<env>): permissions, concurrency and the environment it passes; the called job binds the environment and opens the key. */
|
|
200
|
+
function callerFindings(name, job, doc, refuse) {
|
|
201
|
+
const findings = [];
|
|
202
|
+
const environment = record(job.with) ? job.with.environment : undefined;
|
|
203
|
+
if (job.environment !== undefined)
|
|
204
|
+
findings.push(refuse(DEPLOY, `job ${name} calls a reusable workflow, which cannot bind an environment itself; pass the name as with.environment and let the called job bind it`));
|
|
205
|
+
if (!nonempty(environment))
|
|
206
|
+
findings.push(refuse(DEPLOY, `job ${name} must pass with.environment: the GitHub environment the called deploy job binds`));
|
|
207
|
+
if (!validPermissions(job.permissions ?? doc.permissions))
|
|
208
|
+
findings.push(refuse(DEPLOY, `job ${name} must declare permissions at job or workflow scope`));
|
|
209
|
+
if (!validConcurrency(job.concurrency ?? doc.concurrency))
|
|
210
|
+
findings.push(refuse(DEPLOY, `job ${name} must declare concurrency at job or workflow scope`));
|
|
211
|
+
return findings;
|
|
212
|
+
}
|
|
170
213
|
function deploymentJobFindings(name, job, doc, refuse) {
|
|
214
|
+
if (localCall(job))
|
|
215
|
+
return callerFindings(name, job, doc, refuse);
|
|
171
216
|
const findings = [];
|
|
172
217
|
if (!validEnvironment(job.environment))
|
|
173
218
|
findings.push(refuse(DEPLOY, `job ${name} must declare its protected environment`));
|
|
@@ -205,9 +250,11 @@ function deploymentFindings(doc, file, refuse) {
|
|
|
205
250
|
findings.push(refuse(DEPLOY, 'the deploy workflow must declare a deployment job'));
|
|
206
251
|
for (const [name, job] of deployment)
|
|
207
252
|
findings.push(...deploymentJobFindings(name, job, doc, refuse));
|
|
208
|
-
|
|
253
|
+
// The secrets and dotenv writes are judged where the deploy runs: the workflow-level env and the deployment jobs, not a check job beside them (the Sonar token of ci.yml).
|
|
254
|
+
const judged = { env: doc.env, jobs: Object.fromEntries(deployment) };
|
|
255
|
+
for (const problem of secretProblems(judged))
|
|
209
256
|
findings.push(refuse(DEPLOY, problem));
|
|
210
|
-
if (writesDotenv(
|
|
257
|
+
if (writesDotenv(judged))
|
|
211
258
|
findings.push(refuse(DEPLOY, 'deploy workflows must never write dotenv files or use env_file blobs; materialize sealed custody'));
|
|
212
259
|
return findings;
|
|
213
260
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// R271: a product release keeps its v<semver> tag, root package version, image tags and database deployment ref together.
|
|
1
|
+
// R271: a product release keeps its v<semver> tag, root package version, image tags and database deployment ref together. The pushed images are named by the
|
|
2
|
+
// commit (sha-<sha>); the release name v<version> is a retag of that digest under a release guard. A migration runs in the stack deploy after the release tag.
|
|
2
3
|
// Ordinary untagged development trees have no release version to compare. Workflow checks still run there in both editions.
|
|
3
4
|
// YAML is parsed, so comments and unrelated jobs cannot supply a guard. Shell guards are deliberately a small, explicit form;
|
|
4
5
|
// opaque helper scripts and computed image metadata cannot prove provenance in this static check.
|
|
@@ -171,10 +172,12 @@ function dbJobConstrained(job, guarded) {
|
|
|
171
172
|
constrained = false;
|
|
172
173
|
return constrained;
|
|
173
174
|
}
|
|
175
|
+
// The database deploy workflow is retired in a lite app: its migrations are the migrate phase of the stack deploy (starci app stack deploy, tag-gated by
|
|
176
|
+
// stack-deploy.yml). A full app that keeps its own db-deploy.yml, and a lite app that has not synced yet, are still judged here.
|
|
174
177
|
function dbFindings(file, workflow) {
|
|
175
178
|
const jobs = Object.entries(workflow.jobs ?? {});
|
|
176
179
|
if (!jobs.length)
|
|
177
|
-
return [found(HFS_DB_DEPLOY_UNGUARDED, file, `${file} has no verifiable database deploy job; require a release tag before migration`)];
|
|
180
|
+
return [found(HFS_DB_DEPLOY_UNGUARDED, file, `${file} has no verifiable database deploy job; require a release tag before migration (a lite app's migrations are the migrate phase of the stack deploy: run starci app sync --execute to retire this file)`)];
|
|
178
181
|
const findings = [];
|
|
179
182
|
for (const [name, job] of jobs) {
|
|
180
183
|
if (!job || typeof job !== 'object')
|
|
@@ -203,17 +206,42 @@ function imageTagIsRelease(value, version) {
|
|
|
203
206
|
return SEMVER.test(String(version)) && ref.endsWith(`:v${version}`);
|
|
204
207
|
});
|
|
205
208
|
}
|
|
209
|
+
/** A commit tag: `<repo>:sha-${{ github.sha }}`, immutable and per commit, so any ref may push it. */
|
|
210
|
+
const COMMIT_TAG = /^.+:sha-\$\{\{\s*github\.sha\s*\}\}$/;
|
|
211
|
+
function imageTagsAreCommit(value) {
|
|
212
|
+
if (typeof value !== 'string')
|
|
213
|
+
return false;
|
|
214
|
+
const tags = value.split(/[\n,]+/).map((tag) => tag.trim()).filter(Boolean);
|
|
215
|
+
return tags.length > 0 && tags.every((tag) => COMMIT_TAG.test(tag));
|
|
216
|
+
}
|
|
217
|
+
/** The release guard of a reusable images workflow: a step under `if: ${{ inputs.release }}` whose script rejects a ref that is not refs/tags/v*. */
|
|
218
|
+
function inputReleaseGuard(step) {
|
|
219
|
+
if (typeof step.run !== 'string' || step['continue-on-error'] || !/^\$\{\{\s*inputs\.release\s*\}\}$/.test(String(step.if ?? '').trim()))
|
|
220
|
+
return false;
|
|
221
|
+
const first = step.run.trim().split(/\r?\n/)[0].trim();
|
|
222
|
+
return /^\[\[\s*["']?\$(?:GITHUB_REF|\{GITHUB_REF\})["']?\s*==\s*refs\/tags\/v\*\s*\]\]\s*\|\|\s*exit\s+[1-9]\d*\s*;?$/.test(first);
|
|
223
|
+
}
|
|
224
|
+
/** The release name v<version> is a retag of the commit digest (`imagetools create --tag <repo>:$GITHUB_REF_NAME <repo>:sha-...`): only after the guard, only on a release run. */
|
|
225
|
+
function retagIsGuarded(job, step) {
|
|
226
|
+
const steps = job.steps ?? [];
|
|
227
|
+
const guard = steps.findIndex(inputReleaseGuard);
|
|
228
|
+
return guard >= 0 && guard < steps.indexOf(step) && /^\$\{\{\s*inputs\.release\s*&&/.test(String(step.if ?? '').trim());
|
|
229
|
+
}
|
|
206
230
|
function imageFindings(file, workflow, version) {
|
|
207
231
|
const findings = [];
|
|
208
232
|
let builds = 0;
|
|
209
233
|
for (const [name, job] of Object.entries(workflow.jobs ?? {})) {
|
|
210
234
|
for (const step of job?.steps ?? []) {
|
|
235
|
+
if (typeof step.run === 'string' && /\bimagetools\s+create\b/.test(step.run) && !retagIsGuarded(job, step)) {
|
|
236
|
+
findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} retags an image outside a release guard; put it after a step \`if: \${{ inputs.release }}\` that rejects a ref other than refs/tags/v*, and under \`if: \${{ inputs.release && ... }}\``, { job: name }));
|
|
237
|
+
}
|
|
211
238
|
if (!(step.uses ?? '').startsWith('docker/build-push-action@'))
|
|
212
239
|
continue;
|
|
213
240
|
builds += 1;
|
|
214
241
|
const guarded = tagOnlyTrigger(workflow) || refProof(job.if) === RELEASE_REF || refProof(step.if) === RELEASE_REF;
|
|
215
|
-
|
|
216
|
-
|
|
242
|
+
// An immutable commit tag (sha-<sha>) is pushed by any ref; a release name must come from the checked-out release ref under a guard.
|
|
243
|
+
if (!checkoutPreservesRef(job) || !(imageTagsAreCommit(step.with?.tags) || (guarded && imageTagIsRelease(step.with?.tags, version))))
|
|
244
|
+
findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} must build the checked-out ref and tag every image with sha-\${{ github.sha }} (the release name v<package.json version> is a guarded retag of that digest), or tag with v<version> or github.ref_name under a release-tag guard`, { job: name }));
|
|
217
245
|
}
|
|
218
246
|
}
|
|
219
247
|
if (!builds)
|
|
@@ -20,7 +20,8 @@ const allowedExpressions = (allows) => allows.flatMap((entry) => braceVariants(e
|
|
|
20
20
|
const shapeFinding = (file, message, extra) => found(STACKS_SHAPE, file, message, extra);
|
|
21
21
|
function treeFindings(files, allowed, prefix) {
|
|
22
22
|
const findings = [];
|
|
23
|
-
|
|
23
|
+
// `cache` is reserved: .starcistacks/cache/ is the committed check cache (slot app.starcistacks.cache), written by the CLI, not an environment.
|
|
24
|
+
for (const file of files.filter((f) => f.startsWith(prefix) && !f.startsWith(`${prefix}cache/`))) {
|
|
24
25
|
const rel = file.slice(prefix.length);
|
|
25
26
|
if (/\.enc(?:\.yaml)?$/.test(rel) && !INSIDE_SECRETS.test(rel))
|
|
26
27
|
findings.push(shapeFinding(file, `${file} is a sealed secret outside <env>/secrets/<slug>.enc.yaml; move it to .starcistacks/<env>/secrets/`));
|
|
@@ -34,6 +35,9 @@ function declarationFindings(files, prefix, declaration) {
|
|
|
34
35
|
if (!files.includes(declared))
|
|
35
36
|
return [];
|
|
36
37
|
const findings = [];
|
|
38
|
+
const environments = declaration.doc?.environments;
|
|
39
|
+
if (environments !== null && typeof environments === 'object' && Object.hasOwn(environments, 'cache'))
|
|
40
|
+
findings.push(shapeFinding(declared, `${declared} declares an environment named cache; the name is reserved for the committed check cache (.starcistacks/cache/)`));
|
|
37
41
|
if (declaration.error)
|
|
38
42
|
return [shapeFinding(declared, `${declared} cannot be read: ${declaration.error}`)];
|
|
39
43
|
const services = declaration.doc?.services;
|
|
@@ -5,7 +5,10 @@ const DEPENDABOT = '.github/dependabot.yml';
|
|
|
5
5
|
const CI = '.github/workflows/ci.yml';
|
|
6
6
|
const IMAGES = '.github/workflows/images.yml';
|
|
7
7
|
const ECOSYSTEMS = ['npm', 'github-actions', 'docker'];
|
|
8
|
-
|
|
8
|
+
// A job the plan or the caller decides is no disabled job: a reusable workflow runs a job on `if: ${{ inputs.release }}` whenever its caller asks, and the CI jobs
|
|
9
|
+
// run on `if: ${{ needs.plan.outputs.checks == 'true' }}` on every run that checks (the plan leaves them out only where a green run of the same commit stands in).
|
|
10
|
+
const DECIDED_CONDITION = /^\$\{\{\s*(?:inputs\.[A-Za-z_][\w-]*|needs\.plan\.outputs\.[A-Za-z_]\w*\s*==\s*'true')\s*\}\}$/;
|
|
11
|
+
const enabled = (entry) => entry && (entry.if === undefined || entry.if === true || entry.if === 'true' || (typeof entry.if === 'string' && DECIDED_CONDITION.test(entry.if.trim()))) && !entry['continue-on-error'];
|
|
9
12
|
function shellSeparator(line, index, quote) {
|
|
10
13
|
if (quote)
|
|
11
14
|
return null;
|