diff --git a/.github/workflows/pwa-deployment.yml b/.github/workflows/pwa-deployment.yml new file mode 100644 index 0000000..d31c770 --- /dev/null +++ b/.github/workflows/pwa-deployment.yml @@ -0,0 +1,489 @@ +name: ๐Ÿ“ฑ PWA Deployment + +on: + workflow_call: + inputs: + # AWS Configuration + aws-region: + description: "AWS region for deployment" + type: string + required: false + default: "ap-southeast-2" + s3-bucket: + description: "S3 bucket name for deployment" + type: string + required: true + cloudfront-distribution-id: + description: "CloudFront distribution ID for cache invalidation" + type: string + required: true + + # Environment Configuration + environment: + description: "Deployment environment (GitHub environment name for protection rules)" + type: string + required: false + default: "staging" + + # Build Configuration + package-manager: + description: "Node package manager (yarn/npm)" + type: string + required: false + default: "yarn" + is-yarn-classic: + description: "Use Yarn Classic (pre-Berry) instead of modern Yarn" + type: boolean + required: false + default: false + build-command: + description: "Build command to execute" + type: string + required: false + default: "build" + build-directory: + description: "Directory containing built assets to deploy" + type: string + required: false + default: "dist" + + # Cache Strategy Configuration + cache-strategy: + description: "Cache strategy for assets (immutable/no-cache)" + type: string + required: false + default: "immutable" + + # Preview Environment Configuration + preview-mode: + description: "Enable preview mode for PR-based deployments" + type: boolean + required: false + default: false + preview-base-url: + description: "Base URL for preview deployments" + type: string + required: false + default: "" + + # Multi-brand Configuration + brand-config: + description: "JSON configuration for multi-brand deployments" + type: string + required: false + default: "" + + # Advanced Configuration + cloudfront-invalidation-paths: + description: "CloudFront invalidation paths (JSON array)" + type: string + required: false + default: '["/*"]' + extra-sync-args: + description: "Additional AWS S3 sync arguments" + type: string + required: false + default: "" + + # Debug and Control + debug: + description: "Enable verbose logging and debug output" + type: boolean + required: false + default: false + skip-build: + description: "Skip the build step (use pre-built assets)" + type: boolean + required: false + default: false + skip-tests: + description: "Skip test execution" + type: boolean + required: false + default: false + + secrets: + aws-access-key-id: + description: "AWS access key ID" + required: true + aws-secret-access-key: + description: "AWS secret access key" + required: true + + outputs: + deployment-url: + description: "URL of the deployed application" + value: ${{ jobs.deploy.outputs.deployment-url }} + preview-url: + description: "Preview URL for PR deployments" + value: ${{ jobs.deploy.outputs.preview-url }} + +jobs: + # Validate inputs and prepare deployment configuration + prepare: + name: ๐Ÿ” Prepare Deployment + runs-on: ubuntu-latest + outputs: + cache-control-static: ${{ steps.cache-config.outputs.cache-control-static }} + cache-control-html: ${{ steps.cache-config.outputs.cache-control-html }} + s3-prefix: ${{ steps.deployment-config.outputs.s3-prefix }} + deployment-url: ${{ steps.deployment-config.outputs.deployment-url }} + brand-matrix: ${{ steps.brand-config.outputs.matrix }} + invalidation-paths: ${{ steps.cache-config.outputs.invalidation-paths }} + steps: + - name: Validate required inputs + run: | + if [ -z "${{ inputs.s3-bucket }}" ]; then + echo "โŒ Error: s3-bucket is required" + exit 1 + fi + + if [ -z "${{ inputs.cloudfront-distribution-id }}" ]; then + echo "โŒ Error: cloudfront-distribution-id is required" + exit 1 + fi + + # Environment name is validated by GitHub's environment protection rules + if [ -n "${{ inputs.environment }}" ]; then + echo "๐Ÿ” Using GitHub environment: ${{ inputs.environment }}" + fi + + if [ "${{ inputs.cache-strategy }}" != "immutable" ] && [ "${{ inputs.cache-strategy }}" != "no-cache" ]; then + echo "โŒ Error: cache-strategy must be one of: immutable, no-cache" + exit 1 + fi + + echo "โœ… All required inputs validated" + + - name: Configure cache strategy + id: cache-config + run: | + case "${{ inputs.cache-strategy }}" in + "immutable") + # Static assets with content hashing - cache for 1 year + echo "cache-control-static=public, max-age=31536000, immutable" >> $GITHUB_OUTPUT + # HTML files - cache for 1 hour with revalidation + echo "cache-control-html=public, max-age=3600, must-revalidate" >> $GITHUB_OUTPUT + ;; + "no-cache") + # Force revalidation for all assets + echo "cache-control-static=no-cache, no-store, must-revalidate" >> $GITHUB_OUTPUT + echo "cache-control-html=no-cache, no-store, must-revalidate" >> $GITHUB_OUTPUT + ;; + esac + + # Prepare invalidation paths + echo "invalidation-paths=${{ inputs.cloudfront-invalidation-paths }}" >> $GITHUB_OUTPUT + + if [ "${{ inputs.debug }}" = "true" ]; then + echo "๐Ÿ” Cache configuration:" + echo " Strategy: ${{ inputs.cache-strategy }}" + echo " Static assets: $(cat $GITHUB_OUTPUT | grep cache-control-static | cut -d'=' -f2-)" + echo " HTML files: $(cat $GITHUB_OUTPUT | grep cache-control-html | cut -d'=' -f2-)" + fi + + - name: Configure deployment paths + id: deployment-config + run: | + if [ "${{ inputs.preview-mode }}" = "true" ]; then + # Preview deployment uses PR number or branch name + if [ -n "${{ github.event.pull_request.number }}" ]; then + PREFIX="pr-${{ github.event.pull_request.number }}" + else + PREFIX="branch-$(echo '${{ github.ref_name }}' | sed 's/[^a-zA-Z0-9-]/-/g')" + fi + + echo "s3-prefix=${PREFIX}/" >> $GITHUB_OUTPUT + + if [ -n "${{ inputs.preview-base-url }}" ]; then + echo "deployment-url=${{ inputs.preview-base-url }}/${PREFIX}/" >> $GITHUB_OUTPUT + else + echo "deployment-url=https://${{ inputs.s3-bucket }}.s3.amazonaws.com/${PREFIX}/index.html" >> $GITHUB_OUTPUT + fi + else + # Production/staging deployment to root + echo "s3-prefix=" >> $GITHUB_OUTPUT + + if [ -n "${{ inputs.preview-base-url }}" ]; then + echo "deployment-url=${{ inputs.preview-base-url }}/" >> $GITHUB_OUTPUT + else + echo "deployment-url=https://${{ inputs.s3-bucket }}.s3.amazonaws.com/index.html" >> $GITHUB_OUTPUT + fi + fi + + if [ "${{ inputs.debug }}" = "true" ]; then + echo "๐Ÿ” Deployment configuration:" + echo " S3 Prefix: $(cat $GITHUB_OUTPUT | grep s3-prefix | cut -d'=' -f2-)" + echo " Deployment URL: $(cat $GITHUB_OUTPUT | grep deployment-url | cut -d'=' -f2-)" + fi + + - name: Configure multi-brand matrix + id: brand-config + run: | + if [ -n "${{ inputs.brand-config }}" ]; then + echo "matrix=${{ inputs.brand-config }}" >> $GITHUB_OUTPUT + echo "๐Ÿ“ฑ Multi-brand deployment configured" + else + echo 'matrix={"brand":["default"]}' >> $GITHUB_OUTPUT + echo "๐Ÿ“ฑ Single brand deployment" + fi + + # Build and test the application + build: + name: ๐Ÿ—๏ธ Build Application + runs-on: ubuntu-latest + if: inputs.skip-build == false + needs: [prepare] + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.brand-matrix) }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: ${{ inputs.package-manager }} + + - name: Install dependencies + run: | + debug="" + if [ "${{ inputs.debug }}" = "true" ]; then + debug="--verbose" + fi + + case "${{ inputs.package-manager }}" in + "yarn") + if [ "${{ inputs.is-yarn-classic }}" = "true" ]; then + yarn install --frozen-lockfile $debug + else + yarn install --immutable $debug + fi + ;; + "npm") + npm ci $debug + ;; + *) + echo "โŒ Unsupported package manager: ${{ inputs.package-manager }}" + exit 1 + ;; + esac + + - name: Run tests + if: inputs.skip-tests == false + run: | + debug="" + if [ "${{ inputs.debug }}" = "true" ]; then + debug="--verbose" + fi + + # Check if test script exists + if ${{ inputs.package-manager }} run | grep -q "test"; then + echo "๐Ÿงช Running tests..." + ${{ inputs.package-manager }} run test $debug + else + echo "โ„น๏ธ No test script found, skipping tests" + fi + + - name: Build application + run: | + debug="" + if [ "${{ inputs.debug }}" = "true" ]; then + debug="--verbose" + fi + + # Set brand-specific environment if multi-brand + if [ "${{ matrix.brand }}" != "default" ]; then + echo "๐Ÿท๏ธ Building for brand: ${{ matrix.brand }}" + export BRAND=${{ matrix.brand }} + fi + + echo "๐Ÿ—๏ธ Building application..." + ${{ inputs.package-manager }} run ${{ inputs.build-command }} $debug + + - name: Verify build output + run: | + if [ ! -d "${{ inputs.build-directory }}" ]; then + echo "โŒ Build directory '${{ inputs.build-directory }}' not found" + echo "Available directories:" + ls -la + exit 1 + fi + + echo "โœ… Build completed successfully" + echo "๐Ÿ“ Build directory contents:" + find "${{ inputs.build-directory }}" -type f | head -10 + + - name: Archive build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts-${{ matrix.brand }} + path: ${{ inputs.build-directory }}/ + retention-days: 1 + + # Deploy to S3 and invalidate CloudFront + deploy: + name: ๐Ÿš€ Deploy to AWS + runs-on: ubuntu-latest + needs: [prepare, build] + if: always() && (needs.build.result == 'success' || inputs.skip-build == true) + environment: ${{ inputs.environment }} + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.brand-matrix) }} + outputs: + deployment-url: ${{ needs.prepare.outputs.deployment-url }} + preview-url: ${{ needs.prepare.outputs.deployment-url }} + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.aws-access-key-id }} + aws-secret-access-key: ${{ secrets.aws-secret-access-key }} + aws-region: ${{ inputs.aws-region }} + + - name: Download build artifacts + if: inputs.skip-build == false + uses: actions/download-artifact@v4 + with: + name: build-artifacts-${{ matrix.brand }} + path: ${{ inputs.build-directory }} + + - name: Configure S3 deployment paths + id: s3-config + run: | + S3_PATH="${{ needs.prepare.outputs.s3-prefix }}" + + if [ "${{ matrix.brand }}" != "default" ]; then + S3_PATH="${S3_PATH}${{ matrix.brand }}/" + fi + + echo "s3-path=${S3_PATH}" >> $GITHUB_OUTPUT + echo "๐ŸŽฏ Deploying to: s3://${{ inputs.s3-bucket }}/${S3_PATH}" + + - name: Deploy static assets to S3 + run: | + echo "๐Ÿš€ Deploying static assets..." + + # Deploy static assets with immutable cache headers + aws s3 sync "${{ inputs.build-directory }}" "s3://${{ inputs.s3-bucket }}/${{ steps.s3-config.outputs.s3-path }}" \ + --exclude "*.html" \ + --cache-control "${{ needs.prepare.outputs.cache-control-static }}" \ + --delete \ + ${{ inputs.extra-sync-args }} \ + ${{ inputs.debug == true && '--cli-read-timeout 120 --cli-connect-timeout 60' || '' }} + + - name: Deploy HTML files to S3 + run: | + echo "๐Ÿ“„ Deploying HTML files..." + + # Deploy HTML files with revalidation cache headers + aws s3 sync "${{ inputs.build-directory }}" "s3://${{ inputs.s3-bucket }}/${{ steps.s3-config.outputs.s3-path }}" \ + --include "*.html" \ + --cache-control "${{ needs.prepare.outputs.cache-control-html }}" \ + --delete \ + ${{ inputs.extra-sync-args }} \ + ${{ inputs.debug == true && '--cli-read-timeout 120 --cli-connect-timeout 60' || '' }} + + - name: Invalidate CloudFront cache + run: | + echo "๐Ÿ”„ Invalidating CloudFront cache..." + + # Parse invalidation paths + PATHS=$(echo '${{ needs.prepare.outputs.invalidation-paths }}' | jq -r '.[]') + + # Add brand prefix if multi-brand deployment + if [ "${{ matrix.brand }}" != "default" ]; then + PREFIXED_PATHS="" + for path in $PATHS; do + if [ "$path" = "/*" ]; then + PREFIXED_PATHS="$PREFIXED_PATHS /${{ steps.s3-config.outputs.s3-path }}*" + else + PREFIXED_PATHS="$PREFIXED_PATHS /${{ steps.s3-config.outputs.s3-path }}${path#/}" + fi + done + PATHS="$PREFIXED_PATHS" + fi + + echo "Invalidating paths: $PATHS" + + INVALIDATION_ID=$(aws cloudfront create-invalidation \ + --distribution-id "${{ inputs.cloudfront-distribution-id }}" \ + --paths $PATHS \ + --query 'Invalidation.Id' \ + --output text) + + echo "โœ… CloudFront invalidation created: $INVALIDATION_ID" + + if [ "${{ inputs.debug }}" = "true" ]; then + echo "๐Ÿ” Waiting for invalidation to complete..." + aws cloudfront wait invalidation-completed \ + --distribution-id "${{ inputs.cloudfront-distribution-id }}" \ + --id "$INVALIDATION_ID" + echo "โœ… CloudFront invalidation completed" + fi + + - name: Generate deployment summary + run: | + echo "## ๐Ÿš€ Deployment Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| **Environment** | ${{ inputs.environment }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Brand** | ${{ matrix.brand }} |" >> $GITHUB_STEP_SUMMARY + echo "| **S3 Bucket** | ${{ inputs.s3-bucket }} |" >> $GITHUB_STEP_SUMMARY + echo "| **S3 Path** | ${{ steps.s3-config.outputs.s3-path }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Cache Strategy** | ${{ inputs.cache-strategy }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Deployment URL** | ${{ needs.prepare.outputs.deployment-url }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ๐Ÿ“Š Build Information" >> $GITHUB_STEP_SUMMARY + echo "- **Package Manager**: ${{ inputs.package-manager }}" >> $GITHUB_STEP_SUMMARY + echo "- **Build Command**: ${{ inputs.build-command }}" >> $GITHUB_STEP_SUMMARY + echo "- **Build Directory**: ${{ inputs.build-directory }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ inputs.preview-mode }}" = "true" ]; then + echo "### ๐Ÿ” Preview Environment" >> $GITHUB_STEP_SUMMARY + echo "This is a preview deployment. The application is available at:" >> $GITHUB_STEP_SUMMARY + echo "**[${{ needs.prepare.outputs.deployment-url }}](${{ needs.prepare.outputs.deployment-url }})**" >> $GITHUB_STEP_SUMMARY + else + echo "### ๐ŸŒ Production Deployment" >> $GITHUB_STEP_SUMMARY + echo "Application deployed to ${{ inputs.environment }} environment:" >> $GITHUB_STEP_SUMMARY + echo "**[${{ needs.prepare.outputs.deployment-url }}](${{ needs.prepare.outputs.deployment-url }})**" >> $GITHUB_STEP_SUMMARY + fi + + # Cleanup artifacts to save storage space + cleanup: + name: ๐Ÿงน Cleanup + runs-on: ubuntu-latest + needs: [prepare, build, deploy] + if: always() + permissions: + actions: write + steps: + - name: Delete build artifacts + uses: actions/github-script@v7 + with: + script: | + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: process.env.GITHUB_RUN_ID, + }); + + const buildArtifacts = artifacts.data.artifacts.filter(a => + a.name.startsWith('build-artifacts-') + ); + + if (buildArtifacts.length === 0) { + core.info("No build artifacts found to cleanup."); + } else { + for (const artifact of buildArtifacts) { + await github.rest.actions.deleteArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: artifact.id + }); + core.info(`Deleted artifact '${artifact.name}' with ID ${artifact.id}`); + } + } \ No newline at end of file diff --git a/README.md b/README.md index a60ce50..104144e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,129 @@ jobs: skip-format: false ``` +### PWA Deployment + +A comprehensive Progressive Web Application deployment workflow supporting S3 static hosting with CloudFront CDN, multi-environment deployments, branch-based previews, and multi-brand configurations. + +#### **Features** +- **Multi-environment support**: staging, production, and preview environments +- **Branch-based previews**: Automatic preview deployments for pull requests +- **Dual cache strategies**: Immutable caching for static assets, revalidation for HTML +- **CloudFront integration**: Automatic cache invalidation with configurable paths +- **Multi-brand deployment**: Parallel deployment support for multiple brands +- **Node.js 16-22 support**: Compatible with Yarn and npm package managers +- **Manual production gates**: Environment-based deployment protection +- **Comprehensive caching**: Build artifact optimization and cleanup + +#### **Inputs** +| Name | Required | Type | Default | Description | +|------|----------|------|---------|-------------| +| **AWS Configuration** | +| aws-region | โŒ | string | ap-southeast-2 | AWS region for deployment | +| s3-bucket | โœ… | string | | S3 bucket name for deployment | +| cloudfront-distribution-id | โœ… | string | | CloudFront distribution ID for cache invalidation | +| **Environment Configuration** | +| environment | โŒ | string | staging | Deployment environment (GitHub environment name for protection rules) | +| **Build Configuration** | +| package-manager | โŒ | string | yarn | Node package manager (yarn/npm) | +| is-yarn-classic | โŒ | boolean | false | Use Yarn Classic (pre-Berry) instead of modern Yarn | +| build-command | โŒ | string | build | Build command to execute | +| build-directory | โŒ | string | dist | Directory containing built assets to deploy | +| **Cache Strategy Configuration** | +| cache-strategy | โŒ | string | immutable | Cache strategy for assets (immutable/no-cache) | +| **Preview Environment Configuration** | +| preview-mode | โŒ | boolean | false | Enable preview mode for PR-based deployments | +| preview-base-url | โŒ | string | | Base URL for preview deployments | +| **Multi-brand Configuration** | +| brand-config | โŒ | string | | JSON configuration for multi-brand deployments | +| **Advanced Configuration** | +| cloudfront-invalidation-paths | โŒ | string | ["/*"] | CloudFront invalidation paths (JSON array) | +| extra-sync-args | โŒ | string | | Additional AWS S3 sync arguments | +| **Debug and Control** | +| debug | โŒ | boolean | false | Enable verbose logging and debug output | +| skip-build | โŒ | boolean | false | Skip the build step (use pre-built assets) | +| skip-tests | โŒ | boolean | false | Skip test execution | + +#### **Secrets** +| Name | Required | Description | +|------|----------|-------------| +| aws-access-key-id | โœ… | AWS access key ID | +| aws-secret-access-key | โœ… | AWS secret access key | + +#### **Outputs** +| Name | Description | +|------|-------------| +| deployment-url | URL of the deployed application | +| preview-url | Preview URL for PR deployments | + +#### **Example Usage** + +**Basic Production Deployment:** +```yaml +jobs: + deploy-production: + uses: aligent/workflows/.github/workflows/pwa-deployment.yml@main + with: + s3-bucket: my-production-bucket + cloudfront-distribution-id: E1234567890ABC + environment: production + cache-strategy: immutable + secrets: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +``` + +**Preview Environment for Pull Requests:** +```yaml +jobs: + deploy-preview: + if: github.event_name == 'pull_request' + uses: aligent/workflows/.github/workflows/pwa-deployment.yml@main + with: + s3-bucket: my-preview-bucket + cloudfront-distribution-id: E1234567890ABC + environment: preview + preview-mode: true + preview-base-url: https://preview.example.com + cache-strategy: no-cache + secrets: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +``` + +**Multi-brand Deployment:** +```yaml +jobs: + deploy-multi-brand: + uses: aligent/workflows/.github/workflows/pwa-deployment.yml@main + with: + s3-bucket: my-multi-brand-bucket + cloudfront-distribution-id: E1234567890ABC + environment: production + brand-config: '{"brand":["brand-a","brand-b","brand-c"]}' + build-command: build:brands + secrets: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +``` + +**Custom Build Configuration:** +```yaml +jobs: + deploy-custom: + uses: aligent/workflows/.github/workflows/pwa-deployment.yml@main + with: + s3-bucket: my-custom-bucket + cloudfront-distribution-id: E1234567890ABC + environment: staging + package-manager: npm + build-command: build:staging + build-directory: build + cloudfront-invalidation-paths: '["/*", "/api/*"]' + extra-sync-args: --exclude "*.map" + debug: true +``` + ### S3 Deployment #### **Inputs**