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
4 changes: 4 additions & 0 deletions docs/src/content/docs/release-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ After publishing, Craft merges the release branch into the merge target and
deletes the branch. The merge target defaults to the repository's default
branch; override it with `--merge-target`.

- If the merge target changed lines next to the version strings the release
branch bumped, Craft resolves those conflicts itself: it keeps the merge
target's lines and applies the version bump to them. Any other conflict
aborts the merge and leaves it to you; the release is still published.
- `--no-merge` skips the merge. `--keep-branch` merges but keeps the branch.
- Set `noMerge: true` in `.craft.yml` to never merge. Craft turns this on
automatically for compiled Node.js GitHub Actions (repositories with a
Expand Down
109 changes: 108 additions & 1 deletion src/commands/__tests__/publish.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm } from 'fs/promises';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { vi, describe, test, expect, beforeEach, type Mock } from 'vitest';
import { join as pathJoin } from 'path';
Expand Down Expand Up @@ -702,6 +702,113 @@ describe('handleReleaseBranch', () => {
expect(git.branch).not.toHaveBeenCalled();
});

describe('with a version bump conflict', () => {
const skill = (version: string, description: string) =>
`---\nname: cli-init\nversion: ${version}\ndescription: ${description}\nrequires: sentry\n---\n`;

/**
* Builds the situation from a real release: the release branch bumps a
* version in a file whose neighbouring line changed on the target branch.
*/
async function createConflictingRepository(releaseSkill: string) {
const directory = await mkdtemp(
pathJoin(tmpdir(), 'craft-publish-conflict-'),
);
const repository = pathJoin(directory, 'repository');
const remote = pathJoin(directory, 'remote.git');
const branch = 'release/0.48.0';

await createGitClient(directory).raw('init', '--bare', remote);
await mkdir(repository);
const git = createGitClient(repository);
await git.raw('init', '-b', 'main');
await git.addConfig('user.name', 'Craft Test');
await git.addConfig('user.email', 'craft@example.com');
await git.addConfig('commit.gpgsign', 'false');
await git.addRemote('origin', remote);

const skillPath = pathJoin(repository, 'SKILL.md');
await writeFile(skillPath, skill('0.48.0-dev.0', 'Old description'));
await git.add('SKILL.md');
await git.commit('initial');

await git.checkoutLocalBranch(branch);
await writeFile(skillPath, releaseSkill);
await git.commit('release: 0.48.0', ['--all']);
const releaseRevision = (await git.revparse('HEAD')).trim();

await git.checkout('main');
await writeFile(skillPath, skill('0.48.0-dev.0', 'New description'));
await git.commit('docs: Update description', ['--all']);
await git.raw('push', 'origin', 'main', branch);

return { directory, git, branch, releaseRevision, skillPath };
}

test('resolves and pushes the merge when only the version conflicts', async () => {
const { directory, git, branch, releaseRevision, skillPath } =
await createConflictingRepository(skill('0.48.0', 'Old description'));

try {
await handleReleaseBranch(
git,
'origin',
branch,
releaseRevision,
'main',
false,
branch,
'0.48.0',
);

expect(await readFile(skillPath, 'utf-8')).toBe(
skill('0.48.0', 'New description'),
);
const pushed = (await git.revparse('origin/main')).trim();
expect(pushed).toBe((await git.revparse('HEAD')).trim());
expect(
(await git.raw('rev-list', '--parents', '-n', '1', pushed))
.trim()
.split(' '),
).toContain(releaseRevision);
expect(await git.raw('ls-remote', '--heads', 'origin', branch)).toBe(
'',
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});

test('aborts the merge when the release changed more than the version', async () => {
const { directory, git, branch, releaseRevision, skillPath } =
await createConflictingRepository(skill('0.48.0', 'Hotfix'));

try {
const error = await handleReleaseBranch(
git,
'origin',
branch,
releaseRevision,
'main',
false,
branch,
'0.48.0',
).catch((e: unknown) => e);

expect(error).toBeInstanceOf(MergeConflictError);
expect((error as MergeConflictError).conflictedFiles).toEqual([
'SKILL.md',
]);
expect(await readFile(skillPath, 'utf-8')).toBe(
skill('0.48.0-dev.0', 'New description'),
);
expect((await git.status()).conflicted).toEqual([]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});

test('resolves default branch when mergeTarget is not provided', async () => {
const { getDefaultBranch } = await import('../../utils/git');
vi.mocked(getDefaultBranch).mockResolvedValue('master');
Expand Down
50 changes: 49 additions & 1 deletion src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { buildReleaseCommandEnv } from '../utils/releaseCommandEnv';
import { getPublishStatePath } from '../utils/publishState';
import { DEFAULT_CALVER_CONFIG, getNextCalVerPeriod } from '../utils/calver';
import { runPreReleaseCommand } from '../utils/preReleaseCommand';
import { resolveVersionBumpMergeConflicts } from '../utils/versionConflicts';

/** Default path to post-release script, relative to project root */
const DEFAULT_POST_RELEASE_SCRIPT_PATH = join('scripts', 'post-release.sh');
Expand Down Expand Up @@ -482,6 +483,8 @@ export class BranchCleanupError extends Error {
* @param [mergeTarget] Branch name to merge the release branch into
* @param keepBranch If set to "true", the branch will not be deleted
* @param mergeSource Revision to merge, defaults to the release branch
* @param releaseVersion Released version, used to resolve conflicts that
* only come from the release branch's version bump
*/
export async function handleReleaseBranch(
git: SimpleGit,
Expand All @@ -491,6 +494,7 @@ export async function handleReleaseBranch(
mergeTarget?: string,
keepBranch = false,
mergeSource = branch,
releaseVersion?: string,
): Promise<void> {
if (!mergeTarget) {
mergeTarget = await getDefaultBranch(git, remoteName);
Expand Down Expand Up @@ -519,7 +523,16 @@ export async function handleReleaseBranch(
// Retry with the resolve strategy which handles criss-cross ambiguities
// differently and often succeeds where ort fails on files like CHANGELOG.md
try {
await git.merge(['-s', 'resolve', '--no-ff', '--no-edit', mergeSource]);
try {
await git.merge(['-s', 'resolve', '--no-ff', '--no-edit', mergeSource]);
} catch (resolveError) {
// The target branch may have changed lines next to the version
// strings the release branch bumped. Those conflicts have an obvious
// resolution.
if (!(await tryResolveVersionBumpConflicts(git, releaseVersion))) {
throw resolveError;
}
}
} catch (resolveError) {
// Resolve also failed — capture conflict details before aborting
let conflictedFiles: string[] = [];
Expand Down Expand Up @@ -603,6 +616,40 @@ export async function handleReleaseBranch(
}
}

/**
* Commits the merge resolution when every conflict of the in-progress merge
* only comes from the release version bump.
*
* @returns Whether the merge was resolved and committed
*/
async function tryResolveVersionBumpConflicts(
git: SimpleGit,
releaseVersion: string | undefined,
): Promise<boolean> {
if (!releaseVersion) {
return false;
}
try {
const resolvedFiles = await resolveVersionBumpMergeConflicts(
git,
releaseVersion,
);
if (!resolvedFiles) {
return false;
}
logger.info(
`Resolved conflicts caused only by the ${releaseVersion} version bump in: ${resolvedFiles.join(', ')}`,
);
return true;
} catch (resolutionError) {
logger.debug(
'Could not auto-resolve the version bump conflicts:',
resolutionError,
);
return false;
}
}

/**
* Pull with `--rebase`, aborting the rebase if the pull fails so the
* repository isn't left mid-rebase.
Expand Down Expand Up @@ -1004,6 +1051,7 @@ export async function publishMain(argv: PublishOptions): Promise<any> {
mergeTarget,
argv.keepBranch,
rev ? revision : branchName,
newVersion,
);
mergedInto = mergeTarget;
} catch (mergeError) {
Expand Down
157 changes: 157 additions & 0 deletions src/utils/__tests__/versionConflicts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, test } from 'vitest';

import {
resolveVersionBumpConflicts,
resolveVersionBumpHunk,
} from '../versionConflicts';

const OURS = '<'.repeat(32);
const BASE = '|'.repeat(32);
const SEP = '='.repeat(32);
const THEIRS = '>'.repeat(32);

function conflict(ours: string, base: string, theirs: string): string {
return `${OURS} ours\n${ours}${BASE} base\n${base}${SEP}\n${theirs}${THEIRS} theirs\n`;
}

describe('resolveVersionBumpHunk', () => {
test('applies the version bump to the target branch side', () => {
expect(
resolveVersionBumpHunk(
'version: 0.48.0-dev.0\ndescription: New description\n',
'version: 0.48.0-dev.0\ndescription: Old description\n',
'version: 0.48.0\ndescription: Old description\n',
'0.48.0',
),
).toBe('version: 0.48.0\ndescription: New description\n');
});

test('bumps every occurrence of the old version in the hunk', () => {
expect(
resolveVersionBumpHunk(
'"version": "1.1.0",\n"dependencies": { "core": "1.1.0", "x": "2" }\n',
'"version": "1.1.0",\n"dependencies": { "core": "1.1.0" }\n',
'"version": "1.2.0",\n"dependencies": { "core": "1.2.0" }\n',
'1.2.0',
),
).toBe(
'"version": "1.2.0",\n"dependencies": { "core": "1.2.0", "x": "2" }\n',
);
});

test('handles PEP 440 style development versions', () => {
expect(
resolveVersionBumpHunk(
'version = "2.1.0.dev0"\nrequires-python = ">=3.10"\n',
'version = "2.1.0.dev0"\nrequires-python = ">=3.9"\n',
'version = "2.1.0"\nrequires-python = ">=3.9"\n',
'2.1.0',
),
).toBe('version = "2.1.0"\nrequires-python = ">=3.10"\n');
});

test('does not touch versions that only contain the old version', () => {
expect(
resolveVersionBumpHunk(
'version: 1.0.0\nmin: 11.0.0\nnext: 1.0.0-rc.1\nnew line\n',
'version: 1.0.0\nmin: 11.0.0\nnext: 1.0.0-rc.1\n',
'version: 1.1.0\nmin: 11.0.0\nnext: 1.0.0-rc.1\n',
'1.1.0',
),
).toBe('version: 1.1.0\nmin: 11.0.0\nnext: 1.0.0-rc.1\nnew line\n');
});

test('refuses when the release side changed more than versions', () => {
expect(
resolveVersionBumpHunk(
'version: 1.0.0\ndescription: Target\n',
'version: 1.0.0\ndescription: Base\n',
'version: 1.1.0\ndescription: Hotfix\n',
'1.1.0',
),
).toBeNull();
});

test('refuses when the release side added a line with the version', () => {
expect(
resolveVersionBumpHunk(
'## Unreleased\n\n- Target change\n',
'## Unreleased\n',
'## 1.1.0\n\n- Release change\n',
'1.1.0',
),
).toBeNull();
});

test('refuses when the release side bumped to another version', () => {
expect(
resolveVersionBumpHunk(
'dep: 1.0.0\nother: x\n',
'dep: 1.0.0\n',
'dep: 3.0.0\n',
'1.1.0',
),
).toBeNull();
});

test('refuses when the target branch changed the version itself', () => {
expect(
resolveVersionBumpHunk(
'version: 2.0.0-dev.0\ndescription: Target\n',
'version: 1.1.0-dev.0\ndescription: Base\n',
'version: 1.1.0\ndescription: Base\n',
'1.1.0',
),
).toBeNull();
});
});

describe('resolveVersionBumpConflicts', () => {
test('resolves every hunk and keeps the merged content around them', () => {
const merged = [
'---\n',
'name: init\n',
conflict(
'version: 0.48.0-dev.0\ndescription: New\n',
'version: 0.48.0-dev.0\ndescription: Old\n',
'version: 0.48.0\ndescription: Old\n',
),
'requires: sentry\n',
'=======\n',
conflict('v0.48.0-dev.0 (new)\n', 'v0.48.0-dev.0\n', 'v0.48.0\n'),
].join('');

expect(resolveVersionBumpConflicts(merged, '0.48.0')).toBe(
'---\nname: init\nversion: 0.48.0\ndescription: New\nrequires: sentry\n=======\nv0.48.0 (new)\n',
);
});

test('refuses when any hunk is not a version bump', () => {
const merged = [
conflict(
'version: 1.0.0\na\n',
'version: 1.0.0\nb\n',
'version: 1.1.0\nb\n',
),
conflict('x\n', 'y\n', 'z\n'),
].join('');

expect(resolveVersionBumpConflicts(merged, '1.1.0')).toBeNull();
});

test('refuses conflicts without a base section', () => {
expect(
resolveVersionBumpConflicts(
`${OURS}\nversion: 1.0.0\n${SEP}\nversion: 1.1.0\n${THEIRS}\n`,
'1.1.0',
),
).toBeNull();
});

test('refuses unterminated conflicts and content without conflicts', () => {
expect(
resolveVersionBumpConflicts(`${OURS}\na\n${BASE}\nb\n`, '1.1.0'),
).toBeNull();
expect(resolveVersionBumpConflicts('no conflicts\n', '1.1.0')).toBeNull();
});
});
Loading
Loading