From ee6cea0136fe4297f55fe85fddc5a4f8541bc0b9 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 13 Mar 2026 16:54:42 +1300 Subject: [PATCH 1/3] feat(infra): add Vercel env vars, web deploy IAM role, and SSM params - Expand infra/vercel with root_directory, STRAPI_PREVIEW_SECRET, NEXT_PUBLIC_GRAPHQL_URL, NEXT_PUBLIC_CMS_HOSTNAME env vars - Source all Vercel env vars from /forge/aws/web/ SSM namespace - Add NEXT_PUBLIC_GRAPHQL_URL and NEXT_PUBLIC_CMS_HOSTNAME deploy SSM params to web module, derived from cms_domain_name - Create web deploy IAM role with Vercel API token SSM read access - Add org_id and web_project_id SSM params under /forge/vercel/ - Update Vercel Terraform stack roles to use web SSM paths/KMS keys - Add web-preview, web-stage, web-prod GitHub environments - Wire WEB_DEPLOY_ROLE_ARN secrets and VERCEL_* variables via Terraform Resolves #68 Made-with: Cursor --- infra/aws/github/outputs.tf | 5 ++ infra/aws/github/ssm.tf | 6 +++ infra/aws/github/terraform.tf | 52 ++++++++++++++++--- infra/aws/github/web.tf | 77 +++++++++++++++++++++++++++++ infra/aws/modules/platform/main.tf | 5 +- infra/aws/modules/web/ssm_deploy.tf | 14 ++++++ infra/aws/modules/web/variables.tf | 5 ++ infra/aws/vercel/ssm.tf | 24 +++++++++ infra/github/actions.tf | 33 +++++++++++++ infra/github/data.tf | 16 ++++++ infra/github/environments.tf | 15 ++++++ infra/vercel/data.tf | 31 +++++++++++- infra/vercel/main.tf | 57 ++++++++++++++++++++- 13 files changed, 326 insertions(+), 14 deletions(-) create mode 100644 infra/aws/github/web.tf diff --git a/infra/aws/github/outputs.tf b/infra/aws/github/outputs.tf index 91f80c39..18cba201 100644 --- a/infra/aws/github/outputs.tf +++ b/infra/aws/github/outputs.tf @@ -3,6 +3,11 @@ output "github_actions_cms_deploy_role_arn" { value = aws_iam_role.github_actions_cms_deploy.arn } +output "github_actions_web_deploy_role_arn" { + description = "GitHub Actions role for web Vercel deploy." + value = aws_iam_role.github_actions_web_deploy.arn +} + output "github_actions_terraform_apply_role_arn" { description = "GitHub Actions role for Terraform apply." value = aws_iam_role.github_actions_terraform_apply.arn diff --git a/infra/aws/github/ssm.tf b/infra/aws/github/ssm.tf index f18cde8b..f1d66ba5 100644 --- a/infra/aws/github/ssm.tf +++ b/infra/aws/github/ssm.tf @@ -60,6 +60,12 @@ resource "aws_ssm_parameter" "cms_deploy_role_arn" { value = aws_iam_role.github_actions_cms_deploy.arn } +resource "aws_ssm_parameter" "web_deploy_role_arn" { + name = "/forge/github/web_deploy_role_arn_${var.environment}" + type = "String" + value = aws_iam_role.github_actions_web_deploy.arn +} + resource "aws_ssm_parameter" "terraform_vercel_role_plan_arn" { count = local.create_github_secure_parameters ? 1 : 0 diff --git a/infra/aws/github/terraform.tf b/infra/aws/github/terraform.tf index 78ba68d7..625682ac 100644 --- a/infra/aws/github/terraform.tf +++ b/infra/aws/github/terraform.tf @@ -340,6 +340,26 @@ data "aws_kms_key" "cms_ssm_prod" { key_id = data.aws_kms_alias.cms_ssm_prod[0].target_key_arn } +data "aws_kms_alias" "web_ssm_stage" { + count = local.create_shared_github_resources ? 1 : 0 + name = "alias/forge-web-stage-ssm" +} + +data "aws_kms_key" "web_ssm_stage" { + count = local.create_shared_github_resources ? 1 : 0 + key_id = data.aws_kms_alias.web_ssm_stage[0].target_key_arn +} + +data "aws_kms_alias" "web_ssm_prod" { + count = local.create_shared_github_resources ? 1 : 0 + name = "alias/forge-web-prod-ssm" +} + +data "aws_kms_key" "web_ssm_prod" { + count = local.create_shared_github_resources ? 1 : 0 + key_id = data.aws_kms_alias.web_ssm_prod[0].target_key_arn +} + locals { terraform_stack_roles = local.create_shared_github_resources ? { vercel_plan = { @@ -361,13 +381,19 @@ locals { ] ssm_parameter_arns = [ "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/vercel/api_token", - "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/cms/stage/STRAPI_INTERNAL_API_TOKEN", - "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/cms/prod/STRAPI_INTERNAL_API_TOKEN", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/stage/STRAPI_API_TOKEN", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/prod/STRAPI_API_TOKEN", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/stage/STRAPI_PREVIEW_SECRET", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/prod/STRAPI_PREVIEW_SECRET", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/stage/NEXT_PUBLIC_GRAPHQL_URL", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/prod/NEXT_PUBLIC_GRAPHQL_URL", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/stage/NEXT_PUBLIC_CMS_HOSTNAME", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/prod/NEXT_PUBLIC_CMS_HOSTNAME", ] ssm_kms_key_arns = [ var.vercel_ssm_kms_key_arn, - data.aws_kms_key.cms_ssm_stage[0].arn, - data.aws_kms_key.cms_ssm_prod[0].arn, + data.aws_kms_key.web_ssm_stage[0].arn, + data.aws_kms_key.web_ssm_prod[0].arn, ] } vercel_apply = { @@ -397,13 +423,19 @@ locals { ] ssm_parameter_arns = [ "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/vercel/api_token", - "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/cms/stage/STRAPI_INTERNAL_API_TOKEN", - "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/cms/prod/STRAPI_INTERNAL_API_TOKEN", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/stage/STRAPI_API_TOKEN", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/prod/STRAPI_API_TOKEN", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/stage/STRAPI_PREVIEW_SECRET", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/prod/STRAPI_PREVIEW_SECRET", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/stage/NEXT_PUBLIC_GRAPHQL_URL", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/prod/NEXT_PUBLIC_GRAPHQL_URL", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/stage/NEXT_PUBLIC_CMS_HOSTNAME", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/web/prod/NEXT_PUBLIC_CMS_HOSTNAME", ] ssm_kms_key_arns = [ var.vercel_ssm_kms_key_arn, - data.aws_kms_key.cms_ssm_stage[0].arn, - data.aws_kms_key.cms_ssm_prod[0].arn, + data.aws_kms_key.web_ssm_stage[0].arn, + data.aws_kms_key.web_ssm_prod[0].arn, ] } github_plan = { @@ -425,6 +457,8 @@ locals { ] ssm_parameter_arns = [ "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/github/*", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/vercel/org_id", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/vercel/web_project_id", "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/cms/stage/STRAPI_INTERNAL_API_TOKEN", "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/cms/prod/STRAPI_INTERNAL_API_TOKEN", ] @@ -461,6 +495,8 @@ locals { ] ssm_parameter_arns = [ "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/github/*", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/vercel/org_id", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/vercel/web_project_id", "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/cms/stage/STRAPI_INTERNAL_API_TOKEN", "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/aws/cms/prod/STRAPI_INTERNAL_API_TOKEN", ] diff --git a/infra/aws/github/web.tf b/infra/aws/github/web.tf new file mode 100644 index 00000000..8019aa80 --- /dev/null +++ b/infra/aws/github/web.tf @@ -0,0 +1,77 @@ +data "aws_kms_alias" "vercel_ssm_for_web" { + name = "alias/forge-vercel-prod-ssm" +} + +data "aws_kms_key" "vercel_ssm_for_web" { + key_id = data.aws_kms_alias.vercel_ssm_for_web.target_key_arn +} + +data "aws_iam_policy_document" "github_actions_web_deploy_assume_role" { + statement { + sid = "AllowGitHubActionsWebDeployAssumeRole" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [aws_iam_openid_connect_provider.github_actions.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = concat( + [ + "repo:JesusFilm/forge:ref:refs/heads/${local.branch_name}", + "repo:JesusFilm/forge:environment:web-${var.environment}", + ], + var.environment == "stage" ? ["repo:JesusFilm/forge:environment:web-preview"] : [], + ) + } + } +} + +resource "aws_iam_role" "github_actions_web_deploy" { + name = "forge-github-actions-web-deploy-${var.environment}" + assume_role_policy = data.aws_iam_policy_document.github_actions_web_deploy_assume_role.json + tags = merge(var.tags, { + Environment = var.environment + ManagedBy = "terraform" + Service = "github-actions" + }) +} + +data "aws_iam_policy_document" "github_actions_web_deploy" { + statement { + sid = "SsmReadVercelToken" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters" + ] + resources = [ + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/forge/vercel/api_token" + ] + } + + statement { + sid = "KmsDecryptVercelToken" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [ + data.aws_kms_key.vercel_ssm_for_web.arn + ] + } +} + +resource "aws_iam_role_policy" "github_actions_web_deploy" { + name = "web-deploy" + role = aws_iam_role.github_actions_web_deploy.id + policy = data.aws_iam_policy_document.github_actions_web_deploy.json +} diff --git a/infra/aws/modules/platform/main.tf b/infra/aws/modules/platform/main.tf index fdb4f4b1..14ef4ad3 100644 --- a/infra/aws/modules/platform/main.tf +++ b/infra/aws/modules/platform/main.tf @@ -315,8 +315,9 @@ module "assets" { module "web" { source = "../web" - environment = var.environment - tags = var.tags + environment = var.environment + tags = var.tags + cms_domain_name = local.alb_domain_name depends_on = [module.application] } diff --git a/infra/aws/modules/web/ssm_deploy.tf b/infra/aws/modules/web/ssm_deploy.tf index b303b2ee..5b885622 100644 --- a/infra/aws/modules/web/ssm_deploy.tf +++ b/infra/aws/modules/web/ssm_deploy.tf @@ -44,3 +44,17 @@ resource "aws_ssm_parameter" "strapi_preview_secret" { value_wo_version = var.ssm_secret_version tags = local.tags } + +resource "aws_ssm_parameter" "next_public_graphql_url" { + name = "${local.ssm_parameter_prefix}/NEXT_PUBLIC_GRAPHQL_URL" + type = "String" + value = "https://${var.cms_domain_name}/graphql" + tags = local.tags +} + +resource "aws_ssm_parameter" "next_public_cms_hostname" { + name = "${local.ssm_parameter_prefix}/NEXT_PUBLIC_CMS_HOSTNAME" + type = "String" + value = var.cms_domain_name + tags = local.tags +} diff --git a/infra/aws/modules/web/variables.tf b/infra/aws/modules/web/variables.tf index 9b3cb22d..592e35d7 100644 --- a/infra/aws/modules/web/variables.tf +++ b/infra/aws/modules/web/variables.tf @@ -13,3 +13,8 @@ variable "ssm_secret_version" { type = number default = 1 } + +variable "cms_domain_name" { + description = "Public DNS hostname of the CMS (e.g. cms.stage.forge.jesusfilm.org)." + type = string +} diff --git a/infra/aws/vercel/ssm.tf b/infra/aws/vercel/ssm.tf index 1bec106f..804fe72b 100644 --- a/infra/aws/vercel/ssm.tf +++ b/infra/aws/vercel/ssm.tf @@ -43,3 +43,27 @@ resource "aws_ssm_parameter" "api_token" { } } +resource "aws_ssm_parameter" "org_id" { + count = local.create_vercel_ssm_resources ? 1 : 0 + + name = "/forge/vercel/org_id" + type = "String" + value = "manually set in AWS console" + + lifecycle { + ignore_changes = [value] + } +} + +resource "aws_ssm_parameter" "web_project_id" { + count = local.create_vercel_ssm_resources ? 1 : 0 + + name = "/forge/vercel/web_project_id" + type = "String" + value = "manually set in AWS console" + + lifecycle { + ignore_changes = [value] + } +} + diff --git a/infra/github/actions.tf b/infra/github/actions.tf index 2141c37a..a2bb1971 100644 --- a/infra/github/actions.tf +++ b/infra/github/actions.tf @@ -84,6 +84,27 @@ resource "github_actions_environment_secret" "github_terraform_role_apply" { plaintext_value = data.aws_ssm_parameter.terraform_github_role_apply_arn.value } +resource "github_actions_environment_secret" "web_deploy_role_preview" { + repository = github_repository.forge.name + environment = github_repository_environment.web_preview.environment + secret_name = "WEB_DEPLOY_ROLE_ARN" + plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_stage.value +} + +resource "github_actions_environment_secret" "web_deploy_role_stage" { + repository = github_repository.forge.name + environment = github_repository_environment.web_stage.environment + secret_name = "WEB_DEPLOY_ROLE_ARN" + plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_stage.value +} + +resource "github_actions_environment_secret" "web_deploy_role_prod" { + repository = github_repository.forge.name + environment = github_repository_environment.web_prod.environment + secret_name = "WEB_DEPLOY_ROLE_ARN" + plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_prod.value +} + resource "github_actions_secret" "strapi_api_token" { repository = github_repository.forge.name secret_name = "STRAPI_API_TOKEN" @@ -100,3 +121,15 @@ resource "github_actions_variable" "aws_region" { variable_name = "AWS_REGION" value = var.aws_region } + +resource "github_actions_variable" "vercel_org_id" { + repository = github_repository.forge.name + variable_name = "VERCEL_ORG_ID" + value = data.aws_ssm_parameter.vercel_org_id.value +} + +resource "github_actions_variable" "vercel_web_project_id" { + repository = github_repository.forge.name + variable_name = "VERCEL_WEB_PROJECT_ID" + value = data.aws_ssm_parameter.vercel_web_project_id.value +} diff --git a/infra/github/data.tf b/infra/github/data.tf index 3bfc8268..6901e17c 100644 --- a/infra/github/data.tf +++ b/infra/github/data.tf @@ -57,3 +57,19 @@ data "aws_ssm_parameter" "strapi_api_token_stage" { name = "/forge/aws/cms/stage/STRAPI_INTERNAL_API_TOKEN" with_decryption = true } + +data "aws_ssm_parameter" "web_deploy_role_arn_stage" { + name = "/forge/github/web_deploy_role_arn_stage" +} + +data "aws_ssm_parameter" "web_deploy_role_arn_prod" { + name = "/forge/github/web_deploy_role_arn_prod" +} + +data "aws_ssm_parameter" "vercel_org_id" { + name = "/forge/vercel/org_id" +} + +data "aws_ssm_parameter" "vercel_web_project_id" { + name = "/forge/vercel/web_project_id" +} diff --git a/infra/github/environments.tf b/infra/github/environments.tf index 58a311de..10bad6af 100644 --- a/infra/github/environments.tf +++ b/infra/github/environments.tf @@ -50,3 +50,18 @@ resource "github_repository_environment" "github_prod" { repository = github_repository.forge.name environment = "github-prod" } + +resource "github_repository_environment" "web_preview" { + repository = github_repository.forge.name + environment = "web-preview" +} + +resource "github_repository_environment" "web_stage" { + repository = github_repository.forge.name + environment = "web-stage" +} + +resource "github_repository_environment" "web_prod" { + repository = github_repository.forge.name + environment = "web-prod" +} diff --git a/infra/vercel/data.tf b/infra/vercel/data.tf index 23448bdd..8980dbe6 100644 --- a/infra/vercel/data.tf +++ b/infra/vercel/data.tf @@ -4,12 +4,39 @@ data "aws_ssm_parameter" "api_token" { with_decryption = true } +# All web env vars sourced from the web SSM namespace. data "aws_ssm_parameter" "strapi_api_token_stage" { - name = "/forge/aws/cms/stage/STRAPI_INTERNAL_API_TOKEN" + name = "/forge/aws/web/stage/STRAPI_API_TOKEN" with_decryption = true } data "aws_ssm_parameter" "strapi_api_token_prod" { - name = "/forge/aws/cms/prod/STRAPI_INTERNAL_API_TOKEN" + name = "/forge/aws/web/prod/STRAPI_API_TOKEN" with_decryption = true } + +data "aws_ssm_parameter" "strapi_preview_secret_stage" { + name = "/forge/aws/web/stage/STRAPI_PREVIEW_SECRET" + with_decryption = true +} + +data "aws_ssm_parameter" "strapi_preview_secret_prod" { + name = "/forge/aws/web/prod/STRAPI_PREVIEW_SECRET" + with_decryption = true +} + +data "aws_ssm_parameter" "next_public_graphql_url_stage" { + name = "/forge/aws/web/stage/NEXT_PUBLIC_GRAPHQL_URL" +} + +data "aws_ssm_parameter" "next_public_graphql_url_prod" { + name = "/forge/aws/web/prod/NEXT_PUBLIC_GRAPHQL_URL" +} + +data "aws_ssm_parameter" "next_public_cms_hostname_stage" { + name = "/forge/aws/web/stage/NEXT_PUBLIC_CMS_HOSTNAME" +} + +data "aws_ssm_parameter" "next_public_cms_hostname_prod" { + name = "/forge/aws/web/prod/NEXT_PUBLIC_CMS_HOSTNAME" +} diff --git a/infra/vercel/main.tf b/infra/vercel/main.tf index 214ff40c..9634dff2 100644 --- a/infra/vercel/main.tf +++ b/infra/vercel/main.tf @@ -1,8 +1,11 @@ resource "vercel_project" "web" { - name = "forge-web" - framework = "nextjs" + name = "forge-web" + framework = "nextjs" + root_directory = "apps/web" } +# --- STRAPI_API_TOKEN --- + resource "vercel_project_environment_variable" "strapi_api_token_preview" { project_id = vercel_project.web.id key = "STRAPI_API_TOKEN" @@ -18,3 +21,53 @@ resource "vercel_project_environment_variable" "strapi_api_token_production" { target = ["production"] sensitive = true } + +# --- STRAPI_PREVIEW_SECRET --- + +resource "vercel_project_environment_variable" "strapi_preview_secret_preview" { + project_id = vercel_project.web.id + key = "STRAPI_PREVIEW_SECRET" + value = data.aws_ssm_parameter.strapi_preview_secret_stage.value + target = ["preview"] + sensitive = true +} + +resource "vercel_project_environment_variable" "strapi_preview_secret_production" { + project_id = vercel_project.web.id + key = "STRAPI_PREVIEW_SECRET" + value = data.aws_ssm_parameter.strapi_preview_secret_prod.value + target = ["production"] + sensitive = true +} + +# --- NEXT_PUBLIC_GRAPHQL_URL --- + +resource "vercel_project_environment_variable" "next_public_graphql_url_preview" { + project_id = vercel_project.web.id + key = "NEXT_PUBLIC_GRAPHQL_URL" + value = data.aws_ssm_parameter.next_public_graphql_url_stage.value + target = ["preview"] +} + +resource "vercel_project_environment_variable" "next_public_graphql_url_production" { + project_id = vercel_project.web.id + key = "NEXT_PUBLIC_GRAPHQL_URL" + value = data.aws_ssm_parameter.next_public_graphql_url_prod.value + target = ["production"] +} + +# --- NEXT_PUBLIC_CMS_HOSTNAME --- + +resource "vercel_project_environment_variable" "next_public_cms_hostname_preview" { + project_id = vercel_project.web.id + key = "NEXT_PUBLIC_CMS_HOSTNAME" + value = data.aws_ssm_parameter.next_public_cms_hostname_stage.value + target = ["preview"] +} + +resource "vercel_project_environment_variable" "next_public_cms_hostname_production" { + project_id = vercel_project.web.id + key = "NEXT_PUBLIC_CMS_HOSTNAME" + value = data.aws_ssm_parameter.next_public_cms_hostname_prod.value + target = ["production"] +} From 822806468e10183b7fc322ed51dac3248f7c75c9 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Fri, 13 Mar 2026 16:54:53 +1300 Subject: [PATCH 2/3] feat(infra): add web-deploy GitHub Actions workflow Vercel CLI deploy triggered on push to stage/main and pull_request. Turbo-based affected detection for @forge/web. Reads Vercel API token from SSM via IAM role. PR previews post/update a comment with the deploy URL. Stage and main deploys post commit comments. Made-with: Cursor --- .github/workflows/web-deploy.yml | 232 +++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 .github/workflows/web-deploy.yml diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml new file mode 100644 index 00000000..19cbbc14 --- /dev/null +++ b/.github/workflows/web-deploy.yml @@ -0,0 +1,232 @@ +name: web-deploy + +permissions: + contents: read + +concurrency: + group: web-deploy-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +on: + pull_request: + push: + branches: + - stage + - main + workflow_dispatch: + +jobs: + affected: + if: github.event_name != 'workflow_dispatch' + permissions: + contents: read + runs-on: ubuntu-latest + outputs: + web: ${{ steps.affected.outputs.web }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - id: affected + name: Detect affected web package + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + else + BASE="${{ github.event.before }}" + fi + + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + BASE=$(git rev-parse HEAD^ 2>/dev/null || true) + fi + + if [ -z "$BASE" ]; then + echo "web=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + TURBO_STDERR=$(mktemp) + if ! TURBO_OUT=$(TURBO_SCM_BASE="$BASE" pnpm turbo run lint --affected --dry-run=json 2>"$TURBO_STDERR"); then + cat "$TURBO_STDERR" >&2 + echo "$TURBO_OUT" >&2 + rm -f "$TURBO_STDERR" + echo "Affected detection failed; forcing deploy to avoid false negative." >&2 + echo "web=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + WEB_AFFECTED=$(echo "$TURBO_OUT" | jq -r 'try ([.tasks[]?.package] | any(. == "@forge/web")) catch "true"' 2>/dev/null || echo "true") + rm -f "$TURBO_STDERR" + echo "web=$WEB_AFFECTED" >> "$GITHUB_OUTPUT" + + deploy: + permissions: + id-token: write + contents: write + pull-requests: write + needs: affected + if: >- + always() && ( + (github.event_name == 'workflow_dispatch' && (github.ref_name == 'main' || github.ref_name == 'stage')) + || (github.event_name != 'workflow_dispatch' && needs.affected.outputs.web == 'true') + ) + environment: ${{ github.event_name == 'pull_request' && 'web-preview' || (github.ref_name == 'main' && 'web-prod' || 'web-stage') }} + runs-on: ubuntu-latest + env: + AWS_REGION: ${{ vars.AWS_REGION || 'us-east-2' }} + VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ vars.VERCEL_WEB_PROJECT_ID }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Resolve environment + id: vars + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + DEPLOY_ENV="preview" + VERCEL_ENV="preview" + PROD_FLAG="" + elif [ "${GITHUB_REF_NAME}" = "main" ]; then + DEPLOY_ENV="prod" + VERCEL_ENV="production" + PROD_FLAG="--prod" + elif [ "${GITHUB_REF_NAME}" = "stage" ]; then + DEPLOY_ENV="stage" + VERCEL_ENV="preview" + PROD_FLAG="" + else + echo "Unsupported branch: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + echo "role_arn=${{ secrets.WEB_DEPLOY_ROLE_ARN }}" >> "$GITHUB_OUTPUT" + echo "deploy_env=$DEPLOY_ENV" >> "$GITHUB_OUTPUT" + echo "vercel_env=$VERCEL_ENV" >> "$GITHUB_OUTPUT" + echo "prod_flag=$PROD_FLAG" >> "$GITHUB_OUTPUT" + + - name: Validate role ARN is configured + if: steps.vars.outputs.role_arn == '' + run: | + echo "Missing WEB_DEPLOY_ROLE_ARN secret for ${GITHUB_JOB} environment on ${GITHUB_REF_NAME}" >&2 + exit 1 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v5 + with: + role-to-assume: ${{ steps.vars.outputs.role_arn }} + aws-region: ${{ env.AWS_REGION }} + + - name: Read Vercel API token from SSM + id: ssm + run: | + TOKEN=$(aws ssm get-parameter --name /forge/vercel/api_token --with-decryption --query 'Parameter.Value' --output text) + echo "::add-mask::$TOKEN" + echo "vercel_token=$TOKEN" >> "$GITHUB_OUTPUT" + + - name: Pull Vercel environment + run: pnpm vercel pull --yes --environment=${{ steps.vars.outputs.vercel_env }} --token=${{ steps.ssm.outputs.vercel_token }} + + - name: Build + run: pnpm vercel build ${{ steps.vars.outputs.prod_flag }} --token=${{ steps.ssm.outputs.vercel_token }} + + - name: Deploy + id: deploy + run: | + URL=$(pnpm vercel deploy --prebuilt ${{ steps.vars.outputs.prod_flag }} --token=${{ steps.ssm.outputs.vercel_token }}) + echo "url=$URL" >> "$GITHUB_OUTPUT" + + - name: Post preview URL on PR + if: always() && github.event_name == 'pull_request' && steps.deploy.outputs.url + continue-on-error: true + uses: actions/github-script@v8 + env: + DEPLOY_URL: ${{ steps.deploy.outputs.url }} + JOB_STATUS: ${{ job.status }} + with: + script: | + const marker = "" + const status = process.env.JOB_STATUS === "success" ? "deployed" : "failed" + const url = process.env.DEPLOY_URL + const body = [ + marker, + `**Web preview ${status}**`, + "", + url ? `URL: ${url}` : "", + `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + ].filter(Boolean).join("\n") + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }) + const existing = comments.find(c => c.body?.includes(marker)) + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }) + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }) + } + + - name: Post deployment status comment + if: always() && github.event_name != 'pull_request' + continue-on-error: true + uses: actions/github-script@v8 + env: + DEPLOY_ENV: ${{ steps.vars.outputs.deploy_env }} + DEPLOY_URL: ${{ steps.deploy.outputs.url }} + JOB_STATUS: ${{ job.status }} + with: + script: | + const status = process.env.JOB_STATUS === "success" ? "SUCCESS" : "FAILED" + const body = [ + `Web deploy ${status}`, + "", + `- Environment: \`${process.env.DEPLOY_ENV || "unknown"}\``, + `- Branch: \`${context.ref.replace("refs/heads/", "")}\``, + `- URL: ${process.env.DEPLOY_URL || "n/a"}`, + `- Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + ].join("\n") + + await github.rest.repos.createCommitComment({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + body, + }) From 514e1977979775ca58983496dd800871b99e8a61 Mon Sep 17 00:00:00 2001 From: Tataihono Nikora Date: Sat, 14 Mar 2026 00:33:33 +1300 Subject: [PATCH 3/3] fix(infra): add enable_web_deploy bootstrap guard for github stack Gate all web-deploy SSM data sources, environments, secrets, and variables behind var.enable_web_deploy (default false) so the github terraform-plan does not fail before the AWS and Vercel producer stacks are applied. Made-with: Cursor --- infra/github/actions.tf | 21 +++++++++++++-------- infra/github/data.tf | 12 ++++++++---- infra/github/environments.tf | 3 +++ infra/github/variables.tf | 6 ++++++ 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/infra/github/actions.tf b/infra/github/actions.tf index a2bb1971..67a7e36f 100644 --- a/infra/github/actions.tf +++ b/infra/github/actions.tf @@ -85,24 +85,27 @@ resource "github_actions_environment_secret" "github_terraform_role_apply" { } resource "github_actions_environment_secret" "web_deploy_role_preview" { + count = var.enable_web_deploy ? 1 : 0 repository = github_repository.forge.name - environment = github_repository_environment.web_preview.environment + environment = github_repository_environment.web_preview[0].environment secret_name = "WEB_DEPLOY_ROLE_ARN" - plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_stage.value + plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_stage[0].value } resource "github_actions_environment_secret" "web_deploy_role_stage" { + count = var.enable_web_deploy ? 1 : 0 repository = github_repository.forge.name - environment = github_repository_environment.web_stage.environment + environment = github_repository_environment.web_stage[0].environment secret_name = "WEB_DEPLOY_ROLE_ARN" - plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_stage.value + plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_stage[0].value } resource "github_actions_environment_secret" "web_deploy_role_prod" { + count = var.enable_web_deploy ? 1 : 0 repository = github_repository.forge.name - environment = github_repository_environment.web_prod.environment + environment = github_repository_environment.web_prod[0].environment secret_name = "WEB_DEPLOY_ROLE_ARN" - plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_prod.value + plaintext_value = data.aws_ssm_parameter.web_deploy_role_arn_prod[0].value } resource "github_actions_secret" "strapi_api_token" { @@ -123,13 +126,15 @@ resource "github_actions_variable" "aws_region" { } resource "github_actions_variable" "vercel_org_id" { + count = var.enable_web_deploy ? 1 : 0 repository = github_repository.forge.name variable_name = "VERCEL_ORG_ID" - value = data.aws_ssm_parameter.vercel_org_id.value + value = data.aws_ssm_parameter.vercel_org_id[0].value } resource "github_actions_variable" "vercel_web_project_id" { + count = var.enable_web_deploy ? 1 : 0 repository = github_repository.forge.name variable_name = "VERCEL_WEB_PROJECT_ID" - value = data.aws_ssm_parameter.vercel_web_project_id.value + value = data.aws_ssm_parameter.vercel_web_project_id[0].value } diff --git a/infra/github/data.tf b/infra/github/data.tf index 6901e17c..7a0625dc 100644 --- a/infra/github/data.tf +++ b/infra/github/data.tf @@ -59,17 +59,21 @@ data "aws_ssm_parameter" "strapi_api_token_stage" { } data "aws_ssm_parameter" "web_deploy_role_arn_stage" { - name = "/forge/github/web_deploy_role_arn_stage" + count = var.enable_web_deploy ? 1 : 0 + name = "/forge/github/web_deploy_role_arn_stage" } data "aws_ssm_parameter" "web_deploy_role_arn_prod" { - name = "/forge/github/web_deploy_role_arn_prod" + count = var.enable_web_deploy ? 1 : 0 + name = "/forge/github/web_deploy_role_arn_prod" } data "aws_ssm_parameter" "vercel_org_id" { - name = "/forge/vercel/org_id" + count = var.enable_web_deploy ? 1 : 0 + name = "/forge/vercel/org_id" } data "aws_ssm_parameter" "vercel_web_project_id" { - name = "/forge/vercel/web_project_id" + count = var.enable_web_deploy ? 1 : 0 + name = "/forge/vercel/web_project_id" } diff --git a/infra/github/environments.tf b/infra/github/environments.tf index 10bad6af..ce010e27 100644 --- a/infra/github/environments.tf +++ b/infra/github/environments.tf @@ -52,16 +52,19 @@ resource "github_repository_environment" "github_prod" { } resource "github_repository_environment" "web_preview" { + count = var.enable_web_deploy ? 1 : 0 repository = github_repository.forge.name environment = "web-preview" } resource "github_repository_environment" "web_stage" { + count = var.enable_web_deploy ? 1 : 0 repository = github_repository.forge.name environment = "web-stage" } resource "github_repository_environment" "web_prod" { + count = var.enable_web_deploy ? 1 : 0 repository = github_repository.forge.name environment = "web-prod" } diff --git a/infra/github/variables.tf b/infra/github/variables.tf index d4871047..a3deadef 100644 --- a/infra/github/variables.tf +++ b/infra/github/variables.tf @@ -3,3 +3,9 @@ variable "aws_region" { type = string default = "us-east-2" } + +variable "enable_web_deploy" { + description = "Enable web deploy resources. Set to true after the AWS and Vercel stacks have been applied and the required SSM parameters exist." + type = bool + default = false +}