diff --git a/.github/workflows/comment-on-pr.yml b/.github/workflows/comment-on-pr.yml new file mode 100644 index 0000000..c85ef28 --- /dev/null +++ b/.github/workflows/comment-on-pr.yml @@ -0,0 +1,80 @@ +name: Comment on plugin PR + +# Posts the validation result produced by "Validate plugin submission" back to +# the PR. That workflow runs in the PR (fork) context with a read-only token +# and therefore can't comment itself. This workflow runs via workflow_run in +# the base-repo context, where the token can write to issues/PRs. It only reads +# an artifact — it never checks out or executes PR code — so it is safe to grant +# write permissions even though the triggering run was for untrusted fork code. + +on: + workflow_run: + workflows: ["Validate plugin submission"] + types: + - completed + +permissions: + contents: read + actions: read + issues: write + pull-requests: write + +jobs: + comment: + runs-on: ubuntu-latest + # Only act on runs triggered by pull requests. + if: github.event.workflow_run.event == 'pull_request' + steps: + - name: Download comment artifact + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: pr-comment + path: pr-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post or update comment + if: steps.download.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const body = fs.readFileSync('pr-comment/body.md', 'utf8'); + const issue_number = parseInt( + fs.readFileSync('pr-comment/pr.txt', 'utf8').trim(), + 10, + ); + if (!Number.isInteger(issue_number)) { + core.setFailed('No valid PR number in artifact.'); + return; + } + + const { owner, repo } = context.repo; + + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + }); + + const marker = '## Plugin Validation'; + const existing = comments.find((c) => c.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index 7735e71..c1d8f19 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -5,8 +5,12 @@ on: paths: - 'plugins/**' +# This workflow runs in the PR's (fork) context, so it gets a read-only token +# and must not need write access. The validation result comment is posted by +# the separate comment-on-pr.yml workflow, which runs in the base-repo context +# with write permissions. See that file for why the split is necessary. permissions: - pull-requests: write + contents: read jobs: validate: @@ -225,18 +229,35 @@ jobs: exit 1 fi - - name: Comment on PR + # We can't post the result comment from here: on fork PRs the + # GITHUB_TOKEN is read-only, so any issues/PR write returns 403. Instead + # we render the comment body to a file and hand it off as an artifact to + # comment-on-pr.yml, which runs via workflow_run with a writable token. + - name: Render PR comment + id: render if: always() && steps.changes.outputs.has_changes == 'true' uses: actions/github-script@v7 + env: + DUP_OUTCOME: ${{ steps.duplicates.outcome }} + VAL_OUTCOME: ${{ steps.validation.outcome }} + BUILD_OUTCOME: ${{ steps.build.outcome }} + DUP_RESULT: ${{ steps.duplicates.outputs.result }} + VAL_RESULT: ${{ steps.validation.outputs.result }} + BUILD_RESULT: ${{ steps.build.outputs.result }} with: script: | - const dup = `${{ steps.duplicates.outcome }}`; - const val = `${{ steps.validation.outcome }}`; - const build = `${{ steps.build.outcome }}`; + const fs = require('fs'); - const dupResult = `${{ steps.duplicates.outputs.result }}`; - const valResult = `${{ steps.validation.outputs.result }}`; - const buildResult = `${{ steps.build.outputs.result }}`; + const dup = process.env.DUP_OUTCOME; + const val = process.env.VAL_OUTCOME; + const build = process.env.BUILD_OUTCOME; + + // The shell steps emit literal "\n" sequences; turn them into real + // newlines so the error list renders as Markdown bullets. + const fixup = (s) => (s || '').replace(/\\n/g, '\n'); + const dupResult = fixup(process.env.DUP_RESULT); + const valResult = fixup(process.env.VAL_RESULT); + const buildResult = fixup(process.env.BUILD_RESULT); const allPassed = dup === 'success' && val === 'success' && build === 'success'; const icon = (s) => s === 'success' ? ':white_check_mark:' : s === 'skipped' ? ':next_track_button:' : ':x:'; @@ -259,28 +280,14 @@ jobs: body += `**HTML:**\n\`\`\`html\n\n AgentHub\n\n\`\`\`\n`; } - // Find and update existing bot comment, or create new one - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const marker = '## Plugin Validation'; - const existing = comments.find(c => c.body.startsWith(marker)); - - 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: context.issue.number, - body, - }); - } + fs.mkdirSync('pr-comment', { recursive: true }); + fs.writeFileSync('pr-comment/body.md', body); + fs.writeFileSync('pr-comment/pr.txt', String(context.issue.number)); + + - name: Upload PR comment artifact + if: always() && steps.changes.outputs.has_changes == 'true' + uses: actions/upload-artifact@v4 + with: + name: pr-comment + path: pr-comment/ + retention-days: 1