diff --git a/.github/workflows/deploy-bundle-preview.yml b/.github/workflows/deploy-bundle-preview.yml index 7c6d09d6..69d3f7f7 100644 --- a/.github/workflows/deploy-bundle-preview.yml +++ b/.github/workflows/deploy-bundle-preview.yml @@ -8,16 +8,14 @@ on: branches: - '*' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + jobs: build-and-deploy: runs-on: ubuntu-latest steps: - - name: Configure Git Credentials - uses: de-vri-es/setup-git-credentials@v2 - with: - credentials: ${{ secrets.GIT_CREDENTIALS }} - - name: Checkout Repository uses: actions/checkout@v4 @@ -26,25 +24,66 @@ jobs: with: node-version: 21.6.1 - - name: Install Dependencies - run: npm ci + - name: Validate Branch Names + run: | + # Check if branch names contain invalid characters. Only alphanumeric, _, -, ., and / are allowed. + validate_branch_name() { + local branch_name="$1" + if [[ ! "$branch_name" =~ ^[a-zA-Z0-9/_\.-]+$ ]]; then + echo "Error: Branch name contains invalid characters. Only alphanumeric, _, -, ., and / are allowed." + exit 1 + fi + } + validate_branch_name "${{ github.event.pull_request.head.ref }}" - name: Extract Branch Names - shell: bash - run: | - # transform branch names in form of `refs/heads/main` to `main` - draft_branch=$(basename ${{ github.event.pull_request.head.ref || github.event.ref }}) - echo "draft_branch=$draft_branch" >> $GITHUB_OUTPUT id: extract_branch + run: | + # Extract and transform branch names + extract_branch() { + local input_branch="$1" + # Check if input_branch starts with "refs/heads/" + if [[ "$input_branch" == refs/heads/* ]]; then + # Remove "refs/heads/" prefix safely using parameter expansion + branch_name="${input_branch#refs/heads/}" + echo "$branch_name" + else + echo "$input_branch" + fi + } + + # Transform branch names in form of `refs/heads/main` to `main` + draft_branch=$(extract_branch "${{ github.event.pull_request.head.ref }}") + + # Replace / with - in the draft branch name to use as a directory name + draft_directory=$(echo "$draft_branch" | tr / -) + + # Safe echo to $GITHUB_OUTPUT + { + echo "draft_branch=$draft_branch" + echo "draft_directory=$draft_directory" + } >> "$GITHUB_OUTPUT" + + - name: Set Draft URL + id: draft_url + if: success() + run: | + echo "url=${{ vars.BUNDLE_PREVIEW_BASE_URL }}/docs-ui-drafts/${{ steps.extract_branch.outputs.draft_directory }}/index.html" >> $GITHUB_OUTPUT + + - name: Install Dependencies + run: npm ci - name: Build UI Bundle Preview + if: success() run: | set -o pipefail gulp lint |& tee $GITHUB_WORKSPACE/build.log gulp preview:build |& tee $GITHUB_WORKSPACE/build.log + env: + NO_COLOR: 1 - name: Check Build Result - id: logFail + id: buildLogFail if: failure() run: | MULTILINE_LOG=$(cat $GITHUB_WORKSPACE/build.log) @@ -52,22 +91,28 @@ jobs: echo $MULTILINE_LOG >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV + - name: Assemble Build Success Comment + if: ${{ success() && github.event.pull_request.number }} + run: | + build_success_comment="UI bundle preview build successful! :white_check_mark:" + build_success_comment+="\nDeploying bundle preview." + + echo "BUILD_SUCCESS_COMMENT<> $GITHUB_ENV + echo -e "$build_success_comment" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + - name: Create Build Success Comment if: ${{ success() && github.event.pull_request.number }} - uses: peter-evans/create-or-update-comment@v3 + uses: peter-evans/create-or-update-comment@v4 with: - token: ${{ secrets.DOCS_GITHUB_PAT }} issue-number: ${{ github.event.pull_request.number }} - body: | - UI bundle preview build successful! :white_check_mark: - Deploying preview to GitHub Pages. + body: "${{ env.BUILD_SUCCESS_COMMENT }}" reactions: rocket - name: Create Build Failure Comment if: ${{ failure() && github.event.pull_request.number }} - uses: peter-evans/create-or-update-comment@v3 + uses: peter-evans/create-or-update-comment@v4 with: - token: ${{ secrets.DOCS_GITHUB_PAT }} issue-number: ${{ github.event.pull_request.number }} body: | UI bundle preview build failure! :x: @@ -75,85 +120,87 @@ jobs: reactions: confused - name: Find Comment - if: ${{ success() && github.event.pull_request.number }} - uses: peter-evans/find-comment@v2 id: fc + if: ${{ success() && github.event.pull_request.number }} + uses: peter-evans/find-comment@v3 with: - token: ${{ secrets.DOCS_GITHUB_PAT }} issue-number: ${{ github.event.pull_request.number }} body-includes: UI bundle preview build successful! direction: last - - name: Deploy to GitHub Pages + - name: Configure AWS CLI if: success() run: | - git clone https://github.com/$GITHUB_REPOSITORY.git pages - cd pages - git checkout gh-pages - - # If there was previously a build for the preview, then remove it - # so we get a clean build. This is needed in case a follow up - # build of the same pull request contains content deletions. - rm -rf ${{ steps.extract_branch.outputs.draft_branch }} + aws configure set aws_access_key_id ${{ secrets.DOCS_UI_AWS_ACCESS_KEY_ID }} + aws configure set aws_secret_access_key ${{ secrets.DOCS_UI_AWS_SECRET_ACCESS_KEY }} + aws configure set region us-west-2 - mkdir -p ${{ steps.extract_branch.outputs.draft_branch }} - cp -r ../public/* ${{ steps.extract_branch.outputs.draft_branch }}/. + - name: Deploy to S3 + if: success() + run: | + set -o pipefail + mkdir docs-ui-drafts + mv public docs-ui-drafts/${{ steps.extract_branch.outputs.draft_directory }} + cd docs-ui-drafts # Records the repository that originally triggered the build so we can post back # comments upon clean up of a stale draft if it still has an open pull request. - echo "${{ github.event.repository.full_name }}" > ${{ steps.extract_branch.outputs.draft_branch }}/.github_source_repository - - git add . - git config user.name 'github-actions[bot]' - git config user.email 'github-actions[bot]@users.noreply.github.com' - git commit --allow-empty -m "Auto-deployed from GitHub Actions" - git push -u origin gh-pages - - - name: Obtain GitHub Pages build URL - if: success() - run: | - sleep 5 # Allow time for build to initiate - build_url=$(curl -s -L \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.DOCS_GITHUB_PAT }}" \ - -H "X-GitHub-Api-Version: 2022-11-28" 'https://api.github.com/repos/${{ github.event.repository.full_name }}/pages/builds/latest' \ - | jq -r .url) - echo "url=$build_url" >> $GITHUB_OUTPUT - id: ghpages_build - - - name: Wait for Github Pages deployment - if: success() - run: | - for i in {1..60}; do - build_status=$(curl -s -L \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.DOCS_GITHUB_PAT }}" \ - -H "X-GitHub-Api-Version: 2022-11-28" '${{ steps.ghpages_build.outputs.url }}' \ - | jq -r .status) - - if [ "$build_status" == "built" ]; then echo "Deploy is complete." - exit 0 - else - echo "Deploy is not complete. Status: $build_status. Retrying in 10 seconds..." - sleep 10 - fi - done - echo "Deploy is still not complete after approximately 10 minutes." - exit 1 - - - name: Get GitHub Pages Preview URL + echo "${{ github.event.repository.full_name }}" > ${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository + + s3_params=( + # Hide upload progress for a cleaner sync log + --no-progress + # Because the build will produce new timestamps + # on each build, sync files based on size. + --size-only + --delete + --exclude "*" + --include "${{ steps.extract_branch.outputs.draft_directory }}/*" + ) + + echo "Deploying draft to S3." |& tee -a $GITHUB_WORKSPACE/deploy.log + echo "aws s3 sync . s3://${{ vars.BUNDLE_PREVIEW_S3_BUCKET_NAME }}/docs-ui-drafts ${s3_params[@]}" |& tee -a $GITHUB_WORKSPACE/deploy.log + aws s3 sync . "s3://${{ vars.BUNDLE_PREVIEW_S3_BUCKET_NAME }}/docs-ui-drafts" "${s3_params[@]}" |& tee -a $GITHUB_WORKSPACE/deploy.log + + # Update .github_source_repository file metadata to mark last modified time of the draft. + # This will allow us to later determine if a draft is stale and needs to be cleaned up. + echo "Marking last modified time of the draft." |& tee -a $GITHUB_WORKSPACE/deploy.log + echo "aws s3 cp --metadata '{\"touched\": \"now\"}' \ + s3://${{ vars.BUNDLE_PREVIEW_S3_BUCKET_NAME }}/docs-ui-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \ + s3://${{ vars.BUNDLE_PREVIEW_S3_BUCKET_NAME }}/docs-ui-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository" \ + |& tee -a $GITHUB_WORKSPACE/deploy.log + + aws s3 cp --metadata '{ "touched": "now" }' \ + s3://${{ vars.BUNDLE_PREVIEW_S3_BUCKET_NAME }}/docs-ui-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \ + s3://${{ vars.BUNDLE_PREVIEW_S3_BUCKET_NAME }}/docs-ui-drafts/${{ steps.extract_branch.outputs.draft_directory }}/.github_source_repository \ + |& tee -a $GITHUB_WORKSPACE/deploy.log + + - name: Invalidate CloudFront Cache if: success() shell: bash run: | - echo "url=https://riptano.github.io/${{ github.event.repository.name }}/${{ steps.extract_branch.outputs.draft_branch }}/" >> $GITHUB_OUTPUT - id: draft_url + invalidation_batch="{ \"Paths\": { \"Quantity\": 1, \"Items\": [\"/docs-ui-drafts/${{ steps.extract_branch.outputs.draft_directory }}/*\"] }, \"CallerReference\": \"docs-ui-draft-files-$(date +%s)\" }" - - name: Update comment + echo $invalidation_batch | jq . |& tee -a "$GITHUB_WORKSPACE/deploy.log" + echo "Creating invalidation." |& tee -a "$GITHUB_WORKSPACE/deploy.log" + invalidation_id=$(aws cloudfront create-invalidation --distribution-id "${{ vars.BUNDLE_PREVIEW_CLOUD_FRONT_DISTRIBUTION_ID }}" --invalidation-batch "$invalidation_batch" --query 'Invalidation.Id' --output text |& tee -a "$GITHUB_WORKSPACE/deploy.log") + + echo "Awaiting invalidation." |& tee -a "$GITHUB_WORKSPACE/deploy.log" + aws cloudfront wait invalidation-completed --distribution-id "${{ vars.BUNDLE_PREVIEW_CLOUD_FRONT_DISTRIBUTION_ID }}" --id "$invalidation_id" |& tee -a "$GITHUB_WORKSPACE/deploy.log" + echo "Invalidation complete." |& tee -a "$GITHUB_WORKSPACE/deploy.log" + + - name: Update Comment if: ${{ steps.fc.outputs.comment-id != '' }} - uses: peter-evans/create-or-update-comment@v3 + uses: peter-evans/create-or-update-comment@v4 with: - token: ${{ secrets.DOCS_GITHUB_PAT }} comment-id: ${{ steps.fc.outputs.comment-id }} body: | - Deployment successful! [View preview](${{ steps.draft_url.outputs.url }}) + Deploy successful! [View preview](${{ steps.draft_url.outputs.url }}) reactions: hooray + + - name: Upload Deploy Log + uses: actions/upload-artifact@v4 + if: always() + with: + name: deploy.log + path: ${{ github.workspace }}/deploy.log diff --git a/.github/workflows/gh-pages-cleanup.yml b/.github/workflows/gh-pages-cleanup.yml deleted file mode 100644 index 82b2aee2..00000000 --- a/.github/workflows/gh-pages-cleanup.yml +++ /dev/null @@ -1,125 +0,0 @@ -name: Cleanup GitHub Pages - -on: - schedule: - # Runs daily at 1AM UTC which is 6PM pacific - - cron: '0 1 * * *' - - workflow_dispatch: - inputs: - stale_threshold: - description: 'Threshold after which the preview build is considered to be stale' - default: '2 weeks' - type: string - -jobs: - remove_stale_previews: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: 'gh-pages' - fetch-depth: 0 - - - name: Determine Previews to Delete - id: find_previews - run: | - # Get the stale threshold from the workflow parameter - # and calculate the timestamp for the threshold - STALE_THRESHOLD="${{ github.event.inputs.stale_threshold || '2 weeks' }}" - THRESHOLD_DATE=$(date --date="$STALE_THRESHOLD ago" +%s) - - # Find and collect previews older than the specified threshold, skipping "main" and ".git" - STALE_PREVIEWS=() - for dir in $(find . -type d -mindepth 1 -maxdepth 1); do - # Check if the preview name is "main" or ".git" and skip it - if [ "$(basename "$dir")" == "main" ] || [ "$(basename "$dir")" == ".git" ]; then - continue - fi - - DIR_DATE=$(git log -1 --format="%ct" -- "$dir") - if [ -n "$DIR_DATE" ] && [ "$DIR_DATE" -lt "$THRESHOLD_DATE" ]; then - STALE_PREVIEWS+=("$(basename "$dir")") - fi - done - - if [ -z "${STALE_PREVIEWS[*]}" ]; then - echo "Did not find any stale previews based on $STALE_THRESHOLD threshold. Done." - else - echo "Previews determined to be stale based on $STALE_THRESHOLD threshold: ${STALE_PREVIEWS[*]}" - fi - - # Set the list of previews to be removed as an output - echo "previews_to_delete=${STALE_PREVIEWS[*]}" >> $GITHUB_OUTPUT - - - name: Notify Open Pull Requests of Preview Removal - if: ${{ steps.find_previews.outputs.previews_to_delete != '' }} - run: | - # Notify open pull requests of their preview being deleted - # but don't fail on this step for any reason - set +e - - STALE_PREVIEWS=(${{ steps.find_previews.outputs.previews_to_delete }}) - STALE_THRESHOLD="${{ github.event.inputs.stale_threshold || '2 weeks' }}" - - # Iterate through the previews and look for open PRs - for dir in "${STALE_PREVIEWS[@]}"; do - if [ -f "$dir/.github_source_repository" ]; then - # Get the source repository for the preview build - SOURCE_REPO=$(cat "$dir/.github_source_repository") - echo "Source repository for $dir is $SOURCE_REPO" - else - # Without .github_source_repository we don't know where to post the comment so we'll skip - echo "Could not determine source repository for $dir. Skipping." - continue - fi - - # Check if there are open pull requests for the source repository - OPEN_PRS=$(curl -s -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$SOURCE_REPO/pulls?state=open") - - # Check if any open pull requests match the preview branch name - PREVIEW_PRS=$(echo $OPEN_PRS | jq -r --arg dir "$dir" '.[] | select(.head.ref == $dir) | .number' 2>/dev/null) - - if [[ -n "$PREVIEW_PRS" ]]; then - # Comment on the open PRs - for pr_number in $PREVIEW_PRS; do - COMMENT_BODY="The preview build for this pull request has been cleaned up due to being stale.\n\nPreview builds that were $STALE_THRESHOLD old or older were automatically removed to maintain a tidy GitHub Pages site.\n\nYou can rebuild the preview at any time by pushing a new commit to this pull request:\n\n\`\`\`\ngit checkout $dir\ngit commit --allow-empty -m 'rebuild preview'\ngit push origin $dir\n\`\`\`" - curl -X POST -d "{\"body\":\"$COMMENT_BODY\"}" -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$SOURCE_REPO/issues/$pr_number/comments" - done - else - echo "No open pull requests for $dir. No notification needed." - fi - done - - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Remove Previews and Push to GitHub Pages - if: ${{ steps.find_previews.outputs.previews_to_delete != '' }} - run: | - # Get the list of directories to delete from the previous step's output - STALE_PREVIEWS=(${{ steps.find_previews.outputs.previews_to_delete }}) - - # Get the stale threshold from the workflow parameter - # and calculate the timestamp for the threshold - STALE_THRESHOLD="${{ github.event.inputs.stale_threshold || '2 weeks' }}" - - echo "Removing stale previews based on $STALE_THRESHOLD threshold: ${STALE_PREVIEWS[*]}" - - # Remove the directories and their contents - for dir in "${STALE_PREVIEWS[@]}"; do - rm -rf "$dir" - done - - # Commit and push the changes - git add . - - if [[ `git status --porcelain` ]]; then - git config user.name 'github-actions[bot]' - git config user.email 'github-actions[bot]@users.noreply.github.com' - git commit -m "Removed previews older than $STALE_THRESHOLD via ${{ github.event_name }} by ${{ github.actor }}" - git push origin gh-pages - else - echo "No previews were deleted." - fi