-
Notifications
You must be signed in to change notification settings - Fork 273
/
Copy pathrelease.ts
executable file
·338 lines (283 loc) · 12 KB
/
release.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
#!/usr/bin/env -S node --import ./scripts/register-hook.js
/*
* Copyright (C) 2018-2024 Garden Technologies, Inc. <[email protected]>
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* eslint-disable no-console */
import { execa } from "execa"
import semver from "semver"
import { confirm } from "@inquirer/prompts"
import chalk from "chalk"
import { dirname, relative, resolve } from "node:path"
import fsExtra from "fs-extra"
const { createWriteStream, readFile, writeFile } = fsExtra
import { getPackages } from "./script-utils.js"
import parseArgs from "minimist"
import deline from "deline"
import { replaceInFile } from "replace-in-file"
import { fileURLToPath } from "node:url"
import { finished } from "node:stream/promises"
const moduleDirName = dirname(fileURLToPath(import.meta.url))
type ReleaseType = "minor" | "patch" | "preminor" | "prepatch" | "prerelease"
const RELEASE_TYPES = ["minor", "patch", "preminor", "prepatch", "prerelease"]
const gardenRoot = resolve(moduleDirName, "..")
const rootPackageJsonPath = resolve(gardenRoot, "package.json")
/**
* Performs the following steps to prepare for a release:
* 1. Check out to a branch named release-${version}
* 2. Bump the version in core/package.json and core/package-lock.json.
* 5. Update the changelog.
* 6. Add and commit CHANGELOG.md, core/package.json and core/package-lock.json
* 7. Tag the commit.
* 8. Push the tag. This triggers a CircleCI job that creates the release artifacts and publishes them to Github.
* 9. If we're making a minor release, update links to examples and re-push the tag.
* 10. If this is not a pre-release, pushes the release branch to Github.
*
* Usage: ./scripts/release.ts <minor | patch | preminor | prepatch | prerelease> [--force] [--dry-run]
*/
async function release() {
// Parse arguments
const argv = parseArgs(process.argv.slice(2))
const releaseType = <ReleaseType>argv._[0]
const force = !!argv.force
const dryRun = !!argv["dry-run"]
// Check if branch is clean
try {
await execa("git", ["diff", "--exit-code"], { cwd: gardenRoot })
} catch (_) {
throw new Error("Current branch has unstaged changes, aborting.")
}
if (!RELEASE_TYPES.includes(releaseType)) {
throw new Error(`Invalid release type ${releaseType}, available types are: ${RELEASE_TYPES.join(", ")}`)
}
const prevVersion = JSON.parse(await readFile(rootPackageJsonPath, "utf-8")).version as string
const version = semver.inc(prevVersion, releaseType)!
// Update package.json versions
/**
* For prereleases, we omit the prerelease suffix for all package.json-s except the top-level one.
*
* This is to make references to internal packages (e.g. "@garden-io/core@*") work during the build process in CI.
*/
const packageReleaseTypeMap = { preminor: "minor", prepatch: "patch" }
const incrementedPackageVersion = semver.inc(prevVersion, packageReleaseTypeMap[releaseType] || releaseType)
const parsed = semver.parse(incrementedPackageVersion)
// We omit the prerelease suffix from `incrementedPackageVersion` (if there is one).
const packageVersion = `${parsed?.major}.${parsed?.minor}.${parsed?.patch}`
console.log(`Bumping version from ${prevVersion} to ${version}...`)
await updatePackageJsonVersion(rootPackageJsonPath, version)
console.log(`Setting package versions to ${packageVersion}...`)
const packages = await getPackages()
const packageJsonPaths = Object.values(packages).map((p) => resolve(p.location, "package.json"))
await Promise.all(packageJsonPaths.map(async (p) => await updatePackageJsonVersion(p, packageVersion!)))
const branchName = `release-${version}`
// Check if branch already exists locally
let localBranch
try {
localBranch = (await execa("git", ["rev-parse", "--verify", branchName], { cwd: gardenRoot })).stdout
} catch (_) {
// no op
} finally {
if (localBranch) {
await rollBack()
throw new Error(`Branch ${branchName} already exists locally. Aborting.`)
}
}
// Check if branch already exists remotely
let remoteBranch
try {
remoteBranch = (
await execa("git", ["ls-remote", "--exit-code", "--heads", "origin", branchName], { cwd: gardenRoot })
).stdout
} catch (_) {
// no op
} finally {
if (remoteBranch) {
await rollBack()
throw new Error(`Branch ${branchName} already exists remotely. Aborting.`)
}
}
// Check if user wants to continue
const proceed = await prompt(version)
if (!proceed) {
await rollBack()
return
}
// Pull remote tags
console.log("Pulling remote tags...")
await execa("git", ["fetch", "origin", "--tags", "-f"], { cwd: gardenRoot })
// Verify tag doesn't exist
const tags = (await execa("git", ["tag"], { cwd: gardenRoot })).stdout.split("\n")
if (tags.includes(version) && !force) {
await rollBack()
throw new Error(`Tag ${version} already exists. Use "--force" to override.`)
}
// Checkout to a release branch
console.log(`Checking out to branch ${branchName}...`)
await execa("git", ["checkout", "-b", branchName], { cwd: gardenRoot })
// Remove pre-release tags so they don't get included in the changelog
await stripPrereleaseTags(tags, version)
// Update changelog
console.log("Updating changelog...")
await updateChangelog(version)
// Add and commit changes
console.log("Committing changes...")
await execa(
"git",
["add", "CHANGELOG.md", rootPackageJsonPath, ...packageJsonPaths.map((p) => relative(gardenRoot, p))],
{ cwd: gardenRoot }
)
await execa("git", ["commit", "-m", `chore(release): bump version to ${version}`], { cwd: gardenRoot })
// Tag the commit and push the tag
if (!dryRun) {
console.log("Pushing tag...")
await createTag(version, force)
}
// Reset local tag state (after stripping release tags)
await execa("git", ["fetch", "origin", "--tags"], { cwd: gardenRoot })
// For non pre-releases, we update links to examples in the docs so that they point to the relevant tag.
// E.g.: "github.com/garden-io/tree/v0.8.0/example/..." becomes "github.com/garden-io/tree/v0.9.0/example/..."
// Note that we do this after pushing the tag originally. This is because we check that links are valid in CI
// and the check would fail if the tag hasn't been created in the first place.
if (releaseType === "minor" || releaseType === "patch") {
console.log("Updating links to examples and re-pushing tag...")
await updateExampleLinks(version)
// Add and commit changes to example links
await execa("git", ["add", "README.md", "docs"], { cwd: gardenRoot })
await execa("git", ["commit", "--amend", "--no-edit"], { cwd: gardenRoot })
// Tag the commit and force push the tag after updating the links (this triggers another CI build)
if (!dryRun) {
await createTag(version, true)
}
}
if (!dryRun && !semver.prerelease(version)) {
console.log("Pushing release branch...")
const pushArgs = ["push", "origin", branchName, "--no-verify"]
if (force) {
pushArgs.push("-f")
}
await execa("git", pushArgs, { cwd: gardenRoot })
}
if (dryRun) {
console.log(deline`
Release ${chalk.bold.cyan(version)} is ready! To release, create and push a release tag with:\n
${chalk.bold(`git tag -a ${version} -m "chore(release): release ${version}"`)}
${chalk.bold(`git push push origin ${version} --no-verify`)}\n
Then, if this is not a pre-release, push the branch with:\n
${chalk.bold(`git push origin ${branchName} --no-verify`)}\n
and create a pull request on Github by visiting:
https://github.com/garden-io/garden/pull/new/${branchName}\n
Alternatively, you can undo the commit created by the dry-run and run the script
again without the --dry-run flag. This will perform all the steps automatically.
`)
} else {
console.log(deline`
\nRelease ${chalk.bold.cyan(version)} has been ${chalk.bold("tagged")}, ${chalk.bold("committed")},
and ${chalk.bold("pushed")} to Github! 🎉\n
A CI job that creates the release artifacts is currently in process: https://circleci.com/gh/garden-io/garden\n
If this is not a pre-release, create a pull request for ${branchName} on Github by visiting:
https://github.com/garden-io/garden/pull/new/${branchName}\n
Please refer to our contributing docs for the next steps:
https://github.com/garden-io/garden/blob/main/CONTRIBUTING.md
`)
}
}
async function updatePackageJsonVersion(packageJsonPath: string, newVersion: string) {
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8"))
packageJson.version = newVersion
await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2))
}
async function createTag(version: string, force: boolean) {
// Tag the commit
const createTagArgs = ["tag", "-a", version, "-m", `chore(release): release ${version}`]
if (force) {
createTagArgs.push("-f")
}
await execa("git", createTagArgs, { cwd: gardenRoot })
// Push the tag
const pushTagArgs = ["push", "origin", version, "--no-verify"]
if (force) {
pushTagArgs.push("-f")
}
await execa("git", pushTagArgs, { cwd: gardenRoot })
}
async function updateExampleLinks(version: string) {
const options = {
files: ["docs/**/*.md", "README.md"],
from: /github\.com\/garden-io\/garden\/tree\/[^\/]*\/examples/g,
to: `github.com/garden-io/garden/tree/${version}/examples`,
}
const results = await replaceInFile(options)
console.log(
"Modified files:",
results
.filter((r) => r.hasChanged)
.map((r) => r.file)
.join("\n")
)
}
async function rollBack() {
// Undo any file changes. This is safe since we know the branch is clean.
console.log("Undoing file changes")
await execa("git", ["checkout", "."], { cwd: gardenRoot })
}
async function prompt(version: string): Promise<boolean> {
const message = deline`
Running this script will create a branch and a tag for ${chalk.bold.cyan(version)} and push them to Github.
This triggers a CI process that creates the release artifacts.\n
Are you sure you want to continue?
`
return await confirm({ message })
}
/**
* Update CHANGELOG.md. We need to get the latest entry and prepend it to the current CHANGELOG.md
*/
async function updateChangelog(version: string) {
const changelogPath = "./CHANGELOG.md"
// TODO: Use readStream and pipe
const changelog = await readFile(changelogPath)
const nextChangelogEntry = (
await execa(
"git-chglog",
["--tag-filter-pattern", "^\\d+\\.\\d+\\.\\d+$", "--sort", "semver", "--next-tag", version, version],
{ cwd: gardenRoot }
)
).stdout
const writeStream = createWriteStream(changelogPath)
writeStream.write(nextChangelogEntry)
writeStream.write(changelog)
writeStream.close()
await finished(writeStream)
}
/**
* We don't include pre-release tags in the changelog except for the current release cycle.
* So if we're releasing, say, v0.9.1-3, we include the v0.9.1-0, v0.9.1-1, and v0.9.1-2 tags.
*
* Once we release v0.9.1, we remove the pre-release tags, so the changelog will only show the changes
* between v0.9.0 and v0.9.1.
*/
async function stripPrereleaseTags(tags: string[], version: string) {
const prereleaseTags = tags.filter((t) => !!semver.prerelease(t))
for (const tag of prereleaseTags) {
// If we're not releasing a pre-release, we remove the tag. Or,
// if we are releasing a pre-release and the tag is not from the same cycle, we remove it.
// E.g., if the current tag is v0.5.0-2 and we're releasing v0.9.0-2, we remove it.
// If the current tag is v0.9.0-0 and we're releasing v0.9.0-2, we keep it.
if (!semver.prerelease(version) || semver.diff(version, tag) !== "prerelease") {
await execa("git", ["tag", "-d", tag])
}
}
// We also need to remove the "edge-bonsai" tag
await execa("git", ["tag", "-d", "edge-bonsai"])
}
;(async () => {
try {
await release()
process.exit(0)
} catch (err) {
console.log(err)
process.exit(1)
}
})().catch(() => {})