From 4a4205d5535ec98a30ddd04361437a7b470d221a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 15 Jun 2026 17:08:55 -0700 Subject: [PATCH 1/6] ci(companion): warn on unmerged cross-repo companion PRs Mirror of copilot's repo-agnostic companion-pr-check workflow: a soft, non-blocking warning on PRs to staging/main when a declared cross-repo Companion PR isn't merged in lockstep. Declare via a "Companion:" trailer or a "## Companion PRs" task list. Requires the CROSS_REPO_TOKEN secret. --- .github/workflows/companion-pr-check.yml | 150 +++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 .github/workflows/companion-pr-check.yml diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml new file mode 100644 index 0000000000..eb7e055090 --- /dev/null +++ b/.github/workflows/companion-pr-check.yml @@ -0,0 +1,150 @@ +name: companion-pr-check + +# Soft, NON-BLOCKING warning: when a PR targeting staging/main declares a +# cross-repo "Companion:" PR, surface whether that companion is merged yet, so +# copilot and sim stay in lockstep (a change in one often needs the other). +# +# Declare in a PR description (repeatable; shorthand OR full URL both parse): +# Companion: simstudioai/sim#1234 +# Companion: https://github.com/simstudioai/sim/pull/1234 +# +# Requires a CROSS_REPO_TOKEN secret (fine-grained PAT with pull-requests:read on +# BOTH repos) to read the other repo's PR state. Without it the check still +# surfaces the declared link but reports "couldn't verify". + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + branches: [staging, main] + schedule: + # Refresh open staging/main PRs in case the companion merges AFTER this PR + # was opened (scheduled runs use the workflow from the default branch). + - cron: '*/30 * * * *' + workflow_dispatch: {} + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + companion: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + env: + CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_TOKEN }} + with: + script: | + const STICKY = ''; + // Two ways to declare a companion (either works; both feed this warning): + // 1) a trailer anywhere: Companion: owner/repo#N (or a full PR URL) + // 2) refs in a task list under a "## Companion..." heading — which ALSO + // renders a native live badge + progress bar on the PR (the "both" path): + // ## Companion PRs + // - [ ] owner/repo#N + const TRAILER = /Companion:\s*(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/gi; + const REF = /(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/g; + const { owner, repo } = context.repo; + const crossToken = process.env.CROSS_REPO_TOKEN; + const cross = crossToken ? require('@actions/github').getOctokit(crossToken) : null; + + function parseCompanions(body) { + body = body || ''; + const out = []; + const seen = new Set(); + const add = (o, r, n) => { + const ref = `${o}/${r}#${n}`; + if (seen.has(ref)) return; + seen.add(ref); + out.push({ owner: o, repo: r, number: Number(n), ref }); + }; + // (1) "Companion:" trailers anywhere in the body. + let m; + TRAILER.lastIndex = 0; + while ((m = TRAILER.exec(body)) !== null) add(m[1], m[2], m[3]); + // (2) refs in a task list under a "## Companion..." heading, until the next heading. + let inSection = false; + for (const line of body.split(/\r?\n/)) { + if (/^#{1,6}\s/.test(line)) { inSection = /^#{1,6}\s*companion/i.test(line); continue; } + if (!inSection) continue; + let mm; + REF.lastIndex = 0; + while ((mm = REF.exec(line)) !== null) add(mm[1], mm[2], mm[3]); + } + return out; + } + + async function findSticky(prNumber) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100, + }); + return comments.find((c) => (c.body || '').includes(STICKY)); + } + async function upsert(prNumber, body) { + const ex = await findSticky(prNumber); + if (ex) await github.rest.issues.updateComment({ owner, repo, comment_id: ex.id, body }); + else await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); + } + async function clear(prNumber) { + const ex = await findSticky(prNumber); + if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id }); + } + async function ensureLabel() { + try { await github.rest.issues.getLabel({ owner, repo, name: 'has-companion' }); } + catch { + try { + await github.rest.issues.createLabel({ + owner, repo, name: 'has-companion', color: '5319e7', + description: 'Has a cross-repo companion PR (see companion-pr-check)', + }); + } catch {} + } + } + + async function checkPR(pr) { + const companions = parseCompanions(pr.body); + if (companions.length === 0) { await clear(pr.number); return; } + await ensureLabel(); + try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['has-companion'] }); } catch {} + + const base = pr.base.ref; + const lines = []; + let warn = false; + for (const c of companions) { + if (!cross) { + lines.push(`- ❓ \`${c.ref}\` — set the **CROSS_REPO_TOKEN** secret to verify merge status`); + warn = true; + continue; + } + try { + const { data: cp } = await cross.rest.pulls.get({ owner: c.owner, repo: c.repo, pull_number: c.number }); + const title = (cp.title || '').slice(0, 80); + if (cp.merged) { + const tierOk = cp.base.ref === base; + lines.push(`- ${tierOk ? '✅' : '⚠️'} [\`${c.ref}\`](${cp.html_url}) — merged into \`${cp.base.ref}\`${tierOk ? '' : ` (this PR targets \`${base}\`)`} — ${title}`); + if (!tierOk) warn = true; + } else { + lines.push(`- ❌ [\`${c.ref}\`](${cp.html_url}) — **${String(cp.state).toUpperCase()}, not merged** (targets \`${cp.base.ref}\`) — ${title}`); + warn = true; + } + } catch (e) { + lines.push(`- ❓ \`${c.ref}\` — couldn't read (${e.status || e.message}); check CROSS_REPO_TOKEN scope`); + warn = true; + } + } + const heading = warn ? '## ⚠️ Cross-repo companion check' : '## ✅ Cross-repo companion check'; + const note = warn + ? `One or more companion PRs aren't merged into \`${base}\` yet. Merging this without them will leave copilot and sim out of sync — merge them in lockstep.` + : `All declared companion PRs are merged into \`${base}\`.`; + await upsert(pr.number, `${STICKY}\n${heading}\n\n${note}\n\n${lines.join('\n')}`); + } + + if (context.eventName === 'pull_request') { + await checkPR(context.payload.pull_request); + } else { + for (const b of ['staging', 'main']) { + const prs = await github.paginate(github.rest.pulls.list, { owner, repo, base: b, state: 'open', per_page: 100 }); + for (const pr of prs) await checkPR(pr); + } + } From ef7b4d3f85e4fd1a06d51aa6dc237f305dc90e1c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 15 Jun 2026 17:33:20 -0700 Subject: [PATCH 2/6] ci(companion): aggregate companions across bundled feature PRs on prod releases Mirror of copilot: on staging->main release PRs, aggregate Companion declarations from every squashed feature PR in the release (deduped). dev->staging stays single-PR. --- .github/workflows/companion-pr-check.yml | 42 ++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index eb7e055090..4760f143ad 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -102,8 +102,43 @@ jobs: } } - async function checkPR(pr) { + // staging PRs are a single feature → just this PR's body ("the one"). + // main (prod) release PRs bundle MANY feature PRs → aggregate the + // companions declared on each squashed feature PR too, so "does any + // commit in this release have a companion?" is answered. + async function collectCompanions(pr) { const companions = parseCompanions(pr.body); + const seen = new Set(companions.map((c) => c.ref)); + if (pr.base.ref === 'main') { + let commits = []; + try { + commits = await github.paginate(github.rest.pulls.listCommits, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + } catch {} + const featurePRs = new Set(); + const SQUASH = /\(#(\d+)\)/g; // squash-merge refs like "...(#306)" + for (const c of commits) { + const msg = (c.commit && c.commit.message) || ''; + let m; + SQUASH.lastIndex = 0; + while ((m = SQUASH.exec(msg)) !== null) featurePRs.add(Number(m[1])); + } + for (const n of featurePRs) { + if (n === pr.number) continue; + try { + const { data: fpr } = await github.rest.pulls.get({ owner, repo, pull_number: n }); + for (const c of parseCompanions(fpr.body)) { + if (!seen.has(c.ref)) { seen.add(c.ref); companions.push(c); } + } + } catch {} + } + } + return companions; + } + + async function checkPR(pr) { + const companions = await collectCompanions(pr); if (companions.length === 0) { await clear(pr.number); return; } await ensureLabel(); try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['has-companion'] }); } catch {} @@ -134,9 +169,10 @@ jobs: } } const heading = warn ? '## ⚠️ Cross-repo companion check' : '## ✅ Cross-repo companion check'; + const scope = base === 'main' ? ' (aggregated across the feature PRs in this release)' : ''; const note = warn - ? `One or more companion PRs aren't merged into \`${base}\` yet. Merging this without them will leave copilot and sim out of sync — merge them in lockstep.` - : `All declared companion PRs are merged into \`${base}\`.`; + ? `One or more companion PRs aren't merged into \`${base}\` yet${scope}. Merging this without them will leave copilot and sim out of sync — merge them in lockstep.` + : `All declared companion PRs are merged into \`${base}\`${scope}.`; await upsert(pr.number, `${STICKY}\n${heading}\n\n${note}\n\n${lines.join('\n')}`); } From d085d745f4c441277d485ec4f335ee9ff75f1262 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 15 Jun 2026 17:38:01 -0700 Subject: [PATCH 3/6] fix(companion): read cross-repo PR via fetch (github-script can't require @actions/github) Mirror of copilot: companion-pr-check failed with MODULE_NOT_FOUND. Read the other repo's PR with a plain REST fetch + the PAT in the header (PAT stays read-only; commenting/labeling uses GITHUB_TOKEN). --- .github/workflows/companion-pr-check.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index 4760f143ad..84527960dd 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -47,7 +47,22 @@ jobs: const REF = /(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/g; const { owner, repo } = context.repo; const crossToken = process.env.CROSS_REPO_TOKEN; - const cross = crossToken ? require('@actions/github').getOctokit(crossToken) : null; + // Read the OTHER repo's PR via a plain REST fetch with the PAT in the + // header — keeps the PAT strictly READ-ONLY and avoids re-instantiating + // Octokit inside github-script (which can't require('@actions/github')). + // Commenting/labeling uses the default GITHUB_TOKEN via `github`. + async function crossGetPR(c) { + const res = await fetch(`https://api.github.com/repos/${c.owner}/${c.repo}/pulls/${c.number}`, { + headers: { + authorization: `Bearer ${crossToken}`, + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': 'companion-pr-check', + }, + }); + if (!res.ok) { const e = new Error(`HTTP ${res.status}`); e.status = res.status; throw e; } + return res.json(); + } function parseCompanions(body) { body = body || ''; @@ -147,13 +162,13 @@ jobs: const lines = []; let warn = false; for (const c of companions) { - if (!cross) { + if (!crossToken) { lines.push(`- ❓ \`${c.ref}\` — set the **CROSS_REPO_TOKEN** secret to verify merge status`); warn = true; continue; } try { - const { data: cp } = await cross.rest.pulls.get({ owner: c.owner, repo: c.repo, pull_number: c.number }); + const cp = await crossGetPR(c); const title = (cp.title || '').slice(0, 80); if (cp.merged) { const tierOk = cp.base.ref === base; From 28526dd50441914826fdccf966ec367d5bc74a84 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 15 Jun 2026 17:39:30 -0700 Subject: [PATCH 4/6] fix(companion): drop stale has-companion label + document schedule caveat Mirror of copilot: clear() removes the has-companion label when a PR's companions are all removed; schedule default-branch caveat documented; github-script kept at @v7 to match repo convention. --- .github/workflows/companion-pr-check.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index 84527960dd..430d7e9431 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -17,8 +17,11 @@ on: types: [opened, edited, reopened, synchronize] branches: [staging, main] schedule: - # Refresh open staging/main PRs in case the companion merges AFTER this PR - # was opened (scheduled runs use the workflow from the default branch). + # Refresh open staging/main PRs in case the companion merges AFTER this PR was + # opened. CAVEAT: GitHub runs scheduled workflows ONLY from the DEFAULT branch's + # copy of this file — so this auto-refresh activates once the workflow lands on + # the default branch (via the normal promotion), not before. The pull_request + # triggers below always work; re-editing the PR re-runs the check meanwhile. - cron: '*/30 * * * *' workflow_dispatch: {} @@ -104,6 +107,9 @@ jobs: async function clear(prNumber) { const ex = await findSticky(prNumber); if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id }); + // Drop the label too, so a PR edited to remove all companions doesn't + // keep a stale has-companion badge. 404 if not present → ignore. + try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'has-companion' }); } catch {} } async function ensureLabel() { try { await github.rest.issues.getLabel({ owner, repo, name: 'has-companion' }); } From 94947446dfb34df94b50db7c3f10585b695a7de0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 15 Jun 2026 18:33:52 -0700 Subject: [PATCH 5/6] improvement(ci): rename companion tags to be more descriptive --- .github/workflows/companion-pr-check.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index 430d7e9431..85aa26cbd0 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -49,6 +49,12 @@ jobs: const TRAILER = /Companion:\s*(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/gi; const REF = /(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/g; const { owner, repo } = context.repo; + // Directional label: copilot/mothership PRs get "requires-sim-merge", + // sim PRs get "requires-mothership-merge". Applied whenever the PR + // declares a companion; removed when it declares none. + const otherSide = repo === 'sim' ? 'mothership/copilot' : 'sim'; + const LABEL = repo === 'sim' ? 'requires-mothership-merge' : 'requires-sim-merge'; + const LABEL_DESC = `Has a companion PR on the ${otherSide} side — merge in lockstep`; const crossToken = process.env.CROSS_REPO_TOKEN; // Read the OTHER repo's PR via a plain REST fetch with the PAT in the // header — keeps the PAT strictly READ-ONLY and avoids re-instantiating @@ -108,17 +114,14 @@ jobs: const ex = await findSticky(prNumber); if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id }); // Drop the label too, so a PR edited to remove all companions doesn't - // keep a stale has-companion badge. 404 if not present → ignore. - try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'has-companion' }); } catch {} + // keep a stale badge. 404 if not present → ignore. + try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: LABEL }); } catch {} } async function ensureLabel() { - try { await github.rest.issues.getLabel({ owner, repo, name: 'has-companion' }); } + try { await github.rest.issues.getLabel({ owner, repo, name: LABEL }); } catch { try { - await github.rest.issues.createLabel({ - owner, repo, name: 'has-companion', color: '5319e7', - description: 'Has a cross-repo companion PR (see companion-pr-check)', - }); + await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'd93f0b', description: LABEL_DESC }); } catch {} } } @@ -162,7 +165,7 @@ jobs: const companions = await collectCompanions(pr); if (companions.length === 0) { await clear(pr.number); return; } await ensureLabel(); - try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['has-companion'] }); } catch {} + try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); } catch {} const base = pr.base.ref; const lines = []; From 4e181ede6af30737f508b37f52b1be5d09cab6ec Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 15 Jun 2026 18:42:02 -0700 Subject: [PATCH 6/6] improvement(ci): drop companion cron, log unexpected label-API errors Mirror of copilot: remove the */30 scheduled bulk scan (greptile P2) that re-listed comments for every open staging/main PR toward the GITHUB_TOKEN rate limit. PR-driven only (companions are tagged on both sides); workflow_dispatch stays for manual full re-scans. Also surface non-404/422 label-API failures via core.warning so silent errors are observable. --- .github/workflows/companion-pr-check.yml | 31 ++++++++++++------------ 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index 85aa26cbd0..effdbc8714 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -13,16 +13,13 @@ name: companion-pr-check # surfaces the declared link but reports "couldn't verify". on: + # PR-driven only: runs on the one PR being opened/edited/synced — no periodic + # bulk scan. We assume companions are declared on BOTH sides, so the per-PR + # trigger keeps each side's status fresh; to refresh after a companion merges, + # re-edit the PR (or run this workflow manually via the Actions tab). pull_request: types: [opened, edited, reopened, synchronize] branches: [staging, main] - schedule: - # Refresh open staging/main PRs in case the companion merges AFTER this PR was - # opened. CAVEAT: GitHub runs scheduled workflows ONLY from the DEFAULT branch's - # copy of this file — so this auto-refresh activates once the workflow lands on - # the default branch (via the normal promotion), not before. The pull_request - # triggers below always work; re-editing the PR re-runs the check meanwhile. - - cron: '*/30 * * * *' workflow_dispatch: {} permissions: @@ -114,16 +111,16 @@ jobs: const ex = await findSticky(prNumber); if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id }); // Drop the label too, so a PR edited to remove all companions doesn't - // keep a stale badge. 404 if not present → ignore. - try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: LABEL }); } catch {} + // keep a stale badge. 404 (not present) is expected; surface anything else. + try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: LABEL }); } + catch (e) { if (e.status !== 404) core.warning(`companion: removeLabel ${LABEL} on #${prNumber} failed (${e.status || e.message})`); } } async function ensureLabel() { - try { await github.rest.issues.getLabel({ owner, repo, name: LABEL }); } - catch { - try { - await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'd93f0b', description: LABEL_DESC }); - } catch {} - } + try { await github.rest.issues.getLabel({ owner, repo, name: LABEL }); return; } + catch (e) { if (e.status && e.status !== 404) { core.warning(`companion: getLabel ${LABEL} failed (${e.status})`); return; } } + // 404 → label doesn't exist yet, create it. 422 = another run beat us (fine). + try { await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'd93f0b', description: LABEL_DESC }); } + catch (e) { if (e.status !== 422) core.warning(`companion: createLabel ${LABEL} failed (${e.status || e.message})`); } } // staging PRs are a single feature → just this PR's body ("the one"). @@ -165,7 +162,8 @@ jobs: const companions = await collectCompanions(pr); if (companions.length === 0) { await clear(pr.number); return; } await ensureLabel(); - try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); } catch {} + try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }); } + catch (e) { core.warning(`companion: addLabels ${LABEL} on #${pr.number} failed (${e.status || e.message})`); } const base = pr.base.ref; const lines = []; @@ -203,6 +201,7 @@ jobs: if (context.eventName === 'pull_request') { await checkPR(context.payload.pull_request); } else { + // workflow_dispatch only: manual full re-scan of open staging/main PRs. for (const b of ['staging', 'main']) { const prs = await github.paginate(github.rest.pulls.list, { owner, repo, base: b, state: 'open', per_page: 100 }); for (const pr of prs) await checkPR(pr);