Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .contentrain/content/system/error-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@
"project.config_not_found": "Project configuration file (.contentrain/config.json) not found in the repository.",
"project.config_validation_failed": "Project configuration is invalid: {errors}",
"project.content_branch_failed": "Could not create the 'contentrain' branch in this repository. Check that the GitHub App still has write access, then try connecting again.",
"project.migration_not_merged": "Contentrain Migrate delivered this site on the '{branch}' branch. Merge it into '{base}' first, then connect the repository — connecting now would open the project without its content.",
"project.not_found": "Project not found or you don't have access.",
"project.not_found_in_workspace": "Project not found in this workspace. It may have been moved or deleted.",
"project.review_workflow_upgrade": "Review workflow is available on {plans:workflow.review}. Enable it in project settings.",
Expand Down
19 changes: 15 additions & 4 deletions server/api/workspaces/[workspaceId]/projects/index.post.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { unmergedMigrationBranch } from '~~/server/utils/ensure-content-branch'
import { syncMigrationHandoff } from '~~/server/utils/migration-handoff'

export default defineEventHandler(async (event) => {
Expand Down Expand Up @@ -46,11 +47,21 @@ export default defineEventHandler(async (event) => {

if (installationId) {
const [owner = '', repo = ''] = body.repoFullName.split('/')
const git = useGitProvider({ installationId, owner, repo, contentRoot: body.contentRoot || '/' })
// A Migrate delivery waiting on its own branch: `contentrain` forked from the pre-migration
// default branch would open the project empty (and pass Migrate's "present" check). Wait for the merge.
// Fail closed: a GitHub error here must not let the connect fork a stale `contentrain`.
const waiting = await unmergedMigrationBranch(git, defaultBranch).catch(() => {
throw createError({ statusCode: 502, message: errorMessage('project.content_branch_failed') })
})
if (waiting) {
throw createError({
statusCode: 409,
message: errorMessage('project.migration_not_merged', { branch: waiting, base: defaultBranch }),
})
}
try {
await ensureContentBranch(
useGitProvider({ installationId, owner, repo, contentRoot: body.contentRoot || '/' }),
defaultBranch,
)
await ensureContentBranch(git, defaultBranch)
}
catch {
throw createError({
Expand Down
53 changes: 53 additions & 0 deletions server/utils/ensure-content-branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,56 @@ async function branchExists(git: ContentBranchOps): Promise<boolean> {
const matches = await git.listBranches(CONTENTRAIN_BRANCH)
return matches.some(branch => branch.name === CONTENTRAIN_BRANCH)
}

/** The content store file Migrate commits; present on the default branch once the customer has merged the delivery. */
const CONTENT_STORE_CONFIG = '.contentrain/config.json'

export interface MigrationMergeOps {
listBranches: (prefix?: string) => Promise<{ name: string }[]>
readFile: (path: string, ref?: string) => Promise<string>
}

/** A read that found nothing — the file or the ref is absent. Anything else (rate limit, network, 5xx) is not an answer. */
function isMissing(error: unknown): boolean {
const status = (error as { status?: unknown })?.status
if (status === 404) return true
const message = error instanceof Error ? error.message : ''
return /\b404\b|not found/i.test(message)
}

/**
* The Migrate delivery branch (`migrate/<planHash>`) that has not reached the
* default branch yet, or null. Migrate delivers into a non-empty repository on
* a branch of its own; until the customer merges it the default branch has no
* content store. Creating `contentrain` from that default branch now would
* freeze it at the pre-migration tree: the project would open empty, and
* Migrate's later "branch present" check would take that stale branch for the
* migrated one. So connecting waits for the merge.
*
* Only a `migrate/…` branch that itself carries the content store counts: a
* team's own `migrate/db-v2` branch is not a delivery and never holds a connect.
* Only a repository with no `contentrain` branch yet is ever held.
*
* Fails closed: a read that errors for any reason other than "not there" throws,
* so the caller refuses the connect instead of guessing.
*/
export async function unmergedMigrationBranch(git: MigrationMergeOps, defaultBranch: string): Promise<string | null> {
if ((await git.listBranches(CONTENTRAIN_BRANCH)).some(branch => branch.name === CONTENTRAIN_BRANCH)) return null
const candidates = (await git.listBranches('migrate/')).filter(branch => branch.name.startsWith('migrate/'))
if (!candidates.length) return null
if (await readsStore(git, defaultBranch)) return null
for (const candidate of candidates) {
if (await readsStore(git, candidate.name)) return candidate.name
}
return null
}

async function readsStore(git: MigrationMergeOps, ref: string): Promise<boolean> {
try {
return Boolean(await git.readFile(CONTENT_STORE_CONFIG, ref))
}
catch (error) {
if (isMissing(error)) return false
throw error
}
}
42 changes: 41 additions & 1 deletion tests/unit/ensure-content-branch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureContentBranch } from '../../server/utils/ensure-content-branch'
import { ensureContentBranch, unmergedMigrationBranch } from '../../server/utils/ensure-content-branch'

function ops(overrides: Partial<{
branches: { name: string }[][]
Expand Down Expand Up @@ -57,3 +57,43 @@ describe('ensureContentBranch', () => {
await expect(ensureContentBranch(git, 'main')).rejects.toThrow('Resource not accessible')
})
})

describe('unmergedMigrationBranch', () => {
/** `stores`: refs whose `.contentrain/config.json` reads; a ref in `broken` fails with a transient error. */
const git = (opts: { branches?: string[], stores?: string[], broken?: string[] }) => ({
listBranches: vi.fn(async (prefix?: string) =>
(opts.branches ?? []).filter(name => !prefix || name.startsWith(prefix)).map(name => ({ name }))),
readFile: vi.fn(async (_path: string, ref?: string) => {
if (opts.broken?.includes(ref ?? '')) throw Object.assign(new Error('rate limited'), { status: 403 })
if (!opts.stores?.includes(ref ?? '')) throw Object.assign(new Error('Not Found'), { status: 404 })
return '{}'
}),
})

it('holds the connect while a delivery branch (it carries the store) is unmerged and no content branch exists', async () => {
await expect(unmergedMigrationBranch(git({ branches: ['main', 'migrate/abc123'], stores: ['migrate/abc123'] }), 'main')).resolves.toBe('migrate/abc123')
})

it('does not hold on a team\'s own migrate/ branch: it carries no content store', async () => {
await expect(unmergedMigrationBranch(git({ branches: ['main', 'migrate/db-v2'] }), 'main')).resolves.toBeNull()
})

it('lets it through once the default branch carries the content store', async () => {
await expect(unmergedMigrationBranch(git({ branches: ['main', 'migrate/abc123'], stores: ['main', 'migrate/abc123'] }), 'main')).resolves.toBeNull()
})

it('does not hold a repository that already has a contentrain branch', async () => {
await expect(unmergedMigrationBranch(git({ branches: ['contentrain', 'migrate/abc123'], stores: ['migrate/abc123'] }), 'main')).resolves.toBeNull()
})

it('does not touch an ordinary repository (no migrate/ branch)', async () => {
const ops = git({ branches: ['main'] })
await expect(unmergedMigrationBranch(ops, 'main')).resolves.toBeNull()
expect(ops.readFile).not.toHaveBeenCalled()
})

it('fails closed: a transient read error is thrown, not read as "no delivery"', async () => {
await expect(unmergedMigrationBranch(git({ branches: ['main', 'migrate/abc123'], stores: ['migrate/abc123'], broken: ['main'] }), 'main')).rejects.toThrow('rate limited')
await expect(unmergedMigrationBranch(git({ branches: ['main', 'migrate/abc123'], broken: ['migrate/abc123'] }), 'main')).rejects.toThrow('rate limited')
})
})
Loading