feat: build runtime releases from pinned Git branch commits

This commit is contained in:
2026-10-14 14:25:27 +08:00
parent 9a71772771
commit 20635ffaec
23 changed files with 999 additions and 31 deletions
+3
View File
@@ -12,6 +12,7 @@ vi.mock('./ui', async original => ({
}));
import { api, type Compatibility } from './api';
import { Images } from './Images';
import { ReleasePanel } from './ReleasePanel';
import { CreateInstance } from './CreateInstance';
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
import { Instances } from './Instances';
@@ -26,6 +27,8 @@ beforeEach(() => {state.slots = []; state.cursor = 0; vi.stubGlobal('window', {c
afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();});
it('hides experimental and admission controls from ordinary users', () => {
const tree = render();
expect(nodes(tree, n => n.type === ReleasePanel)).toHaveLength(0);
expect(nodes(render(true), n => n.type === ReleasePanel)).toHaveLength(1);
expect(nodes(tree, n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
expect(nodes(tree, n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(0);
expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(2);
+4 -3
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from 'react';
import { api, normalBuildAllowed, type AdmissionState, type Build } from './api';
import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui';
import { BuildProvenance, ReleasePanel } from './ReleasePanel';
export const activeBuild = (build: Build) => ['queued', 'running', 'validating'].includes(build.status);
function BuildLogs({id, close}: {id: string; close: () => void}) {
const logs = useResource(() => api.build(id), id, 5000, activeBuild);
@@ -9,7 +10,7 @@ function BuildLogs({id, close}: {id: string; close: () => void}) {
<button onClick={close}>关闭日志</button>
<ResourceState resource={logs}/>{logs.data && <>
<p>{logs.data.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}{logs.data.version} · <Status value={logs.data.status}/>
</p>{logs.data.error && <p className="alert">{logs.data.error}</p>}<pre className="logs">{logs.data.logs || '任务尚未输出日志'}</pre>
</p><BuildProvenance build={logs.data}/>{logs.data.error && <p className="alert">{logs.data.error}</p>}<pre className="logs">{logs.data.logs || '任务尚未输出日志'}</pre>
</>}</div>;
}
function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<void>}) {
@@ -42,7 +43,7 @@ export function Images({admin = false}: {admin?:boolean}) {
function build(e: FormEvent) {e.preventDefault(); void action.run(async () => {if (!(admin && experimental) && !normalBuildAllowed(await api.compatibility(), version)) throw new Error('此版本不支持普通构建,请管理员选择实验构建;实验构建不会自动准入。'); await api.startBuild(version, admin && experimental); if (page === 0) await builds.refresh(); else setPage(0);}, '构建任务已提交,请查看任务的实际状态。', (experimental ? '实验构建不会自动准入或用于创建。确认实验构建精确版本 ' : '确认在本机手动构建精确版本 ') + version + '?构建会消耗磁盘、网络与 CPU,不会自动升级实例。');}
const rows = builds.data?.slice(0, 20);
function changePage(next: number) { setSelected(undefined); setPage(next); }
return <>{action.feedback}<Panel title="查询与手动构建">
return <>{action.feedback}{admin && <ReleasePanel onBuilt={async () => {if (page === 0) await builds.refresh(); else changePage(0);}}/>}<Panel title="查询与手动构建">
<ResourceState resource={compatibility}/>{version && compatibility.data && !normalBuildAllowed(compatibility.data, version) && <p className="alert">此版本不支持普通构建。仅管理员可实验构建,不会自动准入。</p>}<div className="split">
<div>
<p className="muted">从 npm 查询 dist-tags.latest,仅查询,不自动构建。</p>
@@ -104,7 +105,7 @@ export function Images({admin = false}: {admin?:boolean}) {
</thead>
<tbody>{rows!.map(b => <tr key={b.id}>
<td className="mono">{b.id}</td>
<td>{b.version}{b.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}</td>
<td>{b.version}{b.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}<BuildProvenance build={b}/></td>
<td>
<Status value={b.status}/>
</td>
+148
View File
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useRef(value: unknown) {const i = hooks.cursor++; return hooks.slots[i] ??= {current:value};},
useState(value: unknown) {const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => {hooks.slots[i] = v;}];},
useCallback(fn: unknown, deps: unknown[]) {
const i = hooks.cursor++, previous = hooks.slots[i];
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.slots[i] = {deps, fn};
return hooks.slots[i].fn;
},
useEffect(fn: () => (() => void) | undefined, deps: unknown[]) {
const i = hooks.cursor++, previous = hooks.slots[i];
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.effects.push(() => {previous?.cleanup?.(); hooks.slots[i] = {deps, cleanup:fn()};});
},
}));
import { api, ApiError, createApi, type Release } from './api';
import { BuildProvenance, ReleasePanel } from './ReleasePanel';
import { ResourceState } from './ui';
const release: Release = {release_id:'release-1', version:'1.2.3', commit:'abc123', repository:'owner/repo', branch:'main', recipe_rev:'7', platforms:['linux/amd64'], compatible:true, reason:'supported'};
const built = {id:'build-1', version:'1.2.3', status:'queued', created_at:''};
const onBuilt = vi.fn(async () => {});
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
function render() {hooks.cursor = 0; const tree = ReleasePanel({onBuilt}); hooks.effects.splice(0).forEach(effect => effect()); return tree;}
const button = () => nodes(render(), n => n.type === 'button')[0];
const settle = () => vi.advanceTimersByTimeAsync(0);
beforeEach(() => {
hooks.slots = []; hooks.cursor = 0; hooks.effects = []; onBuilt.mockClear();
vi.useFakeTimers(); vi.stubGlobal('window', {setInterval, clearInterval, confirm:vi.fn(() => false)});
vi.spyOn(api, 'latestRelease').mockResolvedValue({...release});
vi.spyOn(api, 'buildRelease').mockResolvedValue(built);
vi.spyOn(api, 'build').mockResolvedValue(built);
});
afterEach(() => {hooks.slots.forEach(s => s?.cleanup?.()); vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals();});
it('checks on mount and every five minutes, supports manual refresh, and never automatically builds', async () => {
render(); await settle(); expect(api.latestRelease).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(299_999); expect(api.latestRelease).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); expect(api.latestRelease).toHaveBeenCalledTimes(2);
await render().props.action.props.onClick(); expect(api.latestRelease).toHaveBeenCalledTimes(3);
expect(api.buildRelease).not.toHaveBeenCalled();
hooks.slots.forEach(s => s?.cleanup?.());
await vi.advanceTimersByTimeAsync(300_000); expect(api.latestRelease).toHaveBeenCalledTimes(3);
});
it('cancellation sends no mutation; confirmed duplicate clicks submit the exact displayed commit once', async () => {
render(); await settle(); await button().props.onClick();
expect(api.buildRelease).not.toHaveBeenCalled();
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining(release.commit));
vi.mocked(window.confirm).mockReturnValue(true);
const submit = button().props.onClick; await Promise.all([submit(), submit()]);
expect(api.buildRelease).toHaveBeenCalledExactlyOnceWith(release.commit);
expect(onBuilt).toHaveBeenCalledOnce();
expect(button().props.disabled).toBe(true);
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build).toEqual(built);
});
it.each(['succeeded', 'failed'])('polls the accepted task to %s when latest omits its build', async status => {
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
await button().props.onClick(); render(); await settle();
expect(api.build).toHaveBeenCalledExactlyOnceWith(built.id);
expect(button().props.disabled).toBe(true);
vi.mocked(api.build).mockResolvedValue({...built, status, error:status === 'failed' ? 'retry failed' : undefined});
await vi.advanceTimersByTimeAsync(4999); expect(api.build).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.status).toBe(status);
expect(button().props.disabled).toBe(false);
if (status === 'failed') expect(JSON.stringify(render())).toContain('retry failed');
await vi.advanceTimersByTimeAsync(10_000); expect(api.build).toHaveBeenCalledTimes(2);
expect(api.latestRelease).toHaveBeenCalledTimes(2);
expect(api.buildRelease).toHaveBeenCalledTimes(1);
});
it('prefers a new retry over an old failed build and ignores earlier same-commit task data', async () => {
const old = {...built, id:'old-build', status:'failed', error:'old failure'};
vi.mocked(api.latestRelease).mockResolvedValue({...release, build:old});
vi.mocked(api.build).mockResolvedValue(old);
render(); await settle(); render(); await settle();
expect(button().props.disabled).toBe(false);
vi.mocked(window.confirm).mockReturnValue(true);
await button().props.onClick();
vi.mocked(api.build).mockResolvedValue({...built, status:'running'});
expect(button().props.disabled).toBe(true);
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.id).toBe(built.id);
await button().props.onClick(); expect(api.buildRelease).toHaveBeenCalledTimes(1);
await settle(); expect(api.build).toHaveBeenLastCalledWith(built.id);
expect(nodes(render(), n => n.props?.role === 'alert')).toHaveLength(0);
vi.mocked(api.latestRelease).mockResolvedValue({...release, build:built});
await render().props.action.props.onClick();
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.status).toBe('running');
vi.mocked(api.build).mockResolvedValue({...built, status:'failed', error:'new failure'});
await vi.advanceTimersByTimeAsync(5000);
expect(button().props.disabled).toBe(false);
expect(JSON.stringify(render())).toContain('new failure');
vi.mocked(api.buildRelease).mockResolvedValue({...built, id:'build-2'});
await button().props.onClick();
expect(button().props.disabled).toBe(true);
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.id).toBe('build-2');
});
it('blocks incompatible releases and shows the server reason', async () => {
vi.mocked(api.latestRelease).mockResolvedValue({...release, compatible:false, reason:'unsupported recipe'});
render(); await settle(); expect(button().props.disabled).toBe(true);
expect(JSON.stringify(render())).toContain('unsupported recipe');
await button().props.onClick(); expect(window.confirm).not.toHaveBeenCalled();
expect(api.buildRelease).not.toHaveBeenCalled();
});
it('blocks stale data after refresh failure and exposes retry feedback', async () => {
render(); await settle(); vi.mocked(api.latestRelease).mockRejectedValue(new Error('offline'));
await render().props.action.props.onClick();
expect(button().props.disabled).toBe(true);
expect(nodes(render(), n => n.type === ResourceState)[0].props.resource.error).toBe('offline');
});
it('409 refreshes the branch without retrying a build, and requires new confirmation', async () => {
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
vi.mocked(api.buildRelease).mockRejectedValueOnce(new ApiError(409, 'changed'));
vi.mocked(api.latestRelease).mockResolvedValue({...release, commit:'def456'});
await button().props.onClick();
expect(api.buildRelease).toHaveBeenCalledTimes(1);
expect(JSON.stringify(render())).toContain('再次明确确认');
vi.mocked(window.confirm).mockReturnValue(false); await button().props.onClick();
expect(window.confirm).toHaveBeenLastCalledWith(expect.stringContaining('def456'));
expect(api.buildRelease).toHaveBeenCalledTimes(1);
vi.mocked(window.confirm).mockReturnValue(true); await button().props.onClick();
expect(api.buildRelease).toHaveBeenLastCalledWith('def456');
});
it('shows build errors and optional provenance without affecting legacy builds', async () => {
const build = {...built, status:'failed', error:'build failed', release_id:release.release_id, source_commit:release.commit, source_repository:release.repository};
vi.mocked(api.latestRelease).mockResolvedValue({...release, build});
render(); await settle(); expect(JSON.stringify(render())).toContain('build failed');
const provenance = JSON.stringify(BuildProvenance({build}));
for (const value of [release.release_id, release.commit, release.repository]) expect(provenance).toContain(value);
expect(BuildProvenance({build:built})).toBeNull();
});
it('surfaces build request failure without refreshing or upgrading', async () => {
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
vi.mocked(api.buildRelease).mockRejectedValue(new ApiError(500, 'builder unavailable'));
await button().props.onClick(); expect(JSON.stringify(render())).toContain('builder unavailable');
expect(onBuilt).not.toHaveBeenCalled(); expect(api.latestRelease).toHaveBeenCalledTimes(1);
});
it('uses the assigned endpoints and sends only the pinned commit', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(new Response(JSON.stringify(release))).mockResolvedValueOnce(new Response(JSON.stringify(built), {status:202}));
const client = createApi(fetcher);
expect(await client.latestRelease()).toEqual(release);
expect(await client.buildRelease(release.commit)).toEqual(built);
expect(fetcher.mock.calls[0][0]).toBe('/api/releases/latest');
expect(fetcher.mock.calls[1][0]).toBe('/api/releases/build');
expect(fetcher.mock.calls[1][1]).toMatchObject({method:'POST', body:JSON.stringify({commit:release.commit})});
});
+61
View File
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { api, ApiError, type Build } from './api';
import { Panel, ResourceState, Status, useAction, useResource } from './ui';
export function BuildProvenance({build}: {build: Build}) {
if (!build.release_id && !build.source_commit && !build.source_repository) return null;
return <div className="mono wrap">
{build.release_id && <p>发布标识:{build.release_id}</p>}
{build.source_repository && <p>源码仓库:{build.source_repository}</p>}
{build.source_commit && <p>源码提交:{build.source_commit}</p>}
</div>;
}
export function ReleasePanel({onBuilt}: {onBuilt: () => Promise<void>}) {
const release = useResource(api.latestRelease, '', 300_000);
const action = useAction();
const [submitted, setSubmitted] = useState<{commit: string; build: Build}>();
const current = release.data;
const accepted = submitted?.commit === current?.commit ? submitted?.build : undefined;
const associated = accepted || current?.build;
const task = useResource(() => associated ? api.build(associated.id) : Promise.resolve(undefined), associated?.id, associated ? 5000 : 0,
value => !!value && ['queued', 'running', 'validating'].includes(value.status));
const build = task.data?.id === associated?.id ? task.data : associated;
const active = build && ['queued', 'running', 'validating'].includes(build.status);
const disabled = action.busy || release.loading || !!release.error || !current?.compatible || !!active;
async function submit() {
if (disabled || !current) return;
await action.run(async () => {
let result: Build;
try { result = await api.buildRelease(current.commit); }
catch (error) {
if (error instanceof ApiError && error.status === 409) {
await release.refresh();
throw new Error('发布分支已变化,已重新查询。请核对最新提交并再次明确确认构建。');
}
throw error;
}
setSubmitted({commit: current.commit, build: result});
await Promise.all([release.refresh(), onBuilt()]);
}, '源码发布构建任务已提交,不会自动升级实例。',
`确认手动构建源码发布 ${current.version}?\n仓库:${current.repository}\n分支:${current.branch}\n提交:${current.commit}\n部署协议:${current.recipe_rev}\n平台:${current.platforms.join(', ')}\n构建会消耗磁盘、网络与 CPU;不会自动升级实例,构建成功不等于通过验收。`);
}
return <Panel title="源码发布(管理员)" action={<button disabled={release.loading || action.busy} onClick={release.refresh}>刷新源码发布</button>}>
<p className="muted">进入页面时查询,此后每 5 分钟检查一次。仅检查,不自动构建或升级;与 npm latest / 实验构建相互独立。</p>
<ResourceState resource={release}/>{associated && <ResourceState resource={task}/>} {action.feedback}
{current && <>
<h3>{current.version}</h3>
<div className="mono wrap">
<p>发布标识:{current.release_id}</p>
<p>源码仓库:{current.repository}</p>
<p>分支:{current.branch}</p>
<p>源码提交:{current.commit}</p>
</div>
<p>部署协议:{current.recipe_rev} · 平台:{current.platforms.join(', ')}</p>
<p className={current.compatible ? 'callout' : 'alert'}>兼容性:{current.compatible ? '兼容,可手动构建' : '不兼容,禁止构建'} · {current.reason}</p>
{build && <div><p>关联构建:{build.id} · <Status value={build.status}/></p><BuildProvenance build={build}/>{build.error && <p className="alert" role="alert">{build.error}</p>}</div>}
<button className="primary" disabled={disabled} onClick={submit}>手动构建此源码发布</button>
</>}
<p className="callout">构建和启动验证通过后可用于实例;发布者仍需完成上线验收,实例升级须另行确认。</p>
</Panel>;
}
+4 -1
View File
@@ -6,7 +6,8 @@ export interface Session { user: User; csrf_token: string }
export interface Instance { id: string; name: string; status: string; image_id: string; version: string; created_at: string }
export type AdmissionState = 'recommended' | 'compatible' | 'existing_only' | 'blocked';
export interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; admission?: string; admission_reason?: string; selectable?: boolean; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string }
export interface Build { id: string; version: string; status: string; experimental?: boolean; image_id?: string; logs?: string; error?: string; created_at: string }
export interface Build { id: string; version: string; status: string; experimental?: boolean; image_id?: string; logs?: string; error?: string; created_at: string; release_id?: string; source_commit?: string; source_repository?: string }
export interface Release { release_id: string; version: string; commit: string; repository: string; branch: string; recipe_rev: string; platforms: string[]; compatible: boolean; reason: string; build?: Build }
export const selectableImage = (image: Image) => image.status === 'available' && image.selectable === true;
export interface CompatibilityRule { state: string; build_allowed: boolean; reason: string }
export interface Compatibility { recipe_rev: string; platform: string; entries: (CompatibilityRule & {version:string; recipe_rev:string; platform:string})[]; unknown: CompatibilityRule; experimental_admin_only: boolean }
@@ -84,6 +85,8 @@ export function createApi(fetcher: typeof fetch = fetch) {
instances: () => request<Instance[]>('/instances'),
images: () => request<Image[]>('/images'),
compatibility: () => request<Compatibility>('/compatibility'),
latestRelease: () => request<Release>('/releases/latest'),
buildRelease: (commit: string) => request<Build>('/releases/build', 'POST', {commit}),
startBuild: (version: string, experimental = false) => request<Build>('/builds', 'POST', {version, experimental}),
admitImage: (image_id: string, state: AdmissionState, evidence: string) => request('/images/admission', 'POST', {image_id, state, evidence}),
builds: (limit = 20, offset = 0) => request<Build[]>(`/builds?limit=${limit}&offset=${offset}`),