From ac8bd9878c2a4f2370d76a808beb7f95b90ede9f Mon Sep 17 00:00:00 2001 From: John McCall Date: Mon, 3 Aug 2026 14:36:23 -0400 Subject: [PATCH 01/11] [FEATURE] Add scheduled project status sync workflow Syncs the Status field for issues in multiple org projects (Overture #84, places-surge #78) every 3 hours. Most recently updated status wins. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/workflows/sync-project-status.yml | 184 ++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 .github/workflows/sync-project-status.yml diff --git a/.github/workflows/sync-project-status.yml b/.github/workflows/sync-project-status.yml new file mode 100644 index 0000000..4c61787 --- /dev/null +++ b/.github/workflows/sync-project-status.yml @@ -0,0 +1,184 @@ +--- +# Keeps the Status field in sync for issues that belong to more than one +# Overture ProjectV2 (currently Overture #84 and places-surge #78). +# +# ProjectsV2 has no workflow trigger for field edits, so this runs as a +# scheduled batch job: for each issue present in multiple projects with +# mismatched statuses, the most recently updated Status wins and is copied +# to the other projects. Option names are matched case-insensitively +# ("In Progress" vs "In progress"). +# +# Auth: the default GITHUB_TOKEN cannot read or write org-level projects, +# so this uses a dedicated GitHub App with: +# - Organization permissions: Projects read & write +# - Repository permissions: Issues read, Pull requests read +# Configure vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID and +# secrets.PROJECT_STATUS_SYNC_APP_PEM in this repo. +# +name: Sync Project Status + +on: + schedule: + - cron: "17 */3 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Log intended changes without applying them" + required: false + type: boolean + default: false + +permissions: + contents: none # operates purely on org projects via app token + +concurrency: + group: sync-project-status + cancel-in-progress: false + +jobs: + sync: + name: Sync statuses + runs-on: ubuntu-slim + permissions: + contents: none + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 # zizmor: ignore[github-app] -- dedicated app scoped to org project sync + with: + client-id: ${{ vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID }} + private-key: ${{ secrets.PROJECT_STATUS_SYNC_APP_PEM }} # zizmor: ignore[secrets-outside-env] + owner: ${{ github.repository_owner }} # zizmor: ignore[github-app] -- org-level project access is the point + + - name: Sync project statuses + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PROJECT_NUMBERS: "84,78" # Overture, places-surge + DRY_RUN: ${{ inputs.dry_run || 'false' }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const org = context.repo.owner; + const projectNumbers = process.env.PROJECT_NUMBERS.split(',').map(Number); + const dryRun = process.env.DRY_RUN === 'true'; + + // Fetch a project's Status field and all non-archived items with + // their current Status value and when it was last set. + async function fetchProject(number) { + const project = { number, items: [] }; + let cursor = null; + do { + const result = await github.graphql(` + query($org: String!, $number: Int!, $cursor: String) { + organization(login: $org) { + projectV2(number: $number) { + id + title + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isArchived + content { + ... on Issue { id number repository { nameWithOwner } } + ... on PullRequest { id number repository { nameWithOwner } } + } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + updatedAt + } + } + } + } + } + } + }`, { org, number, cursor }); + + const p = result.organization.projectV2; + project.id = p.id; + project.title = p.title; + project.statusField = p.field; + project.items.push(...p.items.nodes.filter(n => !n.isArchived && n.content?.id)); + cursor = p.items.pageInfo.hasNextPage ? p.items.pageInfo.endCursor : null; + } while (cursor); + return project; + } + + const projects = await Promise.all(projectNumbers.map(fetchProject)); + for (const p of projects) { + if (!p.statusField?.options?.length) { + core.setFailed(`Project "${p.title}" (#${p.number}) has no Status single-select field.`); + return; + } + core.info(`Project "${p.title}" (#${p.number}): ${p.items.length} items`); + } + + // Group items by issue/PR node ID across projects. + const byContent = new Map(); + for (const project of projects) { + for (const item of project.items) { + const entry = byContent.get(item.content.id) ?? { content: item.content, items: [] }; + entry.items.push({ project, item }); + byContent.set(item.content.id, entry); + } + } + + let synced = 0, conflicts = 0, skipped = 0; + for (const { content, items } of byContent.values()) { + if (items.length < 2) continue; + + // Most recently updated status wins. + const source = items + .filter(e => e.item.fieldValueByName?.updatedAt) + .sort((a, b) => new Date(b.item.fieldValueByName.updatedAt) - new Date(a.item.fieldValueByName.updatedAt))[0]; + if (!source) continue; // no project has a status set + + const sourceStatus = source.item.fieldValueByName.name; + const ref = `${content.repository.nameWithOwner}#${content.number}`; + + for (const target of items) { + if (target === source) continue; + const current = target.item.fieldValueByName?.name; + if (current?.toLowerCase() === sourceStatus.toLowerCase()) continue; + + conflicts++; + const option = target.project.statusField.options + .find(o => o.name.toLowerCase() === sourceStatus.toLowerCase()); + if (!option) { + skipped++; + core.warning(`${ref}: project "${target.project.title}" has no Status option matching "${sourceStatus}", skipping.`); + continue; + } + + const action = dryRun ? 'would set' : 'setting'; + core.info(`${ref}: ${action} "${target.project.title}" status ` + + `"${current ?? '(unset)'}" -> "${option.name}" (source: "${source.project.title}")`); + if (!dryRun) { + await github.graphql(` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { clientMutationId } + }`, { + projectId: target.project.id, + itemId: target.item.id, + fieldId: target.project.statusField.id, + optionId: option.id, + }); + } + synced++; + } + } + + core.notice(`Done: ${conflicts} mismatches found, ${synced} ${dryRun ? 'would be ' : ''}synced, ${skipped} skipped (no matching option).`); From 09d1aaad4786f7f41bacb0fd1eef8aa9375ef48b Mon Sep 17 00:00:00 2001 From: John McCall Date: Mon, 3 Aug 2026 16:17:28 -0400 Subject: [PATCH 02/11] [REFACTOR] Move sync logic into composite action with discrete JS modules Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/README.md | 95 ++++++++++ .../actions/sync-project-status/action.yml | 67 ++++++++ .../actions/sync-project-status/src/index.js | 43 +++++ .../actions/sync-project-status/src/plan.js | 80 +++++++++ .../sync-project-status/src/projects.js | 76 ++++++++ .github/workflows/sync-project-status.yml | 162 ++---------------- 6 files changed, 374 insertions(+), 149 deletions(-) create mode 100644 .github/actions/sync-project-status/README.md create mode 100644 .github/actions/sync-project-status/action.yml create mode 100644 .github/actions/sync-project-status/src/index.js create mode 100644 .github/actions/sync-project-status/src/plan.js create mode 100644 .github/actions/sync-project-status/src/projects.js diff --git a/.github/actions/sync-project-status/README.md b/.github/actions/sync-project-status/README.md new file mode 100644 index 0000000..1a5cc93 --- /dev/null +++ b/.github/actions/sync-project-status/README.md @@ -0,0 +1,95 @@ +# Sync Project Status + +A composite GitHub Action that keeps the `Status` field in sync for issues +belonging to more than one org-level ProjectV2. + +## Explanation + +### What it does + +ProjectsV2 has no workflow trigger for field edits, so this runs as a +scheduled batch job. For each issue (or PR) that is an item in two or more of +the configured projects: + +- If statuses differ, the most recently updated `Status` wins (based on the + field value's `updatedAt`) and is copied to the other projects +- Status option names are matched case-insensitively, so `In Progress` and + `In progress` count as already in sync +- A status set in only one project propagates to projects where it's unset +- Archived items, items in only one project, and items with no status + anywhere are ignored +- A mismatch whose status name has no matching option in the target project + is logged as a warning and skipped + +The GraphQL/IO code lives in `src/projects.js`; the planning logic in +`src/plan.js` is pure and side-effect free. + +### Why a GitHub App + +The default `GITHUB_TOKEN` cannot read or write org-level ProjectsV2. This +action mints an installation token from a dedicated GitHub App, which needs: + +- Organization permissions: Projects read & write +- Repository permissions: Issues read, Pull requests read + +## How-to guides + +### Run on a schedule (how this repo uses it) + +See [`sync-project-status.yml`](../../workflows/sync-project-status.yml): +checkout this repo, then reference the action locally. + +```yaml +on: + schedule: + - cron: "17 */3 * * *" + workflow_dispatch: + inputs: + dry_run: + type: boolean + default: false + +jobs: + sync: + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/sync-project-status + with: + clientId: ${{ vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID }} + privateKey: ${{ secrets.PROJECT_STATUS_SYNC_APP_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. + +## Reference + +### Inputs + +- `clientId` (**required**): Client ID of the GitHub App used to mint the + installation token. +- `privateKey` (**required**): Private key for the app. Pass + `${{ secrets.PROJECT_STATUS_SYNC_APP_PEM }}`. Include the full PEM block + with a trailing newline. +- `projectNumbers` (optional): Comma-separated org project numbers to sync. + Defaults to `84,78` (Overture, places-surge). +- `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. + +### Conflict resolution + +Last write wins: whichever project's `Status` was edited most recently is the +source of truth for that item on each run. Between runs, conflicting edits in +different projects are resolved in favor of the later edit. diff --git a/.github/actions/sync-project-status/action.yml b/.github/actions/sync-project-status/action.yml new file mode 100644 index 0000000..e4a5c99 --- /dev/null +++ b/.github/actions/sync-project-status/action.yml @@ -0,0 +1,67 @@ +--- +name: Sync Project Status +description: > + Keeps the Status field in sync for issues that belong to more than one + org-level ProjectV2. For each issue in multiple projects with mismatched + statuses, the most recently updated Status wins and is copied to the + others. Option names are matched case-insensitively. + +inputs: + projectNumbers: + description: Comma-separated org project numbers to sync + required: false + default: "84,78" # Overture, places-surge + 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 Projects read & write access. The default GITHUB_TOKEN + cannot read or write org ProjectsV2. + required: true + privateKey: + description: > + GitHub App private key. Pass secrets.PROJECT_STATUS_SYNC_APP_PEM. + Cannot be defaulted automatically as GitHub Actions does not allow + secrets as input defaults. + required: true + +runs: + using: composite + steps: + - name: Mask private key + shell: bash + run: | + echo "::group::Masking private key" + echo "::add-mask::${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] -- dedicated app scoped to org project sync + with: + client-id: ${{ inputs.clientId }} + private-key: ${{ inputs.privateKey }} + owner: ${{ github.repository_owner }} # zizmor: ignore[github-app] -- org-level project access is the point + + - name: Sync project statuses + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PROJECT_NUMBERS: ${{ inputs.projectNumbers }} + 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')); + await run({ + github, + core, + org: context.repo.owner, + projectNumbers: process.env.PROJECT_NUMBERS.split(',').map(Number), + dryRun: process.env.DRY_RUN === 'true', + }); diff --git a/.github/actions/sync-project-status/src/index.js b/.github/actions/sync-project-status/src/index.js new file mode 100644 index 0000000..073c021 --- /dev/null +++ b/.github/actions/sync-project-status/src/index.js @@ -0,0 +1,43 @@ +// Entry point: fetch projects, plan the sync, apply (or log, in dry-run). +'use strict'; + +const { fetchProject, applyChange } = require('./projects.js'); +const { buildPlan } = require('./plan.js'); + +module.exports = async function run({ github, core, org, projectNumbers, dryRun }) { + const projects = await Promise.all(projectNumbers.map((n) => fetchProject(github, org, n))); + + for (const p of projects) { + if (!p.statusField?.options?.length) { + core.setFailed(`Project "${p.title}" (#${p.number}) has no Status single-select field.`); + return; + } + core.info(`Project "${p.title}" (#${p.number}): ${p.items.length} items`); + } + + const { changes, skipped } = buildPlan(projects); + + for (const s of skipped) { + core.warning( + `${s.ref}: project "${s.projectTitle}" has no Status option matching "${s.sourceStatus}", skipping.` + ); + } + + for (const change of changes) { + const action = dryRun ? 'would set' : 'setting'; + core.info( + `${change.ref}: ${action} "${change.projectTitle}" status ` + + `"${change.currentStatus ?? '(unset)'}" -> "${change.newStatus}" ` + + `(source: "${change.sourceTitle}")` + ); + if (!dryRun) { + await applyChange(github, change); + } + } + + core.notice( + `Done: ${changes.length + skipped.length} mismatches found, ` + + `${changes.length} ${dryRun ? 'would be ' : ''}synced, ` + + `${skipped.length} skipped (no matching option).` + ); +}; diff --git a/.github/actions/sync-project-status/src/plan.js b/.github/actions/sync-project-status/src/plan.js new file mode 100644 index 0000000..61bf4d3 --- /dev/null +++ b/.github/actions/sync-project-status/src/plan.js @@ -0,0 +1,80 @@ +// Pure planning logic: given fetched projects, compute the status changes +// needed to bring shared items in sync. No IO here, so it's unit-testable. +'use strict'; + +// Groups project items by their issue/PR node ID. +function groupByContent(projects) { + const byContent = new Map(); + for (const project of projects) { + for (const item of project.items) { + const entry = byContent.get(item.content.id) ?? { content: item.content, items: [] }; + entry.items.push({ project, item }); + byContent.set(item.content.id, entry); + } + } + return byContent; +} + +// Picks the sync source for one shared item: the project entry whose Status +// was updated most recently. Returns null when no project has a status set. +function pickSource(entries) { + return ( + entries + .filter((e) => e.item.fieldValueByName?.updatedAt) + .sort( + (a, b) => + new Date(b.item.fieldValueByName.updatedAt) - new Date(a.item.fieldValueByName.updatedAt) + )[0] ?? null + ); +} + +// Computes the changes needed to sync statuses across projects. +// Returns { changes, skipped } where changes are ready to apply and skipped +// are mismatches with no matching Status option in the target project. +function buildPlan(projects) { + const changes = []; + const skipped = []; + + for (const { content, items } of groupByContent(projects).values()) { + if (items.length < 2) continue; + + const source = pickSource(items); + if (!source) continue; + + const sourceStatus = source.item.fieldValueByName.name; + const ref = `${content.repository.nameWithOwner}#${content.number}`; + + for (const target of items) { + if (target === source) continue; + const current = target.item.fieldValueByName?.name; + if (current?.toLowerCase() === sourceStatus.toLowerCase()) continue; + + const option = target.project.statusField.options.find( + (o) => o.name.toLowerCase() === sourceStatus.toLowerCase() + ); + const base = { + ref, + projectTitle: target.project.title, + sourceTitle: source.project.title, + currentStatus: current ?? null, + sourceStatus, + }; + if (!option) { + skipped.push(base); + continue; + } + changes.push({ + ...base, + newStatus: option.name, + projectId: target.project.id, + itemId: target.item.id, + fieldId: target.project.statusField.id, + optionId: option.id, + }); + } + } + + return { changes, skipped }; +} + +module.exports = { buildPlan, groupByContent, pickSource }; diff --git a/.github/actions/sync-project-status/src/projects.js b/.github/actions/sync-project-status/src/projects.js new file mode 100644 index 0000000..45aa690 --- /dev/null +++ b/.github/actions/sync-project-status/src/projects.js @@ -0,0 +1,76 @@ +// GraphQL access for org ProjectsV2: fetching projects/items and applying +// status changes. All IO lives here; planning logic is in plan.js. +'use strict'; + +const PROJECT_QUERY = ` + query($org: String!, $number: Int!, $cursor: String) { + organization(login: $org) { + projectV2(number: $number) { + id + title + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isArchived + content { + ... on Issue { id number repository { nameWithOwner } } + ... on PullRequest { id number repository { nameWithOwner } } + } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + updatedAt + } + } + } + } + } + } + }`; + +const UPDATE_STATUS_MUTATION = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { clientMutationId } + }`; + +// Fetches a project's Status field and all non-archived issue/PR items with +// their current Status value and when it was last set. +async function fetchProject(github, org, number) { + const project = { number, items: [] }; + let cursor = null; + do { + const result = await github.graphql(PROJECT_QUERY, { org, number, cursor }); + const p = result.organization.projectV2; + project.id = p.id; + project.title = p.title; + project.statusField = p.field; + project.items.push(...p.items.nodes.filter((n) => !n.isArchived && n.content?.id)); + cursor = p.items.pageInfo.hasNextPage ? p.items.pageInfo.endCursor : null; + } while (cursor); + return project; +} + +// Applies a single planned status change. +async function applyChange(github, change) { + await github.graphql(UPDATE_STATUS_MUTATION, { + projectId: change.projectId, + itemId: change.itemId, + fieldId: change.fieldId, + optionId: change.optionId, + }); +} + +module.exports = { fetchProject, applyChange }; diff --git a/.github/workflows/sync-project-status.yml b/.github/workflows/sync-project-status.yml index 4c61787..474f7d8 100644 --- a/.github/workflows/sync-project-status.yml +++ b/.github/workflows/sync-project-status.yml @@ -3,17 +3,10 @@ # Overture ProjectV2 (currently Overture #84 and places-surge #78). # # ProjectsV2 has no workflow trigger for field edits, so this runs as a -# scheduled batch job: for each issue present in multiple projects with -# mismatched statuses, the most recently updated Status wins and is copied -# to the other projects. Option names are matched case-insensitively -# ("In Progress" vs "In progress"). -# -# Auth: the default GITHUB_TOKEN cannot read or write org-level projects, -# so this uses a dedicated GitHub App with: -# - Organization permissions: Projects read & write -# - Repository permissions: Issues read, Pull requests read -# Configure vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID and -# secrets.PROJECT_STATUS_SYNC_APP_PEM in this repo. +# scheduled batch job. Sync logic lives in .github/actions/sync-project-status; +# see its README for behavior, conflict resolution, and the GitHub App setup +# required for vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID and +# secrets.PROJECT_STATUS_SYNC_APP_PEM. # name: Sync Project Status @@ -29,7 +22,7 @@ on: default: false permissions: - contents: none # operates purely on org projects via app token + contents: read # to check out the sync action concurrency: group: sync-project-status @@ -40,145 +33,16 @@ jobs: name: Sync statuses runs-on: ubuntu-slim permissions: - contents: none + contents: read steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 # zizmor: ignore[github-app] -- dedicated app scoped to org project sync + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - client-id: ${{ vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID }} - private-key: ${{ secrets.PROJECT_STATUS_SYNC_APP_PEM }} # zizmor: ignore[secrets-outside-env] - owner: ${{ github.repository_owner }} # zizmor: ignore[github-app] -- org-level project access is the point + persist-credentials: false + sparse-checkout: .github/actions/sync-project-status - name: Sync project statuses - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - PROJECT_NUMBERS: "84,78" # Overture, places-surge - DRY_RUN: ${{ inputs.dry_run || 'false' }} + uses: ./.github/actions/sync-project-status with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const org = context.repo.owner; - const projectNumbers = process.env.PROJECT_NUMBERS.split(',').map(Number); - const dryRun = process.env.DRY_RUN === 'true'; - - // Fetch a project's Status field and all non-archived items with - // their current Status value and when it was last set. - async function fetchProject(number) { - const project = { number, items: [] }; - let cursor = null; - do { - const result = await github.graphql(` - query($org: String!, $number: Int!, $cursor: String) { - organization(login: $org) { - projectV2(number: $number) { - id - title - field(name: "Status") { - ... on ProjectV2SingleSelectField { - id - options { id name } - } - } - items(first: 100, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - id - isArchived - content { - ... on Issue { id number repository { nameWithOwner } } - ... on PullRequest { id number repository { nameWithOwner } } - } - fieldValueByName(name: "Status") { - ... on ProjectV2ItemFieldSingleSelectValue { - name - optionId - updatedAt - } - } - } - } - } - } - }`, { org, number, cursor }); - - const p = result.organization.projectV2; - project.id = p.id; - project.title = p.title; - project.statusField = p.field; - project.items.push(...p.items.nodes.filter(n => !n.isArchived && n.content?.id)); - cursor = p.items.pageInfo.hasNextPage ? p.items.pageInfo.endCursor : null; - } while (cursor); - return project; - } - - const projects = await Promise.all(projectNumbers.map(fetchProject)); - for (const p of projects) { - if (!p.statusField?.options?.length) { - core.setFailed(`Project "${p.title}" (#${p.number}) has no Status single-select field.`); - return; - } - core.info(`Project "${p.title}" (#${p.number}): ${p.items.length} items`); - } - - // Group items by issue/PR node ID across projects. - const byContent = new Map(); - for (const project of projects) { - for (const item of project.items) { - const entry = byContent.get(item.content.id) ?? { content: item.content, items: [] }; - entry.items.push({ project, item }); - byContent.set(item.content.id, entry); - } - } - - let synced = 0, conflicts = 0, skipped = 0; - for (const { content, items } of byContent.values()) { - if (items.length < 2) continue; - - // Most recently updated status wins. - const source = items - .filter(e => e.item.fieldValueByName?.updatedAt) - .sort((a, b) => new Date(b.item.fieldValueByName.updatedAt) - new Date(a.item.fieldValueByName.updatedAt))[0]; - if (!source) continue; // no project has a status set - - const sourceStatus = source.item.fieldValueByName.name; - const ref = `${content.repository.nameWithOwner}#${content.number}`; - - for (const target of items) { - if (target === source) continue; - const current = target.item.fieldValueByName?.name; - if (current?.toLowerCase() === sourceStatus.toLowerCase()) continue; - - conflicts++; - const option = target.project.statusField.options - .find(o => o.name.toLowerCase() === sourceStatus.toLowerCase()); - if (!option) { - skipped++; - core.warning(`${ref}: project "${target.project.title}" has no Status option matching "${sourceStatus}", skipping.`); - continue; - } - - const action = dryRun ? 'would set' : 'setting'; - core.info(`${ref}: ${action} "${target.project.title}" status ` + - `"${current ?? '(unset)'}" -> "${option.name}" (source: "${source.project.title}")`); - if (!dryRun) { - await github.graphql(` - mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { singleSelectOptionId: $optionId } - }) { clientMutationId } - }`, { - projectId: target.project.id, - itemId: target.item.id, - fieldId: target.project.statusField.id, - optionId: option.id, - }); - } - synced++; - } - } - - core.notice(`Done: ${conflicts} mismatches found, ${synced} ${dryRun ? 'would be ' : ''}synced, ${skipped} skipped (no matching option).`); + clientId: ${{ vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID }} + privateKey: ${{ secrets.PROJECT_STATUS_SYNC_APP_PEM }} # zizmor: ignore[secrets-outside-env] + dryRun: ${{ inputs.dry_run || 'false' }} From 71667db6baa716a9cc55ac0d1088b328f5dbc9ca Mon Sep 17 00:00:00 2001 From: John McCall Date: Tue, 4 Aug 2026 12:21:40 -0400 Subject: [PATCH 03/11] [BUG] Suppress ref-version-mismatch false positive on app token action Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/sync-project-status/action.yml b/.github/actions/sync-project-status/action.yml index e4a5c99..fd2f758 100644 --- a/.github/actions/sync-project-status/action.yml +++ b/.github/actions/sync-project-status/action.yml @@ -42,7 +42,7 @@ runs: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 # zizmor: ignore[github-app] -- dedicated app scoped to org project sync + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 # zizmor: ignore[github-app,ref-version-mismatch] -- dedicated app scoped to org project sync with: client-id: ${{ inputs.clientId }} private-key: ${{ inputs.privateKey }} From d0324205a2a41fb8e266e521d4295e489ef4eb6e Mon Sep 17 00:00:00 2001 From: John McCall Date: Tue, 4 Aug 2026 13:10:45 -0400 Subject: [PATCH 04/11] [ENHANCEMENT] Fetch app PEM from AWS Secrets Manager via OIDC Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/README.md | 27 ++++++++++++++---- .../actions/sync-project-status/action.yml | 7 +++-- .github/workflows/sync-project-status.yml | 28 +++++++++++++++---- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/.github/actions/sync-project-status/README.md b/.github/actions/sync-project-status/README.md index 1a5cc93..812450f 100644 --- a/.github/actions/sync-project-status/README.md +++ b/.github/actions/sync-project-status/README.md @@ -37,7 +37,8 @@ action mints an installation token from a dedicated GitHub App, which needs: ### Run on a schedule (how this repo uses it) See [`sync-project-status.yml`](../../workflows/sync-project-status.yml): -checkout this repo, then reference the action locally. +checkout this repo, assume an IAM role via OIDC, fetch the app PEM from AWS +Secrets Manager, then reference the action locally. ```yaml on: @@ -51,16 +52,30 @@ on: jobs: sync: - runs-on: ubuntu-slim + runs-on: ubuntu-latest + 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: + role-to-assume: ${{ vars.PROJECT_STATUS_SYNC_OIDC_ROLE_ARN }} + aws-region: us-west-2 + + - uses: aws-actions/aws-secretsmanager-get-secrets@v3 + with: + secret-ids: | + PROJECT_STATUS_SYNC_APP_PEM,${{ vars.PROJECT_STATUS_SYNC_PEM_SECRET_ID }} + name-transformation: none + - uses: ./.github/actions/sync-project-status with: clientId: ${{ vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID }} - privateKey: ${{ secrets.PROJECT_STATUS_SYNC_APP_PEM }} + privateKey: ${{ env.PROJECT_STATUS_SYNC_APP_PEM }} dryRun: ${{ inputs.dry_run || 'false' }} ``` @@ -75,9 +90,9 @@ Trigger the workflow manually with `dry_run` checked, or pass - `clientId` (**required**): Client ID of the GitHub App used to mint the installation token. -- `privateKey` (**required**): Private key for the app. Pass - `${{ secrets.PROJECT_STATUS_SYNC_APP_PEM }}`. Include the full PEM block - with a trailing newline. +- `privateKey` (**required**): Private key for the app, e.g. fetched from + AWS Secrets Manager via `aws-actions/aws-secretsmanager-get-secrets`. + Include the full PEM block with a trailing newline. - `projectNumbers` (optional): Comma-separated org project numbers to sync. Defaults to `84,78` (Overture, places-surge). - `dryRun` (optional): `"true"` to log intended changes without applying diff --git a/.github/actions/sync-project-status/action.yml b/.github/actions/sync-project-status/action.yml index fd2f758..87c2506 100644 --- a/.github/actions/sync-project-status/action.yml +++ b/.github/actions/sync-project-status/action.yml @@ -23,9 +23,10 @@ inputs: required: true privateKey: description: > - GitHub App private key. Pass secrets.PROJECT_STATUS_SYNC_APP_PEM. - Cannot be defaulted automatically as GitHub Actions does not allow - secrets as input defaults. + GitHub App private key, e.g. fetched from AWS Secrets Manager via + aws-actions/aws-secretsmanager-get-secrets. Cannot be defaulted + automatically as GitHub Actions does not allow secrets as input + defaults. required: true runs: diff --git a/.github/workflows/sync-project-status.yml b/.github/workflows/sync-project-status.yml index 474f7d8..1b5bd4a 100644 --- a/.github/workflows/sync-project-status.yml +++ b/.github/workflows/sync-project-status.yml @@ -4,9 +4,13 @@ # # ProjectsV2 has no workflow trigger for field edits, so this runs as a # scheduled batch job. Sync logic lives in .github/actions/sync-project-status; -# see its README for behavior, conflict resolution, and the GitHub App setup -# required for vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID and -# secrets.PROJECT_STATUS_SYNC_APP_PEM. +# see its README for behavior and conflict resolution details. +# +# Auth: assumes an IAM role via OIDC and fetches the GitHub App PEM from +# AWS Secrets Manager. Requires these repo Actions variables: +# - PROJECT_STATUS_SYNC_APP_CLIENT_ID: GitHub App client ID +# - PROJECT_STATUS_SYNC_OIDC_ROLE_ARN: IAM role with secretsmanager:GetSecretValue +# - PROJECT_STATUS_SYNC_PEM_SECRET_ID: Secrets Manager secret holding the PEM # name: Sync Project Status @@ -31,18 +35,32 @@ concurrency: jobs: sync: name: Sync statuses - runs-on: ubuntu-slim + runs-on: ubuntu-latest permissions: contents: read + id-token: write # for OIDC authentication with AWS steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false sparse-checkout: .github/actions/sync-project-status + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + with: + role-to-assume: ${{ vars.PROJECT_STATUS_SYNC_OIDC_ROLE_ARN }} + aws-region: us-west-2 + + - name: Read app PEM from AWS Secrets Manager + uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 # v3.0.1 + with: + secret-ids: | + PROJECT_STATUS_SYNC_APP_PEM,${{ vars.PROJECT_STATUS_SYNC_PEM_SECRET_ID }} + name-transformation: none + - name: Sync project statuses uses: ./.github/actions/sync-project-status with: clientId: ${{ vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID }} - privateKey: ${{ secrets.PROJECT_STATUS_SYNC_APP_PEM }} # zizmor: ignore[secrets-outside-env] + privateKey: ${{ env.PROJECT_STATUS_SYNC_APP_PEM }} dryRun: ${{ inputs.dry_run || 'false' }} From 61af9f68729464fbf644d48887cae6d0cbb66cad Mon Sep 17 00:00:00 2001 From: John McCall Date: Tue, 4 Aug 2026 13:28:02 -0400 Subject: [PATCH 05/11] [ENHANCEMENT] Align with omf-github-terraform PEM wiring Hard-code the app client ID, OIDC role ARN, and Secrets Manager secret ID to match OvertureMaps/omf-github-terraform#91, same convention as safe-settings-sync. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/README.md | 14 +++++----- .github/workflows/sync-project-status.yml | 26 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/actions/sync-project-status/README.md b/.github/actions/sync-project-status/README.md index 812450f..afd6c38 100644 --- a/.github/actions/sync-project-status/README.md +++ b/.github/actions/sync-project-status/README.md @@ -37,8 +37,9 @@ action mints an installation token from a dedicated GitHub App, which needs: ### Run on a schedule (how this repo uses it) See [`sync-project-status.yml`](../../workflows/sync-project-status.yml): -checkout this repo, assume an IAM role via OIDC, fetch the app PEM from AWS -Secrets Manager, then reference the action locally. +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: @@ -63,19 +64,18 @@ jobs: - uses: aws-actions/configure-aws-credentials@v6 with: - role-to-assume: ${{ vars.PROJECT_STATUS_SYNC_OIDC_ROLE_ARN }} 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_STATUS_SYNC_APP_PEM,${{ vars.PROJECT_STATUS_SYNC_PEM_SECRET_ID }} - name-transformation: none + PROJECT_MANAGER_PEM, omf-github-terraform/project-manager/pem - uses: ./.github/actions/sync-project-status with: - clientId: ${{ vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID }} - privateKey: ${{ env.PROJECT_STATUS_SYNC_APP_PEM }} + clientId: "Iv23limfwiJlCIqHPHrd" # overture-project-manager app, not sensitive + privateKey: ${{ env.PROJECT_MANAGER_PEM }} dryRun: ${{ inputs.dry_run || 'false' }} ``` diff --git a/.github/workflows/sync-project-status.yml b/.github/workflows/sync-project-status.yml index 1b5bd4a..fd54d81 100644 --- a/.github/workflows/sync-project-status.yml +++ b/.github/workflows/sync-project-status.yml @@ -6,11 +6,9 @@ # scheduled batch job. Sync logic lives in .github/actions/sync-project-status; # see its README for behavior and conflict resolution details. # -# Auth: assumes an IAM role via OIDC and fetches the GitHub App PEM from -# AWS Secrets Manager. Requires these repo Actions variables: -# - PROJECT_STATUS_SYNC_APP_CLIENT_ID: GitHub App client ID -# - PROJECT_STATUS_SYNC_OIDC_ROLE_ARN: IAM role with secretsmanager:GetSecretValue -# - PROJECT_STATUS_SYNC_PEM_SECRET_ID: Secrets Manager secret holding the PEM +# Auth: assumes a narrow OIDC role that can only read the +# overture-project-manager GH App PEM from Secrets Manager. The role, +# secret, and app are managed in omf-github-terraform. # name: Sync Project Status @@ -45,22 +43,24 @@ jobs: persist-credentials: false sparse-checkout: .github/actions/sync-project-status - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + # Narrow OIDC role (omf-github-terraform oidc-aws.tf) that can only + # read the project-manager PEM secret. + - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: - role-to-assume: ${{ vars.PROJECT_STATUS_SYNC_OIDC_ROLE_ARN }} aws-region: us-west-2 + role-to-assume: arn:aws:iam::816069134238:role/gha-project-manager-secrets-reader - - name: Read app PEM from AWS 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: | - PROJECT_STATUS_SYNC_APP_PEM,${{ vars.PROJECT_STATUS_SYNC_PEM_SECRET_ID }} - name-transformation: none + PROJECT_MANAGER_PEM, omf-github-terraform/project-manager/pem - name: Sync project statuses uses: ./.github/actions/sync-project-status with: - clientId: ${{ vars.PROJECT_STATUS_SYNC_APP_CLIENT_ID }} - privateKey: ${{ env.PROJECT_STATUS_SYNC_APP_PEM }} + # Client ID of the org's overture-project-manager GitHub App (not sensitive). + clientId: "Iv23limfwiJlCIqHPHrd" + privateKey: ${{ env.PROJECT_MANAGER_PEM }} dryRun: ${{ inputs.dry_run || 'false' }} From 5f43fdbcfaacad6fdcbfed74457e0c640f85e6c3 Mon Sep 17 00:00:00 2001 From: John McCall Date: Tue, 4 Aug 2026 14:13:45 -0400 Subject: [PATCH 06/11] [ENHANCEMENT] Address review feedback Mask multi-line PEM line-by-line, validate projectNumbers input, and guard against null item nodes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/action.yml | 17 +++++++++++++++-- .../actions/sync-project-status/src/projects.js | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/actions/sync-project-status/action.yml b/.github/actions/sync-project-status/action.yml index 87c2506..da80aee 100644 --- a/.github/actions/sync-project-status/action.yml +++ b/.github/actions/sync-project-status/action.yml @@ -36,7 +36,11 @@ runs: shell: bash run: | echo "::group::Masking private key" - echo "::add-mask::${INPUTS_PRIVATEKEY}" + # Mask line-by-line: a single ::add-mask:: on a multi-line PEM 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 }} @@ -59,10 +63,19 @@ runs: script: | const path = require('path'); const run = require(path.join(process.env.GITHUB_ACTION_PATH, 'src', 'index.js')); + const projectNumbers = process.env.PROJECT_NUMBERS + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map(Number); + if (projectNumbers.length < 2 || projectNumbers.some((n) => !Number.isInteger(n) || n <= 0)) { + core.setFailed(`projectNumbers must be 2+ comma-separated positive integers, got "${process.env.PROJECT_NUMBERS}"`); + return; + } await run({ github, core, org: context.repo.owner, - projectNumbers: process.env.PROJECT_NUMBERS.split(',').map(Number), + projectNumbers, dryRun: process.env.DRY_RUN === 'true', }); diff --git a/.github/actions/sync-project-status/src/projects.js b/.github/actions/sync-project-status/src/projects.js index 45aa690..add0f53 100644 --- a/.github/actions/sync-project-status/src/projects.js +++ b/.github/actions/sync-project-status/src/projects.js @@ -57,7 +57,7 @@ async function fetchProject(github, org, number) { project.id = p.id; project.title = p.title; project.statusField = p.field; - project.items.push(...p.items.nodes.filter((n) => !n.isArchived && n.content?.id)); + project.items.push(...p.items.nodes.filter((n) => n && !n.isArchived && n.content?.id)); cursor = p.items.pageInfo.hasNextPage ? p.items.pageInfo.endCursor : null; } while (cursor); return project; From d9be4406ecd97d03e76e3037de14cd78b7001d91 Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 5 Aug 2026 13:43:07 -0400 Subject: [PATCH 07/11] [DOCS] Flag PEM masking as defense in depth only The action can't mask the key's handling before it arrives as an input; callers must source it from a GitHub secret or aws-secretsmanager-get-secrets, which register masks at fetch time. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/README.md | 9 ++++++--- .github/actions/sync-project-status/action.yml | 16 ++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/actions/sync-project-status/README.md b/.github/actions/sync-project-status/README.md index afd6c38..a147bdb 100644 --- a/.github/actions/sync-project-status/README.md +++ b/.github/actions/sync-project-status/README.md @@ -90,9 +90,12 @@ Trigger the workflow manually with `dry_run` checked, or pass - `clientId` (**required**): Client ID of the GitHub App used to mint the installation token. -- `privateKey` (**required**): Private key for the app, e.g. fetched from - AWS Secrets Manager via `aws-actions/aws-secretsmanager-get-secrets`. - Include the full PEM block with a trailing newline. +- `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. - `projectNumbers` (optional): Comma-separated org project numbers to sync. Defaults to `84,78` (Overture, places-surge). - `dryRun` (optional): `"true"` to log intended changes without applying diff --git a/.github/actions/sync-project-status/action.yml b/.github/actions/sync-project-status/action.yml index da80aee..adad861 100644 --- a/.github/actions/sync-project-status/action.yml +++ b/.github/actions/sync-project-status/action.yml @@ -23,10 +23,11 @@ inputs: required: true privateKey: description: > - GitHub App private key, e.g. fetched from AWS Secrets Manager via - aws-actions/aws-secretsmanager-get-secrets. Cannot be defaulted - automatically as GitHub Actions does not allow secrets as input - defaults. + 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: @@ -36,8 +37,11 @@ runs: shell: bash run: | echo "::group::Masking private key" - # Mask line-by-line: a single ::add-mask:: on a multi-line PEM only - # registers the first line. + # 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" From a55b98d511b589664bb45d7af9a836ad3cb804b2 Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 5 Aug 2026 13:45:49 -0400 Subject: [PATCH 08/11] [REFACTOR] Hoist GH App hard-codes to job env vars Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/workflows/sync-project-status.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/sync-project-status.yml b/.github/workflows/sync-project-status.yml index fd54d81..6adffab 100644 --- a/.github/workflows/sync-project-status.yml +++ b/.github/workflows/sync-project-status.yml @@ -37,30 +37,34 @@ jobs: 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/sync-project-status - # Narrow OIDC role (omf-github-terraform oidc-aws.tf) that can only - # read the project-manager PEM secret. - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: aws-region: us-west-2 - role-to-assume: arn:aws:iam::816069134238:role/gha-project-manager-secrets-reader + 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, omf-github-terraform/project-manager/pem + PROJECT_MANAGER_PEM,${{ env.PROJECT_MANAGER_PEM_SECRET_ID }} - name: Sync project statuses uses: ./.github/actions/sync-project-status with: - # Client ID of the org's overture-project-manager GitHub App (not sensitive). - clientId: "Iv23limfwiJlCIqHPHrd" + clientId: ${{ env.PROJECT_MANAGER_APP_CLIENT_ID }} privateKey: ${{ env.PROJECT_MANAGER_PEM }} dryRun: ${{ inputs.dry_run || 'false' }} From bf3cfd0c582840d87718af04f6f315a74bb55f47 Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 5 Aug 2026 13:46:58 -0400 Subject: [PATCH 09/11] [PERFORMANCE] Run on ubuntu-slim All steps are Node actions or bash; nothing needs the AWS CLI or the full image. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/README.md | 2 +- .github/workflows/sync-project-status.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/sync-project-status/README.md b/.github/actions/sync-project-status/README.md index a147bdb..230715e 100644 --- a/.github/actions/sync-project-status/README.md +++ b/.github/actions/sync-project-status/README.md @@ -53,7 +53,7 @@ on: jobs: sync: - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: contents: read id-token: write # for OIDC authentication with AWS diff --git a/.github/workflows/sync-project-status.yml b/.github/workflows/sync-project-status.yml index 6adffab..18810db 100644 --- a/.github/workflows/sync-project-status.yml +++ b/.github/workflows/sync-project-status.yml @@ -33,7 +33,7 @@ concurrency: jobs: sync: name: Sync statuses - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: contents: read id-token: write # for OIDC authentication with AWS From ba7b53834264b6cf34f32e0d761a3ddaee207b1c Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 5 Aug 2026 13:47:21 -0400 Subject: [PATCH 10/11] [DOCS] Note where to find project numbers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/action.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/sync-project-status/action.yml b/.github/actions/sync-project-status/action.yml index adad861..5d17e96 100644 --- a/.github/actions/sync-project-status/action.yml +++ b/.github/actions/sync-project-status/action.yml @@ -8,6 +8,8 @@ description: > inputs: projectNumbers: + # A project's number is in its URL: github.com/orgs/OvertureMaps/projects/. + # The app must also be granted access to it in the org's app installation settings. description: Comma-separated org project numbers to sync required: false default: "84,78" # Overture, places-surge From af1acd0153e78f48f9b83967886a0509bdeea4ae Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 5 Aug 2026 15:47:30 -0400 Subject: [PATCH 11/11] [ENHANCEMENT] Add CloudDevOps (#54) to default sync projects Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- .github/actions/sync-project-status/README.md | 2 +- .github/actions/sync-project-status/action.yml | 2 +- .github/workflows/sync-project-status.yml | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/actions/sync-project-status/README.md b/.github/actions/sync-project-status/README.md index 230715e..b5fcf02 100644 --- a/.github/actions/sync-project-status/README.md +++ b/.github/actions/sync-project-status/README.md @@ -97,7 +97,7 @@ Trigger the workflow manually with `dry_run` checked, or pass mask the value's handling before it arrives as an input. Include the full PEM block with a trailing newline. - `projectNumbers` (optional): Comma-separated org project numbers to sync. - Defaults to `84,78` (Overture, places-surge). + Defaults to `84,78,54` (Overture, places-surge, CloudDevOps). - `dryRun` (optional): `"true"` to log intended changes without applying them. Defaults to `"false"`. diff --git a/.github/actions/sync-project-status/action.yml b/.github/actions/sync-project-status/action.yml index 5d17e96..b41c24e 100644 --- a/.github/actions/sync-project-status/action.yml +++ b/.github/actions/sync-project-status/action.yml @@ -12,7 +12,7 @@ inputs: # The app must also be granted access to it in the org's app installation settings. description: Comma-separated org project numbers to sync required: false - default: "84,78" # Overture, places-surge + default: "84,78,54" # Overture, places-surge, CloudDevOps dryRun: description: Log intended changes without applying them required: false diff --git a/.github/workflows/sync-project-status.yml b/.github/workflows/sync-project-status.yml index 18810db..c058883 100644 --- a/.github/workflows/sync-project-status.yml +++ b/.github/workflows/sync-project-status.yml @@ -1,6 +1,7 @@ --- # Keeps the Status field in sync for issues that belong to more than one -# Overture ProjectV2 (currently Overture #84 and places-surge #78). +# Overture ProjectV2 (currently Overture #84, places-surge #78, and +# CloudDevOps #54). # # ProjectsV2 has no workflow trigger for field edits, so this runs as a # scheduled batch job. Sync logic lives in .github/actions/sync-project-status;