@starci/hfs 4.3.0 → 4.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/db/migrate.mjs +361 -0
- package/db/post-cutover.mjs +136 -0
- package/emit/contracts.mjs +2 -2
- package/emit/db-types.mjs +12 -3
- package/lint/run.mjs +3 -3
- package/package.json +4 -1
- package/release/gate.mjs +298 -0
- package/release/index.mjs +103 -0
- package/release/push.mjs +110 -0
- package/report/sonar.mjs +1 -1
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +2855 -163
- package/runtime/knowledge/hfs/slots.yaml +333 -62
- package/runtime/knowledge/patterns/be/api.yaml +32 -5
- package/runtime/knowledge/patterns/be/cli.yaml +14 -4
- package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
- package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
- package/runtime/knowledge/patterns/be/projections.yaml +4 -2
- package/runtime/knowledge/patterns/be/queues.yaml +11 -3
- package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
- package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
- package/runtime/knowledge/patterns/be/saga.yaml +20 -3
- package/runtime/knowledge/patterns/be/webhooks.yaml +23 -4
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +3091 -43
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +5 -7
- package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +13769 -0
- package/runtime/packages/cli/src/validate-args.mjs +213 -0
- package/runtime/scripts/api/git/add.mjs +5 -0
- package/runtime/scripts/api/git/init.mjs +7 -0
- package/runtime/scripts/api/git/lib.mjs +4 -1
- package/runtime/scripts/api/git/tag.mjs +5 -0
- package/runtime/scripts/api/node/lib.mjs +20 -0
- package/runtime/scripts/api/node/run-node.mjs +6 -0
- package/runtime/scripts/api/sops/lib.mjs +42 -1
- package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
- package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
- package/runtime/scripts/api/supabase/database-query.mjs +5 -0
- package/runtime/scripts/api/supabase/lib.mjs +44 -0
- package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
- package/runtime/scripts/hfs/app-wiring.mjs +201 -0
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/architecture.mjs +1 -1
- package/runtime/scripts/hfs/check-results.mjs +28 -0
- package/runtime/scripts/hfs/check.mjs +46 -47
- package/runtime/scripts/hfs/coverage-from.mjs +70 -0
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
- package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
- package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/doc-commands.mjs +367 -0
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/example-secrets.mjs +158 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
- package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
- package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
- package/runtime/scripts/hfs/rules/assets.mjs +79 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract.mjs +67 -6
- package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
- package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
- package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
- package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
- package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +286 -7
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
- package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
- package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
- package/runtime/scripts/hfs/rules/database.mjs +9 -0
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
- package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
- package/runtime/scripts/hfs/rules/docker.mjs +24 -7
- package/runtime/scripts/hfs/rules/edition.mjs +9 -8
- package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
- package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
- package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
- package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
- package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
- package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
- package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
- package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
- package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
- package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
- package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
- package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
- package/runtime/scripts/hfs/rules/saga.mjs +8 -16
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
- package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/rules/services.mjs +4 -4
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
- package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
- package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
- package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +41 -1
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +2 -2
- package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
- package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
- package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
- package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
- package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
- package/runtime/scripts/hfs/sealed-members.mjs +101 -0
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
- package/runtime/scripts/hfs/secret-edit.mjs +170 -0
- package/runtime/scripts/hfs/secret-init.mjs +127 -19
- package/runtime/scripts/hfs/secret-sops.mjs +38 -6
- package/runtime/scripts/hfs/secret-store.mjs +56 -54
- package/runtime/scripts/hfs/secret.mjs +34 -5
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
- package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
- package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
- package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/command-tokens.mjs +126 -0
- package/runtime/scripts/lib/compose-values.mjs +11 -0
- package/runtime/scripts/lib/deployment-findings.mjs +7 -0
- package/runtime/scripts/lib/env.mjs +2 -2
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/roots.mjs +2 -4
- package/runtime/scripts/lib/runtime-example.mjs +13 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/sql-ast.mjs +32 -0
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +5 -18
- package/runtime/scripts/lib/trim-end.mjs +8 -0
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/runtime/scripts/lib/workflow-events.mjs +8 -0
- package/scaffold/add-app.mjs +77 -59
- package/scaffold/add-cli-lite.mjs +14 -6
- package/scaffold/add-compose.mjs +79 -0
- package/scaffold/add-database-lite.mjs +14 -0
- package/scaffold/add-fe-overlay.mjs +1 -0
- package/scaffold/add-fe-page.mjs +1 -0
- package/scaffold/add-inbox-lite.mjs +74 -42
- package/scaffold/add-table.mjs +110 -87
- package/scaffold/add.mjs +159 -44
- package/scaffold/app.mjs +124 -39
- package/scaffold/edition-gate.mjs +3 -3
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +2 -2
- package/scaffold/supabase-ports.mjs +1 -1
- package/src/main.mjs +229 -116
- package/stack/age-install.mjs +37 -0
- package/stack/backup.mjs +100 -0
- package/stack/build-args.mjs +42 -0
- package/stack/build.mjs +173 -0
- package/stack/cli.mjs +130 -0
- package/stack/config.mjs +57 -0
- package/stack/context.mjs +57 -0
- package/stack/converge.mjs +107 -0
- package/stack/deploy.mjs +301 -0
- package/stack/digest.mjs +16 -0
- package/stack/docker-transport.mjs +53 -0
- package/stack/edge-alias.mjs +118 -0
- package/stack/errors.mjs +21 -0
- package/stack/image-scan.mjs +152 -0
- package/stack/ingress-create.mjs +114 -0
- package/stack/ingress-publish.mjs +273 -0
- package/stack/ingress.mjs +706 -0
- package/stack/jobs.mjs +51 -0
- package/stack/live-restore.mjs +177 -0
- package/stack/prepare.mjs +266 -0
- package/stack/private-file.mjs +22 -0
- package/stack/provision.mjs +90 -0
- package/stack/prune.mjs +46 -0
- package/stack/release-store.mjs +87 -0
- package/stack/remote.mjs +205 -0
- package/stack/render.mjs +149 -0
- package/stack/retire.mjs +47 -0
- package/stack/sealed.mjs +149 -0
- package/stack/task.mjs +42 -0
- package/stack/verify.mjs +99 -0
- package/sync/ci-commands.mjs +13 -0
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +110 -54
- package/sync/stack-images.mjs +127 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
- package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
- package/templates/app/codegen-graphql.mjs.partial +13 -2
- package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
- package/templates/app/codegen-prelude.mjs.partial +8 -0
- package/templates/app/gitignore +3 -0
- package/templates/app/gitignore-lite +3 -0
- package/templates/app/package-scripts/package.json +1 -0
- package/templates/app/package-scripts/supabase.json +7 -0
- package/templates/app/package-scripts-lite/package.json +1 -7
- package/templates/app/skeleton/.editorconfig +1 -0
- package/templates/app/skeleton/.gitattributes +7 -1
- package/templates/app/skeleton/.github/dependabot.yml +17 -0
- package/templates/app/skeleton/.nvmrc +1 -1
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
- package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
- package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
- package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
- package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
- package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
- package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
- package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
- package/templates/app/skeleton/README.md +6 -6
- package/templates/app/skeleton/scripts/codegen.mjs +1 -2
- package/templates/app/skeleton-lite/.editorconfig +16 -0
- package/templates/app/skeleton-lite/.gitattributes +8 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
- package/templates/app/skeleton-lite/.nvmrc +1 -0
- package/templates/app/skeleton-lite/.sops.yaml +3 -2
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +178 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
- package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
- package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
- package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
- package/templates/app/skeleton-lite/README.md +5 -3
- package/templates/app/skeleton-lite/supabase/config.toml +8 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
- package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/inbox/database-client.spec.ts.tpl +17 -18
- package/templates/be/inbox/database-client.ts.tpl +12 -11
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
- package/templates/be/inbox/event-index.ts.tpl +1 -0
- package/templates/be/inbox/events.json.tpl +10 -0
- package/templates/be/inbox/migration.sql.tpl +1 -0
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
- package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
- package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
- package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
- package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
- package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
- package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
- package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
- package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
- package/templates/be/inbox/platform/migration.sql.tpl +48 -0
- package/templates/be/inbox/service.spec.ts.tpl +42 -9
- package/templates/be/inbox/service.ts.tpl +23 -8
- package/templates/be/inbox/webhook-door.spec.ts.tpl +1 -1
- package/templates/be/inbox/webhook-door.ts.tpl +2 -2
- package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
- package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
- package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
- package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
- package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
- package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
- package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
- package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
- package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
- package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
- package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
- package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
- package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
- package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
- package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
- package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
- package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
- package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
- package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
- package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
- package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
- package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
- package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
- package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
- package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
- package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
- package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
- package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
- package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
- package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
- package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
- package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
- package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
- package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
- package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
- package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +11 -9
- package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +2 -0
- package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
- package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +15 -1
- package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
- package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -14
- package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
- package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
- package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
- package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
- package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
- package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
- package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
- package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
- package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
- package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
- package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
- package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
- package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
- package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
- package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
- package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
- package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
- package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
- package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
- package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
- package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
- package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
- package/templates/be/skeleton-lite/apps/api/src/app.module.ts +14 -18
- package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +2 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/main.ts +2 -3
- package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
- package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
- package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
- package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
- package/templates/be/table/api/feature.module.ts.tpl +2 -1
- package/templates/be/table/api/feature.options.ts.tpl +2 -0
- package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
- package/templates/be/table/api/graphql.module.ts.tpl +3 -2
- package/templates/be/table/api/graphql.options.ts.tpl +2 -0
- package/templates/be/table/migration.sql.tpl +1 -0
- package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
- package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
- package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
- package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
- package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
- package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
- package/templates/fe/auth/supabase-client.ts.tpl +34 -0
- package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
- package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
- package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
- package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
- package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
- package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
- package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
- package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
- package/upgrade/check.mjs +3 -1
- package/upgrade/index.mjs +54 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.1",
|
|
4
4
|
"description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
"LICENSE",
|
|
19
19
|
"src/main.mjs",
|
|
20
20
|
"emit/**",
|
|
21
|
+
"stack/**",
|
|
22
|
+
"release/**",
|
|
23
|
+
"db/**",
|
|
21
24
|
"lint/**",
|
|
22
25
|
"scaffold/**",
|
|
23
26
|
"report/**",
|
package/release/gate.mjs
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and ONLY when every one is green it
|
|
2
|
+
// writes the L4 record <git-common-dir>/starci-release/<HEAD sha>.l4.json (head, tag, per-gate log path and sha256) and creates the annotated tag.
|
|
3
|
+
// The canon pre-push hook lets main and v* tags through only for a HEAD with that tag and that record. Nothing is pushed here (see push.mjs).
|
|
4
|
+
// The gate list comes from the optional `release:` block of .starcistacks/application-stacks.yaml, else from the app's managed package scripts.
|
|
5
|
+
// Commands are argument arrays (never shell strings) run through ctx.spawn; a git tool call always goes through the same seam.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
|
|
10
|
+
import { firstLine, lines, tool } from '../stack/context.mjs';
|
|
11
|
+
import { failure, refusal } from '../stack/errors.mjs';
|
|
12
|
+
|
|
13
|
+
export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
|
|
14
|
+
|
|
15
|
+
const FIELDS = new Set(['gates', 'tagPrefix', 'defaultBranch', 'tagMatchesVersion']);
|
|
16
|
+
const GATE_FIELDS = new Set(['name', 'command', 'summary', 'env']);
|
|
17
|
+
const GATE_NAME = /^[a-z][a-z0-9-]*$/u;
|
|
18
|
+
const TOKEN = /^[A-Za-z0-9_@%+=:,./\\-]+$/u;
|
|
19
|
+
const ENV_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
20
|
+
const REF_NAME = /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/u;
|
|
21
|
+
const PREFIX = /^[A-Za-z][A-Za-z0-9._-]{0,19}$/u;
|
|
22
|
+
export const VERSION = /^(\d+)\.(\d+)\.(\d+)$/u;
|
|
23
|
+
const THRESHOLD_MISS = /coverage threshold .* not met/gu;
|
|
24
|
+
const WRAPPED = new Set(['npm', 'npx']);
|
|
25
|
+
const SCRIPT_GATES = ['typecheck', 'build:be', 'lint', 'build:fe', 'test'];
|
|
26
|
+
const MAX_PATCH = 100;
|
|
27
|
+
|
|
28
|
+
/** True for a branch or remote name git can take as one argument (no leading dash, only path-like characters). */
|
|
29
|
+
export const refName = (value) => typeof value === 'string' && REF_NAME.test(value);
|
|
30
|
+
|
|
31
|
+
const scriptGate = (script) => ({
|
|
32
|
+
name: script.replaceAll(':', '-'),
|
|
33
|
+
command: ['npm', 'run', script],
|
|
34
|
+
summary: script === 'test' ? 'jest' : null,
|
|
35
|
+
env: {},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The gates derived from the managed package scripts: the app check first, then every script the sync renders that the app has. */
|
|
39
|
+
function defaultGates(scripts) {
|
|
40
|
+
const present = SCRIPT_GATES.filter((script) => typeof scripts?.[script] === 'string');
|
|
41
|
+
return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...present.map(scriptGate)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function gateProblems(entry, at, seen) {
|
|
45
|
+
const found = [];
|
|
46
|
+
for (const key of Object.keys(entry)) if (!GATE_FIELDS.has(key)) found.push(`${at}.${key}: is not a field of a gate`);
|
|
47
|
+
if (typeof entry.name !== 'string' || !GATE_NAME.test(entry.name)) found.push(`${at}.name: must match ${GATE_NAME}`);
|
|
48
|
+
else if (seen.has(entry.name)) found.push(`${at}.name: ${entry.name} is declared twice`);
|
|
49
|
+
const command = entry.command;
|
|
50
|
+
if (!Array.isArray(command) || command.length === 0 || !command.every((word) => typeof word === 'string' && TOKEN.test(word))) found.push(`${at}.command: must be a non-empty list of plain words (no shell strings)`);
|
|
51
|
+
if (entry.summary !== undefined && entry.summary !== 'jest') found.push(`${at}.summary: must be jest`);
|
|
52
|
+
const env = entry.env ?? {};
|
|
53
|
+
if (!plain(env) || !Object.entries(env).every(([key, value]) => ENV_KEY.test(key) && typeof value === 'string')) found.push(`${at}.env: must map UPPER_CASE names to string values`);
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function gatesOf(list, problems) {
|
|
58
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
59
|
+
problems.push('release.gates: must be a non-empty list');
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const gates = [];
|
|
64
|
+
list.forEach((entry, index) => {
|
|
65
|
+
const at = `release.gates[${index}]`;
|
|
66
|
+
const found = plain(entry) ? gateProblems(entry, at, seen) : [`${at}: must be a mapping`];
|
|
67
|
+
problems.push(...found);
|
|
68
|
+
if (found.length === 0) {
|
|
69
|
+
seen.add(entry.name);
|
|
70
|
+
gates.push({ name: entry.name, command: entry.command, summary: entry.summary ?? null, env: entry.env ?? {} });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return gates;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Judges the optional `release:` block. Absence is fine (defaults). Returns { config } or { problems }. */
|
|
77
|
+
export function normalizeRelease(block, scripts = {}) {
|
|
78
|
+
const raw = block ?? {};
|
|
79
|
+
if (!plain(raw)) return { problems: ['release: must be a mapping'] };
|
|
80
|
+
const problems = Object.keys(raw).filter((key) => !FIELDS.has(key)).map((key) => `release.${key}: is not a field of the release block`);
|
|
81
|
+
const tagPrefix = raw.tagPrefix ?? 'v';
|
|
82
|
+
const defaultBranch = raw.defaultBranch ?? 'main';
|
|
83
|
+
const tagMatchesVersion = raw.tagMatchesVersion ?? false;
|
|
84
|
+
if (typeof tagPrefix !== 'string' || !PREFIX.test(tagPrefix)) problems.push(`release.tagPrefix: must match ${PREFIX}`);
|
|
85
|
+
if (!refName(defaultBranch)) problems.push(`release.defaultBranch: must match ${REF_NAME}`);
|
|
86
|
+
if (typeof tagMatchesVersion !== 'boolean') problems.push('release.tagMatchesVersion: must be true or false');
|
|
87
|
+
const gates = raw.gates === undefined ? defaultGates(scripts) : gatesOf(raw.gates, problems);
|
|
88
|
+
return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, tagMatchesVersion } } : { problems };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---- git through the seam */
|
|
92
|
+
|
|
93
|
+
/** Runs git in the app root; a missing git or (unless allowFail) a non-zero exit is a failure whose message names the first stderr line. */
|
|
94
|
+
export function git(ctx, args, { allowFail = false } = {}) {
|
|
95
|
+
const run = tool(ctx, 'git', args, { cwd: ctx.cwd });
|
|
96
|
+
if (run.error) throw failure('RELEASE_GIT_MISSING', `cannot run git (${run.error.message})`);
|
|
97
|
+
if (run.status !== 0 && !allowFail) throw failure('RELEASE_GIT_FAILED', `git ${args.slice(0, 2).join(' ')}: ${firstLine(run.stderr)}`);
|
|
98
|
+
return { code: run.status, stdout: String(run.stdout ?? '').trim(), stderr: String(run.stderr ?? '') };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The directory that holds the L4 records: <git common dir>/starci-release. */
|
|
102
|
+
export const releaseDir = (ctx) => path.join(path.resolve(ctx.cwd, git(ctx, ['rev-parse', '--git-common-dir']).stdout), 'starci-release');
|
|
103
|
+
|
|
104
|
+
/** Annotated tags `<prefix><digit>*` that point at HEAD (lightweight tags do not count: the hook ignores them). */
|
|
105
|
+
export function annotatedOnHead(ctx, prefix) {
|
|
106
|
+
const names = lines(git(ctx, ['tag', '--points-at', 'HEAD', '--list', `${prefix}[0-9]*`]).stdout);
|
|
107
|
+
return names.filter((name) => git(ctx, ['cat-file', '-t', `refs/tags/${name}`], { allowFail: true }).stdout === 'tag');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dirtyPaths = (ctx) => lines(git(ctx, ['status', '--porcelain', '--untracked-files=normal']).stdout);
|
|
111
|
+
|
|
112
|
+
/** Tag names that exist locally or on the remote. */
|
|
113
|
+
function takenTags(ctx, prefix, remote) {
|
|
114
|
+
const local = lines(git(ctx, ['tag', '--list', `${prefix}*`]).stdout);
|
|
115
|
+
const listed = lines(git(ctx, ['ls-remote', '--tags', remote], { allowFail: true }).stdout);
|
|
116
|
+
const remoteNames = listed.map((line) => line.split('refs/tags/')[1]?.replace(/\^\{\}$/u, '')).filter(Boolean);
|
|
117
|
+
return new Set([...local, ...remoteNames]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Next free `<prefix><major>.<minor>.<n>` for the package version. */
|
|
121
|
+
function nextFree(prefix, version, taken) {
|
|
122
|
+
const [, major, minor] = VERSION.exec(version);
|
|
123
|
+
for (let patch = 0; patch < MAX_PATCH; patch += 1) {
|
|
124
|
+
const candidate = `${prefix}${major}.${minor}.${patch}`;
|
|
125
|
+
if (!taken.has(candidate)) return candidate;
|
|
126
|
+
}
|
|
127
|
+
throw refusal('RELEASE_NO_FREE_TAG', `no free tag ${prefix}${major}.${minor}.0 to ${prefix}${major}.${minor}.${MAX_PATCH - 1}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The tag this release uses and its state: on-head (reused), free (will be created) or taken (exists on another commit). */
|
|
131
|
+
function chooseTag(ctx, config, { prefix, remote }) {
|
|
132
|
+
const onHead = annotatedOnHead(ctx, prefix);
|
|
133
|
+
const taken = takenTags(ctx, prefix, remote);
|
|
134
|
+
const exact = `${prefix}${config.version}`;
|
|
135
|
+
const tag = config.tagMatchesVersion ? exact : (onHead[0] ?? nextFree(prefix, config.version, taken));
|
|
136
|
+
if (onHead.includes(tag)) return { tag, state: 'on-head' };
|
|
137
|
+
return { tag, state: taken.has(tag) ? 'taken' : 'free' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ---- gate results */
|
|
141
|
+
|
|
142
|
+
const sha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
143
|
+
|
|
144
|
+
const lineAfter = (text, label) => {
|
|
145
|
+
const found = text.split('\n').find((line) => line.startsWith(label));
|
|
146
|
+
return found === undefined ? null : found.slice(label.length).trim();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** The jest summary lines of a log: { suites, failedSuites, tests, failed, passed } or null when jest printed none. */
|
|
150
|
+
function jestSummary(text) {
|
|
151
|
+
const suites = lineAfter(text, 'Test Suites:');
|
|
152
|
+
const tests = lineAfter(text, 'Tests:');
|
|
153
|
+
if (suites === null || tests === null) return null;
|
|
154
|
+
const count = (line, word) => Number(new RegExp(String.raw`(\d+) ${word}`, 'u').exec(line)?.[1] ?? 0);
|
|
155
|
+
return {
|
|
156
|
+
suites: count(suites, 'total'), failedSuites: count(suites, 'failed'),
|
|
157
|
+
tests: count(tests, 'total'), failed: count(tests, 'failed'), passed: count(tests, 'passed'),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Judges a jest-style gate from its log: green needs suites that all passed, every test passed and no coverage threshold miss. */
|
|
162
|
+
function judgeJest(text) {
|
|
163
|
+
const summary = jestSummary(text);
|
|
164
|
+
const misses = (text.match(THRESHOLD_MISS) ?? []).length;
|
|
165
|
+
if (!summary) return { pass: false, detail: 'no jest summary in the log', summary: null, misses };
|
|
166
|
+
const pass = summary.suites > 0 && summary.failedSuites === 0 && summary.failed === 0 && summary.passed === summary.tests && misses === 0;
|
|
167
|
+
const detail = `suites ${summary.suites - summary.failedSuites}/${summary.suites}, tests ${summary.passed}/${summary.tests}, coverage-threshold misses ${misses}`;
|
|
168
|
+
return { pass, detail, summary, misses };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** [command, args] to start an argv; on Windows npm and npx are .cmd files, so they start through cmd.exe (words are validated, never a shell string). */
|
|
172
|
+
export function launch(argv, platform) {
|
|
173
|
+
if (platform === 'win32' && WRAPPED.has(argv[0])) return ['cmd.exe', ['/d', '/s', '/c', ...argv]];
|
|
174
|
+
return [argv[0], argv.slice(1)];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function runGate(ctx, gate, { dir, platform }) {
|
|
178
|
+
const log = path.join(dir, `${gate.name}.log`);
|
|
179
|
+
const started = ctx.now();
|
|
180
|
+
const [command, args] = launch(gate.command, platform);
|
|
181
|
+
const run = tool(ctx, command, args, { cwd: ctx.cwd, env: { ...ctx.env, ...gate.env } });
|
|
182
|
+
const text = `${run.stdout ?? ''}${run.stderr ?? ''}`;
|
|
183
|
+
fs.writeFileSync(log, text);
|
|
184
|
+
const verdict = judgeVerdict(run, gate, text);
|
|
185
|
+
const seconds = Math.round((ctx.now() - started) / 1000);
|
|
186
|
+
return {
|
|
187
|
+
name: gate.name, command: gate.command.join(' '), pass: verdict.pass, detail: verdict.detail, seconds,
|
|
188
|
+
summary: verdict.summary ?? null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function judgeVerdict(run, gate, text) {
|
|
193
|
+
if (run.error) return { pass: false, detail: `cannot run (${run.error.message})` };
|
|
194
|
+
if (run.status !== 0) return { pass: false, detail: `exit ${run.status}` };
|
|
195
|
+
if (gate.summary === 'jest') return judgeJest(text);
|
|
196
|
+
return { pass: true, detail: 'exit 0' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ---- the gate */
|
|
200
|
+
|
|
201
|
+
function sourceProblem(ctx, source, head) {
|
|
202
|
+
if (source === 'HEAD') return null;
|
|
203
|
+
if (!refName(source)) return `--source ${source} is not a ref name`;
|
|
204
|
+
const resolved = git(ctx, ['rev-parse', '--verify', `${source}^{commit}`], { allowFail: true });
|
|
205
|
+
if (resolved.code !== 0) return `cannot resolve --source ${source}`;
|
|
206
|
+
if (resolved.stdout !== head) return `--source ${source} is ${resolved.stdout.slice(0, 10)} but HEAD is ${head.slice(0, 10)}: the hook and the L4 record are about HEAD; check the candidate out first`;
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function tagLine({ tag, state }) {
|
|
211
|
+
if (state === 'on-head') return `tag ${tag} (annotated, already on HEAD)`;
|
|
212
|
+
if (state === 'taken') return `tag ${tag} (TAKEN by another commit)`;
|
|
213
|
+
return `tag ${tag} (free: will be created, annotated)`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Everything that can be known without running a gate: the facts, the red preconditions and the lines that describe them. */
|
|
217
|
+
function inspect(ctx, config, options) {
|
|
218
|
+
const head = git(ctx, ['rev-parse', 'HEAD']).stdout;
|
|
219
|
+
const branch = git(ctx, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true }).stdout;
|
|
220
|
+
const out = [`checkout ${ctx.cwd}`, `HEAD ${head} (${branch})`];
|
|
221
|
+
const red = [];
|
|
222
|
+
const problem = sourceProblem(ctx, options.source ?? 'HEAD', head);
|
|
223
|
+
if (problem) red.push(problem);
|
|
224
|
+
const dirty = dirtyPaths(ctx);
|
|
225
|
+
if (dirty.length > 0) red.push(`working tree is not clean (${dirty.length} path(s), first: ${dirty[0].slice(0, 100)}): the record would vouch for files that are not in HEAD`);
|
|
226
|
+
else out.push('worktree clean');
|
|
227
|
+
const prefix = options.tagPrefix ?? config.tagPrefix;
|
|
228
|
+
if (!PREFIX.test(prefix)) throw refusal('RELEASE_USAGE', `--tag-prefix must match ${PREFIX}`);
|
|
229
|
+
const chosen = chooseTag(ctx, config, { prefix, remote: 'origin' });
|
|
230
|
+
if (chosen.state === 'taken') red.push(`tag ${chosen.tag} already exists on another commit (local or remote)`);
|
|
231
|
+
out.push(tagLine(chosen));
|
|
232
|
+
return { head, chosen, red, out };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function planLines(config, facts, dir, recordPath) {
|
|
236
|
+
const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (all must pass, in this order):'];
|
|
237
|
+
for (const gate of config.gates) {
|
|
238
|
+
const summary = gate.summary ? ` (summary: ${gate.summary})` : '';
|
|
239
|
+
out.push(` ${gate.name.padEnd(17)} ${gate.command.join(' ')}${summary}`);
|
|
240
|
+
}
|
|
241
|
+
out.push(`then: write ${path.basename(recordPath)} and \`git tag -a ${facts.chosen.tag}\` on ${facts.head.slice(0, 10)}; push it with \`starci app release push\`.`);
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function assertUnmoved(ctx, head) {
|
|
246
|
+
const now = git(ctx, ['rev-parse', 'HEAD']).stdout;
|
|
247
|
+
if (now !== head) throw failure('RELEASE_HEAD_MOVED', `HEAD moved during the gates (${head.slice(0, 10)} -> ${now.slice(0, 10)}). No record.`);
|
|
248
|
+
const dirty = dirtyPaths(ctx);
|
|
249
|
+
if (dirty.length > 0) throw failure('RELEASE_TREE_DIRTY', `the gates left ${dirty.length} changed or untracked path(s) (first: ${dirty[0].slice(0, 100)}): generated files differ from HEAD. Commit or ignore them properly, then rerun. No record.`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function writeRecord(ctx, { head, tag, results, recordPath }) {
|
|
253
|
+
const record = {
|
|
254
|
+
schema: 'starci-app-l4-record/1', head, tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release gate',
|
|
255
|
+
note: 'The full gates ran on a clean HEAD; every entry links the real log and its sha256.',
|
|
256
|
+
gates: results,
|
|
257
|
+
};
|
|
258
|
+
fs.mkdirSync(path.dirname(recordPath), { recursive: true });
|
|
259
|
+
fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function runAll(ctx, config, { dir, platform }) {
|
|
263
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
264
|
+
const results = [];
|
|
265
|
+
for (const gate of config.gates) {
|
|
266
|
+
const result = runGate(ctx, gate, { dir, platform });
|
|
267
|
+
results.push(result);
|
|
268
|
+
if (!result.pass) break;
|
|
269
|
+
}
|
|
270
|
+
return results;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const summaryOf = (result) => `${result.pass ? 'PASS' : 'FAIL'} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if all are green,
|
|
277
|
+
* writes the L4 record and creates the annotated tag. options: { execute, source, tagPrefix, platform }.
|
|
278
|
+
*/
|
|
279
|
+
export async function releaseGate(ctx, config, options = {}) {
|
|
280
|
+
const facts = inspect(ctx, config, options);
|
|
281
|
+
const common = releaseDir(ctx);
|
|
282
|
+
const recordPath = path.join(common, `${facts.head}.l4.json`);
|
|
283
|
+
const dir = path.join(common, facts.head.slice(0, 12));
|
|
284
|
+
const plan = [...facts.out, ...planLines(config, facts, dir, recordPath)];
|
|
285
|
+
const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
|
|
286
|
+
if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
|
|
287
|
+
if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
|
|
288
|
+
const results = runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
|
|
289
|
+
const report = results.map(summaryOf);
|
|
290
|
+
const failed = results.filter((result) => !result.pass);
|
|
291
|
+
if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
|
|
292
|
+
assertUnmoved(ctx, facts.head);
|
|
293
|
+
writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath });
|
|
294
|
+
const created = facts.chosen.state !== 'on-head';
|
|
295
|
+
if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: gates green on ${facts.head.slice(0, 12)} (L4 record ${path.basename(recordPath)})`, facts.head]);
|
|
296
|
+
const tagged = created ? `annotated tag ${facts.chosen.tag} created on ${facts.head.slice(0, 10)}` : `annotated tag ${facts.chosen.tag} already on ${facts.head.slice(0, 10)}`;
|
|
297
|
+
return { ok: true, lines: [...plan, ...report, `L4 record written: ${recordPath}`, tagged], data: { ...base, executed: true, results, tagCreated: created } };
|
|
298
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// index.mjs - `starci app release <gate|push>` and the plumbing the release and database verbs share: the context built from the app's IO, strict flag
|
|
2
|
+
// parsing, the optional `release:` / `database:` blocks of .starcistacks/application-stacks.yaml, text and --json output and the exit-code mapping.
|
|
3
|
+
// starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json]
|
|
4
|
+
// starci app release push [--execute] [--remote <name>] [--json]
|
|
5
|
+
// Every action is a dry run unless --execute is given. A refusal is a StackError (exit 2); a failed step returns ok false (exit 1).
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
|
|
9
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
10
|
+
import { STACKS_DIR, STACKS_FILE } from '../stack/config.mjs';
|
|
11
|
+
import { stackContext } from '../stack/context.mjs';
|
|
12
|
+
import { EXIT, StackError, refusal } from '../stack/errors.mjs';
|
|
13
|
+
import { RELEASE_CODE, VERSION, normalizeRelease, releaseGate } from './gate.mjs';
|
|
14
|
+
import { releasePush } from './push.mjs';
|
|
15
|
+
|
|
16
|
+
const IO_KEYS = ['cwd', 'stdout', 'stderr', 'env', 'sops'];
|
|
17
|
+
|
|
18
|
+
/** The stack context of one invocation: the IO of the app (cwd, writers, env, sops) over the injectable seams of `io.stackIo` (spawn, sleep, now, fetch). */
|
|
19
|
+
export function contextOf(io = {}) {
|
|
20
|
+
const own = Object.fromEntries(IO_KEYS.filter((key) => io[key] !== undefined).map((key) => [key, io[key]]));
|
|
21
|
+
return stackContext({ ...io.stackIo, ...own });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
|
|
25
|
+
function parseVerbFlags(argv, spec, code = 'RELEASE_USAGE') {
|
|
26
|
+
const flags = {};
|
|
27
|
+
const positional = [];
|
|
28
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
29
|
+
const arg = argv[index];
|
|
30
|
+
if (spec.boolean.includes(arg)) flags[arg.slice(2)] = true;
|
|
31
|
+
else if (spec.value.includes(arg)) {
|
|
32
|
+
const value = argv[index + 1];
|
|
33
|
+
if (value === undefined || value.startsWith('--')) throw refusal(code, `${arg} needs a value`);
|
|
34
|
+
index += 1;
|
|
35
|
+
flags[arg.slice(2)] = value;
|
|
36
|
+
} else if (arg.startsWith('--')) throw refusal(code, `unknown flag ${arg}`);
|
|
37
|
+
else positional.push(arg);
|
|
38
|
+
}
|
|
39
|
+
return { flags, positional };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The top-level mapping of .starcistacks/application-stacks.yaml; a missing file is an empty declaration, an unreadable one a refusal. */
|
|
43
|
+
export function readDeclaration(root) {
|
|
44
|
+
let source;
|
|
45
|
+
try { source = fs.readFileSync(path.join(root, STACKS_DIR, STACKS_FILE), 'utf8'); } catch { return {}; }
|
|
46
|
+
let doc;
|
|
47
|
+
try { doc = parseYaml(source); } catch { throw refusal('STACKS_DEPLOY_FILE_INVALID', `${STACKS_FILE} is not valid YAML`); }
|
|
48
|
+
return plain(doc) ? doc : {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readPackage(root) {
|
|
52
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { throw refusal('RELEASE_PACKAGE_UNREADABLE', 'package.json is not readable JSON'); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The normalised `release:` block (tolerant of absence) with the package version: { root, version, gates, tagPrefix, defaultBranch, tagMatchesVersion }. */
|
|
56
|
+
function loadReleaseConfig(root) {
|
|
57
|
+
const pkg = readPackage(root);
|
|
58
|
+
if (typeof pkg.version !== 'string' || !VERSION.test(pkg.version)) throw refusal(RELEASE_CODE, 'package.json needs a version like 1.2.3 (the release tag derives from it)');
|
|
59
|
+
const { config, problems } = normalizeRelease(readDeclaration(root).release, pkg.scripts);
|
|
60
|
+
if (!config) throw refusal(RELEASE_CODE, `${problems.length} problem(s) in release: ${problems.slice(0, 6).join('; ')}`);
|
|
61
|
+
return { root, version: pkg.version, ...config };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function print(ctx, name, flags, result) {
|
|
65
|
+
if (flags.json) ctx.stdout(`${JSON.stringify({ verb: name, ok: result.ok, ...result.data }, null, 2)}\n`);
|
|
66
|
+
else for (const line of result.lines ?? []) ctx.stdout(`${line}\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parses the flags, runs `run(ctx, flags, positional)` and prints its result; returns the exit code. Only StackError is mapped, anything else is a bug and propagates. */
|
|
70
|
+
export async function runVerb({ label, name, io, argv, spec, run, code = 'RELEASE_USAGE' }) {
|
|
71
|
+
const ctx = contextOf(io);
|
|
72
|
+
try {
|
|
73
|
+
const { flags, positional } = parseVerbFlags(argv, spec, code);
|
|
74
|
+
const result = await run(ctx, flags, positional);
|
|
75
|
+
print(ctx, name, flags, result);
|
|
76
|
+
return result.ok ? EXIT.ok : EXIT.failed;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!(error instanceof StackError)) throw error;
|
|
79
|
+
ctx.stderr(`${label}: ${error.message}\n`);
|
|
80
|
+
return error.exit;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ACTIONS = {
|
|
85
|
+
gate: {
|
|
86
|
+
spec: { boolean: ['--execute', '--json'], value: ['--source', '--tag-prefix'] },
|
|
87
|
+
run: (ctx, flags) => releaseGate(ctx, loadReleaseConfig(ctx.cwd), { execute: flags.execute === true, source: flags.source, tagPrefix: flags['tag-prefix'] }),
|
|
88
|
+
},
|
|
89
|
+
push: {
|
|
90
|
+
spec: { boolean: ['--execute', '--json'], value: ['--remote'] },
|
|
91
|
+
run: (ctx, flags) => releasePush(ctx, loadReleaseConfig(ctx.cwd), { execute: flags.execute === true, remote: flags.remote }),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Runs `starci app release <gate|push>`; `io` is the app IO (cwd, stdout, stderr, env, sops, stackIo). Returns the exit code. */
|
|
96
|
+
export async function releaseMain(argv, io = {}) {
|
|
97
|
+
const action = Object.hasOwn(ACTIONS, argv[0]) ? ACTIONS[argv[0]] : null;
|
|
98
|
+
if (!action) {
|
|
99
|
+
contextOf(io).stderr('starci app release: takes gate or push\n');
|
|
100
|
+
return EXIT.refused;
|
|
101
|
+
}
|
|
102
|
+
return runVerb({ label: 'starci app release', name: argv[0], io, argv: argv.slice(1), spec: action.spec, run: action.run });
|
|
103
|
+
}
|
package/release/push.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// push.mjs - `starci app release push`: sends the default branch and the release tag to the remote in ONE atomic push through the repository's own
|
|
2
|
+
// pre-push hook (never --no-verify). It requires what the hook requires: an annotated release tag on HEAD and the L4 record of the gate for that
|
|
3
|
+
// head and tag. `git push --dry-run --atomic` always runs first (the hook's verdict is the proof) and any failure refuses the push.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { firstLine } from '../stack/context.mjs';
|
|
7
|
+
import { failure, refusal } from '../stack/errors.mjs';
|
|
8
|
+
import { annotatedOnHead, git, refName, releaseDir } from './gate.mjs';
|
|
9
|
+
|
|
10
|
+
const VERDICT = /RIGHTS_PUSH_NOT_RELEASE|pre-push|failed to push|Everything up|->/u;
|
|
11
|
+
|
|
12
|
+
function readRecord(file) {
|
|
13
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const greenRecord = (record, head, tag) => record?.head === head && record?.tag === tag
|
|
17
|
+
&& Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true);
|
|
18
|
+
|
|
19
|
+
/** The release gate as the hook reads it: the annotated tag on HEAD whose L4 record names head and tag. Returns { tag } or { reason }. */
|
|
20
|
+
function gateState(ctx, head, prefix) {
|
|
21
|
+
const tags = annotatedOnHead(ctx, prefix);
|
|
22
|
+
if (tags.length === 0) return { reason: `no ANNOTATED tag ${prefix}[0-9]* on HEAD: run starci app release gate --execute first` };
|
|
23
|
+
const file = path.join(releaseDir(ctx), `${head}.l4.json`);
|
|
24
|
+
if (!fs.existsSync(file)) return { reason: `no L4 record ${file}: run starci app release gate --execute first` };
|
|
25
|
+
const record = readRecord(file);
|
|
26
|
+
const tag = tags.find((name) => greenRecord(record, head, name));
|
|
27
|
+
return tag ? { tag } : { reason: `the L4 record does not name head ${head.slice(0, 10)} and one of ${tags.join(', ')} with every gate green` };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The remote branch tip, or null on an empty remote; an unreachable remote is a failure (the state cannot be judged). */
|
|
31
|
+
function remoteTip(ctx, remote, branch) {
|
|
32
|
+
const listed = git(ctx, ['ls-remote', '--heads', remote, branch], { allowFail: true });
|
|
33
|
+
if (listed.code !== 0) throw failure('RELEASE_REMOTE_UNREACHABLE', `cannot read ${remote}: ${firstLine(listed.stderr)}`);
|
|
34
|
+
if (listed.stdout === '') return null;
|
|
35
|
+
git(ctx, ['fetch', remote, branch, '--quiet']);
|
|
36
|
+
return listed.stdout.split(/\s/u)[0];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isAncestor = (ctx, older, newer) => git(ctx, ['merge-base', '--is-ancestor', older, newer], { allowFail: true }).code === 0;
|
|
40
|
+
|
|
41
|
+
/** The remote object of a tag ('' when absent). */
|
|
42
|
+
function remoteTagObject(ctx, remote, tag) {
|
|
43
|
+
const wanted = `refs/tags/${tag}`;
|
|
44
|
+
const listed = git(ctx, ['ls-remote', '--tags', remote, wanted], { allowFail: true }).stdout.split('\n');
|
|
45
|
+
return listed.map((line) => line.split('\t')).find((parts) => parts[1] === wanted)?.[0] ?? '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function branchRefs(ctx, { head, remote, branch }, red) {
|
|
49
|
+
const tip = remoteTip(ctx, remote, branch);
|
|
50
|
+
if (tip === null || isAncestor(ctx, tip, head)) return { refs: tip === head ? [] : [`${head}:refs/heads/${branch}`], tip };
|
|
51
|
+
if (isAncestor(ctx, head, tip)) return { refs: [], tip };
|
|
52
|
+
red.push(`${remote}/${branch} (${tip.slice(0, 10)}) is not an ancestor of HEAD: not a fast-forward; merge it first`);
|
|
53
|
+
return { refs: [], tip };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tagRefs(ctx, { remote, tag }, red) {
|
|
57
|
+
const object = remoteTagObject(ctx, remote, tag);
|
|
58
|
+
if (object === '') return [`refs/tags/${tag}`];
|
|
59
|
+
if (object !== git(ctx, ['rev-parse', `refs/tags/${tag}`]).stdout) red.push(`tag ${tag} exists on ${remote} as a different object`);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function probe(ctx, remote, refs, red) {
|
|
64
|
+
const run = git(ctx, ['push', '--dry-run', '--atomic', remote, ...refs], { allowFail: true });
|
|
65
|
+
const verdict = `${run.stderr}\n${run.stdout}`.split('\n').filter((line) => VERDICT.test(line)).slice(0, 4);
|
|
66
|
+
if (run.code !== 0) red.push('the pre-push hook refuses this push as things stand');
|
|
67
|
+
return { exit: run.code, lines: [`hook dry run git push --dry-run --atomic: exit ${run.code}`, ...verdict.map((line) => ` ${line.slice(0, 200)}`)] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NAME_RULE = '^[A-Za-z0-9._/-]+$';
|
|
71
|
+
|
|
72
|
+
function assertNames(remote, branch) {
|
|
73
|
+
if (!refName(remote)) throw refusal('RELEASE_USAGE', `--remote ${remote} must match ${NAME_RULE}`);
|
|
74
|
+
if (!refName(branch)) throw refusal('RELEASE_USAGE', `the default branch ${branch} must match ${NAME_RULE}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const gateLine = (gate) => (gate.tag ? `release gate OK: annotated ${gate.tag} on HEAD, L4 record names head and tag` : 'release gate CLOSED (see RED below)');
|
|
78
|
+
|
|
79
|
+
/** A refusal result: the lines so far, every red item and a closing line; `data.red` lists the red items. */
|
|
80
|
+
const refuse = (out, red, data, closing) => ({ ok: false, lines: [...out, ...red.map((item) => `RED: ${item}`), closing], data: { ...data, red } });
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `starci app release push`. options: { execute, remote }. The remote and branch names are validated; the branch is the declared default branch.
|
|
84
|
+
* Dry run (and --execute) first prove the push with `git push --dry-run --atomic`; --execute then pushes for real, still through the hook.
|
|
85
|
+
*/
|
|
86
|
+
export async function releasePush(ctx, config, options = {}) {
|
|
87
|
+
const remote = options.remote ?? 'origin';
|
|
88
|
+
const branch = config.defaultBranch;
|
|
89
|
+
assertNames(remote, branch);
|
|
90
|
+
const head = git(ctx, ['rev-parse', 'HEAD']).stdout;
|
|
91
|
+
const gate = gateState(ctx, head, config.tagPrefix);
|
|
92
|
+
const out = [`HEAD ${head}`, gateLine(gate)];
|
|
93
|
+
const data = { head, tag: gate.tag ?? null, remote, branch, refs: [], hook: null, pushed: false };
|
|
94
|
+
if (!gate.tag) return refuse(out, [gate.reason], data, 'nothing pushed.');
|
|
95
|
+
const red = [];
|
|
96
|
+
const refs = [...branchRefs(ctx, { head, remote, branch }, red).refs, ...tagRefs(ctx, { remote, tag: gate.tag }, red)];
|
|
97
|
+
data.refs = refs;
|
|
98
|
+
out.push(`plan git push --atomic ${remote} ${refs.join(' ') || '(nothing)'}`);
|
|
99
|
+
if (refs.length === 0 && red.length === 0) return { ok: true, lines: [...out, `already pushed: ${remote}/${branch} holds HEAD and ${gate.tag} is on ${remote}. Nothing to do.`], data };
|
|
100
|
+
if (refs.length > 0) {
|
|
101
|
+
const hook = probe(ctx, remote, refs, red);
|
|
102
|
+
data.hook = { exit: hook.exit };
|
|
103
|
+
out.push(...hook.lines);
|
|
104
|
+
}
|
|
105
|
+
if (red.length > 0) return refuse(out, red, data, options.execute === true ? 'nothing pushed.' : 'dry run: nothing pushed.');
|
|
106
|
+
if (options.execute !== true) return { ok: true, lines: [...out, 'dry run: nothing pushed. ready; re-run with --execute.'], data };
|
|
107
|
+
const run = git(ctx, ['push', '--atomic', remote, ...refs], { allowFail: true });
|
|
108
|
+
if (run.code !== 0) return { ok: false, lines: [...out, `push failed: ${firstLine(run.stderr)}`], data };
|
|
109
|
+
return { ok: true, lines: [...out, `pushed to ${remote}: ${refs.join(' ')}`], data: { ...data, pushed: true } };
|
|
110
|
+
}
|
package/report/sonar.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export function mergeReports(reports) {
|
|
|
50
50
|
* indexes, and a finding about hfs.json, a workflow, a config file or a directory is not on one, so it is filed on this
|
|
51
51
|
* file and its message names the real path. Null when the tracked tree has no source file.
|
|
52
52
|
*/
|
|
53
|
-
|
|
53
|
+
function anchorOf(tracked, sourceRoots) {
|
|
54
54
|
const roots = sourceRoots.map((root) => `${posix(root).replace(/\/$/, '')}/`);
|
|
55
55
|
return [...tracked].map(posix).filter((file) => SOURCE_FILE.test(file) && roots.some((root) => file.startsWith(root))).sort(cmp)[0] ?? null;
|
|
56
56
|
}
|
|
@@ -185,10 +185,11 @@ sonar: {organization: null}
|
|
|
185
185
|
# minFreeRamPct free-RAM percentage below which no new heavy op starts
|
|
186
186
|
# set them only in the gitignored config.yaml copy, e.g.
|
|
187
187
|
# resources: {minFreeDiskGb: 5, minFreeDiskPct: 1, minFreeRamPct: 10}
|
|
188
|
-
# release
|
|
189
|
-
# suite: local the shipped default: `starci release cut` runs the root
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
188
|
+
# release - where the Linux parity of a release is judged (scripts/guards/release-suite-mode.mjs; docs/releasing.md "Where the suite runs"). One key:
|
|
189
|
+
# suite: local the shipped default: `starci release cut` runs the root rows (npm run test:packages, npm run test:e2e, npm run check) and the Linux parity container before it pushes,
|
|
190
|
+
# and the pre-push gate asks for the green root rows
|
|
191
|
+
# suite: ci the owner's recorded choice (2026-10-09): no local Linux container; the GitHub ci workflow runs the Linux parity (and SonarCloud) AFTER the push.
|
|
192
|
+
# The cut still runs the same root rows and the example apps; the record lists linux-parity as delegated, never green.
|
|
193
|
+
# A release can reach GitHub and its tag before the Linux parity has run; a red CI is fixed forward with the next pre-release (starci release ci-status).
|
|
194
|
+
# "none" is refused: the Linux parity does not vanish, it moves to CI. Set it only in the gitignored config.yaml copy: release: {suite: ci}
|
|
194
195
|
release: {suite: local}
|