Merge remote-tracking branch 'origin/main' into feature/ref-progress-bars-20260810
This commit is contained in:
@@ -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.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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는
|
||||||
|
동일하게 검증합니다.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 검사는 그대로 수행합니다.
|
||||||
|
|
||||||
## 운영 확인 목록
|
## 운영 확인 목록
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user