diff --git a/.github/actions/label-external-issues/README.md b/.github/actions/label-external-issues/README.md new file mode 100644 index 0000000..fbc3b80 --- /dev/null +++ b/.github/actions/label-external-issues/README.md @@ -0,0 +1,120 @@ +# Label External Issues + +A composite GitHub Action that labels org-wide issues opened by users who +aren't members of the org. + +## Explanation + +### What it does + +There's no way to add a workflow to every repo in the org at once, and a +per-repo `issues: opened` trigger wouldn't cover repos this action isn't +added to. So this runs as a scheduled batch job instead: on each run it +searches the org for issues that are open, unlabeled with the configured +label, and created within the lookback window, then for each one: + +- Skips it if the author is a bot (bots are never org members, so they'd + otherwise always be mislabeled as external) +- Checks org membership for the author +- Adds the label if the author isn't a member; otherwise skips it, creating + the label in that repo first if it doesn't already exist + +The lookback window (default 180 minutes) should stay comfortably larger +than the schedule interval so a missed or delayed run doesn't drop issues. + +### Why a GitHub App + +The default `GITHUB_TOKEN` is scoped to a single repo and can't read org +membership or write labels in other repos. This action mints an installation +token from a GitHub App, which needs: + +- Organization permissions: Members read +- Repository permissions: Issues write (on every repo it should label) + +This repo reuses the `overture-project-manager` App from +`sync-project-status.yml` rather than standing up a new one, since it's +already installed org-wide with Members (read) and Issues (write) added to +its permission set. + +## How-to guides + +### Run on a schedule (how this repo uses it) + +See [`label-external-issues.yml`](../../workflows/label-external-issues.yml): +checkout this repo, assume a narrow OIDC role, fetch the app PEM from AWS +Secrets Manager, then reference the action locally. The role, secret, and +GitHub App are managed in `omf-github-terraform`. + +```yaml +on: + schedule: + - cron: "*/30 * * * *" + workflow_dispatch: + inputs: + dry_run: + type: boolean + default: false + +jobs: + label: + runs-on: ubuntu-slim + permissions: + contents: read + id-token: write # for OIDC authentication with AWS + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: us-west-2 + role-to-assume: arn:aws:iam::816069134238:role/gha-project-manager-secrets-reader + + - uses: aws-actions/aws-secretsmanager-get-secrets@v3 + with: + secret-ids: | + PROJECT_MANAGER_PEM, omf-github-terraform/project-manager/pem + + - uses: ./.github/actions/label-external-issues + with: + clientId: "Iv23limfwiJlCIqHPHrd" # overture-project-manager app, not sensitive + privateKey: ${{ env.PROJECT_MANAGER_PEM }} + dryRun: ${{ inputs.dry_run || 'false' }} +``` + +### Dry run + +Trigger the workflow manually with `dry_run` checked, or pass +`dryRun: "true"` to the action. Intended changes are logged but not applied. + +### Setup + +`overture-project-manager` already has **Organization: Members (read)** and +**Repository: Issues (write)** added to its permission set, so no per-repo +setup is required. The action creates the `external` label (or your chosen +name) in a target repo the first time it needs to apply it there. + +## Reference + +### Inputs + +- `clientId` (**required**): Client ID of the GitHub App used to mint the + installation token. +- `privateKey` (**required**): Private key for the app. Must come from an + already-masked source (a GitHub Actions secret, or + `aws-actions/aws-secretsmanager-get-secrets` as this repo does): the + action re-masks the value line-by-line as defense in depth, but it cannot + mask the value's handling before it arrives as an input. Include the full + PEM block with a trailing newline. +- `label` (optional): Label to apply to issues from non-members. Defaults + to `external`. +- `lookbackMinutes` (optional): How far back to search for newly opened + issues, in minutes. Defaults to `180`. +- `dryRun` (optional): `"true"` to log intended changes without applying + them. Defaults to `"false"`. + +### Outputs + +This action has no outputs. Results are logged, with a summary notice at the +end. diff --git a/.github/actions/label-external-issues/action.yml b/.github/actions/label-external-issues/action.yml new file mode 100644 index 0000000..2bd3ff7 --- /dev/null +++ b/.github/actions/label-external-issues/action.yml @@ -0,0 +1,93 @@ +--- +name: Label External Issues +description: > + Labels org-wide issues opened by non-org-members. Runs on a schedule so it + covers every repo the app is installed on, without adding a workflow to + each repo individually. + +inputs: + label: + description: Label to apply to issues opened by non-org-members + required: false + default: "external" + lookbackMinutes: + description: > + How far back (in minutes) to search for newly opened issues. Should be + comfortably larger than the schedule interval to tolerate missed/late + runs. + required: false + default: "180" + dryRun: + description: Log intended changes without applying them + required: false + default: "false" + clientId: + description: > + Client ID of a GitHub App installation token with org-level Members + read and repository Issues write access (this repo reuses + overture-project-manager, extended with those two permissions). The + default GITHUB_TOKEN cannot read org membership or write issues in + other repos. + required: true + privateKey: + description: > + GitHub App private key. Must come from an already-masked source: a + GitHub Actions secret or aws-actions/aws-secretsmanager-get-secrets + (both register masks at fetch time). This action re-masks the value + as defense in depth, but that cannot cover the value's handling + before it reaches this action. + required: true + +runs: + using: composite + steps: + - name: Mask private key + shell: bash + run: | + echo "::group::Masking private key" + # Defense in depth only: the PEM already crossed a step boundary as + # an input, so the caller's source must register its own masks + # (GitHub secrets and aws-secretsmanager-get-secrets both do). + # Line-by-line because a single ::add-mask:: on a multi-line value + # only registers the first line. + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done <<< "$INPUTS_PRIVATEKEY" + echo "::endgroup::" + env: + INPUTS_PRIVATEKEY: ${{ inputs.privateKey }} + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 # zizmor: ignore[github-app,ref-version-mismatch] -- shared org-management app, permissions extended for org-wide issue labeling + with: + client-id: ${{ inputs.clientId }} + private-key: ${{ inputs.privateKey }} + owner: ${{ github.repository_owner }} # zizmor: ignore[github-app] -- org-wide issue access is the point + + - name: Label external issues + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + LABEL: ${{ inputs.label }} + LOOKBACK_MINUTES: ${{ inputs.lookbackMinutes }} + DRY_RUN: ${{ inputs.dryRun }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const path = require('path'); + const run = require(path.join(process.env.GITHUB_ACTION_PATH, 'src', 'index.js')); + const rawLookbackMinutes = process.env.LOOKBACK_MINUTES; + if (!/^[1-9]\d*$/.test(rawLookbackMinutes)) { + core.setFailed(`lookbackMinutes must be a positive integer, got "${rawLookbackMinutes}"`); + return; + } + const lookbackMinutes = Number(rawLookbackMinutes); + const sinceIso = new Date(Date.now() - lookbackMinutes * 60_000).toISOString(); + await run({ + github, + core, + org: context.repo.owner, + label: process.env.LABEL, + sinceIso, + dryRun: process.env.DRY_RUN === 'true', + }); diff --git a/.github/actions/label-external-issues/src/index.js b/.github/actions/label-external-issues/src/index.js new file mode 100644 index 0000000..d389406 --- /dev/null +++ b/.github/actions/label-external-issues/src/index.js @@ -0,0 +1,107 @@ +// Entry point: find recently-opened org issues from non-members and label them. +'use strict'; + +// Matches "https://api.github.com/repos/{owner}/{repo}" from a search result's repository_url. +const REPO_URL_RE = /^https:\/\/api\.github\.com\/repos\/([^/]+)\/([^/]+)$/; + +module.exports = async function run({ github, core, org, label, sinceIso, dryRun }) { + const query = `org:${org} is:issue is:open -label:"${label}" created:>=${sinceIso}`; + const issues = await github.paginate(github.rest.search.issuesAndPullRequests, { + q: query, + per_page: 100, + }); + + core.info(`Found ${issues.length} open, unlabeled issue(s) created since ${sinceIso}.`); + + let labeled = 0; + let skippedMembers = 0; + let skippedBots = 0; + const ensuredLabelRepos = new Set(); // `${owner}/${repo}` already checked/created this run + + for (const issue of issues) { + const match = issue.repository_url.match(REPO_URL_RE); + if (!match) { + core.warning(`Couldn't parse owner/repo from ${issue.repository_url}, skipping #${issue.number}.`); + continue; + } + const [, owner, repo] = match; + const author = issue.user?.login; + const ref = `${owner}/${repo}#${issue.number}`; + + if (!author || issue.user.type === 'Bot') { + skippedBots++; + continue; + } + + const isMember = await checkMembership(github, org, author); + if (isMember) { + skippedMembers++; + continue; + } + + const action = dryRun ? 'would label' : 'labeling'; + core.info(`${ref}: ${action} as "${label}" (author "${author}" is not an org member).`); + if (!dryRun) { + await ensureLabelExists(github, core, owner, repo, label, ensuredLabelRepos); + await github.rest.issues.addLabels({ owner, repo, issue_number: issue.number, labels: [label] }); + } + labeled++; + } + + core.notice( + `Done: ${labeled} ${dryRun ? 'would be ' : ''}labeled, ` + + `${skippedMembers} skipped (org members), ${skippedBots} skipped (bots).` + ); +}; + +// Returns true if `username` is a member of `org`, false if not. Membership is +// only visible with an org-scoped read permission; the caller's app must have it. +async function checkMembership(github, org, username) { + try { + const response = await github.rest.orgs.checkMembershipForUser({ org, username }); + // 204 is the only "is a member" response; treat anything else (e.g. a + // 302 for a pending invitation) as not-yet-a-member rather than member. + return response.status === 204; + } catch (error) { + if (error.status === 404 || error.status === 302) { + return false; + } + throw error; + } +} + +// Creates `label` in `owner/repo` if it doesn't already exist, so a fresh +// target repo doesn't need manual label setup before this action can run. +// Checked at most once per repo per run via `ensuredLabelRepos`. +async function ensureLabelExists(github, core, owner, repo, label, ensuredLabelRepos) { + const key = `${owner}/${repo}`; + if (ensuredLabelRepos.has(key)) { + return; + } + ensuredLabelRepos.add(key); + + try { + await github.rest.issues.getLabel({ owner, repo, name: label }); + return; + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: label, + color: 'ededed', + description: 'Opened by a user who is not a member of the organization', + }); + core.info(`${owner}/${repo}: created missing "${label}" label.`); + } catch (error) { + // Another concurrent run may have created it between the check and here. + if (error.status !== 422) { + throw error; + } + } +} diff --git a/.github/workflows/label-external-issues.yml b/.github/workflows/label-external-issues.yml new file mode 100644 index 0000000..0a560cf --- /dev/null +++ b/.github/workflows/label-external-issues.yml @@ -0,0 +1,72 @@ +--- +# Labels issues opened by non-org-members across every OvertureMaps repo the +# overture-project-manager GitHub App is installed on. +# +# Runs as a scheduled batch job (no per-repo `issues: opened` trigger needed) +# using the search API, so adding it here covers all repos at once. Logic +# lives in .github/actions/label-external-issues; see its README for details. +# +# Auth: reuses the overture-project-manager app from sync-project-status.yml, +# with Organization Members (read) and Repository Issues (write) added to +# its permission set. Assumes the same narrow OIDC role that can only read +# the app's PEM from Secrets Manager. The role, secret, and app are managed +# in omf-github-terraform. +# +name: Label External Issues + +on: + schedule: + - cron: "*/30 * * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Log intended changes without applying them" + required: false + type: boolean + default: false + +permissions: + contents: read # to check out the labeling action + +concurrency: + group: label-external-issues + cancel-in-progress: false + +jobs: + label: + name: Label issues from non-members + runs-on: ubuntu-slim + permissions: + contents: read + id-token: write # for OIDC authentication with AWS + env: + # Client ID of the org's overture-project-manager GitHub App (not sensitive). + PROJECT_MANAGER_APP_CLIENT_ID: "Iv23limfwiJlCIqHPHrd" + # Narrow OIDC role (omf-github-terraform oidc-aws.tf) that can only + # read the project-manager PEM secret. + PROJECT_MANAGER_OIDC_ROLE_ARN: arn:aws:iam::816069134238:role/gha-project-manager-secrets-reader + PROJECT_MANAGER_PEM_SECRET_ID: omf-github-terraform/project-manager/pem + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: .github/actions/label-external-issues + + - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + aws-region: us-west-2 + role-to-assume: ${{ env.PROJECT_MANAGER_OIDC_ROLE_ARN }} + + # Exports the PEM as env.PROJECT_MANAGER_PEM, masked (incl. multi-line). + - name: Fetch project-manager PEM from Secrets Manager + uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 # v3.0.1 + with: + secret-ids: | + PROJECT_MANAGER_PEM,${{ env.PROJECT_MANAGER_PEM_SECRET_ID }} + + - name: Label external issues + uses: ./.github/actions/label-external-issues + with: + clientId: ${{ env.PROJECT_MANAGER_APP_CLIENT_ID }} + privateKey: ${{ env.PROJECT_MANAGER_PEM }} + dryRun: ${{ inputs.dry_run || 'false' }}