sourcegraph/dev/release/src/config.ts
coury-clark dd1e512434
release-tool: refactor release config (#47711)
Closes [#47557](https://github.com/sourcegraph/sourcegraph/issues/47557)

This is a somewhat gruesome PR to read, so sorry for that. I wanted to
keep this change atomic so it's easy to revert if we need.

So, I set out to automate the dates in the release config and realized
that fundamentally the architecture of the release tool was coupled to
this config in a way that was making life more difficult than it needed
to be.

This PR refactors the release config in the release tool. It uses an
entirely new schema, and for the most part automates every interaction
anyone will have with the config. The new schema is designed to do a few
things:
1. Deprecate the annoying cached version by specifying two sections of
the release config, both a definition and an active releases field.
Anything in the active releases is considered in progress.
2. Sets up some architecture to support multi-release functionality from
the release tool. There are guard rails right now since this isn't fully
supported.

Also,
* Automatically detect and suggest versions
* Automatically generate release dates from a specified date or current
time
* QOL changes to flow to the appropriate commands if the release config
is in the wrong state. For example it will prompt for input if the
config is empty for a given version for commands that require a valid
state
* Guardrails around every interaction I could find that causes problems
* New commands to interact with the release config (activate-release,
deactivate-release, util:previous-version)

## Usage

To set a release as active, either use the activate command directly
```
pnpm run release release:activate-release
```
or transitively through another dependent command.

All release commands that interact with the _current_ release will read
from the active release in the release config.
All release commands that interact with _future_ releases will read and
write to the release definitions in the release config.

To deactivate the release:
```
pnpm run release release:deactivate-release
```

Most of these steps are not explicitly mandatory, and are coupled into
other relevant commands (for example closing the release will deactivate
the release also).

To add new scheduled release definitions:
```
pnpm run release release:prepare
```

To remove scheduled releases:
```
pnpm run release release:remove
```

To determine previous versions:
```
pnpm run release util:previous-version 4.2.1
Getting previous version from: 4.2.1...
4.2.0
```

or

```
pnpm run release util:previous-version
Getting previous version...
4.4.2
```


## Test plan

I did a lot of manual testing since so many commands were impacted. Some
example output is below, and here is an example of a fake issue that was
generated from the release tool:
https://github.com/sourcegraph/sourcegraph/issues/47760

Example of how the input will flow if the config is not in the correct
state, including version suggestions:
```
> @sourcegraph/dev-release@0.0.1 release /Users/coury@sourcegraph.com/Documents/code/sourcegraph/dev/release
> ts-node --transpile-only ./src/main.ts "release:status"

No active releases are defined! Attempting to activate...
Next minor release: 4.5.0
Next patch release: 4.4.3
Enter the version to activate: 
```

```
> @sourcegraph/dev-release@0.0.1 release /Users/coury@sourcegraph.com/Documents/code/sourcegraph/dev/release
> ts-node --transpile-only ./src/main.ts "release:status"

No active releases are defined! Attempting to activate...
Next minor release: 4.5.0
Next patch release: 4.4.3
Enter the version to activate: 4.4.3
Attempting to detect previous version...
Detected previous version: 4.4.2
Release definition not found for: 4.4.3, enter release information.

Enter the release date (YYYY-MM-DD). Enter blank to use current date: 
Using current time: 2023-02-16T11:01:34.710-08:00
Enter the github username of the release captain: coury-clark
Enter the slack username of the release captain: coury
Version created:
{
  "codeFreezeDate": "2023-02-09T10:01:34.710-08:00",
  "securityApprovalDate": "2023-02-09T10:01:34.710-08:00",
  "releaseDate": "2023-02-16T10:01:34.710-08:00",
  "current": "4.4.3",
  "captainGitHubUsername": "coury-clark",
  "captainSlackUsername": "coury"
}
Release: 4.4.3 activated!
```


<!-- All pull requests REQUIRE a test plan:
https://docs.sourcegraph.com/dev/background-information/testing_principles
-->

## App preview:

-
[Web](https://sg-web-cclark-refactor-release-config.onrender.com/search)

Check out the [client app preview
documentation](https://docs.sourcegraph.com/dev/how-to/client_pr_previews)
to learn more.
2023-02-17 11:00:42 -07:00

253 lines
8.4 KiB
TypeScript

import { readFileSync, writeFileSync } from 'fs'
import chalk from 'chalk'
import { parse as parseJSONC } from 'jsonc-parser'
import { DateTime } from 'luxon'
import * as semver from 'semver'
import { SemVer } from 'semver'
import { getPreviousVersion } from './git'
import { retryInput } from './util'
const releaseConfigPath = 'release-config.jsonc'
/**
* Release configuration file format
*/
export interface Config {
teamEmail: string
captainSlackUsername: string
captainGitHubUsername: string
previousRelease: string
upcomingRelease: string
oneWorkingWeekBeforeRelease: string
threeWorkingDaysBeforeRelease: string
releaseDate: string
oneWorkingDayAfterRelease: string
oneWorkingWeekAfterRelease: string
slackAnnounceChannel: string
dryRun: {
tags?: boolean
changesets?: boolean
trackingIssues?: boolean
slack?: boolean
calendar?: boolean
}
}
export async function getActiveRelease(config: ReleaseConfig): Promise<ActiveRelease> {
if (!config.in_progress || config.in_progress.releases.length === 0) {
console.log(chalk.yellow('No active releases are defined! Attempting to activate...'))
await activateRelease(config)
}
if (!config.in_progress) {
throw new Error('unable to activate a release!')
}
if (config.in_progress.releases.length > 1) {
throw new Error(
chalk.red(
'The release config has multiple versions activated. This feature is not yet supported by the release tool! Please activate only a single release.'
)
)
}
const rel = config.in_progress.releases[0]
const def = config.scheduledReleases[rel.version]
const version = new SemVer(rel.version)
return {
version,
previous: new SemVer(rel.previous),
...(def as ReleaseDates),
...(def as ReleaseCaptainInformation),
branch: `${version.minor}.${version.minor}`,
}
}
export function loadReleaseConfig(): ReleaseConfig {
return parseJSONC(readFileSync(releaseConfigPath).toString()) as ReleaseConfig
}
export function saveReleaseConfig(config: ReleaseConfig): void {
writeFileSync(releaseConfigPath, JSON.stringify(config, null, 2))
}
export function newRelease(
version: SemVer,
releaseDate: DateTime,
captainGithub: string,
captainSlack: string
): ScheduledReleaseDefinition {
return {
...releaseDates(releaseDate),
current: version.version,
captainGitHubUsername: captainGithub,
captainSlackUsername: captainSlack,
}
}
export async function newReleaseFromInput(versionOverride?: SemVer): Promise<ScheduledReleaseDefinition> {
let version = versionOverride
if (!version) {
version = await selectVersionWithSuggestion('Enter the desired version number')
}
const releaseDateStr = await retryInput(
'Enter the release date (YYYY-MM-DD). Enter blank to use current date: ',
val => {
if (val && /^\d{4}-\d{2}-\d{2}$/.test(val)) {
return true
}
// this will return false if the input doesn't match the regexp above but does exist, allowing blank input to still be valid
return !val
},
'invalid date, expected format YYYY-MM-DD'
)
let releaseTime: DateTime
if (!releaseDateStr) {
releaseTime = DateTime.now().setZone('America/Los_Angeles')
console.log(chalk.blue(`Using current time: ${releaseTime.toString()}`))
} else {
releaseTime = DateTime.fromISO(releaseDateStr, { zone: 'America/Los_Angeles' })
}
const captainGithubUsername = await retryInput('Enter the github username of the release captain: ', val => !!val)
const captainSlackUsername = await retryInput('Enter the slack username of the release captain: ', val => !!val)
const rel = newRelease(version, releaseTime, captainGithubUsername, captainSlackUsername)
console.log(chalk.green('Version created:'))
console.log(chalk.green(JSON.stringify(rel, null, 2)))
return rel
}
function releaseDates(releaseDate: DateTime): ReleaseDates {
releaseDate = releaseDate.set({ hour: 10 })
return {
codeFreezeDate: releaseDate.plus({ days: -7 }).toString(),
securityApprovalDate: releaseDate.plus({ days: -7 }).toString(),
releaseDate: releaseDate.toString(),
}
}
export function addScheduledRelease(config: ReleaseConfig, release: ScheduledReleaseDefinition): ReleaseConfig {
config.scheduledReleases[release.current] = release
return config
}
export function removeScheduledRelease(config: ReleaseConfig, version: string): ReleaseConfig {
delete config.scheduledReleases[version]
return config
}
export interface ReleaseDates {
releaseDate: string
codeFreezeDate: string
securityApprovalDate: string
}
export interface ActiveRelease extends ReleaseCaptainInformation, ReleaseDates {
version: SemVer
previous: SemVer
branch: string
}
export interface ActiveReleaseDefinition {
version: string
previous: string
}
export interface ReleaseCaptainInformation {
captainSlackUsername: string
captainGitHubUsername: string
}
export interface InProgress extends ReleaseCaptainInformation {
releases: ActiveReleaseDefinition[]
}
export interface ReleaseConfig {
metadata: {
teamEmail: string
slackAnnounceChannel: string
}
scheduledReleases: {
[version: string]: ScheduledReleaseDefinition
}
in_progress?: InProgress
dryRun: {
tags?: boolean
changesets?: boolean
trackingIssues?: boolean
slack?: boolean
calendar?: boolean
}
}
export interface ScheduledReleaseDefinition extends ReleaseDates, ReleaseCaptainInformation {
current: string
}
// Prompt a user for input and activate the given release version if possible. Will redirect to release creation input if
// the version isn't defined.
export async function activateRelease(config: ReleaseConfig): Promise<void> {
const next = await selectVersionWithSuggestion('Enter the version to activate')
console.log('Attempting to detect previous version...')
const previous = getPreviousVersion(next)
console.log(chalk.blue(`Detected previous version: ${previous.version}`))
const scheduled = await getScheduledReleaseWithInput(config, next)
config.in_progress = {
captainGitHubUsername: scheduled.captainGitHubUsername,
captainSlackUsername: scheduled.captainSlackUsername,
releases: [{ version: next.version, previous: previous.version }],
}
saveReleaseConfig(config)
console.log(chalk.green(`Release: ${next.version} activated!`))
}
export function deactivateAllReleases(config: ReleaseConfig): void {
delete config.in_progress
saveReleaseConfig(config)
}
// Prompt a user for a major / minor version input with automation suggestion by adding a minor version to the previous version.
async function selectVersionWithSuggestion(prompt: string): Promise<SemVer> {
const probablyMinor = getPreviousVersion().inc('minor')
const probablyPatch = getPreviousVersion().inc('patch')
const input = await retryInput(
`Next minor release: ${probablyMinor.version}\nNext patch release: ${probablyPatch.version}\n${chalk.blue(
prompt
)}: `,
val => {
const version = semver.parse(val)
return !!version
}
)
return new SemVer(input)
}
// Prompt a user for a release definition input, and redirect to creation input if it doesn't exist.
export async function getReleaseDefinition(config: ReleaseConfig): Promise<ScheduledReleaseDefinition> {
const next = await selectVersionWithSuggestion('Enter the version number to select')
return getScheduledReleaseWithInput(config, next)
}
// Helper function to get a release definition from the release config, redirecting to creation input if it doesn't exist.
async function getScheduledReleaseWithInput(
config: ReleaseConfig,
releaseVersion: SemVer
): Promise<ScheduledReleaseDefinition> {
let scheduled = config.scheduledReleases[releaseVersion.version]
if (!scheduled) {
console.log(
chalk.yellow(`Release definition not found for: ${releaseVersion.version}, enter release information.\n`)
)
scheduled = await newReleaseFromInput(releaseVersion)
addScheduledRelease(config, scheduled)
}
return scheduled
}