dev/release: improve automation for ensuring followup (#24614)

Aims to improve some processes and automation around release follow-ups.

- Update patch request template to be clearer about actions for release captains, including documenting follow-ups.
- `yarn release tracking:issues`: Automatically close out old release tracking issues if subsequent ones are created after leaving a comment linking to the new release. We often have leftover tracking issues, especially for managed instances.
- `yarn release release:finalize`: Add outstanding patch request issues to comment for potential follow-up
- `yarn release release:finalize`: Add link to generated comment for visibility (post-release steps often get skipped)
- Backlink to tracking issue in release campaign

<!-- Reminder: Have you updated the changelog and relevant docs (user docs, architecture diagram, etc) ? -->
<!-- Please notify @distrubution if this PR contains changes to CI that may need to be cherry-picked on to patch release branches -->
This commit is contained in:
Robert Lin 2021-09-09 15:52:57 -04:00 committed by GitHub
parent 3c1d1fd2c1
commit 37ef3413e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 192 additions and 93 deletions

View File

@ -9,10 +9,12 @@ assignees: ''
@sourcegraph/distribution I am requesting the following commits be included in a patch release. They are already merged into `main`:
The intent of the questions below is to ensure we keep Sourcegraph high quality and [only create patch releases based on a strict criteria.](https://about.sourcegraph.com/handbook/engineering/releases#when-are-patch-releases-performed) If you can answer yes to many or most of these questions, we will be happy to create the patch release.
- <!-- LINK TO EXACT MERGED COMMITS HERE -->
---
The intent of the questions below is to ensure we keep Sourcegraph high quality and [only create patch releases based on a strict criteria.](https://about.sourcegraph.com/handbook/engineering/releases#when-are-patch-releases-performed) If you can answer yes to many or most of these questions, we will be happy to create the patch release.
I have read [when and why we perform patch releases](https://about.sourcegraph.com/handbook/engineering/releases#when-are-patch-releases-performed) and answer the questions as follows:
> Are users/customers actively asking us for these changes and cannot wait until the next full release?
@ -39,12 +41,13 @@ I have read [when and why we perform patch releases](https://about.sourcegraph.c
---
**For the [release captain](https://about.sourcegraph.com/handbook/engineering/releases#release-captain)** - after reviewing and approving this request:
**For the [release captain](https://about.sourcegraph.com/handbook/engineering/releases#release-captain)** - after reviewing this request:
- If there is [already an upcoming patch release](https://github.com/sourcegraph/sourcegraph/issues?q=is%3Aissue+label%3Arelease-tracking+), add the listed commits alongside a link to this issue
- If there is no upcoming patch release, create a new one:
- Update [`dev/release/release-config.jsonc`](https://sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/dev/release/release-config.jsonc) with the patch release in `upcomingRelease` and `releaseDate` (and open a PR to `main` to update it)
- `yarn release tracking:issues`
- Add the listed commits alongside a link to this issue to the generated [release tracking issue](https://github.com/sourcegraph/sourcegraph/issues?q=is%3Aissue+label%3Arelease-tracking+)
Comment and close this issue once the relevant commit(s) have been cherry-picked into the release branch.
- [ ] **Comment on this issue** with a decision regarding the request.
- [ ] If approved, **add it to a patch release**:
- If there is [already an upcoming patch release](https://github.com/sourcegraph/sourcegraph/issues?q=is%3Aissue+label%3Arelease-tracking+), add the listed commits alongside a link to this issue
- If there is no upcoming patch release, create a new one:
- Update [`dev/release/release-config.jsonc`](https://sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/dev/release/release-config.jsonc) with the patch release in `upcomingRelease` and `releaseDate` (and open a PR to `main` to update it)
- `yarn release tracking:issues`
- Add the listed commits alongside a link to this issue to the generated [release tracking issue](https://github.com/sourcegraph/sourcegraph/issues?q=is%3Aissue+label%3Arelease-tracking+)
- [ ] **Comment and close this issue once the relevant commit(s) have been cherry-picked into the release branch**.

View File

@ -31,6 +31,7 @@
"tags": false,
"changesets": false,
"trackingIssues": false,
"calendar": false
"calendar": false,
"slack": false
}
}

View File

@ -33,7 +33,6 @@ export async function sourcegraphCLIConfig(): Promise<SourcegraphCLIConfig> {
*/
export interface BatchChangeOptions {
name: string
description: string
namespace: string
cliConfig: SourcegraphCLIConfig
}
@ -44,7 +43,6 @@ export interface BatchChangeOptions {
export function releaseTrackingBatchChange(version: string, cliConfig: SourcegraphCLIConfig): BatchChangeOptions {
return {
name: `release-sourcegraph-${version}`,
description: `Track publishing of sourcegraph@${version}`,
namespace: 'sourcegraph',
cliConfig,
}
@ -62,7 +60,11 @@ export function batchChangeURL(options: BatchChangeOptions): string {
/**
* Create a new batch change from a set of changes.
*/
export async function createBatchChange(changes: CreatedChangeset[], options: BatchChangeOptions): Promise<void> {
export async function createBatchChange(
changes: CreatedChangeset[],
options: BatchChangeOptions,
description: string
): Promise<void> {
// create a batch change spec
const importChangesets = changes.map(change => ({
repository: `github.com/${change.repository}`,
@ -72,7 +74,7 @@ export async function createBatchChange(changes: CreatedChangeset[], options: Ba
return await applyBatchChange(
{
name: options.name,
description: options.description,
description,
importChangesets,
},
options

View File

@ -27,6 +27,7 @@ export interface Config {
tags?: boolean
changesets?: boolean
trackingIssues?: boolean
slack?: boolean
calendar?: boolean
}
}

View File

@ -27,8 +27,23 @@ export function releaseName(release: semver.SemVer): string {
return `${release.major}.${release.minor}${release.patch !== 0 ? `.${release.patch}` : ''}`
}
// https://github.com/sourcegraph/sourcegraph/labels/release-tracking
const labelReleaseTracking = 'release-tracking'
export enum IssueLabel {
// https://github.com/sourcegraph/sourcegraph/labels/release-tracking
RELEASE_TRACKING = 'release-tracking',
// https://github.com/sourcegraph/sourcegraph/labels/patch-release-request
PATCH_REQUEST = 'patch-release-request',
// New labels to better distinguish release-tracking issues
RELEASE = 'release',
PATCH = 'patch',
MANAGED = 'managed-instances',
}
enum IssueTitleSuffix {
RELEASE_TRACKING = 'release tracking issue',
PATCH_TRACKING = 'patch release tracking issue',
MANAGED_TRACKING = 'upgrade managed instances tracking issue',
}
/**
* Template used to generate tracking issue
@ -45,7 +60,7 @@ interface IssueTemplate {
/**
* Title for issue.
*/
title: (v: semver.SemVer) => string
titleSuffix: IssueTitleSuffix
/**
* Labels to apply on issues.
*/
@ -74,6 +89,37 @@ interface IssueTemplateArguments {
oneWorkingDayAfterRelease: Date
}
/**
* Configure templates for the release tool to generate issues with.
*
* Ensure these templates are up to date with the state of the tooling and release processes.
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const getTemplates = () => {
const releaseIssue: IssueTemplate = {
owner: 'sourcegraph',
repo: 'about',
path: 'handbook/engineering/releases/release_issue_template.md',
titleSuffix: IssueTitleSuffix.RELEASE_TRACKING,
labels: [IssueLabel.RELEASE_TRACKING, IssueLabel.RELEASE],
}
const patchReleaseIssue: IssueTemplate = {
owner: 'sourcegraph',
repo: 'about',
path: 'handbook/engineering/releases/patch_release_issue_template.md',
titleSuffix: IssueTitleSuffix.PATCH_TRACKING,
labels: [IssueLabel.RELEASE_TRACKING, IssueLabel.PATCH],
}
const upgradeManagedInstanceIssue: IssueTemplate = {
owner: 'sourcegraph',
repo: 'about',
path: 'handbook/engineering/releases/upgrade_managed_issue_template.md',
titleSuffix: IssueTitleSuffix.MANAGED_TRACKING,
labels: [IssueLabel.RELEASE_TRACKING, IssueLabel.MANAGED],
}
return { releaseIssue, patchReleaseIssue, upgradeManagedInstanceIssue }
}
async function execTemplate(
octokit: Octokit,
template: IssueTemplate,
@ -97,40 +143,10 @@ async function execTemplate(
)
}
/**
* Configure templates for the release tool to generate issues with.
*
* Ensure these templates are up to date with the state of the tooling and release processes.
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const getTemplates = () => {
const releaseIssue: IssueTemplate = {
owner: 'sourcegraph',
repo: 'about',
path: 'handbook/engineering/releases/release_issue_template.md',
title: trackingIssueTitle,
labels: [labelReleaseTracking],
}
const patchReleaseIssue: IssueTemplate = {
owner: 'sourcegraph',
repo: 'about',
path: 'handbook/engineering/releases/patch_release_issue_template.md',
title: trackingIssueTitle,
labels: [labelReleaseTracking],
}
const upgradeManagedInstanceIssue: IssueTemplate = {
owner: 'sourcegraph',
repo: 'about',
path: 'handbook/engineering/releases/upgrade_managed_issue_template.md',
title: (version: semver.SemVer) => `${version.version} upgrade managed instances tracking issue`,
labels: [labelReleaseTracking, 'managed-instances'],
}
return { releaseIssue, patchReleaseIssue, upgradeManagedInstanceIssue }
}
interface MaybeIssue {
title: string
url: string
number: number
created: boolean
}
@ -192,7 +208,7 @@ export async function ensureTrackingIssues({
const issue = await ensureIssue(
octokit,
{
title: template.title(version),
title: trackingIssueTitle(version, template),
labels: template.labels,
body: parentIssue ? `${body}\n\n---\n\nAlso see [${parentIssue.title}](${parentIssue.url})` : body,
assignees,
@ -207,6 +223,20 @@ export async function ensureTrackingIssues({
parentIssue = { ...issue }
}
created.push({ ...issue })
// close previous iterations of this issue
const previous = await queryIssues(octokit, template.titleSuffix, template.labels)
for (const previousIssue of previous) {
if (dryRun) {
console.log(`dryRun enabled, skipping closure of #${previousIssue.number} '${previousIssue.title}'`)
continue
}
const comment = await commentOnIssue(octokit, previousIssue, `Superseded by #${issue.number}`)
console.log(
`Closing #${previousIssue.number} '${previousIssue.title}' - commented with an update: ${comment}`
)
await closeIssue(octokit, previousIssue)
}
}
return created
}
@ -243,7 +273,7 @@ async function ensureIssue(
assignees: string[]
body: string
milestone?: number
labels?: string[]
labels: string[]
},
dryRun: boolean
): Promise<MaybeIssue> {
@ -255,18 +285,18 @@ async function ensureIssue(
milestone,
labels,
}
const issue = await getIssueByTitle(octokit, title)
const issue = await getIssueByTitle(octokit, title, labels)
if (issue) {
return { title, url: issue.url, created: false }
return { title, url: issue.url, number: issue.number, created: false }
}
if (dryRun) {
console.log('Dry run enabled, skipping issue creation')
console.log(`Issue that would have been created:\n${JSON.stringify(issueData, null, 1)}`)
console.log(`With body: ${body}`)
return { title, url: '', created: false }
return { title, url: '', number: 0, created: false }
}
const createdIssue = await octokit.issues.create({ body, ...issueData })
return { title, url: createdIssue.data.html_url, created: true }
return { title, url: createdIssue.data.html_url, number: createdIssue.data.number, created: true }
}
export async function listIssues(
@ -277,6 +307,7 @@ export async function listIssues(
}
export interface Issue {
title: string
number: number
url: string
@ -286,7 +317,32 @@ export interface Issue {
}
export async function getTrackingIssue(client: Octokit, release: semver.SemVer): Promise<Issue | null> {
return getIssueByTitle(client, trackingIssueTitle(release))
const templates = getTemplates()
const template = release.patch ? templates.patchReleaseIssue : templates.releaseIssue
return getIssueByTitle(client, trackingIssueTitle(release, template), template.labels)
}
function trackingIssueTitle(release: semver.SemVer, template: IssueTemplate): string {
return `${release.version} ${template.titleSuffix}`
}
export async function commentOnIssue(client: Octokit, issue: Issue, body: string): Promise<string> {
const comment = await client.issues.createComment({
body,
issue_number: issue.number,
owner: issue.owner,
repo: issue.repo,
})
return comment.data.url
}
async function closeIssue(client: Octokit, issue: Issue): Promise<void> {
await client.issues.update({
state: 'closed',
issue_number: issue.number,
owner: issue.owner,
repo: issue.repo,
})
}
interface Milestone {
@ -319,29 +375,33 @@ async function getReleaseMilestone(client: Octokit, release: semver.SemVer): Pro
: null
}
function trackingIssueTitle(version: semver.SemVer): string {
if (!version.patch) {
return `${version.major}.${version.minor} release tracking issue`
}
return `${version.version} patch release tracking issue`
}
async function getIssueByTitle(octokit: Octokit, title: string): Promise<Issue | null> {
export async function queryIssues(octokit: Octokit, titleQuery: string, labels: string[]): Promise<Issue[]> {
const owner = 'sourcegraph'
const repo = 'sourcegraph'
const response = await octokit.search.issuesAndPullRequests({
per_page: 100,
q: `type:issue repo:${owner}/${repo} is:open ${JSON.stringify(title)}`,
q: `type:issue repo:${owner}/${repo} is:open ${labels
.map(label => `label:${label}`)
.join(' ')} ${JSON.stringify(titleQuery)}`,
})
return response.data.items.map(item => ({
title: item.title,
number: item.number,
url: item.html_url,
owner,
repo,
}))
}
const matchingIssues = response.data.items.filter(issue => issue.title === title)
async function getIssueByTitle(octokit: Octokit, title: string, labels: string[]): Promise<Issue | null> {
const matchingIssues = (await queryIssues(octokit, title, labels)).filter(issue => issue.title === title)
if (matchingIssues.length === 0) {
return null
}
if (matchingIssues.length > 1) {
throw new Error(`Multiple issues matched issue title ${JSON.stringify(title)}`)
}
return { number: matchingIssues[0].number, url: matchingIssues[0].html_url, owner, repo }
return matchingIssues[0]
}
export type EditFunc = (d: string) => void

View File

@ -16,6 +16,9 @@ import {
createTag,
ensureTrackingIssues,
releaseName,
commentOnIssue,
queryIssues,
IssueLabel,
} from './github'
import { ensureEvent, getClient, EventOptions, calendarTime } from './google-calendar'
import { postMessage, slackURL } from './slack'
@ -186,8 +189,10 @@ ${trackingIssues.map(index => `- ${slackURL(index.title, index.url)}`).join('\n'
patchRequestTemplate
)}, or it will not be included.`
}
await postMessage(annoncement, slackAnnounceChannel)
console.log(`Posted to Slack channel ${slackAnnounceChannel}`)
if (!dryRun.slack) {
await postMessage(annoncement, slackAnnounceChannel)
console.log(`Posted to Slack channel ${slackAnnounceChannel}`)
}
} else {
console.log('No tracking issues were created, skipping Slack announcement')
}
@ -265,7 +270,9 @@ ${trackingIssues.map(index => `- ${slackURL(index.title, index.url)}`).join('\n'
* Tracking issue: ${trackingIssue.url}
* ${blockingMessage}: ${blockingIssuesURL}`
await postMessage(message, config.slackAnnounceChannel)
if (!config.dryRun.slack) {
await postMessage(message, config.slackAnnounceChannel)
}
},
},
{
@ -317,8 +324,7 @@ ${trackingIssues.map(index => `- ${slackURL(index.title, index.url)}`).join('\n'
const batchChangeURL = batchChanges.batchChangeURL(batchChange)
const trackingIssue = await getTrackingIssue(await getAuthenticatedGitHubClient(), release)
if (!trackingIssue) {
// Do not block release staging on lack of tracking issue
console.error(`Tracking issue for version ${release.version} not found - has it been created yet?`)
throw new Error(`Tracking issue for version ${release.version} not found - has it been created yet?`)
}
// default PR content
@ -492,19 +498,25 @@ cc @${config.captainGitHubUsername}
// Create batch change to track changes
try {
console.log(`Creating batch change in ${batchChange.cliConfig.SRC_ENDPOINT}`)
await batchChanges.createBatchChange(createdChanges, batchChange)
await batchChanges.createBatchChange(
createdChanges,
batchChange,
`Track publishing of sourcegraph v${release.version}: ${trackingIssue?.url}`
)
} catch (error) {
console.error(error)
console.error('Failed to create batch change for this release, continuing with announcement')
}
// Announce release update in Slack
await postMessage(
`:captain: *Sourcegraph ${release.version} has been staged.*
if (!dryRun.slack) {
await postMessage(
`:captain: *Sourcegraph ${release.version} has been staged.*
Batch change: ${batchChangeURL}`,
slackAnnounceChannel
)
slackAnnounceChannel
)
}
}
},
},
@ -537,7 +549,7 @@ Batch change: ${batchChangeURL}`,
},
{
id: 'release:finalize',
description: 'Run final tasks for the sourcegraph/sourcegraph release pull request',
description: 'Run final tasks for sourcegraph/sourcegraph release pull requests',
run: async config => {
const { upcoming: release } = await releaseVersions(config)
let failed = false
@ -573,7 +585,7 @@ Batch change: ${batchChangeURL}`,
id: 'release:close',
description: 'Mark a release as closed',
run: async config => {
const { slackAnnounceChannel } = config
const { slackAnnounceChannel, dryRun } = config
const { upcoming: release } = await releaseVersions(config)
const githubClient = await getAuthenticatedGitHubClient()
@ -588,22 +600,36 @@ Batch change: ${batchChangeURL}`,
* Release batch change: ${batchChangeURL}`
// Slack
await postMessage(`:captain: ${releaseMessage}`, slackAnnounceChannel)
console.log(`Posted to Slack channel ${slackAnnounceChannel}`)
const slackMessage = `:captain: ${releaseMessage}`
if (!dryRun.slack) {
await postMessage(slackMessage, slackAnnounceChannel)
console.log(`Posted to Slack channel ${slackAnnounceChannel}`)
} else {
console.log(`dryRun enabled, skipping Slack post to ${slackAnnounceChannel}: ${slackMessage}`)
}
// GitHub
// GitHub tracking issues
const trackingIssue = await getTrackingIssue(githubClient, release)
if (!trackingIssue) {
console.warn(`Could not find tracking issue for release ${release.version} - skipping`)
} else {
await githubClient.issues.createComment({
owner: trackingIssue.owner,
repo: trackingIssue.repo,
issue_number: trackingIssue.number,
body: `${releaseMessage}
// Note patch release requests if there are any outstanding
let comment = `${releaseMessage}
@${config.captainGitHubUsername}: Please complete the post-release steps before closing this issue.`,
})
@${config.captainGitHubUsername}: Please complete the post-release steps before closing this issue.`
const patchRequestIssues = await queryIssues(githubClient, '', [IssueLabel.PATCH_REQUEST])
if (patchRequestIssues && patchRequestIssues.length > 0) {
comment += `
Please also update outstanding patch requests, if relevant:
${patchRequestIssues.map(issue => `* #${issue.number}`).join('\n')}`
}
if (!dryRun.trackingIssues) {
const commentURL = await commentOnIssue(githubClient, trackingIssue, comment)
console.log(`Please make sure to follow up on the release issue: ${commentURL}`)
} else {
console.log(`dryRun enabled, skipping GitHub comment to ${trackingIssue.url}: ${comment}`)
}
}
},
},
@ -634,8 +660,10 @@ Batch change: ${batchChangeURL}`,
id: '_test:slack',
description: 'Test Slack integration',
argNames: ['channel', 'message'],
run: async (_config, channel, message) => {
await postMessage(message, channel)
run: async ({ dryRun }, channel, message) => {
if (!dryRun.slack) {
await postMessage(message, channel)
}
},
},
{
@ -659,7 +687,11 @@ Batch change: ${batchChangeURL}`,
cliConfig: await batchChanges.sourcegraphCLIConfig(),
}
await batchChanges.createBatchChange(batchChangeConfig.changes, batchChange)
await batchChanges.createBatchChange(
batchChangeConfig.changes,
batchChange,
'release tool testing batch change'
)
console.log(`Created batch change ${batchChanges.batchChangeURL(batchChange)}`)
},
},
@ -673,7 +705,7 @@ Batch change: ${batchChangeURL}`,
{
id: '_test:dockerensure',
description: 'test docker ensure function',
run: async config => {
run: async () => {
try {
await ensureDocker()
} catch (error) {