Skip to content

Commit 6f76e39

Browse files
authored
ci(repo): add release pre-flight workflow (#7822)
1 parent a168cb5 commit 6f76e39

5 files changed

Lines changed: 250 additions & 5 deletions

File tree

.changeset/changelog.js

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,166 @@
1-
const { getInfo, getInfoFromPullRequest } = require('@changesets/get-github-info');
2-
31
const repo = 'clerk/javascript';
2+
const [owner, repoName] = repo.split('/');
3+
4+
// Cache to avoid duplicate fetches for the same commit/PR
5+
const cache = new Map();
6+
7+
// Simple concurrency limiter to avoid hitting GitHub secondary rate limits
8+
const MAX_CONCURRENT = 6;
9+
let active = 0;
10+
const queue = [];
11+
12+
function withLimit(fn) {
13+
return (...args) =>
14+
new Promise((resolve, reject) => {
15+
const run = async () => {
16+
active++;
17+
try {
18+
resolve(await fn(...args));
19+
} catch (e) {
20+
reject(e);
21+
} finally {
22+
active--;
23+
if (queue.length > 0) queue.shift()();
24+
}
25+
};
26+
if (active < MAX_CONCURRENT) run();
27+
else queue.push(run);
28+
});
29+
}
30+
31+
async function graphql(query) {
32+
const token = process.env.GITHUB_TOKEN;
33+
if (!token) {
34+
throw new Error('GITHUB_TOKEN environment variable is required');
35+
}
36+
37+
const res = await fetch('https://api.github.com/graphql', {
38+
method: 'POST',
39+
headers: {
40+
Authorization: `Token ${token}`,
41+
'Content-Type': 'application/json',
42+
},
43+
body: JSON.stringify({ query }),
44+
});
45+
46+
if (!res.ok) {
47+
throw new Error(`GitHub API responded with ${res.status}: ${await res.text()}`);
48+
}
49+
50+
const json = await res.json();
51+
if (json.errors) {
52+
throw new Error(`GitHub GraphQL error: ${JSON.stringify(json.errors, null, 2)}`);
53+
}
54+
if (!json.data) {
55+
throw new Error(`Unexpected GitHub response: ${JSON.stringify(json)}`);
56+
}
57+
return json.data;
58+
}
59+
60+
// Fetches commit info with a single small GraphQL query per commit
61+
const fetchCommitInfo = withLimit(async commit => {
62+
const key = `commit:${commit}`;
63+
if (cache.has(key)) return cache.get(key);
64+
65+
const data = await graphql(`query {
66+
repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(repoName)}) {
67+
object(expression: ${JSON.stringify(commit)}) {
68+
... on Commit {
69+
commitUrl
70+
associatedPullRequests(first: 50) {
71+
nodes { number url mergedAt author { login url } }
72+
}
73+
author { user { login url } }
74+
}
75+
}
76+
}
77+
}`);
78+
79+
const obj = data.repository.object;
80+
if (!obj) {
81+
const result = {
82+
user: null,
83+
pull: null,
84+
links: {
85+
commit: `[\`${commit.slice(0, 7)}\`](https://github.com/${repo}/commit/${commit})`,
86+
pull: null,
87+
user: null,
88+
},
89+
};
90+
cache.set(key, result);
91+
return result;
92+
}
93+
94+
let user = obj.author && obj.author.user ? obj.author.user : null;
95+
const associatedPR =
96+
obj.associatedPullRequests &&
97+
obj.associatedPullRequests.nodes &&
98+
obj.associatedPullRequests.nodes.length
99+
? obj.associatedPullRequests.nodes.sort((a, b) => {
100+
if (a.mergedAt === null && b.mergedAt === null) return 0;
101+
if (a.mergedAt === null) return 1;
102+
if (b.mergedAt === null) return -1;
103+
return new Date(b.mergedAt) - new Date(a.mergedAt);
104+
})[0]
105+
: null;
106+
107+
if (associatedPR && associatedPR.author) user = associatedPR.author;
108+
109+
const result = {
110+
user: user ? user.login : null,
111+
pull: associatedPR ? associatedPR.number : null,
112+
links: {
113+
commit: `[\`${commit.slice(0, 7)}\`](${obj.commitUrl})`,
114+
pull: associatedPR ? `[#${associatedPR.number}](${associatedPR.url})` : null,
115+
user: user ? `[@${user.login}](${user.url})` : null,
116+
},
117+
};
118+
cache.set(key, result);
119+
return result;
120+
});
121+
122+
// Fetches pull request info with a single small GraphQL query per PR
123+
const fetchPullRequestInfo = withLimit(async pull => {
124+
const key = `pull:${pull}`;
125+
if (cache.has(key)) return cache.get(key);
126+
127+
const data = await graphql(`query {
128+
repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(repoName)}) {
129+
pullRequest(number: ${pull}) {
130+
url
131+
author { login url }
132+
mergeCommit { commitUrl abbreviatedOid }
133+
}
134+
}
135+
}`);
136+
137+
const pr = data.repository.pullRequest;
138+
const user = pr && pr.author ? pr.author : null;
139+
const mergeCommit = pr && pr.mergeCommit ? pr.mergeCommit : null;
140+
141+
const result = {
142+
user: user ? user.login : null,
143+
commit: mergeCommit ? mergeCommit.abbreviatedOid : null,
144+
links: {
145+
commit: mergeCommit
146+
? `[\`${mergeCommit.abbreviatedOid}\`](${mergeCommit.commitUrl})`
147+
: null,
148+
pull: `[#${pull}](https://github.com/${repo}/pull/${pull})`,
149+
user: user ? `[@${user.login}](${user.url})` : null,
150+
},
151+
};
152+
cache.set(key, result);
153+
return result;
154+
});
155+
156+
// Drop-in replacements for @changesets/get-github-info
157+
async function getInfo({ commit }) {
158+
return fetchCommitInfo(commit);
159+
}
160+
161+
async function getInfoFromPullRequest({ pull }) {
162+
return fetchPullRequestInfo(pull);
163+
}
4164

5165
const getDependencyReleaseLine = async (changesets, dependenciesUpdated) => {
6166
if (dependenciesUpdated.length === 0) return '';
@@ -10,7 +170,6 @@ const getDependencyReleaseLine = async (changesets, dependenciesUpdated) => {
10170
changesets.map(async cs => {
11171
if (cs.commit) {
12172
let { links } = await getInfo({
13-
repo,
14173
commit: cs.commit,
15174
});
16175
return links.commit;
@@ -54,7 +213,6 @@ const getReleaseLine = async (changeset, type, options) => {
54213
const links = await (async () => {
55214
if (prFromSummary !== undefined) {
56215
let { links } = await getInfoFromPullRequest({
57-
repo,
58216
pull: prFromSummary,
59217
});
60218
if (commitFromSummary) {
@@ -68,7 +226,6 @@ const getReleaseLine = async (changeset, type, options) => {
68226
const commitToFetchFrom = commitFromSummary || changeset.commit;
69227
if (commitToFetchFrom) {
70228
let { links } = await getInfo({
71-
repo,
72229
commit: commitToFetchFrom,
73230
});
74231
return links;

.changeset/wacky-worms-hope.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
name: Release Preflight
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches: [main]
7+
8+
concurrency:
9+
group: release-preflight-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
rehearse:
14+
name: Release Preflight
15+
runs-on: ubuntu-latest
16+
permissions:
17+
contents: read
18+
timeout-minutes: 30
19+
20+
steps:
21+
- name: Checkout
22+
uses: actions/checkout@v4
23+
with:
24+
fetch-depth: 100
25+
fetch-tags: false
26+
filter: 'blob:none'
27+
show-progress: false
28+
29+
- name: Fetch main branch for changeset comparison
30+
run: git fetch origin main:refs/remotes/origin/main --depth=100
31+
32+
- name: Setup
33+
uses: ./.github/actions/init
34+
with:
35+
turbo-team: ''
36+
turbo-token: ''
37+
38+
# 1) Validate changesets against base branch
39+
- name: Changeset status
40+
run: pnpm changeset status --output .changeset-status.json
41+
42+
# 2) Build (same path as production releases)
43+
- name: Build
44+
run: pnpm build
45+
46+
# 3) Version packages (uses existing script: changeset version + lockfile update)
47+
- name: Version packages (preflight)
48+
run: pnpm version-packages
49+
env:
50+
GITHUB_TOKEN: ${{ github.token }}
51+
52+
# 4) Fail on unexpected file changes after versioning
53+
- name: Post-version diff guard
54+
run: |
55+
UNEXPECTED=$(git diff --name-only | grep -Ev '^(\.changeset/|package\.json$|pnpm-lock\.yaml$|packages/.*/package\.json$|packages/.*/CHANGELOG\.md$)' || true)
56+
if [ -n "$UNEXPECTED" ]; then
57+
echo "::error::Unexpected files changed after versioning:"
58+
echo "$UNEXPECTED"
59+
exit 1
60+
fi
61+
62+
# 5) Simulate publish by packing all public packages
63+
- name: Pack public packages
64+
run: |
65+
mkdir -p .release-artifacts
66+
pnpm -r exec -- sh -c '
67+
if [ "$(node -p "Boolean(require(\"./package.json\").private)")" = "true" ]; then
68+
echo "Skipping private package: $(node -p "require(\"./package.json\").name")"
69+
else
70+
npm pack --json
71+
fi
72+
' > .release-artifacts/pack-output.json 2>&1
73+
74+
- name: Upload preflight artifacts
75+
if: always()
76+
uses: actions/upload-artifact@v4
77+
with:
78+
name: release-preflight-artifacts
79+
path: |
80+
.changeset-status.json
81+
.release-artifacts/pack-output.json

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ sessions.pem
8989
# Verdaccio
9090
.verdaccio
9191

92+
# Release preflight
93+
.changeset-status.json
94+
.release-artifacts/
95+
9296
# Workflow Outputs
9397
/packages/*/*.tgz
9498
/packages/*/tsconfig*.vitest-temp.json

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"release:canary": "changeset publish --tag canary --no-git-tag",
3434
"release:canary-core3": "changeset publish --tag canary-core3 --no-git-tag",
3535
"release:snapshot": "changeset publish --tag snapshot --no-git-tag",
36+
"release:status": "changeset status --output .changeset-status.json",
3637
"release:verdaccio": "if [ \"$(npm config get registry)\" = \"https://registry.npmjs.org/\" ]; then echo 'Error: Using default registry' && exit 1; else TURBO_CONCURRENCY=1 pnpm build && changeset publish --no-git-tag; fi",
3738
"test": "FORCE_COLOR=1 turbo test --concurrency=${TURBO_CONCURRENCY:-80%}",
3839
"test:cache:clear": "FORCE_COLOR=1 turbo test:cache:clear --continue --concurrency=${TURBO_CONCURRENCY:-80%}",

0 commit comments

Comments
 (0)