diff --git a/docs/src/content/docs/targets/github.md b/docs/src/content/docs/targets/github.md index 57cd98ca..59b77efe 100644 --- a/docs/src/content/docs/targets/github.md +++ b/docs/src/content/docs/targets/github.md @@ -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. diff --git a/src/targets/__tests__/github.test.ts b/src/targets/__tests__/github.test.ts index fdf2a906..9ac3c02b 100644 --- a/src/targets/__tests__/github.test.ts +++ b/src/targets/__tests__/github.test.ts @@ -278,6 +278,41 @@ describe('GitHubTarget', () => { }); }); + describe('updateFloatingTags', () => { + let updateRef: ReturnType; + + 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 = { diff --git a/src/targets/github.ts b/src/targets/github.ts index 4c8decdd..f8c4938f 100644 --- a/src/targets/github.ts +++ b/src/targets/github.ts @@ -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 @@ -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}`; diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index b8c06714..14f4876b 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -155,6 +155,53 @@ describe('release base tags', () => { }); }); +describe('release base tags with floating tags', () => { + let dir: string; + let client: ReturnType; + + 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-')); diff --git a/src/utils/git.ts b/src/utils/git.ts index 698f5d50..1aa77cbb 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -72,7 +72,7 @@ export async function getLatestTag( git: SimpleGit, tagPrefix = '', ): Promise { - return describeLatestTag(git, tagPrefix, []); + return findLatestTag(git, tagPrefix, () => true); } /** @@ -87,7 +87,7 @@ export async function getLatestStableTag( git: SimpleGit, tagPrefix = '', ): Promise { - return findLatestTag(git, tagPrefix, tagVersion => !tagVersion?.pre); + return findLatestTag(git, tagPrefix, tagVersion => !tagVersion.pre); } /** @@ -109,7 +109,7 @@ export async function getChangelogBaseTag( git, tagPrefix, tagVersion => - !tagVersion?.pre || + !tagVersion.pre || (!!release?.pre && tagVersion.major === release.major && tagVersion.minor === release.minor && @@ -117,16 +117,25 @@ export async function getChangelogBaseTag( ); } -/** 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 { 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);