Skip to content

Commit 1f97df8

Browse files
authored
Add task for automatically creating the github release (#8410)
2 parents b5e8784 + 423cda4 commit 1f97df8

File tree

4 files changed

+179
-36
lines changed

4 files changed

+179
-36
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Create GitHub Release
2+
on:
3+
push:
4+
tags:
5+
- 'v2.*'
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
# This job runs against the yaml defined in the tag we were triggered on.
12+
# So we can modify the release creation without having to wait for the change to flow into the commit
13+
# that we're tagging, we call a reusable workflow from main
14+
create-release:
15+
uses: dotnet/vscode-csharp/.github/workflows/release-reusable.yml@main
16+
with:
17+
tag: ${{ github.ref_name }}
18+
secrets: inherit
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Reusable - Create Release from Changelog
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
tag:
7+
description: Tag name to create a release for (e.g. v2.88.0 or v2.88.0-prerelease)
8+
required: true
9+
type: string
10+
11+
permissions:
12+
contents: write
13+
14+
jobs:
15+
create-release:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout tag
19+
uses: actions/checkout@v4
20+
with:
21+
ref: refs/tags/${{ inputs.tag }}
22+
23+
- name: Create release from CHANGELOG
24+
uses: actions/github-script@v7
25+
env:
26+
TAG: ${{ inputs.tag }}
27+
with:
28+
github-token: ${{ secrets.GITHUB_TOKEN }}
29+
script: |
30+
const { readFile } = require('fs/promises');
31+
32+
const tag = process.env.TAG;
33+
core.info(`Creating release for tag: ${tag}`);
34+
35+
if (!tag || !tag.startsWith('v2.')) {
36+
core.setFailed('Invalid tag name. Tag name must start with "v2."');
37+
return;
38+
}
39+
40+
// Read CHANGELOG.md and extract the latest section (first single '#' header)
41+
const changelog = await readFile('CHANGELOG.md', 'utf8');
42+
const headerMatch = changelog.match(/^# .+$/m);
43+
if (!headerMatch) {
44+
core.setFailed('Could not find a top-level # header in CHANGELOG.md');
45+
return;
46+
}
47+
const startIdx = changelog.indexOf(headerMatch[0]);
48+
let endIdx = changelog.indexOf('\n# ', startIdx + headerMatch[0].length);
49+
if (endIdx === -1) {
50+
endIdx = changelog.length;
51+
}
52+
const releaseNotes = changelog.substring(startIdx, endIdx).trim();
53+
54+
const isPrerelease = tag.includes('-prerelease');
55+
core.info(`Prerelease: ${isPrerelease}`);
56+
57+
try {
58+
const response = await github.rest.repos.createRelease({
59+
owner: context.repo.owner,
60+
repo: context.repo.repo,
61+
tag_name: tag,
62+
name: tag,
63+
body: releaseNotes,
64+
prerelease: isPrerelease,
65+
});
66+
core.info(`Release created: ${response.data.html_url}`);
67+
} catch (err) {
68+
if (err && err.status === 422 && err.message && String(err.message).includes('already_exists')) {
69+
core.warning(`Release for tag '${tag}' already exists.`);
70+
} else {
71+
core.setFailed(`Error creating release: ${err?.message ?? err}`);
72+
}
73+
}

azure-pipelines/release.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ extends:
7878
displayName: 'Install vsce'
7979
- task: AzureCLI@2
8080
displayName: '🚀 Publish to Marketplace'
81+
name: PublishToMarketplaceStep
8182
inputs:
8283
azureSubscription: 'VSCode Marketplace Publishing'
8384
scriptType: "pscore"
@@ -112,8 +113,10 @@ extends:
112113
If ( $uploadPrerelease ) {
113114
$basePublishArgs += "--pre-release"
114115
Write-Host "Publish to pre-release channel."
116+
Write-Host "##vso[task.setvariable variable=isPrerelease;isOutput=true]true"
115117
} Else {
116118
Write-Host "Publish to release channel."
119+
Write-Host "##vso[task.setvariable variable=isPrerelease;isOutput=true]false"
117120
}
118121
$basePublishArgs += '--azure-credential'
119122
$basePublishArgs += '--packagePath'
@@ -139,6 +142,9 @@ extends:
139142
displayName: 'Tag release of vscode-csharp'
140143
dependsOn: 'PublishStage'
141144
condition: succeeded('PublishStage')
145+
variables:
146+
- name: isPrerelease
147+
value: $[ stageDependencies.PublishStage.PublishToMarketplace.outputs['PublishToMarketplace.PublishToMarketplaceStep.isPrerelease'] ]
142148
jobs:
143149
- job: 'Tag'
144150
pool:
@@ -163,7 +169,7 @@ extends:
163169
displayName: 'Install dependencies.'
164170
- pwsh: |
165171
# build number is generated by nerdbank git versioning and set to AzureDev
166-
gulp createTags --releaseVersion $(resources.pipeline.officialBuildCI.runName) --releaseCommit $(resources.pipeline.officialBuildCI.sourceCommit) --dryRun ${{ parameters.test }}
172+
gulp createTags --releaseVersion $(resources.pipeline.officialBuildCI.runName) --releaseCommit $(resources.pipeline.officialBuildCI.sourceCommit) --dryRun ${{ parameters.test }} --prerelease $(isPrerelease)
167173
env:
168174
GitHubPAT: $(BotAccount-dotnet-bot-content-rw-grained-pat)
169175
displayName: 'Create release tags'

tasks/createTagsTasks.ts

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,68 +11,78 @@ import { allNugetPackages, NugetPackageInfo, platformSpecificPackages } from './
1111
import { PlatformInformation } from '../src/shared/platform';
1212
import path from 'path';
1313

14-
interface Options {
14+
interface CreateTagsOptions {
1515
releaseVersion: string;
1616
releaseCommit: string;
1717
// Even it is specified as boolean, it would still be parsed as string in compiled js.
1818
dryRun: string;
1919
githubPAT: string | null;
20+
prerelease: string | null;
2021
}
2122

2223
gulp.task('createTags:roslyn', async (): Promise<void> => {
23-
const options = minimist<Options>(process.argv.slice(2));
24+
const options = minimist<CreateTagsOptions>(process.argv.slice(2));
2425

2526
return createTagsAsync(
2627
options,
2728
'dotnet',
2829
'roslyn',
2930
async () => getCommitFromNugetAsync(allNugetPackages.roslyn),
30-
(releaseVersion: string): [string, string] => [
31-
`VSCode-CSharp-${releaseVersion}`,
32-
`${releaseVersion} VSCode C# extension release`,
33-
]
31+
(releaseVersion: string, isPrerelease: boolean): [string, string] => {
32+
const prereleaseText = isPrerelease ? '-prerelease' : '';
33+
return [
34+
`VSCode-CSharp-${releaseVersion}${prereleaseText}`,
35+
`${releaseVersion} VSCode C# extension ${prereleaseText}`,
36+
];
37+
}
3438
);
3539
});
3640

3741
gulp.task('createTags:razor', async (): Promise<void> => {
38-
const options = minimist<Options>(process.argv.slice(2));
42+
const options = minimist<CreateTagsOptions>(process.argv.slice(2));
3943

4044
return createTagsAsync(
4145
options,
4246
'dotnet',
4347
'razor',
4448
async () => getCommitFromNugetAsync(allNugetPackages.razor),
45-
(releaseVersion: string): [string, string] => [
46-
`VSCode-CSharp-${releaseVersion}`,
47-
`${releaseVersion} VSCode C# extension release`,
48-
]
49+
(releaseVersion: string, isPrerelease: boolean): [string, string] => {
50+
const prereleaseText = isPrerelease ? '-prerelease' : '';
51+
return [
52+
`VSCode-CSharp-${releaseVersion}${prereleaseText}`,
53+
`${releaseVersion} VSCode C# extension ${prereleaseText}`,
54+
];
55+
}
4956
);
5057
});
5158

5259
gulp.task('createTags:vscode-csharp', async (): Promise<void> => {
53-
const options = minimist<Options>(process.argv.slice(2));
60+
const options = minimist<CreateTagsOptions>(process.argv.slice(2));
5461

5562
return createTagsAsync(
5663
options,
5764
'dotnet',
5865
'vscode-csharp',
5966
async () => options.releaseCommit,
60-
(releaseVersion: string): [string, string] => [`v${releaseVersion}`, releaseVersion]
67+
(releaseVersion: string, isPrerelease: boolean): [string, string] => {
68+
const prereleaseText = isPrerelease ? '-prerelease' : '';
69+
return [`v${releaseVersion}${prereleaseText}`, releaseVersion];
70+
}
6171
);
6272
});
6373

6474
gulp.task('createTags', gulp.series('createTags:roslyn', 'createTags:razor', 'createTags:vscode-csharp'));
6575

6676
async function createTagsAsync(
67-
options: Options,
77+
options: CreateTagsOptions,
6878
owner: string,
6979
repo: string,
7080
getCommit: () => Promise<string | null>,
71-
getTagAndMessage: (releaseVersion: string) => [string, string]
81+
getTagAndMessage: (releaseVersion: string, isPrerelease: boolean) => [string, string]
7282
): Promise<void> {
7383
console.log(`releaseVersion: ${options.releaseVersion}`);
7484
console.log(`releaseCommit: ${options.releaseCommit}`);
75-
const dryRun = options.dryRun ? options.dryRun.toLocaleLowerCase() === 'true' : false;
85+
const dryRun = getFlag('dryRun', options);
7686
console.log(`dry run: ${dryRun}`);
7787

7888
const commit = await getCommit();
@@ -81,7 +91,10 @@ async function createTagsAsync(
8191
return;
8292
}
8393

84-
const [tag, message] = getTagAndMessage(options.releaseVersion);
94+
const prerelease = getFlag('prerelease', options);
95+
console.log(`prerelease: ${prerelease}`);
96+
97+
const [tag, message] = getTagAndMessage(options.releaseVersion, prerelease);
8598
console.log(`tag: ${tag}`);
8699
console.log(`message: ${message}`);
87100

@@ -90,7 +103,8 @@ async function createTagsAsync(
90103
console.log('Tagging is skipped in dry run mode.');
91104
return;
92105
} else {
93-
const tagCreated = await tagRepoAsync(owner, repo, commit, tag, message, options.githubPAT);
106+
const githubPAT = getGitHubPAT(options);
107+
const tagCreated = await tagRepoAsync(owner, repo, commit, tag, message, githubPAT);
94108

95109
if (!tagCreated) {
96110
logError(`Failed to tag '${owner}/${repo}'`);
@@ -107,15 +121,10 @@ async function tagRepoAsync(
107121
commit: string,
108122
releaseTag: string,
109123
tagMessage: string,
110-
githubPAT: string | null
124+
githubPAT: string
111125
): Promise<boolean> {
112-
const pat = githubPAT ?? process.env['GitHubPAT'];
113-
if (!pat) {
114-
throw 'No GitHub Pat found. Specify with --githubPAT or set GitHubPAT environment variable.';
115-
}
116-
117126
console.log(`Start to tag ${owner}/${repo}. Commit: ${commit}, tag: ${releaseTag}, message: ${tagMessage}`);
118-
const octokit = new Octokit({ auth: pat });
127+
const octokit = new Octokit({ auth: githubPAT });
119128
await octokit.auth();
120129
const createTagResponse = await octokit.request(`POST /repos/${owner}/${repo}/git/tags`, {
121130
owner: owner,
@@ -130,25 +139,62 @@ async function tagRepoAsync(
130139
logError(`Failed to create tag for ${commit} in ${owner}/${repo}.`);
131140
return false;
132141
}
133-
const refCreationResponse = await octokit.request(`Post /repos/${owner}/${repo}/git/refs`, {
134-
owner: owner,
135-
repo: repo,
136-
ref: `refs/tags/${releaseTag}`,
137-
sha: commit,
138-
});
139-
140-
if (refCreationResponse.status !== 201) {
141-
logError(`Failed to create reference for ${commit} in ${owner}/${repo}.`);
142-
return false;
142+
try {
143+
const refCreationResponse = await octokit.request(`Post /repos/${owner}/${repo}/git/refs`, {
144+
owner: owner,
145+
repo: repo,
146+
ref: `refs/tags/${releaseTag}`,
147+
sha: commit,
148+
});
149+
150+
if (refCreationResponse.status !== 201) {
151+
logError(`Failed to create reference for ${commit} in ${owner}/${repo}.`);
152+
return false;
153+
}
154+
} catch (err: any) {
155+
if (err.status === 422 && err.message && err.message.includes('Reference already exists')) {
156+
logWarning(`Reference for tag '${releaseTag}' already exists in ${owner}/${repo}.`);
157+
return true;
158+
} else {
159+
logError(`Failed to create reference for ${commit} in ${owner}/${repo}: ${err}`);
160+
return false;
161+
}
143162
}
144163

145164
console.log(`Tag is created.`);
146165
return true;
147166
}
148167

168+
// --- Helper functions ---
169+
170+
function getGitHubPAT(options: { githubPAT?: string | null }): string {
171+
const pat = options.githubPAT ?? process.env['GitHubPAT'];
172+
if (!pat) {
173+
throw 'No GitHub Pat found. Specify with --githubPAT or set GitHubPAT environment variable.';
174+
}
175+
return pat;
176+
}
177+
178+
function getFlag<T extends CreateTagsOptions>(option: keyof T, options: T): boolean {
179+
const value = options[option];
180+
if (!value) {
181+
logError(`Missing required argument: --${option.toString()}`);
182+
}
183+
if (typeof value === 'string') {
184+
return value.toLocaleLowerCase() === 'true';
185+
} else {
186+
throw new Error(`Expected boolean value for --${option.toString()}, but got ${typeof value}`);
187+
}
188+
}
189+
190+
function logWarning(message: string): void {
191+
console.log(`##vso[task.logissue type=warning]${message}`);
192+
}
193+
149194
function logError(message: string): void {
150195
console.log(`##vso[task.logissue type=error]${message}`);
151196
}
197+
152198
async function getCommitFromNugetAsync(packageInfo: NugetPackageInfo): Promise<string | null> {
153199
const packageJsonString = fs.readFileSync('./package.json').toString();
154200
const packageJson = JSON.parse(packageJsonString);

0 commit comments

Comments
 (0)