diff --git a/azure-pipelines/release.yml b/azure-pipelines/release.yml index e915c5e8a9..a3b2a37b24 100644 --- a/azure-pipelines/release.yml +++ b/azure-pipelines/release.yml @@ -112,8 +112,10 @@ extends: If ( $uploadPrerelease ) { $basePublishArgs += "--pre-release" Write-Host "Publish to pre-release channel." + Write-Host "##vso[task.setvariable variable=isPrerelease]true" } Else { Write-Host "Publish to release channel." + Write-Host "##vso[task.setvariable variable=isPrerelease]false" } $basePublishArgs += '--azure-credential' $basePublishArgs += '--packagePath' @@ -167,3 +169,9 @@ extends: env: GitHubPAT: $(BotAccount-dotnet-bot-content-rw-grained-pat) displayName: 'Create release tags' + + - pwsh: | + gulp createRelease:vscode-csharp --githubPAT $(BotAccount-dotnet-bot-content-rw-grained-pat) --prerelease $(isPrerelease) --dryRun ${{ parameters.test }} --releaseVersion $(resources.pipeline.officialBuildCI.runName) + env: + GitHubPAT: $(BotAccount-dotnet-bot-content-rw-grained-pat) + displayName: 'Create GitHub Release' diff --git a/tasks/createTagsTasks.ts b/tasks/createTagsTasks.ts index 62c7837d33..1c9c13a086 100644 --- a/tasks/createTagsTasks.ts +++ b/tasks/createTagsTasks.ts @@ -17,6 +17,7 @@ interface Options { // Even it is specified as boolean, it would still be parsed as string in compiled js. dryRun: string; githubPAT: string | null; + prerelease: string | null; } gulp.task('createTags:roslyn', async (): Promise => { @@ -57,12 +58,83 @@ gulp.task('createTags:vscode-csharp', async (): Promise => { 'dotnet', 'vscode-csharp', async () => options.releaseCommit, - (releaseVersion: string): [string, string] => [`v${releaseVersion}`, releaseVersion] + (releaseVersion: string): [string, string] => [getVscodeCsharpTagName(releaseVersion), releaseVersion] ); }); gulp.task('createTags', gulp.series('createTags:roslyn', 'createTags:razor', 'createTags:vscode-csharp')); +gulp.task('createRelease:vscode-csharp', async (): Promise => { + const options = minimist(process.argv.slice(2)); + const dryRun = getFlag('dryRun', options); + const prerelease = getFlag('prerelease', options); + const pat = getGitHubPAT(options); + const releaseVersion = options.releaseVersion; + if (!releaseVersion) { + logError('Missing required argument: --releaseVersion'); + return; + } + const tag = getVscodeCsharpTagName(releaseVersion); + await createReleaseFromChangelog(pat, dryRun, prerelease, tag); +}); + +async function createReleaseFromChangelog( + pat: string, + dryRun: boolean, + prerelease: boolean, + tag: string +): Promise { + // Read CHANGELOG.md and extract the latest section (first single '#' header) + const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); + const headerMatch = changelog.match(/^# .+$/m); + if (!headerMatch) { + logError('Could not find a top-level # header in CHANGELOG.md'); + return; + } + const startIdx = changelog.indexOf(headerMatch[0]); + let endIdx = changelog.indexOf('\n# ', startIdx + headerMatch[0].length); + if (endIdx === -1) { + endIdx = changelog.length; + } + // Include the heading in the release notes + const releaseNotes = changelog.substring(startIdx, endIdx).trim(); + + console.log(`Creating GitHub release for tag: ${tag}`); + console.log(`Prerelease: ${prerelease}`); + console.log(`Dry run: ${dryRun}`); + + const octokit = new Octokit({ auth: pat }); + await octokit.auth(); + + if (dryRun) { + console.log(`[DryRun] Would create release for tag ${tag} with prerelease=${prerelease}`); + console.log(`[DryRun] Release notes:\n${releaseNotes}`); + return; + } + + try { + const response = await octokit.repos.createRelease({ + owner: 'dotnet', + repo: 'vscode-csharp', + tag_name: tag, + name: tag, + body: releaseNotes, + prerelease: prerelease, + }); + if (response.status === 201) { + console.log(`Release created: ${response.data.html_url}`); + } else { + logError(`Failed to create release. Status: ${response.status}`); + } + } catch (err: any) { + if (err.status === 422 && err.message && err.message.includes('already_exists')) { + logWarning(`Release for tag '${tag}' already exists in dotnet/vscode-csharp.`); + } else { + logError('Error creating release: ' + err); + } + } +} + async function createTagsAsync( options: Options, owner: string, @@ -72,7 +144,7 @@ async function createTagsAsync( ): Promise { console.log(`releaseVersion: ${options.releaseVersion}`); console.log(`releaseCommit: ${options.releaseCommit}`); - const dryRun = options.dryRun ? options.dryRun.toLocaleLowerCase() === 'true' : false; + const dryRun = getFlag('dryRun', options); console.log(`dry run: ${dryRun}`); const commit = await getCommit(); @@ -90,7 +162,8 @@ async function createTagsAsync( console.log('Tagging is skipped in dry run mode.'); return; } else { - const tagCreated = await tagRepoAsync(owner, repo, commit, tag, message, options.githubPAT); + const githubPAT = getGitHubPAT(options); + const tagCreated = await tagRepoAsync(owner, repo, commit, tag, message, githubPAT); if (!tagCreated) { logError(`Failed to tag '${owner}/${repo}'`); @@ -107,15 +180,10 @@ async function tagRepoAsync( commit: string, releaseTag: string, tagMessage: string, - githubPAT: string | null + githubPAT: string ): Promise { - const pat = githubPAT ?? process.env['GitHubPAT']; - if (!pat) { - throw 'No GitHub Pat found. Specify with --githubPAT or set GitHubPAT environment variable.'; - } - console.log(`Start to tag ${owner}/${repo}. Commit: ${commit}, tag: ${releaseTag}, message: ${tagMessage}`); - const octokit = new Octokit({ auth: pat }); + const octokit = new Octokit({ auth: githubPAT }); await octokit.auth(); const createTagResponse = await octokit.request(`POST /repos/${owner}/${repo}/git/tags`, { owner: owner, @@ -130,25 +198,66 @@ async function tagRepoAsync( logError(`Failed to create tag for ${commit} in ${owner}/${repo}.`); return false; } - const refCreationResponse = await octokit.request(`Post /repos/${owner}/${repo}/git/refs`, { - owner: owner, - repo: repo, - ref: `refs/tags/${releaseTag}`, - sha: commit, - }); + try { + const refCreationResponse = await octokit.request(`Post /repos/${owner}/${repo}/git/refs`, { + owner: owner, + repo: repo, + ref: `refs/tags/${releaseTag}`, + sha: commit, + }); - if (refCreationResponse.status !== 201) { - logError(`Failed to create reference for ${commit} in ${owner}/${repo}.`); - return false; + if (refCreationResponse.status !== 201) { + logError(`Failed to create reference for ${commit} in ${owner}/${repo}.`); + return false; + } + } catch (err: any) { + if (err.status === 422 && err.message && err.message.includes('Reference already exists')) { + logWarning(`Reference for tag '${releaseTag}' already exists in ${owner}/${repo}.`); + return true; + } else { + logError(`Failed to create reference for ${commit} in ${owner}/${repo}: ${err}`); + return false; + } } console.log(`Tag is created.`); return true; } +// --- Helper functions --- + +function getGitHubPAT(options: { githubPAT?: string | null }): string { + const pat = options.githubPAT ?? process.env['GitHubPAT']; + if (!pat) { + throw 'No GitHub Pat found. Specify with --githubPAT or set GitHubPAT environment variable.'; + } + return pat; +} + +function getFlag(option: keyof Options, options: Options): boolean { + const value = options[option]; + if (!value) { + logError(`Missing required argument: --${option}`); + } + if (typeof value === 'string') { + return value.toLocaleLowerCase() === 'true'; + } else { + throw new Error(`Expected boolean value for --${option}, but got ${typeof value}`); + } +} + +function logWarning(message: string): void { + console.log(`##vso[task.logissue type=warning]${message}`); +} + function logError(message: string): void { console.log(`##vso[task.logissue type=error]${message}`); } + +function getVscodeCsharpTagName(releaseVersion: string): string { + return `v${releaseVersion}`; +} + async function getCommitFromNugetAsync(packageInfo: NugetPackageInfo): Promise { const packageJsonString = fs.readFileSync('./package.json').toString(); const packageJson = JSON.parse(packageJsonString);