Skip to content

Commit 9b3446f

Browse files
committed
Add task for automatically creating the github release
1 parent 881106c commit 9b3446f

File tree

4 files changed

+173
-36
lines changed

4 files changed

+173
-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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,10 @@ extends:
112112
If ( $uploadPrerelease ) {
113113
$basePublishArgs += "--pre-release"
114114
Write-Host "Publish to pre-release channel."
115+
Write-Host "##vso[task.setvariable variable=isPrerelease]true"
115116
} Else {
116117
Write-Host "Publish to release channel."
118+
Write-Host "##vso[task.setvariable variable=isPrerelease]false"
117119
}
118120
$basePublishArgs += '--azure-credential'
119121
$basePublishArgs += '--packagePath'
@@ -163,7 +165,7 @@ extends:
163165
displayName: 'Install dependencies.'
164166
- pwsh: |
165167
# 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 }}
168+
gulp createTags --releaseVersion $(resources.pipeline.officialBuildCI.runName) --releaseCommit $(resources.pipeline.officialBuildCI.sourceCommit) --dryRun ${{ parameters.test }} --prerelease $(isPrerelease)
167169
env:
168170
GitHubPAT: $(BotAccount-dotnet-bot-content-rw-grained-pat)
169171
displayName: 'Create release tags'

tasks/createTagsTasks.ts

Lines changed: 79 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,8 @@ async function createTagsAsync(
8191
return;
8292
}
8393

84-
const [tag, message] = getTagAndMessage(options.releaseVersion);
94+
const prerelease = getFlag('prerelease', options);
95+
const [tag, message] = getTagAndMessage(options.releaseVersion, prerelease);
8596
console.log(`tag: ${tag}`);
8697
console.log(`message: ${message}`);
8798

@@ -90,7 +101,8 @@ async function createTagsAsync(
90101
console.log('Tagging is skipped in dry run mode.');
91102
return;
92103
} else {
93-
const tagCreated = await tagRepoAsync(owner, repo, commit, tag, message, options.githubPAT);
104+
const githubPAT = getGitHubPAT(options);
105+
const tagCreated = await tagRepoAsync(owner, repo, commit, tag, message, githubPAT);
94106

95107
if (!tagCreated) {
96108
logError(`Failed to tag '${owner}/${repo}'`);
@@ -107,15 +119,10 @@ async function tagRepoAsync(
107119
commit: string,
108120
releaseTag: string,
109121
tagMessage: string,
110-
githubPAT: string | null
122+
githubPAT: string
111123
): 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-
117124
console.log(`Start to tag ${owner}/${repo}. Commit: ${commit}, tag: ${releaseTag}, message: ${tagMessage}`);
118-
const octokit = new Octokit({ auth: pat });
125+
const octokit = new Octokit({ auth: githubPAT });
119126
await octokit.auth();
120127
const createTagResponse = await octokit.request(`POST /repos/${owner}/${repo}/git/tags`, {
121128
owner: owner,
@@ -130,25 +137,62 @@ async function tagRepoAsync(
130137
logError(`Failed to create tag for ${commit} in ${owner}/${repo}.`);
131138
return false;
132139
}
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;
140+
try {
141+
const refCreationResponse = await octokit.request(`Post /repos/${owner}/${repo}/git/refs`, {
142+
owner: owner,
143+
repo: repo,
144+
ref: `refs/tags/${releaseTag}`,
145+
sha: commit,
146+
});
147+
148+
if (refCreationResponse.status !== 201) {
149+
logError(`Failed to create reference for ${commit} in ${owner}/${repo}.`);
150+
return false;
151+
}
152+
} catch (err: any) {
153+
if (err.status === 422 && err.message && err.message.includes('Reference already exists')) {
154+
logWarning(`Reference for tag '${releaseTag}' already exists in ${owner}/${repo}.`);
155+
return true;
156+
} else {
157+
logError(`Failed to create reference for ${commit} in ${owner}/${repo}: ${err}`);
158+
return false;
159+
}
143160
}
144161

145162
console.log(`Tag is created.`);
146163
return true;
147164
}
148165

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

0 commit comments

Comments
 (0)