From 13d8a16c40ec4cec001597a7f3fff2e253ca6443 Mon Sep 17 00:00:00 2001 From: John McCall Date: Fri, 11 Sep 2026 15:52:23 -0400 Subject: [PATCH 1/5] feat: label issues from non-org-members across all repos Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .../actions/label-external-issues/README.md | 124 ++++++++++++++++++ .../actions/label-external-issues/action.yml | 91 +++++++++++++ .../label-external-issues/src/index.js | 67 ++++++++++ .github/workflows/label-external-issues.yml | 70 ++++++++++ 4 files changed, 352 insertions(+) create mode 100644 .github/actions/label-external-issues/README.md create mode 100644 .github/actions/label-external-issues/action.yml create mode 100644 .github/actions/label-external-issues/src/index.js create mode 100644 .github/workflows/label-external-issues.yml diff --git a/.github/actions/label-external-issues/README.md b/.github/actions/label-external-issues/README.md new file mode 100644 index 0000000..f1f19e4 --- /dev/null +++ b/.github/actions/label-external-issues/README.md @@ -0,0 +1,124 @@ +# 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 + +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 dedicated GitHub App, which needs: + +- Organization permissions: Members read +- Repository permissions: Issues write (on every repo it should label) + +## 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-label-external-issues-secrets-reader + + - uses: aws-actions/aws-secretsmanager-get-secrets@v3 + with: + secret-ids: | + LABEL_EXTERNAL_PEM, omf-github-terraform/label-external-issues/pem + + - uses: ./.github/actions/label-external-issues + with: + clientId: "" # not sensitive + privateKey: ${{ env.LABEL_EXTERNAL_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. + +### One-time setup required + +This action needs a dedicated GitHub App before it can run for real: + +1. Create a GitHub App (or extend an existing internal one) with + **Organization: Members (read)** and **Repository: Issues (write)** + permissions, and install it on every repo that should be covered. +2. Add the app's client ID, OIDC role, and Secrets Manager PEM entry in + `omf-github-terraform`, matching the naming convention this workflow + expects (`LABEL_EXTERNAL_APP_CLIENT_ID`, `gha-label-external-issues-secrets-reader`, + `omf-github-terraform/label-external-issues/pem`). +3. Create the `external` label (or your chosen name) in each target repo, + or its default label templates, so the action can apply it. + +Until then, `label-external-issues.yml`'s placeholder client ID will cause +runs to fail at the token-minting step. + +## 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..e6c645e --- /dev/null +++ b/.github/actions/label-external-issues/action.yml @@ -0,0 +1,91 @@ +--- +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: > + GitHub App client ID used to generate an installation token with + org-level Members read and repository Issues write access. 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] -- dedicated app scoped to 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 lookbackMinutes = parseInt(process.env.LOOKBACK_MINUTES, 10); + if (!Number.isInteger(lookbackMinutes) || lookbackMinutes <= 0) { + core.setFailed(`lookbackMinutes must be a positive integer, got "${process.env.LOOKBACK_MINUTES}"`); + return; + } + 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..ebf9500 --- /dev/null +++ b/.github/actions/label-external-issues/src/index.js @@ -0,0 +1,67 @@ +// 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; + + 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 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 { + await github.rest.orgs.checkMembershipForUser({ org, username }); + return true; + } catch (error) { + if (error.status === 404) { + return false; + } + throw error; + } +} diff --git a/.github/workflows/label-external-issues.yml b/.github/workflows/label-external-issues.yml new file mode 100644 index 0000000..06b19d3 --- /dev/null +++ b/.github/workflows/label-external-issues.yml @@ -0,0 +1,70 @@ +--- +# Labels issues opened by non-org-members across every OvertureMaps repo the +# label-external-issues 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: assumes a narrow OIDC role that can only read the dedicated 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 label-external-issues GitHub App (not sensitive). + LABEL_EXTERNAL_APP_CLIENT_ID: "TODO-set-after-app-creation" + # Narrow OIDC role (omf-github-terraform oidc-aws.tf) that can only + # read the label-external-issues PEM secret. + LABEL_EXTERNAL_OIDC_ROLE_ARN: arn:aws:iam::816069134238:role/gha-label-external-issues-secrets-reader + LABEL_EXTERNAL_PEM_SECRET_ID: omf-github-terraform/label-external-issues/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.LABEL_EXTERNAL_OIDC_ROLE_ARN }} + + # Exports the PEM as env.LABEL_EXTERNAL_PEM, masked (incl. multi-line). + - name: Fetch label-external-issues PEM from Secrets Manager + uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 # v3.0.1 + with: + secret-ids: | + LABEL_EXTERNAL_PEM,${{ env.LABEL_EXTERNAL_PEM_SECRET_ID }} + + - name: Label external issues + uses: $/.github/actions/label-external-issues + with: + clientId: ${{ env.LABEL_EXTERNAL_APP_CLIENT_ID }} + privateKey: ${{ env.LABEL_EXTERNAL_PEM }} + dryRun: ${{ inputs.dry_run || 'false' }} From 5987c3d476b86ca0040dd3c0a8213ad7bca65613 Mon Sep 17 00:00:00 2001 From: John McCall Date: Fri, 11 Sep 2026 15:56:14 -0400 Subject: [PATCH 2/5] refactor: reuse overture-project-manager app for issue labeling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .../actions/label-external-issues/README.md | 34 ++++++++++--------- .../actions/label-external-issues/action.yml | 7 ++-- .github/workflows/label-external-issues.yml | 32 +++++++++-------- 3 files changed, 39 insertions(+), 34 deletions(-) diff --git a/.github/actions/label-external-issues/README.md b/.github/actions/label-external-issues/README.md index f1f19e4..3cb92e1 100644 --- a/.github/actions/label-external-issues/README.md +++ b/.github/actions/label-external-issues/README.md @@ -25,11 +25,16 @@ than the schedule interval so a missed or delayed run doesn't drop issues. 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 dedicated GitHub App, which needs: +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. That app's permission set needs Members (read) +and Issues (write) added. + ## How-to guides ### Run on a schedule (how this repo uses it) @@ -63,17 +68,17 @@ jobs: - uses: aws-actions/configure-aws-credentials@v6 with: aws-region: us-west-2 - role-to-assume: arn:aws:iam::816069134238:role/gha-label-external-issues-secrets-reader + role-to-assume: arn:aws:iam::816069134238:role/gha-project-manager-secrets-reader - uses: aws-actions/aws-secretsmanager-get-secrets@v3 with: secret-ids: | - LABEL_EXTERNAL_PEM, omf-github-terraform/label-external-issues/pem + PROJECT_MANAGER_PEM, omf-github-terraform/project-manager/pem - uses: ./.github/actions/label-external-issues with: - clientId: "" # not sensitive - privateKey: ${{ env.LABEL_EXTERNAL_PEM }} + clientId: "Iv23limfwiJlCIqHPHrd" # overture-project-manager app, not sensitive + privateKey: ${{ env.PROJECT_MANAGER_PEM }} dryRun: ${{ inputs.dry_run || 'false' }} ``` @@ -84,20 +89,17 @@ Trigger the workflow manually with `dry_run` checked, or pass ### One-time setup required -This action needs a dedicated GitHub App before it can run for real: +This action reuses the `overture-project-manager` App, but that app doesn't +have the right permissions yet: -1. Create a GitHub App (or extend an existing internal one) with - **Organization: Members (read)** and **Repository: Issues (write)** - permissions, and install it on every repo that should be covered. -2. Add the app's client ID, OIDC role, and Secrets Manager PEM entry in - `omf-github-terraform`, matching the naming convention this workflow - expects (`LABEL_EXTERNAL_APP_CLIENT_ID`, `gha-label-external-issues-secrets-reader`, - `omf-github-terraform/label-external-issues/pem`). -3. Create the `external` label (or your chosen name) in each target repo, +1. In `omf-github-terraform`, add **Organization: Members (read)** and + **Repository: Issues (write)** to the app's permission set (it's already + installed org-wide, so no new installation is needed). +2. Create the `external` label (or your chosen name) in each target repo, or its default label templates, so the action can apply it. -Until then, `label-external-issues.yml`'s placeholder client ID will cause -runs to fail at the token-minting step. +Until (1) is done, the token mints fine but membership checks and label +writes will 403. ## Reference diff --git a/.github/actions/label-external-issues/action.yml b/.github/actions/label-external-issues/action.yml index e6c645e..24e7c0c 100644 --- a/.github/actions/label-external-issues/action.yml +++ b/.github/actions/label-external-issues/action.yml @@ -23,8 +23,9 @@ inputs: default: "false" clientId: description: > - GitHub App client ID used to generate an installation token with - org-level Members read and repository Issues write access. The + 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 @@ -58,7 +59,7 @@ runs: - 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] -- dedicated app scoped to org-wide issue labeling + 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 }} diff --git a/.github/workflows/label-external-issues.yml b/.github/workflows/label-external-issues.yml index 06b19d3..85cd605 100644 --- a/.github/workflows/label-external-issues.yml +++ b/.github/workflows/label-external-issues.yml @@ -1,14 +1,16 @@ --- # Labels issues opened by non-org-members across every OvertureMaps repo the -# label-external-issues GitHub App is installed on. +# 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: assumes a narrow OIDC role that can only read the dedicated app's PEM -# from Secrets Manager. The role, secret, and app are managed in -# omf-github-terraform. +# Auth: reuses the overture-project-manager app from sync-project-status.yml, +# which needs 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 @@ -38,12 +40,12 @@ jobs: contents: read id-token: write # for OIDC authentication with AWS env: - # Client ID of the org's label-external-issues GitHub App (not sensitive). - LABEL_EXTERNAL_APP_CLIENT_ID: "TODO-set-after-app-creation" + # 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 label-external-issues PEM secret. - LABEL_EXTERNAL_OIDC_ROLE_ARN: arn:aws:iam::816069134238:role/gha-label-external-issues-secrets-reader - LABEL_EXTERNAL_PEM_SECRET_ID: omf-github-terraform/label-external-issues/pem + # 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: @@ -53,18 +55,18 @@ jobs: - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 with: aws-region: us-west-2 - role-to-assume: ${{ env.LABEL_EXTERNAL_OIDC_ROLE_ARN }} + role-to-assume: ${{ env.PROJECT_MANAGER_OIDC_ROLE_ARN }} - # Exports the PEM as env.LABEL_EXTERNAL_PEM, masked (incl. multi-line). - - name: Fetch label-external-issues PEM from Secrets Manager + # 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: | - LABEL_EXTERNAL_PEM,${{ env.LABEL_EXTERNAL_PEM_SECRET_ID }} + PROJECT_MANAGER_PEM,${{ env.PROJECT_MANAGER_PEM_SECRET_ID }} - name: Label external issues uses: $/.github/actions/label-external-issues with: - clientId: ${{ env.LABEL_EXTERNAL_APP_CLIENT_ID }} - privateKey: ${{ env.LABEL_EXTERNAL_PEM }} + clientId: ${{ env.PROJECT_MANAGER_APP_CLIENT_ID }} + privateKey: ${{ env.PROJECT_MANAGER_PEM }} dryRun: ${{ inputs.dry_run || 'false' }} From 6772cd24b9b4339676df28c6d278ba1bfab62e35 Mon Sep 17 00:00:00 2001 From: John McCall Date: Fri, 11 Sep 2026 15:59:37 -0400 Subject: [PATCH 3/5] docs: app permissions are now in place Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .../actions/label-external-issues/README.md | 19 ++++++------------- .github/workflows/label-external-issues.yml | 8 ++++---- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/actions/label-external-issues/README.md b/.github/actions/label-external-issues/README.md index 3cb92e1..b92bd4d 100644 --- a/.github/actions/label-external-issues/README.md +++ b/.github/actions/label-external-issues/README.md @@ -32,8 +32,8 @@ token from a GitHub App, which needs: 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. That app's permission set needs Members (read) -and Issues (write) added. +already installed org-wide with Members (read) and Issues (write) added to +its permission set. ## How-to guides @@ -89,17 +89,10 @@ Trigger the workflow manually with `dry_run` checked, or pass ### One-time setup required -This action reuses the `overture-project-manager` App, but that app doesn't -have the right permissions yet: - -1. In `omf-github-terraform`, add **Organization: Members (read)** and - **Repository: Issues (write)** to the app's permission set (it's already - installed org-wide, so no new installation is needed). -2. Create the `external` label (or your chosen name) in each target repo, - or its default label templates, so the action can apply it. - -Until (1) is done, the token mints fine but membership checks and label -writes will 403. +`overture-project-manager` already has **Organization: Members (read)** and +**Repository: Issues (write)** added to its permission set. The only +remaining step: create the `external` label (or your chosen name) in each +target repo, or its default label templates, so the action can apply it. ## Reference diff --git a/.github/workflows/label-external-issues.yml b/.github/workflows/label-external-issues.yml index 85cd605..1f47dad 100644 --- a/.github/workflows/label-external-issues.yml +++ b/.github/workflows/label-external-issues.yml @@ -7,10 +7,10 @@ # lives in .github/actions/label-external-issues; see its README for details. # # Auth: reuses the overture-project-manager app from sync-project-status.yml, -# which needs 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. +# 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 From f0c12f8a14604cf8df6071850e5ae1766ae3d2b1 Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 16 Sep 2026 13:52:13 -0400 Subject: [PATCH 4/5] fix: create missing label and address review feedback Fixes three review comments: the local action reference used `$/.` instead of `./`, which would fail the workflow before labeling ran; lookbackMinutes used a bare parseInt so "180minutes" passed validation; and checkMembership only treated 404 as non-member, so a 302 (pending invitation) would have been misread as a member. Also has the action create the target label itself the first time it needs to apply it, instead of requiring a manual setup step per repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .../actions/label-external-issues/README.md | 11 +++-- .../actions/label-external-issues/action.yml | 7 +-- .../label-external-issues/src/index.js | 46 +++++++++++++++++-- .github/workflows/label-external-issues.yml | 2 +- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/.github/actions/label-external-issues/README.md b/.github/actions/label-external-issues/README.md index b92bd4d..fbc3b80 100644 --- a/.github/actions/label-external-issues/README.md +++ b/.github/actions/label-external-issues/README.md @@ -16,7 +16,8 @@ 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 +- 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. @@ -87,12 +88,12 @@ jobs: Trigger the workflow manually with `dry_run` checked, or pass `dryRun: "true"` to the action. Intended changes are logged but not applied. -### One-time setup required +### Setup `overture-project-manager` already has **Organization: Members (read)** and -**Repository: Issues (write)** added to its permission set. The only -remaining step: create the `external` label (or your chosen name) in each -target repo, or its default label templates, so the action can apply it. +**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 diff --git a/.github/actions/label-external-issues/action.yml b/.github/actions/label-external-issues/action.yml index 24e7c0c..2bd3ff7 100644 --- a/.github/actions/label-external-issues/action.yml +++ b/.github/actions/label-external-issues/action.yml @@ -76,11 +76,12 @@ runs: script: | const path = require('path'); const run = require(path.join(process.env.GITHUB_ACTION_PATH, 'src', 'index.js')); - const lookbackMinutes = parseInt(process.env.LOOKBACK_MINUTES, 10); - if (!Number.isInteger(lookbackMinutes) || lookbackMinutes <= 0) { - core.setFailed(`lookbackMinutes must be a positive integer, got "${process.env.LOOKBACK_MINUTES}"`); + 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, diff --git a/.github/actions/label-external-issues/src/index.js b/.github/actions/label-external-issues/src/index.js index ebf9500..d389406 100644 --- a/.github/actions/label-external-issues/src/index.js +++ b/.github/actions/label-external-issues/src/index.js @@ -16,6 +16,7 @@ module.exports = async function run({ github, core, org, label, sinceIso, dryRun 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); @@ -41,6 +42,7 @@ module.exports = async function run({ github, core, org, label, sinceIso, dryRun 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++; @@ -56,12 +58,50 @@ module.exports = async function run({ github, core, org, label, sinceIso, dryRun // only visible with an org-scoped read permission; the caller's app must have it. async function checkMembership(github, org, username) { try { - await github.rest.orgs.checkMembershipForUser({ org, username }); - return true; + 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) { + 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 index 1f47dad..0a560cf 100644 --- a/.github/workflows/label-external-issues.yml +++ b/.github/workflows/label-external-issues.yml @@ -65,7 +65,7 @@ jobs: PROJECT_MANAGER_PEM,${{ env.PROJECT_MANAGER_PEM_SECRET_ID }} - name: Label external issues - uses: $/.github/actions/label-external-issues + uses: ./.github/actions/label-external-issues with: clientId: ${{ env.PROJECT_MANAGER_APP_CLIENT_ID }} privateKey: ${{ env.PROJECT_MANAGER_PEM }} From 9070efe941f211d6b7dc9c1bb2186602a7e34e7a Mon Sep 17 00:00:00 2001 From: John McCall Date: Mon, 21 Sep 2026 15:49:36 -0400 Subject: [PATCH 5/5] Update label-external-issues.yml Signed-off-by: John McCall --- .github/workflows/label-external-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/label-external-issues.yml b/.github/workflows/label-external-issues.yml index 0a560cf..1f47dad 100644 --- a/.github/workflows/label-external-issues.yml +++ b/.github/workflows/label-external-issues.yml @@ -65,7 +65,7 @@ jobs: PROJECT_MANAGER_PEM,${{ env.PROJECT_MANAGER_PEM_SECRET_ID }} - name: Label external issues - uses: ./.github/actions/label-external-issues + uses: $/.github/actions/label-external-issues with: clientId: ${{ env.PROJECT_MANAGER_APP_CLIENT_ID }} privateKey: ${{ env.PROJECT_MANAGER_PEM }}