-
Notifications
You must be signed in to change notification settings - Fork 3.7k
improvement(ci): rename companion tags to be more descriptive #5081
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
+21
−19
Merged
Changes from 5 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
4a4205d
ci(companion): warn on unmerged cross-repo companion PRs
Sg312 ef7b4d3
ci(companion): aggregate companions across bundled feature PRs on pro…
Sg312 d085d74
fix(companion): read cross-repo PR via fetch (github-script can't req…
Sg312 28526dd
fix(companion): drop stale has-companion label + document schedule ca…
Sg312 9494744
improvement(ci): rename companion tags to be more descriptive
Sg312 31f7893
Merge origin/staging into dev
Sg312 4e181ed
improvement(ci): drop companion cron, log unexpected label-API errors
Sg312 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| 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. 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: | ||
| 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 = '<!-- companion-pr-check -->'; | ||
| // 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; | ||
| // 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 | ||
| // 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 || ''; | ||
| 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 }); | ||
| // 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 {} | ||
| } | ||
| async function ensureLabel() { | ||
| try { await github.rest.issues.getLabel({ owner, repo, name: LABEL }); } | ||
| catch { | ||
| try { | ||
|
Sg312 marked this conversation as resolved.
Outdated
|
||
| await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'd93f0b', description: LABEL_DESC }); | ||
| } catch {} | ||
| } | ||
| } | ||
|
|
||
| // 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: [LABEL] }); } catch {} | ||
|
|
||
| const base = pr.base.ref; | ||
| const lines = []; | ||
| let warn = false; | ||
| for (const c of companions) { | ||
| if (!crossToken) { | ||
| lines.push(`- ❓ \`${c.ref}\` — set the **CROSS_REPO_TOKEN** secret to verify merge status`); | ||
| warn = true; | ||
| continue; | ||
| } | ||
| try { | ||
| const cp = await crossGetPR(c); | ||
| 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 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${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')}`); | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.