.NET SDK: Reflect updated PubSub message handler for streaming subscriptions #315
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto-approve & merge SDK docs PRs (per directory/team) | |
| # Run on PRs (including forks) but act with repo-level permissions. | |
| # We DO NOT check out PR code; we only read PR metadata via the API. | |
| on: | |
| pull_request_target: | |
| types: [opened, synchronize, reopened, ready_for_review, edited, labeled, unlabeled] | |
| paths: | |
| - 'sdkdocs/**' | |
| # Token scopes needed: label, review, and merge for repo actions. | |
| permissions: | |
| contents: write # required to merge | |
| pull-requests: write # required to approve/merge | |
| issues: write # required to create/add labels | |
| jobs: | |
| sdk-docs-automerge: | |
| runs-on: ubuntu-latest | |
| # Ignore drafts | |
| if: ${{ github.event.pull_request.draft == false }} | |
| steps: | |
| # 1) Check team membership with an org-scoped token | |
| - name: Check SDK team membership | |
| id: teamcheck | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.ORG_READ_MEMBERS_TOKEN }} # PAT read:org or fine-grained Members: Read | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const pr = context.payload.pull_request; | |
| const number = pr.number; | |
| // --- Mapping: directory prefixes -> team slug + label --- | |
| const MAPPINGS = [ | |
| { label: 'automerge: dotnet', teamSlug: 'maintainers-dotnet-sdk', prefixes: ['sdkdocs/dotnet/'] }, | |
| { label: 'automerge: go', teamSlug: 'maintainers-go-sdk', prefixes: ['sdkdocs/go/'] }, | |
| { label: 'automerge: java', teamSlug: 'maintainers-java-sdk', prefixes: ['sdkdocs/java/content/en/'] }, | |
| { label: 'automerge: js', teamSlug: 'maintainers-js-sdk', prefixes: ['sdkdocs/js/'] }, | |
| { label: 'automerge: php', teamSlug: 'maintainers-php-sdk', prefixes: ['sdkdocs/php/'] }, | |
| { label: 'automerge: python', teamSlug: 'maintainers-python-sdk', prefixes: ['sdkdocs/python/'] }, | |
| { label: 'automerge: rust', teamSlug: 'maintainers-rust-sdk', prefixes: ['sdkdocs/rust/content/en/'] }, | |
| ]; | |
| const action = context.payload.action; | |
| // Helper: verify a user is an active member of a team | |
| async function checkMembership(teamSlug, username) { | |
| const membership = await github.rest.teams.getMembershipForUserInOrg({ | |
| org: owner, team_slug: teamSlug, username | |
| }); | |
| return membership.data.state === 'active'; | |
| } | |
| // Helper: verify all PR files fall within a single mapping's prefixes | |
| async function resolveMapping(number) { | |
| const files = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number: number, per_page: 100 } | |
| ); | |
| if (files.length === 0) return null; | |
| let mapping = null; | |
| for (const f of files) { | |
| const matched = MAPPINGS.find(m => m.prefixes.some(p => f.filename.startsWith(p))); | |
| if (!matched) return null; | |
| if (!mapping) mapping = matched; | |
| else if (mapping !== matched) return null; | |
| } | |
| return mapping; | |
| } | |
| if (action === 'labeled') { | |
| // --- Maintainer-approved flow --- | |
| // A maintainer adds an automerge label to a PR they didn't necessarily author. | |
| // Validate: label is a known automerge label, adder is in that team, PR only | |
| // touches that SDK's directory. | |
| const addedLabel = context.payload.label.name; | |
| const mapping = MAPPINGS.find(m => m.label === addedLabel); | |
| if (!mapping) { | |
| core.info(`Label "${addedLabel}" is not a recognized automerge label; skipping.`); | |
| core.setOutput('eligible', 'false'); | |
| return; | |
| } | |
| const labelAdder = context.payload.sender.login; | |
| try { | |
| const active = await checkMembership(mapping.teamSlug, labelAdder); | |
| if (!active) { | |
| core.info(`User ${labelAdder} is not active in team ${mapping.teamSlug}; removing label.`); | |
| core.setOutput('eligible', 'false'); | |
| core.setOutput('remove_label', addedLabel); | |
| return; | |
| } | |
| } catch (err) { | |
| core.info(`Membership check failed for ${labelAdder} in team ${mapping.teamSlug}: ${err.status} ${err.message}`); | |
| core.setOutput('eligible', 'false'); | |
| core.setOutput('remove_label', addedLabel); | |
| return; | |
| } | |
| const resolvedMapping = await resolveMapping(number); | |
| if (!resolvedMapping || resolvedMapping !== mapping) { | |
| core.info('PR touches files outside the labeled SDK directory or multiple directories; removing label.'); | |
| core.setOutput('eligible', 'false'); | |
| core.setOutput('remove_label', addedLabel); | |
| return; | |
| } | |
| core.info(`Maintainer ${labelAdder} approved merge via label for ${mapping.label}.`); | |
| core.setOutput('eligible', 'true'); | |
| core.setOutput('label', mapping.label); | |
| core.setOutput('teamSlug', mapping.teamSlug); | |
| core.setOutput('lang', (mapping.label.split(': ')[1] || 'sdk')); | |
| } else { | |
| // --- Author-is-maintainer flow --- | |
| // PR author must themselves be a maintainer of the single SDK directory the PR touches. | |
| const username = pr.user.login; | |
| const currentMapping = await resolveMapping(number); | |
| if (!currentMapping) { | |
| core.info('PR is not eligible: no changed files, outside mapped paths, or touches multiple SDK directories.'); | |
| core.setOutput('eligible', 'false'); | |
| return; | |
| } | |
| try { | |
| const active = await checkMembership(currentMapping.teamSlug, username); | |
| if (!active) { | |
| core.info(`User ${username} is not active in team ${currentMapping.teamSlug}.`); | |
| core.setOutput('eligible', 'false'); | |
| return; | |
| } | |
| } catch (err) { | |
| core.info(`Membership check failed or user not in team ${currentMapping.teamSlug}: ${err.status} ${err.message}`); | |
| core.setOutput('eligible', 'false'); | |
| return; | |
| } | |
| core.setOutput('eligible', 'true'); | |
| core.setOutput('label', currentMapping.label); | |
| core.setOutput('teamSlug', currentMapping.teamSlug); | |
| core.setOutput('lang', (currentMapping.label.split(': ')[1] || 'sdk')); | |
| } | |
| # 2) Remove unauthorized automerge labels (uses default repo-scoped token) | |
| - name: Remove unauthorized automerge label | |
| if: steps.teamcheck.outputs.remove_label != '' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const number = context.payload.pull_request.number; | |
| const labelName = '${{ steps.teamcheck.outputs.remove_label }}'; | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: labelName }); | |
| core.info(`Removed unauthorized label "${labelName}" from PR #${number}.`); | |
| } catch (e) { | |
| if (e.status !== 404) throw e; // 404 = already removed, ignore | |
| } | |
| # 3) If eligible, label, approve and merge with the default repo-scoped token | |
| - name: Label, auto-approve & merge | |
| if: steps.teamcheck.outputs.eligible == 'true' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const number = context.payload.pull_request.number; | |
| const labelName = '${{ steps.teamcheck.outputs.label }}'; | |
| const lang = '${{ steps.teamcheck.outputs.lang }}'; | |
| const mergeMethod = process.env.MERGE_METHOD || 'squash'; | |
| // Ensure label exists then add it | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name: labelName }); | |
| } catch (e) { | |
| if (e.status === 404) { | |
| await github.rest.issues.createLabel({ | |
| owner, repo, name: labelName, color: '6A9286', | |
| description: 'Auto-merged language-specific SDK docs' | |
| }); | |
| } else { | |
| throw e; | |
| } | |
| } | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [labelName] }); | |
| // Auto-approve | |
| try { | |
| await github.rest.pulls.createReview({ | |
| owner, repo, pull_number: number, | |
| event: 'APPROVE', | |
| body: `Auto-approval: ${lang} SDK docs` | |
| }); | |
| } catch (e) { | |
| core.warning(`Failed to create review: ${e.message}`); | |
| } | |
| // Block if "do-not-merge" label is present | |
| const currentLabels = context.payload.pull_request.labels.map(l => l.name.toLowerCase()); | |
| if (currentLabels.includes('do-not-merge')) { | |
| core.info('PR has "do-not-merge" label; skipping merge until it is removed.'); | |
| return; | |
| } | |
| // Poll mergeability | |
| const wait = ms => new Promise(r => setTimeout(r, ms)); | |
| for (let i = 0; i < 12; i++) { | |
| const pr = await github.rest.pulls.get({ owner, repo, pull_number: number }); | |
| if (pr.data.mergeable && ['clean','unstable'].includes(pr.data.mergeable_state)) break; | |
| await wait(5000); | |
| } | |
| // Merge | |
| await github.rest.pulls.merge({ | |
| owner, repo, pull_number: number, | |
| merge_method: mergeMethod, | |
| commit_title: `${lang}: ${context.payload.pull_request.title}`, | |
| commit_message: `Auto-merged by SDK maintainer merge bot (${lang})` | |
| }); |