diff --git a/.github/workflows/validate-content-comment.yml b/.github/workflows/validate-content-comment.yml new file mode 100644 index 00000000..495a5545 --- /dev/null +++ b/.github/workflows/validate-content-comment.yml @@ -0,0 +1,85 @@ +name: Post content validation comment + +# Runs after "Validate content" completes and posts the result as a PR comment. +# +# The pull_request trigger gives a read-only GITHUB_TOKEN for fork PRs — no +# permissions block can override that. workflow_run always runs in the base +# repo's context with a writable token, so it can post comments even when the +# originating PR came from a fork. The validation result is passed via artifact. + +on: + workflow_run: + workflows: ["Validate content"] + types: [completed] + +jobs: + comment: + runs-on: ubuntu-latest + # Only post comments for PR-triggered runs, not manual workflow_dispatch runs + if: github.event.workflow_run.event == 'pull_request' + permissions: + pull-requests: write + issues: write + actions: read # required to download artifacts from another workflow run + + steps: + - name: Download PR info artifact + id: download + uses: actions/download-artifact@v4 + continue-on-error: true # gracefully skip if artifact is missing (e.g. cancelled run) + with: + name: pr-info + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Post or update PR comment + if: steps.download.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const fs = require('fs'); + + const prNumber = parseInt(fs.readFileSync('pr-number.txt', 'utf8').trim(), 10); + const branch = fs.readFileSync('branch.txt', 'utf8').trim(); + const outcome = fs.readFileSync('outcome.txt', 'utf8').trim(); + + // No content files changed in this PR — nothing to comment on + if (outcome === 'skip') return; + + let output = ''; + try { output = fs.readFileSync('output.txt', 'utf8').trim(); } catch {} + + // Detect if this PR was auto-generated from an issue + const issueMatch = branch.match(/^publish\/issue-(\d+)$/); + const linkedIssue = issueMatch ? issueMatch[1] : null; + + const fixNote = linkedIssue + ? `\n\nThis PR was auto-generated from issue #${linkedIssue}. [Edit the issue](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${linkedIssue}) to fix the errors above — removing and re-adding the \`approved\` label will regenerate the PR automatically.` + : `\n\nFix the errors above and push again — validation will re-run automatically.`; + + const successBody = `${marker}\n✅ **Content validation passed!**`; + const failureBody = `${marker}\n## ❌ Content Validation Failed\n\n\`\`\`\n${output}\n\`\`\`${fixNote}`; + const body = outcome === 'failure' ? failureBody : successBody; + + const { data: issueComments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = issueComments.find(c => c.body?.includes(marker)); + + if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + }); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); diff --git a/.github/workflows/validate-content.yml b/.github/workflows/validate-content.yml index 3be1bf7a..23575f0d 100644 --- a/.github/workflows/validate-content.yml +++ b/.github/workflows/validate-content.yml @@ -7,8 +7,12 @@ on: jobs: validate: runs-on: ubuntu-latest + # Read-only is intentional — write permissions are handled by the + # validate-content-comment workflow via workflow_run, so that fork PRs + # (which always get a read-only token on pull_request) can still get + # validation comments posted on them. permissions: - pull-requests: write + contents: read steps: - uses: actions/checkout@v4 @@ -20,12 +24,6 @@ jobs: with: files: 'src/content/**/*.md' - - name: Skip if no content files changed - if: github.event_name == 'pull_request' && steps.changed.outputs.any_changed != 'true' - run: | - echo "No content files changed — skipping validation." - exit 0 - - uses: actions/setup-node@v4 if: github.event_name == 'workflow_dispatch' || steps.changed.outputs.any_changed == 'true' with: @@ -47,52 +45,30 @@ jobs: if: github.event_name == 'workflow_dispatch' run: npx tsx scripts/validate-content.ts - - name: Post or update PR comment - if: github.event_name == 'pull_request' && steps.changed.outputs.any_changed == 'true' - uses: actions/github-script@v7 + # Save PR metadata as an artifact so validate-content-comment can read it. + # Must run before "Fail if validation failed" so the artifact is always uploaded + # even when validation fails. + - name: Save PR metadata for comment workflow + if: github.event_name == 'pull_request' + run: | + mkdir -p /tmp/pr-info + echo "${{ github.event.pull_request.number }}" > /tmp/pr-info/pr-number.txt + echo "${{ github.event.pull_request.head.ref }}" > /tmp/pr-info/branch.txt + if [ "${{ steps.changed.outputs.any_changed }}" != "true" ]; then + echo "skip" > /tmp/pr-info/outcome.txt + echo "" > /tmp/pr-info/output.txt + else + echo "${{ steps.validate.outcome }}" > /tmp/pr-info/outcome.txt + cp /tmp/validate-output.txt /tmp/pr-info/output.txt 2>/dev/null || echo "" > /tmp/pr-info/output.txt + fi + + - name: Upload PR metadata artifact + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 with: - script: | - const marker = ''; - const outcome = '${{ steps.validate.outcome }}'; - const fs = require('fs'); - - let output = ''; - try { output = fs.readFileSync('/tmp/validate-output.txt', 'utf8').trim(); } catch {} - - // Detect if this PR was auto-generated from an issue - const branchName = context.payload.pull_request?.head?.ref || ''; - const issueMatch = branchName.match(/^publish\/issue-(\d+)$/); - const linkedIssue = issueMatch ? issueMatch[1] : null; - - const fixNote = linkedIssue - ? `\n\nThis PR was auto-generated from issue #${linkedIssue}. [Edit the issue](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${linkedIssue}) to fix the errors above — removing and re-adding the \`approved\` label will regenerate the PR automatically.` - : `\n\nFix the errors above and push again — validation will re-run automatically.`; - - const successBody = `${marker}\n✅ **Content validation passed!**`; - const failureBody = `${marker}\n## ❌ Content Validation Failed\n\n\`\`\`\n${output}\n\`\`\`${fixNote}`; - const body = outcome === 'failure' ? failureBody : successBody; - - const { data: issueComments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = issueComments.find(c => c.body?.includes(marker)); - - if (existing) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - }); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); + name: pr-info + path: /tmp/pr-info/ + retention-days: 1 - name: Fail if validation failed if: steps.validate.outcome == 'failure'