diff --git a/.gitignore b/.gitignore index 153eb45..14be480 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist-ssr .env .env.local .env.production +.env.staging +.env.preview .DS_Store *.pem coverage diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..bde5e10 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,174 @@ +# ───────────────────────────────────────────────────────────────────────────── +# BudgetWise CI/CD Pipeline +# +# Branch → Environment mapping: +# main → production (https://{PROD_URL}) +# staging → staging (https://{STAGING_URL}) +# merge request → preview (https://{MR_IID}.{BASE_DOMAIN}) +# +# All config lives in GitLab CI/CD variables (Settings → CI/CD → Variables). +# No .env files are committed — the build generates them at runtime. +# +# Required variables: +# SSH_PRIVATE_KEY Private key with access to DEPLOY_HOST +# DEPLOY_HOST Server hostname or IP +# DEPLOY_USER SSH username on the server +# DEPLOY_PATH Base deploy path e.g. /var/www/budget-app +# +# PROD_URL e.g. https://budget.kushalgaywala.com +# PROD_APPWRITE_ENDPOINT e.g. https://appwrite.example.com/v1 +# PROD_APPWRITE_PROJECT_ID +# +# STAGING_URL e.g. https://staging.budget.kushalgaywala.com +# STAGING_APPWRITE_ENDPOINT +# STAGING_APPWRITE_PROJECT_ID +# +# BASE_DOMAIN MR preview base e.g. budget.kushalgaywala.com +# Preview URLs: https://{mr-iid}.{BASE_DOMAIN} +# Preview shares staging Appwrite credentials. +# +# Server directory layout: +# $DEPLOY_PATH/production/ ← prod +# $DEPLOY_PATH/staging/ ← staging +# $DEPLOY_PATH/mr-{iid}/ ← MR preview (removed on MR close) +# ───────────────────────────────────────────────────────────────────────────── + +stages: + - build + - deploy + +.deploy_base: + image: alpine:latest + before_script: + - apk add --no-cache rsync openssh-client + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh && chmod 700 ~/.ssh + - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + +# ── Production ──────────────────────────────────────────────────────────────── + +build:production: + stage: build + image: node:20-alpine + variables: + VITE_APP_URL: $PROD_URL + VITE_APPWRITE_ENDPOINT: $PROD_APPWRITE_ENDPOINT + VITE_APPWRITE_PROJECT_ID: $PROD_APPWRITE_PROJECT_ID + script: + - npm ci + - npm run build + - echo "VITE_APP_URL=$PROD_URL" > deploy.env + artifacts: + paths: + - dist/ + reports: + dotenv: deploy.env + expire_in: 1 hour + rules: + - if: $CI_COMMIT_BRANCH == "main" + +deploy:production: + extends: .deploy_base + stage: deploy + environment: + name: production + url: $VITE_APP_URL + script: + - rsync -avz --delete dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/production/" + needs: + - job: build:production + artifacts: true + rules: + - if: $CI_COMMIT_BRANCH == "main" + +# ── Staging ─────────────────────────────────────────────────────────────────── + +build:staging: + stage: build + image: node:20-alpine + variables: + VITE_APP_URL: $STAGING_URL + VITE_APPWRITE_ENDPOINT: $STAGING_APPWRITE_ENDPOINT + VITE_APPWRITE_PROJECT_ID: $STAGING_APPWRITE_PROJECT_ID + script: + - npm ci + - npx tsc --noEmit + - npx vite build --mode staging + - echo "VITE_APP_URL=$STAGING_URL" > deploy.env + artifacts: + paths: + - dist/ + reports: + dotenv: deploy.env + expire_in: 1 hour + rules: + - if: $CI_COMMIT_BRANCH == "staging" + +deploy:staging: + extends: .deploy_base + stage: deploy + environment: + name: staging + url: $VITE_APP_URL + script: + - rsync -avz --delete dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/staging/" + needs: + - job: build:staging + artifacts: true + rules: + - if: $CI_COMMIT_BRANCH == "staging" + +# ── Preview / MR ────────────────────────────────────────────────────────────── +# One ephemeral environment per MR: https://{mr-iid}.{BASE_DOMAIN} +# Uses staging Appwrite credentials. +# Environment is torn down automatically when the MR is closed. + +build:preview: + stage: build + image: node:20-alpine + variables: + VITE_APPWRITE_ENDPOINT: $STAGING_APPWRITE_ENDPOINT + VITE_APPWRITE_PROJECT_ID: $STAGING_APPWRITE_PROJECT_ID + script: + - npm ci + - npx tsc --noEmit + - export MR_URL="https://$CI_MERGE_REQUEST_IID.$BASE_DOMAIN" + - VITE_APP_URL=$MR_URL npx vite build --mode preview + - echo "VITE_APP_URL=$MR_URL" > deploy.env + artifacts: + paths: + - dist/ + reports: + dotenv: deploy.env + expire_in: 1 day + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +deploy:preview: + extends: .deploy_base + stage: deploy + environment: + name: review/mr-$CI_MERGE_REQUEST_IID + url: $VITE_APP_URL + on_stop: stop:preview + script: + - rsync -avz --delete dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/mr-$CI_MERGE_REQUEST_IID/" + needs: + - job: build:preview + artifacts: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + +stop:preview: + extends: .deploy_base + stage: deploy + environment: + name: review/mr-$CI_MERGE_REQUEST_IID + action: stop + script: + - ssh "$DEPLOY_USER@$DEPLOY_HOST" "rm -rf $DEPLOY_PATH/mr-$CI_MERGE_REQUEST_IID" + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + when: manual + allow_failure: true