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
10 changes: 10 additions & 0 deletions docs/src/content/docs/targets/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ targets:

This is useful for users who want to pin to a major version while automatically receiving updates.

Prereleases (versions with a prerelease identifier, such as `2.16.0-alpha.0`)
do not move floating tags, so users pinned to `v2` only get stable releases.

When Craft looks up the previous release, it ignores tags that have no
`major.minor.patch` version after the tag prefix, such as `v2`, `v2.15`, or
`latest`. A floating tag on the same commit as a release tag therefore does not
hide that release. Prerelease tags still count as releases: a prerelease builds
on the previous prerelease of its version, and a stable release on the latest
stable tag (see [Changelog base](../../versioning/#changelog-base)).

## Preview Releases

If `previewReleases` is `true` (default), releases containing pre-release identifiers like `alpha`, `beta`, `rc`, etc. are marked as pre-releases on GitHub. See [Versioning](../../versioning/#preview-releases) for the identifiers Craft recognizes.
Expand Down
35 changes: 35 additions & 0 deletions src/targets/__tests__/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,41 @@ describe('GitHubTarget', () => {
});
});

describe('updateFloatingTags', () => {
let updateRef: ReturnType<typeof vi.fn>;

beforeEach(() => {
githubTarget = new GitHubTarget(
{ name: 'github', floatingTags: ['v{major}', 'v{major}.{minor}'] },
new NoneArtifactProvider(),
{ owner: 'testOwner', repo: 'testRepo' },
);
updateRef = vi.fn().mockResolvedValue({});
githubTarget.github.rest.git.updateRef = updateRef as any;
});

it('moves floating tags to a stable release', async () => {
await (githubTarget as any).updateFloatingTags('0.53.0', 'abc123');

expect(updateRef).toHaveBeenCalledTimes(2);
expect(updateRef).toHaveBeenCalledWith(
expect.objectContaining({ ref: 'tags/v0', sha: 'abc123', force: true }),
);
expect(updateRef).toHaveBeenCalledWith(
expect.objectContaining({ ref: 'tags/v0.53', sha: 'abc123' }),
);
});

it('leaves floating tags alone for a prerelease', async () => {
await (githubTarget as any).updateFloatingTags(
'0.54.0-alpha.0',
'abc123',
);

expect(updateRef).not.toHaveBeenCalled();
});
});

describe('getRelease', () => {
it('returns release data on success', async () => {
const mockRelease = {
Expand Down
10 changes: 9 additions & 1 deletion src/targets/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,8 @@ export class GitHubTarget extends BaseTarget {
* Creates or updates floating tags for the release.
*
* Floating tags (like "v2") point to the latest release in a major version line.
* They are force-updated if they already exist.
* They are force-updated if they already exist. Prereleases leave them
* unchanged, so users pinned to a floating tag only get stable releases.
*
* @param version The version being released
* @param revision Git commit SHA to point the tags to
Expand All @@ -688,6 +689,13 @@ export class GitHubTarget extends BaseTarget {
return;
}

if (parsedVersion.pre) {
this.logger.info(
`Not updating floating tags because "${version}" is a prerelease.`,
);
return;
}

for (const pattern of floatingTags) {
const tag = this.resolveFloatingTag(pattern, parsedVersion);
const tagRef = `refs/tags/${tag}`;
Expand Down
47 changes: 47 additions & 0 deletions src/utils/__tests__/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,53 @@ describe('release base tags', () => {
});
});

describe('release base tags with floating tags', () => {
let dir: string;
let client: ReturnType<typeof createGitClient>;

beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'craft-floating-tags-'));
const git = (...args: string[]) =>
execFileSync('git', ['-C', dir, ...args], {
env: {
...process.env,
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
},
});
git('init', '--quiet');
git('config', 'user.name', 'Craft Test');
git('config', 'user.email', 'craft-test@example.com');
for (const tags of [['v0.52.0'], ['v0.53.0', 'v0', 'latest'], []]) {
git(
'-c',
'commit.gpgsign=false',
'commit',
'--quiet',
'--allow-empty',
'-m',
tags.length ? `release: ${tags[0]}` : 'Unreleased change',
);
for (const tag of tags) {
git('tag', tag);
}
}
client = createGitClient(dir);
});

afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});

it('skips floating tags on the latest release commit', async () => {
// `git describe` alone picks `v0`, which sorts before `v0.53.0`.
expect(await getLatestTag(client, 'v')).toBe('v0.53.0');
expect(await getLatestTag(client)).toBe('v0.53.0');
expect(await getLatestStableTag(client, 'v')).toBe('v0.53.0');
expect(await getChangelogBaseTag(client, '0.54.0', 'v')).toBe('v0.53.0');
});
});

describe('getGitClient', () => {
it('preserves Git identity environment variables for commits', async () => {
const dir = mkdtempSync(join(tmpdir(), 'craft-git-identity-'));
Expand Down
21 changes: 15 additions & 6 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export async function getLatestTag(
git: SimpleGit,
tagPrefix = '',
): Promise<string> {
return describeLatestTag(git, tagPrefix, []);
return findLatestTag(git, tagPrefix, () => true);
}

/**
Expand All @@ -87,7 +87,7 @@ export async function getLatestStableTag(
git: SimpleGit,
tagPrefix = '',
): Promise<string> {
return findLatestTag(git, tagPrefix, tagVersion => !tagVersion?.pre);
return findLatestTag(git, tagPrefix, tagVersion => !tagVersion.pre);
}

/**
Expand All @@ -109,24 +109,33 @@ export async function getChangelogBaseTag(
git,
tagPrefix,
tagVersion =>
!tagVersion?.pre ||
!tagVersion.pre ||
(!!release?.pre &&
tagVersion.major === release.major &&
tagVersion.minor === release.minor &&
tagVersion.patch === release.patch),
);
}

/** Returns the latest tag whose version `accept` accepts. */
/**
* Returns the latest tag whose version `accept` accepts.
*
* Tags without a version after the prefix, such as floating tags (`v2`) or
* `latest`, are never release tags, so they are skipped.
*/
async function findLatestTag(
git: SimpleGit,
tagPrefix: string,
accept: (tagVersion: SemVer | null) => boolean,
accept: (tagVersion: SemVer) => boolean,
): Promise<string> {
const skippedTags: string[] = [];
for (;;) {
const tag = await describeLatestTag(git, tagPrefix, skippedTags);
if (!tag || accept(parseVersion(tag.slice(tagPrefix.length)))) {
if (!tag) {
return tag;
}
const tagVersion = parseVersion(tag.slice(tagPrefix.length));
if (tagVersion && accept(tagVersion)) {
return tag;
}
skippedTags.push(tag);
Expand Down
Loading