@starci/hfs 4.12.2 → 4.13.2

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 (160) hide show
  1. package/CHANGELOG.md +50 -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 +20 -8
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +48 -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 +18 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -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 +33 -0
  107. package/stack/ci-key.d.mts +11 -0
  108. package/stack/ci-key.mjs +44 -0
  109. package/stack/cli.d.mts +7 -1
  110. package/stack/cli.mjs +22 -13
  111. package/stack/config.d.mts +15 -0
  112. package/stack/converge.d.mts +2 -0
  113. package/stack/converge.mjs +1 -1
  114. package/stack/db-phases.d.mts +185 -0
  115. package/stack/db-phases.mjs +346 -0
  116. package/stack/deploy.d.mts +22 -5
  117. package/stack/deploy.mjs +242 -72
  118. package/stack/journal.d.mts +146 -0
  119. package/stack/journal.mjs +191 -0
  120. package/stack/manager-backup.d.mts +54 -0
  121. package/stack/manager-backup.mjs +136 -0
  122. package/stack/notify.d.mts +9 -0
  123. package/stack/notify.mjs +32 -0
  124. package/stack/release-store.d.mts +49 -1
  125. package/stack/release-store.mjs +102 -16
  126. package/stack/remote.d.mts +2 -0
  127. package/stack/remote.mjs +32 -11
  128. package/stack/rollback-guard.d.mts +10 -0
  129. package/stack/rollback-guard.mjs +41 -0
  130. package/stack/server-key.d.mts +2 -0
  131. package/stack/server-key.mjs +1 -1
  132. package/stack/ssh-gate.d.mts +35 -0
  133. package/stack/ssh-gate.mjs +145 -0
  134. package/stack/status.d.mts +51 -0
  135. package/stack/status.mjs +115 -0
  136. package/stack/urgent-floor.d.mts +10 -61
  137. package/stack/urgent-floor.mjs +28 -123
  138. package/stack/verify.mjs +26 -0
  139. package/sync/ci-commands.mjs +10 -5
  140. package/sync/format-worker.d.mts +1 -0
  141. package/sync/format-worker.mjs +17 -0
  142. package/sync/format.d.mts +21 -3
  143. package/sync/format.mjs +99 -7
  144. package/sync/index.d.mts +3 -6
  145. package/sync/index.mjs +12 -12
  146. package/sync/stack-images.d.mts +25 -10
  147. package/sync/stack-images.mjs +215 -54
  148. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  149. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  150. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  151. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  152. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  153. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  154. package/templates/app/gitignore +1 -0
  155. package/templates/app/gitignore-lite +1 -0
  156. package/templates/app/package-scripts/db.json +1 -1
  157. package/templates/app/skeleton/.gitattributes +2 -0
  158. package/templates/app/skeleton-lite/.gitattributes +2 -0
  159. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  160. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
@@ -1764,7 +1764,7 @@ CI_STANDARD:
1764
1764
  CI_TRIGGERS_RELEASE_ONLY:
1765
1765
  title: "Workflow trigger outside R221"
1766
1766
  title_vi: "Workflow có trigger ngoài R221"
1767
- meaning_vi: "Workflow khai báo trigger ngoài R221: main được phép trong workflow runtime và ci.yml của app; các nhánh, sự kiện và bộ lọc khác phải theo R221."
1767
+ meaning_vi: "Workflow khai báo trigger ngoài R221: main được phép trong workflow runtime và ci.yml của app; pull_request tới main chỉ được phép trong ci.yml, workflow_call dành cho workflow dùng lại; pull_request_target, workflow_run, lịch chạy và các nhánh, sự kiện, bộ lọc khác phải theo R221."
1768
1768
  causes_vi:
1769
1769
  - "Khai báo nhánh, sự kiện hoặc bộ lọc không thuộc R221"
1770
1770
  - "Khai báo nhánh, sự kiện hoặc bộ lọc không thuộc R221"
@@ -3180,7 +3180,7 @@ HFS_APP_LEXICON:
3180
3180
  HFS_APP_WORKFLOW_TRIGGER:
3181
3181
  title: "App workflow trigger outside R251"
3182
3182
  title_vi: "Workflow ứng dụng có sự kiện khởi chạy không hợp lệ"
3183
- meaning_vi: "Workflow ứng dụng khai báo trigger ngoài R251; ci.yml được chạy trên main và nhánh release candidate, các workflow khác giữ phạm vi trigger của R251."
3183
+ meaning_vi: "Workflow ứng dụng khai báo trigger ngoài R251; ci.yml được chạy trên main, nhánh release candidate và pull_request tới main (quyền chỉ đọc, không gắn environment), images.yml và stack-deploy.yml là workflow dùng lại (workflow_call), các workflow khác giữ phạm vi trigger của R251."
3184
3184
  causes_vi:
3185
3185
  - "Khai báo trigger không thuộc R251"
3186
3186
  nextStep_vi: "Sửa trigger theo R251 và chạy starci app sync --execute."
@@ -3351,7 +3351,7 @@ HFS_DB_DEPLOY_UNGUARDED:
3351
3351
  title_vi: "Triển khai cơ sở dữ liệu chấp nhận ref không phải tag"
3352
3352
  meaning_vi: "Mỗi đường chạy triển khai migration phải từ chối ref không phải tag phát hành trước khi tác động cơ sở dữ liệu."
3353
3353
  causes_vi:
3354
- - "Workflow db-deploy thiếu chốt kiểm tag hoặc chốt không chặn đường chạy migration theo luật R271."
3354
+ - "Workflow db-deploy thiếu chốt kiểm tag hoặc chốt không chặn đường chạy migration theo luật R271 (app lite không còn db-deploy: migration chạy trong bước migrate của stack deploy)."
3355
3355
  nextStep_vi: "Thêm chốt kiểm ref tag trước mỗi bước triển khai migration, rồi chứng minh ref nhánh bị từ chối."
3356
3356
  owner: op-retry
3357
3357
  kind: check-finding
@@ -3653,6 +3653,37 @@ HFS_FORBIDDEN_PRESENT:
3653
3653
  owner: op-retry
3654
3654
  kind: check-finding
3655
3655
 
3656
+ HFS_CHECK_CACHE_FORGED:
3657
+ title: "A committed check cache record disagrees with what the check computes"
3658
+ title_vi: "Bản ghi cache kiểm tra đã commit không khớp kết quả kiểm tra thật"
3659
+ meaning_vi: "`<file>`: bản ghi trong `.starcistacks/cache/` có khóa khớp cây hiện tại nhưng phát hiện (findings) của nó khác với kết quả mà bộ kiểm tra tính ra từ cache tại base tin cậy. CI chỉ tin cache như nó đang có ở base, nên bản ghi này bị sửa tay hoặc một bước kiểm tra không xác định (khác nhau giữa các máy)."
3660
+ causes_vi:
3661
+ - "Sửa tay một tệp trong `.starcistacks/cache/` (xóa phát hiện để qua mặt kiểm tra)"
3662
+ - "Một bước kiểm tra cho kết quả khác nhau trên các máy khác nhau"
3663
+ nextStep_vi: "Chạy lại `starci app check` trên cây sạch để sinh lại cache rồi commit; không sửa tay thư mục cache. Lỗi này chặn ở mọi số lượng."
3664
+ owner: op-retry
3665
+ kind: check-finding
3666
+
3667
+ HFS_CHECK_CACHE_STALE:
3668
+ title: "A changed file has no current record in the committed check cache"
3669
+ title_vi: "Tệp đã đổi nhưng cache kiểm tra đã commit chưa có bản ghi hiện hành"
3670
+ meaning_vi: "`<file>` đổi so với base tin cậy mà `.starcistacks/cache/` đã commit không có bản ghi khớp nội dung hiện tại. CI vẫn kiểm tra đầy đủ tệp này nên kết quả đúng, chỉ tốn thời gian (cảnh báo)."
3671
+ causes_vi:
3672
+ - "Quên chạy `starci app check` và commit cache sau khi sửa"
3673
+ nextStep_vi: "Chạy `starci app check` rồi commit `.starcistacks/cache/` cùng thay đổi."
3674
+ owner: op-retry
3675
+ kind: check-finding
3676
+
3677
+ HFS_CHECK_CACHE_MISSING:
3678
+ title: "The app commits no check cache"
3679
+ title_vi: "Ứng dụng chưa commit cache kiểm tra"
3680
+ meaning_vi: "`.starcistacks/cache/` không có bản ghi nào nên mọi lần chạy đều kiểm tra đầy đủ (cảnh báo, kết quả vẫn đúng)."
3681
+ causes_vi:
3682
+ - "Ứng dụng mới hoặc vừa nâng cấp, chưa chạy `starci app check` lần đầu"
3683
+ nextStep_vi: "Chạy `starci app check` một lần rồi commit `.starcistacks/cache/` để các lần sau chỉ kiểm tra phần thay đổi."
3684
+ owner: op-retry
3685
+ kind: check-finding
3686
+
3656
3687
  HFS_FORMAT:
3657
3688
  title: "Prettier is the only formatter"
3658
3689
  title_vi: "Chưa format bằng Prettier"
@@ -4026,7 +4057,7 @@ HFS_README_WORK_POINTER_MISSING:
4026
4057
  HFS_RELEASE_TAG_MISMATCH:
4027
4058
  title: "Product release tag does not match its version or image tag"
4028
4059
  title_vi: "Tag phát hành sản phẩm không khớp phiên bản hoặc tag image"
4029
- meaning_vi: "Tag phát hành phải có dạng v cộng phiên bản package.json; workflow images.yml phải dùng tag phát hành đó cho image trong lite và full."
4060
+ meaning_vi: "Tag phát hành phải có dạng v cộng phiên bản package.json; workflow images.yml đẩy image với tag bất biến sha-<commit>, còn tên phát hành v<phiên bản> chỉ là retag của digest đó trên lần chạy tag phát hành, sau một chốt kiểm ref."
4030
4061
  causes_vi:
4031
4062
  - "Phiên bản package và tag khác nhau hoặc image dùng tag khác theo luật R271."
4032
4063
  nextStep_vi: "Đồng bộ phiên bản package và tag phát hành, rồi dùng cùng tag trong workflow xây image."
@@ -5541,6 +5572,17 @@ BE_ENTITY_SCHEMA_DRIFT:
5541
5572
  owner: op-retry
5542
5573
  kind: check-finding
5543
5574
 
5575
+ BE_ENTITY_EXTENDS_BASE:
5576
+ title: "An entity repeats the columns every table shares instead of extending the base"
5577
+ title_vi: "Entity lặp lại các cột mọi bảng đều có thay vì kế thừa lớp nền"
5578
+ meaning_vi: "File `<path>` khai một lớp @Entity tự map cột created_at hoặc updated_at do cơ sở dữ liệu đặt (CreateDateColumn, UpdateDateColumn, hoặc Column với insert false, update false hay default) và có thể cả khóa uuid được sinh ra bên cạnh cả hai."
5579
+ causes_vi:
5580
+ - "Vi phạm R342 (BE-PERSISTENCE-3, phán quyết entity-base): các cột mọi bảng cùng có được kế thừa, không lặp lại trong từng entity."
5581
+ - "Entity được viết tay hoặc sao chép từ entity cũ trước khi có AbstractEntity và UuidAbstractEntity trong platform/database."
5582
+ nextStep_vi: "Xóa các thuộc tính đó và khai `extends UuidAbstractEntity` (có khóa uuid id) hoặc `extends AbstractEntity` (không có), import từ `@modules/platform/database`. Bảng có timestamp do ứng dụng đặt hoặc thiếu một trong hai cột thì không kế thừa gì và map cột đó bằng `@Column` thường."
5583
+ owner: op-retry
5584
+ kind: check-finding
5585
+
5544
5586
  BE_AUTH_PASSPORT_OWNER:
5545
5587
  title: "An identity library is imported outside integrations/passport"
5546
5588
  title_vi: "Thư viện định danh bị import ngoài integrations/passport"
@@ -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'}
@@ -607,6 +633,7 @@ $defs:
607
633
  uniqueItems: true
608
634
  items: {type: string, pattern: '^[a-z][a-z0-9-]{0,40}$'}
609
635
  backup: {$ref: '#/$defs/deployBackup'}
636
+ notify: {$ref: '#/$defs/deployNotify'}
610
637
  registry: {$ref: '#/$defs/deployRegistry'}
611
638
  ingress:
612
639
  description: The shared host ingress (stack-ingress-config.mjs is the authority for the fields; starci app check R294 judges them).
@@ -824,6 +851,27 @@ $defs:
824
851
  type: array
825
852
  minItems: 1
826
853
  items: {$ref: '#/$defs/deployBackupVolume'}
854
+ deployNotify:
855
+ description: >-
856
+ One webhook message per deploy outcome (accepted, rolled-back, rollback-failed, failed; `refused` only when listed): the outcome, the environment, the commit, the tag, the
857
+ release and the attempt, as JSON with a `text` and a `content` line so a chat webhook shows it. Off unless declared. The URL is a sealed value of the environment
858
+ (https only), never written here; a failing webhook is a line of the result and never fails the deploy.
859
+ type: object
860
+ additionalProperties: false
861
+ required: [webhook]
862
+ properties:
863
+ webhook:
864
+ type: object
865
+ additionalProperties: false
866
+ required: [member, key]
867
+ properties:
868
+ member: {$ref: '#/$defs/deployName'}
869
+ key: {$ref: '#/$defs/deployKey'}
870
+ on:
871
+ type: array
872
+ uniqueItems: true
873
+ minItems: 1
874
+ items: {enum: [accepted, rolled-back, rollback-failed, failed, refused]}
827
875
  deployRegistry:
828
876
  description: >-
829
877
  The image registry and the visibility the app's packages are meant to have. The canon scaffold writes `public`; the value then belongs to the app.
@@ -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
  }