diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1b0e56b..aa34d37b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: release_kind: - description: "Release kind: rc publishes to next, stable publishes to latest" + description: "Release kind: stable publishes to latest, main rc publishes to next. Branch rc releases publish to branch-specific npm tags." required: true type: choice default: "stable" diff --git a/packages/context/src/ingest/local-stage-ingest.test.ts b/packages/context/src/ingest/local-stage-ingest.test.ts index d12ce167..66fea320 100644 --- a/packages/context/src/ingest/local-stage-ingest.test.ts +++ b/packages/context/src/ingest/local-stage-ingest.test.ts @@ -351,6 +351,43 @@ describe('local ingest', () => { ).rejects.toThrow(); }); + it('writes a new raw snapshot when an unchanged latest snapshot is missing from disk', async () => { + const sourceDir = join(tempDir, 'missing-snapshot-source'); + await mkdir(join(sourceDir, 'orders'), { recursive: true }); + await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders","version":1}\n', 'utf-8'); + + const first = await runLocalStageOnlyIngest({ + project, + adapters: [new FakeSourceAdapter()], + adapter: 'fake', + connectionId: 'warehouse', + sourceDir, + jobId: 'local-missing-snapshot-1', + now: () => new Date('2026-04-27T12:20:00.000Z'), + }); + + await rm(join(project.projectDir, 'raw-sources/warehouse/fake', first.syncId), { recursive: true, force: true }); + + const rerun = await runLocalStageOnlyIngest({ + project, + adapters: [new FakeSourceAdapter()], + adapter: 'fake', + connectionId: 'warehouse', + sourceDir, + jobId: 'local-missing-snapshot-2', + now: () => new Date('2026-04-27T12:25:00.000Z'), + }); + + expect(rerun.previousRunId).toBe(first.runId); + expect(rerun.syncId).toBe('2026-04-27-122500-local-missing-snapshot-2'); + expect(rerun.diffSummary).toEqual({ added: 0, modified: 0, deleted: 0, unchanged: 1 }); + expect(rerun.workUnitCount).toBe(0); + + await expect( + readFile(join(project.projectDir, 'raw-sources/warehouse/fake', rerun.syncId, 'orders/orders.json'), 'utf-8'), + ).resolves.toBe('{"name":"orders","version":1}\n'); + }); + it('reuses the existing sync id when the same local run id is retried', async () => { const sourceDir = join(tempDir, 'idempotent-source'); await mkdir(join(sourceDir, 'orders'), { recursive: true }); diff --git a/packages/context/src/ingest/local-stage-ingest.ts b/packages/context/src/ingest/local-stage-ingest.ts index 001c321e..0365b071 100644 --- a/packages/context/src/ingest/local-stage-ingest.ts +++ b/packages/context/src/ingest/local-stage-ingest.ts @@ -209,6 +209,16 @@ async function pruneStaleRawFiles(input: { return staleRawPaths; } +async function rawSnapshotContainsFiles( + project: KtxLocalProject, + rawPrefix: string, + relativeFiles: string[], +): Promise { + const existing = await project.fileStore.listFiles(rawPrefix); + const existingFiles = new Set(existing.files); + return relativeFiles.every((file) => existingFiles.has(`${rawPrefix}/${file}`)); +} + async function prepareLocalStagedDir( project: KtxLocalProject, adapter: SourceAdapter, @@ -292,14 +302,20 @@ async function runLocalStageOnlyIngestInner(options: RunLocalStageOnlyIngestOpti priorHashes, scopeDescriptor ? scopeDescriptor.isPathInScope.bind(scopeDescriptor) : undefined, ); - const unchangedFromLatestCompletedRun = + const matchesLatestCompletedRun = !existingRun && !!latestReport && diffSet.added.length === 0 && diffSet.modified.length === 0 && diffSet.deleted.length === 0; + const reusableLatestSyncId = matchesLatestCompletedRun ? latestReport.syncId : null; + const latestRawPrefix = reusableLatestSyncId + ? `raw-sources/${connectionId}/${adapter.source}/${reusableLatestSyncId}` + : null; + const canReuseLatestCompletedRun = + latestRawPrefix !== null && (await rawSnapshotContainsFiles(options.project, latestRawPrefix, relativeFiles)); const syncId = - existingRun?.syncId ?? (unchangedFromLatestCompletedRun ? latestReport.syncId : buildSyncId(started, jobId)); + existingRun?.syncId ?? (canReuseLatestCompletedRun && reusableLatestSyncId ? reusableLatestSyncId : buildSyncId(started, jobId)); options.memoryFlow?.update({ syncId }); options.memoryFlow?.emit({ type: 'raw_snapshot_written', syncId, rawFileCount: relativeFiles.length }); options.memoryFlow?.emit({ @@ -319,7 +335,7 @@ async function runLocalStageOnlyIngestInner(options: RunLocalStageOnlyIngestOpti }); const rawPrefix = `raw-sources/${connectionId}/${adapter.source}/${syncId}`; const rawPaths = relativeFiles.map((file) => `${rawPrefix}/${file}`); - const staleRawPaths = options.dryRun || unchangedFromLatestCompletedRun + const staleRawPaths = options.dryRun || canReuseLatestCompletedRun ? [] : await pruneStaleRawFiles({ project: options.project, @@ -331,7 +347,7 @@ async function runLocalStageOnlyIngestInner(options: RunLocalStageOnlyIngestOpti for (const file of relativeFiles) { const absolutePath = assertInside(stagedDir, join(stagedDir, file)); const rawPath = `${rawPrefix}/${file}`; - if (!options.dryRun && !unchangedFromLatestCompletedRun) { + if (!options.dryRun && !canReuseLatestCompletedRun) { await options.project.fileStore.writeFile( rawPath, await readFile(absolutePath, 'utf-8'), @@ -387,7 +403,7 @@ async function runLocalStageOnlyIngestInner(options: RunLocalStageOnlyIngestOpti rawContentHashes: Object.fromEntries(hashes), }); - const commitPaths = unchangedFromLatestCompletedRun ? [] : [...rawPaths, ...staleRawPaths].sort(); + const commitPaths = canReuseLatestCompletedRun ? [] : [...rawPaths, ...staleRawPaths].sort(); if (commitPaths.length > 0) { await options.project.git.commitFiles( commitPaths, diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index 20b437c7..76b299e3 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -9,6 +9,7 @@ describe('release workflow', () => { assert.match(workflow, /^name: KTX Release$/m); assert.match(workflow, /^ workflow_dispatch:$/m); assert.match(workflow, /release_kind:/); + assert.match(workflow, /Branch rc releases publish to branch-specific npm tags/); assert.match(workflow, /release_kind:[\s\S]*?default: "stable"/); assert.match(workflow, /options:\n - stable\n - rc/); assert.match(workflow, /force_release:/); diff --git a/scripts/semantic-release-config.cjs b/scripts/semantic-release-config.cjs index d1f17bbf..31b3e5e2 100644 --- a/scripts/semantic-release-config.cjs +++ b/scripts/semantic-release-config.cjs @@ -78,15 +78,43 @@ function releaseKind(env) { return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc'; } -function releaseTag(kind) { - return kind === 'rc' ? 'next' : 'latest'; +function currentBranchName(env = process.env) { + return env.GITHUB_REF_NAME || env.INPUT_BRANCH || 'main'; +} + +function branchPrereleaseId(branchName) { + return ( + branchName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'branch' + ); +} + +function releaseTag(kind, env = process.env) { + if (kind !== 'rc') { + return 'latest'; + } + + const branchName = currentBranchName(env); + if (branchName === 'main') { + return 'next'; + } + + return `branch-${branchPrereleaseId(branchName)}`; } function releaseBranches(env = process.env) { const kind = releaseKind(env); if (kind === 'rc') { - return [{ name: 'main', prerelease: 'rc', channel: 'next' }]; + const branches = [{ name: 'main', prerelease: 'rc', channel: 'next' }]; + const branchName = currentBranchName(env); + if (branchName !== 'main') { + const prerelease = branchPrereleaseId(branchName); + branches.push({ name: branchName, prerelease, channel: `branch-${prerelease}` }); + } + return branches; } if (kind === 'stable') { @@ -98,7 +126,7 @@ function releaseBranches(env = process.env) { function createReleaseConfig(env = process.env) { const kind = releaseKind(env); - const tag = releaseTag(kind); + const tag = releaseTag(kind, env); return { tagFormat: 'v${version}', diff --git a/scripts/semantic-release-config.test.mjs b/scripts/semantic-release-config.test.mjs index be4dcc38..4fb26d42 100644 --- a/scripts/semantic-release-config.test.mjs +++ b/scripts/semantic-release-config.test.mjs @@ -39,6 +39,24 @@ describe('semantic-release config', () => { assert.doesNotMatch(JSON.stringify(config.plugins), /release:npm-publish/); }); + it('configures rc releases from branches with branch-specific prerelease and npm tag', () => { + assert.equal(releaseTag('rc', { GITHUB_REF_NAME: 'feature/branch-release' }), 'branch-feature-branch-release'); + assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'feature/branch-release' }), [ + { name: 'main', prerelease: 'rc', channel: 'next' }, + { name: 'feature/branch-release', prerelease: 'feature-branch-release', channel: 'branch-feature-branch-release' }, + ]); + + const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'feature/branch-release' }); + assert.match( + releaseExecOptions(config).prepareCmd, + /update-public-release-version\.mjs "\$\{nextRelease\.version\}" "branch-feature-branch-release"/, + ); + assert.match( + releaseExecOptions(config).publishCmd, + /^npm publish dist\/artifacts\/npm\/kaelio-ktx-\$\{nextRelease\.version\}\.tgz --tag branch-feature-branch-release --access public --provenance/, + ); + }); + it('configures stable releases only from main with latest tag', () => { assert.equal(releaseKind({ KTX_RELEASE_KIND: 'stable' }), 'stable'); assert.equal(releaseTag('stable'), 'latest');