@starci/hfs 4.12.0 → 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.
Files changed (152) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +19 -7
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +31 -0
  107. package/stack/cli.d.mts +3 -1
  108. package/stack/cli.mjs +17 -11
  109. package/stack/config.d.mts +6 -0
  110. package/stack/converge.d.mts +2 -0
  111. package/stack/converge.mjs +1 -1
  112. package/stack/db-phases.d.mts +185 -0
  113. package/stack/db-phases.mjs +346 -0
  114. package/stack/deploy.d.mts +18 -4
  115. package/stack/deploy.mjs +235 -70
  116. package/stack/journal.d.mts +127 -0
  117. package/stack/journal.mjs +182 -0
  118. package/stack/manager-backup.d.mts +54 -0
  119. package/stack/manager-backup.mjs +136 -0
  120. package/stack/release-store.d.mts +44 -0
  121. package/stack/release-store.mjs +89 -6
  122. package/stack/remote.d.mts +2 -0
  123. package/stack/remote.mjs +31 -11
  124. package/stack/server-key.d.mts +2 -0
  125. package/stack/server-key.mjs +1 -1
  126. package/stack/status.d.mts +49 -0
  127. package/stack/status.mjs +114 -0
  128. package/stack/urgent-floor.d.mts +10 -61
  129. package/stack/urgent-floor.mjs +28 -123
  130. package/stack/verify.mjs +26 -0
  131. package/sync/ci-commands.mjs +10 -5
  132. package/sync/format-worker.d.mts +1 -0
  133. package/sync/format-worker.mjs +17 -0
  134. package/sync/format.d.mts +21 -3
  135. package/sync/format.mjs +99 -7
  136. package/sync/index.d.mts +3 -6
  137. package/sync/index.mjs +12 -12
  138. package/sync/stack-images.d.mts +25 -10
  139. package/sync/stack-images.mjs +215 -54
  140. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  141. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  142. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  143. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  144. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  145. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  146. package/templates/app/gitignore +1 -0
  147. package/templates/app/gitignore-lite +1 -0
  148. package/templates/app/package-scripts/db.json +1 -1
  149. package/templates/app/skeleton/.gitattributes +2 -0
  150. package/templates/app/skeleton-lite/.gitattributes +2 -0
  151. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  152. 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: "fs" | "git";
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: "fs" | "git";
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: "missing" | "installed" | "empty" | "unreadable";
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: "fs" | "git";
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: "fs" | "git";
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: "missing" | "installed" | "empty" | "unreadable";
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 missing = shape.required.filter((id) => !ids.includes(id));
51
- const extra = ids.filter((id) => !shape.required.includes(id) && !shape.optional.includes(id));
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 ${shape.required.join(', ')} (optional ${shape.optional.join(', ')}); ${gaps}`));
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 (/(^|\/)ci\.ya?ml$/.test(file) && !(record(on.push) && Array.isArray(on.push.branches) && on.push.branches.includes(CANDIDATE_BRANCHES))) {
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, /(^|\/)ci\.ya?ml$/.test(file))) {
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
- for (const problem of secretProblems(doc))
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(doc))
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
- if (!guarded || !checkoutPreservesRef(job) || !imageTagIsRelease(step.with?.tags, version))
216
- findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} must build the checked-out release ref and tag every image with v<package.json version> or github.ref_name under a release-tag guard`, { job: name }));
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
- for (const file of files.filter((f) => f.startsWith(prefix))) {
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
- const enabled = (entry) => entry && (entry.if === undefined || entry.if === true || entry.if === 'true') && !entry['continue-on-error'];
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;