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
36 changes: 34 additions & 2 deletions .github/workflows/csharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ jobs:
path: csharp/artifacts/

- name: Publish to NuGet
id: nuget_publish
if: steps.version_check.outputs.should_release == 'true'
working-directory: ./csharp
env:
Expand Down Expand Up @@ -275,6 +276,35 @@ jobs:
fi
echo "$OUT"

- name: Verify package on NuGet
# Mirrors the PyPI verification step (see python.yml). The NuGet CDN can
# take a minute or so to mirror a successful push; without an explicit
# registry probe the workflow can succeed even if the package never
# surfaces. See docs/case-studies/issue-33/README.md.
if: steps.version_check.outputs.should_release == 'true' && steps.nuget_publish.outcome == 'success'
run: |
PKG="${{ steps.version_check.outputs.package_id }}"
VER="${{ steps.version_check.outputs.current_version }}"
if [ -z "$PKG" ]; then
echo "::warning title=NuGet verification skipped::package_id not exported by version_check; skipping verify."
exit 0
fi
PKG_LOWER=$(echo "$PKG" | tr '[:upper:]' '[:lower:]')
for i in 1 2 3 4 5 6; do
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
"https://api.nuget.org/v3-flatcontainer/${PKG_LOWER}/${VER}/${PKG_LOWER}.nuspec")
echo "Attempt $i: NuGet HTTP status for ${PKG}@${VER}: ${STATUS}"
if [ "$STATUS" = "200" ]; then
echo "✅ Verified ${PKG}@${VER} is on NuGet"
exit 0
fi
sleep 10
done
echo "::error title=NuGet verification failed::${PKG}@${VER} is not on NuGet after publish."
echo "The publish step reported success but the registry does not see the version."
echo "See docs/case-studies/issue-33/README.md for the runbook."
exit 1

- name: Create GitHub Release
if: steps.version_check.outputs.should_release == 'true'
env:
Expand All @@ -284,7 +314,8 @@ jobs:
node scripts/create-github-release.mjs \
--version "${{ steps.version_check.outputs.current_version }}" \
--repository "${{ github.repository }}" \
--tag-prefix "csharp-v"
--tag-prefix "csharp_v" \
--language "C#"

# Manual release via workflow_dispatch
manual-release:
Expand Down Expand Up @@ -383,4 +414,5 @@ jobs:
node scripts/create-github-release.mjs \
--version "${{ steps.version.outputs.new_version }}" \
--repository "${{ github.repository }}" \
--tag-prefix "csharp-v"
--tag-prefix "csharp_v" \
--language "C#"
8 changes: 4 additions & 4 deletions .github/workflows/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,14 +254,14 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
working-directory: ./js
run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --tag-prefix "js-v"
run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --tag-prefix "js_v" --language "JavaScript"

- name: Format GitHub release notes
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
working-directory: ./js
run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" --tag-prefix "js-v"
run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" --tag-prefix "js_v" --language "JavaScript"

# Manual Instant Release - triggered via workflow_dispatch with instant mode
instant-release:
Expand Down Expand Up @@ -316,14 +316,14 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
working-directory: ./js
run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --tag-prefix "js-v"
run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --tag-prefix "js_v" --language "JavaScript"

- name: Format GitHub release notes
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
working-directory: ./js
run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" --tag-prefix "js-v"
run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" --tag-prefix "js_v" --language "JavaScript"

# Manual Changeset PR - creates a pull request with the changeset for review
changeset-pr:
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ jobs:
python scripts/create_github_release.py \
--version "${{ steps.version_check.outputs.current_version }}" \
--repository "${{ github.repository }}" \
--tag-prefix "python-v"
--tag-prefix "python_v" \
--language "Python"

# Manual release via workflow_dispatch - only after CI passes
manual-release:
Expand Down Expand Up @@ -382,4 +383,5 @@ jobs:
python scripts/create_github_release.py \
--version "${{ steps.version.outputs.new_version }}" \
--repository "${{ github.repository }}" \
--tag-prefix "python-v"
--tag-prefix "python_v" \
--language "Python"
6 changes: 4 additions & 2 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ jobs:
node scripts/create-github-release.mjs \
--version "${{ steps.current_version.outputs.version }}" \
--repository "${{ github.repository }}" \
--tag-prefix "rust-v"
--tag-prefix "rust_v" \
--language "Rust"

# === MANUAL INSTANT RELEASE ===
# Manual release via workflow_dispatch - only after CI passes
Expand Down Expand Up @@ -473,7 +474,8 @@ jobs:
node scripts/create-github-release.mjs \
--version "${{ steps.version.outputs.new_version }}" \
--repository "${{ github.repository }}" \
--tag-prefix "rust-v"
--tag-prefix "rust_v" \
--language "Rust"

# === MANUAL CHANGELOG PR ===
changelog-pr:
Expand Down
141 changes: 123 additions & 18 deletions csharp/scripts/create-github-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,36 @@

/**
* Create GitHub Release from CHANGELOG.md for C# package
* Usage: node scripts/create-github-release.mjs --version <version> --repository <repository> [--tag-prefix <prefix>]
* Usage: node scripts/create-github-release.mjs --version <version> --repository <repository> [--tag-prefix <prefix>] [--language <name>] [--package-name <name>]
* version: Version number (e.g., 1.0.0)
* repository: GitHub repository (e.g., owner/repo)
* tag-prefix: Tag prefix (default: "csharp-v")
* tag-prefix: Tag prefix (default: "csharp_v")
* language: Display label for the release title (default: "C#")
* package-name: NuGet package name for the badge (auto-detected from .csproj if missing)
*
* Per issue #33:
* - Tag format: csharp_v<semver>
* - Title format: [C#] X.Y.Z
* - Body MUST contain a NuGet shields.io badge
* - The script must check `gh api` exit code so 422 (e.g. tag conflicts)
* no longer reports a false-positive success.
*
* Uses link-foundation libraries:
* - use-m: Dynamic package loading without package.json dependencies
* - command-stream: Modern shell command execution with streaming support
* - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files
*/

import { readFileSync } from 'fs';
import { readFileSync, readdirSync, statSync } from 'fs';
import path from 'path';

import {
buildNuGetVersionBadge,
buildReleaseTag,
buildReleaseTitle,
isAlreadyExistingReleaseError,
normalizeReleaseVersionForBadge,
} from './release-format-helpers.mjs';

// Load use-m dynamically
const { use } = eval(
Expand All @@ -24,7 +42,54 @@ const { use } = eval(
const { $ } = await use('command-stream');
const { makeConfig } = await use('lino-arguments');

// Parse CLI arguments using lino-arguments
function findCsprojPackageName(rootDir = '.') {
// Walk a small tree (one level deep) to find the first .csproj with
// <PackageId> or <AssemblyName>. We avoid pulling in an XML parser
// because the value we need is always a single tag inside a PropertyGroup.
const candidates = [];
function walk(dir, depth) {
if (depth > 3) return;
let entries;
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
if (entry === 'node_modules' || entry === 'bin' || entry === 'obj') continue;
const full = path.join(dir, entry);
let stat;
try {
stat = statSync(full);
} catch {
continue;
}
if (stat.isDirectory()) {
walk(full, depth + 1);
} else if (full.endsWith('.csproj')) {
candidates.push(full);
}
}
}
walk(rootDir, 0);

for (const csproj of candidates) {
let xml;
try {
xml = readFileSync(csproj, 'utf8');
} catch {
continue;
}
const packageIdMatch = xml.match(/<PackageId>([^<]+)<\/PackageId>/);
if (packageIdMatch) return packageIdMatch[1].trim();
const assemblyNameMatch = xml.match(/<AssemblyName>([^<]+)<\/AssemblyName>/);
if (assemblyNameMatch) return assemblyNameMatch[1].trim();
// Fall back to the file name (without extension).
return path.basename(csproj, '.csproj');
}
return null;
}

const config = makeConfig({
yargs: ({ yargs, getenv }) =>
yargs
Expand All @@ -41,28 +106,42 @@ const config = makeConfig({
})
.option('tag-prefix', {
type: 'string',
default: getenv('TAG_PREFIX', 'csharp-v'),
default: getenv('TAG_PREFIX', 'csharp_v'),
describe: 'Tag prefix for the release',
})
.option('language', {
type: 'string',
default: getenv('LANGUAGE', 'C#'),
describe: 'Human-readable language label (used in the release title)',
})
.option('package-name', {
type: 'string',
default: getenv('PACKAGE_NAME', ''),
describe:
'NuGet package name for the badge (auto-detected from .csproj if missing)',
}),
});

const { version, repository, tagPrefix } = config;
const { version: rawVersion, repository, tagPrefix, language } = config;

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

const tag = `${tagPrefix}${version}`;
const semver = normalizeReleaseVersionForBadge(rawVersion);
const tag = buildReleaseTag(tagPrefix, semver);
const title = buildReleaseTitle(language, semver);
const packageName = config.packageName || findCsprojPackageName('.');

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

try {
// Read CHANGELOG.md
let changelog;
let changelog = '';
try {
changelog = readFileSync('./CHANGELOG.md', 'utf8');
} catch {
Expand All @@ -72,35 +151,61 @@ try {
// Extract changelog entry for this version
// Read from CHANGELOG.md between this version header and the next version header
const versionHeaderRegex = new RegExp(
`## \\[?${version.replace(/\./g, '\\.')}\\]?[\\s\\S]*?(?=## \\[?\\d|$)`
`## \\[?${semver.replace(/\./g, '\\.')}\\]?[\\s\\S]*?(?=## \\[?\\d|$)`
);
const match = changelog.match(versionHeaderRegex);

let releaseNotes = '';
if (match) {
// Remove the version header itself and trim
releaseNotes = match[0]
.replace(new RegExp(`## \\[?${version.replace(/\./g, '\\.')}\\]?[^\\n]*`), '')
.replace(
new RegExp(`## \\[?${semver.replace(/\./g, '\\.')}\\]?[^\\n]*`),
''
)
.trim();
}

if (!releaseNotes) {
releaseNotes = `Release ${version}`;
releaseNotes = `Release ${semver}`;
}

if (packageName && !/img\.shields\.io/.test(releaseNotes)) {
const badge = buildNuGetVersionBadge(packageName, semver);
releaseNotes = `${releaseNotes}\n\n---\n\n${badge}`;
}

// Create release using GitHub API with JSON input
// This avoids shell escaping issues that occur when passing text via command-line arguments
const payload = JSON.stringify({
tag_name: tag,
name: `C# ${version}`,
name: title,
body: releaseNotes,
});

await $`gh api repos/${repository}/releases -X POST --input -`.run({
stdin: payload,
});
const result =
await $`gh api repos/${repository}/releases -X POST --input -`.run({
stdin: payload,
capture: true,
});

if (result.stdout) {
console.log(result.stdout);
}
if (result.stderr) {
console.error(result.stderr);
}

if (result.code && result.code !== 0) {
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
if (isAlreadyExistingReleaseError(output)) {
console.log(`GitHub release already exists: ${tag}`);
process.exit(0);
}
throw new Error(`gh api exited with code ${result.code}`);
}

console.log(`\u2705 Created GitHub release: ${tag}`);
console.log(` Created GitHub release: ${tag}`);
} catch (error) {
console.error('Error creating release:', error.message);
process.exit(1);
Expand Down
Loading
Loading