@ossjs/release
Version:
Minimalistic, opinionated, and predictable release automation tool.
789 lines (690 loc) • 20.7 kB
text/typescript
import * as fs from 'node:fs'
import {
http,
HttpResponse,
graphql,
type HttpResponseResolver,
type GraphQLResponseResolver,
} from 'msw'
import { log } from '#/src/logger.js'
import { Publish } from '#/src/commands/publish.js'
import type { GitHubRelease } from '#/src/utils/github/get-github-release.js'
import { testEnvironment } from '#/test/env.js'
import { execAsync } from '#/src/utils/exec-async.js'
const { setup, reset, cleanup, api, createRepository } = testEnvironment({
fileSystemPath: './publish',
})
beforeAll(async () => {
await setup()
})
afterEach(async () => {
await reset()
})
afterAll(async () => {
await cleanup()
})
const githubLatestReleaseHandler = http.get<never, never, GitHubRelease>(
`https://api.github.com/repos/:owner/:name/releases/latest`,
() => {
return new HttpResponse(null, { status: 404 })
},
)
it('publishes the next minor version', async () => {
const repo = await createRepository('version-next-minor')
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({ data: {} })
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
)
await repo.fs.create({
'package.json': JSON.stringify({
name: 'test',
version: '0.0.0',
}),
})
await repo.fs.exec(`git add . && git commit -m 'feat: new things'`)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'echo "release script input: $RELEASE_VERSION"',
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
expect(log.error).not.toHaveBeenCalled()
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining('found 2 new commits:'),
)
// Must notify about the next version.
expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0')
// The release script is provided with the environmental variables.
expect(process.stdout.write).toHaveBeenCalledWith(
'release script input: 0.1.0\n',
)
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining('bumped version in package.json to: 0.1.0'),
)
// Must bump the "version" in package.json.
expect(
JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
).toHaveProperty('version', '0.1.0')
expect(await repo.fs.exec('git log')).toHaveProperty(
'stdout',
expect.stringContaining('chore(release): v0.1.0'),
)
// Must create a new tag for the release.
expect(await repo.fs.exec('git tag')).toHaveProperty(
'stdout',
expect.stringContaining('0.1.0'),
)
expect(log.info).toHaveBeenCalledWith('created release: /releases/1')
expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})
it('releases a new version after an existing version', async () => {
const repo = await createRepository('version-new-after-existing')
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({ data: {} })
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
)
await repo.fs.create({
'package.json': JSON.stringify({
name: 'test',
version: '1.2.3',
}),
})
await execAsync(`git commit -m 'chore(release): v1.2.3' --allow-empty`)
await execAsync('git tag v1.2.3')
await execAsync(`git commit -m 'fix: stuff' --allow-empty`)
await execAsync(`git commit -m 'feat: stuff' --allow-empty`)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'echo "release script input: $RELEASE_VERSION"',
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
expect(log.error).not.toHaveBeenCalled()
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining('found 2 new commits:'),
)
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining('found latest release: v1.2.3'),
)
// Must notify about the next version.
expect(log.info).toHaveBeenCalledWith('release type "minor": 1.2.3 -> 1.3.0')
// The release script is provided with the environmental variables.
expect(process.stdout.write).toHaveBeenCalledWith(
'release script input: 1.3.0\n',
)
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining('bumped version in package.json to: 1.3.0'),
)
// Must bump the "version" in package.json.
expect(
JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
).toHaveProperty('version', '1.3.0')
expect(await repo.fs.exec('git log')).toHaveProperty(
'stdout',
expect.stringContaining('chore(release): v1.3.0'),
)
// Must create a new tag for the release.
expect(await repo.fs.exec('git tag')).toHaveProperty(
'stdout',
expect.stringContaining('v1.3.0'),
)
expect(log.info).toHaveBeenCalledWith('created release: /releases/1')
expect(log.info).toHaveBeenCalledWith('release "v1.3.0" completed!')
})
it('comments on relevant github issues', async () => {
const repo = await createRepository('issue-comments')
const commentsCreated = new Map<string, string>()
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({
data: {
repository: {
pullRequest: {
author: { login: 'octocat' },
commits: {
nodes: [],
},
},
},
},
})
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
http.get('https://api.github.com/repos/:owner/:repo/issues/:id', () => {
return HttpResponse.json({})
}),
http.post<{ id: string }, string>(
'https://api.github.com/repos/:owner/:repo/issues/:id/comments',
async ({ params, request }) => {
commentsCreated.set(params.id, await request.text())
return new HttpResponse(null, { status: 201 })
},
),
)
await repo.fs.create({
'package.json': JSON.stringify({
name: 'test',
version: '0.0.0',
}),
})
await repo.fs.exec(
`git commit -m 'feat: supports graphql (#10)' --allow-empty`,
)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'echo "release script input: $RELEASE_VERSION"',
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
expect(log.info).toHaveBeenCalledWith('commenting on 1 GitHub issue:\n - 10')
expect(commentsCreated).toEqual(
new Map([['10', expect.stringContaining('## Released: v0.1.0 🎉')]]),
)
expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})
it('supports dry-run mode', async () => {
const repo = await createRepository('dry-mode')
const getReleaseContributorsResolver = vi.fn<GraphQLResponseResolver>(() => {
return new HttpResponse(null, { status: 500 })
})
const createGitHubReleaseResolver = vi.fn<HttpResponseResolver>(() => {
return new HttpResponse(null, { status: 500 })
})
api.use(
graphql.query('GetCommitAuthors', getReleaseContributorsResolver),
githubLatestReleaseHandler,
http.post(
'https://api.github.com/repos/:owner/:repo/releases',
createGitHubReleaseResolver,
),
http.get('https://api.github.com/repos/:owner/:repo/issues/:id', () => {
return HttpResponse.json({})
}),
)
await repo.fs.create({
'package.json': JSON.stringify({
name: 'test',
version: '1.2.3',
}),
})
await execAsync(`git commit -m 'chore(release): v1.2.3' --allow-empty`)
await execAsync('git tag v1.2.3')
await execAsync(`git commit -m 'fix: stuff (#2)' --allow-empty`)
await execAsync(`git commit -m 'feat: stuff' --allow-empty`)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'touch release.script.artifact',
},
],
},
{
_: [],
profile: 'latest',
dryRun: true,
},
)
await publish.run()
expect(log.info).toHaveBeenCalledWith(
'preparing release for "octocat/dry-mode" from branch "main"...',
)
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining('found 2 new commits:'),
)
// Package.json version bump.
expect(log.info).toHaveBeenCalledWith('release type "minor": 1.2.3 -> 1.3.0')
expect(log.warn).toHaveBeenCalledWith(
'skip version bump in package.json in dry-run mode (next: 1.3.0)',
)
expect(
JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
).toHaveProperty('version', '1.2.3')
// Publishing script.
expect(log.warn).toHaveBeenCalledWith(
'skip executing publishing script in dry-run mode',
)
expect(fs.existsSync(repo.fs.resolve('release.script.artifact'))).toBe(false)
// No release commit must be created.
expect(log.warn).toHaveBeenCalledWith(
'skip creating a release commit in dry-run mode: "chore(release): v1.3.0"',
)
expect(log.info).not.toHaveBeenCalledWith('created release commit!')
// No release tag must be created.
expect(log.warn).toHaveBeenCalledWith(
'skip creating a release tag in dry-run mode: v1.3.0',
)
expect(log.info).not.toHaveBeenCalledWith('created release tag "v1.3.0"!')
expect(await execAsync('git tag')).toEqual({
stderr: '',
stdout: 'v1.2.3\n',
})
// Release notes must still be generated.
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining('generated release notes:\n\n## v1.3.0'),
)
expect(createGitHubReleaseResolver).not.toHaveBeenCalled()
// The actual GitHub release must not be created.
expect(log.warn).toHaveBeenCalledWith(
'skip creating a GitHub release in dry-run mode',
)
// Dry mode still gets all release contributors because
// it's a read operation.
expect(getReleaseContributorsResolver).toHaveBeenCalledTimes(1)
expect(log.warn).toHaveBeenCalledWith(
'release "v1.3.0" completed in dry-run mode!',
)
})
it('streams the release script stdout to the main process', async () => {
const repo = await createRepository('stream-stdout')
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({ data: {} })
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
)
await repo.fs.create({
'package.json': JSON.stringify({
name: 'publish-stream',
version: '0.0.0',
}),
'stream-stdout.js': `
console.log('hello')
setTimeout(() => console.log('world'), 100)
setTimeout(() => process.exit(0), 150)
`,
})
await execAsync(
`git commit -m 'feat: stream release script stdout' --allow-empty`,
)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'node stream-stdout.js',
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
// Must log the release script stdout.
expect(process.stdout.write).toHaveBeenCalledWith('hello\n')
expect(process.stdout.write).toHaveBeenCalledWith('world\n')
// Must report a successful release.
expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0')
expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})
it('streams the release script stderr to the main process', async () => {
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({ data: {} })
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
)
const repo = await createRepository('stream-stderr')
await repo.fs.create({
'package.json': JSON.stringify({
name: 'publish-stream',
version: '0.0.0',
main: './index.js',
}),
'index.js': '',
'stream-stderr.js': `
console.error('something')
setTimeout(() => console.error('went wrong'), 100)
setTimeout(() => process.exit(0), 150)
`,
})
await execAsync(
`git commit -m 'feat: stream release script stderr' --allow-empty`,
)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'node stream-stderr.js',
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
// Must log the release script stderr.
expect(process.stderr.write).toHaveBeenCalledWith('something\n')
expect(process.stderr.write).toHaveBeenCalledWith('went wrong\n')
// Must report a successful release.
// As long as the publish script doesn't exit, it is successful.
expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0')
expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})
it('only pushes the newly created release tag to the remote', async () => {
const repo = await createRepository('push-release-tag')
await repo.fs.create({
'package.json': JSON.stringify({ name: 'push-tag', version: '1.0.0' }),
})
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({ data: {} })
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
)
// Create an existing tag
await execAsync(`git tag v1.0.0`)
await execAsync(`git push origin v1.0.0`)
// Create a new commit.
await execAsync(`git commit -m 'feat: new feature' --allow-empty`)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'exit 0',
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
expect(log.info).toHaveBeenCalledWith('release type "minor": 1.0.0 -> 1.1.0')
expect(log.info).toHaveBeenCalledWith('release "v1.1.0" completed!')
})
it('treats breaking changes as minor versions when "prerelease" is set to true', async () => {
const repo = await createRepository('prerelease-major-as-minor')
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({ data: {} })
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
)
await repo.fs.create({
'package.json': JSON.stringify({
name: 'test',
version: '0.1.2',
}),
})
await execAsync(`git commit -m 'chore(release): v0.1.2' --allow-empty`)
await execAsync('git tag v0.1.2')
await repo.fs.exec(
`git add . && git commit -m 'feat: new things' -m 'BREAKING CHANGE: beware'`,
)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'echo "release script input: $RELEASE_VERSION"',
// This forces breaking changes to result in a minor
// version bump.
prerelease: true,
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
expect(log.error).not.toHaveBeenCalled()
// Must bump the minor version upon breaking change
// due to the "prerelease" configuration set.
expect(log.info).toHaveBeenCalledWith('release type "minor": 0.1.2 -> 0.2.0')
// Must expose the correct environment variable
// to the publish script.
expect(process.stdout.write).toHaveBeenCalledWith(
'release script input: 0.2.0\n',
)
// Must bump the "version" in package.json.
expect(
JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
).toHaveProperty('version', '0.2.0')
expect(await repo.fs.exec('git log')).toHaveProperty(
'stdout',
expect.stringContaining('chore(release): v0.2.0'),
)
expect(log.info).toHaveBeenCalledWith('release "v0.2.0" completed!')
})
it('treats minor bumps as minor versions when "prerelease" is set to true', async () => {
const repo = await createRepository('prerelease-major-as-minor')
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({ data: {} })
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
)
await repo.fs.create({
'package.json': JSON.stringify({
name: 'test',
version: '0.0.0',
}),
})
await repo.fs.exec(`git add . && git commit -m 'feat: new things'`)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'echo "release script input: $RELEASE_VERSION"',
// This forces breaking changes to result in a minor
// version bump.
prerelease: true,
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
expect(log.error).not.toHaveBeenCalled()
// Must bump the minor version upon breaking change
// due to the "prerelease" configuration set.
expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0')
// Must expose the correct environment variable
// to the publish script.
expect(process.stdout.write).toHaveBeenCalledWith(
'release script input: 0.1.0\n',
)
// Must bump the "version" in package.json.
expect(
JSON.parse(await repo.fs.readFile('package.json', 'utf8')),
).toHaveProperty('version', '0.1.0')
expect(await repo.fs.exec('git log')).toHaveProperty(
'stdout',
expect.stringContaining('chore(release): v0.1.0'),
)
expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!')
})
it('aborts the release if the package does not pass publint', async () => {
api.use(
graphql.query('GetCommitAuthors', () => {
return HttpResponse.json({ data: {} })
}),
githubLatestReleaseHandler,
http.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
() => {
return HttpResponse.json(
{
tag_name: 'v1.0.0',
html_url: '/releases/1',
},
{ status: 201 },
)
},
),
)
const repo = await createRepository('publish--lint-error')
await repo.fs.create({
'package.json': JSON.stringify({
name: 'test',
version: '0.0.0',
main: './non-existing-file.js',
}),
})
await repo.fs.exec(`git add . && git commit -m 'feat: new things'`)
const publish = new Publish(
{
profiles: [
{
name: 'latest',
use: 'echo "release script input: $RELEASE_VERSION"',
},
],
},
{
_: [],
profile: 'latest',
},
)
await publish.run()
expect(process.exit).toHaveBeenCalledWith(1)
expect(log.error, 'Must print linting outcome').toHaveBeenCalledWith(
`Failed to lint the package at "${repo.fs.resolve()}": the package contains issues that can potentially produce a broken release. Please resolve the issues above and retry the release.`,
)
expect(
log.info,
'Must not log a successful release',
).not.toHaveBeenCalledWith('release "v0.1.0" completed!')
})