diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml new file mode 100644 index 0000000..26d3ebe --- /dev/null +++ b/.github/workflows/deploy-frontend.yml @@ -0,0 +1,61 @@ +name: Deploy Frontend + +on: + push: + branches: [main] + paths: + - "frontend/**" + - "infra/terraform/**" + - ".github/workflows/deploy-frontend.yml" + +jobs: + deploy: + name: Build & Deploy to S3 + CloudFront + runs-on: ubuntu-latest + environment: production + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Build frontend + env: + VITE_GOVERNANCE_CONTRACT_ID: ${{ vars.GOVERNANCE_CONTRACT_ID }} + VITE_TOKEN_CONTRACT_ID: ${{ vars.TOKEN_CONTRACT_ID }} + VITE_STELLAR_NETWORK: ${{ vars.STELLAR_NETWORK || 'mainnet' }} + VITE_STELLAR_RPC_URL: ${{ vars.STELLAR_RPC_URL }} + run: npm run build + working-directory: frontend + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + + - name: Sync to S3 + run: | + aws s3 sync frontend/dist/ s3://${{ secrets.S3_BUCKET }} \ + --delete \ + --cache-control "public,max-age=31536000,immutable" \ + --exclude "index.html" + aws s3 cp frontend/dist/index.html s3://${{ secrets.S3_BUCKET }}/index.html \ + --cache-control "no-cache,no-store,must-revalidate" + + - name: Invalidate CloudFront cache + run: | + aws cloudfront create-invalidation \ + --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ + --paths "/*" diff --git a/docs/frontend-deployment.md b/docs/frontend-deployment.md new file mode 100644 index 0000000..654b180 --- /dev/null +++ b/docs/frontend-deployment.md @@ -0,0 +1,75 @@ +# Frontend Deployment (S3 + CloudFront) + +The frontend is deployed to AWS S3 + CloudFront via Terraform and GitHub Actions. + +## Infrastructure + +Terraform configuration lives in `infra/terraform/`. + +| File | Purpose | +|------|---------| +| `main.tf` | S3 bucket, CloudFront distribution, OAC | +| `variables.tf` | Input variables | +| `outputs.tf` | Bucket name, CloudFront domain and ID | +| `envs/staging.tfvars` | Staging environment values | +| `envs/production.tfvars` | Production environment values | + +## First-time Setup + +```bash +cd infra/terraform + +# Staging +terraform init +terraform apply -var-file=envs/staging.tfvars + +# Production +terraform apply -var-file=envs/production.tfvars +``` + +Record the outputs and add them as GitHub secrets/variables: + +| Output | GitHub secret/variable | +|--------|----------------------| +| `s3_bucket` | `S3_BUCKET` | +| `cloudfront_distribution_id` | `CLOUDFRONT_DISTRIBUTION_ID` | + +Also set: +- `AWS_DEPLOY_ROLE_ARN` — IAM role ARN with S3 write + CloudFront invalidation permissions +- `GOVERNANCE_CONTRACT_ID` (variable) — deployed governance contract ID +- `TOKEN_CONTRACT_ID` (variable) — deployed token contract ID +- `STELLAR_RPC_URL` (variable) — RPC endpoint + +## Environment Config Injection + +Vite reads environment variables prefixed with `VITE_` at build time. The CI workflow injects: + +``` +VITE_GOVERNANCE_CONTRACT_ID +VITE_TOKEN_CONTRACT_ID +VITE_STELLAR_NETWORK +VITE_STELLAR_RPC_URL +``` + +Use them in the frontend via `import.meta.env.VITE_*`. + +## CI/CD + +The `.github/workflows/deploy-frontend.yml` workflow triggers on pushes to `main` that touch `frontend/` or `infra/terraform/`. It: + +1. Builds the frontend with environment-specific config +2. Syncs the `dist/` output to S3 (immutable cache for assets, no-cache for `index.html`) +3. Invalidates the CloudFront distribution + +## Manual Deploy + +```bash +cd frontend +VITE_GOVERNANCE_CONTRACT_ID= \ +VITE_TOKEN_CONTRACT_ID= \ +VITE_STELLAR_NETWORK=mainnet \ +npm run build + +aws s3 sync dist/ s3:// --delete +aws cloudfront create-invalidation --distribution-id --paths "/*" +``` diff --git a/infra/terraform/envs/production.tfvars b/infra/terraform/envs/production.tfvars new file mode 100644 index 0000000..55c0ed8 --- /dev/null +++ b/infra/terraform/envs/production.tfvars @@ -0,0 +1,2 @@ +environment = "production" +aws_region = "us-east-1" diff --git a/infra/terraform/envs/staging.tfvars b/infra/terraform/envs/staging.tfvars new file mode 100644 index 0000000..2f618d2 --- /dev/null +++ b/infra/terraform/envs/staging.tfvars @@ -0,0 +1,2 @@ +environment = "staging" +aws_region = "us-east-1" diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..bd84592 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,117 @@ +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +# CloudFront requires ACM certs in us-east-1 +provider "aws" { + alias = "us_east_1" + region = "us-east-1" +} + +locals { + bucket_name = "cosmosvote-frontend-${var.environment}" + origin_id = "cosmosvote-s3-${var.environment}" +} + +# ─── S3 bucket ──────────────────────────────────────────────────────────────── +resource "aws_s3_bucket" "frontend" { + bucket = local.bucket_name + force_destroy = var.environment != "production" +} + +resource "aws_s3_bucket_public_access_block" "frontend" { + bucket = aws_s3_bucket.frontend.id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_versioning" "frontend" { + bucket = aws_s3_bucket.frontend.id + versioning_configuration { status = "Enabled" } +} + +# ─── CloudFront OAC ─────────────────────────────────────────────────────────── +resource "aws_cloudfront_origin_access_control" "frontend" { + name = local.origin_id + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +resource "aws_s3_bucket_policy" "frontend" { + bucket = aws_s3_bucket.frontend.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowCloudFront" + Effect = "Allow" + Principal = { Service = "cloudfront.amazonaws.com" } + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.frontend.arn}/*" + Condition = { + StringEquals = { + "AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn + } + } + }] + }) +} + +# ─── CloudFront distribution ────────────────────────────────────────────────── +resource "aws_cloudfront_distribution" "frontend" { + enabled = true + default_root_object = "index.html" + price_class = "PriceClass_100" + comment = "CosmosVote frontend (${var.environment})" + + origin { + domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name + origin_id = local.origin_id + origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id + } + + default_cache_behavior { + target_origin_id = local.origin_id + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + compress = true + + forwarded_values { + query_string = false + cookies { forward = "none" } + } + } + + # SPA fallback: return index.html for 403/404 + custom_error_response { + error_code = 403 + response_code = 200 + response_page_path = "/index.html" + } + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + } + + restrictions { + geo_restriction { restriction_type = "none" } + } + + viewer_certificate { + cloudfront_default_certificate = true + } +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..ae4705e --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,14 @@ +output "cloudfront_domain" { + description = "CloudFront distribution domain name" + value = aws_cloudfront_distribution.frontend.domain_name +} + +output "s3_bucket" { + description = "S3 bucket name" + value = aws_s3_bucket.frontend.id +} + +output "cloudfront_distribution_id" { + description = "CloudFront distribution ID (needed for cache invalidation)" + value = aws_cloudfront_distribution.frontend.id +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..d4d6ef6 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,14 @@ +variable "aws_region" { + description = "AWS region for S3 bucket" + type = string + default = "us-east-1" +} + +variable "environment" { + description = "Deployment environment: staging or production" + type = string + validation { + condition = contains(["staging", "production"], var.environment) + error_message = "environment must be staging or production" + } +}