Merge remote-tracking branch 'origin/main' into feature/ref-progress-bars-20260810

This commit is contained in:
2026-08-11 00:40:42 +00:00
7 changed files with 67 additions and 11 deletions
@@ -31,7 +31,10 @@ const assertMigrationHead = async (workspaceRoot: string, directory: string, exp
} }
}; };
export const readReleaseManifest = async (workspaceRoot: string): Promise<ReleaseManifest> => { export const readReleaseManifest = async (
workspaceRoot: string,
options: { allowControllerUpgrade?: boolean } = {}
): Promise<ReleaseManifest> => {
const manifestPath = path.join(workspaceRoot, 'release-manifest.json'); const manifestPath = path.join(workspaceRoot, 'release-manifest.json');
const parsed = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as unknown; const parsed = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as unknown;
if ( if (
@@ -46,7 +49,7 @@ export const readReleaseManifest = async (workspaceRoot: string): Promise<Releas
) { ) {
throw new Error(`Invalid release manifest: ${manifestPath}`); throw new Error(`Invalid release manifest: ${manifestPath}`);
} }
if (parsed.controllerProtocol > RELEASE_CONTROLLER_PROTOCOL) { if (parsed.controllerProtocol > RELEASE_CONTROLLER_PROTOCOL && !options.allowControllerUpgrade) {
throw new Error( throw new Error(
`Release requires controller protocol ${parsed.controllerProtocol}; this controller supports ${RELEASE_CONTROLLER_PROTOCOL}.` `Release requires controller protocol ${parsed.controllerProtocol}; this controller supports ${RELEASE_CONTROLLER_PROTOCOL}.`
); );
@@ -77,12 +77,25 @@ export class GitWorkspaceManager {
sourceMode === 'BRANCH' sourceMode === 'BRANCH'
? [`refs/remotes/origin/${ref}^{commit}`, `refs/heads/${ref}^{commit}`] ? [`refs/remotes/origin/${ref}^{commit}`, `refs/heads/${ref}^{commit}`]
: [`${ref}^{commit}`]; : [`${ref}^{commit}`];
for (const candidate of candidates) { const resolveCandidates = async (): Promise<string | undefined> => {
const result = await runGit(['rev-parse', '--verify', candidate], this.repoRoot, this.baseEnv); for (const candidate of candidates) {
const commitSha = result.output.trim().split('\n')[0]; const result = await runGit(['rev-parse', '--verify', candidate], this.repoRoot, this.baseEnv);
if (result.ok && /^[0-9a-f]{40}$/i.test(commitSha)) { const commitSha = result.output.trim().split('\n')[0];
return commitSha; if (result.ok && /^[0-9a-f]{40}$/i.test(commitSha)) {
return commitSha;
}
} }
return undefined;
};
const localCommit = await resolveCandidates();
if (localCommit) return localCommit;
if (sourceMode === 'COMMIT') {
const fetched = await runGit(['fetch', '--all', '--tags'], this.repoRoot, this.baseEnv);
if (!fetched.ok) {
throw new Error(fetched.output || 'Failed to fetch git commits.');
}
const fetchedCommit = await resolveCandidates();
if (fetchedCommit) return fetchedCommit;
} }
throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`); throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`);
} }
+18 -2
View File
@@ -8,7 +8,7 @@ import { readReleaseManifest, RELEASE_CONTROLLER_PROTOCOL } from '../src/orchest
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
const createWorkspace = async (gatewayHead: string, gameHead: string): Promise<string> => { const createWorkspace = async (gatewayHead: string, gameHead: string, controllerProtocol = 1): Promise<string> => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-release-manifest-')); const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-release-manifest-'));
temporaryDirectories.push(workspace); temporaryDirectories.push(workspace);
await fs.mkdir(path.join(workspace, 'packages/infra/prisma/gateway-migrations', gatewayHead), { await fs.mkdir(path.join(workspace, 'packages/infra/prisma/gateway-migrations', gatewayHead), {
@@ -19,7 +19,7 @@ const createWorkspace = async (gatewayHead: string, gameHead: string): Promise<s
path.join(workspace, 'release-manifest.json'), path.join(workspace, 'release-manifest.json'),
JSON.stringify({ JSON.stringify({
formatVersion: 1, formatVersion: 1,
controllerProtocol: 1, controllerProtocol,
gatewaySchemaHead: gatewayHead, gatewaySchemaHead: gatewayHead,
gameSchemaHead: gameHead, gameSchemaHead: gameHead,
components: ['gateway-api', 'gateway-frontend', 'game-api', 'game-engine', 'game-frontend'], components: ['gateway-api', 'gateway-frontend', 'game-api', 'game-engine', 'game-frontend'],
@@ -58,4 +58,20 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspace)).rejects.toThrow('does not match workspace head'); await expect(readReleaseManifest(workspace)).rejects.toThrow('does not match workspace head');
}); });
it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
const workspace = await createWorkspace(
'20260801000000_gateway',
'20260801000000_game',
futureProtocol
);
await expect(readReleaseManifest(workspace)).rejects.toThrow(
`Release requires controller protocol ${futureProtocol}`
);
await expect(readReleaseManifest(workspace, { allowControllerUpgrade: true })).resolves.toMatchObject({
controllerProtocol: futureProtocol,
});
});
}); });
@@ -75,6 +75,23 @@ describe('GitWorkspaceManager source resolution', () => {
expect(await manager.resolveCommit('BRANCH', 'main')).toBe(secondCommit); expect(await manager.resolveCommit('BRANCH', 'main')).toBe(secondCommit);
}); });
it('fetches a remote commit that is not present in the controller checkout yet', async () => {
const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({
repoRoot: fixture.checkout,
worktreeRoot: fixture.worktrees,
});
fs.writeFileSync(path.join(fixture.source, 'version.txt'), 'remote-only\n');
git(fixture.source, 'add', 'version.txt');
git(fixture.source, 'commit', '-m', 'remote only');
const remoteCommit = git(fixture.source, 'rev-parse', 'HEAD');
git(fixture.source, 'push', 'origin', 'main');
expect(() => git(fixture.checkout, 'cat-file', '-e', `${remoteCommit}^{commit}`)).toThrow();
await expect(manager.resolveCommit('COMMIT', remoteCommit)).resolves.toBe(remoteCommit);
});
it('rejects option-like and range refs', async () => { it('rejects option-like and range refs', async () => {
const fixture = createRepositoryFixture(); const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({ const manager = new GitWorkspaceManager({
+3 -1
View File
@@ -86,4 +86,6 @@ rollback하려면 새 schema와의 하위 호환성을 릴리스 전에 확인
`release-manifest.json``controllerProtocol`이 올라간 릴리스는 controller를 `release-manifest.json``controllerProtocol`이 올라간 릴리스는 controller를
먼저 self-upgrade해야 합니다. Protocol 2는 `GatewayReleaseLog` 진행 로그 저장을 먼저 self-upgrade해야 합니다. Protocol 2는 `GatewayReleaseLog` 진행 로그 저장을
요구합니다. 구형 controller로 새 Gateway만 배포하면 관리자 화면과 controller의 요구합니다. 구형 controller로 새 Gateway만 배포하면 관리자 화면과 controller의
기능이 어긋날 수 있으므로, manifest protocol 검사를 우회하지 마세요. 기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지
마세요. Self-upgrade CLI만 다음 protocol을 허용하며 schema head와 component는
동일하게 검증합니다.
+4 -1
View File
@@ -65,7 +65,10 @@ export const upgradeReleaseController = async (options: {
}): Promise<{ commitSha: string; workspace: string }> => { }): Promise<{ commitSha: string; workspace: string }> => {
const commitSha = await options.workspaceManager.resolveCommit(options.sourceMode, options.sourceRef); const commitSha = await options.workspaceManager.resolveCommit(options.sourceMode, options.sourceRef);
const workspace = await options.workspaceManager.prepare(commitSha); const workspace = await options.workspaceManager.prepare(commitSha);
const manifest = await readReleaseManifest(workspace.root); // The target controller, rather than this bootstrap CLI, owns the target
// controller protocol. Keep all manifest/schema/component checks while
// allowing this explicit self-upgrade boundary to cross protocol versions.
const manifest = await readReleaseManifest(workspace.root, { allowControllerUpgrade: true });
assertReleaseComponents(manifest, ['release-controller']); assertReleaseComponents(manifest, ['release-controller']);
const build = await options.buildRunner.run( const build = await options.buildRunner.run(
buildReleaseControllerCommands(workspace.root, workspace.needsInstall, options.config) buildReleaseControllerCommands(workspace.root, workspace.needsInstall, options.config)
+2
View File
@@ -204,6 +204,8 @@ release-controller가 `GatewayReleaseLog` 진행 로그를 저장하는 것이
로그 기능이 포함된 Gateway API/frontend만 먼저 배포하면 화면은 polling하지만 로그 기능이 포함된 Gateway API/frontend만 먼저 배포하면 화면은 polling하지만
구형 controller는 로그를 만들 수 있으므로, protocol 변경 commit은 위 구형 controller는 로그를 만들 수 있으므로, protocol 변경 commit은 위
`self-upgrade`로 controller를 먼저 전환한 뒤 Gateway 배포를 요청해야 합니다. `self-upgrade`로 controller를 먼저 전환한 뒤 Gateway 배포를 요청해야 합니다.
명시적인 self-upgrade 경로만 다음 controller protocol의 manifest를 읽을 수 있고,
schema head·component 검사는 그대로 수행합니다.
## 운영 확인 목록 ## 운영 확인 목록