mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat(release): validate version-only bumps before approval-exempt queueing (#8800)
Co-authored-by: lefarcen <935902669@qq.com>
This commit is contained in:
co-authored by
lefarcen
parent
5a221d5043
commit
1eaa2381aa
@@ -0,0 +1,155 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const REPO = 'nexu-io/open-design';
|
||||
const BOT = 'open-design-release-bot[bot]';
|
||||
const manifestPath = /^(?:package\.json|e2e\/package\.json|(?:apps|packages|tools)\/[^/]+\/package\.json)$/;
|
||||
type Entry = { mode: string; type: string; oid: string; path: string };
|
||||
type Pull = {
|
||||
number: number; node_id: string; state: string; draft: boolean; user: { login: string };
|
||||
head: { sha: string; ref: string; repo: { full_name: string } | null };
|
||||
base: { sha: string; ref: string; repo: { full_name: string } };
|
||||
labels: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
function command(bin: string, args: string[]): string {
|
||||
return execFileSync(bin, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 60_000 });
|
||||
}
|
||||
function api<T>(path: string): T { return JSON.parse(command('gh', ['api', path])) as T; }
|
||||
function git(...args: string[]): string { return command('git', args); }
|
||||
function sha(value: string): string {
|
||||
if (!/^[a-f0-9]{40}$/.test(value)) throw new Error('Invalid commit SHA');
|
||||
return value;
|
||||
}
|
||||
export function nextPatch(version: string): string {
|
||||
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) throw new Error('Invalid stable version');
|
||||
const [major, minor, patch] = version.split('.');
|
||||
return `${major}.${minor}.${BigInt(patch!) + 1n}`;
|
||||
}
|
||||
export function eligibleIdentity(pr: Pull, version: string): boolean {
|
||||
return pr.state === 'open' && !pr.draft && pr.user.login === BOT
|
||||
&& pr.base.ref === 'main' && pr.base.repo.full_name === REPO && pr.head.repo?.full_name === REPO
|
||||
&& pr.head.ref === `release-bot/bump-main-v${nextPatch(version)}`
|
||||
&& !pr.labels.some(({ name }) => /^(hold|do-not-merge|blocked)$/i.test(name));
|
||||
}
|
||||
function tree(ref: string): Map<string, Entry> {
|
||||
return new Map(git('ls-tree', '-r', '-z', ref).split('\0').filter(Boolean).map(line => {
|
||||
const match = /^(\d+) (\w+) ([a-f0-9]+)\t([\s\S]+)$/.exec(line);
|
||||
if (!match) throw new Error('Invalid tree entry');
|
||||
const entry = { mode: match[1]!, type: match[2]!, oid: match[3]!, path: match[4]! };
|
||||
return [entry.path, entry];
|
||||
}));
|
||||
}
|
||||
function blob(entry: Entry | undefined): string {
|
||||
if (!entry || entry.type !== 'blob' || entry.mode !== '100644') throw new Error('Expected regular manifest');
|
||||
return git('cat-file', 'blob', entry.oid);
|
||||
}
|
||||
|
||||
/** Compare whole Git trees and exact npm output, never execute anything from the PR. */
|
||||
export function validateBumpTree(base: string, head: string, version: string): number {
|
||||
const before = tree(sha(base));
|
||||
const after = tree(sha(head));
|
||||
if (JSON.parse(blob(before.get('package.json'))).version !== version
|
||||
|| JSON.parse(blob(before.get('apps/packaged/package.json'))).version !== version) {
|
||||
throw new Error('Base versions do not match the published stable');
|
||||
}
|
||||
const next = nextPatch(version);
|
||||
const expected = new Set<string>();
|
||||
for (const [path, entry] of before) {
|
||||
if (!manifestPath.test(path)) continue;
|
||||
const source = JSON.parse(blob(entry)) as Record<string, unknown>;
|
||||
if (source.version !== version) continue;
|
||||
expected.add(path);
|
||||
source.version = next;
|
||||
if (blob(after.get(path)) !== `${JSON.stringify(source, null, 2)}\n`) {
|
||||
throw new Error(`Not an exact version-only bump: ${path}`);
|
||||
}
|
||||
}
|
||||
const changed = [...new Set([...before.keys(), ...after.keys()])].filter(path => {
|
||||
const a = before.get(path), b = after.get(path);
|
||||
return a?.oid !== b?.oid || a?.mode !== b?.mode || a?.type !== b?.type;
|
||||
});
|
||||
if (changed.length !== expected.size || changed.some(path => !expected.has(path))) {
|
||||
throw new Error('Changed tree includes files outside the synchronized version bump');
|
||||
}
|
||||
return expected.size;
|
||||
}
|
||||
|
||||
type Review = { reviewDecision: string; mergeable: string; mergeQueueEntry: { position: number } | null;
|
||||
timelineItems?: { nodes: Array<{ beforeCommit: { oid: string } | null; reason: string | null }> };
|
||||
reviewThreads: { nodes: Array<{ isResolved: boolean }>; pageInfo: { hasNextPage: boolean } };
|
||||
commits: { nodes: Array<{ commit: { statusCheckRollup: { contexts: {
|
||||
nodes: Array<{ name?: string; status?: string; conclusion?: string; state?: string }>;
|
||||
pageInfo: { hasNextPage: boolean };
|
||||
} } | null } }> };
|
||||
};
|
||||
export function validationBlocker(review: Review): string | null {
|
||||
if (review.timelineItems?.nodes.length) return '曾被移出合并队列,需要人工检查失败或移除原因后再入队';
|
||||
if (review.reviewDecision === 'CHANGES_REQUESTED') return '等待处理审查意见';
|
||||
if (review.mergeable !== 'MERGEABLE') return '等待确认合并冲突状态';
|
||||
if (review.reviewThreads.pageInfo.hasNextPage || review.reviewThreads.nodes.some(t => !t.isResolved)) return '等待解决审查讨论';
|
||||
const contexts = review.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
||||
if (!contexts || contexts.pageInfo.hasNextPage) return '检查结果不完整';
|
||||
if (!contexts.nodes.some(c => c.name === 'Validate workspace' && c.status === 'COMPLETED' && c.conclusion === 'SUCCESS')) return '等待 Validate workspace 通过';
|
||||
if (contexts.nodes.some(c => c.state ? c.state !== 'SUCCESS' : c.status !== 'COMPLETED' || !['SUCCESS', 'SKIPPED', 'NEUTRAL'].includes(c.conclusion ?? ''))) return '等待 CI 通过;失败由发布助手诊断,禁止跳过';
|
||||
return null;
|
||||
}
|
||||
function review(number: number): Review {
|
||||
const query = `query { repository(owner:"nexu-io",name:"open-design") { pullRequest(number:${number}) {
|
||||
reviewDecision mergeable mergeQueueEntry { position }
|
||||
timelineItems(last:100,itemTypes:[REMOVED_FROM_MERGE_QUEUE_EVENT]) {
|
||||
nodes { ... on RemovedFromMergeQueueEvent { beforeCommit { oid } reason } }
|
||||
}
|
||||
reviewThreads(first:100) { nodes { isResolved } pageInfo { hasNextPage } }
|
||||
commits(last:1) { nodes { commit { statusCheckRollup { contexts(first:100) {
|
||||
nodes { ... on CheckRun { name status conclusion } ... on StatusContext { state } }
|
||||
pageInfo { hasNextPage }
|
||||
} } } } }
|
||||
} } }`;
|
||||
const result = JSON.parse(command('gh', ['api', 'graphql', '-f', `query=${query}`]));
|
||||
if (result.errors) throw new Error('Could not read review/check state');
|
||||
return result.data.repository.pullRequest;
|
||||
}
|
||||
|
||||
export function followBump(apply: boolean): void {
|
||||
const release = api<{ tag_name: string; draft: boolean; prerelease: boolean }>(`repos/${REPO}/releases/latest`);
|
||||
if (release.draft || release.prerelease || !release.tag_name.startsWith('open-design-v')) throw new Error('No published stable');
|
||||
const version = release.tag_name.slice('open-design-v'.length);
|
||||
const branch = `release-bot/bump-main-v${nextPatch(version)}`;
|
||||
const prs = api<Pull[]>(`repos/${REPO}/pulls?state=open&base=main&head=nexu-io:${encodeURIComponent(branch)}&per_page=100`);
|
||||
if (!prs.length) { console.log('No open bump PR; nothing to do.'); return; }
|
||||
if (prs.length !== 1) throw new Error('Expected one exact bump PR');
|
||||
const pr = api<Pull>(`repos/${REPO}/pulls/${prs[0]!.number}`);
|
||||
if (!eligibleIdentity(pr, version)) throw new Error('Not an eligible release bot bump PR');
|
||||
const state = review(pr.number);
|
||||
if (state.mergeQueueEntry) { console.log(`Already queued at ${state.mergeQueueEntry.position}`); return; }
|
||||
git('fetch', '--no-tags', 'origin', sha(pr.base.sha), sha(pr.head.sha));
|
||||
const base = git('merge-base', sha(pr.base.sha), sha(pr.head.sha)).trim();
|
||||
const count = validateBumpTree(base, pr.head.sha, version);
|
||||
// A different release may already have advanced main since the PR was opened.
|
||||
const current = tree(pr.base.sha);
|
||||
for (const path of ['package.json', 'apps/packaged/package.json']) {
|
||||
if (JSON.parse(blob(current.get(path))).version !== version) throw new Error('Main has already advanced; human reconciliation required');
|
||||
}
|
||||
const blocker = validationBlocker(state);
|
||||
console.log(JSON.stringify({ pr: pr.number, head: pr.head.sha, manifests: count, pureVersionBump: true, blocker, apply }));
|
||||
if (blocker || !apply) return;
|
||||
// Pin both the live policy inputs and the queue mutation to the inspected head.
|
||||
const fresh = api<Pull>(`repos/${REPO}/pulls/${pr.number}`);
|
||||
if (!eligibleIdentity(fresh, version) || fresh.head.sha !== pr.head.sha || fresh.base.sha !== pr.base.sha) throw new Error('PR changed during validation');
|
||||
const latest = api<{ tag_name: string }>(`repos/${REPO}/releases/latest`);
|
||||
if (latest.tag_name !== release.tag_name) throw new Error('Stable advanced during validation');
|
||||
const finalState = review(pr.number);
|
||||
if (finalState.mergeQueueEntry) return;
|
||||
const finalBlocker = validationBlocker(finalState);
|
||||
if (finalBlocker) throw new Error(finalBlocker);
|
||||
const query = 'mutation($id:ID!,$head:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$head,jump:false}){mergeQueueEntry{position}}}';
|
||||
if (!process.env.BUMP_QUEUE_TOKEN) throw new Error('Dedicated release App token is required to enqueue');
|
||||
const queued = JSON.parse(execFileSync('gh', ['api', 'graphql', '-f', `query=${query}`, '-f', `id=${pr.node_id}`, '-f', `head=${pr.head.sha}`], {
|
||||
encoding: 'utf8', timeout: 30_000, env: { ...process.env, GH_TOKEN: process.env.BUMP_QUEUE_TOKEN },
|
||||
}));
|
||||
if (queued.errors || !queued.data?.enqueuePullRequest?.mergeQueueEntry) throw new Error('Enqueue failed; no direct-merge fallback');
|
||||
console.log(JSON.stringify({ pr: pr.number, queued: queued.data.enqueuePullRequest.mergeQueueEntry }));
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) followBump(process.argv.includes('--apply'));
|
||||
Reference in New Issue
Block a user