diff --git a/.github/workflows/build-frontend.yml b/.github/workflows/build-frontend.yml index b8170100..317cfc85 100644 --- a/.github/workflows/build-frontend.yml +++ b/.github/workflows/build-frontend.yml @@ -2,6 +2,12 @@ name: Build Frontend on: workflow_call: + inputs: + ref: + description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.' + type: string + required: false + default: '' jobs: build-frontend: @@ -22,7 +28,7 @@ jobs: timeout-minutes: 1 with: repository: ${{ github.repository }} - ref: ${{ github.ref_name }} + ref: ${{ inputs.ref || github.ref_name }} token: ${{ env.REPO_READONLY_PAT }} - name: Setup Node.js diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml index 69d3a513..61eea5c0 100644 --- a/.github/workflows/build-windows-installer.yml +++ b/.github/workflows/build-windows-installer.yml @@ -8,6 +8,11 @@ on: type: string required: false default: '' + ref: + description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.' + type: string + required: false + default: '' jobs: build-windows-installer: @@ -58,7 +63,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ env.githubRepository }} - ref: ${{ github.ref_name }} + ref: ${{ inputs.ref || github.ref_name }} token: ${{ env.REPO_READONLY_PAT }} - name: Download frontend artifact diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 00000000..6b2c7272 --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,181 @@ +name: PR Build (Comment Triggered) + +on: + issue_comment: + types: [created] + +concurrency: + group: pr-build-${{ github.event.issue.number }} + cancel-in-progress: true + +permissions: + issues: write + pull-requests: read + actions: read + +jobs: + validate: + runs-on: ubuntu-latest + if: github.event.issue.pull_request != null + outputs: + build_windows: ${{ steps.parse.outputs.build_windows }} + pr_ref: ${{ steps.parse.outputs.pr_ref }} + pr_sha: ${{ steps.parse.outputs.pr_sha }} + pr_number: ${{ steps.parse.outputs.pr_number }} + + steps: + - name: React to comment + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes' + }); + + - name: Parse command and check permissions + id: parse + uses: actions/github-script@v7 + with: + script: | + const comment = context.payload.comment.body.trim(); + + // Parse supported commands + const commands = { + '/build-windows': 'build_windows' + }; + + const command = commands[comment]; + if (!command) { + console.log(`Comment "${comment}" is not a recognized build command, skipping.`); + core.setOutput('build_windows', 'false'); + return; + } + + // Fetch PR details + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + // Verify PR is open + if (pr.data.state !== 'open') { + console.log('PR is not open, skipping.'); + core.setOutput('build_windows', 'false'); + return; + } + + // Block fork PRs — fork code should not run with access to secrets + const isFork = pr.data.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo; + if (isFork) { + console.log(`PR is from fork ${pr.data.head.repo.full_name}, blocking build.`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'On-demand builds are not available for PRs from forks.' + }); + core.setOutput('build_windows', 'false'); + return; + } + + // Verify commenter has write access + let permission = 'none'; + try { + const resp = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login + }); + permission = resp.data.permission; + } catch (_) {} + + if (!['admin', 'write'].includes(permission)) { + console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — insufficient.`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `@${context.payload.comment.user.login} Only collaborators with write access can trigger builds.` + }); + core.setOutput('build_windows', 'false'); + return; + } + + console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — proceeding with ${command}.`); + core.setOutput(command, 'true'); + + // Export PR details for downstream jobs + core.setOutput('pr_ref', pr.data.head.ref); + core.setOutput('pr_sha', pr.data.head.sha); + core.setOutput('pr_number', String(pr.data.number)); + + build-frontend: + needs: validate + if: needs.validate.outputs.build_windows == 'true' + uses: ./.github/workflows/build-frontend.yml + with: + ref: ${{ needs.validate.outputs.pr_ref }} + secrets: inherit + + build-windows: + needs: [validate, build-frontend] + if: needs.validate.outputs.build_windows == 'true' + uses: ./.github/workflows/build-windows-installer.yml + with: + ref: ${{ needs.validate.outputs.pr_ref }} + secrets: inherit + + post-result: + needs: [validate, build-windows] + if: always() && needs.validate.outputs.build_windows == 'true' + runs-on: ubuntu-latest + + steps: + - name: Post result comment + uses: actions/github-script@v7 + with: + script: | + const buildResult = '${{ needs.build-windows.result }}'; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const prRef = '${{ needs.validate.outputs.pr_ref }}'; + const prSha = '${{ needs.validate.outputs.pr_sha }}'; + const shortSha = prSha.substring(0, 7); + + // Skip comment for skipped builds + if (buildResult === 'skipped') { + console.log('Build was skipped, no comment needed.'); + return; + } + + let body; + if (buildResult === 'success') { + body = [ + `Windows installer build **succeeded** for \`${prRef}\` (\`${shortSha}\`).`, + ``, + `**Download:** open the [workflow run](${runUrl}), scroll to the **Artifacts** section at the bottom.`, + `The artifact \`Cleanuparr-windows-installer\` is retained for 30 days.` + ].join('\n'); + } else if (buildResult === 'cancelled') { + body = [ + `Windows installer build was **cancelled** for \`${prRef}\` (\`${shortSha}\`).`, + ``, + `See the [workflow run](${runUrl}) for details.` + ].join('\n'); + } else { + body = [ + `Windows installer build **failed** for \`${prRef}\` (\`${shortSha}\`).`, + ``, + `See the [workflow run](${runUrl}) for details.` + ].join('\n'); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parseInt('${{ needs.validate.outputs.pr_number }}'), + body + });