diff --git a/src/commands/__test__/notes.test.ts b/src/commands/__test__/notes.test.ts index 9cef899..43de2f0 100644 --- a/src/commands/__test__/notes.test.ts +++ b/src/commands/__test__/notes.test.ts @@ -22,6 +22,13 @@ let gitHubReleaseHandler: jest.Mock = jest.fn< ) }) +const githubLatestReleaseHandler = rest.get( + `https://api.github.com/repos/:owner/:name/releases/latest`, + (req, res, ctx) => { + return res(ctx.status(404)) + }, +) + beforeAll(async () => { await setup() }) @@ -47,6 +54,7 @@ it('creates a GitHub release for a past release', async () => { await createRepository('past-release') api.use( + githubLatestReleaseHandler, rest.get( 'https://api.github.com/repos/:owner/:repo/releases/tags/:tag', (req, res, ctx) => { @@ -99,7 +107,12 @@ it('creates a GitHub release for a past release', async () => { const notes = new Notes( { - script: 'exit 0', + profiles: [ + { + name: 'latest', + use: 'exit 0', + }, + ], }, { _: ['', '0.2.0'], @@ -139,11 +152,13 @@ it('skips creating a GitHub release if the given release already exists', async await createRepository('skip-if-exists') api.use( + githubLatestReleaseHandler, rest.get( 'https://api.github.com/repos/:owner/:repo/releases/tags/:tag', (req, res, ctx) => { return res( ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) @@ -153,7 +168,12 @@ it('skips creating a GitHub release if the given release already exists', async const notes = new Notes( { - script: 'exit 0', + profiles: [ + { + name: 'latest', + use: 'exit 0', + }, + ], }, { _: ['', '1.0.0'], diff --git a/src/commands/__test__/publish.test.ts b/src/commands/__test__/publish.test.ts index 1d9481d..6495fa3 100644 --- a/src/commands/__test__/publish.test.ts +++ b/src/commands/__test__/publish.test.ts @@ -22,6 +22,13 @@ afterAll(async () => { await cleanup() }) +const githubLatestReleaseHandler = rest.get( + `https://api.github.com/repos/:owner/:name/releases/latest`, + (req, res, ctx) => { + return res(ctx.status(404)) + }, +) + it('publishes the next minor version', async () => { const repo = await createRepository('version-next-minor') @@ -29,12 +36,14 @@ it('publishes the next minor version', async () => { graphql.query('GetCommitAuthors', (req, res, ctx) => { return res(ctx.data({})) }), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( ctx.status(201), ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) @@ -110,12 +119,14 @@ it('releases a new version after an existing version', async () => { graphql.query('GetCommitAuthors', (req, res, ctx) => { return res(ctx.data({})) }), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( ctx.status(201), ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) @@ -210,12 +221,14 @@ it('comments on relevant github issues', async () => { }), ) }), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( ctx.status(201), ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) @@ -288,6 +301,7 @@ it('supports dry-run mode', async () => { api.use( graphql.query('GetCommitAuthors', getReleaseContributorsResolver), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', createGitHubReleaseResolver, @@ -396,12 +410,14 @@ it('streams the release script stdout to the main process', async () => { graphql.query('GetCommitAuthors', (req, res, ctx) => { return res(ctx.data({})) }), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( ctx.status(201), ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) @@ -456,12 +472,14 @@ it('streams the release script stderr to the main process', async () => { graphql.query('GetCommitAuthors', (req, res, ctx) => { return res(ctx.data({})) }), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( ctx.status(201), ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) @@ -521,12 +539,14 @@ it('only pushes the newly created release tag to the remote', async () => { graphql.query('GetCommitAuthors', (req, res, ctx) => { return res(ctx.data({})) }), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( ctx.status(201), ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) @@ -568,12 +588,14 @@ it('treats breaking changes as minor versions when "prerelease" is set to true', graphql.query('GetCommitAuthors', (req, res, ctx) => { return res(ctx.data({})) }), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( ctx.status(201), ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) @@ -644,12 +666,14 @@ it('treats minor bumps as minor versions when "prerelease" is set to true', asyn graphql.query('GetCommitAuthors', (req, res, ctx) => { return res(ctx.data({})) }), + githubLatestReleaseHandler, rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( ctx.status(201), ctx.json({ + tag_name: 'v1.0.0', html_url: '/releases/1', }), ) diff --git a/src/commands/publish.ts b/src/commands/publish.ts index a86ce3c..130f734 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -104,7 +104,13 @@ export class Publish extends Command { ), ) - // Get the latest release. + /** + * Get the latest release. + * @note This refers to the latest release tag at the current + * state of the branch. Since Release doesn't do branch analysis, + * this doesn't guarantee the latest release in general + * (consider backport releases where you checkout an old SHA). + */ const tags = await getTags() const latestRelease = await getLatestRelease(tags) diff --git a/src/utils/github/__test__/createGitHubRelease.test.ts b/src/utils/github/__test__/createGitHubRelease.test.ts new file mode 100644 index 0000000..d8b35d0 --- /dev/null +++ b/src/utils/github/__test__/createGitHubRelease.test.ts @@ -0,0 +1,78 @@ +import { rest } from 'msw' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { testEnvironment } from '../../../../test/env' +import { mockRepo } from '../../../../test/fixtures' +import type { GitHubRelease } from '../getGitHubRelease' +import { createGitHubRelease } from '../createGitHubRelease' + +const { setup, reset, cleanup, api } = testEnvironment({ + fileSystemPath: 'create-github-release', +}) + +beforeAll(async () => { + await setup() +}) + +afterEach(async () => { + await reset() +}) + +afterAll(async () => { + await cleanup() +}) + +it('marks the release as non-latest if there is a higher version released on GitHub', async () => { + const repo = mockRepo() + const requestBodyPromise = new DeferredPromise() + api.use( + rest.get( + `https://api.github.com/repos/:owner/:name/releases/latest`, + (req, res, ctx) => { + return res( + // Set the latest GitHub release as v2.0.0. + ctx.json({ + tag_name: 'v2.0.0', + html_url: '/v2.0.0', + }), + ) + }, + ), + rest.post( + `https://api.github.com/repos/:owner/:name/releases`, + (req, res, ctx) => { + requestBodyPromise.resolve(req.json()) + return res( + ctx.status(201), + ctx.json({ + tag_name: 'v1.1.1', + html_url: '/v1.1.1', + }), + ) + }, + ), + ) + + // Try to release a backport version for v1.0.0. + const notes = '# Release notes' + const githubRelease = await createGitHubRelease( + { + repo, + nextRelease: { + version: '1.1.1', + tag: 'v1.1.1', + publishedAt: new Date(), + }, + }, + notes, + ) + expect(githubRelease).toHaveProperty('html_url', '/v1.1.1') + + const requestBody = await requestBodyPromise + expect(requestBody).toEqual({ + tag_name: 'v1.1.1', + name: 'v1.1.1', + body: notes, + // Must set "false" as the value of the "make_latest" property. + make_latest: 'false', + }) +}) diff --git a/src/utils/github/__test__/getCommitAuthors.test.ts b/src/utils/github/__test__/getCommitAuthors.test.ts index 857a89a..02a5e22 100644 --- a/src/utils/github/__test__/getCommitAuthors.test.ts +++ b/src/utils/github/__test__/getCommitAuthors.test.ts @@ -1,9 +1,9 @@ +import { graphql } from 'msw' import { getCommitAuthors } from '../getCommitAuthors' import { log } from '../../../logger' import { mockCommit } from '../../../../test/fixtures' import { parseCommits } from '../../git/parseCommits' import { testEnvironment } from '../../../../test/env' -import { graphql } from 'msw' const { setup, reset, cleanup, api } = testEnvironment({ fileSystemPath: 'get-commit-authors', diff --git a/src/utils/github/createGitHubRelease.ts b/src/utils/github/createGitHubRelease.ts index 472ac5e..6531448 100644 --- a/src/utils/github/createGitHubRelease.ts +++ b/src/utils/github/createGitHubRelease.ts @@ -1,11 +1,14 @@ import fetch from 'node-fetch' import { format } from 'outvariant' +import { lt } from 'semver' import type { ReleaseContext } from '../createContext' -import type { GitHubRelease } from './getGitHubRelease' +import { getGitHubRelease, type GitHubRelease } from './getGitHubRelease' import { log } from '../../logger' /** * Create a new GitHub release with the given release notes. + * This is only called if there's no existing GitHub release + * for the next release tag. * @return {string} The URL of the newly created release. */ export async function createGitHubRelease( @@ -22,6 +25,26 @@ export async function createGitHubRelease( ), ) + // Determine if the next release should be marked as the + // latest release on GitHub. For that, fetch whichever latest + // release exists on GitHub and see if its version is larger + // than the version we are releasing right now. + const latestGitHubRelease = await getGitHubRelease('latest').catch( + (error) => { + log.error(`Failed to fetch the latest GitHub release:`, error) + // We aren't interested in the GET endpoint errors in this context. + return undefined + }, + ) + const shouldMarkAsLatest = latestGitHubRelease + ? lt(latestGitHubRelease.tag_name || '0.0.0', context.nextRelease.tag) + : // Undefined is fine, it means GitHub will use its default + // value for the "make_latest" property in the API. + undefined + + /** + * @see https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release + */ const response = await fetch( `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`, { @@ -35,6 +58,7 @@ export async function createGitHubRelease( tag_name: context.nextRelease.tag, name: context.nextRelease.tag, body: notes, + make_latest: shouldMarkAsLatest?.toString(), }), }, ) diff --git a/src/utils/github/getGitHubRelease.ts b/src/utils/github/getGitHubRelease.ts index c9831f9..a2926b5 100644 --- a/src/utils/github/getGitHubRelease.ts +++ b/src/utils/github/getGitHubRelease.ts @@ -3,16 +3,19 @@ import fetch from 'node-fetch' import { getInfo } from '../git/getInfo' export interface GitHubRelease { + tag_name: string html_url: string } export async function getGitHubRelease( - tag: string, + tag: string | ('latest' & {}), ): Promise { const repo = await getInfo() const response = await fetch( - `https://api.github.com/repos/${repo.owner}/${repo.name}/releases/tags/${tag}`, + tag === 'latest' + ? `https://api.github.com/repos/${repo.owner}/${repo.name}/releases/latest` + : `https://api.github.com/repos/${repo.owner}/${repo.name}/releases/tags/${tag}`, { headers: { Accept: 'application/json', diff --git a/test/env.ts b/test/env.ts index 1d2154e..cc7b29f 100644 --- a/test/env.ts +++ b/test/env.ts @@ -16,7 +16,9 @@ export const api = setupServer( ) beforeAll(() => { - api.listen() + api.listen({ + onUnhandledRequest: 'error', + }) }) afterEach(() => {