diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= index 093c22887ec..76e53ff0879 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= @@ -25,5 +25,4 @@ client/testing/package.json=366497236 client/web/package.json=-1637464847 client/wildcard/package.json=362918481 client/vscode/package.json=1345248728 -dev/release/package.json=1426426960 pnpm-workspace.yaml=-69372893 diff --git a/.bazelignore b/.bazelignore index 9adbc47f649..49dfd63b650 100644 --- a/.bazelignore +++ b/.bazelignore @@ -24,7 +24,6 @@ client/testing/node_modules client/web/node_modules client/web-sveltekit/node_modules client/wildcard/node_modules -dev/release/node_modules cmd/symbols/squirrel/test_repos/starlark diff --git a/.prettierignore b/.prettierignore index 722f85d2849..8d46769aa3e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -59,5 +59,4 @@ dev/backcompat/flakes.json # Errors when used with @ianvs/prettier-plugin-sort-imports due to TS language API hitting global `eval` client/browser/src/types/webextension-polyfill/index.d.ts -dev/release/release-config.jsonc graphql-operations.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 0b9091383fa..2fee1798853 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,7 +45,6 @@ "typescript.format.semicolons": "remove", "typescript.tsc.autoDetect": "off", "typescript.tsdk": "node_modules/typescript/lib", - "eslint.packageManager": "pnpm", "eslint.lintTask.enable": false, "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.codeActionsOnSave": {}, @@ -53,7 +52,7 @@ "eslint.options": { "cache": true }, - "eslint.workingDirectories": ["./dev/release", "./client/*"], + "eslint.workingDirectories": ["./client/*"], "go.lintTool": "golangci-lint", "shellformat.flag": "-i 2 -ci", "vscode-graphql.useSchemaFileDefinitions": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9060a77ed76..cf02aae2498 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -85,13 +85,6 @@ "path": "client/web/", "problemMatcher": ["$eslint-stylish"] }, - { - "label": "eslint:release", - "type": "npm", - "script": "eslint", - "path": "dev/release/", - "problemMatcher": ["$eslint-stylish"] - }, { "label": "eslint:extension-api", "type": "npm", diff --git a/dev/ci/internal/ci/changed/diff.go b/dev/ci/internal/ci/changed/diff.go index e639abf7257..806bef0b8f7 100644 --- a/dev/ci/internal/ci/changed/diff.go +++ b/dev/ci/internal/ci/changed/diff.go @@ -101,12 +101,6 @@ func ParseDiff(files []string) (diff Diff, changedFiles ChangedFiles) { diff |= Pnpm } - // dev/release contains a nodejs script that doesn't have tests but needs to be - // linted with Client linters. We skip the release config file to reduce friction editing during releases. - if strings.HasPrefix(p, "dev/release/") && !strings.Contains(p, "release-config") { - diff |= Client - } - // Affects GraphQL if strings.HasSuffix(p, ".graphql") { diff |= GraphQL diff --git a/dev/foreach-non-web-client-project.sh b/dev/foreach-non-web-client-project.sh index 4bc2fd9287b..8ec1d2958d0 100755 --- a/dev/foreach-non-web-client-project.sh +++ b/dev/foreach-non-web-client-project.sh @@ -17,7 +17,7 @@ DIRS=( client/build-config client/client-api client/codeintellify - client/common + client/common client/extension-api client/extension-api-types client/http-client @@ -30,7 +30,6 @@ DIRS=( client/testing client/vscode client/wildcard - dev/release ) # Keep the list of client workspaces in alphabetical order! diff --git a/dev/release/.eslintrc.js b/dev/release/.eslintrc.js deleted file mode 100644 index 25cc7500fb0..00000000000 --- a/dev/release/.eslintrc.js +++ /dev/null @@ -1,10 +0,0 @@ -const baseConfig = require('../../.eslintrc') -module.exports = { - extends: '../../.eslintrc.js', - parserOptions: { - ...baseConfig.parserOptions, - project: __dirname + '/tsconfig.json', - }, - rules: { 'no-console': 'off' }, - overrides: baseConfig.overrides, -} diff --git a/dev/release/.gitignore b/dev/release/.gitignore deleted file mode 100644 index 8d5279b97b4..00000000000 --- a/dev/release/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.secrets/ diff --git a/dev/release/.yarn/install-state.gz b/dev/release/.yarn/install-state.gz deleted file mode 100644 index 5e6b0e956f4..00000000000 Binary files a/dev/release/.yarn/install-state.gz and /dev/null differ diff --git a/dev/release/README.md b/dev/release/README.md deleted file mode 100644 index e1a1d2a7340..00000000000 --- a/dev/release/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Sourcegraph release tool - -This directory contains scripts and code to automate our releases. Refer to -[the handbook](https://handbook.sourcegraph.com/engineering/releases) for details -on our release process and how this tool is used. - -To see all available steps: - -```sh -pnpm run release help # add 'all' to see test commands as well -``` - -Before using this tool, please verify that the [release configuration](./release-config.jsonc) -is set up correctly. diff --git a/dev/release/package.json b/dev/release/package.json deleted file mode 100644 index 1fedb76e847..00000000000 --- a/dev/release/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "private": true, - "name": "@sourcegraph/dev-release", - "version": "0.0.1", - "description": "Scripts for managing release captain duties", - "scripts": { - "release": "ts-node --transpile-only ./src/main.ts", - "lint:js": "eslint --cache 'src/**/*.[jt]s?(x)'" - }, - "dependencies": { - "@types/luxon": "^3.2.0", - "luxon": "^3.2.1" - } -} diff --git a/dev/release/src/batchChanges.ts b/dev/release/src/batchChanges.ts deleted file mode 100644 index 10b44f22f18..00000000000 --- a/dev/release/src/batchChanges.ts +++ /dev/null @@ -1,154 +0,0 @@ -import commandExists from 'command-exists' -import execa from 'execa' -import fetch from 'node-fetch' -import YAML from 'yaml' - -import type { CreatedChangeset } from './github' -import { readLine, cacheFolder } from './util' - -// https://handbook.sourcegraph.com/departments/engineering/dev/process/deployments/instances/#sourcegraphsourcegraphcom-s2 -const DEFAULT_SRC_ENDPOINT = 'https://sourcegraph.sourcegraph.com' - -interface SourcegraphCLIConfig { - SRC_ENDPOINT: string - SRC_ACCESS_TOKEN: string - [index: string]: string -} - -/** - * Retrieves src-cli configuration and ensures src-cli exists. - */ -export async function sourcegraphCLIConfig(): Promise { - await commandExists('src') // CLI must be present for batch change interactions - return { - SRC_ENDPOINT: DEFAULT_SRC_ENDPOINT, - // I updated the file name here to avoid a situation where folks with existing s2 token - // cached will get a 403 because it's not valid for S2. - SRC_ACCESS_TOKEN: await readLine('s2 src-cli token: ', `${cacheFolder}/src-cli-s2.txt`), - } -} - -/** - * Parameters defining a batch change to interact with. - * - * Generate `cliConfig` using `sourcegraphCLIConfig()`. - */ -export interface BatchChangeOptions { - name: string - namespace: string - cliConfig: SourcegraphCLIConfig -} - -/** - * Generate batch change configuration for a given release. - */ -export function releaseTrackingBatchChange(version: string, cliConfig: SourcegraphCLIConfig): BatchChangeOptions { - return { - name: `release-sourcegraph-${version}`, - namespace: 'sourcegraph', - cliConfig, - } -} - -/** - * Generates a URL for a batch change that would be created under the given camapign options. - * - * Does not ensure the batch change exists. - */ -export function batchChangeURL(options: BatchChangeOptions): string { - return `${options.cliConfig.SRC_ENDPOINT}/organizations/${options.namespace}/batch-changes/${options.name}` -} - -/** - * Create a new batch change from a set of changes. - */ -export async function createBatchChange( - changes: CreatedChangeset[], - options: BatchChangeOptions, - description: string -): Promise { - // create a batch change spec - const importChangesets = changes.map(change => ({ - repository: `github.com/${change.repository}`, - externalIDs: [change.pullRequestNumber], - })) - // apply batch change - // eslint-disable-next-line @typescript-eslint/return-await - return await applyBatchChange( - { - name: options.name, - description, - importChangesets, - }, - options - ) -} - -/** - * Append changes to an existing batch change. - */ -export async function addToBatchChange( - changes: { repository: string; pullRequestNumber: number }[], - options: BatchChangeOptions -): Promise { - const response = await fetch(`${options.cliConfig.SRC_ENDPOINT}/.api/graphql`, { - method: 'POST', - headers: { - Authorization: `token ${options.cliConfig.SRC_ACCESS_TOKEN}`, - }, - body: JSON.stringify({ - query: `query getBatchChanges($namespace:String!) { - organization(name:$namespace) { - batchChanges(first:99) { - nodes { name currentSpec { originalInput } } - } - } - }`, - variables: { - namespace: options.namespace, - }, - }), - }) - const { - data: { - organization: { - batchChanges: { nodes: results }, - }, - }, - } = (await response.json()) as { - data: { organization: { batchChanges: { nodes: { name: string; currentSpec: { originalInput: string } }[] } } } - } - const batchChange = results.find(result => result.name === options.name) - if (!batchChange) { - throw new Error(`Cannot find batch change ${options.name}`) - } - - const importChangesets = changes.map(change => ({ - repository: `github.com/${change.repository}`, - externalIDs: [change.pullRequestNumber], - })) - const newSpec = YAML.parse(batchChange.currentSpec.originalInput) as BatchChangeSpec - newSpec.importChangesets.push(...importChangesets) - await applyBatchChange(newSpec, options) -} - -/** - * Subset of batch change spec: https://sourcegraph.com/docs/batch_changes/references/batch_spec_yaml_reference - */ -interface BatchChangeSpec { - name: string - description: string - importChangesets: { repository: string; externalIDs: number[] }[] -} - -async function applyBatchChange(batchChange: BatchChangeSpec, options: BatchChangeOptions): Promise { - const batchChangeYAML = YAML.stringify(batchChange) - console.log(`Rendered batch change spec:\n\n${batchChangeYAML}`) - - // apply the batch change - await execa('src', ['batch', 'apply', '-namespace', options.namespace, '-f', '-'], { - stdout: 'inherit', - input: batchChangeYAML, - env: options.cliConfig, - }) -} diff --git a/dev/release/src/changelog.ts b/dev/release/src/changelog.ts deleted file mode 100644 index 70bc467c735..00000000000 --- a/dev/release/src/changelog.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const divider = '' - -export const releaseTemplate = `${divider} - -## Unreleased - -### Added - -- - -### Changed - -- - -### Fixed - -- - -### Removed - --` - -export const simpleReleaseTemplate = `${divider} - -## Unreleased -` diff --git a/dev/release/src/chart.ts b/dev/release/src/chart.ts deleted file mode 100644 index 0031c985380..00000000000 --- a/dev/release/src/chart.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { readFileSync } from 'fs' - -import { load as loadYAML } from 'js-yaml' - -export interface Metadata { - apiVersion: string - name: string - description: string - type: string - version: string - appVersion: string -} - -export function parseChartMetadata(chartYamlPath: string): Metadata { - const chartYamlContents = readFileSync(chartYamlPath, 'utf8').toString() - return loadYAML(chartYamlContents) as Metadata -} diff --git a/dev/release/src/config.ts b/dev/release/src/config.ts deleted file mode 100644 index 878b6077140..00000000000 --- a/dev/release/src/config.ts +++ /dev/null @@ -1,293 +0,0 @@ -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 { - 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.major}.${version.minor}`, - srcCliVersion: config.in_progress.srcCliVersion ? new SemVer(config.in_progress.srcCliVersion) : undefined, - } -} - -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, version.patch === 0), - current: version.version, - captainGitHubUsername: captainGithub, - captainSlackUsername: captainSlack, - } -} - -export async function newReleaseFromInput(versionOverride?: SemVer): Promise { - 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, includePatches?: boolean): ReleaseDates { - releaseDate = releaseDate.set({ hour: 10 }) - return { - codeFreezeDate: releaseDate.plus({ days: -7 }).toString(), - securityApprovalDate: releaseDate.plus({ days: -7 }).toString(), - releaseDate: releaseDate.toString(), - patches: includePatches - ? generatePatchDates(releaseDate, releaseDate.plus({ months: 3 }), 2).map(rdate => rdate.toString()) - : undefined, - } -} - -function generatePatchDates(start: DateTime, end: DateTime, intervalWeeks: number): DateTime[] { - const patches = [] - let current: DateTime = start.plus({ weeks: intervalWeeks }) - while (current < end.minus({ weeks: 1 })) { - patches.push(current) - current = current.plus({ weeks: intervalWeeks }) - } - return patches -} - -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 - patches?: string[] -} - -export interface ActiveRelease extends ReleaseCaptainInformation, ReleaseDates { - version: SemVer - previous: SemVer - branch: string - srcCliVersion?: SemVer -} - -export interface ActiveReleaseDefinition { - version: string - previous: string -} - -export interface ReleaseCaptainInformation { - captainSlackUsername: string - captainGitHubUsername: string -} - -export interface InProgress extends ReleaseCaptainInformation { - releases: ActiveReleaseDefinition[] - srcCliVersion?: string - googleExecutorVersion?: string - awsExecutorVersion?: string -} - -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 { - 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 { - 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 { - 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 { - 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) - saveReleaseConfig(config) - } - return scheduled -} - -export function setSrcCliVersion(config: ReleaseConfig, version: string): void { - if (config.in_progress) { - config.in_progress.srcCliVersion = version - } - saveReleaseConfig(config) -} - -export function setGoogleExecutorVersion(config: ReleaseConfig, version: string): void { - if (config.in_progress) { - config.in_progress.googleExecutorVersion = version - } - saveReleaseConfig(config) -} - -export function setAWSExecutorVersion(config: ReleaseConfig, version: string): void { - if (config.in_progress) { - config.in_progress.awsExecutorVersion = version - } - saveReleaseConfig(config) -} diff --git a/dev/release/src/git.ts b/dev/release/src/git.ts deleted file mode 100644 index 41294573710..00000000000 --- a/dev/release/src/git.ts +++ /dev/null @@ -1,64 +0,0 @@ -import execa from 'execa' -import { SemVer } from 'semver' -import * as semver from 'semver' - -import { localSourcegraphRepo } from './github' - -export function getTags(workdir: string, prefix?: string): string[] { - execa.sync('git', ['fetch', '--tags'], { cwd: workdir }) - return execa - .sync('git', ['--no-pager', 'tag', '-l', `${prefix}`, '--sort=v:refname'], { cwd: workdir }) - .stdout.split('\n') -} - -export function getCandidateTags(workdir: string, version: string): string[] { - return getTags(workdir, `v${version}-rc*`) -} - -export function getReleaseTags(workdir: string, prefix: string): string[] { - const raw = getTags(workdir, prefix) - // since tags are globbed they can overmatch when we just want pure release tags - return raw.filter(tag => tag.match('[0-9]+\\.[0-9]+\\.[0-9]+$')) -} - -const mainRepoTagPrefix = 'v[0-9]*.[0-9]*.[0-9]*' -export const srcCliTagPrefix = '[0-9]*.[0-9]*.[0-9]*' -export const executorTagPrefix = 'v[0-9]*.[0-9]*.[0-9]*' - -// Returns the version tagged in the repository previous to a provided input version. If no input version it will -// simply return the highest version found in the repository. -export function getPreviousVersion( - version?: SemVer, - prefix: string = mainRepoTagPrefix, - repoDir: string = localSourcegraphRepo -): SemVer { - const lowest = new SemVer('0.0.1') - const tags = getReleaseTags(repoDir, prefix) - if (tags.length === 0) { - return lowest - } - if (!version) { - return new SemVer(tags.at(-1)) - } - - for ( - let reallyLongVariableNameBecauseESLintRulesAreSilly = tags.length - 1; - reallyLongVariableNameBecauseESLintRulesAreSilly >= 0; - reallyLongVariableNameBecauseESLintRulesAreSilly-- - ) { - const tag = tags[reallyLongVariableNameBecauseESLintRulesAreSilly] - const temp = semver.parse(tag) - if (temp && temp.compare(version) === -1) { - return temp - } - } - return lowest -} - -export function getPreviousVersionSrcCli(path: string): SemVer { - return getPreviousVersion(undefined, srcCliTagPrefix, path) -} - -export function getPreviousVersionExecutor(path: string): SemVer { - return getPreviousVersion(undefined, executorTagPrefix, path) -} diff --git a/dev/release/src/github.ts b/dev/release/src/github.ts deleted file mode 100644 index c157a241b5a..00000000000 --- a/dev/release/src/github.ts +++ /dev/null @@ -1,747 +0,0 @@ -import { existsSync, mkdtemp as original_mkdtemp, readFileSync, copyFileSync } from 'fs' -import * as os from 'os' -import * as path from 'path' -import { promisify } from 'util' - -import Octokit, { type IssuesAddLabelsParams } from '@octokit/rest' -import commandExists from 'command-exists' -import execa from 'execa' -import fetch from 'node-fetch' -import * as semver from 'semver' - -import type { ActiveRelease } from './config' -import { cacheFolder, changelogURL, formatDate, getContainerRegistryCredential, readLine, timezoneLink } from './util' - -const mkdtemp = promisify(original_mkdtemp) -let githubPAT: string - -export async function getAuthenticatedGitHubClient(): Promise { - const cacheFile = `${cacheFolder}/github.txt` - if (existsSync(cacheFile) && (await validateToken()) === true) { - githubPAT = readFileSync(`${cacheFolder}/github.txt`, 'utf-8') - } else { - githubPAT = await readLine( - 'Enter a GitHub personal access token with "repo" scope (https://github.com/settings/tokens/new): ', - cacheFile - ) - } - - const trimmedGithubPAT = githubPAT.trim() - return new Octokit({ auth: trimmedGithubPAT }) -} - -/** - * releaseName generates a standardized format for referring to releases. - */ -export function releaseName(release: semver.SemVer): string { - return `${release.major}.${release.minor}${release.patch !== 0 ? `.${release.patch}` : ''}` -} - -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', - DEVOPS_TEAM = 'team/devops', - SECURITY_TEAM = 'team/security', - RELEASE_BLOCKER = 'release-blocker', -} - -enum IssueTitleSuffix { - RELEASE_TRACKING = 'release tracking issue', - PATCH_TRACKING = 'patch release tracking issue', - MANAGED_TRACKING = 'upgrade managed instances tracking issue', - SECURITY_TRACKING = 'container image vulnerability assessment tracking issue', -} - -/** - * Template used to generate tracking issue - */ -interface IssueTemplate { - owner: string - repo: string - /** - * Relative path to markdown file containing template body. - * - * Template bodies can leverage arguments as described in `IssueTemplateArguments` docstrings. - */ - path: string - /** - * Title for issue. - */ - titleSuffix: IssueTitleSuffix - /** - * Labels to apply on issues. - */ - labels: string[] -} - -/** - * Arguments available for rendering IssueTemplate - */ -interface IssueTemplateArguments { - /** - * Available as `$MAJOR`, `$MINOR`, and `$PATCH` - */ - version: semver.SemVer - /** - * Available as `$SECURITY_REVIEW_DATE` - */ - securityReviewDate: Date - /** - * Available as `$CODE_FREEZE_DATE` - */ - codeFreezeDate: Date - /** - * Available as `$RELEASE_DATE` - */ - releaseDate: 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: 'sourcegraph', - path: 'dev/release/templates/release_issue_template.md', - titleSuffix: IssueTitleSuffix.RELEASE_TRACKING, - labels: [IssueLabel.RELEASE_TRACKING, IssueLabel.RELEASE], - } - const patchReleaseIssue: IssueTemplate = { - owner: 'sourcegraph', - repo: 'sourcegraph', - path: 'dev/release/templates/patch_release_issue_template.md', - titleSuffix: IssueTitleSuffix.PATCH_TRACKING, - labels: [IssueLabel.RELEASE_TRACKING, IssueLabel.PATCH], - } - const securityAssessmentIssue: IssueTemplate = { - owner: 'sourcegraph', - repo: 'sourcegraph', - path: 'dev/release/templates/security_assessment.md', - titleSuffix: IssueTitleSuffix.SECURITY_TRACKING, - labels: [IssueLabel.RELEASE_TRACKING, IssueLabel.SECURITY_TEAM, IssueLabel.RELEASE_BLOCKER], - } - return { releaseIssue, patchReleaseIssue, securityAssessmentIssue } -} - -function dateMarkdown(date: Date, name: string): string { - return `[${formatDate(date)}](${timezoneLink(date, name)})` -} - -async function execTemplate( - octokit: Octokit, - template: IssueTemplate, - { version, securityReviewDate, codeFreezeDate, releaseDate }: IssueTemplateArguments -): Promise { - console.log(`Preparing issue from ${JSON.stringify(template)}`) - const name = releaseName(version) - const content = await getContent(octokit, template) - return content - .replaceAll('$MAJOR', version.major.toString()) - .replaceAll('$MINOR', version.minor.toString()) - .replaceAll('$PATCH', version.patch.toString()) - .replaceAll( - '$SECURITY_REVIEW_DATE', - dateMarkdown(securityReviewDate, `One working week before ${name} release`) - ) - .replaceAll('$CODE_FREEZE_DATE', dateMarkdown(codeFreezeDate, `Three working days before ${name} release`)) - .replaceAll('$RELEASE_DATE', dateMarkdown(releaseDate, `${name} release date`)) -} - -interface MaybeIssue { - title: string - url: string - number: number - created: boolean -} - -/** - * Ensures tracking issues for the given release. - * - * The first returned issue is considered the parent issue. - */ -export async function ensureTrackingIssues({ - version, - assignees, - releaseDate, - securityReviewDate, - codeFreezeDate, - dryRun, -}: { - version: semver.SemVer - assignees: string[] - releaseDate: Date - securityReviewDate: Date - codeFreezeDate: Date - dryRun: boolean -}): Promise { - const octokit = await getAuthenticatedGitHubClient() - const templates = getTemplates() - const release = releaseName(version) - - // Determine what issues to generate. The first issue is considered the "main" - // tracking issue, and subsequent issues will contain references to it. - let issueTemplates: IssueTemplate[] - if (version.patch === 0) { - issueTemplates = [templates.releaseIssue] - } else { - issueTemplates = [templates.patchReleaseIssue] - } - - // Release milestones are not as emphasised now as they used to be, since most teams - // use sprints shorter than releases to track their work. For reference, if one is - // available we apply it to this tracking issue, otherwise just leave it without a - // milestone. - let milestoneNumber: number | undefined - const milestone = await getReleaseMilestone(octokit, version) - if (!milestone) { - console.log(`Milestone ${release} is closed or not found — omitting from issue.`) - } else { - milestoneNumber = milestone ? milestone.number : undefined - } - - // Create issues - let parentIssue: MaybeIssue | undefined - const created: MaybeIssue[] = [] - for (const template of issueTemplates) { - const body = await execTemplate(octokit, template, { - version, - releaseDate, - securityReviewDate, - codeFreezeDate, - }) - const issue = await ensureIssue( - octokit, - { - title: trackingIssueTitle(version, template), - labels: template.labels, - body: parentIssue ? `${body}\n\n---\n\nAlso see [${parentIssue.title}](${parentIssue.url})` : body, - assignees, - owner: 'sourcegraph', - repo: 'sourcegraph', - milestone: milestoneNumber, - }, - dryRun - ) - // if this is the first issue, we treat it as the parent issue - if (!parentIssue) { - parentIssue = { ...issue } - } - created.push({ ...issue }) - } - return created -} - -async function getContent( - octokit: Octokit, - parameters: { - owner: string - repo: string - path: string - } -): Promise { - const response = await octokit.repos.getContents(parameters) - if (Array.isArray(response.data)) { - throw new TypeError(`${parameters.path} is a directory`) - } - return Buffer.from(response.data.content as string, 'base64').toString() -} - -async function ensureIssue( - octokit: Octokit, - { - title, - owner, - repo, - assignees, - body, - milestone, - labels, - }: { - title: string - owner: string - repo: string - assignees: string[] - body: string - milestone?: number - labels: string[] - }, - dryRun: boolean -): Promise { - const issueData = { - title, - owner, - repo, - assignees, - milestone, - labels, - } - const issue = await getIssueByTitle(octokit, title, labels) - if (issue) { - 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: '', number: 0, created: false } - } - const createdIssue = await octokit.issues.create({ body, ...issueData }) - return { title, url: createdIssue.data.html_url, number: createdIssue.data.number, created: true } -} - -export async function listIssues( - octokit: Octokit, - query: string -): Promise { - return (await octokit.search.issuesAndPullRequests({ per_page: 100, q: query })).data.items -} - -export interface Issue { - title: string - number: number - url: string - - // Repository - owner: string - repo: string -} - -export async function getTrackingIssue(client: Octokit, release: semver.SemVer): Promise { - 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 { - const comment = await client.issues.createComment({ - body, - issue_number: issue.number, - owner: issue.owner, - repo: issue.repo, - }) - return comment.data.html_url -} - -async function closeIssue(client: Octokit, issue: Issue): Promise { - await client.issues.update({ - state: 'closed', - issue_number: issue.number, - owner: issue.owner, - repo: issue.repo, - }) -} - -interface Milestone { - number: number - url: string - - // Repository - owner: string - repo: string -} - -async function getReleaseMilestone(client: Octokit, release: semver.SemVer): Promise { - const owner = 'sourcegraph' - const repo = 'sourcegraph' - const milestoneTitle = releaseName(release) - const milestones = await client.issues.listMilestonesForRepo({ - owner, - repo, - per_page: 100, - direction: 'desc', - }) - const milestone = milestones.data.filter(milestone => milestone.title === milestoneTitle) - return milestone.length > 0 - ? { - number: milestone[0].number, - url: milestone[0].html_url, - owner, - repo, - } - : null -} - -export async function queryIssues(octokit: Octokit, titleQuery: string, labels: string[]): Promise { - const owner = 'sourcegraph' - const repo = 'sourcegraph' - const response = await octokit.search.issuesAndPullRequests({ - per_page: 100, - 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, - })) -} - -async function getIssueByTitle(octokit: Octokit, title: string, labels: string[]): Promise { - 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 matchingIssues[0] -} - -export type EditFunc = (d: string) => void - -export type Edit = string | EditFunc - -export interface CreateBranchWithChangesOptions { - owner: string - repo: string - base: string - head: string - commitMessage: string - edits: Edit[] - dryRun?: boolean -} - -export interface ChangesetsOptions { - requiredCommands: string[] - changes: (Octokit.PullsCreateParams & CreateBranchWithChangesOptions & { labels?: string[] })[] - dryRun?: boolean -} - -export interface CreatedChangeset { - repository: string - branch: string - pullRequestURL: string - pullRequestNumber: number -} - -export async function createChangesets(options: ChangesetsOptions): Promise { - // Overwriting `process.env` may not be a good practice, - // but it's the easiest way to avoid making changes all over the place - const dockerHubCredential = await getContainerRegistryCredential('index.docker.io') - process.env.CR_USERNAME = dockerHubCredential.username - process.env.CR_PASSWORD = dockerHubCredential.password - for (const command of options.requiredCommands) { - try { - await commandExists(command) - } catch { - throw new Error(`Required command ${command} does not exist`) - } - } - const octokit = await getAuthenticatedGitHubClient() - if (options.dryRun) { - console.log('Changesets dry run enabled - diffs and pull requests will be printed instead') - } else { - console.log('Generating changes and publishing as pull requests') - } - - // Generate and push changes. We abort here if a repo fails because it should be safe - // to re-run changesets, which force push changes to a PR branch. - for (const change of options.changes) { - const repository = `${change.owner}/${change.repo}` - console.log(`${repository}: Preparing change for on '${change.base}' to '${change.head}'`) - await createBranchWithChanges(octokit, { ...change, dryRun: options.dryRun }) - } - - // Publish changes as pull requests only if all changes are successfully created. We - // continue on error for errors when publishing. - const results: CreatedChangeset[] = [] - let publishChangesFailed = false - for (const change of options.changes) { - const repository = `${change.owner}/${change.repo}` - console.log(`${repository}: Preparing pull request for change from '${change.base}' to '${change.head}': - -Title: ${change.title} -Body: ${change.body || 'none'}`) - let pullRequest: { url: string; number: number } = { url: '', number: -1 } - try { - if (!options.dryRun) { - pullRequest = await createPR(octokit, change) - if (change.labels) { - await octokit.issues.addLabels({ - issue_number: pullRequest.number, - repo: change.repo, - owner: change.owner, - labels: change.labels, - } as IssuesAddLabelsParams) - } - } - - results.push({ - repository, - branch: change.base, - pullRequestURL: pullRequest.url, - pullRequestNumber: pullRequest.number, - }) - } catch (error) { - publishChangesFailed = true - console.error(error) - console.error(`Failed to create pull request for change in ${repository}`, { change }) - } - } - - // Log results - for (const result of results) { - console.log(`${result.repository} (${result.branch}): created pull request ${result.pullRequestURL}`) - } - if (publishChangesFailed) { - throw new Error('Error occured applying some changes - please check log output') - } - - return results -} - -export async function cloneRepo( - octokit: Octokit, - owner: string, - repo: string, - checkout: { - revision: string - revisionMustExist?: boolean - } -): Promise<{ - workdir: string -}> { - const tmpdir = await mkdtemp(path.join(os.tmpdir(), `sg-release-${owner}-${repo}-`)) - console.log(`Created temp directory ${tmpdir}`) - const fetchFlags = '--depth 1' - - // Determine whether or not to create the base branch, or use the existing one - let revisionExists = true - if (!checkout.revisionMustExist) { - try { - await octokit.repos.getBranch({ branch: checkout.revision, owner, repo }) - } catch (error) { - if (error.status === 404) { - console.log(`Target revision ${checkout.revision} does not exist, this branch will be created`) - revisionExists = false - } else { - throw error - } - } - } - const checkoutCommand = - revisionExists === true - ? // for an existing branch - fetch fails if we are already checked out, so ignore errors optimistically - `git fetch ${fetchFlags} origin ${checkout.revision}:${checkout.revision} || true ; git checkout ${checkout.revision}` - : // create from HEAD and publish base branch if it does not yet exist - `git checkout -b ${checkout.revision} ; git push origin ${checkout.revision}:${checkout.revision}` - - // PERF: if we have a local clone using reference avoids needing to fetch - // all the objects from the remote. We assume the local clone will exist - // in the same directory as the current sourcegraph/sourcegraph clone. - const cloneFlags = `${fetchFlags} --reference-if-able ${localSourcegraphRepo}/../${repo}` - - // Set up repository - const setupScript = `set -ex - -git clone ${cloneFlags} git@github.com:${owner}/${repo} || git clone ${cloneFlags} https://github.com/${owner}/${repo}; -cd ${repo}; -${checkoutCommand};` - await execa('bash', ['-c', setupScript], { stdio: 'inherit', cwd: tmpdir }) - return { - workdir: path.join(tmpdir, repo), - } -} - -export const localSourcegraphRepo = `${process.cwd()}/../..` - -async function createBranchWithChanges( - octokit: Octokit, - { owner, repo, base: baseRevision, head: headBranch, commitMessage, edits, dryRun }: CreateBranchWithChangesOptions -): Promise { - // Set up repository - const { workdir } = await cloneRepo(octokit, owner, repo, { revision: baseRevision }) - - // Bazel depends on configuration in the sourcegraph repo. So to run it in - // our temporary clone we need to copy those files over. - if (owner === 'sourcegraph' && repo === 'sourcegraph') { - // All the try-import files from .bazelrc - for (const name of ['.aspect/bazelrc/user.bazelrc', 'user.bazelrc', '.bazelrc-nix']) { - const src = `${localSourcegraphRepo}/${name}` - const dest = `${workdir}/${name}` - if (existsSync(src)) { - copyFileSync(src, dest) - } - } - } - - // Apply edits - for (const edit of edits) { - switch (typeof edit) { - case 'function': { - edit(workdir) - break - } - case 'string': { - const editScript = `set -ex - - ${edit};` - await execa('bash', ['-c', editScript], { stdio: 'inherit', cwd: workdir }) - } - } - } - - if (dryRun) { - console.warn('Dry run enabled - printing diff instead of publishing') - const showChangesScript = `set -ex - - git --no-pager diff;` - await execa('bash', ['-c', showChangesScript], { stdio: 'inherit', cwd: workdir }) - } else { - // Publish changes. We force push to ensure that the generated changes are applied. - const publishScript = `set -ex - - git add :/; - git commit -a -m ${JSON.stringify(commitMessage)}; - git push --force origin HEAD:${headBranch};` - await execa('bash', ['-c', publishScript], { stdio: 'inherit', cwd: workdir }) - } -} - -async function createPR( - octokit: Octokit, - options: { - owner: string - repo: string - head: string - base: string - title: string - body?: string - } -): Promise<{ url: string; number: number }> { - const response = await octokit.pulls.create(options) - return { - url: response.data.html_url, - number: response.data.number, - } -} - -export interface TagOptions { - owner: string - repo: string - branch: string - tag: string -} - -/** - * Creates a tag on a remote branch for the given repository. - * - * The target branch must exist on the remote. - */ -export async function createTag( - octokit: Octokit, - workdir: string, - { owner, repo, branch: rawBranch, tag: rawTag }: TagOptions, - dryRun: boolean -): Promise { - const branch = JSON.stringify(rawBranch) - const tag = JSON.stringify(rawTag) - const finalizeTag = dryRun ? `git --no-pager show ${tag} --no-patch` : `git push origin ${tag}` - if (dryRun) { - console.log(`Dry-run enabled - creating and printing tag ${tag} on ${owner}/${repo}@${branch}`) - return - } - console.log(`Creating and pushing tag ${tag} on ${owner}/${repo}@${branch}`) - await execa('bash', ['-c', `git tag -a ${tag} -m ${tag} && ${finalizeTag}`], { stdio: 'inherit', cwd: workdir }) -} - -// createLatestRelease generates a GitHub release iff this release is the latest and -// greatest, otherwise it is a no-op. -export async function createLatestRelease( - octokit: Octokit, - { owner, repo, release }: { owner: string; repo: string; release: semver.SemVer }, - dryRun?: boolean -): Promise { - const latest = await octokit.repos.getLatestRelease({ - owner, - repo, - }) - const latestTag = latest.data.tag_name - if (semver.gt(latestTag.startsWith('v') ? latestTag.slice(1) : latestTag, release)) { - // if latest is greater than release, do not generate a release - console.log(`Latest release ${latestTag} is more recent than ${release.version}, skipping GitHub release`) - return '' - } - - const updateURL = 'https://sourcegraph.com/docs/admin/updates' - const releasePostURL = `https://sourcegraph.com/blog/release/${release.major}.${release.minor}` // CI:URL_OK - - const request: Octokit.RequestOptions & Octokit.ReposCreateReleaseParams = { - owner, - repo, - tag_name: `v${release.version}`, - name: `Sourcegraph ${release.version}`, - prerelease: false, - draft: false, - body: `Sourcegraph ${release.version} is now available! - -- [Changelog](${changelogURL(release.format())}) -- [Update](${updateURL}) -- [Release post](${releasePostURL}) (might not be available immediately upon release) -`, - } - if (dryRun) { - console.log('Skipping GitHub release, parameters:', request) - return '' - } - const response = await octokit.repos.createRelease(request) - return response.data.html_url -} - -async function validateToken(): Promise { - const githubPAT: string = readFileSync(`${cacheFolder}/github.txt`, 'utf-8') - const trimmedGithubPAT = githubPAT.trim() - const response = await fetch('https://api.github.com/repos/sourcegraph/sourcegraph', { - method: 'GET', - headers: { - Authorization: `token ${trimmedGithubPAT}`, - }, - }) - - if (response.status !== 200) { - console.log(`Existing GitHub token is invalid, got status ${response.statusText}`) - return false - } - return true -} - -export async function closeTrackingIssue(version: semver.SemVer): Promise { - const octokit = await getAuthenticatedGitHubClient() - const release = releaseName(version) - const labels = [IssueLabel.RELEASE_TRACKING, IssueLabel.RELEASE] - // close old tracking issue - const previous = await queryIssues(octokit, release, labels) - for (const previousIssue of previous) { - const comment = await commentOnIssue( - octokit, - previousIssue, - `Issue closed by release tool. #${previousIssue.number}` - ) - console.log(`Closing #${previousIssue.number} '${previousIssue.title} with ${comment}`) - await closeIssue(octokit, previousIssue) - } -} - -export const releaseBlockerLabel = 'release-blocker' - -export function getBackportLabelForRelease(release: ActiveRelease): string { - return `backport ${release.branch}` -} diff --git a/dev/release/src/google-calendar.ts b/dev/release/src/google-calendar.ts deleted file mode 100644 index 7d9a188a67a..00000000000 --- a/dev/release/src/google-calendar.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http' -import type { AddressInfo } from 'net' - -import { addMinutes } from 'date-fns' -import type { Credentials } from 'google-auth-library' -import { google, type calendar_v3 } from 'googleapis' -import { OAuth2Client } from 'googleapis-common' -import { DateTime } from 'luxon' -import { readFile, writeFile } from 'mz/fs' -import open from 'open' - -import { readLine, cacheFolder } from './util' - -export interface Installed { - client_id?: string - client_secret?: string - redirect_uri?: string -} -export interface OAuth2ClientOptions { - installed: Installed -} - -const SCOPES = ['https://www.googleapis.com/auth/calendar.events'] -const TOKEN_PATH = `${cacheFolder}/google-calendar-token.json` - -export async function getClient(): Promise { - const credentials: OAuth2ClientOptions = JSON.parse( - await readLine( - 'Paste Google Calendar credentials (1Password "Release automation Google Calendar API App credentials"): ', - `${cacheFolder}/google-calendar-credentials.json` - ) - ) - const oauth2Client = await authorize(credentials) - return oauth2Client -} -async function authorize(credentials: OAuth2ClientOptions): Promise { - let oauth2Client: OAuth2Client - try { - const token = await getAccessCachedToken() - oauth2Client = new OAuth2Client({ - clientId: credentials.installed.client_id, - clientSecret: credentials.installed.client_secret, - redirectUri: credentials.installed.redirect_uri, - }) - oauth2Client.setCredentials(token) - return oauth2Client - } catch { - const server = await new Promise(resolve => { - const serv = createServer() - serv.listen(0, () => resolve(serv)) - }) - const { port } = server.address() as AddressInfo - const oauth2Client = new OAuth2Client({ - clientId: credentials.installed.client_id, - clientSecret: credentials.installed.client_secret, - redirectUri: `http://localhost:${port}`, - }) - - const token = await getAccessTokenNoCache(server, oauth2Client) - await writeFile(TOKEN_PATH, JSON.stringify(token)) - oauth2Client.setCredentials(token) - server.close() - return oauth2Client - } -} - -async function getAccessCachedToken(): Promise { - const content = await readFile(TOKEN_PATH, { encoding: 'utf8' }) - return JSON.parse(content) -} - -async function getAccessTokenNoCache(server: Server, oauth2Client: OAuth2Client): Promise { - const authUrl = oauth2Client.generateAuthUrl({ - access_type: 'offline', - scope: SCOPES, - }) - - const authCode = await new Promise((resolve, reject) => { - server.on('request', (request: IncomingMessage, response: ServerResponse) => { - try { - const urlParts = new URL(request.url ?? '', 'http://localhost').searchParams - const code = urlParts.get('code') - const error = urlParts.get('error') - if (error) { - throw new Error(error) - } - if (code) { - resolve(code) - } - response.end('Authentication successful! Please return to the console') - } catch (error) { - reject(error) - } - }) - open(authUrl, { wait: false }) - .then(childProcess => childProcess.unref()) - .catch(reject) - }) - - const { tokens } = await oauth2Client.getToken(authCode) - return tokens -} - -export interface EventOptions { - anyoneCanAddSelf?: boolean - attendees?: string[] - startDate?: string - endDate?: string - startDateTime?: string - endDateTime?: string - description?: string - title: string - transparency: string -} - -export async function ensureEvent( - { - anyoneCanAddSelf = false, - attendees = [], - startDate, - endDate, - startDateTime, - endDateTime, - description = '', - title, - transparency, - }: EventOptions, - auth: OAuth2Client -): Promise { - const existingEvents = await listEvents(auth) - const foundEvents = (existingEvents || []).filter(({ summary }) => summary === title) - if (foundEvents.length > 0) { - console.log(`Event ${JSON.stringify(title)} already exists (not updating)`) - return - } - - const calendar = google.calendar({ version: 'v3', auth }) - await calendar.events.insert({ - calendarId: 'primary', - requestBody: { - anyoneCanAddSelf, - attendees: attendees.map(email => ({ email, optional: true })), - start: { date: startDate, dateTime: startDateTime }, - end: { date: endDate, dateTime: endDateTime }, - description, - summary: title, - transparency, - }, - }) -} - -export async function listEvents(auth: OAuth2Client): Promise { - const calendar = google.calendar({ version: 'v3', auth }) - const result = await calendar.events.list({ - calendarId: 'primary', - timeMin: new Date().toISOString(), - timeMax: DateTime.now().plus({ year: 1 }).toJSDate().toISOString(), // this ends up returning a lot of events, so filtering down to the next year should be fine - maxResults: 2500, - singleEvents: true, - orderBy: 'startTime', - }) - return result.data.items -} - -export function calendarTime(date: string): { startDateTime: string; endDateTime: string } { - return { - startDateTime: new Date(date).toISOString(), - endDateTime: addMinutes(new Date(date), 1).toISOString(), - } -} diff --git a/dev/release/src/main.ts b/dev/release/src/main.ts deleted file mode 100644 index 505ce481da6..00000000000 --- a/dev/release/src/main.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { loadReleaseConfig } from './config' -import { runStep, type StepID } from './release' -import { ensureMainBranchUpToDate } from './util' - -/** - * Release captain automation - */ -async function main(): Promise { - const config = loadReleaseConfig() - const args = process.argv.slice(2) - if (args.length === 0) { - await runStep(config, 'help') - console.error('The release tool expects at least 1 argument') - return - } - - const step = args[0] as StepID - const stepArguments = args.slice(1) - ensureMainBranchUpToDate() - await runStep(config, step, ...stepArguments) -} - -main().catch(error => console.error(error)) diff --git a/dev/release/src/release.ts b/dev/release/src/release.ts deleted file mode 100644 index 81a54f6fec7..00000000000 --- a/dev/release/src/release.ts +++ /dev/null @@ -1,1452 +0,0 @@ -import { readFileSync, rmdirSync, writeFileSync } from 'fs' -import * as path from 'path' -import { exit } from 'process' - -import chalk from 'chalk' -import commandExists from 'command-exists' -import { addMinutes } from 'date-fns' -import execa from 'execa' -import { DateTime } from 'luxon' -import { SemVer } from 'semver' -import semver from 'semver/preload' - -import * as batchChanges from './batchChanges' -import * as changelog from './changelog' -import { - activateRelease, - addScheduledRelease, - loadReleaseConfig, - newReleaseFromInput, - type ReleaseConfig, - getActiveRelease, - removeScheduledRelease, - saveReleaseConfig, - getReleaseDefinition, - deactivateAllReleases, - setSrcCliVersion, - newRelease, - setGoogleExecutorVersion, - setAWSExecutorVersion, -} from './config' -import { getCandidateTags, getPreviousVersion } from './git' -import { - cloneRepo, - closeTrackingIssue, - commentOnIssue, - createChangesets, - type CreatedChangeset, - createLatestRelease, - createTag, - type Edit, - ensureTrackingIssues, - getAuthenticatedGitHubClient, - getTrackingIssue, - IssueLabel, - localSourcegraphRepo, - queryIssues, - releaseBlockerLabel, - releaseName, -} from './github' -import { calendarTime, ensureEvent, type EventOptions, getClient } from './google-calendar' -import { postMessage, slackURL } from './slack' -import { - bakeAWSExecutorsSteps, - bakeGoogleExecutorsSteps, - bakeSrcCliSteps, - batchChangesInAppChangelog, - combyReplace, -} from './static-updates' -import { - backportStatus, - cacheFolder, - changelogURL, - ensureDocker, - ensureReleaseBranchUpToDate, - ensureSrcCliEndpoint, - ensureSrcCliUpToDate, - formatDate, - getAllUpgradeGuides, - getLatestSrcCliGithubRelease, - getLatestTag, - getReleaseBlockers, - nextSrcCliVersionInputWithAutodetect, - nextGoogleExecutorVersionInputWithAutodetect, - nextAWSExecutorVersionInputWithAutodetect, - pullRequestBody, - releaseBlockerUri, - retryInput, - timezoneLink, - updateDocsUpgradeGuides, - validateNoOpenBackports, - validateNoReleaseBlockers, - verifyWithInput, - type ReleaseTag, - getContainerRegistryCredential, -} from './util' - -const sed = process.platform === 'linux' ? 'sed' : 'gsed' - -export type StepID = - | 'help' - // release tracking - | 'tracking:timeline' - | 'tracking:issues' - // branch cut - | 'changelog:cut' - | 'release:branch-cut' - // release - | 'release:status' - | 'release:backport-status' - | 'release:create-candidate' - | 'release:promote-candidate' - | 'release:check-candidate' - | 'release:stage' - | 'release:add-to-batch-change' - | 'release:finalize' - | 'release:announce' - | 'release:close' - | 'release:bake-content' - | 'release:prepare' - | 'release:remove' - | 'release:activate-release' - | 'release:deactivate-release' - // src-cli and executors - | 'release:create-tags' - | 'release:verify-releases' - // util - | 'util:clear-cache' - | 'util:previous-version' - // testing - | '_test:google-calendar' - | '_test:slack' - | '_test:batchchange-create-from-changes' - | '_test:config' - | '_test:dockerensure' - | '_test:srccliensure' - | '_test:patch-dates' - | '_test:release-guide-content' - | '_test:release-guide-update' - -/** - * Runs given release step with the provided configuration and arguments. - */ -export async function runStep(config: ReleaseConfig, step: StepID, ...args: string[]): Promise { - if (!steps.map(({ id }) => id as string).includes(step)) { - throw new Error(`Unrecognized step ${JSON.stringify(step)}`) - } - await Promise.all( - steps - .filter(({ id }) => id === step) - .map(async step => { - if (step.run) { - await step.run(config, ...args) - } - }) - ) -} - -interface Step { - id: StepID - description: string - run?: - | ((config: ReleaseConfig, ...args: string[]) => Promise) - | ((config: ReleaseConfig, ...args: string[]) => void) - argNames?: string[] -} - -const steps: Step[] = [ - { - id: 'help', - description: 'Output help text about this tool', - argNames: ['all'], - run: (_config, all) => { - console.error('Sourcegraph release tool - https://handbook.sourcegraph.com/engineering/releases') - console.error('\nUSAGE\n') - console.error('\tpnpm run release ') - console.error('\nAVAILABLE STEPS\n') - console.error( - steps - .filter(({ id }) => all || !id.startsWith('_')) - .map( - ({ id, argNames, description }) => - '\t' + - id + - (argNames && argNames.length > 0 - ? ' ' + argNames.map(argumentName => `<${argumentName}>`).join(' ') - : '') + - '\n\t\t' + - description - ) - .join('\n') + '\n' - ) - }, - }, - { - id: 'tracking:timeline', - description: 'Generate a set of Google Calendar events for a MAJOR.MINOR release', - run: async config => { - const next = await getReleaseDefinition(config) - const name = releaseName(new SemVer(next.current)) - const events: EventOptions[] = [ - { - title: `Security Team to Review Release Container Image Scans ${name}`, - description: '(This is not an actual event to attend, just a calendar marker.)', - anyoneCanAddSelf: true, - attendees: [config.metadata.teamEmail], - transparency: 'transparent', - ...calendarTime(next.securityApprovalDate), - }, - { - title: `Cut Sourcegraph ${name}`, - description: '(This is not an actual event to attend, just a calendar marker.)', - anyoneCanAddSelf: true, - attendees: [config.metadata.teamEmail], - transparency: 'transparent', - ...calendarTime(next.codeFreezeDate), - }, - { - title: `Release Sourcegraph ${name}`, - description: '(This is not an actual event to attend, just a calendar marker.)', - anyoneCanAddSelf: true, - attendees: [config.metadata.teamEmail], - transparency: 'transparent', - ...calendarTime(next.releaseDate), - }, - ] - - if (next.patches) { - for (let i = 0; i < next.patches.length; i++) { - events.push({ - title: `Scheduled Patch #${i + 1} Sourcegraph ${name}`, - description: '(This is not an actual event to attend, just a calendar marker.)', - anyoneCanAddSelf: true, - attendees: [config.metadata.teamEmail], - transparency: 'transparent', - ...calendarTime(next.patches[i]), - }) - } - } - - if (!config.dryRun.calendar) { - const googleCalendar = await getClient() - for (const event of events) { - console.log(`Create calendar event: ${event.title}: ${event.startDateTime || 'undefined'}`) - await ensureEvent(event, googleCalendar) - } - } else { - console.log('dryRun.calendar=true, skipping calendar event creation', events) - } - }, - }, - { - id: 'tracking:issues', - description: 'Generate GitHub tracking issues for a release', - run: async (config: ReleaseConfig) => { - const next = await getReleaseDefinition(config) - const version = new SemVer(next.current) - const date = new Date(next.releaseDate) - - // Create issue - const trackingIssues = await ensureTrackingIssues({ - version, - assignees: [next.captainGitHubUsername], - releaseDate: date, - securityReviewDate: new Date(next.securityApprovalDate), - codeFreezeDate: new Date(next.codeFreezeDate), - dryRun: config.dryRun.trackingIssues || false, - }) - console.log('Rendered tracking issues', trackingIssues) - - // If at least one issue was created, post to Slack - if (trackingIssues.find(({ created }) => created)) { - const name = releaseName(version) - const releaseDateString = slackURL(formatDate(date), timezoneLink(date, `${name} release`)) - let annoncement = `:mega: *${name} release* - -:captain: Release captain: @${next.captainSlackUsername} -:spiral_calendar_pad: Scheduled for: ${releaseDateString} -:pencil: Tracking issues: -${trackingIssues.map(index => `- ${slackURL(index.title, index.url)}`).join('\n')}` - if (version.patch !== 0) { - const patchRequestTemplate = `https://github.com/sourcegraph/sourcegraph/issues/new?assignees=&labels=team%2Fdistribution&template=request_patch_release.md&title=${version.version}%3A+` - annoncement += `\n\nIf you have changes that should go into this patch release, ${slackURL( - 'please *file a patch request issue*', - patchRequestTemplate - )}, or it will not be included.` - } - if (!config.dryRun.slack) { - await postMessage(annoncement, config.metadata.slackAnnounceChannel) - console.log(`Posted to Slack channel ${config.metadata.slackAnnounceChannel}`) - } - } else { - console.log('No tracking issues were created, skipping Slack announcement') - } - }, - }, - { - id: 'changelog:cut', - description: 'Generate pull requests to perform a changelog cut for branch cut', - argNames: ['changelogFile'], - run: async (config, changelogFile = 'CHANGELOG.md') => { - const upcoming = await getActiveRelease(config) - const srcCliNext = await nextSrcCliVersionInputWithAutodetect(config) - - const commitMessage = `changelog: cut sourcegraph@${upcoming.version.version}` - const prBody = commitMessage + '\n\n ## Test plan\n\nN/A' - const pullRequest = await createChangesets({ - requiredCommands: [], - changes: [ - { - owner: 'sourcegraph', - repo: 'sourcegraph', - base: 'main', - head: `changelog-${upcoming.version.version}`, - title: commitMessage, - commitMessage, - body: prBody, - edits: [ - (directory: string) => { - console.log(`Updating '${changelogFile} for ${upcoming.version.format()}'`) - const changelogPath = path.join(directory, changelogFile) - let changelogContents = readFileSync(changelogPath).toString() - - // Convert 'unreleased' to a release - const releaseHeader = `## ${upcoming.version.format()}` - const unreleasedHeader = '## Unreleased' - changelogContents = changelogContents.replace(unreleasedHeader, releaseHeader) - - // Add a blank changelog template for the next release - changelogContents = changelogContents.replace( - changelog.divider, - changelog.releaseTemplate - ) - - // Update changelog - writeFileSync(changelogPath, changelogContents) - }, - ], - }, - { - owner: 'sourcegraph', - repo: 'deploy-sourcegraph-helm', - base: 'main', - head: `changelog-${upcoming.version.version}`, - title: commitMessage, - commitMessage, - body: prBody, - edits: [ - (directory: string) => { - console.log(`Updating '${changelogFile} for ${upcoming.version.format()}'`) - const changelogPath = path.join(directory, 'charts', 'sourcegraph', changelogFile) - let changelogContents = readFileSync(changelogPath).toString() - - // Convert 'unreleased' to a release - const releaseHeader = `## ${upcoming.version.format()}` - const releaseUpdate = - releaseHeader + `\n\n- Sourcegraph ${upcoming.version.format()} is now available\n` - const unreleasedHeader = '## Unreleased\n' - changelogContents = changelogContents.replace(unreleasedHeader, releaseUpdate) - - // Add a blank changelog template for the next release - changelogContents = changelogContents.replace( - changelog.divider, - changelog.simpleReleaseTemplate - ) - - // Update changelog - writeFileSync(changelogPath, changelogContents) - }, - ], - }, - { - owner: 'sourcegraph', - repo: 'src-cli', - base: 'main', - head: `changelog-${srcCliNext.version}`, - title: commitMessage, - body: prBody, - commitMessage, - edits: [ - (directory: string) => { - console.log(`Updating '${changelogFile} for ${srcCliNext.format()}'`) - const changelogPath = path.join(directory, changelogFile) - let changelogContents = readFileSync(changelogPath).toString() - - // Convert 'unreleased' to a release - const unreleasedHeader = '## Unreleased' - const unreleasedSection = `${unreleasedHeader}\n\n### Added\n\n### Changed\n\n### Fixed\n\n### Removed\n\n` - const newSection = `${unreleasedSection}## ${srcCliNext.format()}` - changelogContents = changelogContents.replace(unreleasedHeader, newSection) - - // Update changelog - writeFileSync(changelogPath, changelogContents) - }, - ], - }, - ], - dryRun: config.dryRun.changesets, - }) - const changeLogPrUrl = pullRequest[0].pullRequestURL - console.log( - `\nPlease review the changelog PR at ${changeLogPrUrl}, and merge manually when checks have passed.` - ) - }, - }, - { - id: 'release:branch-cut', - description: 'Create release branch', - run: async config => { - const release = await getActiveRelease(config) - const client = await getAuthenticatedGitHubClient() - let message: string - // notify cs team on patch release cut - if (release.version.patch !== 0) { - message = `:mega: *${release.version.version}* branch has been cut cc: @cs\nIf you need to include changes in the release, see instructions on how to backport: https://handbook.sourcegraph.com/departments/engineering/dev/tools/backport/#how-should-i-use-the-backporting-tool.` - } else { - message = `:mega: *${release.version.version}* branch has been cut.` - } - try { - // Create and push new release branch from changelog commit - await execa('git', ['branch', release.branch]) - await execa('git', ['push', 'origin', release.branch]) - await postMessage(message, config.metadata.slackAnnounceChannel) - console.log(`To check the status of the branch, run:\nsg ci status -branch ${release.branch} --wait\n`) - } catch (error) { - console.error('Failed to create release branch', error) - } - - if (release.version.patch === 0) { - // create backport label for major / minor versions - const params = { - owner: 'sourcegraph', - repo: 'sourcegraph', - } - const labelName = `backport ${release.version.major}.${release.version.minor}` - const labelExists = await client.issues - .getLabel({ name: labelName, ...params }) - .then(resp => resp.status === 200) - .catch(() => false) - if (!labelExists) { - console.log(await client.issues.createLabel({ name: labelName, color: 'e69138', ...params })) - console.log(`Label ${labelName} created`) - } else { - console.log(`label ${labelName} already exists`) - } - } - }, - }, - { - id: 'release:status', - description: 'Post a message in Slack summarizing the progress of a release', - run: async config => { - const githubClient = await getAuthenticatedGitHubClient() - const release = await getActiveRelease(config) - - const trackingIssue = await getTrackingIssue(githubClient, release.version) - if (!trackingIssue) { - throw new Error( - `Tracking issue for version ${release.version.version} not found - has it been created yet?` - ) - } - const latestTag = (await getLatestTag('sourcegraph', 'sourcegraph')).toString() - const latestBuildURL = `https://buildkite.com/sourcegraph/sourcegraph/builds?branch=${latestTag}` - const latestBuildMessage = `Latest release build: ${latestTag}. See the build status on <${latestBuildURL}|Buildkite>` - - const blockingIssues = await getReleaseBlockers(githubClient) - const blockingMessage = - blockingIssues.length === 0 - ? 'There are no release-blocking issues' - : `There ${ - blockingIssues.length === 1 - ? 'is 1 release-blocking issue' - : `are ${blockingIssues.length} release-blocking issues` - }` - - const message = `:mega: *${release.version.version} Release Status Update* - -* Tracking issue: ${trackingIssue.url} -* ${blockingMessage}: ${releaseBlockerUri()} -* ${latestBuildMessage}` - if (!config.dryRun.slack) { - await postMessage(message, config.metadata.slackAnnounceChannel) - } else { - console.log(chalk.green('Dry run: ' + message)) - } - }, - }, - { - id: 'release:backport-status', - description: 'Check for backport issues on the currently active release', - run: async config => { - const release = await getActiveRelease(config) - getAuthenticatedGitHubClient() - .then(client => backportStatus(client, release.version)) - .then(str => console.log(str)) - .catch(error => error) - }, - }, - { - id: 'release:create-candidate', - description: 'Generate the Nth release candidate. Set to "final" to generate a final release', - run: async config => { - const release = await getActiveRelease(config) - ensureReleaseBranchUpToDate(release.branch) - - const owner = 'sourcegraph' - const repo = 'sourcegraph' - - try { - const client = await getAuthenticatedGitHubClient() - const { workdir } = await cloneRepo(client, owner, repo, { - revision: release.branch, - revisionMustExist: true, - }) - - const tags = getCandidateTags(workdir, release.version.version) - let nextCandidate = 1 - for (const tag of tags) { - const lastNum = tag.match('.*-rc\\.(\\d+)') - if (!lastNum || lastNum.length === 0) { - break - } - const num = parseInt(lastNum[1], 10) - if (num >= nextCandidate) { - nextCandidate = num + 1 - } - } - const tag = `v${release.version.version}-rc.${nextCandidate}` - - console.log(`Detected next candidate: ${nextCandidate}, attempting to create tag: ${tag}`) - await createTag( - client, - workdir, - { - owner, - repo, - branch: release.branch, - tag, - }, - config.dryRun.tags || false - ) - console.log(`To check the status of the build, run:\nsg ci status -branch ${tag} --wait\n`) - } catch (error) { - console.error('Failed to create tag', error) - } - }, - }, - { - id: 'release:promote-candidate', - description: - 'Promote a release candidate to release build. Specify the full candidate tag to promote the tagged commit to release.', - argNames: ['candidate'], - run: async (config, candidate) => { - const release = await getActiveRelease(config) - ensureReleaseBranchUpToDate(release.branch) - - const client = await getAuthenticatedGitHubClient() - await validateNoReleaseBlockers(client) - await validateNoOpenBackports(client, release.version) - - const warnMsg = - 'Verify the provided tag is correct to promote to release. Note: it is very unusual to require a non-standard tag to promote to release, proceed with caution.' - const exampleTag = `v${release.version.version}-rc.1` - if (!candidate) { - throw new Error( - `Candidate tag is a required argument. This should be the git tag of the commit to promote to release (ex.${exampleTag}` - ) - } else if (!candidate.match('v\\d\\.\\d(?:\\.\\d)?-rc\\.\\d')) { - await verifyWithInput( - `Warning!\nCandidate tag: ${candidate} does not match the standard convention (ex. ${exampleTag}). ${warnMsg}` - ) - } else if (!candidate.match(`${release.version.version}-rc\\.\\d`)) { - await verifyWithInput( - `Warning!\nCandidate tag: ${candidate} does not match the expected version ${release.version.version} (ex. ${exampleTag}). ${warnMsg}` - ) - } - - const owner = 'sourcegraph' - const repo = 'sourcegraph' - - const releaseTag = `v${release.version.version}` - - try { - // passing the tag as branch so that only the specified tag is shallow cloned - const { workdir } = await cloneRepo(client, owner, repo, { - revision: candidate, - revisionMustExist: true, - }) - - execa.sync('git', ['fetch', '--tags'], { stdio: 'inherit', cwd: workdir }) - await createTag( - client, - workdir, - { - owner, - repo, - branch: candidate, - tag: releaseTag, - }, - config.dryRun.tags || false - ) - console.log(`To check the status of the build, run:\nsg ci status -branch ${releaseTag} --wait\n`) - } catch (error) { - console.error('Failed to create tag', error) - } - }, - }, - { - id: 'release:check-candidate', - description: 'Check release candidates.', - argNames: ['version'], - run: async (config, version) => { - if (!version) { - const release = await getActiveRelease(config) - version = release.version.version - } - const tags = getCandidateTags(localSourcegraphRepo, version) - if (tags.length > 0) { - console.log(`Release candidate tags for version: ${chalk.blue(version)}`) - for (const tag of tags) { - console.log(tag) - } - console.log('To check the status of the build, run:\nsg ci status -branch tag\n') - } else { - console.log(chalk.yellow('No candidates found!')) - } - }, - }, - { - id: 'release:stage', - description: 'Open pull requests and a batch change staging a release', - run: async config => { - const release = await getActiveRelease(config) - // ensure docker is running for 'batch changes' - try { - await ensureDocker() - } catch (error) { - console.log(error) - console.log('Docker required for batch changes') - process.exit(1) - } - // ensure $SRC_ENDPOINT is set - ensureSrcCliEndpoint() - // ensure src-cli is up to date - await ensureSrcCliUpToDate() - // set up batch change config - const batchChange = batchChanges.releaseTrackingBatchChange( - release.version.version, - await batchChanges.sourcegraphCLIConfig() - ) - - await validateNoReleaseBlockers(await getAuthenticatedGitHubClient()) - - // default values - const notPatchRelease = release.version.patch === 0 - const versionRegex = '[0-9]+\\.[0-9]+\\.[0-9]+' - const batchChangeURL = batchChanges.batchChangeURL(batchChange) - const trackingIssue = await getTrackingIssue(await getAuthenticatedGitHubClient(), release.version) - if (!trackingIssue) { - throw new Error( - `Tracking issue for version ${release.version.version} not found - has it been created yet?` - ) - } - - // default PR content - const defaultPRMessage = `release: sourcegraph@${release.version.version}` - const prBodyAndDraftState = ( - actionItems: string[], - customMessage?: string - ): { draft: boolean; body: string } => { - const defaultBody = `This pull request is part of the Sourcegraph ${release.version.version} release. -${customMessage || ''} - -* [Release batch change](${batchChangeURL}) -* ${trackingIssue ? `[Tracking issue](${trackingIssue.url})` : 'No tracking issue exists for this release'} - -### Test plan - -CI checks in this repository should pass, and a manual review should confirm if the generated changes are correct.` - - if (!actionItems || actionItems.length === 0) { - return { draft: false, body: defaultBody } - } - return { - draft: true, // further actions required before merge - body: `${defaultBody} - -### :warning: Additional changes required - -These steps must be completed before this PR can be merged, unless otherwise stated. Push any required changes directly to this PR branch. - -${actionItems.map(item => `- [ ] ${item}`).join('\n')} - -cc @${release.captainGitHubUsername} - -`, - } - } - - const { username: dockerUsername, password: dockerPassword } = await getContainerRegistryCredential( - 'index.docker.io' - ) - - // Render changes - const createdChanges = await createChangesets({ - requiredCommands: ['comby', sed, 'find', 'go', 'src', 'sg'], - changes: [ - { - owner: 'sourcegraph', - repo: 'sourcegraph', - base: 'main', - head: `publish-${release.version.version}`, - commitMessage: notPatchRelease - ? `draft sourcegraph@${release.version.version} release` - : defaultPRMessage, - title: defaultPRMessage, - edits: [ - // Update references to Sourcegraph deployment versions - `comby -in-place 'latestReleaseKubernetesBuild = newPingResponse(":[1]")' "latestReleaseKubernetesBuild = newPingResponse(\\"${release.version.version}\\")" internal/updatecheck/handler.go`, - `comby -in-place 'latestReleaseDockerServerImageBuild = newPingResponse(":[1]")' "latestReleaseDockerServerImageBuild = newPingResponse(\\"${release.version.version}\\")" internal/updatecheck/handler.go`, - `comby -in-place 'latestReleaseDockerComposeOrPureDocker = newPingResponse(":[1]")' "latestReleaseDockerComposeOrPureDocker = newPingResponse(\\"${release.version.version}\\")" internal/updatecheck/handler.go`, - - // Support current release as the "previous release" going forward - notPatchRelease - ? `comby -in-place 'const minimumUpgradeableVersion = ":[1]"' 'const minimumUpgradeableVersion = "${release.version.version}"' dev/ci/internal/ci/*.go` - : 'echo "Skipping minimumUpgradeableVersion bump on patch release"', - ], - ...prBodyAndDraftState( - ((): string[] => { - const items: string[] = [] - items.push( - 'Ensure all other pull requests in the batch change have been merged', - 'Run `pnpm run release release:finalize` to generate the tags required. CI will not pass until this command is run.', - 'Re-run the build on this branch (using either the command `sg ci build` or the Buildkite UI) and merge when the build passes.' - ) - return items - })() - ), - }, - { - owner: 'sourcegraph', - repo: 'docs', - base: 'main', - head: `publish-${release.version.version}`, - commitMessage: notPatchRelease - ? `draft sourcegraph@${release.version.version} release` - : defaultPRMessage, - title: defaultPRMessage, - edits: [ - // Update sourcegraph/server:VERSION everywhere except changelog - `find . -type f -name '*.mdx' ! -name 'doc/CHANGELOG.md' -exec ${sed} -i -E 's/sourcegraph\\/server:${versionRegex}/sourcegraph\\/server:${release.version.version}/g' {} +`, - // Update Sourcegraph versions in installation guides - `find ./docs/admin/deploy/ -type f -name '*.mdx' -exec ${sed} -i -E 's/SOURCEGRAPH_VERSION="v${versionRegex}"/SOURCEGRAPH_VERSION="v${release.version.version}"/g' {} +`, - `find ./docs/admin/deploy/ -type f -name '*.mdx' -exec ${sed} -i -E 's/--version ${versionRegex}/--version ${release.version.version}/g' {} +`, - // Update fork variables in installation guides - `find ./docs/admin/deploy/ -type f -name '*.mdx' -exec ${sed} -i -E "s/DEPLOY_SOURCEGRAPH_DOCKER_FORK_REVISION='v${versionRegex}'/DEPLOY_SOURCEGRAPH_DOCKER_FORK_REVISION='v${release.version.version}'/g" {} +`, - - updateDocsUpgradeGuides(release.previous.version, release.version.version), - ], - ...prBodyAndDraftState([]), - }, - { - owner: 'sourcegraph', - repo: 'about', - base: 'main', - head: `publish-${release.version.version}`, - commitMessage: defaultPRMessage, - title: defaultPRMessage, - edits: [ - // Update sourcegraph/server:VERSION in all tsx files - `find . -type f -name '*.tsx' -exec ${sed} -i -E 's/sourcegraph\\/server:${versionRegex}/sourcegraph\\/server:${release.version.version}/g' {} +`, - ], - ...prBodyAndDraftState( - [], - notPatchRelease ? 'Note that this PR does *not* include the release blog post.' : undefined - ), - }, - { - owner: 'sourcegraph', - repo: 'deploy-sourcegraph', - base: release.branch, - head: `publish-${release.version.version}`, - commitMessage: defaultPRMessage, - title: defaultPRMessage, - edits: [`tools/update-docker-tags.sh ${release.version.version}`], - ...prBodyAndDraftState([]), - }, - { - owner: 'sourcegraph', - repo: 'deploy-sourcegraph-k8s', - base: release.branch, - head: `publish-${release.version.version}`, - commitMessage: defaultPRMessage, - title: defaultPRMessage, - edits: [ - `sg ops update-images -cr-username ${dockerUsername} -cr-password ${dockerPassword} -pin-tag ${release.version.version} base/`, - `sg ops update-images -cr-username ${dockerUsername} -cr-password ${dockerPassword} -pin-tag ${release.version.version} components/executors/`, - `sg ops update-images -cr-username ${dockerUsername} -cr-password ${dockerPassword} -pin-tag ${release.version.version} components/utils/migrator`, - ], - ...prBodyAndDraftState([]), - }, - { - owner: 'sourcegraph', - repo: 'deploy-sourcegraph-docker', - base: release.branch, - head: `publish-${release.version.version}`, - commitMessage: defaultPRMessage, - title: defaultPRMessage, - edits: [`tools/update-docker-tags.sh ${release.version.version}`], - ...prBodyAndDraftState([]), - }, - { - owner: 'sourcegraph', - repo: 'deploy-sourcegraph-docker-customer-replica-1', - base: release.branch, - head: `publish-${release.version.version}`, - commitMessage: defaultPRMessage, - title: defaultPRMessage, - edits: [`tools/update-docker-tags.sh ${release.version.version}`], - ...prBodyAndDraftState([]), - }, - { - owner: 'sourcegraph', - repo: 'deploy-sourcegraph-aws', - base: 'master', - head: `publish-${release.version.version}`, - commitMessage: defaultPRMessage, - title: defaultPRMessage, - edits: [ - `${sed} -i -E 's/export SOURCEGRAPH_VERSION=${versionRegex}/export SOURCEGRAPH_VERSION=${release.version.version}/g' resources/amazon-linux2.sh`, - ], - ...prBodyAndDraftState([]), - }, - { - owner: 'sourcegraph', - repo: 'deploy-sourcegraph-digitalocean', - base: 'master', - head: `publish-${release.version.version}`, - commitMessage: defaultPRMessage, - title: defaultPRMessage, - edits: [ - `${sed} -i -E 's/export SOURCEGRAPH_VERSION=${versionRegex}/export SOURCEGRAPH_VERSION=${release.version.version}/g' resources/user-data.sh`, - ], - ...prBodyAndDraftState([]), - }, - { - owner: 'sourcegraph', - repo: 'deploy-sourcegraph-helm', - base: `release/${release.branch}`, - head: `publish-${release.version.version}`, - commitMessage: defaultPRMessage, - title: defaultPRMessage, - edits: [ - `for i in charts/{sourcegraph,sourcegraph-executor/{dind,k8s},sourcegraph-migrator}; do sg ops update-images -cr-username ${dockerUsername} -cr-password ${dockerPassword} -kind helm -pin-tag ${release.version.version} $i/.; done`, - `find charts -name Chart.yaml | xargs ${sed} -i 's/appVersion:.*/appVersion: "${release.version.version}"/g'`, - `find charts -name Chart.yaml | xargs ${sed} -i 's/version:.*/version: "${release.version.version}"/g'`, - './scripts/helm-docs.sh', - ], - ...prBodyAndDraftState([]), - }, - ], - dryRun: config.dryRun.changesets, - }) - - // if changesets were actually published, set up a batch change and post in Slack - if (!config.dryRun.changesets) { - // Create batch change to track changes - try { - console.log(`Creating batch change in ${batchChange.cliConfig.SRC_ENDPOINT}`) - await batchChanges.createBatchChange( - createdChanges, - batchChange, - `Track publishing of sourcegraph v${release.version.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 - if (!config.dryRun.slack) { - await postMessage( - `:captain: *Sourcegraph ${release.version.version} has been staged.* - -Batch change: ${batchChangeURL}`, - config.metadata.slackAnnounceChannel - ) - } - } - }, - }, - { - id: 'release:add-to-batch-change', - description: 'Manually add a change to a release batch change', - argNames: ['changeRepo', 'changeID'], - // Example: pnpm run release release:add-to-batch-change sourcegraph/about 1797 - run: async (config, changeRepo, changeID) => { - const release = await getActiveRelease(config) - if (!changeRepo || !changeID) { - throw new Error('Missing parameters (required: version, repo, change ID)') - } - - const batchChange = batchChanges.releaseTrackingBatchChange( - release.version.version, - await batchChanges.sourcegraphCLIConfig() - ) - await batchChanges.addToBatchChange( - [ - { - repository: changeRepo, - pullRequestNumber: parseInt(changeID, 10), - }, - ], - batchChange - ) - console.log(`Added ${changeRepo}#${changeID} to batch change ${batchChanges.batchChangeURL(batchChange)}`) - }, - }, - { - id: 'release:finalize', - description: 'Run final tasks for sourcegraph/sourcegraph release pull requests', - run: async config => { - const release = await getActiveRelease(config) - let failed = false - - const defaultBranchPattern = `${release.branch}` - const defaultTagPattern = `v${release.version.version}` - const defaults = { branchPattern: defaultBranchPattern, tagPattern: defaultTagPattern } - const repoConfigs = [ - { repo: 'deploy-sourcegraph', ...defaults }, - { repo: 'deploy-sourcegraph-docker', ...defaults }, - { repo: 'deploy-sourcegraph-docker-customer-replica-1', ...defaults }, - { repo: 'deploy-sourcegraph-k8s', ...defaults }, - { - repo: 'deploy-sourcegraph-helm', - branchPattern: `release/${release.branch}`, - tagPattern: `sourcegraph-${release.version.version}`, - }, - ] - - const owner = 'sourcegraph' - // Push final tags - for (const repoConfig of repoConfigs) { - try { - const client = await getAuthenticatedGitHubClient() - const { workdir } = await cloneRepo(client, owner, repoConfig.repo, { - revision: repoConfig.branchPattern, - revisionMustExist: true, - }) - await createTag( - client, - workdir, - { - owner, - repo: repoConfig.repo, - branch: repoConfig.branchPattern, - tag: repoConfig.tagPattern, - }, - config.dryRun.tags || false - ) - } catch (error) { - console.error(error) - console.error( - `Failed to create tag ${repoConfig.tagPattern} on ${repoConfig.repo}@${release.branch}` - ) - failed = true - } - } - - if (failed) { - throw new Error('Error occured applying some changes - please check log output') - } - }, - }, - { - id: 'release:announce', - description: 'Announce a release as live', - run: async config => { - const release = await getActiveRelease(config) - const githubClient = await getAuthenticatedGitHubClient() - - // Create final GitHub release - let githubRelease = '' - try { - githubRelease = await createLatestRelease( - githubClient, - { - owner: 'sourcegraph', - repo: 'sourcegraph', - release: release.version, - }, - config.dryRun.tags - ) - } catch (error) { - console.error('Failed to generate GitHub release:', error) - // Do not block process - } - - // Set up announcement message - const batchChangeURL = batchChanges.batchChangeURL( - batchChanges.releaseTrackingBatchChange( - release.version.version, - await batchChanges.sourcegraphCLIConfig() - ) - ) - const releaseMessage = `*Sourcegraph ${release.version.version} has been published* - -* Changelog: ${changelogURL(release.version.format())} -* GitHub release: ${githubRelease || 'No release generated'} -* Release batch change: ${batchChangeURL}` - - // Slack - const slackMessage = `:captain: ${releaseMessage}` - if (!config.dryRun.slack) { - await postMessage(slackMessage, config.metadata.slackAnnounceChannel) - console.log(`Posted to Slack channel ${config.metadata.slackAnnounceChannel}`) - } else { - console.log( - `dryRun enabled, skipping Slack post to ${config.metadata.slackAnnounceChannel}: ${slackMessage}` - ) - } - - // GitHub tracking issues - const trackingIssue = await getTrackingIssue(githubClient, release.version) - if (!trackingIssue) { - console.warn(`Could not find tracking issue for release ${release.version.version} - skipping`) - } else { - // Note patch release requests if there are any outstanding - let comment = `${releaseMessage} - -@${release.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 (!config.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}`) - } - } - }, - }, - { - id: 'release:close', - description: 'Close tracking issues for current release', - run: async config => { - const active = await getActiveRelease(config) - // close tracking issue - await closeTrackingIssue(active.version) - console.log(chalk.blue('Deactivating release...')) - removeScheduledRelease(config, active.version.version) - deactivateAllReleases(config) - console.log(chalk.green(`Release ${active.version.format()} closed!`)) - }, - }, - { - id: 'release:prepare', - description: 'Schedule a release', - run: async config => { - const rel = await newReleaseFromInput() - addScheduledRelease(config, rel) - saveReleaseConfig(config) - }, - }, - { - id: 'release:remove', - description: 'Remove a release from the config', - argNames: ['version'], - run: async (config, version) => { - await verifyWithInput(`Confirm you want to remove release: ${version} from the release config?`) - const rconfig = loadReleaseConfig() - removeScheduledRelease(rconfig, version) - saveReleaseConfig(rconfig) - }, - }, - { - id: 'release:activate-release', - description: 'Activate a feature release', - run: async config => { - await activateRelease(config) - }, - }, - { - id: 'release:deactivate-release', - description: 'De-activate a feature release', - run: async config => { - await verifyWithInput('Are you sure you want to deactivate all releases?') - deactivateAllReleases(config) - }, - }, - { - id: 'release:bake-content', - description: - 'Bake constants and other static content into the release. Only required for minor / major versions.', - run: async config => { - const release = await getActiveRelease(config) - if (release.version.patch !== 0) { - console.log('content bake is only required for major / minor versions') - exit(1) - } - - // Creates PR's for executor release steps - await bakeGoogleExecutorsSteps(config) - await bakeAWSExecutorsSteps(config) - - const releaseBranch = release.branch - const version = release.version.version - ensureReleaseBranchUpToDate(releaseBranch) - - const multiVersionSteps: Edit[] = [ - `git remote set-branches --add origin '${releaseBranch}'`, - `git fetch --depth 1 origin ${releaseBranch}`, - combyReplace( - 'const maxVersionString = ":[1]"', - version, - 'internal/database/migration/shared/data/cmd/generator/consts.go' - ), - `bazel run //dev:write_all_generated`, - ] - const srcCliSteps = await bakeSrcCliSteps(config) - - const mainBranchEdits: Edit[] = [ - ...multiVersionSteps, - ...srcCliSteps, - ...batchChangesInAppChangelog(new SemVer(release.version.version).inc('minor'), true), // in the next main branch this will reflect the guessed next version - ] - - const releaseBranchEdits: Edit[] = [ - ...multiVersionSteps, - ...srcCliSteps, - ...batchChangesInAppChangelog(release.version, false), - ] - - const prDetails = { - body: pullRequestBody(`Bake constants and static content into version v${version}.`), - title: `v${version} bake constants and static content`, - commitMessage: `bake constants and static content for version v${version}`, - } - - const sets = await createChangesets({ - requiredCommands: ['comby', 'go', 'bazel'], - changes: [ - { - ...prDetails, - repo: 'sourcegraph', - owner: 'sourcegraph', - base: 'main', - head: `${version}-bake`, - edits: mainBranchEdits, - labels: [releaseBlockerLabel], - }, - { - ...prDetails, - repo: 'sourcegraph', - owner: 'sourcegraph', - base: releaseBranch, - head: `${version}-bake-rb`, - edits: releaseBranchEdits, - labels: [releaseBlockerLabel], - }, - ], - dryRun: config.dryRun.changesets, - }) - console.log('Merge the following pull requests:\n') - for (const set of sets) { - console.log(set.pullRequestURL) - } - }, - }, - { - id: 'release:create-tags', - description: 'Release a new version of src-cli and executors. Only required for minor and major versions', - run: async config => { - const release = await getActiveRelease(config) - if (release.version.patch !== 0) { - console.log('src-cli and executors releases are only supported in this tool for major / minor releases') - exit(1) - } - - const repos = ['src-cli', 'terraform-google-executors', 'terraform-aws-executors'] - const tags: ReleaseTag[] = [] - - const client = await getAuthenticatedGitHubClient() - - for (const repo of repos) { - const { workdir } = await cloneRepo(client, 'sourcegraph', repo, { - revision: repo === 'src-cli' ? 'main' : 'master', - revisionMustExist: true, - }) - - switch (repo) { - case 'src-cli': { - const next = await nextSrcCliVersionInputWithAutodetect(config, workdir) - setSrcCliVersion(config, next.version) - tags.push({ - repo, - nextTag: next.version, - workDir: workdir, - }) - break - } - case 'terraform-google-executors': { - const nextGoogle = await nextGoogleExecutorVersionInputWithAutodetect(config, workdir) - setGoogleExecutorVersion(config, nextGoogle.version) - tags.push({ - repo, - nextTag: nextGoogle.version, - workDir: workdir, - }) - break - } - case 'terraform-aws-executors': { - const nextAWS = await nextAWSExecutorVersionInputWithAutodetect(config, workdir) - setAWSExecutorVersion(config, nextAWS.version) - tags.push({ - repo, - nextTag: nextAWS.version, - workDir: workdir, - }) - break - } - } - } - - for (const tag of tags) { - const { repo, nextTag, workDir } = tag - if (!config.dryRun.changesets) { - // actually execute the release - if (repo === 'src-cli') { - await execa('bash', ['-c', 'yes | ./release.sh'], { - stdio: 'inherit', - cwd: workDir, - env: { ...process.env, VERSION: nextTag }, - }) - } else { - await execa('bash', ['-c', `yes | ./release.sh ${nextTag}`], { - stdio: 'inherit', - cwd: workDir, - }) - } - } else { - console.log(chalk.blue(`Skipping ${repo} release for dry run`)) - } - } - }, - }, - { - id: 'release:verify-releases', - description: 'Verify src-cli version is available in brew and npm and executors tags are available', - run: async config => { - let passed = true - let expected = config.in_progress?.srcCliVersion - let expectedAWSExecutor = config.in_progress?.awsExecutorVersion - let expectedGoogleExecutor = config.in_progress?.googleExecutorVersion - - const formatVersion = function (val: string): string { - if (val === expected) { - return chalk.green(val) - } - passed = false - return chalk.red(val) - } - if (!config.in_progress?.srcCliVersion) { - expected = await retryInput( - 'Enter the expected version of src-cli: ', - val => !!semver.parse(val), - 'Expected semver format' - ) - } else { - console.log(`Expecting src-cli version ${expected} from release config`) - } - if (!config.in_progress?.googleExecutorVersion) { - expectedGoogleExecutor = await retryInput( - 'Enter the expected version of the Google executor: ', - val => !!semver.parse(val), - 'Expected semver format' - ) - } else { - console.log(`Expecting Google executor version v${expectedGoogleExecutor} from release config`) - } - - if (!config.in_progress?.awsExecutorVersion) { - expectedAWSExecutor = await retryInput( - 'Enter the expected version of the AWS executor: ', - val => !!semver.parse(val), - 'Expected semver format' - ) - } else { - console.log(`Expecting AWS executor version v${expectedAWSExecutor} from release config`) - } - - const latestGoogleVersion = await getLatestTag('sourcegraph', 'terraform-google-executors') - const latestAWSVersion = await getLatestTag('sourcegraph', 'terraform-aws-executors') - - if (latestGoogleVersion !== `v${expectedGoogleExecutor}`) { - passed = false - } - console.log(`terraform-google-executors:\t${formatVersion(latestGoogleVersion)}`) - - if (latestAWSVersion !== `v${expectedAWSExecutor}`) { - passed = false - } - - console.log(`terraform-aws-executors:\t${formatVersion(latestAWSVersion)}`) - - const githubRelease = await getLatestSrcCliGithubRelease() - console.log(`github:\t${formatVersion(githubRelease)}`) - - const brewVersion = execa.sync('bash', [ - '-c', - "brew info sourcegraph/src-cli/src-cli -q | sed -n 's/.*stable \\([0-9]\\.[0-9]\\.[0-9]\\)/\\1/p'", - ]).stdout - console.log(`brew:\t${formatVersion(brewVersion)}`) - - const npmVersion = execa.sync('bash', ['-c', 'npm show @sourcegraph/src version']).stdout - console.log(`npm:\t${formatVersion(npmVersion)}`) - - if (passed === true) { - console.log(chalk.green('All versions matched expected version!')) - } else { - console.log(chalk.red('Failed to verify versions')) - exit(1) - } - }, - }, - { - id: 'util:clear-cache', - description: 'Clear release tool cache', - run: () => { - rmdirSync(cacheFolder, { recursive: true }) - }, - }, - { - id: 'util:previous-version', - description: 'Calculate the previous version based on repo tags', - argNames: ['version'], - run: (config: ReleaseConfig, version?: string) => { - let ver: SemVer | undefined - if (version) { - ver = new SemVer(version) - console.log(`Getting previous version from: ${version}...`) - } else { - console.log('Getting previous version...') - } - const prev = getPreviousVersion(ver) - console.log(chalk.green(`${prev.format()}`)) - }, - }, - { - id: '_test:release-guide-content', - description: 'Generate upgrade guides', - argNames: ['previous', 'next'], - run: (config, previous, next) => { - for (const content of getAllUpgradeGuides(previous, next)) { - console.log(content) - } - }, - }, - { - id: '_test:release-guide-update', - description: 'Test update the upgrade guides', - argNames: ['previous', 'next', 'dir'], - run: (config, previous, next, dir) => { - updateDocsUpgradeGuides(previous, next)(dir) - }, - }, - { - id: '_test:google-calendar', - description: 'Test Google Calendar integration', - run: async config => { - const googleCalendar = await getClient() - const release = await getActiveRelease(config) - await ensureEvent( - { - title: 'TEST EVENT', - startDateTime: new Date(release.releaseDate).toISOString(), - endDateTime: addMinutes(new Date(release.releaseDate), 1).toISOString(), - transparency: 'transparent', - }, - googleCalendar - ) - }, - }, - { - id: '_test:slack', - description: 'Test Slack integration', - argNames: ['channel', 'message'], - run: async ({ dryRun }, channel, message) => { - if (!dryRun.slack) { - await postMessage(message, channel) - } - }, - }, - { - id: '_test:batchchange-create-from-changes', - description: 'Test batch changes integration', - argNames: ['batchchangeConfigJSON'], - // Example: pnpm run release _test:batchchange-create-from-changes "$(cat ./.secrets/test-batch-change-import.json)" - run: async (_config, batchchangeConfigJSON) => { - const batchChangeConfig = JSON.parse(batchchangeConfigJSON) as { - changes: CreatedChangeset[] - name: string - description: string - } - - // set up src-cli - await commandExists('src') - const batchChange = { - name: batchChangeConfig.name, - description: batchChangeConfig.description, - namespace: 'sourcegraph', - cliConfig: await batchChanges.sourcegraphCLIConfig(), - } - - await batchChanges.createBatchChange( - batchChangeConfig.changes, - batchChange, - 'release tool testing batch change' - ) - console.log(`Created batch change ${batchChanges.batchChangeURL(batchChange)}`) - }, - }, - { - id: '_test:config', - description: 'Test release configuration loading', - run: config => { - console.log(JSON.stringify(config, null, 2)) - }, - }, - { - id: '_test:dockerensure', - description: 'test docker ensure function', - run: async () => { - try { - await ensureDocker() - } catch (error) { - console.log(error) - console.log('Docker required for batch changes') - process.exit(1) - } - }, - }, - { - id: '_test:srccliensure', - description: 'test srccli version', - run: async () => { - ensureSrcCliEndpoint() - await ensureSrcCliUpToDate() - }, - }, - { - id: '_test:patch-dates', - description: 'test patch dates', - run: () => { - console.log(newRelease(new SemVer('1.0.0'), DateTime.fromISO('2023-03-22'), 'test', 'test')) - }, - }, -] diff --git a/dev/release/src/slack.ts b/dev/release/src/slack.ts deleted file mode 100644 index f9c1794ae23..00000000000 --- a/dev/release/src/slack.ts +++ /dev/null @@ -1,17 +0,0 @@ -import got from 'got' - -import { readLine, cacheFolder } from './util' - -export async function postMessage(message: string, channel: string): Promise { - const webhookURL = await readLine( - `Enter the Slack webhook URL corresponding to the #${channel} channel (https://start.1password.com/open/i?a=HEDEDSLHPBFGRBTKAKJWE23XX4&v=dnrhbauihkhjs5ag6vszsme45a&i=pldpna5vivapxe4phewnqd42ji&h=team-sourcegraph.1password.com): `, - `${cacheFolder}/slackWebhookURL-${channel}.txt` - ) - await got.post(webhookURL, { - body: JSON.stringify({ text: message, link_names: true }), - }) -} - -export function slackURL(text: string, url: string): string { - return `<${url}|${text}>` -} diff --git a/dev/release/src/static-updates.ts b/dev/release/src/static-updates.ts deleted file mode 100644 index 58b44451ed8..00000000000 --- a/dev/release/src/static-updates.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { SemVer } from 'semver' - -import { type ReleaseConfig, setAWSExecutorVersion, setGoogleExecutorVersion, setSrcCliVersion } from './config' -import { cloneRepo, createChangesets, type Edit, getAuthenticatedGitHubClient, releaseBlockerLabel } from './github' -import { - nextAWSExecutorVersionInputWithAutodetect, - nextGoogleExecutorVersionInputWithAutodetect, - nextSrcCliVersionInputWithAutodetect, - pullRequestBody, -} from './util' - -export async function bakeSrcCliSteps(config: ReleaseConfig): Promise { - const client = await getAuthenticatedGitHubClient() - - // NOTE(keegan): 2024-02-13 I am running the 5.3 release but this is all - // borked. We used to run a src-cli reference doc generator, but we now - // have a docs repo which uses mdx files. So the reference generator needs - // to be updated. So for now I am just skipping this and will follow up - // later. Leaving the original comment below since we still need the next - // var to be calculated. Additionally just commenting out the broken code. - // - // ok, this seems weird that we're cloning src-cli here, so read on - - // We have docs that live in the main src/src repo about src-cli. Each version we update these docs for any changes - // from the most recent version of src-cli. Cool, makes sense. - // The thing is that these docs are generated from src-cli itself (a literal command, src docs). - // So our options are either to release a new version of src-cli, wait for the github action to be complete and THEN update the src/src repo, - // OR we can assume that main is going to be the new version (which it is). So we will clone it and execute the - // commands against the binary directly, saving ourselves a lot of time. - const { workdir } = await cloneRepo(client, 'sourcegraph', 'src-cli', { - revision: 'main', - revisionMustExist: true, - }) - - const next = await nextSrcCliVersionInputWithAutodetect(config, workdir) - setSrcCliVersion(config, next.version) - - return [ - combyReplace('const MinimumVersion = ":[1]"', next.version, 'internal/src-cli/consts.go'), - // Broken since docs migration - //`cd ${workdir}/cmd/src && go build`, - //`cd doc/cli/references && go run ./doc.go --binaryPath="${workdir}/cmd/src/src"`, - ] -} -export async function bakeAWSExecutorsSteps(config: ReleaseConfig): Promise { - const client = await getAuthenticatedGitHubClient() - const { workdir } = await cloneRepo(client, 'sourcegraph', 'terraform-aws-executors', { - revision: 'master', - revisionMustExist: true, - }) - - const next = await nextAWSExecutorVersionInputWithAutodetect(config, workdir) - setAWSExecutorVersion(config, next.version) - console.log(next) - - const prDetails = { - body: pullRequestBody(`Update files for ${next.version} release`), - title: `executor: v${next.version}`, - commitMessage: `executor: v${next.version}`, - } - /* - TODO prepare-release.sh commits and pushes the change, but - createChangesets expects to do this. This needs to be fixed before the - next minor release. I propose making prepare-release not commit and - push. Or even better just get rid of it since its an overengineered - wrapper around a single sed call. Then you can also remove the unshallow - call. - */ - const sets = await createChangesets({ - requiredCommands: [], - changes: [ - { - ...prDetails, - owner: 'sourcegraph', - repo: 'terraform-aws-executors', - base: 'master', - head: `release/prepare-${next.version}`, - // prepare-release.sh needs full history to read tags - edits: ['git fetch --unshallow', `./prepare-release.sh ${next.version}`], - labels: [releaseBlockerLabel], - draft: true, - }, - ], - dryRun: config.dryRun.changesets, - }) - console.log('Merge the following pull requests:\n') - for (const set of sets) { - console.log(set.pullRequestURL) - } -} - -export async function bakeGoogleExecutorsSteps(config: ReleaseConfig): Promise { - const client = await getAuthenticatedGitHubClient() - const { workdir } = await cloneRepo(client, 'sourcegraph', 'terraform-google-executors', { - revision: 'master', - revisionMustExist: true, - }) - console.log(`Cloned sourcegraph/terraform-google-executors to ${workdir}`) - - const next = await nextGoogleExecutorVersionInputWithAutodetect(config, workdir) - setGoogleExecutorVersion(config, next.version) - - const prDetails = { - body: pullRequestBody(`Update files for ${next.version} release`), - title: `executor: v${next.version}`, - commitMessage: `executor: v${next.version}`, - } - /* - TODO prepare-release.sh commits and pushes the change, but - createChangesets expects to do this. This needs to be fixed before the - next minor release. I propose making prepare-release not commit and - push. Or even better just get rid of it since its an overengineered - wrapper around a single sed call. Then you can also remove the unshallow - call. - */ - const sets = await createChangesets({ - requiredCommands: [], - changes: [ - { - ...prDetails, - repo: 'terraform-google-executors', - owner: 'sourcegraph', - base: 'master', - head: `release/prepare-${next.version}`, - // prepare-release.sh needs full history to read tags - edits: ['git fetch --unshallow', `./prepare-release.sh ${next.version}`], - labels: [releaseBlockerLabel], - draft: true, - }, - ], - dryRun: config.dryRun.changesets, - }) - console.log('Merge the following pull requests:\n') - for (const set of sets) { - console.log(set.pullRequestURL) - } -} - -export function batchChangesInAppChangelog(version: SemVer, resetShow: boolean): Edit[] { - const path = 'client/web/src/enterprise/batches/list/BatchChangesChangelogAlert.tsx' - const steps = [combyReplace("const CURRENT_VERSION = ':[1]'", `${version.major}.${version.minor}`, path)] - if (resetShow) { - steps.push(combyReplace('const SHOW_CHANGELOG = :[1]', 'false', path)) - } - return steps -} - -// given a comby pattern such as 'const MinimumVersion = ":[1]"' generate the comby expression to replace with provided substitution -export function combyReplace(pattern: string, replace: string, path: string): Edit { - pattern = pattern.replaceAll('"', '\\"') - const sub = pattern.replace(':[1]', replace) - return `comby -in-place "${pattern}" "${sub}" ${path}` -} diff --git a/dev/release/src/update.ts b/dev/release/src/update.ts deleted file mode 100644 index 6c79cac7787..00000000000 --- a/dev/release/src/update.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const divider = '' - -export const releaseTemplate = `${divider} - -## Unreleased - -` diff --git a/dev/release/src/util.ts b/dev/release/src/util.ts deleted file mode 100644 index 7ffcaac25b0..00000000000 --- a/dev/release/src/util.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { readdirSync, readFileSync, writeFileSync } from 'fs' -import * as path from 'path' -import * as readline from 'readline' - -import type Octokit from '@octokit/rest' -import chalk from 'chalk' -import execa from 'execa' -import { mkdir, readFile, writeFile } from 'mz/fs' -import fetch from 'node-fetch' -import * as semver from 'semver' -import { SemVer } from 'semver' - -import type { ReleaseConfig } from './config' -import { getPreviousVersionExecutor, getPreviousVersionSrcCli } from './git' -import { cloneRepo, type EditFunc, getAuthenticatedGitHubClient, listIssues } from './github' -import * as update from './update' - -const SOURCEGRAPH_RELEASE_INSTANCE_URL = 'https://sourcegraph.sourcegraph.com' - -export interface ReleaseTag { - repo: string - nextTag: string - workDir: string -} - -/* eslint-disable @typescript-eslint/consistent-type-assertions */ -export function formatDate(date: Date): string { - return `${date.toLocaleString('en-US', { - timeZone: 'UTC', - dateStyle: 'medium', - timeStyle: 'short', - } as Intl.DateTimeFormatOptions)} (UTC)` -} -/* eslint-enable @typescript-eslint/consistent-type-assertions */ - -const addZero = (index: number): string => (index < 10 ? `0${index}` : `${index}`) - -/** - * Generates a link for comparing given Date with local time. - */ -export function timezoneLink(date: Date, linkName: string): string { - const timeString = `${addZero(date.getUTCHours())}${addZero(date.getUTCMinutes())}` - return `https://time.is/${timeString}_${date.getUTCDate()}_${date.toLocaleString('en-US', { - month: 'short', - })}_${date.getUTCFullYear()}_in_UTC?${encodeURI(linkName)}` -} - -export const cacheFolder = './.secrets' - -export async function readLine(prompt: string, cacheFile?: string): Promise { - if (!cacheFile) { - return readLineNoCache(prompt) - } - - try { - return (await readFile(cacheFile, { encoding: 'utf8' })).trimEnd() - } catch { - const userInput = await readLineNoCache(prompt) - await mkdir(path.dirname(cacheFile), { recursive: true }) - await writeFile(cacheFile, userInput) - return userInput - } -} - -async function readLineNoCache(prompt: string): Promise { - const readlineInterface = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - const userInput = await new Promise(resolve => readlineInterface.question(prompt, resolve)) - readlineInterface.close() - return userInput -} - -export async function verifyWithInput(prompt: string): Promise { - await readLineNoCache(chalk.yellow(`${prompt}\nInput yes to confirm: `)).then(val => { - if (!(val === 'yes' || val === 'y')) { - console.log(chalk.red('Aborting!')) - process.exit(0) - } - }) -} - -// similar to verifyWithInput but will not exit and will allow the caller to decide what to do -export async function softVerifyWithInput(prompt: string): Promise { - return readLineNoCache(chalk.yellow(`${prompt}\nInput yes to confirm: `)).then(val => val === 'yes' || val === 'y') -} - -export async function ensureDocker(): Promise> { - return execa('docker', ['version'], { stdout: 'ignore' }) -} - -export function changelogURL(version: string): string { - const versionAnchor = version.replaceAll('.', '-') - return `https://sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/CHANGELOG.md#${versionAnchor}` -} - -function ensureBranchUpToDate(baseBranch: string, targetBranch: string): boolean { - const [behind, ahead] = execa - .sync('git', ['rev-list', '--left-right', '--count', targetBranch + '...' + baseBranch]) - .stdout.split('\t') - - if (behind === '0' && ahead === '0') { - return true - } - - const countCommits = function (numberOfCommits: string, aheadOrBehind: string): string { - return numberOfCommits === '1' - ? numberOfCommits + ' commit ' + aheadOrBehind - : numberOfCommits + ' commits ' + aheadOrBehind - } - - if (behind !== '0' && ahead !== '0') { - console.log( - `Your branch is ${countCommits(ahead, 'ahead')} and ${countCommits( - behind, - 'behind' - )} the branch ${targetBranch}.` - ) - } else if (behind !== '0') { - console.log(`Your branch is ${countCommits(behind, 'behind')} the branch ${targetBranch}.`) - } else if (ahead !== '0') { - console.log(`Your branch is ${countCommits(ahead, 'ahead')} the branch ${targetBranch}.`) - } - - return false -} - -export function ensureMainBranchUpToDate(): void { - const mainBranch = 'main' - const remoteMainBranch = 'origin/main' - const currentBranch = execa.sync('git', ['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim() - if (currentBranch !== mainBranch) { - console.log( - `Expected to be on branch ${mainBranch}, but was on ${currentBranch}. Run \`git checkout ${mainBranch}\` to switch to the main branch.` - ) - process.exit(1) - } - execa.sync('git', ['remote', 'update'], { stdout: 'ignore' }) - if (!ensureBranchUpToDate(mainBranch, remoteMainBranch)) { - process.exit(1) - } -} - -export function ensureReleaseBranchUpToDate(branch: string): void { - const remoteBranch = 'origin/' + branch - if (!ensureBranchUpToDate(branch, remoteBranch)) { - process.exit(1) - } -} - -export async function getLatestSrcCliGithubRelease(): Promise { - return fetch('https://api.github.com/repos/sourcegraph/src-cli/releases/latest', { - method: 'GET', - headers: { - Accept: 'application/json', - }, - }) - .then(response => response.json()) - .then(json => json.tag_name) -} - -export async function ensureSrcCliUpToDate(): Promise { - const latestTag = await getLatestSrcCliGithubRelease() - let installedTag = execa.sync('src', ['version']).stdout.split('\n') - installedTag = installedTag[0].split(':') - const trimmedInstalledTag = installedTag[1].trim() - - if (trimmedInstalledTag !== latestTag) { - try { - console.log('Uprading src-cli to the latest version.') - execa.sync('brew', ['upgrade', 'src-cli']) - } catch (error) { - console.log('Trouble upgrading src-cli:', error) - process.exit(1) - } - } -} - -export function ensureSrcCliEndpoint(): void { - const srcEndpoint = process.env.SRC_ENDPOINT - if (srcEndpoint !== SOURCEGRAPH_RELEASE_INSTANCE_URL) { - throw new Error(`the $SRC_ENDPOINT provided doesn't match what is expected by the release tool. -Expected $SRC_ENDPOINT to be "${SOURCEGRAPH_RELEASE_INSTANCE_URL}"`) - } -} - -export async function getLatestTag(owner: string, repo: string): Promise { - return fetch(`https://api.github.com/repos/${owner}/${repo}/tags`, { - method: 'GET', - headers: { - Accept: 'application/json', - }, - }) - .then(response => response.json()) - .then(json => json[0].name) -} - -interface ContainerRegistryCredential { - username: string - password: string - hostname: string -} - -export async function getContainerRegistryCredential(registryHostname: string): Promise { - const registryUsername = await readLine( - `Enter your container registry (${registryHostname} ) username: `, - `${cacheFolder}/cr_${registryHostname.replace('.', '_')}_username.txt` - ) - const registryPassword = await readLine( - `Enter your container registry (${registryHostname} ) access token: `, - `${cacheFolder}/cr_${registryHostname.replace('.', '_')}_password.txt` - ) - const credential: ContainerRegistryCredential = { - username: registryUsername, - password: registryPassword, - hostname: registryHostname, - } - return credential -} - -export type ContentFunc = (previousVersion?: string, nextVersion?: string) => string - -const upgradeContentGenerators: { [s: string]: ContentFunc } = { - docker_compose: () => '', - kubernetes: () => '', - server: () => '', - pure_docker: (previousVersion?: string, nextVersion?: string) => { - const compare = `compare/v${previousVersion}...v${nextVersion}` - return `As a template, perform the same actions as the following diff in your own deployment: [\`Upgrade to v${nextVersion}\`](https://github.com/sourcegraph/deploy-sourcegraph-docker/${compare}) -\nFor non-standard replica builds: -- [\`Customer Replica 1: ➔ v${nextVersion}\`](https://github.com/sourcegraph/deploy-sourcegraph-docker-customer-replica-1/${compare})` - }, -} -export const getUpgradeGuide = (mode: string): ContentFunc => upgradeContentGenerators[mode] - -export const getAllUpgradeGuides = (previous: string, next: string): string[] => - Object.keys(upgradeContentGenerators).map( - key => `Guide for: ${key}\n\n${upgradeContentGenerators[key](previous, next)}` - ) - -export const updateUpgradeGuides = (previous: string, next: string): EditFunc => { - let updateDirectory = '/doc/admin/updates' - const notPatchRelease = next.endsWith('.0') - - return (directory: string): void => { - updateDirectory = directory + updateDirectory - for (const file of readdirSync(updateDirectory)) { - if (file === 'index.md') { - continue - } - const mode = file.replace('.md', '') - const updateFunc = getUpgradeGuide(mode) - if (updateFunc === undefined) { - console.log(`Skipping upgrade file: ${file} due to missing content generator`) - continue - } - const guide = getUpgradeGuide(mode)(previous, next) - - const fullPath = path.join(updateDirectory, file) - console.log(`Updating upgrade guide: ${fullPath}`) - let updateContents = readFileSync(fullPath).toString() - const releaseHeader = `## v${previous} ➔ v${next}` - const notesHeader = '\n\n#### Notes:' - - if (notPatchRelease) { - let content = `${update.releaseTemplate}\n\n${releaseHeader}` - if (guide) { - content = `${content}\n\n${guide}` - } - content = content + notesHeader - updateContents = updateContents.replace(update.releaseTemplate, content) - } else { - const prevReleaseHeaderPattern = `##\\s+v\\d\\.\\d(?:\\.\\d)? ➔ v${previous}\\s*` - const matches = updateContents.match(new RegExp(prevReleaseHeaderPattern)) - if (!matches || matches.length === 0) { - console.log(`Unable to find header using pattern: ${prevReleaseHeaderPattern}. Skipping.`) - continue - } - const prevReleaseHeader = matches[0] - let content = `${releaseHeader}` - if (guide) { - content = `${content}\n\n${guide}` - } - content = content + notesHeader + `\n\n${prevReleaseHeader}` - updateContents = updateContents.replace(prevReleaseHeader, content) - } - writeFileSync(fullPath, updateContents) - } - } -} - -// This is a copy of updateUpgradeGuides designed to target mdx files instead of md files and also search files in the docs/ rather than doc/ directory -export const updateDocsUpgradeGuides = (previous: string, next: string): EditFunc => { - let updateDirectory = '/docs/admin/updates' - const notPatchRelease = next.endsWith('.0') - - return (directory: string): void => { - updateDirectory = directory + updateDirectory - for (const file of readdirSync(updateDirectory)) { - if (file === 'index.mdx') { - continue - } - const mode = file.replace('.mdx', '') - const updateFunc = getUpgradeGuide(mode) - if (updateFunc === undefined) { - console.log(`Skipping upgrade file: ${file} due to missing content generator`) - continue - } - const guide = getUpgradeGuide(mode)(previous, next) - - const fullPath = path.join(updateDirectory, file) - console.log(`Updating upgrade guide: ${fullPath}`) - let updateContents = readFileSync(fullPath).toString() - const releaseHeader = `## v${previous} ➔ v${next}` - const notesHeader = '\n\n#### Notes:' - - if (notPatchRelease) { - let content = `${update.releaseTemplate}\n\n${releaseHeader}` - if (guide) { - content = `${content}\n\n${guide}` - } - content = content + notesHeader - updateContents = updateContents.replace(update.releaseTemplate, content) - } else { - const prevReleaseHeaderPattern = `##\\s+v\\d\\.\\d(?:\\.\\d)? ➔ v${previous}\\s*` - const matches = updateContents.match(new RegExp(prevReleaseHeaderPattern)) - if (!matches || matches.length === 0) { - console.log(`Unable to find header using pattern: ${prevReleaseHeaderPattern}. Skipping.`) - continue - } - const prevReleaseHeader = matches[0] - let content = `${releaseHeader}` - if (guide) { - content = `${content}\n\n${guide}` - } - content = content + notesHeader + `\n\n${prevReleaseHeader}` - updateContents = updateContents.replace(prevReleaseHeader, content) - } - writeFileSync(fullPath, updateContents) - } - } -} - -export async function retryInput( - prompt: string, - delegate: (val: string) => boolean, - errorMessage?: string -): Promise { - while (true) { - const val = await readLine(prompt).then(value => value) - if (delegate(val)) { - return val - } - if (errorMessage) { - console.log(chalk.red(errorMessage)) - } else { - console.log(chalk.red('invalid input')) - } - } -} - -const blockingQuery = 'is:open org:sourcegraph label:release-blocker' - -export async function getReleaseBlockers( - octokit: Octokit -): Promise { - return listIssues(octokit, blockingQuery) -} - -export function backportIssueQuery(version: SemVer): string { - return `is:open is:pr repo:sourcegraph org:sourcegraph label:"backported-to-${version.major}.${version.minor}"` -} - -export async function getBackportsForVersion( - octokit: Octokit, - version: SemVer -): Promise { - return listIssues(octokit, backportIssueQuery(version)) -} - -export function releaseBlockerUri(): string { - return issuesQueryUri(blockingQuery) -} - -function issuesQueryUri(query: string): string { - return `https://github.com/issues?q=${encodeURIComponent(query)}` -} - -export async function validateNoOpenBackports(octokit: Octokit, version: SemVer): Promise { - const backports = await getBackportsForVersion(octokit, version) - if (backports.length > 0) { - await verifyWithInput(`${backportWarning(backports.length, version)})\nConfirm to proceed`) - } else { - console.log('No backports found!') - } -} - -export async function backportStatus(octokit: Octokit, version: SemVer): Promise { - const backports = await getBackportsForVersion(octokit, version) - return backportWarning(backports.length, version) -} - -export function backportWarning(numBackports: number, version: SemVer): string { - return `Warning! There are ${chalk.red(numBackports)} backport pull requests open!\n${issuesQueryUri( - backportIssueQuery(version) - )}` -} - -export async function validateNoReleaseBlockers(octokit: Octokit): Promise { - const blockers = await getReleaseBlockers(octokit) - if (blockers.length > 0) { - await verifyWithInput( - `Warning! There are ${chalk.red( - blockers.length - )} release blocking issues open!\n${releaseBlockerUri()}\nConfirm to proceed` - ) - } -} - -export async function nextSrcCliVersionInputWithAutodetect(config: ReleaseConfig, repoPath?: string): Promise { - let next: SemVer - if (!config.in_progress?.srcCliVersion) { - if (!repoPath) { - const client = await getAuthenticatedGitHubClient() - const { workdir } = await cloneRepo(client, 'sourcegraph', 'src-cli', { - revision: 'main', - revisionMustExist: true, - }) - repoPath = workdir - } - console.log('Attempting to detect previous src-cli version...') - const previous = getPreviousVersionSrcCli(repoPath) - console.log(chalk.blue(`Detected previous src-cli version: ${previous.version}`)) - next = previous.inc('minor') - } else { - next = new SemVer(config.in_progress.srcCliVersion) - } - - if (!(await softVerifyWithInput(`Confirm next version of src-cli should be: ${next.version}`))) { - return new SemVer( - await retryInput( - 'Enter the next version of src-cli: ', - val => !!semver.parse(val), - 'Expected semver format' - ) - ) - } - return next -} - -export async function nextGoogleExecutorVersionInputWithAutodetect( - config: ReleaseConfig, - repoPath?: string -): Promise { - let next: SemVer - if (!config.in_progress?.googleExecutorVersion) { - if (!repoPath) { - const client = await getAuthenticatedGitHubClient() - const { workdir } = await cloneRepo(client, 'sourcegraph', 'terraform-google-executors', { - revision: 'main', - revisionMustExist: true, - }) - repoPath = workdir - } - console.log('Attempting to detect previous executor version...') - const previous = getPreviousVersionExecutor(repoPath) - console.log(chalk.blue(`Detected previous executor version: ${previous.version}`)) - next = previous.inc('minor') - } else { - next = new SemVer(config.in_progress.googleExecutorVersion) - } - - if ( - !(await softVerifyWithInput( - `Confirm next version of sourcegraph/terraform-google-executors should be: ${next.version}` - )) - ) { - return new SemVer( - await retryInput( - 'Enter the next version of executor: ', - val => !!semver.parse(val), - 'Expected semver format' - ) - ) - } - return next -} - -export async function nextAWSExecutorVersionInputWithAutodetect( - config: ReleaseConfig, - repoPath?: string -): Promise { - let next: SemVer - if (!config.in_progress?.awsExecutorVersion) { - if (!repoPath) { - const client = await getAuthenticatedGitHubClient() - const { workdir } = await cloneRepo(client, 'sourcegraph', 'terraform-aws-executors', { - revision: 'main', - revisionMustExist: true, - }) - repoPath = workdir - } - console.log('Attempting to detect previous executor version...') - const previous = getPreviousVersionExecutor(repoPath) - console.log(chalk.blue(`Detected previous sourcegraph/terraform-aws-executors version: ${previous.version}`)) - next = previous.inc('minor') - } else { - next = new SemVer(config.in_progress.awsExecutorVersion) - } - - if (!(await softVerifyWithInput(`Confirm next version of executor should be: ${next.version}`))) { - return new SemVer( - await retryInput( - 'Enter the next version of executor: ', - val => !!semver.parse(val), - 'Expected semver format' - ) - ) - } - return next -} - -export function pullRequestBody(content: string): string { - const header = 'This pull request was automatically generated by the release-tool.\n' - const testPlan = '\n## Test Plan:\nN/A' - return `${header}${content}${testPlan}` -} diff --git a/dev/release/templates/patch_release_issue_template.md b/dev/release/templates/patch_release_issue_template.md deleted file mode 100644 index edf5e4a6542..00000000000 --- a/dev/release/templates/patch_release_issue_template.md +++ /dev/null @@ -1,115 +0,0 @@ - - -# $MAJOR.$MINOR.$PATCH patch release - -This release is scheduled for **$RELEASE_DATE**. - -> [!WARNING] -> To get your commits in `main` included in this patch release, add the `backport-$MAJOR.$MINOR` to the PR to `main`. - -## Setup - - - -- [ ] Ensure you have the latest version of the release tooling and configuration by checking out and updating `sourcegraph@main`. -- [ ] Ensure release configuration in [`dev/release/release-config.jsonc`](https://sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/dev/release/release-config.jsonc) on `main` has version $MAJOR.$MINOR.$PATCH selected by using the command: - -```shell -pnpm run release release:activate-release -``` - -- [ ] Create the release tracking issue - -```shell -pnpm release tracking:issues -``` - -## Prepare release - -- [ ] Ensure that all [backported PRs](https://github.com/sourcegraph/sourcegraph/pulls?q=is%3Apr+is%3Aopen+base%3A$MAJOR.$MINOR) have been merged. - -Create and test the first release candidate: - -> [!NOTE] -> Ensure that you've pulled both main and release branches before running this command. - -- [ ] Push a new release candidate tag. This command will automatically detect the appropriate release candidate number. This command can be executed as many times as required, and will increment the release candidate number for each subsequent build: : - - ```sh - pnpm run release release:create-candidate - ``` - -- [ ] Ensure that the following Buildkite pipelines all pass for the `v$MAJOR.$MINOR.$PATCH-rc.1` tag: - - [ ] [Sourcegraph pipeline](https://buildkite.com/sourcegraph/sourcegraph/builds?branch=v$MAJOR.$MINOR.$PATCH-rc.1) -- [ ] File any failures and regressions in the pipelines as `release-blocker` issues and assign the appropriate teams. - -> [!NOTE] -> You will need to re-check the above pipelines for any subsequent release candidates. You can see the Buildkite logs by tweaking the "branch" query parameter in the URLs to point to the desired release candidate. In general, the URL scheme looks like the following (replacing `N` in the URL): `https://buildkite.com/sourcegraph/sourcegraph/builds?branch=v$MAJOR.$MINOR.$PATCH-rc.N` - -Once there is a release candidate available: - -- [ ] Create a [Security release approval](https://github.com/sourcegraph/sourcegraph/issues/new?assignees=andreeleuterio%2C+evict%2C+willdollman%2C+mohammadualam&labels=release-blocker&projects=&template=security-release-approval.md&title=$MAJOR.$MINOR.$PATCH+Security+approval) issue and post a message in the [#discuss-security](https://sourcegraph.slack.com/archives/C1JH2BEHZ) channel tagging `@security-support`. - -## Stage release - - - -- [ ] Verify the **$MAJOR.$MINOR.$PATCH** section of [CHANGELOG](https://github.com/sourcegraph/sourcegraph/blob/main/CHANGELOG.md) on the `main` is accurate. -- [ ] Ensure security has approved the [Security release approval](https://github.com/sourcegraph/sourcegraph/issues?q=label%3Arelease-blocker+Security+approval+is%3Aopen) issue you created. -- [ ] Promote a release candidate to the final release build. You will need to provide the tag of the release candidate which you would like to promote as an argument. To get a list of available release candidates, you can use: - ```shell - pnpm run release release:check-candidate - ``` - To promote the candidate, use the command: - ```sh - pnpm run release release:promote-candidate - ``` -- [ ] Ensure that the pipeline for the `v$MAJOR.$MINOR.$PATCH` tag has passed: [Sourcegraph pipeline](https://buildkite.com/sourcegraph/sourcegraph/builds?branch=v$MAJOR.$MINOR.$PATCH) -- [ ] Wait for the `$MAJOR.$MINOR.$PATCH` release Docker images to be available in [Docker Hub](https://hub.docker.com/r/sourcegraph/server/tags) -- [ ] Open PRs that publish the new release and address any action items required to finalize draft PRs (track PR status via the [generated release batch change](https://sourcegraph.sourcegraph.com/organizations/sourcegraph/batch-changes/release-sourcegraph-$MAJOR.$MINOR.$PATCH)): - ```sh - pnpm run release release:stage - ``` - -## Finalize release - - - -- [ ] From the [release batch change](https://sourcegraph.sourcegraph.com/organizations/sourcegraph/batch-changes/release-sourcegraph-$MAJOR.$MINOR.$PATCH), merge the release-publishing PRs created previously. Note: some PRs require certain actions performed before merging. -- [ ] **After all the PRs are merged**, perform following checks/actions - - For [deploy-sourcegraph](https://github.com/sourcegraph/deploy-sourcegraph) - - [ ] Ensure the [release tag](https://github.com/sourcegraph/deploy-sourcegraph/tags) has been created - - For [deploy-sourcegraph-docker](https://github.com/sourcegraph/deploy-sourcegraph-docker) - - [ ] Ensure the [release tag](https://github.com/sourcegraph/deploy-sourcegraph-docker/tags) has been created - - For [deploy-sourcegraph-helm](https://github.com/sourcegraph/deploy-sourcegraph-helm), also: - - [ ] Update the [changelog](https://github.com/sourcegraph/deploy-sourcegraph-helm/blob/main/charts/sourcegraph/CHANGELOG.md) to include changes from the patch - - [ ] Cherry-pick the release-publishing PR from the release branch into `main` -- [ ] Announce that the release is live: - ```sh - pnpm run release release:announce - ``` - -## Post-release - -- [ ] Close the release: - -```shell - pnpm run release release:close -``` - -- [ ] Open a PR to update [`dev/release/release-config.jsonc`](https://github.com/sourcegraph/sourcegraph/edit/main/dev/release/release-config.jsonc) after the auto-generated changes above if any. -- [ ] Update the [CHANGELOG](https://github.com/sourcegraph/sourcegraph/blob/main/CHANGELOG.md) by opening and merging a PR into `main` (**not** the release branch), making the following changes: - - [ ] Move the released changes into the $MAJOR.$MINOR.$PATCH version section - - [ ] Add a new section for the [upcoming patch release](https://handbook.sourcegraph.com/departments/engineering/dev/process/releases/#current-patch-schedule) if this is not the last planned patch release for version $MAJOR.$MINOR - -> [!NOTE] -> If another patch release is requested after the release, ask that a [patch request issue](https://github.com/sourcegraph/sourcegraph/issues/new?assignees=&labels=team%2Fdistribution&template=request_patch_release.md) be filled out and approved first. diff --git a/dev/release/templates/release_issue_template.md b/dev/release/templates/release_issue_template.md deleted file mode 100644 index 4e7be837211..00000000000 --- a/dev/release/templates/release_issue_template.md +++ /dev/null @@ -1,179 +0,0 @@ - - -# $MAJOR.$MINOR release - -This release is scheduled for **$RELEASE_DATE**. - ---- - -## Setup - - - -- [ ] Ensure you have the latest version of the release tooling and configuration by checking out and updating `sourcegraph@main`. -- [ ] Ensure release configuration in [`dev/release/release-config.jsonc`](https://sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/dev/release/release-config.jsonc) on `main` has version $MAJOR.$MINOR.$PATCH selected by using the command: - -```shell -pnpm run release release:activate-release -``` - -## Security review ($SECURITY_REVIEW_DATE) - -- [ ] Create a [Security release approval](https://github.com/sourcegraph/sourcegraph/issues/new/choose#:~:text=Security%20release%20approval) issue and post a message in the [#discuss-security](https://sourcegraph.slack.com/archives/C1JH2BEHZ) channel tagging `@security-support`. - -## Cut release ($CODE_FREEZE_DATE) - -Perform these steps three days before the release date to generate a stable release candidate. - -### Prepare release - -- [ ] Post a release status update to Slack - [review all release-blocking issues](https://github.com/sourcegraph/sourcegraph/issues?q=is%3Aopen+is%3Aissue+label%3Arelease-blocker), and ensure someone is resolving each. - - ```sh - pnpm run release release:status - ``` - -Do the [branch cut](https://handbook.sourcegraph.com/departments/engineering/dev/process/releases/#release-branches) for the release: - -- [ ] Update the changelog and create pull requests: - - ```sh - pnpm run release changelog:cut - ``` - -- [ ] Manually review the pull requests created in the previous step and merge. -- [ ] Wait for CI of the commit on `main` to pass. -- [ ] Request Admin permissions of `sourcegraph/sourcegraph` repository through [Entitle](https://app.entitle.io/request?targetType=resource&duration=1800&justification=Temporarily%20disable%20the%20%22Require%20linear%20history%22%20rule%20for%20release%20branches%20to%20create%20a%20new%20release%20branch.&integrationId=032680b6-f13d-42aa-9837-38097b45f0fe&resourceId=cd16ad0f-0e7e-4f20-8a8c-b3c57751dafd&roleId=5151f2f3-40a3-4697-99a2-b5e756e43f5b&grantMethodId=5151f2f3-40a3-4697-99a2-b5e756e43f5b) in order to disable the [**Require linear history** protection rule for release branches](https://github.com/sourcegraph/sourcegraph/settings/branch_protection_rules/34536616#:~:text=Require%20linear%20history). - -- [ ] Create the `$MAJOR.$MINOR` branch off the CHANGELOG commit in the previous step: - - ```sh - pnpm run release release:branch-cut - ``` - -- [ ] Re-enable the [**Require linear history** protection rule for release branches](https://github.com/sourcegraph/sourcegraph/settings/branch_protection_rules/34536616#:~:text=Require%20linear%20history). - -- [ ] Push a new release candidate tag. This command will automatically detect the appropriate release candidate number. This command can be executed as many times as required, and will increment the release candidate number for each subsequent build: : - - ```sh - pnpm run release release:create-candidate - ``` - -- [ ] Ensure that the following Buildkite pipelines all pass for the `v$MAJOR.$MINOR.$PATCH-rc.N` tag: -- [ ] [Sourcegraph pipeline](https://buildkite.com/sourcegraph/sourcegraph/builds?branch=v$MAJOR.$MINOR.$PATCH-rc.1) -- [ ] Cross check all reported CVEs are in the accepted list (`https://handbook.sourcegraph.com/departments/security/tooling/trivy/$MAJOR-$MINOR-$PATCH`). You can use the utility command `sg release cve-check` to help with this step. Otherwise, alert `@security-support` in the [#release-guild](https://sourcegraph.slack.com/archives/C032Z79NZQC) channel ASAP. -- [ ] File any failures and regressions in the pipelines as `release-blocker` issues and assign the appropriate teams. - -Revert or disable features that may cause delays. As necessary, `git cherry-pick` bugfix (not feature!) commits from `main` into the release branch. Continue to create new release candidates as necessary, until no more `release-blocker` issues remain. - -- [ ] Update the [target branch of the RC test instance](https://github.com/sourcegraph/cloud/blob/main/.github/workflows/mi_upgrade_rctest.yml#L51) to the new release branch `$MAJOR.$MINOR`. -- [ ] Trigger a [manual run of the GitHub Action](https://github.com/sourcegraph/cloud/actions/workflows/mi_upgrade_rctest.yml) to upgrade the RC test instance. It should complete without an error, otherwise there might be a database migration problem that warrants a `release-blocker` issue. - -> [!important] -> You will need to re-check the above pipelines for any subsequent release candidates. You can see the Buildkite logs by tweaking the "branch" query parameter in the URLs to point to the desired release candidate. In general, the URL scheme looks like the following (replacing `N` in the URL): - -- Sourcegraph: `https://buildkite.com/sourcegraph/sourcegraph/builds?branch=v$MAJOR.$MINOR.$PATCH-rc.N` - -- [ ] Post a release status update to Slack: - - ```sh - pnpm run release release:status - ``` - -## Code Freeze - -Create candidates as necessary - -```shell -pnpm run release release:create-candidate -``` - -Monitor the release branch, and backports. Ensure the branch remains healthy. - -## Release day ($RELEASE_DATE) - -### Stage release - - - -On the day of the release, confirm there are no more release-blocking issues (as reported by the `release:status` command), then proceed with creating the final release: - -- [ ] Bake constants and other static values into the release branch (and also update main) This requires the release branch exists (should be automated above). - ```shell - pnpm run release release:bake-content - ``` -- [ ] Merge the resulting pull requests for the content bake generated by the command above -- [ ] Release a new version of src-cli, terraform-google-executors, aws-executors - ```shell - pnpm run release release:create-tags - ``` -- [ ] Ensure the latest version of src-cli is available in all sources. You may need to run this command a few times in the background. - ```shell - pnpm run release release:verify-releases - ``` -- [ ] Make another release candidate with the baked content -- [ ] Make sure [CHANGELOG entries](https://github.com/sourcegraph/sourcegraph/blob/main/CHANGELOG.md) have been moved from **Unreleased** to **$MAJOR.$MINOR.$PATCH**, but exluding the ones that merged to `main` after the branch cut (whose changes are not in the `$MAJOR.$MINOR` branch). -- [ ] Ensure security has approved the [Security release approval](https://github.com/sourcegraph/sourcegraph/issues?q=label%3Arelease-blocker+Security+approval+is%3Aopen) issue you created. -- [ ] Make sure [deploy-sourcegraph-helm CHANGELOG entries](https://github.com/sourcegraph/deploy-sourcegraph-helm/blob/main/charts/sourcegraph/CHANGELOG.md) have been moved from **Unreleased** to **$MAJOR.$MINOR.$PATCH**, but exluding the ones that merged to `main` after the branch cut (whose changes are not in the `$MAJOR.$MINOR` branch). -- [ ] Promote a release candidate to the final release build. You will need to provide the tag of the release candidate which you would like to promote as an argument. To get a list of available release candidates, you can use: - - ```shell - pnpm run release release:check-candidate - ``` - - To promote the candidate, use the command: - - ```sh - pnpm run release release:promote-candidate - ``` - -- [ ] Ensure that the following pipelines all pass for the `v$MAJOR.$MINOR.$PATCH` tag: - - [ ] [Sourcegraph pipeline](https://buildkite.com/sourcegraph/sourcegraph/builds?branch=v$MAJOR.$MINOR.$PATCH) -- [ ] Wait for the `v$MAJOR.$MINOR.$PATCH` release Docker images to be available in [Docker Hub](https://hub.docker.com/r/sourcegraph/server/tags) -- [ ] Open PRs that publish the new release and address any action items required to finalize draft PRs (track PR status via the [generated release batch change](https://sourcegraph.sourcegraph.com/organizations/sourcegraph/batch-changes)): - - ```sh - pnpm run release release:stage - ``` - -### Finalize release - -- [ ] From the [release batch change](https://sourcegraph.sourcegraph.com/organizations/sourcegraph/batch-changes), merge the release-publishing PRs created previously. - - For [deploy-sourcegraph](https://github.com/sourcegraph/deploy-sourcegraph) - - [ ] Ensure the [release tag `v$MAJOR.$MINOR.$PATCH`](https://github.com/sourcegraph/deploy-sourcegraph/tags) has been created - - For [deploy-sourcegraph-docker](https://github.com/sourcegraph/deploy-sourcegraph-docker) - - [ ] Ensure the [release tag `v$MAJOR.$MINOR.$PATCH`](https://github.com/sourcegraph/deploy-sourcegraph-docker/tags) has been created - - For [deploy-sourcegraph-helm](https://github.com/sourcegraph/deploy-sourcegraph-helm) - - [ ] Cherry pick the release-publishing PR from the release branch into main -- [ ] Alert the marketing team in [#release-post](https://sourcegraph.slack.com/archives/C022Y5VUSBU) that they can merge the release post. -- [ ] Announce that the release is live: - ```sh - pnpm run release release:announce - ``` -- [ ] Disable the `release-protector` github action in sourcegraph/sourcegraph. This may require you to request admin permissions using Entitle. - -### Post-release - -- [ ] Create release calendar events, tracking issue, and announcement for next release (note: these commands will prompt for user input to generate the definition for the next release): - ```sh - pnpm run release release:prepare - pnpm run release tracking:issues - pnpm run release tracking:timeline - ``` -- [ ] Close the release. - ```sh - pnpm run release release:close - ``` -- [ ] Open a PR to update [`dev/release/release-config.jsonc`](https://sourcegraph.com/github.com/sourcegraph/sourcegraph/-/blob/dev/release/release-config.jsonc) with the auto-generated changes from above. - -**Note:** If a patch release is requested after the release, ask that a [patch request issue](https://github.com/sourcegraph/sourcegraph/issues/new?assignees=&labels=team%2Fdistribution&template=request_patch_release.md&title=$MAJOR.$MINOR.1%3A+) be filled out and approved first. diff --git a/dev/release/templates/security_assessment.md b/dev/release/templates/security_assessment.md deleted file mode 100644 index 5d639b25e98..00000000000 --- a/dev/release/templates/security_assessment.md +++ /dev/null @@ -1,24 +0,0 @@ - - -# $MAJOR.$MINOR.$PATCH Security Review: Container Images - -This is a Release Blocker issue to nudge the Release Captain to check -with the Security Team whether there are any security patches that must be -applied to Sourcegraph Container Images before the release is published. - -See [RFC 678](https://docs.google.com/document/d/1v0TXVLPCNA42cQwIYeGLICoy6zfK9KprMRpMa2Fn6IE/edit#) for more information. - -The Security Team should use **this** issue to communicate and coordinate -actions to meet our obligations under the [Vulerability Management Process](../../../../security/vulnerability-management-process.md). - -After all items are resolved, this issue can be closed an the release should be -considered unblocked by the Security Assessment. diff --git a/dev/release/testdata/test-batch-change-import.json b/dev/release/testdata/test-batch-change-import.json deleted file mode 100644 index 63cc4cf8eed..00000000000 --- a/dev/release/testdata/test-batch-change-import.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "release-sourcegraph-3.30.3", - "description": "Track publishing of sourcegraph@3.30.3", - "changes": [ - { - "repository": "sourcegraph/sourcegraph", - "pullRequestNumber": 23337 - }, - { - "repository": "sourcegraph/about", - "pullRequestNumber": 3942 - }, - { - "repository": "sourcegraph/deploy-sourcegraph", - "pullRequestNumber": 3524 - }, - { - "repository": "sourcegraph/deploy-sourcegraph-docker", - "pullRequestNumber": 482 - } - ] -} diff --git a/dev/release/tsconfig.json b/dev/release/tsconfig.json deleted file mode 100644 index a926b56d5ee..00000000000 --- a/dev/release/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["esnext"], - "module": "commonjs", - "rootDir": "src", - "outDir": "out", - }, - "include": ["./src/**/*", "./src/**/*.json"], - "exclude": ["./node_modules", "../../node_modules", "out"], -} diff --git a/package.json b/package.json index 981a3048e85..778dfc1607e 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "storybook:web": "pnpm --filter @sourcegraph/web run storybook", "storybook:wildcard": "pnpm --filter @sourcegraph/wildcard run storybook", "storybook:build": "pnpm --filter @sourcegraph/storybook run build", - "release": "cd dev/release && pnpm run release", "docsite:serve": "./dev/docsite.sh -config doc/docsite.json serve -http=localhost:5080", "build-browser-extension": "pnpm --filter @sourcegraph/browser run build", "chromatic": "CHROMATIC=true pnpm run _chromatic --storybook-config-dir client/storybook/src --build-script-name=storybook:build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15e36acd38e..5849325e9cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1695,15 +1695,6 @@ importers: specifier: workspace:* version: link:../testing - dev/release: - dependencies: - '@types/luxon': - specifier: ^3.2.0 - version: 3.2.0 - luxon: - specifier: ^3.2.1 - version: 3.2.1 - schema: {} packages: @@ -11075,10 +11066,6 @@ packages: resolution: {integrity: sha512-69x+Dhrm2aShFkTqUuPgUXbKYwvq4FH/DVeeQH7MBfTjbKjPX51NGLERxVV1vf33N71dzLvXCko4OLqRvsq53Q==} dev: true - /@types/luxon@3.2.0: - resolution: {integrity: sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==} - dev: false - /@types/marked@4.0.3: resolution: {integrity: sha512-HnMWQkLJEf/PnxZIfbm0yGJRRZYYMhb++O9M36UCTA9z53uPvVoSlAwJr3XOpDEryb7Hwl1qAx/MV6YIW1RXxg==} dev: true @@ -19396,11 +19383,6 @@ packages: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} dev: true - /luxon@3.2.1: - resolution: {integrity: sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==} - engines: {node: '>=12'} - dev: false - /lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a466215717a..733521a1e6c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,3 @@ packages: - 'client/*' - 'schema' - - 'dev/release' diff --git a/tsconfig.json b/tsconfig.json index 210afe28d35..446345d419a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,6 @@ { "path": "client/extension-api-types" }, { "path": "client/storybook" }, { "path": "client/testing" }, - { "path": "dev/release" }, { "path": "schema" }, { "path": "client/codeintellify" }, { "path": "client/client-api" },