Repository navigation
Repo sync #32995
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: Confirm internal staff meant to post in public | ||
|
Check warning on line 1 in .github/workflows/confirm-internal-staff-work-in-docs.yml
|
||
| # Asks GitHub staff to confirm public issues and PRs belong in github/docs. | ||
| on: | ||
| issues: | ||
| types: | ||
| - opened | ||
| - transferred | ||
| # pull_request_target lets the workflow check org membership for forked PR authors. | ||
| pull_request_target: | ||
| types: | ||
| - opened | ||
| permissions: | ||
| contents: read | ||
| jobs: | ||
| check-team-membership: | ||
| runs-on: ubuntu-latest | ||
| continue-on-error: true | ||
| if: github.repository == 'github/docs' && github.actor != 'docs-bot' | ||
| steps: | ||
| - id: membership_check | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 | ||
| env: | ||
| TEAM_CONTENT_REPO: ${{ secrets.TEAM_CONTENT_REPO }} | ||
| with: | ||
| github-token: ${{ secrets.DOCS_BOT_PAT_BASE }} | ||
| script: | | ||
| // Only GitHub employees need confirmation before public posts stay public. | ||
| try { | ||
| await github.rest.teams.getMembershipForUserInOrg({ | ||
| org: 'github', | ||
| team_slug: 'employees', | ||
| username: context.payload.sender.login, | ||
| }); | ||
| } catch(err) { | ||
| // The employee membership lookup throws for non-employees, so skip confirmation. | ||
| return | ||
| } | ||
| // Docs team members can intentionally work in github/docs. | ||
| try { | ||
| // 9919 is the github org and 325922 the docs team; numeric IDs survive renames. | ||
| await github.request('GET /organizations/{org_id}/team/{team_id}/memberships/{username}', { | ||
| org_id: 9919, | ||
| team_id: 325922, | ||
| username: context.payload.sender.login, | ||
| }); | ||
| return | ||
| } catch(err) { | ||
| // The docs team membership lookup throws for non-members, who need an internal confirmation issue. | ||
| } | ||
| const issueNo = context.number || context.issue.number | ||
| await github.rest.issues.create({ | ||
| owner: 'github', | ||
| repo: process.env.TEAM_CONTENT_REPO, | ||
| title: `@${context.payload.sender.login} confirm that \#${issueNo} should be in the public github/docs repo`, | ||
| body: `@${context.payload.sender.login} opened https://github.com/github/docs/issues/${issueNo} publicly in the github/docs repo, instead of the private github/${process.env.TEAM_CONTENT_REPO} repo.\n\n@${context.payload.sender.login}, if this specifically belongs in the public repo, and no sensitive information is disclosed, please ignore this message. Otherwise, please ${context.payload.pull_request ? 'close the pull request and recreate it internally in https://github.com/github/docs-content.' : 'close the issue and reopen it internally in https://github.com/github/docs-content, or transfer it there.'}\n\nIf sensitive information was shared, please close https://github.com/github/docs/issues/${issueNo} and notify us in the \#docs-open-source channel, so we can delete it. Thanks!`, | ||
| labels: ['OS confirmation', 'skip FR board'], | ||
| }); | ||
| core.setOutput('did_warn', 'true') | ||
| - name: Resolve Slack user ID from username | ||
| id: slack_user | ||
| if: ${{ steps.membership_check.outputs.did_warn == 'true' }} | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 | ||
| env: | ||
| SLACK_BOT_TOKEN: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} | ||
| with: | ||
| github-token: ${{ github.token }} | ||
| script: | | ||
| const githubLogin = context.payload.sender.login.toLowerCase(); | ||
| const lookupStartedAt = Date.now(); | ||
| core.info(`Looking up Slack user for @${githubLogin}...`); | ||
| core.setOutput('slack_user_id', ''); | ||
| core.setOutput('slack_mention', `GitHub user \`${context.payload.sender.login}\``); | ||
| const token = process.env.SLACK_BOT_TOKEN; | ||
| if (!token) { | ||
| throw new Error('SLACK_DOCS_BOT_TOKEN is not configured.'); | ||
| } | ||
| const sleep = (milliseconds) => | ||
| new Promise((resolve) => setTimeout(resolve, milliseconds)); | ||
| const maxRetries = 5; | ||
| const minimumRequestIntervalMs = 3000; | ||
| let lastRequestAt = 0; | ||
| async function fetchUsersPage(url, pageNumber) { | ||
| for (let attempt = 0; attempt <= maxRetries; attempt += 1) { | ||
| const spacingDelay = Math.max( | ||
| 0, | ||
| minimumRequestIntervalMs - (Date.now() - lastRequestAt), | ||
| ); | ||
| if (spacingDelay > 0) { | ||
| await sleep(spacingDelay); | ||
| } | ||
| lastRequestAt = Date.now(); | ||
| const requestStartedAt = lastRequestAt; | ||
| const response = await fetch(url, { | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| }, | ||
| }); | ||
| const requestDurationMs = Date.now() - requestStartedAt; | ||
| if (response.status === 429) { | ||
| if (attempt === maxRetries) { | ||
| throw new Error( | ||
| `Slack users.list page ${pageNumber} remained rate-limited after ${maxRetries} retries.`, | ||
| ); | ||
| } | ||
| const retryAfterSeconds = Number(response.headers.get('retry-after')); | ||
| const retryDelayMs = | ||
| Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 | ||
| ? retryAfterSeconds * 1000 | ||
| : Math.min(1000 * 2 ** attempt, 30000); | ||
| core.warning( | ||
| `Slack users.list page ${pageNumber} was rate-limited (attempt ${attempt + 1}/${maxRetries + 1}); retrying in ${retryDelayMs} ms.`, | ||
| ); | ||
| await sleep(retryDelayMs); | ||
| continue; | ||
| } | ||
| if (!response.ok) { | ||
| throw new Error( | ||
| `Slack users.list page ${pageNumber} returned HTTP ${response.status} after ${requestDurationMs} ms.`, | ||
| ); | ||
| } | ||
| if (attempt > 0) { | ||
| core.info( | ||
| `Slack users.list page ${pageNumber} succeeded after ${attempt} retry attempt(s) in ${requestDurationMs} ms.`, | ||
| ); | ||
| } else { | ||
| core.info( | ||
| `Slack users.list page ${pageNumber} returned HTTP ${response.status} in ${requestDurationMs} ms.`, | ||
| ); | ||
| } | ||
| return response.json(); | ||
| } | ||
| throw new Error(`Slack users.list page ${pageNumber} request did not complete.`); | ||
| } | ||
| let cursor = ''; | ||
| let pages = 0; | ||
| do { | ||
| const pageNumber = pages + 1; | ||
| core.info(`Fetching Slack users.list page ${pageNumber}...`); | ||
| const url = new URL('https://slack.com/api/users.list'); | ||
| url.searchParams.set('limit', '200'); | ||
| if (cursor) { | ||
| url.searchParams.set('cursor', cursor); | ||
| } | ||
| const result = await fetchUsersPage(url, pageNumber); | ||
| if (!result.ok) { | ||
| throw new Error( | ||
| `Slack users.list page ${pageNumber} failed: ${result.error || 'unknown error'}.`, | ||
| ); | ||
| } | ||
| if (!Array.isArray(result.members)) { | ||
| throw new Error( | ||
| `Slack users.list page ${pageNumber} did not include a members array.`, | ||
| ); | ||
| } | ||
| pages += 1; | ||
| core.info(`Slack users.list page ${pages} contained ${result.members.length} member records.`); | ||
| const match = result.members.find((member) => | ||
| !member.deleted && | ||
| !member.is_bot && | ||
| !member.is_app_user && | ||
| typeof member.id === 'string' && | ||
| typeof member.name === 'string' && | ||
| member.name.toLowerCase() === githubLogin | ||
| ); | ||
| if (match) { | ||
| core.info( | ||
| `Found the matching Slack username on page ${pages} after ${Date.now() - lookupStartedAt} ms.`, | ||
| ); | ||
| core.setOutput('slack_user_id', match.id); | ||
| core.setOutput('slack_mention', `<@${match.id}>`); | ||
| return; | ||
| } | ||
| cursor = result.response_metadata?.next_cursor || ''; | ||
| core.info( | ||
| `No matching Slack username on page ${pages}; ${cursor ? 'continuing pagination.' : 'no more pages.'}`, | ||
| ); | ||
| } while (cursor); | ||
| core.warning( | ||
| `No active Slack username matched @${githubLogin} after ${pages} page(s) and ${Date.now() - lookupStartedAt} ms; no DM will be sent.`, | ||
| ); | ||
| - name: DM the author in Slack | ||
| id: slack_dm | ||
| if: ${{ steps.slack_user.outputs.slack_user_id != '' }} | ||
| uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 | ||
| with: | ||
| method: chat.postMessage | ||
| token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} | ||
| errors: true | ||
| payload: | | ||
| channel: ${{ steps.slack_user.outputs.slack_user_id }} | ||
| text: ${{ toJSON(format('You opened {0} publicly in github/docs instead of the private repo. An internal confirmation issue was created.', github.event.issue.html_url || github.event.pull_request.html_url)) }} | ||
| - name: Log Slack DM result | ||
| if: ${{ !cancelled() && steps.membership_check.outputs.did_warn == 'true' }} | ||
| env: | ||
| SLACK_USER_ID: ${{ steps.slack_user.outputs.slack_user_id }} | ||
| SLACK_LOOKUP_OUTCOME: ${{ steps.slack_user.outcome }} | ||
| SLACK_DM_OUTCOME: ${{ steps.slack_dm.outcome }} | ||
| SLACK_DM_OK: ${{ steps.slack_dm.outputs.ok }} | ||
| SLACK_DM_RESPONSE: ${{ steps.slack_dm.outputs.response }} | ||
| SLACK_DM_TS: ${{ steps.slack_dm.outputs.ts }} | ||
| run: | | ||
| echo "Slack user lookup outcome: ${SLACK_LOOKUP_OUTCOME:-not run}" | ||
| if [ -z "$SLACK_USER_ID" ]; then | ||
| echo "Slack DM skipped because no Slack user ID was resolved." | ||
| elif [ "$SLACK_DM_OK" = "true" ]; then | ||
| echo "Slack DM sent successfully (timestamp: ${SLACK_DM_TS:-unavailable})." | ||
| else | ||
| echo "Slack DM did not return success (step outcome: ${SLACK_DM_OUTCOME:-unknown})." | ||
| if [ -n "$SLACK_DM_RESPONSE" ]; then | ||
| printf '%s\n' "$SLACK_DM_RESPONSE" | jq -r 'if type == "object" and (.error | type) == "string" then "Slack API error: " + .error else "Slack API response did not include an error field." end' | ||
| else | ||
| echo "Slack DM API response was unavailable." | ||
| fi | ||
| fi | ||
| - name: Notify the docs-open-source channel | ||
| id: slack_channel_notification | ||
| if: ${{ !cancelled() && steps.membership_check.outputs.did_warn == 'true' }} | ||
| uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 | ||
| env: | ||
| SLACK_CHANNEL_ID: ${{ secrets.DOCS_OPEN_SOURCE_SLACK_CHANNEL_ID }} | ||
| SLACK_MESSAGE: ${{ format('{0} opened {1} publicly in github/docs instead of the private repo. An internal confirmation issue was created.', steps.slack_user.outputs.slack_mention, github.event.issue.html_url || github.event.pull_request.html_url) }} | ||
| with: | ||
| method: chat.postMessage | ||
| token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} | ||
| errors: true | ||
| payload: | | ||
| channel: ${{ toJSON(env.SLACK_CHANNEL_ID) }} | ||
| text: ${{ toJSON(env.SLACK_MESSAGE) }} | ||
| - name: Log Slack channel notification result | ||
| if: ${{ !cancelled() && steps.membership_check.outputs.did_warn == 'true' }} | ||
| env: | ||
| SLACK_CHANNEL_OUTCOME: ${{ steps.slack_channel_notification.outcome }} | ||
| SLACK_CHANNEL_OK: ${{ steps.slack_channel_notification.outputs.ok }} | ||
| SLACK_CHANNEL_RESPONSE: ${{ steps.slack_channel_notification.outputs.response }} | ||
| SLACK_CHANNEL_TS: ${{ steps.slack_channel_notification.outputs.ts }} | ||
| run: | | ||
| if [ "$SLACK_CHANNEL_OK" = "true" ]; then | ||
| echo "Slack channel notification sent successfully (timestamp: ${SLACK_CHANNEL_TS:-unavailable})." | ||
| else | ||
| echo "Slack channel notification did not return success (step outcome: ${SLACK_CHANNEL_OUTCOME:-unknown})." | ||
| if [ -n "$SLACK_CHANNEL_RESPONSE" ]; then | ||
| printf '%s\n' "$SLACK_CHANNEL_RESPONSE" | jq -r 'if type == "object" and (.error | type) == "string" then "Slack API error: " + .error else "Slack API response did not include an error field." end' | ||
| else | ||
| echo "Slack channel API response was unavailable." | ||
| fi | ||
| fi | ||
| - name: Check out repo | ||
| if: ${{ failure() && github.event_name != 'pull_request_target' }} | ||
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | ||
| - uses: ./.github/actions/slack-alert | ||
| if: ${{ failure() && github.event_name != 'pull_request_target' }} | ||
| with: | ||
| slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} | ||