mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-28 08:49:38 +02:00
fix: recover snapshots and branch rc tags (#185)
This commit is contained in:
parent
c24e07a115
commit
2667952aa9
6 changed files with 110 additions and 10 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -4,7 +4,7 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
release_kind:
|
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
|
required: true
|
||||||
type: choice
|
type: choice
|
||||||
default: "stable"
|
default: "stable"
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,43 @@ describe('local ingest', () => {
|
||||||
).rejects.toThrow();
|
).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 () => {
|
it('reuses the existing sync id when the same local run id is retried', async () => {
|
||||||
const sourceDir = join(tempDir, 'idempotent-source');
|
const sourceDir = join(tempDir, 'idempotent-source');
|
||||||
await mkdir(join(sourceDir, 'orders'), { recursive: true });
|
await mkdir(join(sourceDir, 'orders'), { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,16 @@ async function pruneStaleRawFiles(input: {
|
||||||
return staleRawPaths;
|
return staleRawPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rawSnapshotContainsFiles(
|
||||||
|
project: KtxLocalProject,
|
||||||
|
rawPrefix: string,
|
||||||
|
relativeFiles: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
const existing = await project.fileStore.listFiles(rawPrefix);
|
||||||
|
const existingFiles = new Set(existing.files);
|
||||||
|
return relativeFiles.every((file) => existingFiles.has(`${rawPrefix}/${file}`));
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareLocalStagedDir(
|
async function prepareLocalStagedDir(
|
||||||
project: KtxLocalProject,
|
project: KtxLocalProject,
|
||||||
adapter: SourceAdapter,
|
adapter: SourceAdapter,
|
||||||
|
|
@ -292,14 +302,20 @@ async function runLocalStageOnlyIngestInner(options: RunLocalStageOnlyIngestOpti
|
||||||
priorHashes,
|
priorHashes,
|
||||||
scopeDescriptor ? scopeDescriptor.isPathInScope.bind(scopeDescriptor) : undefined,
|
scopeDescriptor ? scopeDescriptor.isPathInScope.bind(scopeDescriptor) : undefined,
|
||||||
);
|
);
|
||||||
const unchangedFromLatestCompletedRun =
|
const matchesLatestCompletedRun =
|
||||||
!existingRun &&
|
!existingRun &&
|
||||||
!!latestReport &&
|
!!latestReport &&
|
||||||
diffSet.added.length === 0 &&
|
diffSet.added.length === 0 &&
|
||||||
diffSet.modified.length === 0 &&
|
diffSet.modified.length === 0 &&
|
||||||
diffSet.deleted.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 =
|
const syncId =
|
||||||
existingRun?.syncId ?? (unchangedFromLatestCompletedRun ? latestReport.syncId : buildSyncId(started, jobId));
|
existingRun?.syncId ?? (canReuseLatestCompletedRun && reusableLatestSyncId ? reusableLatestSyncId : buildSyncId(started, jobId));
|
||||||
options.memoryFlow?.update({ syncId });
|
options.memoryFlow?.update({ syncId });
|
||||||
options.memoryFlow?.emit({ type: 'raw_snapshot_written', syncId, rawFileCount: relativeFiles.length });
|
options.memoryFlow?.emit({ type: 'raw_snapshot_written', syncId, rawFileCount: relativeFiles.length });
|
||||||
options.memoryFlow?.emit({
|
options.memoryFlow?.emit({
|
||||||
|
|
@ -319,7 +335,7 @@ async function runLocalStageOnlyIngestInner(options: RunLocalStageOnlyIngestOpti
|
||||||
});
|
});
|
||||||
const rawPrefix = `raw-sources/${connectionId}/${adapter.source}/${syncId}`;
|
const rawPrefix = `raw-sources/${connectionId}/${adapter.source}/${syncId}`;
|
||||||
const rawPaths = relativeFiles.map((file) => `${rawPrefix}/${file}`);
|
const rawPaths = relativeFiles.map((file) => `${rawPrefix}/${file}`);
|
||||||
const staleRawPaths = options.dryRun || unchangedFromLatestCompletedRun
|
const staleRawPaths = options.dryRun || canReuseLatestCompletedRun
|
||||||
? []
|
? []
|
||||||
: await pruneStaleRawFiles({
|
: await pruneStaleRawFiles({
|
||||||
project: options.project,
|
project: options.project,
|
||||||
|
|
@ -331,7 +347,7 @@ async function runLocalStageOnlyIngestInner(options: RunLocalStageOnlyIngestOpti
|
||||||
for (const file of relativeFiles) {
|
for (const file of relativeFiles) {
|
||||||
const absolutePath = assertInside(stagedDir, join(stagedDir, file));
|
const absolutePath = assertInside(stagedDir, join(stagedDir, file));
|
||||||
const rawPath = `${rawPrefix}/${file}`;
|
const rawPath = `${rawPrefix}/${file}`;
|
||||||
if (!options.dryRun && !unchangedFromLatestCompletedRun) {
|
if (!options.dryRun && !canReuseLatestCompletedRun) {
|
||||||
await options.project.fileStore.writeFile(
|
await options.project.fileStore.writeFile(
|
||||||
rawPath,
|
rawPath,
|
||||||
await readFile(absolutePath, 'utf-8'),
|
await readFile(absolutePath, 'utf-8'),
|
||||||
|
|
@ -387,7 +403,7 @@ async function runLocalStageOnlyIngestInner(options: RunLocalStageOnlyIngestOpti
|
||||||
rawContentHashes: Object.fromEntries(hashes),
|
rawContentHashes: Object.fromEntries(hashes),
|
||||||
});
|
});
|
||||||
|
|
||||||
const commitPaths = unchangedFromLatestCompletedRun ? [] : [...rawPaths, ...staleRawPaths].sort();
|
const commitPaths = canReuseLatestCompletedRun ? [] : [...rawPaths, ...staleRawPaths].sort();
|
||||||
if (commitPaths.length > 0) {
|
if (commitPaths.length > 0) {
|
||||||
await options.project.git.commitFiles(
|
await options.project.git.commitFiles(
|
||||||
commitPaths,
|
commitPaths,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ describe('release workflow', () => {
|
||||||
assert.match(workflow, /^name: KTX Release$/m);
|
assert.match(workflow, /^name: KTX Release$/m);
|
||||||
assert.match(workflow, /^ workflow_dispatch:$/m);
|
assert.match(workflow, /^ workflow_dispatch:$/m);
|
||||||
assert.match(workflow, /release_kind:/);
|
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, /release_kind:[\s\S]*?default: "stable"/);
|
||||||
assert.match(workflow, /options:\n - stable\n - rc/);
|
assert.match(workflow, /options:\n - stable\n - rc/);
|
||||||
assert.match(workflow, /force_release:/);
|
assert.match(workflow, /force_release:/);
|
||||||
|
|
|
||||||
|
|
@ -78,15 +78,43 @@ function releaseKind(env) {
|
||||||
return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc';
|
return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc';
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseTag(kind) {
|
function currentBranchName(env = process.env) {
|
||||||
return kind === 'rc' ? 'next' : 'latest';
|
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) {
|
function releaseBranches(env = process.env) {
|
||||||
const kind = releaseKind(env);
|
const kind = releaseKind(env);
|
||||||
|
|
||||||
if (kind === 'rc') {
|
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') {
|
if (kind === 'stable') {
|
||||||
|
|
@ -98,7 +126,7 @@ function releaseBranches(env = process.env) {
|
||||||
|
|
||||||
function createReleaseConfig(env = process.env) {
|
function createReleaseConfig(env = process.env) {
|
||||||
const kind = releaseKind(env);
|
const kind = releaseKind(env);
|
||||||
const tag = releaseTag(kind);
|
const tag = releaseTag(kind, env);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tagFormat: 'v${version}',
|
tagFormat: 'v${version}',
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,24 @@ describe('semantic-release config', () => {
|
||||||
assert.doesNotMatch(JSON.stringify(config.plugins), /release:npm-publish/);
|
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', () => {
|
it('configures stable releases only from main with latest tag', () => {
|
||||||
assert.equal(releaseKind({ KTX_RELEASE_KIND: 'stable' }), 'stable');
|
assert.equal(releaseKind({ KTX_RELEASE_KIND: 'stable' }), 'stable');
|
||||||
assert.equal(releaseTag('stable'), 'latest');
|
assert.equal(releaseTag('stable'), 'latest');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue