Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitkeep

This file was deleted.

275 changes: 183 additions & 92 deletions csharp/scripts/create-github-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,78 +4,88 @@
* Create GitHub Release from CHANGELOG.md
* Usage:
* node csharp/scripts/create-github-release.mjs --release-version <version> --repository <owner/repo> [--tag-prefix v] [--changelog-path CHANGELOG.md] [--assets-glob csharp/artifacts/*.nupkg]
*
* When --package-id is provided the release notes are prefixed with NuGet
* version and downloads shields.io badges that link to the specific package
* version. The Rust release script does the same with Crates.io and Docs.rs
* badges; the templates' csharp pipeline appends a NuGet badge as well.
*/

import { readFileSync, existsSync, readdirSync } from 'fs';
import { execFileSync } from 'child_process';
import { dirname, basename, join, isAbsolute } from 'path';

// Simple argument parsing
const args = process.argv.slice(2);
const getArg = (name, fallback = null) => {
const index = args.indexOf(`--${name}`);
if (index === -1) return fallback;
return args[index + 1] ?? fallback;
};

const version = getArg('release-version');
const repository = getArg('repository');
const tagPrefix = getArg('tag-prefix', 'v');
const changelogPath = getArg('changelog-path', 'CHANGELOG.md');
const language = getArg('language', '');
const packageId = getArg('package-id', '');
const assetsGlob = getArg('assets-glob', '');
const dryRun = args.includes('--dry-run');
import { dirname, basename, join, isAbsolute, resolve } from 'path';
import { fileURLToPath } from 'url';

/**
* Resolve a simple `directory/*.ext` glob to a list of file paths.
* Only `*` in the file name part is supported; matches are returned in name order.
* Parse `--flag value` and `--flag=value` style arguments. Returns the value
* or the provided fallback when the flag is absent.
* @param {string[]} argv
* @param {string} name
* @param {string|null} fallback
* @returns {string|null}
*/
function resolveAssets(pattern) {
if (!pattern) return [];
const dir = dirname(pattern) || '.';
const filePattern = basename(pattern);
if (!existsSync(dir)) return [];

if (!filePattern.includes('*')) {
const candidate = isAbsolute(pattern) ? pattern : join(dir, filePattern);
return existsSync(candidate) ? [candidate] : [];
export function getArg(argv, name, fallback = null) {
const flag = `--${name}`;
const eqPrefix = `${flag}=`;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === flag) {
return argv[i + 1] ?? fallback;
}
if (a.startsWith(eqPrefix)) {
return a.slice(eqPrefix.length);
}
}

const escaped = filePattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
const regex = new RegExp(`^${escaped}$`);
return readdirSync(dir)
.filter((name) => regex.test(name))
.sort()
.map((name) => join(dir, name));
return fallback;
}

if (!version || !repository) {
console.error('Error: Missing required arguments');
console.error(
'Usage: node csharp/scripts/create-github-release.mjs --release-version <version> --repository <repository>'
);
process.exit(1);
/**
* Build NuGet version + downloads badges that link to a specific package version.
* Mirrors the Rust release script that emits Crates.io + Docs.rs badges.
* @param {string} packageId NuGet package id (e.g. `clink`).
* @param {string} version Bare semver string (e.g. `2.4.0`).
* @returns {string} A single line of two markdown badges separated by a space.
*/
export function buildNugetBadges(packageId, version) {
const id = encodeURIComponent(packageId);
const versionPath = encodeURIComponent(version);
const versionUrl = `https://www.nuget.org/packages/${id}/${versionPath}`;
const versionBadge = `[![NuGet](https://img.shields.io/nuget/v/${id}?logo=nuget&label=NuGet)](${versionUrl})`;
const downloadsBadge = `[![NuGet Downloads](https://img.shields.io/nuget/dt/${id}?logo=nuget&label=downloads)](${versionUrl})`;
return `${versionBadge} ${downloadsBadge}`;
}

const tag = `${tagPrefix}${version}`;

console.log(`Creating GitHub release for ${tag}...`);
/**
* Prepend NuGet badges to release notes when a package id is known and badges
* are not already present. Returns the notes unchanged otherwise.
* @param {string} releaseNotes
* @param {string} packageId
* @param {string} version
* @returns {string}
*/
export function prependNugetBadges(releaseNotes, packageId, version) {
if (!packageId || !version) {
return releaseNotes;
}
if (/img\.shields\.io\/nuget\//i.test(releaseNotes)) {
return releaseNotes;
}
return `${buildNugetBadges(packageId, version)}\n\n${releaseNotes}`;
}

/**
* Extract changelog content for a specific version
* @param {string} version
* @param {string} changelogPath
* @returns {string}
*/
function getChangelogForVersion(version, changelogPath) {
export function getChangelogForVersion(version, changelogPath) {
if (!existsSync(changelogPath)) {
return `Release v${version}`;
}

const content = readFileSync(changelogPath, 'utf-8');

// Find the section for this version
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(
`## \\[${escapedVersion}\\].*?\\n([\\s\\S]*?)(?=\\n## \\[|$)`
Expand All @@ -89,66 +99,147 @@ function getChangelogForVersion(version, changelogPath) {
return `Release v${version}`;
}

try {
const releaseNotes = getChangelogForVersion(version, changelogPath);
const body = packageId
? `${releaseNotes}\n\nPackage: \`${packageId}\``
: releaseNotes;

const payload = {
tag_name: tag,
name: language ? `${language} v${version}` : `v${version}`,
/**
* Build the GitHub release API payload.
* @param {{changelogPath: string, language: string, packageId: string, releaseVersion: string, tagPrefix: string}} options
* @returns {{tag_name: string, name: string, body: string}}
*/
export function buildReleasePayload({
changelogPath,
language,
packageId,
releaseVersion,
tagPrefix,
}) {
const changelogNotes = getChangelogForVersion(releaseVersion, changelogPath);
const notesWithPackage = packageId
? `${changelogNotes}\n\nPackage: \`${packageId}\``
: changelogNotes;
const body = prependNugetBadges(notesWithPackage, packageId, releaseVersion);

return {
tag_name: `${tagPrefix}${releaseVersion}`,
name: language ? `${language} v${releaseVersion}` : `v${releaseVersion}`,
body,
};
}

/**
* Resolve a simple `directory/*.ext` glob to a list of file paths.
* Only `*` in the file name part is supported; matches are returned in name order.
* @param {string} pattern
* @returns {string[]}
*/
export function resolveAssets(pattern) {
if (!pattern) return [];
const dir = dirname(pattern) || '.';
const filePattern = basename(pattern);
if (!existsSync(dir)) return [];

if (!filePattern.includes('*')) {
const candidate = isAbsolute(pattern) ? pattern : join(dir, filePattern);
return existsSync(candidate) ? [candidate] : [];
}

const escaped = filePattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
const regex = new RegExp(`^${escaped}$`);
return readdirSync(dir)
.filter((name) => regex.test(name))
.sort()
.map((name) => join(dir, name));
}

function main(argv) {
const version = getArg(argv, 'release-version');
const repository = getArg(argv, 'repository');
const tagPrefix = getArg(argv, 'tag-prefix', 'v');
const changelogPath = getArg(argv, 'changelog-path', 'CHANGELOG.md');
const language = getArg(argv, 'language', '');
const packageId = getArg(argv, 'package-id', '');
const assetsGlob = getArg(argv, 'assets-glob', '');
const dryRun = argv.includes('--dry-run');

if (!version || !repository) {
console.error('Error: Missing required arguments');
console.error(
'Usage: node csharp/scripts/create-github-release.mjs --release-version <version> --repository <repository>'
);
process.exit(1);
}

const payload = buildReleasePayload({
changelogPath,
language,
packageId,
releaseVersion: version,
tagPrefix,
});
const tag = payload.tag_name;

console.log(`Creating GitHub release for ${tag}...`);

if (dryRun) {
console.log(JSON.stringify(payload, null, 2));
process.exit(0);
}

const assetPaths = resolveAssets(assetsGlob);

let releaseExists = false;
try {
execFileSync('gh', ['release', 'view', tag, '--repo', repository], {
stdio: 'ignore',
});
releaseExists = true;
console.log(`Release ${tag} already exists, will reconcile assets`);
} catch {
// Release does not exist yet.
}
const assetPaths = resolveAssets(assetsGlob);

if (!releaseExists) {
let releaseExists = false;
try {
execFileSync('gh', ['api', `repos/${repository}/releases`, '-X', 'POST', '--input', '-'], {
input: JSON.stringify(payload),
encoding: 'utf-8',
stdio: ['pipe', 'inherit', 'inherit'],
execFileSync('gh', ['release', 'view', tag, '--repo', repository], {
stdio: 'ignore',
});
console.log(`Created GitHub release: ${tag}`);
} catch (error) {
if (error.message && error.message.includes('already exists')) {
console.log(`Release ${tag} already exists, will reconcile assets`);
} else {
throw error;
releaseExists = true;
console.log(`Release ${tag} already exists, will reconcile assets`);
} catch {
// Release does not exist yet.
}

if (!releaseExists) {
try {
execFileSync('gh', ['api', `repos/${repository}/releases`, '-X', 'POST', '--input', '-'], {
input: JSON.stringify(payload),
encoding: 'utf-8',
stdio: ['pipe', 'inherit', 'inherit'],
});
console.log(`Created GitHub release: ${tag}`);
} catch (error) {
if (error.message && error.message.includes('already exists')) {
console.log(`Release ${tag} already exists, will reconcile assets`);
} else {
throw error;
}
}
}
}

if (assetPaths.length === 0) {
if (assetsGlob) {
console.log(`No assets matched ${assetsGlob}, skipping asset upload`);
if (assetPaths.length === 0) {
if (assetsGlob) {
console.log(`No assets matched ${assetsGlob}, skipping asset upload`);
}
} else {
console.log(`Uploading ${assetPaths.length} asset(s) to ${tag}`);
execFileSync(
'gh',
['release', 'upload', tag, ...assetPaths, '--clobber', '--repo', repository],
{ stdio: 'inherit' }
);
}
} else {
console.log(`Uploading ${assetPaths.length} asset(s) to ${tag}`);
execFileSync(
'gh',
['release', 'upload', tag, ...assetPaths, '--clobber', '--repo', repository],
{ stdio: 'inherit' }
);
} catch (error) {
console.error('Error creating release:', error.message);
process.exit(1);
}
} catch (error) {
console.error('Error creating release:', error.message);
process.exit(1);
}

function isCliEntryPoint() {
return (
typeof process !== 'undefined' &&
process.argv?.[1] &&
fileURLToPath(import.meta.url) === resolve(process.argv[1])
);
}

if (isCliEntryPoint()) {
main(process.argv.slice(2));
}
Loading
Loading