feat(release): 정적 프런트엔드와 격리 빌더를 도입한다

Gateway와 프로필 릴리스가 불변 프런트엔드 아티팩트를 원자적으로 게시하고 실패 시 이전 포인터를 복구하도록 한다.

릴리스 빌드는 전용 builder protocol로 분리하고 성공한 Gateway 커밋을 재기동용 Git ref에 고정한다.
This commit is contained in:
2026-08-22 09:32:47 +00:00
parent d74a8e48bb
commit d3e5d12cbf
18 changed files with 991 additions and 48 deletions
@@ -33,6 +33,22 @@ export interface BuildRunner {
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
const RELEASE_BUILD_ENV_NAME = /^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
export const sanitizeReleaseBuildEnv = (
env: NodeJS.ProcessEnv | Record<string, string> | undefined
): Record<string, string> => {
const sanitized = Object.fromEntries(
Object.entries(env ?? {}).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string' && RELEASE_BUILD_ENV_NAME.test(entry[0])
)
);
if (sanitized.RELEASE_BUILD_NODE_OPTIONS) {
sanitized.NODE_OPTIONS = sanitized.RELEASE_BUILD_NODE_OPTIONS;
delete sanitized.RELEASE_BUILD_NODE_OPTIONS;
}
return sanitized;
};
export const resolveReleaseTurboConcurrency = (env?: Record<string, string>): number => {
const configured = env?.RELEASE_TURBO_CONCURRENCY?.trim();
@@ -213,3 +229,100 @@ export class PnpmBuildRunner implements BuildRunner {
};
}
}
interface RemoteBuildMessage {
event?: BuildProgressEvent;
result?: BuildResult;
error?: string;
}
export class RemoteBuildRunner implements BuildRunner {
private readonly endpoint: string;
constructor(baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {
this.endpoint = new URL('/v1/builds', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
}
async run(
commands: BuildCommand[],
onProgress?: BuildProgressObserver,
options?: BuildRunOptions
): Promise<BuildResult> {
let output = '';
try {
const response = await this.fetchImpl(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
commands: commands.map((command) => ({
...command,
env: sanitizeReleaseBuildEnv(command.env),
})),
}),
signal: options?.signal,
});
if (!response.ok || !response.body) {
const detail = await response.text().catch(() => '');
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, detail || `Release builder returned HTTP ${response.status}.`),
};
}
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') {
output = appendOutputTail(output, `${result.event.message}\n`);
}
if (result.result) return result.result;
if (result.error) {
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
}
}
if (buffer.trim()) {
const result = await this.handleRemoteMessage(buffer, onProgress);
if (result.result) return result.result;
if (result.error) return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
return { ok: false, exitCode: null, output: appendOutputTail(output, 'Release builder closed without a result.') };
} catch (error) {
const aborted = options?.signal?.aborted ?? false;
return {
ok: false,
exitCode: null,
output: appendOutputTail(
output,
aborted ? 'Build cancelled by operator.' : error instanceof Error ? error.message : String(error)
),
...(aborted ? { aborted: true } : {}),
};
}
}
private async handleRemoteMessage(
line: string,
onProgress?: BuildProgressObserver
): Promise<RemoteBuildMessage> {
let message: RemoteBuildMessage;
try {
message = JSON.parse(line) as RemoteBuildMessage;
} catch {
return { error: 'Release builder returned malformed progress data.' };
}
if (message.event && onProgress) await onProgress(message.event);
return message;
}
}
export const createReleaseBuildRunner = (
baseUrl: string | undefined,
localRunner: BuildRunner,
fetchImpl: typeof fetch = fetch
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl) : localRunner);
@@ -0,0 +1,241 @@
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
export type FrontendServeMode = 'preview' | 'static';
export interface FrontendArtifactManifest {
version: 1;
frontendKey: string;
commitSha: string;
digest: string;
releaseId: string;
files: number;
}
export interface StagedFrontendArtifact {
releaseId: string;
releasePath: string;
manifest: FrontendArtifactManifest;
}
const MANIFEST_FILE = '.sammo-artifact.json';
const FRONTEND_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/u;
const COMMIT_SHA = /^[0-9a-f]{40,64}$/iu;
export const resolveFrontendServeMode = (value: string | undefined): FrontendServeMode => {
const normalized = value?.trim().toLowerCase();
if (!normalized || normalized === 'preview') return 'preview';
if (normalized === 'static') return 'static';
throw new Error('FRONTEND_SERVE_MODE must be preview or static.');
};
const assertFrontendKey = (value: string): void => {
if (!FRONTEND_KEY.test(value)) throw new Error(`Invalid frontend artifact key: ${value}`);
};
const assertCommitSha = (value: string): void => {
if (!COMMIT_SHA.test(value)) throw new Error('Frontend artifact commit SHA must be a full hexadecimal SHA.');
};
const listSourceFiles = async (sourceRoot: string): Promise<string[]> => {
const rootStat = await fs.lstat(sourceRoot);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
throw new Error(`Frontend artifact source must be a real directory: ${sourceRoot}`);
}
const files: string[] = [];
const visit = async (relativeDirectory: string): Promise<void> => {
const directory = path.join(sourceRoot, relativeDirectory);
const entries = await fs.readdir(directory, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const relativePath = path.join(relativeDirectory, entry.name);
const absolutePath = path.join(sourceRoot, relativePath);
const stat = await fs.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error(`Frontend artifact source contains a symbolic link: ${relativePath}`);
}
if (stat.isDirectory()) {
await visit(relativePath);
continue;
}
if (!stat.isFile()) {
throw new Error(`Frontend artifact source contains an unsupported entry: ${relativePath}`);
}
files.push(relativePath);
}
};
await visit('');
if (!files.includes('index.html')) {
throw new Error(`Frontend artifact source is missing index.html: ${sourceRoot}`);
}
return files;
};
const buildDigest = async (sourceRoot: string, files: string[]): Promise<string> => {
const hash = createHash('sha256');
for (const relativePath of files) {
hash.update(relativePath.split(path.sep).join('/'));
hash.update('\0');
hash.update(await fs.readFile(path.join(sourceRoot, relativePath)));
hash.update('\0');
}
return hash.digest('hex');
};
const readManifest = async (releasePath: string): Promise<FrontendArtifactManifest> => {
const raw = JSON.parse(await fs.readFile(path.join(releasePath, MANIFEST_FILE), 'utf8')) as Partial<FrontendArtifactManifest>;
if (
raw.version !== 1 ||
typeof raw.frontendKey !== 'string' ||
typeof raw.commitSha !== 'string' ||
typeof raw.digest !== 'string' ||
typeof raw.releaseId !== 'string' ||
typeof raw.files !== 'number'
) {
throw new Error(`Invalid frontend artifact manifest: ${releasePath}`);
}
await fs.access(path.join(releasePath, 'index.html'));
return raw as FrontendArtifactManifest;
};
const isMissing = (error: unknown): boolean =>
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT';
export class FrontendArtifactManager {
readonly root: string;
constructor(root: string) {
this.root = path.resolve(root);
}
private frontendRoot(frontendKey: string): string {
assertFrontendKey(frontendKey);
return path.join(this.root, frontendKey);
}
private releasePath(frontendKey: string, releaseId: string): string {
if (!/^[0-9a-f]{40,64}-[0-9a-f]{16}$/iu.test(releaseId)) {
throw new Error(`Invalid frontend artifact release id: ${releaseId}`);
}
return path.join(this.frontendRoot(frontendKey), 'releases', releaseId);
}
async stage(options: {
frontendKey: string;
sourceRoot: string;
commitSha: string;
}): Promise<StagedFrontendArtifact> {
assertFrontendKey(options.frontendKey);
assertCommitSha(options.commitSha);
const commitSha = options.commitSha.toLowerCase();
const sourceRoot = path.resolve(options.sourceRoot);
const files = await listSourceFiles(sourceRoot);
const digest = await buildDigest(sourceRoot, files);
const releaseId = `${commitSha}-${digest.slice(0, 16)}`;
const releasePath = this.releasePath(options.frontendKey, releaseId);
const manifest: FrontendArtifactManifest = {
version: 1,
frontendKey: options.frontendKey,
commitSha,
digest,
releaseId,
files: files.length,
};
try {
const existing = await readManifest(releasePath);
if (existing.digest !== digest || existing.commitSha !== commitSha) {
throw new Error(`Frontend artifact release collision: ${releaseId}`);
}
return { releaseId, releasePath, manifest: existing };
} catch (error) {
if (!isMissing(error)) throw error;
}
const releasesRoot = path.dirname(releasePath);
await fs.mkdir(releasesRoot, { recursive: true, mode: 0o755 });
const stagingPath = path.join(releasesRoot, `.staging-${randomUUID()}`);
await fs.mkdir(stagingPath, { mode: 0o755 });
try {
for (const relativePath of files) {
const targetPath = path.join(stagingPath, relativePath);
await fs.mkdir(path.dirname(targetPath), { recursive: true, mode: 0o755 });
await fs.copyFile(path.join(sourceRoot, relativePath), targetPath);
await fs.chmod(targetPath, 0o644);
}
await fs.writeFile(path.join(stagingPath, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o644,
});
try {
await fs.rename(stagingPath, releasePath);
} catch (error) {
if (!isMissing(error) && error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST') {
const existing = await readManifest(releasePath);
if (existing.digest !== digest || existing.commitSha !== commitSha) throw error;
} else {
throw error;
}
}
} finally {
await fs.rm(stagingPath, { recursive: true, force: true });
}
return { releaseId, releasePath, manifest };
}
async readCurrentReleaseId(frontendKey: string): Promise<string | null> {
const frontendRoot = this.frontendRoot(frontendKey);
try {
const target = await fs.readlink(path.join(frontendRoot, 'current'));
const normalized = target.split(path.sep).join('/');
const match = /^releases\/([0-9a-f]{40,64}-[0-9a-f]{16})$/iu.exec(normalized);
if (!match) throw new Error(`Invalid current frontend artifact pointer for ${frontendKey}.`);
await readManifest(this.releasePath(frontendKey, match[1]));
return match[1];
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
}
async activate(frontendKey: string, releaseId: string): Promise<string | null> {
const frontendRoot = this.frontendRoot(frontendKey);
const releasePath = this.releasePath(frontendKey, releaseId);
const manifest = await readManifest(releasePath);
if (manifest.frontendKey !== frontendKey || manifest.releaseId !== releaseId) {
throw new Error(`Frontend artifact manifest does not match ${frontendKey}/${releaseId}.`);
}
await fs.mkdir(frontendRoot, { recursive: true, mode: 0o755 });
const previousReleaseId = await this.readCurrentReleaseId(frontendKey);
const replacePointer = async (name: 'current' | 'previous', targetReleaseId: string): Promise<void> => {
const temporary = path.join(frontendRoot, `.${name}-${randomUUID()}`);
await fs.symlink(path.join('releases', targetReleaseId), temporary);
try {
await fs.rename(temporary, path.join(frontendRoot, name));
} finally {
await fs.rm(temporary, { force: true });
}
};
if (previousReleaseId && previousReleaseId !== releaseId) {
await replacePointer('previous', previousReleaseId);
}
await replacePointer('current', releaseId);
return previousReleaseId;
}
async stageAndActivate(options: {
frontendKey: string;
sourceRoot: string;
commitSha: string;
}): Promise<StagedFrontendArtifact & { previousReleaseId: string | null }> {
const staged = await this.stage(options);
const previousReleaseId = await this.activate(options.frontendKey, staged.releaseId);
return { ...staged, previousReleaseId };
}
async deactivate(frontendKey: string): Promise<string | null> {
const releaseId = await this.readCurrentReleaseId(frontendKey);
await fs.rm(path.join(this.frontendRoot(frontendKey), 'current'), { force: true });
return releaseId;
}
}
@@ -30,6 +30,8 @@ import {
type BuildProgressEvent,
type BuildProgressObserver,
type BuildRunner,
createReleaseBuildRunner,
sanitizeReleaseBuildEnv,
} from './buildRunner.js';
import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js';
import type {
@@ -51,12 +53,21 @@ import {
} from './workspaceManager.js';
import type { AdminSeedUser } from './seedProfileDatabase.js';
import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js';
import {
FrontendArtifactManager,
resolveFrontendServeMode,
type FrontendServeMode,
} from './frontendArtifactManager.js';
export interface GatewayProcessConfig {
workspaceRoot: string;
redisKeyPrefix: string;
gameTokenSecret: string;
gatewayInternalApiUrl: string;
frontendServeMode?: FrontendServeMode;
frontendArtifactRoot?: string;
frontendReadinessOrigin?: string;
releaseBuilderUrl?: string;
baseEnv?: Record<string, string>;
}
@@ -567,14 +578,14 @@ export const buildProfileFrontendCommands = (
throw new Error('Profile frontend build requires a full commit SHA.');
}
const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim();
const buildEnv = {
const buildEnv = sanitizeReleaseBuildEnv({
...(env ?? {}),
...(profileFrontendBuildNodeOptions ? { NODE_OPTIONS: profileFrontendBuildNodeOptions } : {}),
VITE_APP_BASE_PATH: `/${profile.profile}`,
VITE_GAME_API_URL: `/${profile.profile}/api/trpc`,
VITE_GAME_SSE_URL: `/${profile.profile}/api/events`,
VITE_BUILD_COMMIT_SHA: buildCommitSha.trim().toLowerCase(),
};
});
return [
buildTurboReleaseTaskCommand(
workspaceRoot,
@@ -599,16 +610,17 @@ export const buildWorkspaceCommands = (
cacheAnchorRoot: string = workspaceRoot,
packageNames: string[] = ['@sammo-ts/game-api', '@sammo-ts/gateway-api']
): BuildCommand[] => {
const buildEnv = sanitizeReleaseBuildEnv(env);
const commands: BuildCommand[] = [];
if (needsInstall) {
commands.push({
command: 'pnpm',
args: ['install', '--frozen-lockfile'],
cwd: workspaceRoot,
env,
env: buildEnv,
});
}
commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, env));
commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, buildEnv));
return commands;
};
@@ -646,8 +658,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private readonly repository: GatewayProfileRepository;
private readonly processManager: ProcessManager;
private readonly buildRunner: BuildRunner;
private readonly releaseBuildRunner: BuildRunner;
private readonly workspaceManager: GitWorkspaceManager;
private readonly processConfig: GatewayProcessConfig;
private readonly frontendServeMode: FrontendServeMode;
private readonly artifactManager: FrontendArtifactManager;
private readonly reconcileIntervalMs: number;
private readonly scheduleIntervalMs: number;
private readonly buildIntervalMs: number;
@@ -679,8 +694,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.repository = options.repository;
this.processManager = options.processManager;
this.buildRunner = options.buildRunner;
this.releaseBuildRunner = createReleaseBuildRunner(
options.processConfig.releaseBuilderUrl,
options.buildRunner,
options.fetchImpl ?? fetch
);
this.workspaceManager = options.workspaceManager;
this.processConfig = options.processConfig;
this.frontendServeMode = resolveFrontendServeMode(options.processConfig.frontendServeMode);
this.artifactManager = new FrontendArtifactManager(
options.processConfig.frontendArtifactRoot ?? '/srv/frontend-artifacts'
);
this.reconcileIntervalMs = options.reconcileIntervalMs;
this.scheduleIntervalMs = options.scheduleIntervalMs;
this.buildIntervalMs = options.buildIntervalMs;
@@ -802,7 +826,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
async listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]> {
const processStates = await this.loadProcessStatusMap();
return mapRuntimeStates(profileNames, processStates);
const snapshots = mapRuntimeStates(profileNames, processStates);
if (this.frontendServeMode === 'preview') return snapshots;
await Promise.all(
snapshots.map(async (snapshot) => {
const profile = await this.repository.getProfile(snapshot.profileName);
snapshot.frontendRunning = profile
? (await this.artifactManager.readCurrentReleaseId(profile.profile)) !== null
: false;
})
);
return snapshots;
}
async listRuntimeSettings(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]> {
@@ -885,6 +919,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
continue;
}
const runtime = mapRuntimeStates([profile.profileName], processStates)[0];
if (this.frontendServeMode === 'static') {
runtime.frontendRunning =
(await this.artifactManager.readCurrentReleaseId(profile.profile)) !== null;
}
const plan = planProfileReconcile(profile.status, runtime);
if (plan.shouldStart) {
await this.startProfile(profile);
@@ -1521,7 +1559,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
),
];
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
const result = await this.buildRunner.run(commands, this.buildProgress(operationId, 'build'), {
const result = await this.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), {
signal: this.activeOperationAbortSignal,
});
if (!result.ok) {
@@ -1933,6 +1971,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
currentScenario: String(scenarioId),
status: desiredStatus,
buildStatus: 'SUCCEEDED',
buildCommitSha: commitSha,
buildWorkspace: workspace.root,
buildLastUsedAt: completedAt,
buildCompletedAt: completedAt,
@@ -1970,6 +2009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
currentScenario: String(scenarioId),
scenario: String(scenarioId),
status: desiredStatus,
buildCommitSha: commitSha,
buildWorkspace: workspace.root,
};
await appendLog('switch', '초기화된 profile process를 시작합니다.');
@@ -2119,7 +2159,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
);
}
return {
result: await this.buildRunner.run(
result: await this.releaseBuildRunner.run(
commands,
operationId ? this.buildProgress(operationId, 'build') : undefined,
{ signal: this.activeOperationAbortSignal }
@@ -2281,7 +2321,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async startProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<boolean> {
const definitions = buildProcessDefinitions(profile, this.processConfig);
const orderedDefinitions = [
definitions.frontend,
...(this.frontendServeMode === 'preview' ? [definitions.frontend] : []),
definitions.api,
definitions.daemon,
definitions.auction,
@@ -2290,10 +2330,26 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
];
const attemptedNames: string[] = [];
try {
const stagedArtifact =
this.frontendServeMode === 'static'
? await (async () => {
if (!profile.buildCommitSha) {
throw new Error(`Profile ${profile.profileName} is missing the build commit SHA.`);
}
const runtimeWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
return this.artifactManager.stage({
frontendKey: profile.profile,
sourceRoot: buildProfileFrontendOutDir(runtimeWorkspace, profile.profileName),
commitSha: profile.buildCommitSha,
});
})()
: null;
const expectedNames = new Set(orderedDefinitions.map((definition) => definition.name));
const obsoleteNames =
this.frontendServeMode === 'static' ? new Set([definitions.frontend.name]) : new Set<string>();
const existingNames = new Set(
(await this.processManager.list())
.filter((process) => expectedNames.has(process.name))
.filter((process) => expectedNames.has(process.name) || obsoleteNames.has(process.name))
.map((process) => process.name)
);
for (const name of existingNames) {
@@ -2307,6 +2363,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.processManager.start(definition);
await assertLease?.();
}
if (stagedArtifact) {
await this.artifactManager.activate(profile.profile, stagedArtifact.releaseId);
}
if (!assertLease) {
await this.repository.updateLastError(profile.profileName, null);
}
@@ -2341,9 +2400,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
): Promise<boolean> {
const deadline = Date.now() + this.profileReadinessTimeoutMs;
const definitions = buildProcessDefinitions(profile, this.processConfig);
const expectedNames = Object.values(definitions).map((definition) => definition.name);
const expectedNames = Object.entries(definitions)
.filter(([role]) => this.frontendServeMode === 'preview' || role !== 'frontend')
.map(([, definition]) => definition.name);
const apiUrl = `http://127.0.0.1:${profile.apiPort}/healthz`;
const frontendUrl = `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`;
const frontendUrl =
this.frontendServeMode === 'static'
? new URL(
`/${profile.profile}/`,
this.processConfig.frontendReadinessOrigin ?? 'http://caddy'
).toString()
: `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`;
while (Date.now() < deadline) {
await assertLease?.();
try {
@@ -2381,6 +2448,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament');
await assertLease?.();
if (this.frontendServeMode === 'static') {
await this.artifactManager.deactivate(profile.profile);
}
await assertLease?.();
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
await assertLease?.();
const failures: string[] = [];
@@ -41,6 +41,10 @@ export const createGatewayOrchestrator = (
redisKeyPrefix: config.redisKeyPrefix,
gameTokenSecret: config.gameTokenSecret,
gatewayInternalApiUrl: config.gatewayInternalApiUrl,
frontendServeMode: config.frontendServeMode,
frontendArtifactRoot: config.frontendArtifactRoot,
frontendReadinessOrigin: config.frontendReadinessOrigin,
releaseBuilderUrl: config.releaseBuilderUrl,
baseEnv,
},
reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
@@ -66,6 +66,8 @@ const ensureDir = (dir: string): void => {
const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/;
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
const PERSISTENT_RELEASE_REF_PATTERN = /^refs\/sammo\/[a-z0-9][a-z0-9._/-]{0,127}$/u;
const NULL_COMMIT_SHA = '0'.repeat(40);
const assertGitRef = (value: string): string => {
const ref = value.trim();
@@ -123,6 +125,47 @@ export class GitWorkspaceManager {
throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`);
}
async readPersistentReleaseRef(ref: string): Promise<string | null> {
if (!PERSISTENT_RELEASE_REF_PATTERN.test(ref)) {
throw new Error('Persistent release ref must be below refs/sammo/.');
}
const result = await runGit(['rev-parse', '--verify', `${ref}^{commit}`], this.repoRoot, this.baseEnv);
if (!result.ok) return null;
const commitSha = result.output.trim().split('\n')[0];
if (!COMMIT_SHA_PATTERN.test(commitSha)) {
throw new Error(`Persistent release ref does not resolve to a commit: ${ref}`);
}
return commitSha.toLowerCase();
}
async compareAndSwapPersistentReleaseRef(
ref: string,
expectedCommitSha: string | null,
nextCommitSha: string | null
): Promise<void> {
if (!PERSISTENT_RELEASE_REF_PATTERN.test(ref)) {
throw new Error('Persistent release ref must be below refs/sammo/.');
}
for (const commitSha of [expectedCommitSha, nextCommitSha]) {
if (commitSha !== null && !COMMIT_SHA_PATTERN.test(commitSha)) {
throw new Error('Persistent release ref commit must be a full SHA.');
}
}
if (nextCommitSha) {
const exists = await runGit(['cat-file', '-e', `${nextCommitSha}^{commit}`], this.repoRoot, this.baseEnv);
if (!exists.ok) throw new Error(`Persistent release commit is unavailable: ${nextCommitSha}`);
}
const args = nextCommitSha
? ['update-ref', ref, nextCommitSha, expectedCommitSha ?? NULL_COMMIT_SHA]
: ['update-ref', '-d', ref, expectedCommitSha ?? NULL_COMMIT_SHA];
const updated = await runGit(args, this.repoRoot, this.baseEnv);
if (!updated.ok) {
throw new Error(
`Failed to update persistent release ref ${ref}${updated.output ? `: ${updated.output.trim()}` : ''}`
);
}
}
async prepare(commitSha: string): Promise<WorkspaceInfo> {
if (!COMMIT_SHA_PATTERN.test(commitSha)) {
throw new Error('Invalid commit SHA.');