@zaaxch/tailframe 4.0.1 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/new.mjs +352 -118
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaaxch/tailframe",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
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
@@ -243,8 +243,7 @@ add(`${svc}/package.json`, JSON.stringify({
243
243
  "schema:apply": "node dist/app/cli/applySchema.js", "schema:apply:dev": "ts-node -r tsconfig-paths/register src/app/cli/applySchema.ts",
244
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",
245
245
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
246
- "docker:prod": "docker compose -f docker-compose.yml up -d",
247
- "docker:push": "bash scripts/build_and_push.sh"
246
+ "docker:prod": "docker compose -f docker-compose.yml up -d"
248
247
  }, dependencies: svcDeps, devDependencies: svcDevDeps
249
248
  }, null, "\t"));
250
249
  add(`${svc}/tsconfig.json`, JSON.stringify({
@@ -552,10 +551,10 @@ ${options.ui ? "- In production, serve the compiled UI from `dist/public` with a
552
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.` : ""}
553
552
 
554
553
  ## Production deployment
555
- 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 with distinct AWS roles: the release role publishes, while the pull-only deployment role manually deploys from \`main\` using an already-published full SHA from \`main\` history and that commit's deployment files. Product data migrations must authenticate to the registry, pull and digest-verify that exact release image, and explicitly select the production datastore; never inherit rehearsal image or target defaults. 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\`.
556
555
 
557
556
  ## Tests and validation
558
- 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.
559
558
  `);
560
559
 
561
560
  if (options.ui) {
@@ -757,7 +756,7 @@ set -euo pipefail
757
756
 
758
757
  SERVICE_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
759
758
  PRODUCT_ROOT="$(cd "\${SERVICE_DIR}/../.." && pwd)"
760
- PRODUCT_SHA="$(git -C "\${PRODUCT_ROOT}" rev-parse --short HEAD)"
759
+ PRODUCT_SHA="$(git -C "\${PRODUCT_ROOT}" rev-parse HEAD)"
761
760
  ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
762
761
  IMAGE_TAG="\${IMAGE_TAG:-\${PRODUCT_SHA}}"
763
762
  REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
@@ -765,6 +764,11 @@ IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
765
764
  DOCKERFILE="\${SERVICE_DIR}/Dockerfile"
766
765
  ${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${PRODUCT_ROOT}/apps/ui/.env.production}"` : ""}
767
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
+
768
772
  aws ecr get-login-password --region "\${AWS_REGION}" |
769
773
  docker login --username AWS --password-stdin "\${REGISTRY}"
770
774
 
@@ -773,106 +777,305 @@ docker push "\${IMAGE}"
773
777
 
774
778
  echo "Pushed \${IMAGE}"
775
779
  `);
776
- const workflowUiPath = options.ui ? ' - "apps/ui/**"\n' : "";
780
+ const workflowUiPath = options.ui ? ' - "apps/ui/**"\n' : "";
777
781
  const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
778
- - name: Write UI production environment
779
- env:
780
- UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
781
- run: |
782
- 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
783
789
  ` : "";
784
- 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
785
794
 
786
795
  on:
787
- push:
788
- branches:
789
- - main
790
- paths:
791
- - "apps/service/**"
792
- ${workflowUiPath} - "package.json"
793
- - "pnpm-workspace.yaml"
794
- - "pnpm-lock.yaml"
795
- - ".github/workflows/deploy.yml"
796
- 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"
797
816
 
798
817
  permissions:
799
- contents: read
800
- id-token: write
818
+ contents: read
801
819
 
802
820
  concurrency:
803
- group: ${options.name}-production
804
- cancel-in-progress: false
821
+ group: ${options.name}-release-\${{ github.ref }}
822
+ cancel-in-progress: true
805
823
 
806
824
  jobs:
807
- deploy:
808
- name: Deploy production
809
- if: \${{ vars.DEPLOY_HOST != '' && vars.DEPLOY_USER != '' }}
810
- runs-on: ubuntu-latest
811
- environment: production
812
- env:
813
- AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
814
- AWS_REGION: \${{ vars.AWS_REGION }}
815
- ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
816
- DEPLOY_HOST: \${{ vars.DEPLOY_HOST }}
817
- DEPLOY_USER: \${{ vars.DEPLOY_USER }}
818
-
819
- steps:
820
- - name: Checkout product
821
- uses: actions/checkout@v6
822
- ${workflowUiEnvironment}
823
- - name: Resolve immutable image
824
- id: image
825
- run: |
826
- product_sha="$(git rev-parse --short HEAD)"
827
- echo "tag=\${product_sha}" >> "$GITHUB_OUTPUT"
828
- echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
829
- echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
830
-
831
- - name: Configure AWS credentials
832
- uses: aws-actions/configure-aws-credentials@v6
833
- with:
834
- role-to-assume: \${{ vars.AWS_ROLE_ARN }}
835
- aws-region: \${{ vars.AWS_REGION }}
836
-
837
- - 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_RELEASE_ROLE_ARN != ''
876
+ runs-on: ubuntu-latest
877
+ environment:
878
+ name: release
879
+ deployment: false
880
+ permissions:
881
+ contents: read
882
+ id-token: write
838
883
  env:
839
- IMAGE_TAG: \${{ steps.image.outputs.tag }}
840
- 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_RELEASE_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
+ printf -- '- Image: \`%s:%s\`\\n' "$IMAGE" "$IMAGE_TAG"
954
+ printf -- '- Digest: \`%s\`\\n' "$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
841
974
 
842
- - 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
843
981
  env:
844
- DEPLOY_SSH_PRIVATE_KEY: \${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
845
- DEPLOY_SSH_KNOWN_HOSTS: \${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
846
- run: |
847
- install -m 700 -d ~/.ssh
848
- printf '%s\\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/${options.name}-deploy
849
- chmod 600 ~/.ssh/${options.name}-deploy
850
- printf '%s\\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
851
-
852
- - name: Authenticate server with ECR
853
- run: |
854
- aws ecr get-login-password --region "$AWS_REGION" |
855
- ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
856
- "docker login --username AWS --password-stdin '\${{ steps.image.outputs.registry }}'"
857
-
858
- - name: Install deployment files
859
- run: |
860
- ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
861
- "install -m 755 -d /opt/${options.name}/current/deploy/mongo"
862
- scp -i ~/.ssh/${options.name}-deploy \\
863
- apps/service/docker-compose.yml \\
864
- apps/service/scripts/deploy_remote.sh \\
865
- "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
866
- scp -i ~/.ssh/${options.name}-deploy \\
867
- apps/service/deploy/mongo/10-create-users.js \\
868
- "\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
869
- ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
870
- "chmod 755 /opt/${options.name}/current/deploy_remote.sh"
871
-
872
- - name: Deploy image
873
- run: |
874
- ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
875
- "/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_DEPLOY_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
+ printf -- '- Image: \`%s:%s\`\\n' "$IMAGE" "$IMAGE_TAG"
1077
+ printf -- '- Digest: \`%s\`\\n' "$IMAGE_DIGEST"
1078
+ } >> "$GITHUB_STEP_SUMMARY"
876
1079
  `);
877
1080
  add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
878
1081
  set -euo pipefail
@@ -925,9 +1128,20 @@ add(`${svc}/docs/production-deployment.md`, `# Production deployment
925
1128
 
926
1129
  ## Delivery model
927
1130
 
928
- GitHub Actions checks out the product once, builds one
929
- non-root production image, tags it \`<product-sha>\`, pushes it to ECR, and installs only deployment artifacts on the host.
930
- 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. The release-only role may publish; the distinct deployment
1144
+ role is pull-only. Record the full commit SHA and ECR digest; deployment and product migrations use that release image.
931
1145
 
932
1146
  The host separates replaceable artifacts from persistent state:
933
1147
 
@@ -936,14 +1150,15 @@ The host separates replaceable artifacts from persistent state:
936
1150
  ├── current/
937
1151
  │ ├── docker-compose.yml
938
1152
  │ ├── deploy_remote.sh
939
- │ └── deploy/mongo/10-create-users.js
1153
+ │ └── deploy/${deploymentArtifactDirectory}/${deploymentArtifactFile}
940
1154
  └── shared/
941
1155
  ├── .env.production
942
1156
  ├── .env.infrastructure
943
1157
  ${options.auth === "firebase" ? ` ├── secrets/firebase-service-account.json
944
- ` : ""} ├── mongo/config/keyfile
1158
+ ` : ""}${postgres ? ` ├── postgres/data/
1159
+ ` : ` ├── mongo/config/keyfile
945
1160
  ├── mongo/data/
946
- ${options.redis ? ` └── redis/data/
1161
+ `}${options.redis ? ` └── redis/data/
947
1162
  ` : ""}\`\`\`
948
1163
 
949
1164
  Actions may replace \`current/\` and must never replace \`shared/\`. The server never needs a source checkout.
@@ -962,42 +1177,61 @@ Do not inject \`.env.infrastructure\` into Node. Make both host environment file
962
1177
  ## One-time host bootstrap
963
1178
 
964
1179
  1. Install Docker Engine and Docker Compose.
965
- 2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
966
- \`/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\`` : ""}.`}
967
1184
  3. Install \`.env.production\` and a completed \`.env.infrastructure\` under \`shared/\`.
968
- 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
969
1186
  mode \`0400\`.
970
- ${options.auth === "firebase" ? `5. Install the Firebase service account at \`shared/secrets/firebase-service-account.json\`, owned by \`root:1000\` and mode \`0640\`.
971
- 6. ` : "5. "}Give the deployment user Docker access and write access to \`current/\`; keep \`shared/\` and its files protected.
972
- ${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.
973
1190
 
974
- 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.
975
1192
 
976
- ## GitHub production environment
1193
+ ## GitHub repository variables
977
1194
 
978
- Configure these variables:
1195
+ Configure \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, \`AWS_RELEASE_ROLE_ARN\`, and \`AWS_DEPLOY_ROLE_ARN\` as repository
1196
+ variables. Optionally set \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
979
1197
 
980
- - \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, and \`AWS_ROLE_ARN\` for the repository-restricted OIDC role.
981
- - \`DEPLOY_HOST\` and \`DEPLOY_USER\`; the deployment job remains skipped until both exist.
982
- - Optional \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
1198
+ The ECR repository and AWS OIDC provider/roles are provisioned separately. Workflows never store long-lived AWS keys
1199
+ or create cloud infrastructure.
983
1200
 
984
- Configure these secrets:
1201
+ ## GitHub release environment
985
1202
 
986
- ${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
987
- ` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
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
1206
+
1207
+ Configure \`DEPLOY_HOST\` and \`DEPLOY_USER\` as production-environment variables. Configure these secrets:
1208
+
1209
+ - \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
988
1210
  - \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
989
1211
 
990
- The ECR repository and AWS OIDC provider/role are provisioned separately. The workflow never stores long-lived AWS
991
- keys or creates cloud infrastructure.
1212
+ Restrict both environments' deployment branches to \`main\`. The release role trust accepts only the repository's
1213
+ \`release\` environment OIDC subject; the pull-only deployment role accepts only its \`production\` subject. Use the
1214
+ repository's active GitHub subject format: legacy subjects name the owner/repository, while immutable subjects use
1215
+ their IDs. Do not use a repository-wide wildcard. Required reviewer approval MAY add a second manual gate.
992
1216
 
993
1217
  ## Deploy and rollback
994
1218
 
1219
+ Run \`Deploy release image\` manually from \`main\` and enter the full SHA recorded by the release workflow. The
1220
+ deployment fails before contacting the host if the SHA is invalid, is not in \`main\` history, cannot be checked out,
1221
+ or has no matching ECR image.
1222
+
995
1223
  The remote script requires the provisioned infrastructure file, atomically replaces only \`ECR_IMAGE\` and
996
1224
  \`IMAGE_TAG\`, validates Compose, pulls, and starts with health waiting. Roll back by selecting a previous immutable
997
- tag and running the same script. Never use \`docker compose down -v\` in production; image rollback does not restore
998
- database state.
1225
+ full-SHA release and running the same workflow. Never use \`docker compose down -v\` in production; image rollback
1226
+ does not restore database state.
1227
+
1228
+ ## Product data-migration handoff
1229
+
1230
+ Tailframe does not generate product-specific data-migration orchestration. Before a production data migration,
1231
+ authenticate to ECR, pull the selected full-SHA release image, verify its digest against the release record, and
1232
+ explicitly configure the production datastore target. Never inherit a rehearsal URI, local image default, or source build.
999
1233
 
1000
- MongoDB and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
1234
+ ${postgres ? "PostgreSQL" : "MongoDB"} and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
1001
1235
  database/cache authentication, file permissions, and off-host backup/restore before treating the deployment as
1002
1236
  production-ready. Product-specific TLS, capacity limits, migrations, and scheduled-work grace periods must be added
1003
1237
  from measured application requirements rather than copied from another product.