diff --git a/.github/workflows/api-diff.yml b/.github/workflows/api-diff.yml new file mode 100644 index 0000000000..f855380f9e --- /dev/null +++ b/.github/workflows/api-diff.yml @@ -0,0 +1,180 @@ +name: Output API Diff + +on: + issue_comment: + types: [created] + +permissions: {} + +concurrency: + group: api-diff-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + api-diff: + name: Output API Diff + if: >- + github.event.issue.pull_request + && contains(github.event.comment.body, '/api-diff') + && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + steps: + - name: Check maintainer permission + uses: actions/github-script@v7 + with: + script: | + const { data: permLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + const allowed = ['admin', 'maintain', 'write']; + if (!allowed.includes(permLevel.permission)) { + core.setFailed(`User @${context.payload.comment.user.login} does not have write access.`); + } + + - name: Add reaction to acknowledge command + 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: Get PR branch info + id: pr + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Cannot run /api-diff on fork PRs — would execute untrusted code.'); + return; + } + core.setOutput('ref', pr.head.ref); + core.setOutput('sha', pr.head.sha); + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Run OutputApiDiff + run: dotnet run --project ./nukebuild/_build.csproj -- OutputApiDiff + + - name: Post API diff as PR comment + if: always() && steps.pr.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const diffDir = path.join(process.env.GITHUB_WORKSPACE, 'artifacts', 'api-diff', 'markdown'); + const mergedPath = path.join(diffDir, '_diff.md'); + + let body; + if (fs.existsSync(mergedPath)) { + let diff = fs.readFileSync(mergedPath, 'utf8').trim(); + if (!diff || diff.toLowerCase().includes('no changes')) { + body = '### API Diff\n\n✅ No public API changes detected in this PR.'; + } else { + const MAX_COMMENT_LENGTH = 60000; // GitHub comment limit is 65536 + const header = '### API Diff\n\n'; + const footer = '\n\n---\n_Generated by `/api-diff` command._'; + const budget = MAX_COMMENT_LENGTH - header.length - footer.length; + + if (diff.length > budget) { + diff = diff.substring(0, budget) + '\n\n> ⚠️ Output truncated. See the [full workflow run](' + + `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + + ') for complete diff.'; + } + + body = header + diff + footer; + } + } else { + body = '### API Diff\n\n⚠️ No diff output was produced. Check the [workflow run](' + + `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + + ') for details.'; + } + + // Collapse into
if large + if (body.length > 2000) { + const inner = body; + body = '
\n📋 API Diff (click to expand)\n\n' + inner + '\n\n
'; + } + + // Update existing bot comment or create a new one + const marker = ''; + body = marker + '\n' + body; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const existing = comments.find(c => c.body?.includes(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, + }); + } + + - name: Add success reaction + if: success() + 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: 'rocket', + }); + + - name: Report failure + if: failure() + 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: '-1', + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `❌ \`/api-diff\` failed. [See logs](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + }); diff --git a/.github/workflows/update-api.yml b/.github/workflows/update-api.yml new file mode 100644 index 0000000000..611a4ead50 --- /dev/null +++ b/.github/workflows/update-api.yml @@ -0,0 +1,123 @@ +name: Update API Suppressions + +on: + issue_comment: + types: [created] + +permissions: {} + +concurrency: + group: update-api-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + update-api: + name: Update API Suppressions + if: >- + github.event.issue.pull_request + && contains(github.event.comment.body, '/update-api') + && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + steps: + - name: Check maintainer permission + uses: actions/github-script@v7 + with: + script: | + const { data: permLevel } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + const allowed = ['admin', 'maintain', 'write']; + if (!allowed.includes(permLevel.permission)) { + core.setFailed(`User @${context.payload.comment.user.login} does not have write access.`); + } + + - name: Add reaction to acknowledge command + 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: Get PR branch info + id: pr + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Cannot run /update-api on fork PRs — would execute untrusted code with write permissions.'); + return; + } + core.setOutput('ref', pr.head.ref); + core.setOutput('sha', pr.head.sha); + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.sha }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Run ValidateApiDiff + run: dotnet run --project ./nukebuild/_build.csproj -- ValidateApiDiff --update-api-suppression true + + - name: Commit and push changes + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add api/ + if git diff --cached --quiet; then + echo "No API suppression changes to commit." + else + git commit -m "Update API suppressions" + git push origin HEAD:${{ steps.pr.outputs.ref }} + fi + + - name: Add success reaction + if: success() + 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: 'rocket', + }); + + - name: Report failure + if: failure() + 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: '-1', + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `❌ \`/update-api\` failed. [See logs](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + });