@zaaxch/tailframe 4.0.0 → 4.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaaxch/tailframe",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/new.mjs CHANGED
@@ -131,6 +131,8 @@ add("package.json", JSON.stringify({
131
131
  add("pnpm-workspace.yaml", `packages:
132
132
  - "apps/*"
133
133
  injectWorkspacePackages: true
134
+ minimumReleaseAgeExclude:
135
+ - "@zaaxch/tailframe@${contractVersion}"
134
136
  allowBuilds:
135
137
  "@firebase/util": true
136
138
  esbuild: true
@@ -241,8 +243,7 @@ add(`${svc}/package.json`, JSON.stringify({
241
243
  "schema:apply": "node dist/app/cli/applySchema.js", "schema:apply:dev": "ts-node -r tsconfig-paths/register src/app/cli/applySchema.ts",
242
244
  "test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
243
245
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
244
- "docker:prod": "docker compose -f docker-compose.yml up -d",
245
- "docker:push": "bash scripts/build_and_push.sh"
246
+ "docker:prod": "docker compose -f docker-compose.yml up -d"
246
247
  }, dependencies: svcDeps, devDependencies: svcDevDeps
247
248
  }, null, "\t"));
248
249
  add(`${svc}/tsconfig.json`, JSON.stringify({
@@ -550,10 +551,10 @@ ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a
550
551
  The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<product-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<product-sha>` and production Compose requires that exact tag."} The runtime image contains production dependencies only and runs as the Node user.${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
551
552
 
552
553
  ## Production deployment
553
- Only service \`main\` pushes or manual workflow dispatch deploy; UI pushes never trigger deployment. GitHub Actions may replace files under \`/opt/${options.name}/current\` and MUST NOT replace persistent configuration, credentials, keyfiles, or data under \`/opt/${options.name}/shared\`. Keep Node runtime behavior in the shared service \`.env.production\`, Compose interpolation and infrastructure credentials in \`.env.infrastructure\`, and fixed topology wiring in Compose \`environment:\`. The optional UI build-time \`.env.production\` is a separate ephemeral file. Do not run deployment until the host and GitHub production environment have been bootstrapped according to \`docs/production-deployment.md\`.
554
+ Pull requests into product \`main\` validate release candidates. The resulting \`main\` push validates again before GitHub Actions may publish an immutable image tagged with the full product commit SHA. Image publication and production deployment are separate workflows: deployment is manual from \`main\`, accepts only an already-published full SHA from \`main\` history, and checks out that exact commit for deployment files. GitHub Actions may replace files under \`/opt/${options.name}/current\` and MUST NOT replace persistent configuration, credentials, keyfiles, or data under \`/opt/${options.name}/shared\`. Keep Node runtime behavior in the shared service \`.env.production\`, Compose interpolation and infrastructure credentials in \`.env.infrastructure\`, and fixed topology wiring in Compose \`environment:\`. The optional UI build-time \`.env.production\` is a separate ephemeral file. Do not run deployment until the host and GitHub environments have been bootstrapped according to \`docs/production-deployment.md\`.
554
555
 
555
556
  ## Tests and validation
556
- Run \`pnpm validate:architecture\` and \`pnpm format:check\` after changing files or imports. The generated service also includes unit tests and ${postgres ? "PostgreSQL-backed" : "Mongo-backed"} integration tests. Run focused service database tests through pnpm for the full service check. Test use cases, policies, ${postgres ? "PostgreSQL" : "Mongo"} repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need ${postgres ? "PostgreSQL" : "Mongo"} ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including the service \`docker:push\` script, must run outside the sandbox from the first attempt.
557
+ Run \`pnpm validate:architecture\` and \`pnpm format:check\` after changing files or imports. The generated service also includes unit tests and ${postgres ? "PostgreSQL-backed" : "Mongo-backed"} integration tests. Run focused service database tests through pnpm for the full service check. Test use cases, policies, ${postgres ? "PostgreSQL" : "Mongo"} repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need ${postgres ? "PostgreSQL" : "Mongo"} ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including the release image script, must run outside the sandbox from the first attempt.
557
558
  `);
558
559
 
559
560
  if (options.ui) {
@@ -564,8 +565,8 @@ add(`${ui}/package.json`, JSON.stringify({ name: uiPackage, version: "0.1.0", pr
564
565
  add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
565
566
  add(`${ui}/.gitattributes`, gitAttributes);
566
567
  add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
567
- add(`${ui}/tsconfig.app.json`, JSON.stringify({ extends: "@vue/tsconfig/tsconfig.dom.json", include: ["env.d.ts", "src/**/*", "src/**/*.vue"], compilerOptions: { composite: true, baseUrl: ".", paths: { "@/*": ["./src/*"] } } }, null, "\t"));
568
- add(`${ui}/tsconfig.node.json`, JSON.stringify({ extends: "@tsconfig/node22/tsconfig.json", include: ["vite.config.*"], compilerOptions: { composite: true, types: ["node"] } }, null, "\t"));
568
+ add(`${ui}/tsconfig.app.json`, JSON.stringify({ extends: "@vue/tsconfig/tsconfig.dom.json", include: ["env.d.ts", "src/**/*", "src/**/*.vue"], compilerOptions: { composite: true, noEmit: true, tsBuildInfoFile: "./.tmp/tsconfig.app.tsbuildinfo", types: ["vitest/globals"], baseUrl: ".", paths: { "@/*": ["./src/*"] } } }, null, "\t"));
569
+ add(`${ui}/tsconfig.node.json`, JSON.stringify({ extends: "@tsconfig/node22/tsconfig.json", include: ["vite.config.ts"], compilerOptions: { composite: true, noEmit: true, tsBuildInfoFile: "./.tmp/tsconfig.node.tsbuildinfo", types: ["node"] } }, null, "\t"));
569
570
  add(`${ui}/vite.config.ts`, `import { fileURLToPath, URL } from "node:url";\nimport { defineConfig } from "vitest/config";\nimport vue from "@vitejs/plugin-vue";\nimport tailwindcss from "@tailwindcss/vite";\nexport default defineConfig({ plugins: [vue(), tailwindcss()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, server: { proxy: { "/api": { target: "http://localhost:3000", changeOrigin: true } } }, test: { environment: "jsdom", globals: true } });`);
570
571
  add(`${ui}/env.d.ts`, `/// <reference types="vite/client" />`);
571
572
  add(`${ui}/index.html`, `<!doctype html>
@@ -581,7 +582,7 @@ add(`${ui}/index.html`, `<!doctype html>
581
582
  </body>
582
583
  </html>
583
584
  `);
584
- add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.log\n`);
585
+ add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.log\n.tmp/\n`);
585
586
  if (options.auth === "firebase") add(`${ui}/.env.example`, `VITE_FIREBASE_API_KEY=\nVITE_FIREBASE_AUTH_DOMAIN=\nVITE_FIREBASE_PROJECT_ID=\nVITE_FIREBASE_APP_ID=\n`);
586
587
  add(`${ui}/src/core/errors.ts`, uiErrorsSource);
587
588
  add(`${ui}/src/core/rpc.ts`, uiRpcSource);
@@ -719,7 +720,7 @@ Run \`pnpm validate:architecture\` and \`pnpm format:check\`, then add focused V
719
720
  }
720
721
 
721
722
  add(`${svc}/Dockerfile`, `FROM node:22.13-bookworm AS build
722
- RUN corepack enable
723
+ RUN npm install --global corepack@0.34.5 && corepack enable
723
724
  WORKDIR /workspace
724
725
  COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
725
726
  COPY apps/service/package.json apps/service/package.json
@@ -737,7 +738,7 @@ EXPOSE 3000
737
738
  CMD ["node", "dist/server.js"]
738
739
  `);
739
740
  add(`${svc}/Dockerfile.dev`, `FROM node:22.13-alpine
740
- RUN corepack enable
741
+ RUN npm install --global corepack@0.34.5 && corepack enable
741
742
  WORKDIR /workspace
742
743
  COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
743
744
  COPY apps/service/package.json apps/service/package.json
@@ -755,7 +756,7 @@ set -euo pipefail
755
756
 
756
757
  SERVICE_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
757
758
  PRODUCT_ROOT="$(cd "\${SERVICE_DIR}/../.." && pwd)"
758
- PRODUCT_SHA="$(git -C "\${PRODUCT_ROOT}" rev-parse --short HEAD)"
759
+ PRODUCT_SHA="$(git -C "\${PRODUCT_ROOT}" rev-parse HEAD)"
759
760
  ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
760
761
  IMAGE_TAG="\${IMAGE_TAG:-\${PRODUCT_SHA}}"
761
762
  REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
@@ -763,6 +764,11 @@ IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
763
764
  DOCKERFILE="\${SERVICE_DIR}/Dockerfile"
764
765
  ${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${PRODUCT_ROOT}/apps/ui/.env.production}"` : ""}
765
766
 
767
+ if [[ "\${IMAGE_TAG}" != "\${PRODUCT_SHA}" ]]; then
768
+ echo "IMAGE_TAG must equal the full product commit SHA (\${PRODUCT_SHA})" >&2
769
+ exit 1
770
+ fi
771
+
766
772
  aws ecr get-login-password --region "\${AWS_REGION}" |
767
773
  docker login --username AWS --password-stdin "\${REGISTRY}"
768
774
 
@@ -771,106 +777,305 @@ docker push "\${IMAGE}"
771
777
 
772
778
  echo "Pushed \${IMAGE}"
773
779
  `);
774
- const workflowUiPath = options.ui ? ' - "apps/ui/**"\n' : "";
780
+ const workflowUiPath = options.ui ? ' - "apps/ui/**"\n' : "";
775
781
  const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
776
- - name: Write UI production environment
777
- env:
778
- UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
779
- run: |
780
- printf '%s\\n' "$UI_ENV_PRODUCTION" > apps/ui/.env.production
782
+ - name: Write UI production environment
783
+ if: steps.existing.outputs.exists != 'true'
784
+ env:
785
+ UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
786
+ run: |
787
+ test -n "$UI_ENV_PRODUCTION"
788
+ printf '%s\\n' "$UI_ENV_PRODUCTION" > apps/ui/.env.production
781
789
  ` : "";
782
- add(".github/workflows/deploy.yml", `name: Build and deploy
790
+ const deploymentArtifactDirectory = postgres ? "postgres" : "mongo";
791
+ const deploymentArtifactFile = postgres ? "10-create-application-user.sh" : "10-create-users.js";
792
+ const deploymentArtifactChmod = postgres ? ` /opt/${options.name}/current/deploy/postgres/10-create-application-user.sh` : "";
793
+ add(".github/workflows/release-image.yml", `name: Validate and build release image
783
794
 
784
795
  on:
785
- push:
786
- branches:
787
- - main
788
- paths:
789
- - "apps/service/**"
790
- ${workflowUiPath} - "package.json"
791
- - "pnpm-workspace.yaml"
792
- - "pnpm-lock.yaml"
793
- - ".github/workflows/deploy.yml"
794
- workflow_dispatch:
796
+ pull_request:
797
+ branches:
798
+ - main
799
+ paths:
800
+ - "apps/service/**"
801
+ ${workflowUiPath} - "package.json"
802
+ - "pnpm-workspace.yaml"
803
+ - "pnpm-lock.yaml"
804
+ - "tailframe.json"
805
+ - ".github/workflows/release-image.yml"
806
+ push:
807
+ branches:
808
+ - main
809
+ paths:
810
+ - "apps/service/**"
811
+ ${workflowUiPath} - "package.json"
812
+ - "pnpm-workspace.yaml"
813
+ - "pnpm-lock.yaml"
814
+ - "tailframe.json"
815
+ - ".github/workflows/release-image.yml"
795
816
 
796
817
  permissions:
797
- contents: read
798
- id-token: write
818
+ contents: read
799
819
 
800
820
  concurrency:
801
- group: ${options.name}-production
802
- cancel-in-progress: false
821
+ group: ${options.name}-release-\${{ github.ref }}
822
+ cancel-in-progress: true
803
823
 
804
824
  jobs:
805
- deploy:
806
- name: Deploy production
807
- if: \${{ vars.DEPLOY_HOST != '' && vars.DEPLOY_USER != '' }}
808
- runs-on: ubuntu-latest
809
- environment: production
810
- env:
811
- AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
812
- AWS_REGION: \${{ vars.AWS_REGION }}
813
- ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
814
- DEPLOY_HOST: \${{ vars.DEPLOY_HOST }}
815
- DEPLOY_USER: \${{ vars.DEPLOY_USER }}
816
-
817
- steps:
818
- - name: Checkout product
819
- uses: actions/checkout@v6
820
- ${workflowUiEnvironment}
821
- - name: Resolve immutable image
822
- id: image
823
- run: |
824
- product_sha="$(git rev-parse --short HEAD)"
825
- echo "tag=\${product_sha}" >> "$GITHUB_OUTPUT"
826
- echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
827
- echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
828
-
829
- - name: Configure AWS credentials
830
- uses: aws-actions/configure-aws-credentials@v6
831
- with:
832
- role-to-assume: \${{ vars.AWS_ROLE_ARN }}
833
- aws-region: \${{ vars.AWS_REGION }}
834
-
835
- - name: Build and push image
825
+ validate:
826
+ name: Validate release candidate
827
+ runs-on: ubuntu-latest
828
+
829
+ steps:
830
+ - name: Checkout product
831
+ uses: actions/checkout@v6
832
+
833
+ - name: Install pnpm
834
+ uses: pnpm/action-setup@v4
835
+ with:
836
+ version: 11.22.0
837
+
838
+ - name: Install Node.js
839
+ uses: actions/setup-node@v6
840
+ with:
841
+ node-version: 22.13.0
842
+ cache: pnpm
843
+
844
+ - name: Install dependencies
845
+ run: pnpm install --frozen-lockfile
846
+
847
+ - name: Validate architecture and formatting
848
+ run: |
849
+ pnpm sync:architecture
850
+ pnpm validate:architecture
851
+ pnpm format:check
852
+
853
+ - name: Start integration database
854
+ run: pnpm --filter ${servicePackage} test:db:up
855
+
856
+ - name: Run application tests
857
+ run: pnpm test
858
+
859
+ - name: Stop integration database
860
+ if: always()
861
+ run: pnpm --filter ${servicePackage} test:db:down
862
+
863
+ - name: Type-check and build applications
864
+ run: |
865
+ pnpm type-check
866
+ pnpm build
867
+
868
+ build-image:
869
+ name: Build immutable image
870
+ needs: validate
871
+ if: >-
872
+ github.event_name == 'push' &&
873
+ vars.AWS_ACCOUNT_ID != '' &&
874
+ vars.AWS_REGION != '' &&
875
+ vars.AWS_ROLE_ARN != ''
876
+ runs-on: ubuntu-latest
877
+ environment:
878
+ name: release
879
+ deployment: false
880
+ permissions:
881
+ contents: read
882
+ id-token: write
836
883
  env:
837
- IMAGE_TAG: \${{ steps.image.outputs.tag }}
838
- run: apps/service/scripts/build_and_push.sh
884
+ AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
885
+ AWS_REGION: \${{ vars.AWS_REGION }}
886
+ ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
887
+
888
+ steps:
889
+ - name: Checkout product
890
+ uses: actions/checkout@v6
891
+
892
+ - name: Configure AWS credentials
893
+ uses: aws-actions/configure-aws-credentials@v6
894
+ with:
895
+ role-to-assume: \${{ vars.AWS_ROLE_ARN }}
896
+ aws-region: \${{ vars.AWS_REGION }}
897
+
898
+ - name: Resolve immutable image
899
+ id: image
900
+ run: |
901
+ product_sha="$(git rev-parse HEAD)"
902
+ registry="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
903
+ image="\${registry}/\${ECR_REPOSITORY}"
904
+ echo "tag=\${product_sha}" >> "$GITHUB_OUTPUT"
905
+ echo "registry=\${registry}" >> "$GITHUB_OUTPUT"
906
+ echo "image=\${image}" >> "$GITHUB_OUTPUT"
907
+
908
+ - name: Check for existing image
909
+ id: existing
910
+ env:
911
+ IMAGE_TAG: \${{ steps.image.outputs.tag }}
912
+ run: |
913
+ digest="$(aws ecr describe-images \\
914
+ --repository-name "$ECR_REPOSITORY" \\
915
+ --image-ids "imageTag=$IMAGE_TAG" \\
916
+ --query 'imageDetails[0].imageDigest' \\
917
+ --output text 2>/dev/null || true)"
918
+ if [[ -n "$digest" && "$digest" != "None" ]]; then
919
+ echo "exists=true" >> "$GITHUB_OUTPUT"
920
+ else
921
+ echo "exists=false" >> "$GITHUB_OUTPUT"
922
+ fi
923
+ ${workflowUiEnvironment}
924
+ - name: Build and push image
925
+ if: steps.existing.outputs.exists != 'true'
926
+ env:
927
+ IMAGE_TAG: \${{ steps.image.outputs.tag }}
928
+ run: apps/service/scripts/build_and_push.sh
929
+
930
+ - name: Verify published image
931
+ id: published
932
+ env:
933
+ IMAGE_TAG: \${{ steps.image.outputs.tag }}
934
+ run: |
935
+ digest="$(aws ecr describe-images \\
936
+ --repository-name "$ECR_REPOSITORY" \\
937
+ --image-ids "imageTag=$IMAGE_TAG" \\
938
+ --query 'imageDetails[0].imageDigest' \\
939
+ --output text)"
940
+ test -n "$digest"
941
+ test "$digest" != "None"
942
+ echo "digest=\${digest}" >> "$GITHUB_OUTPUT"
943
+
944
+ - name: Record release image
945
+ env:
946
+ IMAGE: \${{ steps.image.outputs.image }}
947
+ IMAGE_TAG: \${{ steps.image.outputs.tag }}
948
+ IMAGE_DIGEST: \${{ steps.published.outputs.digest }}
949
+ run: |
950
+ {
951
+ echo "### ${title} release image"
952
+ echo
953
+ echo "- Image: \`\${IMAGE}:\${IMAGE_TAG}\`"
954
+ echo "- Digest: \`\${IMAGE_DIGEST}\`"
955
+ } >> "$GITHUB_STEP_SUMMARY"
956
+ `);
957
+ add(".github/workflows/deploy.yml", `name: Deploy release image
958
+
959
+ on:
960
+ workflow_dispatch:
961
+ inputs:
962
+ image_tag:
963
+ description: Full 40-character product commit SHA already published to ECR
964
+ required: true
965
+ type: string
966
+
967
+ permissions:
968
+ contents: read
969
+ id-token: write
970
+
971
+ concurrency:
972
+ group: ${options.name}-production
973
+ cancel-in-progress: false
839
974
 
840
- - name: Configure deployment SSH
975
+ jobs:
976
+ deploy:
977
+ name: Deploy production
978
+ if: github.ref == 'refs/heads/main'
979
+ runs-on: ubuntu-latest
980
+ environment: production
841
981
  env:
842
- DEPLOY_SSH_PRIVATE_KEY: \${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
843
- DEPLOY_SSH_KNOWN_HOSTS: \${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
844
- run: |
845
- install -m 700 -d ~/.ssh
846
- printf '%s\\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/${options.name}-deploy
847
- chmod 600 ~/.ssh/${options.name}-deploy
848
- printf '%s\\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
849
-
850
- - name: Authenticate server with ECR
851
- run: |
852
- aws ecr get-login-password --region "$AWS_REGION" |
853
- ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
854
- "docker login --username AWS --password-stdin '\${{ steps.image.outputs.registry }}'"
855
-
856
- - name: Install deployment files
857
- run: |
858
- ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
859
- "install -m 755 -d /opt/${options.name}/current/deploy/mongo"
860
- scp -i ~/.ssh/${options.name}-deploy \\
861
- apps/service/docker-compose.yml \\
862
- apps/service/scripts/deploy_remote.sh \\
863
- "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
864
- scp -i ~/.ssh/${options.name}-deploy \\
865
- apps/service/deploy/mongo/10-create-users.js \\
866
- "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
867
- ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
868
- "chmod 755 /opt/${options.name}/current/deploy_remote.sh"
869
-
870
- - name: Deploy image
871
- run: |
872
- ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
873
- "/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
982
+ AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
983
+ AWS_REGION: \${{ vars.AWS_REGION }}
984
+ ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
985
+ DEPLOY_HOST: \${{ vars.DEPLOY_HOST }}
986
+ DEPLOY_USER: \${{ vars.DEPLOY_USER }}
987
+ IMAGE_TAG: \${{ inputs.image_tag }}
988
+
989
+ steps:
990
+ - name: Validate deployment input
991
+ run: |
992
+ [[ "$IMAGE_TAG" =~ ^[0-9a-f]{40}$ ]]
993
+ test -n "$AWS_ACCOUNT_ID"
994
+ test -n "$AWS_REGION"
995
+ test -n "$DEPLOY_HOST"
996
+ test -n "$DEPLOY_USER"
997
+
998
+ - name: Checkout exact product commit
999
+ uses: actions/checkout@v6
1000
+ with:
1001
+ ref: \${{ inputs.image_tag }}
1002
+ fetch-depth: 0
1003
+
1004
+ - name: Verify product release commit
1005
+ run: |
1006
+ test "$(git rev-parse HEAD)" = "$IMAGE_TAG"
1007
+ git merge-base --is-ancestor "$IMAGE_TAG" origin/main
1008
+
1009
+ - name: Configure AWS credentials
1010
+ uses: aws-actions/configure-aws-credentials@v6
1011
+ with:
1012
+ role-to-assume: \${{ vars.AWS_ROLE_ARN }}
1013
+ aws-region: \${{ vars.AWS_REGION }}
1014
+
1015
+ - name: Resolve and verify image
1016
+ id: image
1017
+ run: |
1018
+ registry="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
1019
+ image="\${registry}/\${ECR_REPOSITORY}"
1020
+ digest="$(aws ecr describe-images \\
1021
+ --repository-name "$ECR_REPOSITORY" \\
1022
+ --image-ids "imageTag=$IMAGE_TAG" \\
1023
+ --query 'imageDetails[0].imageDigest' \\
1024
+ --output text)"
1025
+ test -n "$digest"
1026
+ test "$digest" != "None"
1027
+ echo "registry=\${registry}" >> "$GITHUB_OUTPUT"
1028
+ echo "image=\${image}" >> "$GITHUB_OUTPUT"
1029
+ echo "digest=\${digest}" >> "$GITHUB_OUTPUT"
1030
+
1031
+ - name: Configure deployment SSH
1032
+ env:
1033
+ DEPLOY_SSH_PRIVATE_KEY: \${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
1034
+ DEPLOY_SSH_KNOWN_HOSTS: \${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
1035
+ run: |
1036
+ test -n "$DEPLOY_SSH_PRIVATE_KEY"
1037
+ test -n "$DEPLOY_SSH_KNOWN_HOSTS"
1038
+ install -m 700 -d ~/.ssh
1039
+ printf '%s\\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/${options.name}-deploy
1040
+ chmod 600 ~/.ssh/${options.name}-deploy
1041
+ printf '%s\\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
1042
+
1043
+ - name: Authenticate server with ECR
1044
+ run: |
1045
+ aws ecr get-login-password --region "$AWS_REGION" |
1046
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
1047
+ "docker login --username AWS --password-stdin '\${{ steps.image.outputs.registry }}'"
1048
+
1049
+ - name: Install deployment files
1050
+ run: |
1051
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
1052
+ "install -m 755 -d /opt/${options.name}/current/deploy/${deploymentArtifactDirectory}"
1053
+ scp -i ~/.ssh/${options.name}-deploy \\
1054
+ apps/service/docker-compose.yml \\
1055
+ apps/service/scripts/deploy_remote.sh \\
1056
+ "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
1057
+ scp -i ~/.ssh/${options.name}-deploy \\
1058
+ apps/service/deploy/${deploymentArtifactDirectory}/${deploymentArtifactFile} \\
1059
+ "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/${deploymentArtifactDirectory}/"
1060
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
1061
+ "chmod 755 /opt/${options.name}/current/deploy_remote.sh${deploymentArtifactChmod}"
1062
+
1063
+ - name: Deploy image
1064
+ run: |
1065
+ ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
1066
+ "/opt/${options.name}/current/deploy_remote.sh '$IMAGE_TAG' '\${{ steps.image.outputs.image }}'"
1067
+
1068
+ - name: Record deployment
1069
+ env:
1070
+ IMAGE: \${{ steps.image.outputs.image }}
1071
+ IMAGE_DIGEST: \${{ steps.image.outputs.digest }}
1072
+ run: |
1073
+ {
1074
+ echo "### ${title} production deployment"
1075
+ echo
1076
+ echo "- Image: \`\${IMAGE}:\${IMAGE_TAG}\`"
1077
+ echo "- Digest: \`\${IMAGE_DIGEST}\`"
1078
+ } >> "$GITHUB_STEP_SUMMARY"
874
1079
  `);
875
1080
  add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
876
1081
  set -euo pipefail
@@ -923,9 +1128,20 @@ add(`${svc}/docs/production-deployment.md`, `# Production deployment
923
1128
 
924
1129
  ## Delivery model
925
1130
 
926
- GitHub Actions checks out the product once, builds one
927
- non-root production image, tags it \`<product-sha>\`, pushes it to ECR, and installs only deployment artifacts on the host.
928
- Changes to the service, optional UI, root workspace/lock configuration, or deployment files on product \`main\`, plus manual dispatch, trigger this workflow.
1131
+ Delivery is split into two GitHub Actions workflows:
1132
+
1133
+ 1. \`release-image.yml\` validates pull requests into \`main\`. After merge, the resulting \`main\` push is validated
1134
+ again before the workflow builds one non-root production image, tags it with the full 40-character product commit
1135
+ SHA, and pushes it to ECR. If that tag already exists, the workflow reuses it instead of rebuilding it.
1136
+ 2. \`deploy.yml\` is manual-only. It runs only from product \`main\`, accepts the full SHA of an existing ECR image,
1137
+ verifies the commit belongs to \`main\` history, checks out that exact commit for deployment files, and updates the host.
1138
+
1139
+ Only service${options.ui ? ", UI," : ","} workspace, lockfile, architecture, and release-workflow changes trigger validation.
1140
+ The ECR build job runs only for the post-merge \`main\` push and remains skipped until the repository AWS variables are
1141
+ configured. No push or merge deploys production automatically.
1142
+
1143
+ Configure the ECR repository with immutable image tags and restrict routine push permission to the repository's
1144
+ release workflow. Record the full commit SHA and ECR digest; deployment and migration must use that release image.
929
1145
 
930
1146
  The host separates replaceable artifacts from persistent state:
931
1147
 
@@ -934,14 +1150,15 @@ The host separates replaceable artifacts from persistent state:
934
1150
  ├── current/
935
1151
  │ ├── docker-compose.yml
936
1152
  │ ├── deploy_remote.sh
937
- │ └── deploy/mongo/10-create-users.js
1153
+ │ └── deploy/${deploymentArtifactDirectory}/${deploymentArtifactFile}
938
1154
  └── shared/
939
1155
  ├── .env.production
940
1156
  ├── .env.infrastructure
941
1157
  ${options.auth === "firebase" ? ` ├── secrets/firebase-service-account.json
942
- ` : ""} ├── mongo/config/keyfile
1158
+ ` : ""}${postgres ? ` ├── postgres/data/
1159
+ ` : ` ├── mongo/config/keyfile
943
1160
  ├── mongo/data/
944
- ${options.redis ? ` └── redis/data/
1161
+ `}${options.redis ? ` └── redis/data/
945
1162
  ` : ""}\`\`\`
946
1163
 
947
1164
  Actions may replace \`current/\` and must never replace \`shared/\`. The server never needs a source checkout.
@@ -960,42 +1177,53 @@ Do not inject \`.env.infrastructure\` into Node. Make both host environment file
960
1177
  ## One-time host bootstrap
961
1178
 
962
1179
  1. Install Docker Engine and Docker Compose.
963
- 2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
964
- \`/opt/${options.name}/shared/mongo/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.
1180
+ ${postgres
1181
+ ? `2. Create \`/opt/${options.name}/current/deploy/postgres\` and \`/opt/${options.name}/shared/postgres/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.`
1182
+ : `2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
1183
+ \`/opt/${options.name}/shared/mongo/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.`}
965
1184
  3. Install \`.env.production\` and a completed \`.env.infrastructure\` under \`shared/\`.
966
- 4. Generate the MongoDB replica key with \`openssl rand -base64 756\`, make it owned by UID/GID \`999:999\`, and
1185
+ ${postgres ? "" : `4. Generate the MongoDB replica key with \`openssl rand -base64 756\`, make it owned by UID/GID \`999:999\`, and
967
1186
  mode \`0400\`.
968
- ${options.auth === "firebase" ? `5. Install the Firebase service account at \`shared/secrets/firebase-service-account.json\`, owned by \`root:1000\` and mode \`0640\`.
969
- 6. ` : "5. "}Give the deployment user Docker access and write access to \`current/\`; keep \`shared/\` and its files protected.
970
- ${options.auth === "firebase" ? "7" : "6"}. Authorize the dedicated deployment SSH key and record the host key for GitHub Actions.
1187
+ `}${options.auth === "firebase" ? `${postgres ? "4" : "5"}. Install the Firebase service account at \`shared/secrets/firebase-service-account.json\`, owned by \`root:1000\` and mode \`0640\`.
1188
+ ` : ""}${postgres ? (options.auth === "firebase" ? "5" : "4") : (options.auth === "firebase" ? "6" : "5")}. Give the deployment user Docker access and write access to \`current/\`; keep \`shared/\` and its files protected.
1189
+ ${postgres ? (options.auth === "firebase" ? "6" : "5") : (options.auth === "firebase" ? "7" : "6")}. Authorize the dedicated deployment SSH key and record the host key for GitHub Actions.
971
1190
 
972
- Use a fresh Mongo data directory. Migrating an existing unauthenticated database requires a separately reviewed plan.
1191
+ Use a fresh ${postgres ? "PostgreSQL" : "MongoDB"} data directory. Migrating an existing unauthenticated database requires a separately reviewed plan.
973
1192
 
974
- ## GitHub production environment
1193
+ ## GitHub repository variables
1194
+
1195
+ Configure \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, and \`AWS_ROLE_ARN\` as repository variables for the
1196
+ repository-restricted OIDC role. Optionally set \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
1197
+
1198
+ The ECR repository and AWS OIDC provider/role are provisioned separately. Workflows never store long-lived AWS keys
1199
+ or create cloud infrastructure.
975
1200
 
976
- Configure these variables:
1201
+ ## GitHub release environment
977
1202
 
978
- - \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, and \`AWS_ROLE_ARN\` for the repository-restricted OIDC role.
979
- - \`DEPLOY_HOST\` and \`DEPLOY_USER\`; the deployment job remains skipped until both exist.
980
- - Optional \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
1203
+ ${options.ui && options.auth === "firebase" ? `Configure the \`UI_ENV_PRODUCTION\` secret for the Vite/Firebase build-time environment. The release environment MAY require approval before exposing production-capable browser configuration.
1204
+ ` : "The release workflow uses this environment without creating a deployment record. It MAY require approval before publishing an image.\n"}
1205
+ ## GitHub production environment
981
1206
 
982
- Configure these secrets:
1207
+ Configure \`DEPLOY_HOST\` and \`DEPLOY_USER\` as production-environment variables. Configure these secrets:
983
1208
 
984
- ${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
985
- ` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
1209
+ - \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
986
1210
  - \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
987
1211
 
988
- The ECR repository and AWS OIDC provider/role are provisioned separately. The workflow never stores long-lived AWS
989
- keys or creates cloud infrastructure.
1212
+ Restrict the production environment's deployment branches to \`main\`. Required reviewer approval MAY add a second
1213
+ manual gate, but the branch restriction and manual full-SHA input remain mandatory.
990
1214
 
991
1215
  ## Deploy and rollback
992
1216
 
1217
+ Run \`Deploy release image\` manually from \`main\` and enter the full SHA recorded by the release workflow. The
1218
+ deployment fails before contacting the host if the SHA is invalid, is not in \`main\` history, cannot be checked out,
1219
+ or has no matching ECR image.
1220
+
993
1221
  The remote script requires the provisioned infrastructure file, atomically replaces only \`ECR_IMAGE\` and
994
1222
  \`IMAGE_TAG\`, validates Compose, pulls, and starts with health waiting. Roll back by selecting a previous immutable
995
- tag and running the same script. Never use \`docker compose down -v\` in production; image rollback does not restore
996
- database state.
1223
+ full-SHA release and running the same workflow. Never use \`docker compose down -v\` in production; image rollback
1224
+ does not restore database state.
997
1225
 
998
- MongoDB and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
1226
+ ${postgres ? "PostgreSQL" : "MongoDB"} and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
999
1227
  database/cache authentication, file permissions, and off-host backup/restore before treating the deployment as
1000
1228
  production-ready. Product-specific TLS, capacity limits, migrations, and scheduled-work grace periods must be added
1001
1229
  from measured application requirements rather than copied from another product.
@@ -1005,8 +1233,9 @@ const serviceVolumes = [
1005
1233
  ...(options.auth === "firebase" ? [" - ${SECRETS_DIR:?Set SECRETS_DIR}/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro"] : [])
1006
1234
  ];
1007
1235
  const devVolumes = [
1008
- " - .:/app",
1009
- " - /app/node_modules",
1236
+ " - .:/workspace/apps/service",
1237
+ " - /workspace/node_modules",
1238
+ " - /workspace/apps/service/node_modules",
1010
1239
  ...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
1011
1240
  ];
1012
1241
  const productionDependencies = [
@@ -1031,9 +1260,13 @@ const productionEnvironment = [
1031
1260
  ...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
1032
1261
  ];
1033
1262
  const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
1034
- const databaseEnvironmentBlock = postgres
1035
- ? `\n environment:\n POSTGRES_URI: "postgresql://${options.name}-app:development@postgres:5432/${databaseName}"`
1036
- : `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
1263
+ const developmentEnvironment = [
1264
+ postgres
1265
+ ? ` POSTGRES_URI: "postgresql://${options.name}-app:development@postgres:5432/${databaseName}"`
1266
+ : ` MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`,
1267
+ ...(options.auth === "firebase" ? [" FIREBASE_SERVICE_ACCOUNT_PATH: /app/firebase-service-account.development.json"] : [])
1268
+ ];
1269
+ const developmentEnvironmentBlock = `\n environment:\n${developmentEnvironment.join("\n")}`;
1037
1270
  const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
1038
1271
  const workerService = options.worker ? `\n worker:
1039
1272
  image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
@@ -1206,7 +1439,7 @@ const devWorker = options.worker ? `\n worker:
1206
1439
  dockerfile: Dockerfile.dev
1207
1440
  env_file:
1208
1441
  - .env.development
1209
- command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
1442
+ command: ["npm", "run", "dev:worker"]${developmentEnvironmentBlock}
1210
1443
  volumes:
1211
1444
  ${devVolumes.join("\n")}${devDependsBlock}` : "";
1212
1445
  const devDatabase = postgres ? `\n postgres:
@@ -1270,7 +1503,7 @@ services:
1270
1503
  dockerfile: Dockerfile.dev
1271
1504
  env_file:
1272
1505
  - .env.development
1273
- command: ["npm", "run", "schema:apply:dev"]${databaseEnvironmentBlock}
1506
+ command: ["npm", "run", "schema:apply:dev"]${developmentEnvironmentBlock}
1274
1507
  volumes:
1275
1508
  ${devVolumes.join("\n")}
1276
1509
  depends_on:
@@ -1283,7 +1516,7 @@ ${devVolumes.join("\n")}
1283
1516
  env_file:
1284
1517
  - .env.development
1285
1518
  ports:
1286
- - "3000:3000"${databaseEnvironmentBlock}
1519
+ - "3000:3000"${developmentEnvironmentBlock}
1287
1520
  volumes:
1288
1521
  ${devVolumes.join("\n")}${devDependsBlock}${devWorker}${devDatabase}${devRedis}
1289
1522
  volumes:
package/src/validate.mjs CHANGED
@@ -65,6 +65,50 @@ function validateAppMetadata(productRoot, app) {
65
65
  const errors = [];
66
66
  const root = appRoot(productRoot, app);
67
67
  if (!fs.existsSync(root)) return [`Missing configured application root: ${app.path}`];
68
+ if (app.kind === "service") {
69
+ const composeFile = path.join(root, "docker-compose.dev.yml");
70
+ if (!fs.existsSync(composeFile)) errors.push(`${app.path}/docker-compose.dev.yml is required`);
71
+ else {
72
+ const compose = fs.readFileSync(composeFile, "utf8");
73
+ const generatedProcessCount = app.profiles.includes("worker") ? 3 : 2;
74
+ const count = (term) => compose.split(term).length - 1;
75
+ for (const term of [
76
+ " - .:/workspace/apps/service",
77
+ " - /workspace/node_modules",
78
+ " - /workspace/apps/service/node_modules"
79
+ ]) {
80
+ if (count(term) < generatedProcessCount) {
81
+ errors.push(`${app.path}/docker-compose.dev.yml must mount every generated service process at the image workspace: ${term.trim()}`);
82
+ }
83
+ }
84
+ if (compose.includes(" - .:/app\n")) {
85
+ errors.push(`${app.path}/docker-compose.dev.yml uses the obsolete /app service source mount`);
86
+ }
87
+ if (app.profiles.includes("firebase")) {
88
+ for (const term of [
89
+ " FIREBASE_SERVICE_ACCOUNT_PATH: /app/firebase-service-account.development.json",
90
+ " - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"
91
+ ]) {
92
+ if (count(term) < generatedProcessCount) {
93
+ errors.push(`${app.path}/docker-compose.dev.yml must use the canonical Firebase development credential path for every generated service process: ${term.trim()}`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ if (app.kind === "ui") {
100
+ const generatedArtifacts = fs.readdirSync(root, { withFileTypes: true })
101
+ .filter(
102
+ (entry) =>
103
+ entry.isFile() &&
104
+ (entry.name.endsWith(".tsbuildinfo") || ["vite.config.js", "vite.config.d.ts"].includes(entry.name))
105
+ )
106
+ .map((entry) => entry.name)
107
+ .sort();
108
+ for (const artifact of generatedArtifacts) {
109
+ errors.push(`${app.path}/${artifact} is generated compiler output; keep incremental state under apps/ui/.tmp`);
110
+ }
111
+ }
68
112
  if (fs.existsSync(path.join(root, "tailframe.json"))) {
69
113
  errors.push(`${app.path}/tailframe.json is forbidden; the product root owns Tailframe metadata`);
70
114
  }