From 42a9d6b607c20514bd8fc7f140380627ce240dd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:40:22 +0000 Subject: [PATCH 1/2] Initial plan From 68d97cd43a32f5a5b99805f031c495b939b324d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:48:16 +0000 Subject: [PATCH 2/2] fix(ci): use workflow_run pattern to fix deploy-preview permissions on fork PRs GitHub restricts GITHUB_TOKEN to read-only for pull_request_target workflows triggered by a fork PR that modifies any workflow file (even with an explicit `contents: write` declaration). PR #40 modifies deploy.yml, causing the 403 Permission denied when peaceiris/actions-gh-pages tries to push to gh-pages. Fix: split into two workflows following the GitHub-recommended workflow_run pattern: - pr-preview.yml: build-only (contents: read), uploads dist as artifact - pr-deploy.yml: triggered by workflow_run, always gets full write access from the base repository's token regardless of fork restrictions --- .github/workflows/pr-deploy.yml | 152 +++++++++++++++++++++++++++++++ .github/workflows/pr-preview.yml | 86 ++++------------- 2 files changed, 170 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/pr-deploy.yml diff --git a/.github/workflows/pr-deploy.yml b/.github/workflows/pr-deploy.yml new file mode 100644 index 0000000..6748dad --- /dev/null +++ b/.github/workflows/pr-deploy.yml @@ -0,0 +1,152 @@ +name: PR Preview Deploy + +# This workflow deploys preview builds and cleans up closed PRs. +# It is triggered by the "PR Preview" workflow_run so that it always runs +# in the context of the base repository and receives a GITHUB_TOKEN with +# write access — even when the originating PR is from a fork that modifies +# workflow files (which would otherwise restrict the token to read-only). +on: + workflow_run: + workflows: ["PR Preview"] + types: [completed] + +permissions: + contents: write + pull-requests: write + actions: read + +jobs: + deploy-preview: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - name: Download preview artifact + id: download + uses: actions/download-artifact@v4 + with: + pattern: pr-preview-* + path: ./artifacts + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + - name: Find PR number and dist directory + id: pr + if: steps.download.outcome == 'success' + run: | + # Each workflow run processes exactly one PR, so at most one + # pr-preview-* artifact exists per run. + PREVIEW_DIR=$(find ./artifacts -maxdepth 1 -type d -name 'pr-preview-*' | head -1) + if [ -n "$PREVIEW_DIR" ]; then + PR_NUMBER=$(basename "$PREVIEW_DIR" | sed 's/pr-preview-//') + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "dist=$PREVIEW_DIR" >> "$GITHUB_OUTPUT" + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Deploy preview to gh-pages + if: steps.pr.outputs.found == 'true' + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ${{ steps.pr.outputs.dist }} + publish_branch: gh-pages + destination_dir: pr-previews/pr-${{ steps.pr.outputs.number }} + keep_files: true + + - name: Post or update preview URL comment + if: steps.pr.outputs.found == 'true' + uses: actions/github-script@v7 + with: + script: | + const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); + if (!prNumber || isNaN(prNumber)) { + core.setFailed('Could not determine PR number from artifact name'); + return; + } + const previewUrl = `https://${{ github.repository_owner }}.github.io/ReproInventory/pr-previews/pr-${prNumber}/`; + const body = [ + '## 🔍 PR Preview', + '', + `**Preview URL:** ${previewUrl}`, + '', + `_Last updated: ${new Date().toUTCString()}_`, + ].join('\n'); + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + + const existing = comments.data.find( + c => c.user.type === 'Bot' && c.body.includes('## 🔍 PR Preview') + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + + cleanup-preview: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - name: Download close signal artifact + id: download + uses: actions/download-artifact@v4 + with: + pattern: pr-closed-* + path: ./artifacts + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + - name: Find closed PR number + id: pr + if: steps.download.outcome == 'success' + run: | + # Each workflow run processes exactly one PR, so at most one + # pr-closed-* artifact exists per run. + CLOSED_DIR=$(find ./artifacts -maxdepth 1 -type d -name 'pr-closed-*' | head -1) + if [ -n "$CLOSED_DIR" ]; then + PR_NUMBER=$(basename "$CLOSED_DIR" | sed 's/pr-closed-//') + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout gh-pages branch + if: steps.pr.outputs.found == 'true' + uses: actions/checkout@v4 + with: + ref: gh-pages + + - name: Remove preview directory + if: steps.pr.outputs.found == 'true' + run: | + PR_DIR="pr-previews/pr-${{ steps.pr.outputs.number }}" + if [ -d "$PR_DIR" ]; then + rm -rf "$PR_DIR" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: remove PR preview for #${{ steps.pr.outputs.number }}" + git push + else + echo "No preview directory found, nothing to clean up." + fi diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index ea299b5..500b538 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -4,12 +4,13 @@ on: pull_request_target: types: [opened, synchronize, reopened, closed] +# Read-only token is sufficient here; deploy/cleanup happen in pr-deploy.yml +# via workflow_run which is not subject to the fork-workflow-file restriction. permissions: - contents: write - pull-requests: write + contents: read jobs: - deploy-preview: + build-preview: if: github.event.action != 'closed' runs-on: ubuntu-latest steps: @@ -31,74 +32,23 @@ jobs: working-directory: ./frontend run: npm run build -- --base=/ReproInventory/pr-previews/pr-${{ github.event.pull_request.number }}/ - - name: Deploy preview - uses: peaceiris/actions-gh-pages@v3 + - name: Upload preview artifact + uses: actions/upload-artifact@v4 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./frontend/dist - publish_branch: gh-pages - destination_dir: pr-previews/pr-${{ github.event.pull_request.number }} - keep_files: true + name: pr-preview-${{ github.event.pull_request.number }} + path: frontend/dist/ + retention-days: 1 - - name: Post preview URL comment - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.payload.pull_request.number; - const previewUrl = `https://${{ github.repository_owner }}.github.io/ReproInventory/pr-previews/pr-${prNumber}/`; - const body = [ - '## 🔍 PR Preview', - '', - `**Preview URL:** ${previewUrl}`, - '', - `_Last updated: ${new Date().toUTCString()}_`, - ].join('\n'); - - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const existing = comments.data.find( - c => c.user.type === 'Bot' && c.body.includes('## 🔍 PR Preview') - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body, - }); - } - - cleanup-preview: + record-close: if: github.event.action == 'closed' runs-on: ubuntu-latest steps: - - name: Checkout gh-pages branch - uses: actions/checkout@v4 - with: - ref: gh-pages + - name: Create close signal + run: echo "closed" > close-signal.txt - - name: Remove preview directory - run: | - PR_DIR="pr-previews/pr-${{ github.event.pull_request.number }}" - if [ -d "$PR_DIR" ]; then - rm -rf "$PR_DIR" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "chore: remove PR preview for #${{ github.event.pull_request.number }}" - git push - else - echo "No preview directory found, nothing to clean up." - fi + - name: Upload close signal + uses: actions/upload-artifact@v4 + with: + name: pr-closed-${{ github.event.pull_request.number }} + path: close-signal.txt + retention-days: 1