diff --git a/.github/workflows/build-and-preview-docs.yml b/.github/workflows/build-and-preview-docs.yml deleted file mode 100644 index ddf08ce9223..00000000000 --- a/.github/workflows/build-and-preview-docs.yml +++ /dev/null @@ -1,208 +0,0 @@ -name: Build and Preview Docs - -on: - pull_request_target: - branches: [master] - types: [opened, synchronize, reopened, closed] - -permissions: - contents: write - pull-requests: write - -concurrency: - group: preview-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - build-and-deploy-preview: - runs-on: ubuntu-24.04 - outputs: - removed_prs_json: ${{ steps.prune-previews.outputs.removed_prs_json }} - env: - HUGO_VERSION: 0.158.0 - PREVIEW_RETENTION_LIMIT: 6 - - steps: - - name: Checkout PR code - if: github.event.action != 'closed' - uses: actions/checkout@v6 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - submodules: recursive - fetch-depth: 0 - - - name: Checkout for cleanup - if: github.event.action == 'closed' - uses: actions/checkout@v6 - with: - ref: gh-pages - fetch-depth: 0 - - - name: Install Hugo CLI - if: github.event.action != 'closed' - run: | - wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \ - && sudo dpkg -i ${{ runner.temp }}/hugo.deb - - - name: Install Dart Sass (npm) - if: github.event.action != 'closed' - run: | - npm i -g sass - sass --version - - - name: Setup Node - if: github.event.action != 'closed' - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install dependencies - if: github.event.action != 'closed' - run: - '[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || - true' - - - name: Build PR preview - if: github.event.action != 'closed' - env: - BASE_URL: /pr-preview/pr-${{ github.event.pull_request.number }}/ - HUGO_PREVIEW: 'true' - run: | - npm run build:preview - cat > public/robots.txt <<'EOF' - User-agent: * - Disallow: / - EOF - - - name: Deploy PR preview - if: github.event.action != 'closed' - uses: rossjrw/pr-preview-action@v1.6.3 - with: - source-dir: ./public - preview-branch: gh-pages - umbrella-dir: pr-preview - action: auto - comment: false - - - name: Checkout gh-pages for preview retention - if: github.event.action != 'closed' - uses: actions/checkout@v6 - with: - ref: gh-pages - fetch-depth: 0 - path: gh-pages-maintenance - - - name: Prune old PR previews - id: prune-previews - if: github.event.action != 'closed' - run: | - cd gh-pages-maintenance - mkdir -p pr-preview - removed_prs=() - - mapfile -t previews < <( - while IFS= read -r preview; do - timestamp="$(git log -1 --format=%ct -- "pr-preview/$preview" 2>/dev/null || echo 0)" - printf '%s %s\n' "$timestamp" "$preview" - done < <(find pr-preview -mindepth 1 -maxdepth 1 -type d -name 'pr-*' -printf '%f\n') \ - | sort -nr \ - | awk '{print $2}' - ) - - if (( ${#previews[@]} <= PREVIEW_RETENTION_LIMIT )); then - echo "removed_prs_json=[]" >> "$GITHUB_OUTPUT" - exit 0 - fi - - for preview in "${previews[@]:PREVIEW_RETENTION_LIMIT}"; do - rm -rf "pr-preview/$preview" - removed_prs+=("${preview#pr-}") - done - - if git diff --quiet -- pr-preview; then - echo "removed_prs=" >> "$GITHUB_OUTPUT" - echo "removed_prs_json=[]" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add pr-preview - git commit -m "Prune old PR previews" - git push - - echo "removed_prs=$(IFS=,; echo "${removed_prs[*]}")" >> "$GITHUB_OUTPUT" - echo "removed_prs_json=$(printf '%s\n' "${removed_prs[@]}" | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT" - - - name: Comment PR with Preview URL - if: github.event.action != 'closed' - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: pr-preview - message: | - 🚀 Preview deployment: https://docs.layer5.io/pr-preview/pr-${{ github.event.pull_request.number }}/ - > *Note: Preview may take a moment (GitHub Pages deployment in progress). Please wait and refresh. Track deployment [here](https://github.com/${{ github.repository }}/actions/workflows/pages/pages-build-deployment)* - - - name: Comment on pruned previews - if: github.event.action != 'closed' && steps.prune-previews.outputs.removed_prs_json != '[]' - uses: actions/github-script@v7 - env: - REMOVED_PRS_JSON: ${{ steps.prune-previews.outputs.removed_prs_json }} - PREVIEW_RETENTION_LIMIT: ${{ env.PREVIEW_RETENTION_LIMIT }} - with: - script: | - const removedPrs = JSON.parse(process.env.REMOVED_PRS_JSON); - const retentionLimit = process.env.PREVIEW_RETENTION_LIMIT; - const header = "pr-preview"; - const marker = ``; - - for (const prNumber of removedPrs) { - const body = - `Preview deployment for PR #${prNumber} removed.\n\n` + - `This PR preview was automatically pruned because we keep only the ${retentionLimit} most recently updated previews on GitHub Pages to stay within deployment size limits.\n\n` + - `If needed, push a new commit to this PR to generate a fresh preview.\n` + - `${marker}`; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: Number(prNumber), - per_page: 100, - }); - - const existingComment = [...comments].reverse().find((comment) => - comment.user?.login === "github-actions[bot]" && - comment.body?.includes(marker) - ); - - if (existingComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existingComment.id, - body, - }); - continue; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: Number(prNumber), - body, - }); - } - - - name: Cleanup PR preview on close - if: github.event.action == 'closed' - uses: rossjrw/pr-preview-action@v1.6.3 - with: - preview-branch: gh-pages - umbrella-dir: pr-preview - action: remove diff --git a/.github/workflows/build-docs-preview.yml b/.github/workflows/build-docs-preview.yml new file mode 100644 index 00000000000..7f1da79e662 --- /dev/null +++ b/.github/workflows/build-docs-preview.yml @@ -0,0 +1,92 @@ +name: Build Docs Preview + +on: + pull_request: + branches: [master] + types: [opened, synchronize, reopened] + paths: + - 'content/**' + - '**.md' + - '**.go' + - 'hugo.yaml' + - '.github/workflows/build-docs-preview.yml' + - '.github/workflows/deploy-docs-preview.yml' + +permissions: + contents: read + +concurrency: + group: docs-preview-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + build: + runs-on: ubuntu-24.04 + env: + HUGO_VERSION: 0.158.0 + + steps: + # Hugo Module fetching hits proxy.golang.org and sum.golang.org. Harmless + # under egress-policy: audit, but if this is promoted to block, those two + # plus objects.githubusercontent.com must be in allowed-endpoints or the + # build dies at module resolution. + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout PR + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + submodules: recursive + + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - name: Install dependencies + run: | + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install + fi + + - name: Build PR preview + env: + HUGO_PREVIEW: "true" + BASE_URL: /pr-preview/pr-${{ github.event.pull_request.number }}/ + run: | + npm run build:preview + cat > public/robots.txt <<'ROBOTS' + User-agent: * + Disallow: / + ROBOTS + + - name: Save PR Metadata + run: | + mkdir -p pr + echo "${{ github.event.action }}" > pr/action + echo "${{ github.event.number }}" > pr/number + echo "${{ github.event.pull_request.head.sha }}" > pr/sha + + - name: Upload build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docs-preview-build + path: | + public/ + pr/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/deploy-docs-preview.yml b/.github/workflows/deploy-docs-preview.yml new file mode 100644 index 00000000000..3b88317ea2d --- /dev/null +++ b/.github/workflows/deploy-docs-preview.yml @@ -0,0 +1,245 @@ +name: Docs Preview Post-Build + +on: + workflow_run: + workflows: ["Build Docs Preview"] + types: [completed] + +permissions: + contents: write + pull-requests: write + actions: read + +concurrency: + group: docs-preview-post-build-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +env: + PREVIEW_RETENTION_LIMIT: 6 + +jobs: + deploy-preview: + runs-on: ubuntu-24.04 + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true + + - name: Download Artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: docs-preview-build + path: temp-artifact + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Read Metadata + id: meta + run: | + ACTION=$(head -n1 temp-artifact/pr/action 2>/dev/null | tr -d '[:space:]') + case "$ACTION" in + opened|reopened|synchronize) ;; + *) echo "::error::invalid or missing action: '$ACTION'"; exit 1 ;; + esac + + PR_NUM=$(head -n1 temp-artifact/pr/number 2>/dev/null | tr -d '[:space:]') + case "$PR_NUM" in + ''|*[!0-9]*) echo "::error::invalid or missing PR number: '$PR_NUM'"; exit 1 ;; + esac + + SHA=$(head -n1 temp-artifact/pr/sha 2>/dev/null | tr -d '[:space:]') + case "$SHA" in + ''|*[!a-f0-9]*) echo "::error::invalid or missing commit SHA: '$SHA'"; exit 1 ;; + esac + + echo "action=$ACTION" >> "$GITHUB_OUTPUT" + echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + - name: Deploy PR preview + uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1.8.1 + with: + source-dir: ./temp-artifact/public + preview-branch: gh-pages + umbrella-dir: pr-preview + pr-number: ${{ steps.meta.outputs.pr_number }} + pages-base-url: docs.layer5.io + action: deploy + # Polls the Deployments API so the comment lands on a live preview. + wait-for-pages-deployment: true + comment: false + + - name: Comment PR with Preview URL + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: pr-preview + number: ${{ steps.meta.outputs.pr_number }} + message: | + 🚀 Preview deployment: https://docs.layer5.io/pr-preview/pr-${{ steps.meta.outputs.pr_number }}/ + + prune: + # Both jobs push to gh-pages, but only prune retries a lost race, so deploy + # must land first. + needs: deploy-preview + runs-on: ubuntu-24.04 + outputs: + removed_prs: ${{ steps.prune-previews.outputs.removed_prs }} + removed_prs_json: ${{ steps.prune-previews.outputs.removed_prs_json }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout gh-pages for preview retention + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: gh-pages + fetch-depth: 0 + persist-credentials: true # required: the prune step pushes to gh-pages + sparse-checkout: | + pr-preview + path: gh-pages-maintenance + + - name: Prune old PR previews + id: prune-previews + # Pruning is best-effort housekeeping on the shared gh-pages branch. + # Many preview runs write to gh-pages at once, so it must never be + # the reason a preview is reported as failed: the push is retried against + # the freshest state, and the step is continue-on-error as a backstop. + continue-on-error: true + run: | + cd gh-pages-maintenance + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Write safe defaults first. $GITHUB_OUTPUT is last-write-wins, so even + # if this step is interrupted, the downstream "Comment on pruned + # previews" step sees a valid empty list rather than an empty string. + { + echo "removed_prs=" + echo "removed_prs_json=[]" + } >> "$GITHUB_OUTPUT" + + # A naive push to the shared gh-pages branch loses the race with other + # concurrent preview runs ("! [rejected] (fetch first)"). Re-derive the + # prune from the freshest remote state on every attempt and retry with + # backoff so a lost race self-heals instead of failing the workflow. + removed_prs=() + outcome="" + attempts=5 + + for attempt in $(seq 1 "$attempts"); do + git fetch --quiet origin gh-pages + git reset --quiet --hard FETCH_HEAD + mkdir -p pr-preview + + mapfile -t previews < <( + while IFS= read -r preview; do + timestamp="$(git log -1 --format=%ct -- "pr-preview/$preview" 2>/dev/null || echo 0)" + printf '%s %s\n' "$timestamp" "$preview" + done < <(find pr-preview -mindepth 1 -maxdepth 1 -type d -name 'pr-*' -printf '%f\n') \ + | sort -nr \ + | awk '{print $2}' + ) + + removed_prs=() + if (( ${#previews[@]} > PREVIEW_RETENTION_LIMIT )); then + for preview in "${previews[@]:PREVIEW_RETENTION_LIMIT}"; do + rm -rf "pr-preview/$preview" + removed_prs+=("${preview#pr-}") + done + fi + + # Within the retention limit, or another run already pruned: done. + if git diff --quiet -- pr-preview; then + outcome="noop" + break + fi + + git add pr-preview + git commit --quiet -m "Prune old PR previews" + + if git push origin HEAD:gh-pages; then + outcome="pushed" + break + fi + + echo "Prune push lost the race (attempt ${attempt}/${attempts}); re-syncing gh-pages and retrying..." + sleep "$(( attempt * 5 + RANDOM % 5 ))" + done + + if [ -z "$outcome" ]; then + echo "::warning::Could not prune old PR previews after ${attempts} attempts due to concurrent gh-pages updates; a later run will retry. Not failing the preview." + removed_prs=() + fi + + if [ "${#removed_prs[@]}" -eq 0 ]; then + { + echo "removed_prs=" + echo "removed_prs_json=[]" + } >> "$GITHUB_OUTPUT" + else + { + echo "removed_prs=$(IFS=,; echo "${removed_prs[*]}")" + echo "removed_prs_json=$(printf '%s\n' "${removed_prs[@]}" | jq -R . | jq -sc .)" + } >> "$GITHUB_OUTPUT" + fi + + - name: Comment on pruned previews + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + REMOVED_PRS_JSON: ${{ steps.prune-previews.outputs.removed_prs_json }} + PREVIEW_RETENTION_LIMIT: ${{ env.PREVIEW_RETENTION_LIMIT }} + with: + script: | + const removedPrs = JSON.parse(process.env.REMOVED_PRS_JSON); + const retentionLimit = process.env.PREVIEW_RETENTION_LIMIT; + const header = "pr-preview"; + const marker = ``; + + for (const prNumber of removedPrs) { + const body = + `Preview deployment for PR #${prNumber} removed.\n\n` + + `This PR preview was automatically pruned because we keep only the ${retentionLimit} most recently updated previews on GitHub Pages to stay within deployment size limits.\n\n` + + `If needed, push a new commit to this PR to generate a fresh preview.\n` + + `${marker}`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(prNumber), + per_page: 100, + }); + + const existingComment = [...comments].reverse().find((comment) => + comment.user?.login === "github-actions[bot]" && + comment.body?.includes(marker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body, + }); + continue; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(prNumber), + body, + }); + } \ No newline at end of file