@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.
- package/CHANGELOG.md +50 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +97 -7
- package/runtime/knowledge/hfs/slots.yaml +20 -8
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +33 -0
- package/stack/ci-key.d.mts +11 -0
- package/stack/ci-key.mjs +44 -0
- package/stack/cli.d.mts +7 -1
- package/stack/cli.mjs +22 -13
- package/stack/config.d.mts +15 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +22 -5
- package/stack/deploy.mjs +242 -72
- package/stack/journal.d.mts +146 -0
- package/stack/journal.mjs +191 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/notify.d.mts +9 -0
- package/stack/notify.mjs +32 -0
- package/stack/release-store.d.mts +49 -1
- package/stack/release-store.mjs +102 -16
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +32 -11
- package/stack/rollback-guard.d.mts +10 -0
- package/stack/rollback-guard.mjs +41 -0
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/ssh-gate.d.mts +35 -0
- package/stack/ssh-gate.mjs +145 -0
- package/stack/status.d.mts +51 -0
- package/stack/status.mjs +115 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
|
@@ -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
|
|
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
|
|
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
|
|
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: "
|
|
53
|
+
source: "git" | "fs";
|
|
54
54
|
rootEntries: number;
|
|
55
55
|
apps: string[];
|
|
56
56
|
ruleIds: string[];
|
|
@@ -69,7 +69,7 @@ export declare function checkAppRoot({ root, resolver, tree }: {
|
|
|
69
69
|
violations: ArchitectureViolation[];
|
|
70
70
|
coverage: {
|
|
71
71
|
status: string;
|
|
72
|
-
source: "
|
|
72
|
+
source: "git" | "fs";
|
|
73
73
|
hooksPath: {
|
|
74
74
|
status: string;
|
|
75
75
|
reason: string;
|
|
@@ -80,7 +80,7 @@ export declare function checkAppRoot({ root, resolver, tree }: {
|
|
|
80
80
|
hooks?: never;
|
|
81
81
|
} | {
|
|
82
82
|
status: string;
|
|
83
|
-
hooks: "
|
|
83
|
+
hooks: "installed" | "missing" | "empty" | "unreadable";
|
|
84
84
|
reason?: never;
|
|
85
85
|
};
|
|
86
86
|
rootEntries: number;
|
|
@@ -100,7 +100,7 @@ export declare function checkHfsWithoutConfig(repositoryRoot: string): {
|
|
|
100
100
|
violations: ArchitectureViolation[];
|
|
101
101
|
coverage: {
|
|
102
102
|
status: string;
|
|
103
|
-
source: "
|
|
103
|
+
source: "git" | "fs";
|
|
104
104
|
rootEntries: number;
|
|
105
105
|
apps: string[];
|
|
106
106
|
ruleIds: string[];
|
|
@@ -109,7 +109,7 @@ export declare function checkHfsWithoutConfig(repositoryRoot: string): {
|
|
|
109
109
|
violations: ArchitectureViolation[];
|
|
110
110
|
coverage: {
|
|
111
111
|
status: string;
|
|
112
|
-
source: "
|
|
112
|
+
source: "git" | "fs";
|
|
113
113
|
hooksPath: {
|
|
114
114
|
status: string;
|
|
115
115
|
reason: string;
|
|
@@ -120,7 +120,7 @@ export declare function checkHfsWithoutConfig(repositoryRoot: string): {
|
|
|
120
120
|
hooks?: never;
|
|
121
121
|
} | {
|
|
122
122
|
status: string;
|
|
123
|
-
hooks: "
|
|
123
|
+
hooks: "installed" | "missing" | "empty" | "unreadable";
|
|
124
124
|
reason?: never;
|
|
125
125
|
};
|
|
126
126
|
rootEntries: number;
|
|
@@ -113,7 +113,7 @@ export const CHECK_CODES = Object.freeze([
|
|
|
113
113
|
'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'LITE_DB_MIGRATION',
|
|
114
114
|
'HFS_DOCKER_HEALTHCHECK_FAKE', 'DB_APP_ROLE', 'DB_FORCE_RLS_DEFAULT',
|
|
115
115
|
'DB_POLICY_TAUTOLOGY', 'DB_VIEW_UNSAFE', 'DB_STORAGE_PREFIX', 'DB_DEFINER_REVOKE',
|
|
116
|
-
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT',
|
|
116
|
+
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT', 'HFS_CHECK_CACHE_FORGED', 'HFS_CHECK_CACHE_STALE', 'HFS_CHECK_CACHE_MISSING',
|
|
117
117
|
'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
|
|
118
118
|
...A15_CODES,
|
|
119
119
|
...REFUSAL_CODES,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// gate-threshold.mjs - the verdict of `starci app check` and `starci app lint` (owner ruling gate-threshold-20, 2026-10-30): green
|
|
2
2
|
// while fewer than GATE_ERROR_LIMIT error findings remain and none of them is a hard blocker, so a release is not held back finding
|
|
3
|
-
// by finding (5, 4, 3, ...) by the last few structural nits. A plaintext secret, a failed boot proof and a broken migration block at
|
|
3
|
+
// by finding (5, 4, 3, ...) by the last few structural nits. A plaintext secret, a failed boot proof, a forged check cache record and a broken migration block at
|
|
4
4
|
// any count. Every finding is still printed; only the exit code changes. Typecheck, build and the tests are not judged here.
|
|
5
5
|
export const GATE_ERROR_LIMIT = 20;
|
|
6
6
|
/** The limit in force: policy/quality.yaml `gate.errorLimit`, handed in by the harness CLI as STARCI_GATE_ERROR_LIMIT, else 20. */
|
|
@@ -14,6 +14,7 @@ export const HARD_BLOCKERS = Object.freeze(new Set([
|
|
|
14
14
|
'HFS_PLAINTEXT_SECRET', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'RT_SECRET_TRACKED',
|
|
15
15
|
'STACKS_SECRET_ENV_LITERAL', 'APP_SECRET_IN_CI', 'BE_LOG_SECRET',
|
|
16
16
|
'BE_APP_BOOT_PROOF_MISSING',
|
|
17
|
+
'HFS_CHECK_CACHE_FORGED',
|
|
17
18
|
'DB_MIGRATION_SHAPE', 'DB_MIGRATION_DESTRUCTIVE', 'LITE_DB_MIGRATION', 'BE_MIGRATION_REVERSIBLE',
|
|
18
19
|
]));
|
|
19
20
|
export function gateVerdict(errors, limit = gateErrorLimit()) {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Repo } from './rule-types.mjs';
|
|
2
2
|
export declare const BE_APP_BOOT_PROOF_MISSING = "BE_APP_BOOT_PROOF_MISSING";
|
|
3
|
+
/**
|
|
4
|
+
* Runs `work` with the ordinary content finder's boot proof off. The app check cache runs the proof as its own scoped step (`--wiring`,
|
|
5
|
+
* keyed on the back-end side), so the always-run app pass must not compile the Nest graph a second time.
|
|
6
|
+
*/
|
|
7
|
+
export declare function withoutBootProof<T>(work: () => T): T;
|
|
3
8
|
/** Root scopes delegate to the be side, so each app is compiled once. */
|
|
4
9
|
export declare function bootProofFindings({ repoRoot, files, all, repo, isRoot, inScope }: {
|
|
5
10
|
repoRoot: string;
|
|
@@ -3,9 +3,23 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { appWiring } from '../app-wiring.mjs';
|
|
5
5
|
export const BE_APP_BOOT_PROOF_MISSING = 'BE_APP_BOOT_PROOF_MISSING';
|
|
6
|
+
let suppressed = 0;
|
|
7
|
+
/**
|
|
8
|
+
* Runs `work` with the ordinary content finder's boot proof off. The app check cache runs the proof as its own scoped step (`--wiring`,
|
|
9
|
+
* keyed on the back-end side), so the always-run app pass must not compile the Nest graph a second time.
|
|
10
|
+
*/
|
|
11
|
+
export function withoutBootProof(work) {
|
|
12
|
+
suppressed += 1;
|
|
13
|
+
try {
|
|
14
|
+
return work();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
suppressed -= 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
6
20
|
/** Root scopes delegate to the be side, so each app is compiled once. */
|
|
7
21
|
export function bootProofFindings({ repoRoot, files, all = files, repo, isRoot = Boolean(repo?.sides), inScope = () => true }) {
|
|
8
|
-
if (repo?.edition !== 'lite' || isRoot || repo?.profile === 'fe' || repo?.kind === 'fe')
|
|
22
|
+
if (suppressed > 0 || repo?.edition !== 'lite' || isRoot || repo?.profile === 'fe' || repo?.kind === 'fe')
|
|
9
23
|
return [];
|
|
10
24
|
// Dependencies not installed (a fresh scaffold, a lint-only checkout): the Nest graph cannot be compiled, so the proof is unavailable here; CI and every installed app judge it.
|
|
11
25
|
const installed = [repoRoot, path.dirname(repoRoot ?? '.')].some((dir) => dir && fs.existsSync(path.join(dir, 'node_modules')));
|
|
@@ -8,6 +8,8 @@ const DECLARATION = `${STACK_ROOT}/application-stacks.yaml`;
|
|
|
8
8
|
const named = (entry) => typeof entry.name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(entry.name);
|
|
9
9
|
const identity = (kind, name) => `${kind}:${name}`;
|
|
10
10
|
const atCi = (section) => `${DECLARATION}#ci.${section}`;
|
|
11
|
+
/** The variables the managed workflows themselves read; an app declares none of them. STARCI_DEPLOY_PAUSED is the brake of the automatic deploy, set per GitHub environment by the owner. */
|
|
12
|
+
const MANAGED_VARIABLES = ['STARCI_DEPLOY_PAUSED'];
|
|
11
13
|
/** A CI or Dependabot secret must never also live in a sealed app member. */
|
|
12
14
|
function sealedFindings(ci, sealed) {
|
|
13
15
|
const names = new Set([...ci.secrets, ...ci.dependabotSecrets].filter(named).map((entry) => entry.name));
|
|
@@ -44,7 +46,7 @@ function runtimeFindings(doc, ci, sealed) {
|
|
|
44
46
|
/** Workflow reads are class-sensitive; legacy lists remain readable, but an absent ci block always signals migration once per name. */
|
|
45
47
|
function workflowFindings(doc, declared, references) {
|
|
46
48
|
const hasCi = doc.ci !== undefined;
|
|
47
|
-
const allowed = new Set(declared.map(({ kind, name }) => identity(kind, name)));
|
|
49
|
+
const allowed = new Set([...declared.map(({ kind, name }) => identity(kind, name)), ...MANAGED_VARIABLES.map((name) => identity('vars', name))]);
|
|
48
50
|
if (hasCi) {
|
|
49
51
|
for (const entry of ciNamesOfServices(doc).filter(named)) {
|
|
50
52
|
const legacy = list(doc.services?.[entry.service]?.ci?.[`${entry.kind}s`]);
|
|
@@ -47,11 +47,14 @@ function jobShape(doc, edition, standard, say) {
|
|
|
47
47
|
const shape = standard.jobs[edition] ?? standard.jobs.full;
|
|
48
48
|
const ids = jobsOf(doc).map(([id]) => id);
|
|
49
49
|
const out = [];
|
|
50
|
-
const
|
|
51
|
-
const
|
|
50
|
+
const prefix = String(standard.deployJobPrefix ?? 'deploy-');
|
|
51
|
+
const deployIds = ids.filter((id) => id.startsWith(prefix));
|
|
52
|
+
const must = [...shape.required, ...(shape.pipeline ?? [])];
|
|
53
|
+
const missing = must.filter((id) => !ids.includes(id));
|
|
54
|
+
const extra = ids.filter((id) => !must.includes(id) && !shape.optional.includes(id) && !deployIds.includes(id));
|
|
52
55
|
const gaps = [missing.length ? 'missing ' + missing.join(', ') : '', extra.length ? 'not in the standard ' + extra.join(', ') : ''].filter(Boolean).join('; ');
|
|
53
56
|
if (gaps)
|
|
54
|
-
out.push(say('job-shape', `the jobs must be ${
|
|
57
|
+
out.push(say('job-shape', `the jobs must be ${must.join(', ')} (optional ${shape.optional.join(', ')}); ${gaps}`));
|
|
55
58
|
const withStep = (match) => jobsOf(doc).filter(([, job]) => stepsOf(job).some((step) => match(step))).map(([id]) => id);
|
|
56
59
|
const unit = withStep((step) => step.name === 'unit');
|
|
57
60
|
const upload = withStep((step) => usesOf(step).startsWith('codecov/codecov-action@'));
|
|
@@ -65,6 +68,32 @@ function jobShape(doc, edition, standard, say) {
|
|
|
65
68
|
const oidc = jobsOf(doc).filter(([, job]) => job.permissions?.['id-token'] === 'write').map(([id]) => id);
|
|
66
69
|
if (upload.length && oidc.join() !== 'coverage')
|
|
67
70
|
out.push(say('job-shape', `only the coverage job holds the OIDC id-token (id-token: write), not ${oidc.join(', ') || 'no job'}`));
|
|
71
|
+
out.push(...pipelineShape(doc, ids, deployIds, say));
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const needsOf = (job) => (Array.isArray(job?.needs) ? job.needs.map(String) : typeof job?.needs === 'string' ? [job.needs] : []);
|
|
75
|
+
/** The plan, images, verdict and deploy jobs: the verdict judges every other job and always runs, the images are the reusable images.yml, a deploy job calls stack-deploy.yml after the verdict. */
|
|
76
|
+
function pipelineShape(doc, ids, deployIds, say) {
|
|
77
|
+
const out = [];
|
|
78
|
+
const others = ids.filter((id) => id !== 'verdict' && !deployIds.includes(id));
|
|
79
|
+
const verdict = doc.jobs?.verdict;
|
|
80
|
+
if (record(verdict)) {
|
|
81
|
+
const judged = needsOf(verdict);
|
|
82
|
+
const unjudged = others.filter((id) => !judged.includes(id));
|
|
83
|
+
if (unjudged.length)
|
|
84
|
+
out.push(say('job-shape', `the verdict job must need every other job; it does not need ${unjudged.join(', ')}`));
|
|
85
|
+
if (!/^(?:\$\{\{\s*)?always\(\)/.test(String(verdict.if ?? '').trim()))
|
|
86
|
+
out.push(say('job-shape', 'the verdict job runs under if: always(), so a skipped or red job reaches it'));
|
|
87
|
+
}
|
|
88
|
+
if (record(doc.jobs?.images) && doc.jobs.images.uses !== './.github/workflows/images.yml')
|
|
89
|
+
out.push(say('job-shape', 'the images job calls the reusable ./.github/workflows/images.yml'));
|
|
90
|
+
for (const id of deployIds) {
|
|
91
|
+
const job = doc.jobs[id];
|
|
92
|
+
if (!needsOf(job).includes('verdict'))
|
|
93
|
+
out.push(say('job-shape', `job ${id} deploys, so it must need the verdict job`));
|
|
94
|
+
if (job.uses !== './.github/workflows/stack-deploy.yml')
|
|
95
|
+
out.push(say('job-shape', `job ${id} deploys through the reusable ./.github/workflows/stack-deploy.yml, never with steps of its own`));
|
|
96
|
+
}
|
|
68
97
|
return out;
|
|
69
98
|
}
|
|
70
99
|
function testSharding(doc, say) {
|
|
@@ -33,6 +33,20 @@ export declare function databaseSqlTokens(text: string): {
|
|
|
33
33
|
export declare function databaseSqlStatements(tokens: Parsed): any[][];
|
|
34
34
|
/** SQL and PL/pgSQL bodies are scanned; comments, data literals and other function languages are opaque. */
|
|
35
35
|
export declare function databaseExecutableTokens(text: string, depth?: number): any[];
|
|
36
|
+
/**
|
|
37
|
+
* A contract migration must not ship in the same release as the code change it depends on (cicd.md, Database migrations). The deploy applies
|
|
38
|
+
* a contract migration right after the accept, while the release it replaced is still the rollback target: if that release's code still names
|
|
39
|
+
* the destroyed object, a rollback after the contract would run code against a schema that no longer has it. The check judges exactly that
|
|
40
|
+
* with git: each contract migration that is new since the previous release tag (absent there) is read for the objects it destroys, and the
|
|
41
|
+
* be/src sources AS THEY WERE AT THE PREVIOUS RELEASE TAG must not reference them (the sources at HEAD are judged by destructiveMigrationFindings).
|
|
42
|
+
* The first release, a repository without a release tag, or an unreadable git history has no previous release and no finding. It reports
|
|
43
|
+
* DB_MIGRATION_DESTRUCTIVE (R259), a hard blocker: ship the code change in one release and its contract migration in the next.
|
|
44
|
+
*/
|
|
45
|
+
export declare function contractReleaseFindings({ repoRoot, migrations, git }: {
|
|
46
|
+
repoRoot: string;
|
|
47
|
+
migrations: Parsed;
|
|
48
|
+
git: Parsed;
|
|
49
|
+
}): Promise<any>;
|
|
36
50
|
/** Destructive changes require a contract migration and no remaining SQL/entity reference in be/src. */
|
|
37
51
|
export declare function destructiveMigrationFindings({ repoRoot, migrations, files }: {
|
|
38
52
|
repoRoot: string;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { lsTree } from '../../api/git/ls-tree.mjs';
|
|
4
4
|
import { mergeBase } from '../../api/git/merge-base.mjs';
|
|
5
5
|
import { show } from '../../api/git/show.mjs';
|
|
6
|
+
import { tag } from '../../api/git/tag.mjs';
|
|
6
7
|
import { withoutGitLocalEnv } from '../../lib/git.mjs';
|
|
7
8
|
import { eachInOrder, findInOrder } from '../../lib/in-order.mjs';
|
|
8
9
|
import { sameText } from '../../lib/same-text.mjs';
|
|
@@ -37,12 +38,21 @@ export function defaultGit(args, { cwd }) {
|
|
|
37
38
|
const sha = mergeBase(cwd, rest[0], rest[1]);
|
|
38
39
|
return { ok: sha !== null, stdout: sha ?? '' };
|
|
39
40
|
}
|
|
41
|
+
if (verb === 'previous-release')
|
|
42
|
+
return previousReleaseTag(cwd, env, rest[0]);
|
|
40
43
|
let call = show;
|
|
41
44
|
if (verb === 'ls-tree')
|
|
42
45
|
call = lsTree;
|
|
43
46
|
const result = call(rest, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
|
|
44
47
|
return { ok: !result.error && result.status === 0, stdout: result.stdout };
|
|
45
48
|
}
|
|
49
|
+
/** The nearest release tag before HEAD: the newest version among the tags HEAD contains that do not point at HEAD itself. `pattern` is a tag glob (v*). */
|
|
50
|
+
function previousReleaseTag(cwd, env, pattern) {
|
|
51
|
+
const listed = (args) => { const result = tag(args, { cwd, env }); return !result.error && result.status === 0 ? String(result.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean) : []; };
|
|
52
|
+
const here = new Set(listed(['--points-at', 'HEAD']));
|
|
53
|
+
const previous = listed(['--merged', 'HEAD', '--list', pattern, '--sort=-v:refname']).find((name) => !here.has(name));
|
|
54
|
+
return { ok: previous !== undefined, stdout: previous ?? '' };
|
|
55
|
+
}
|
|
46
56
|
const runGit = (git, repoRoot, args) => Promise.resolve(git(args, { cwd: repoRoot }))
|
|
47
57
|
.then((result) => ({ ok: result?.ok === true, stdout: String(result?.stdout ?? '') }));
|
|
48
58
|
async function baseShaOf(git, repoRoot, base) {
|
|
@@ -515,6 +525,51 @@ function destructiveFileFindings(file, text, references) {
|
|
|
515
525
|
return finding ? [finding] : [];
|
|
516
526
|
});
|
|
517
527
|
}
|
|
528
|
+
const BE_SQL_SOURCE = /^be\/src\/.+\.(?:sql|entity)\.[cm]?tsx?$/;
|
|
529
|
+
/** The tag glob of a release (release.tagPrefix defaults to v). */
|
|
530
|
+
const RELEASE_TAG_GLOB = 'v*';
|
|
531
|
+
/**
|
|
532
|
+
* A contract migration must not ship in the same release as the code change it depends on (cicd.md, Database migrations). The deploy applies
|
|
533
|
+
* a contract migration right after the accept, while the release it replaced is still the rollback target: if that release's code still names
|
|
534
|
+
* the destroyed object, a rollback after the contract would run code against a schema that no longer has it. The check judges exactly that
|
|
535
|
+
* with git: each contract migration that is new since the previous release tag (absent there) is read for the objects it destroys, and the
|
|
536
|
+
* be/src sources AS THEY WERE AT THE PREVIOUS RELEASE TAG must not reference them (the sources at HEAD are judged by destructiveMigrationFindings).
|
|
537
|
+
* The first release, a repository without a release tag, or an unreadable git history has no previous release and no finding. It reports
|
|
538
|
+
* DB_MIGRATION_DESTRUCTIVE (R259), a hard blocker: ship the code change in one release and its contract migration in the next.
|
|
539
|
+
*/
|
|
540
|
+
export async function contractReleaseFindings({ repoRoot, migrations, git }) {
|
|
541
|
+
const contracts = migrations.filter((file) => /_contract_[a-z0-9]+(?:[-_][a-z0-9]+)*\.sql$/.test(file));
|
|
542
|
+
if (!contracts.length)
|
|
543
|
+
return [];
|
|
544
|
+
const previous = await runGit(git, repoRoot, ['previous-release', RELEASE_TAG_GLOB]);
|
|
545
|
+
const release = previous.stdout.trim();
|
|
546
|
+
if (!previous.ok || !release)
|
|
547
|
+
return [];
|
|
548
|
+
const known = new Set(await migrationFilesAt(git, repoRoot, release));
|
|
549
|
+
const fresh = contracts.filter((file) => !known.has(file));
|
|
550
|
+
if (!fresh.length)
|
|
551
|
+
return [];
|
|
552
|
+
const listing = await runGit(git, repoRoot, ['ls-tree', '-r', '--name-only', '-z', release, '--', 'be/src']);
|
|
553
|
+
const sources = listing.ok ? listing.stdout.split('\0').filter((file) => BE_SQL_SOURCE.test(file)) : [];
|
|
554
|
+
if (!sources.length)
|
|
555
|
+
return [];
|
|
556
|
+
const ts = loadTypescript(repoRoot, HERE);
|
|
557
|
+
if (!ts)
|
|
558
|
+
return [found(DB_MIGRATION_DESTRUCTIVE, 'hfs.json', 'the contract-release check requires the TypeScript parser to read the be/src references of the previous release')];
|
|
559
|
+
const references = [];
|
|
560
|
+
await eachInOrder(sources, async (file) => {
|
|
561
|
+
references.push({ file, scopes: sourceNameScopes(ts, await baseFileText(git, repoRoot, release, file) ?? '', file) });
|
|
562
|
+
});
|
|
563
|
+
return fresh.flatMap((file) => {
|
|
564
|
+
const text = readText(repoRoot, file);
|
|
565
|
+
if (text === null)
|
|
566
|
+
return [];
|
|
567
|
+
return databaseSqlStatements(databaseExecutableTokens(text)).flatMap(destructiveObjects).flatMap((object) => {
|
|
568
|
+
const reference = references.find(({ scopes }) => referencesObject(scopes, object));
|
|
569
|
+
return reference ? [found(DB_MIGRATION_DESTRUCTIVE, file, `${file}:${object.line} destroys ${object.name}, which the previous release ${release} still references in ${reference.file}; the contract migration runs right after the accept while that release is the rollback target: ship the code change in one release and this contract migration in the next`, { line: object.line, object: object.name, release, reference: reference.file })] : [];
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
}
|
|
518
573
|
/** Destructive changes require a contract migration and no remaining SQL/entity reference in be/src. */
|
|
519
574
|
export function destructiveMigrationFindings({ repoRoot, migrations, files }) {
|
|
520
575
|
const sources = files.filter((file) => /^be\/src\/.+\.(?:sql|entity)\.[cm]?tsx?$/.test(file));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The database rules of a lite app's db/ tree (design 4.2, L02-L09). SQL migrations are parsed with libpg-query through
|
|
2
2
|
// database-sql.mjs; Git history and generated-type checks are split into bounded sibling modules.
|
|
3
3
|
import { found, readJson, readText } from './read.mjs';
|
|
4
|
-
import { defaultGit, migrationShapeFindings } from './database-migrations.mjs';
|
|
4
|
+
import { contractReleaseFindings, defaultGit, migrationShapeFindings } from './database-migrations.mjs';
|
|
5
5
|
import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
|
|
6
6
|
import { migrationSetDefinerFindings } from './database-plpgsql.mjs';
|
|
7
7
|
import { databaseAccessFindings } from './database-access.mjs';
|
|
@@ -66,6 +66,7 @@ export async function checkDatabase({ repoRoot, files, base, git, edition, db })
|
|
|
66
66
|
findings.push(...liteDbPathFindings(files));
|
|
67
67
|
const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
|
|
68
68
|
findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base }));
|
|
69
|
+
findings.push(...await contractReleaseFindings({ repoRoot, migrations, git: git ?? defaultGit }));
|
|
69
70
|
const analyses = await migrationAnalyses({ repoRoot, migrations, exposed: APP_SCHEMAS, forceRls, findings });
|
|
70
71
|
findings.push(...migrationSetPolicyFindings(analyses), ...migrationSetDefinerFindings(analyses, APP_SCHEMAS), ...await databaseAccessFindings({ analyses, exposed: APP_SCHEMAS, db: declared }));
|
|
71
72
|
return findings;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// R251: product workflow custody, immutable dependencies and release-only triggers, in both editions.
|
|
2
|
+
// A release deploys through the reusable stack-deploy.yml: its jobs hold the environment, the age key and the transport; the job of ci.yml that calls it
|
|
3
|
+
// (a deployment caller) holds permissions, concurrency and the environment name it passes. A pull_request run reads and never binds an environment.
|
|
2
4
|
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
3
5
|
import { found } from './read.mjs';
|
|
4
6
|
import { appWorkflowFindings } from './app-workflow-findings.mjs';
|
|
@@ -71,6 +73,7 @@ const deployName = (value) => !/authorize[\s_.-]+deploy[\s_.-]+key/i.test(value)
|
|
|
71
73
|
const materializeCommand = (script) => commands(script).some(({ text }) => /^starci\s+app\s+secret\s+materialize\b/.test(commandBody(text)));
|
|
72
74
|
const DEPLOYMENT_COMMANDS = [
|
|
73
75
|
/^starci\s+(?:app\s+)?(?:deploy|secret\s+materialize)\b/,
|
|
76
|
+
/^(?:npx\s+(?:--no-install\s+)?)?starci\s+app\s+stack\s+(?:deploy|rollback|accept)\b/,
|
|
74
77
|
/^docker\s+stack\s+deploy\b/,
|
|
75
78
|
/^(?:ba?sh\s+)?(?:\.\/)?deploy\//,
|
|
76
79
|
/^(?:ba?sh\s+)?(?:\.\/)?(?:scripts\/)?[\w.-]*deploy[\w.-]*\.sh\b/,
|
|
@@ -92,22 +95,46 @@ const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
|
92
95
|
const pushFilterValid = (options, ci) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
93
96
|
&& (options.tags === undefined || (Array.isArray(options.tags) && options.tags.length === 1 && options.tags[0] === 'v*'))
|
|
94
97
|
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length > 0 && new Set(options.branches).size === options.branches.length && options.branches.every((branch) => branch === CANDIDATE_BRANCHES || (ci && branch === 'main'))));
|
|
98
|
+
const isCi = (file) => /(^|\/)ci\.ya?ml$/.test(file);
|
|
99
|
+
/** A pull request to main in ci.yml: the checks of a change. Read-only workflow permissions, no environment on a job of the file itself (a call of a reusable workflow cannot have one). */
|
|
100
|
+
function pullRequestFindings(doc, options, refuse, file) {
|
|
101
|
+
if (!isCi(file))
|
|
102
|
+
return [refuse(TRIGGER, 'pull_request may start only ci.yml: no other app workflow runs the code of a pull request')];
|
|
103
|
+
const findings = [];
|
|
104
|
+
const filters = record(options) ? options : {};
|
|
105
|
+
const branches = filters.branches;
|
|
106
|
+
if (!Array.isArray(branches) || branches.length !== 1 || branches[0] !== 'main' || Object.keys(filters).some((key) => key !== 'branches'))
|
|
107
|
+
findings.push(refuse(TRIGGER, "pull_request may contain only branches: ['main']"));
|
|
108
|
+
const permissions = doc.permissions;
|
|
109
|
+
const readOnly = record(permissions) && Object.values(permissions).every((value) => value === 'read' || value === 'none');
|
|
110
|
+
if (!readOnly)
|
|
111
|
+
findings.push(refuse(TRIGGER, 'a workflow that runs on pull_request declares read-only workflow permissions (permissions: contents: read); a job that needs more asks for it itself'));
|
|
112
|
+
for (const [name, job] of Object.entries(record(doc.jobs) ? doc.jobs : {})) {
|
|
113
|
+
if (record(job) && job.environment !== undefined)
|
|
114
|
+
findings.push(refuse(TRIGGER, `job ${name} binds an environment in a workflow that runs on pull_request; a pull request run reads no environment secret`));
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
95
118
|
function triggerFindings(doc, refuse, file = '') {
|
|
96
119
|
const on = workflowEventOptions(doc.on);
|
|
97
120
|
if (!record(on) || !Object.keys(on).length)
|
|
98
121
|
return [refuse(TRIGGER, 'declare release tags [v*] and/or workflow_dispatch')];
|
|
99
122
|
const findings = [];
|
|
100
|
-
if (
|
|
123
|
+
if (isCi(file) && !(record(on.push) && Array.isArray(on.push.branches) && on.push.branches.includes(CANDIDATE_BRANCHES))) {
|
|
101
124
|
findings.push(refuse(TRIGGER, `the release CI must run on push of branches ['${CANDIDATE_BRANCHES}']: starci app release push waits for that run (starci app sync renders it)`));
|
|
102
125
|
}
|
|
103
126
|
for (const [event, options] of Object.entries(on)) {
|
|
104
|
-
if (event === 'workflow_dispatch')
|
|
127
|
+
if (event === 'workflow_dispatch' || event === 'workflow_call')
|
|
105
128
|
continue;
|
|
129
|
+
if (event === 'pull_request') {
|
|
130
|
+
findings.push(...pullRequestFindings(doc, options, refuse, file));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
106
133
|
if (event !== 'push') {
|
|
107
|
-
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches, main in ci.yml, or workflow_dispatch`));
|
|
134
|
+
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches, main and pull requests to main in ci.yml, workflow_call or workflow_dispatch`));
|
|
108
135
|
continue;
|
|
109
136
|
}
|
|
110
|
-
if (!pushFilterValid(options,
|
|
137
|
+
if (!pushFilterValid(options, isCi(file))) {
|
|
111
138
|
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; ci.yml may also name main; other branch and path filters are forbidden`));
|
|
112
139
|
}
|
|
113
140
|
}
|
|
@@ -158,6 +185,8 @@ function scriptWritesDotenv(value) {
|
|
|
158
185
|
function writesDotenv(doc) {
|
|
159
186
|
return entries(doc).some(({ key, value }) => key === 'env_file' || key === 'env-file' || (['run', 'script'].includes(key) && typeof value === 'string' && scriptWritesDotenv(value)));
|
|
160
187
|
}
|
|
188
|
+
/** The job of a workflow that calls a reusable workflow of the app (`uses: ./.github/workflows/<file>`): a deployment caller when the callee deploys. */
|
|
189
|
+
const localCall = (job) => typeof job.uses === 'string' && job.uses.startsWith('./.github/workflows/');
|
|
161
190
|
function deploymentJobs(doc, file) {
|
|
162
191
|
const jobs = record(doc.jobs) ? Object.entries(doc.jobs).filter(([, job]) => record(job)) : [];
|
|
163
192
|
const identified = jobs.filter(([name, job]) => deployName(name) || deployName(String(job.name ?? '')) || scripts(job).some(deploymentCommand) || (typeof job.uses === 'string' && deployName(job.uses)));
|
|
@@ -167,7 +196,23 @@ function deploymentJobs(doc, file) {
|
|
|
167
196
|
return jobs;
|
|
168
197
|
return [];
|
|
169
198
|
}
|
|
199
|
+
/** A deployment caller (ci.yml's deploy-<env>): permissions, concurrency and the environment it passes; the called job binds the environment and opens the key. */
|
|
200
|
+
function callerFindings(name, job, doc, refuse) {
|
|
201
|
+
const findings = [];
|
|
202
|
+
const environment = record(job.with) ? job.with.environment : undefined;
|
|
203
|
+
if (job.environment !== undefined)
|
|
204
|
+
findings.push(refuse(DEPLOY, `job ${name} calls a reusable workflow, which cannot bind an environment itself; pass the name as with.environment and let the called job bind it`));
|
|
205
|
+
if (!nonempty(environment))
|
|
206
|
+
findings.push(refuse(DEPLOY, `job ${name} must pass with.environment: the GitHub environment the called deploy job binds`));
|
|
207
|
+
if (!validPermissions(job.permissions ?? doc.permissions))
|
|
208
|
+
findings.push(refuse(DEPLOY, `job ${name} must declare permissions at job or workflow scope`));
|
|
209
|
+
if (!validConcurrency(job.concurrency ?? doc.concurrency))
|
|
210
|
+
findings.push(refuse(DEPLOY, `job ${name} must declare concurrency at job or workflow scope`));
|
|
211
|
+
return findings;
|
|
212
|
+
}
|
|
170
213
|
function deploymentJobFindings(name, job, doc, refuse) {
|
|
214
|
+
if (localCall(job))
|
|
215
|
+
return callerFindings(name, job, doc, refuse);
|
|
171
216
|
const findings = [];
|
|
172
217
|
if (!validEnvironment(job.environment))
|
|
173
218
|
findings.push(refuse(DEPLOY, `job ${name} must declare its protected environment`));
|
|
@@ -205,9 +250,11 @@ function deploymentFindings(doc, file, refuse) {
|
|
|
205
250
|
findings.push(refuse(DEPLOY, 'the deploy workflow must declare a deployment job'));
|
|
206
251
|
for (const [name, job] of deployment)
|
|
207
252
|
findings.push(...deploymentJobFindings(name, job, doc, refuse));
|
|
208
|
-
|
|
253
|
+
// The secrets and dotenv writes are judged where the deploy runs: the workflow-level env and the deployment jobs, not a check job beside them (the Sonar token of ci.yml).
|
|
254
|
+
const judged = { env: doc.env, jobs: Object.fromEntries(deployment) };
|
|
255
|
+
for (const problem of secretProblems(judged))
|
|
209
256
|
findings.push(refuse(DEPLOY, problem));
|
|
210
|
-
if (writesDotenv(
|
|
257
|
+
if (writesDotenv(judged))
|
|
211
258
|
findings.push(refuse(DEPLOY, 'deploy workflows must never write dotenv files or use env_file blobs; materialize sealed custody'));
|
|
212
259
|
return findings;
|
|
213
260
|
}
|