Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .github/actions/label-external-issues/README.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 93 additions & 0 deletions .github/actions/label-external-issues/action.yml
Original file line number Diff line number Diff line change
@@ -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',
});
107 changes: 107 additions & 0 deletions .github/actions/label-external-issues/src/index.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
72 changes: 72 additions & 0 deletions .github/workflows/label-external-issues.yml
Original file line number Diff line number Diff line change
@@ -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' }}
Loading