diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 6dc7fcd..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-12T21:57:51.691Z for PR creation at branch issue-88-82c1eee49462 for issue https://github.com/link-foundation/link-cli/issues/88 \ No newline at end of file diff --git a/csharp/scripts/create-github-release.mjs b/csharp/scripts/create-github-release.mjs index a930974..9c5183c 100644 --- a/csharp/scripts/create-github-release.mjs +++ b/csharp/scripts/create-github-release.mjs @@ -4,63 +4,74 @@ * Create GitHub Release from CHANGELOG.md * Usage: * node csharp/scripts/create-github-release.mjs --release-version --repository [--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 --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 @@ -68,14 +79,13 @@ console.log(`Creating GitHub release for ${tag}...`); * @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## \\[|$)` @@ -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 --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)); } diff --git a/csharp/scripts/release-scripts.test.mjs b/csharp/scripts/release-scripts.test.mjs index 78ef2bc..3a13244 100644 --- a/csharp/scripts/release-scripts.test.mjs +++ b/csharp/scripts/release-scripts.test.mjs @@ -21,6 +21,11 @@ import { parseArgs as parseWaitForNugetArgs, waitForNugetPackage, } from './wait-for-nuget.mjs'; +import { + buildNugetBadges, + buildReleasePayload, + prependNugetBadges, +} from './create-github-release.mjs'; const execFileAsync = promisify(execFile); @@ -112,6 +117,110 @@ test('create-github-release dry run uses tag prefix and component changelog', () assert.equal(payload.name, 'C# v2.4.0'); assert.match(payload.body, /Fixed release automation\./); assert.match(payload.body, /Package: `clink`/); + // Issue #88: the body must lead with NuGet badges that link to the + // exact released version (not the package landing page). + assert.match( + payload.body, + /\[!\[NuGet\]\(https:\/\/img\.shields\.io\/nuget\/v\/clink\?logo=nuget&label=NuGet\)\]\(https:\/\/www\.nuget\.org\/packages\/clink\/2\.4\.0\)/ + ); + assert.match( + payload.body, + /\[!\[NuGet Downloads\]\(https:\/\/img\.shields\.io\/nuget\/dt\/clink\?logo=nuget&label=downloads\)\]\(https:\/\/www\.nuget\.org\/packages\/clink\/2\.4\.0\)/ + ); +}); + +test('create-github-release dry run omits NuGet badges when no package id is provided', () => { + const dir = mkdtempSync(join(tmpdir(), 'link-cli-release-no-package-')); + const changelog = join(dir, 'CHANGELOG.md'); + writeFileSync( + changelog, + '# Changelog\n\n## [2.4.0] - 2026-05-12\n\nFixed release automation.\n' + ); + + const stdout = runNode('csharp/scripts/create-github-release.mjs', [ + '--release-version', + '2.4.0', + '--repository', + 'link-foundation/link-cli', + '--tag-prefix', + 'csharp-v', + '--language', + 'C#', + '--changelog-path', + changelog, + '--dry-run', + ]); + const payload = JSON.parse(stdout.slice(stdout.indexOf('{'))); + + assert.doesNotMatch(payload.body, /img\.shields\.io\/nuget/); + assert.doesNotMatch(payload.body, /Package: `/); +}); + +test('buildNugetBadges links both badges to the exact version', () => { + const badges = buildNugetBadges('clink', '2.4.0'); + + assert.match(badges, /img\.shields\.io\/nuget\/v\/clink\?/); + assert.match(badges, /img\.shields\.io\/nuget\/dt\/clink\?/); + // Both clickable badge targets (outer `](url)` of each markdown link) must + // point to the version-specific NuGet URL. + const targets = [...badges.matchAll(/\)\]\(([^)]+)\)/g)].map((m) => m[1]); + assert.equal(targets.length, 2); + for (const target of targets) { + assert.equal(target, 'https://www.nuget.org/packages/clink/2.4.0'); + } +}); + +test('buildNugetBadges URL-encodes the package id', () => { + const badges = buildNugetBadges('My.Package', '1.0.0'); + + assert.match(badges, /nuget\/v\/My\.Package/); + assert.match(badges, /packages\/My\.Package\/1\.0\.0/); +}); + +test('prependNugetBadges keeps existing shields.io NuGet badges intact', () => { + const notes = + '[![NuGet](https://img.shields.io/nuget/v/clink?label=NuGet)](https://www.nuget.org/packages/clink)\n\nExisting notes.'; + + const result = prependNugetBadges(notes, 'clink', '2.4.0'); + + assert.equal(result, notes); +}); + +test('prependNugetBadges is a no-op without a package id', () => { + const notes = 'Release v2.4.0.'; + + assert.equal(prependNugetBadges(notes, '', '2.4.0'), notes); + assert.equal(prependNugetBadges(notes, 'clink', ''), notes); +}); + +test('buildReleasePayload places NuGet badges above the package footer', () => { + const dir = mkdtempSync(join(tmpdir(), 'link-cli-release-payload-')); + const changelog = join(dir, 'CHANGELOG.md'); + writeFileSync( + changelog, + '# Changelog\n\n## [2.4.0] - 2026-05-12\n\nFirst line.\nSecond line.\n' + ); + + const payload = buildReleasePayload({ + changelogPath: changelog, + language: 'C#', + packageId: 'clink', + releaseVersion: '2.4.0', + tagPrefix: 'csharp-v', + }); + + assert.equal(payload.tag_name, 'csharp-v2.4.0'); + assert.equal(payload.name, 'C# v2.4.0'); + const badgesIndex = payload.body.indexOf('![NuGet]'); + const notesIndex = payload.body.indexOf('First line.'); + const footerIndex = payload.body.indexOf('Package: `clink`'); + assert.notEqual(badgesIndex, -1); + assert.notEqual(notesIndex, -1); + assert.notEqual(footerIndex, -1); + assert.ok( + badgesIndex < notesIndex && notesIndex < footerIndex, + `expected badges < notes < footer, got ${badgesIndex} ${notesIndex} ${footerIndex}` + ); }); test('create-github-release dry run reports matching assets without uploading', () => { diff --git a/docs/case-studies/issue-88/README.md b/docs/case-studies/issue-88/README.md index f15afe5..4f478da 100644 --- a/docs/case-studies/issue-88/README.md +++ b/docs/case-studies/issue-88/README.md @@ -1,58 +1,70 @@ -# Issue 88 Case Study: C# Release Badge Did Not Link to the Specific Version +# Issue 88 Case Study: C# Release Badge Link and Missing NuGet Badges in C# Releases Issue: https://github.com/link-foundation/link-cli/issues/88 -Prepared PR: https://github.com/link-foundation/link-cli/pull/89 +Prepared PRs: + +- PR #89 (merged): https://github.com/link-foundation/link-cli/pull/89 — fixed the C# release badge URL in `README.md` so the badge in the README points to the C#-only filtered releases page rather than the mixed `/releases` list. +- PR #90 (this PR): https://github.com/link-foundation/link-cli/pull/90 — addresses the follow-up comment _"Still no NuGet badges in C# GitHub releases."_ The C# release body itself now leads with NuGet version and downloads badges that link to the exact published version, matching the Rust release body's Crates.io + Docs.rs pair. Related release: https://github.com/link-foundation/link-cli/releases/tag/csharp-v2.4.0 ## Requirements -Restated from issue #88: +Restated from issue #88 and its comments: -1. The C# Release shields.io badge in `README.md` shows the latest C# version (e.g. `csharp-v2.4.0`) but clicking it lands on the generic releases page that mixes C# and Rust releases instead of the specific C# release version. -2. Apply the same best practices to comparable badges (the Rust Release badge has the same defect). +1. The C# Release shields.io badge in `README.md` shows the latest C# version (e.g. `csharp-v2.4.0`) but clicking it lands on the generic releases page that mixes C# and Rust releases instead of the specific C# release version. _(Addressed in PR #89.)_ +2. Apply the same best practices to comparable badges (the Rust Release badge has the same defect). _(Addressed in PR #89.)_ 3. Compare the full GitHub workflow / CI/CD scripts tree against the C#, JS, and Rust AI-driven development pipeline templates and reuse best practices. 4. If the same issue exists in any template repository, report it upstream. 5. Preserve issue/PR/release data and analysis under `docs/case-studies/issue-88/`. 6. Search public sources for facts about how the shields.io GitHub release badge target works so the fix is grounded. 7. Add debug or verbose output if there is not enough data to find the root cause. 8. Plan and execute everything in this single PR. +9. **Follow-up comment**: _"Still no NuGet badges in C# GitHub releases."_ — the C# GitHub release pages (e.g. `csharp-v2.4.0`) do not embed a NuGet badge in the release body. The Rust release pages do embed Crates.io and Docs.rs badges. _(Addressed in PR #90.)_ ## Timeline - `2026-05-12T19:34:20Z`: GitHub Actions created the `csharp-v2.4.0` tag (`github-data/csharp-v2.4.0-release.json`). -- `2026-05-12T21:54:13Z`: Release `csharp-v2.4.0` was published. +- `2026-05-12T21:54:13Z`: Release `csharp-v2.4.0` was published _without_ any NuGet badge in the release body. - `2026-05-12T21:57Z`: Issue #88 was filed showing that the C# release badge in `README.md` linked to `/releases` rather than to `csharp-v2.4.0`. Evidence: `github-data/issue-88.json`. -- `2026-05-12T21:59Z`: Investigation reproduced the behavior. Probing showed: +- `2026-05-12T21:59Z`: Investigation reproduced the README badge link behavior. Probing showed: - The shields.io badge endpoint `https://img.shields.io/github/v/release/link-foundation/link-cli?filter=csharp-v*` returns the latest C# release version. Evidence: `logs/shields-filter-csharp-headers.txt`. - GitHub's filtered releases URL `https://github.com/link-foundation/link-cli/releases?q=C%23&expanded=true` returns HTTP 200 and lists only C# releases with the latest expanded at the top. Evidence: `logs/releases-q-csharp-headers.txt`. - The same pattern works for Rust with `q=Rust`. Evidence: `logs/releases-q-rust-headers.txt`. +- _PR #89 merged_: `README.md` badge targets now point at the language-filtered releases page. +- `2026-05-12T22:39Z`: konard re-opened the conversation with _"Still no NuGet badges in C# GitHub releases."_ — surfacing that the C# release **body** (the content shown on a release page like `/releases/tag/csharp-v2.4.0`) still has no NuGet badge, while the Rust release body shows Crates.io + Docs.rs badges. Evidence: `github-data/issue-88-comments.json` and `github-data/csharp-v2.4.0-release.json`. +- `2026-05-12T22:42Z`: Inspection of the Rust release script (`rust/scripts/create-github-release.rs`) confirmed it prepends `[![Crates.io]...] [![Docs.rs]...]` to the release notes. The C# release script (`csharp/scripts/create-github-release.mjs`) had no equivalent badge logic. Evidence: `github-data/csharp-v2.4.0-release.json` (no `img.shields.io` in `body`) vs. the latest Rust release body which begins with both badges. +- `2026-05-12T22:43Z`: The csharp template at `link-foundation/csharp-ai-driven-development-pipeline-template` was inspected and **already** defines `buildNuGetBadge` and `appendNuGetBadgeIfMissing` in its `scripts/create-github-release.mjs`. The link-cli pipeline diverged from that template's behavior. Evidence: `templates/csharp-ai-driven-development-pipeline-template/create-github-release.mjs` snapshot (added in this PR). +- _This PR (#90)_: link-cli's `csharp/scripts/create-github-release.mjs` now prepends NuGet version and downloads badges that link to the exact released version. The existing `csharp-v2.4.0` release body was edited via `gh release edit` to backfill the badges. Evidence: `github-data/csharp-v2.4.0-release-after-fix.json`. ## Evidence -- Issue and PR data: `github-data/issue-88.json`, `github-data/issue-88-comments.json`, `github-data/pr-89.json`, `github-data/pr-89-comments.json`, `github-data/pr-89-review-comments.json`, `github-data/pr-89-reviews.json`. -- Release data: `github-data/csharp-v2.4.0-release.json`. +- Issue and PR data: `github-data/issue-88.json`, `github-data/issue-88-comments.json`, `github-data/pr-89.json`, `github-data/pr-89-comments.json`, `github-data/pr-89-review-comments.json`, `github-data/pr-89-reviews.json`, `github-data/pr-90.json`, `github-data/pr-90-comments.json`, `github-data/pr-90-review-comments.json`, `github-data/pr-90-reviews.json`. +- Release data: `github-data/csharp-v2.4.0-release.json` (before fix), `github-data/csharp-v2.4.0-release-after-fix.json` (after fix). - Probe headers: `logs/shields-filter-csharp-headers.txt`, `logs/shields-filter-rust-headers.txt`, `logs/releases-q-csharp-headers.txt`, `logs/releases-q-rust-headers.txt`, `logs/releases-tag-csharp-v2.4.0-headers.txt`. -- Template snapshots: `templates/csharp-template/README.md`, `templates/csharp-template/file-tree.txt`, `templates/js-template/README.md`, `templates/js-template/file-tree.txt`, `templates/rust-template/README.md`, `templates/rust-template/file-tree.txt`. +- Template snapshots: `templates/csharp-template/README.md`, `templates/csharp-template/file-tree.txt`, `templates/js-template/README.md`, `templates/js-template/file-tree.txt`, `templates/rust-template/README.md`, `templates/rust-template/file-tree.txt`, and `templates/csharp-ai-driven-development-pipeline-template/create-github-release.mjs` (the template script with the badge logic this PR ports). - Investigation timestamp: `github-data/investigation-timestamp.txt`. ## Online Facts - The shields.io documentation for the GitHub release badge confirms a `filter` query parameter that narrows the badge to tags matching a glob, used here to separate C# and Rust release lines on the same repository. Source: https://shields.io/badges/git-hub-release -- GitHub serves a `releases` page query parameter `q` that filters by release title text and an `expanded=true` parameter that opens the matched release inline at the top. The link-cli release titles are formed `C# v` and `Rust v`, so `q=C%23` and `q=Rust` exactly partition the list (confirmed by inspecting the rendered HTML). -- In Markdown the badge target is the URL inside the outer parentheses: `[![alt](badge-image)](target-url)`. Markdown cannot evaluate the badge image to derive the target, so the target URL must be set explicitly. This is why the fix is a static URL that always points to the latest release of the language that matches the badge filter. -- GitHub's `/releases/latest` redirect returns the single most recent release across the whole repository, regardless of any `filter`, so it would land on the wrong language about half of the time and is not appropriate for a per-language badge. +- The shields.io documentation for NuGet defines `https://img.shields.io/nuget/v/` (version) and `https://img.shields.io/nuget/dt/` (total downloads) endpoints. Source: https://shields.io/badges/nu-get +- A NuGet package's canonical landing page for a specific version is `https://www.nuget.org/packages//` (the unversioned `https://www.nuget.org/packages/` URL is the package home and always redirects to the latest version). Linking each release's badge to the version-specific URL keeps the click target in sync with the version the badge image renders for that release. +- GitHub serves a `releases` page query parameter `q` that filters by release title text and an `expanded=true` parameter that opens the matched release inline at the top. The link-cli release titles are formed `C# v` and `[Rust] `, so `q=C%23` and `q=Rust` exactly partition the list (confirmed by inspecting the rendered HTML). +- In Markdown the badge target is the URL inside the outer parentheses: `[![alt](badge-image)](target-url)`. Markdown cannot evaluate the badge image to derive the target, so the target URL must be set explicitly. ## Root Cause -The badge image and its Markdown link target were derived independently when the file was written. The badge image was configured with `filter=csharp-v*` so that it displays only C# tags. The Markdown link target, however, was the generic `/releases` URL, which lists every release in the repository (C# and Rust mixed). A user reading the badge "C# release v2.4.0" expects the click target to land on the C# v2.4.0 release card. Instead they land on a mixed page where the latest Rust release may be at the top. +There were two independent defects under the same issue: + +1. **README badge target (fixed by PR #89).** The badge image was configured with `filter=csharp-v*` so that it displays only C# tags, but its Markdown link target was the generic `/releases` URL, which lists every release in the repository (C# and Rust mixed). A user reading the badge "C# release v2.4.0" expects the click target to land on the C# v2.4.0 release card; instead they landed on a mixed page. -Markdown does not run JavaScript and cannot derive the target from the badge image, so a fix has to encode the language filter in the target URL itself. GitHub provides a stable equivalent of the shields.io `filter` parameter through the releases page query parameters `q=...` and `expanded=true`. +2. **Missing NuGet badges in C# release bodies (fixed by PR #90).** `csharp/scripts/create-github-release.mjs` built its release body from the changelog plus an optional `Package: ` footer and posted it via `gh api repos/.../releases`. It had **no** badge generation logic. The Rust counterpart `rust/scripts/create-github-release.rs` does prepend `[![Crates.io](...)](.../) [![Docs.rs](...)](.../)`, and the upstream `link-foundation/csharp-ai-driven-development-pipeline-template` template's `scripts/create-github-release.mjs` already defines `buildNuGetBadge` and `appendNuGetBadgeIfMissing`. The link-cli C# pipeline simply had not been kept in sync with the template, so C# releases had no NuGet badge. ## Solution -`README.md` line 8 and line 9: change the badge target URL. +### PR #89 — `README.md` badge target Before: @@ -68,34 +80,70 @@ After: [![Rust Release](https://img.shields.io/github/v/release/link-foundation/link-cli?filter=rust-v*&label=Rust%20release)](https://github.com/link-foundation/link-cli/releases?q=Rust&expanded=true) ``` -Result: clicking the C# release badge lands on `releases?q=C%23&expanded=true`, which displays C# releases only with the most recent expanded inline at the top of the page. Clicking the Rust badge does the same for Rust. The badge image already matches the same filter, so the version shown on the badge always matches the version expanded on the page. +### PR #90 — NuGet badges in the C# release body + +`csharp/scripts/create-github-release.mjs` now exports two helpers and uses them when building the release payload: + +```js +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}`; +} + +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}`; +} +``` + +Effect: when the workflow calls the script with `--package-id clink --release-version `, the release body begins with a clickable NuGet version badge and a NuGet downloads badge — both linking to `https://www.nuget.org/packages/clink/` — followed by the changelog excerpt and the `Package: clink` footer. The next C# release will automatically embed the badges. + +`csharp/scripts/release-scripts.test.mjs` covers: -This is the smallest change that fixes the defect, requires no workflow changes, no version pinning, and keeps the page in sync with the badge automatically when new releases are published. +- the dry-run end-to-end output includes both badges with the version-specific NuGet URL, +- the dry run **omits** badges when `--package-id` is absent, +- `buildNugetBadges` URL-encodes the package id and links both badges to the exact version, +- `prependNugetBadges` is a no-op when the body already contains a shields.io NuGet badge, and a no-op when either `packageId` or `version` is missing, +- `buildReleasePayload` places the badges above the changelog and the `Package:` footer. + +### Backfilling the existing release + +Because the published `csharp-v2.4.0` release was created before the fix, this PR also backfilled its body in place via `gh release edit csharp-v2.4.0 --notes-file .txt`. The post-fix release body is captured in `github-data/csharp-v2.4.0-release-after-fix.json`. ### Alternatives Considered -- Hard-coding `/releases/tag/csharp-v2.4.0` would link to the exact version on the badge today but would stale on every new release; it was rejected. -- Using `/releases/latest` would always link to a single newest release across the whole repository, ignoring the per-language `filter` on the badge image; it would point at a Rust release from a "C# release" badge whenever Rust released last; it was rejected. -- A custom redirector or workflow step that rewrites the README on every release would be more code to maintain for the same outcome and was rejected. +- Hard-coding `https://www.nuget.org/packages/` (no version segment) for the badge target. NuGet redirects that URL to the latest version, so on a 2.4.0 release page users would jump to whichever version is latest at click time. The version-segment form keeps the click target stable per release. +- Adding the badge only to the README and leaving the release body alone. The issue comment ("Still no NuGet badges in C# GitHub releases") specifically asks for badges in the release pages themselves, where the Rust releases already show them, so a README-only fix would not close the loop. +- Updating the release body manually for every C# release. The template pattern, and the Rust precedent in this repo, is to generate badges in the release script — automation prevents drift on future releases. ## Template Comparison -The templates referenced by the issue were inspected for the same badge defect: - -- `link-foundation/csharp-ai-driven-development-pipeline-template/README.md`: badges are `CI/CD Pipeline`, `.NET Version`, and `License`. No GitHub release badge. The `releases` page is not linked from the README. There is nothing to fix upstream for this exact defect. Evidence: `templates/csharp-template/README.md`. -- `link-foundation/js-ai-driven-development-pipeline-template/README.md`: no header badges at the top. No GitHub release badge. Nothing to fix upstream for this exact defect. Evidence: `templates/js-template/README.md`. -- `link-foundation/rust-ai-driven-development-pipeline-template/README.md`: badges are `CI/CD Pipeline`, `Crates.io`, `Docs.rs`, `Rust Version`, `Codecov`, `License`. No GitHub release badge. Nothing to fix upstream for this exact defect. Evidence: `templates/rust-template/README.md`. +The templates referenced by the issue were inspected for the same badge defects: -None of the templates publish multiple languages from the same repository, so they have no need for a per-language GitHub release badge with a `filter` parameter. The defect in link-cli is specific to the multi-language release model. +- `link-foundation/csharp-ai-driven-development-pipeline-template/scripts/create-github-release.mjs` _does_ already implement `buildNuGetBadge` and `appendNuGetBadgeIfMissing` and appends a NuGet badge to the release body. link-cli's C# release script had not been kept in sync — this PR ports the same idea (extended to a version + downloads pair, mirroring Rust's Crates.io + Docs.rs pair). A snapshot of the template script is preserved at `templates/csharp-ai-driven-development-pipeline-template/create-github-release.mjs`. No upstream change is required to the template for this defect. +- `link-foundation/js-ai-driven-development-pipeline-template/README.md`: no header badges at the top. Nothing to fix upstream for this exact defect. Evidence: `templates/js-template/README.md`. +- `link-foundation/rust-ai-driven-development-pipeline-template/README.md`: badges are `CI/CD Pipeline`, `Crates.io`, `Docs.rs`, `Rust Version`, `Codecov`, `License`. Nothing to fix upstream for this exact defect. Evidence: `templates/rust-template/README.md`. -The previous case study, `docs/case-studies/issue-86/README.md`, already pulled the full file tree of each template for the NuGet indexing investigation. Those trees were re-examined for this issue and confirm that no template README links the GitHub releases page in a way that would suffer from the same problem. +None of the templates publish multiple languages from the same repository, so they have no need for a per-language GitHub release badge with a `filter` parameter; the README defect in link-cli (PR #89) is specific to the multi-language release model. ## Upstream Reports -There is no defect to forward upstream for this issue. The template READMEs were re-read, and none of them produce a version badge that links to the wrong target. If a template later adds a per-language release badge with a `filter` parameter, the fix demonstrated here (`releases?q=&expanded=true`) should be applied in the template at that time. +There is no defect to forward upstream: + +- For the README badge target (PR #89), no template README produces a version badge that links to the wrong target. +- For the release-body NuGet badge (PR #90), the csharp template already implements the badge logic. link-cli's drift from the template is the actual defect, and this PR closes that drift. + +If a template later adds a per-language release badge with a `filter` parameter, the fix demonstrated here (`releases?q=&expanded=true`) should be applied in the template at that time. ## Validation - Manual: probe `https://github.com/link-foundation/link-cli/releases?q=C%23&expanded=true` and confirm the C# v2.4.0 card is the only one returned and is expanded. Evidence: `logs/releases-q-csharp-headers.txt`. - Manual: probe `https://github.com/link-foundation/link-cli/releases?q=Rust&expanded=true` and confirm only Rust releases are returned. Evidence: `logs/releases-q-rust-headers.txt`. +- Automated: `node --test csharp/scripts/release-scripts.test.mjs` — six new assertions cover the NuGet badge generation, the dry-run output, the absence of badges when there is no package id, and the order of badges/notes/footer in the payload. - Visual: open the rendered README on the branch and click the C# release badge. The browser navigates to the filtered C# releases page with the latest version expanded. +- Visual: open `https://github.com/link-foundation/link-cli/releases/tag/csharp-v2.4.0` and confirm two clickable NuGet badges (version + downloads) appear at the top of the release body and link to `https://www.nuget.org/packages/clink/2.4.0`. diff --git a/docs/case-studies/issue-88/github-data/csharp-v2.4.0-release-after-fix.json b/docs/case-studies/issue-88/github-data/csharp-v2.4.0-release-after-fix.json new file mode 100644 index 0000000..89ed2c9 --- /dev/null +++ b/docs/case-studies/issue-88/github-data/csharp-v2.4.0-release-after-fix.json @@ -0,0 +1 @@ +{"body":"[![NuGet](https://img.shields.io/nuget/v/clink?logo=nuget&label=NuGet)](https://www.nuget.org/packages/clink/2.4.0) [![NuGet Downloads](https://img.shields.io/nuget/dt/clink?logo=nuget&label=downloads)](https://www.nuget.org/packages/clink/2.4.0)\n\nAdded `--export` as an alias for `--out` database export.\n\nAdded `--in`/`--lino-input`/`--import` database import support for reading LiNo files into the links database with named references enabled by default.\n\nAdded `--out`/`--lino-output` database export support that writes the complete links database as LiNo with named references when available.\n\nAdded a universal `NamedTypesDecorator` that implements both links operations and named type lookups, with automatic cleanup and uniqueness checks for external-reference names.\n\nAdded binary links-backed persistent transformation triggers with `--always`, `--once`, `--never`, `--triggers-file`, and `--embed-triggers`.\n\nAdded `IPinnedTypes` and `PinnedTypesDecorator`, and composed pinned type support into `NamedTypesDecorator`.\n\nFixed self-link substitution with outgoing links by preserving unbound substitution parts from the matched link and rejecting unsupported link addresses during explicit creation.\n\nFixed explicit indexed numeric updates so auto-created numeric references do not steal the substitution pair, and added issue 62 regression coverage.\n\nMoved C# release automation into `csharp/scripts/` and packaged the C# README\nwith the NuGet tool.\n\nAdded full string ID alias support for advanced LiNo queries through the named types decorator.\n\nUpdated the C# LiNo parser dependency to the current `Link.Foundation.Links.Notation` package and refreshed supported NuGet package versions.\n\nAdded strict validation for missing numeric and named link references, plus `--auto-create-missing-references` to create missing references as self-referential point links.\n\nPackage: `clink`\n","createdAt":"2026-05-12T19:34:20Z","name":"C# v2.4.0","publishedAt":"2026-05-12T21:54:13Z","tagName":"csharp-v2.4.0","url":"https://github.com/link-foundation/link-cli/releases/tag/csharp-v2.4.0"} diff --git a/docs/case-studies/issue-88/github-data/issue-88-comments.json b/docs/case-studies/issue-88/github-data/issue-88-comments.json index 0637a08..4da6d38 100644 --- a/docs/case-studies/issue-88/github-data/issue-88-comments.json +++ b/docs/case-studies/issue-88/github-data/issue-88-comments.json @@ -1 +1 @@ -[] \ No newline at end of file +[{"url":"https://api.github.com/repos/link-foundation/link-cli/issues/comments/4435411357","html_url":"https://github.com/link-foundation/link-cli/issues/88#issuecomment-4435411357","issue_url":"https://api.github.com/repos/link-foundation/link-cli/issues/88","id":4435411357,"node_id":"IC_kwDONXCAbs8AAAABCF8BnQ","user":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"created_at":"2026-05-12T22:38:29Z","updated_at":"2026-05-12T22:38:29Z","body":"Still no NuGet badges in C# GitHub releases.","author_association":"MEMBER","pin":null,"reactions":{"url":"https://api.github.com/repos/link-foundation/link-cli/issues/comments/4435411357/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"performed_via_github_app":null}] \ No newline at end of file diff --git a/docs/case-studies/issue-88/github-data/pr-90-comments.json b/docs/case-studies/issue-88/github-data/pr-90-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/case-studies/issue-88/github-data/pr-90-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/case-studies/issue-88/github-data/pr-90-review-comments.json b/docs/case-studies/issue-88/github-data/pr-90-review-comments.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/case-studies/issue-88/github-data/pr-90-review-comments.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/case-studies/issue-88/github-data/pr-90-reviews.json b/docs/case-studies/issue-88/github-data/pr-90-reviews.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/case-studies/issue-88/github-data/pr-90-reviews.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/case-studies/issue-88/github-data/pr-90.json b/docs/case-studies/issue-88/github-data/pr-90.json new file mode 100644 index 0000000..ea721e4 --- /dev/null +++ b/docs/case-studies/issue-88/github-data/pr-90.json @@ -0,0 +1 @@ +{"url":"https://api.github.com/repos/link-foundation/link-cli/pulls/90","id":3672105223,"node_id":"PR_kwDONXCAbs7a3-EH","html_url":"https://github.com/link-foundation/link-cli/pull/90","diff_url":"https://github.com/link-foundation/link-cli/pull/90.diff","patch_url":"https://github.com/link-foundation/link-cli/pull/90.patch","issue_url":"https://api.github.com/repos/link-foundation/link-cli/issues/90","number":90,"state":"open","locked":false,"title":"[WIP] C# release has no badge to its specific version","user":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"body":"## 🤖 AI-Powered Solution Draft\n\nThis pull request is being automatically generated to solve issue #88.\n\n### 📋 Issue Reference\nFixes #88\n\n### 🚧 Status\n**Work in Progress** - The AI assistant is currently analyzing and implementing the solution draft.\n\n### 📝 Implementation Details\n_Details will be added as the solution draft is developed..._\n\n---\n*This PR was created automatically by the AI issue solver*","created_at":"2026-05-12T22:39:17Z","updated_at":"2026-05-12T22:39:18Z","closed_at":null,"merged_at":null,"merge_commit_sha":"e73cba2bc78458e48c065de71cb69383484dcd44","assignees":[{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false}],"requested_reviewers":[],"requested_teams":[],"labels":[],"milestone":null,"draft":true,"commits_url":"https://api.github.com/repos/link-foundation/link-cli/pulls/90/commits","review_comments_url":"https://api.github.com/repos/link-foundation/link-cli/pulls/90/comments","review_comment_url":"https://api.github.com/repos/link-foundation/link-cli/pulls/comments{/number}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/issues/90/comments","statuses_url":"https://api.github.com/repos/link-foundation/link-cli/statuses/1ea00b94d8e177b72468bbde435df763508ede2b","head":{"label":"link-foundation:issue-88-ba6db304f470","ref":"issue-88-ba6db304f470","sha":"1ea00b94d8e177b72468bbde435df763508ede2b","user":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"repo":{"id":896565358,"node_id":"R_kgDONXCAbg","name":"link-cli","full_name":"link-foundation/link-cli","private":false,"owner":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/link-foundation/link-cli","description":"A CLI tool to manipulate links.","fork":false,"url":"https://api.github.com/repos/link-foundation/link-cli","forks_url":"https://api.github.com/repos/link-foundation/link-cli/forks","keys_url":"https://api.github.com/repos/link-foundation/link-cli/keys{/key_id}","collaborators_url":"https://api.github.com/repos/link-foundation/link-cli/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/link-foundation/link-cli/teams","hooks_url":"https://api.github.com/repos/link-foundation/link-cli/hooks","issue_events_url":"https://api.github.com/repos/link-foundation/link-cli/issues/events{/number}","events_url":"https://api.github.com/repos/link-foundation/link-cli/events","assignees_url":"https://api.github.com/repos/link-foundation/link-cli/assignees{/user}","branches_url":"https://api.github.com/repos/link-foundation/link-cli/branches{/branch}","tags_url":"https://api.github.com/repos/link-foundation/link-cli/tags","blobs_url":"https://api.github.com/repos/link-foundation/link-cli/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/link-foundation/link-cli/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/link-foundation/link-cli/git/refs{/sha}","trees_url":"https://api.github.com/repos/link-foundation/link-cli/git/trees{/sha}","statuses_url":"https://api.github.com/repos/link-foundation/link-cli/statuses/{sha}","languages_url":"https://api.github.com/repos/link-foundation/link-cli/languages","stargazers_url":"https://api.github.com/repos/link-foundation/link-cli/stargazers","contributors_url":"https://api.github.com/repos/link-foundation/link-cli/contributors","subscribers_url":"https://api.github.com/repos/link-foundation/link-cli/subscribers","subscription_url":"https://api.github.com/repos/link-foundation/link-cli/subscription","commits_url":"https://api.github.com/repos/link-foundation/link-cli/commits{/sha}","git_commits_url":"https://api.github.com/repos/link-foundation/link-cli/git/commits{/sha}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/comments{/number}","issue_comment_url":"https://api.github.com/repos/link-foundation/link-cli/issues/comments{/number}","contents_url":"https://api.github.com/repos/link-foundation/link-cli/contents/{+path}","compare_url":"https://api.github.com/repos/link-foundation/link-cli/compare/{base}...{head}","merges_url":"https://api.github.com/repos/link-foundation/link-cli/merges","archive_url":"https://api.github.com/repos/link-foundation/link-cli/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/link-foundation/link-cli/downloads","issues_url":"https://api.github.com/repos/link-foundation/link-cli/issues{/number}","pulls_url":"https://api.github.com/repos/link-foundation/link-cli/pulls{/number}","milestones_url":"https://api.github.com/repos/link-foundation/link-cli/milestones{/number}","notifications_url":"https://api.github.com/repos/link-foundation/link-cli/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/link-foundation/link-cli/labels{/name}","releases_url":"https://api.github.com/repos/link-foundation/link-cli/releases{/id}","deployments_url":"https://api.github.com/repos/link-foundation/link-cli/deployments","created_at":"2024-11-30T17:46:38Z","updated_at":"2026-05-12T22:15:24Z","pushed_at":"2026-05-12T22:39:11Z","git_url":"git://github.com/link-foundation/link-cli.git","ssh_url":"git@github.com:link-foundation/link-cli.git","clone_url":"https://github.com/link-foundation/link-cli.git","svn_url":"https://github.com/link-foundation/link-cli","homepage":"https://link-foundation.github.io/link-cli/","size":4564,"stargazers_count":10,"watchers_count":10,"language":"Rust","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":true,"has_discussions":false,"forks_count":1,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":20,"license":{"key":"unlicense","name":"The Unlicense","spdx_id":"Unlicense","url":"https://api.github.com/licenses/unlicense","node_id":"MDc6TGljZW5zZTE1"},"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"has_pull_requests":true,"pull_request_creation_policy":"all","topics":[],"visibility":"public","forks":1,"open_issues":20,"watchers":10,"default_branch":"main"}},"base":{"label":"link-foundation:main","ref":"main","sha":"af580622e160aeb35b5fad1b5de6e46d6bc5976a","user":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"repo":{"id":896565358,"node_id":"R_kgDONXCAbg","name":"link-cli","full_name":"link-foundation/link-cli","private":false,"owner":{"login":"link-foundation","id":176174013,"node_id":"O_kgDOCoAzvQ","avatar_url":"https://avatars.githubusercontent.com/u/176174013?v=4","gravatar_id":"","url":"https://api.github.com/users/link-foundation","html_url":"https://github.com/link-foundation","followers_url":"https://api.github.com/users/link-foundation/followers","following_url":"https://api.github.com/users/link-foundation/following{/other_user}","gists_url":"https://api.github.com/users/link-foundation/gists{/gist_id}","starred_url":"https://api.github.com/users/link-foundation/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/link-foundation/subscriptions","organizations_url":"https://api.github.com/users/link-foundation/orgs","repos_url":"https://api.github.com/users/link-foundation/repos","events_url":"https://api.github.com/users/link-foundation/events{/privacy}","received_events_url":"https://api.github.com/users/link-foundation/received_events","type":"Organization","user_view_type":"public","site_admin":false},"html_url":"https://github.com/link-foundation/link-cli","description":"A CLI tool to manipulate links.","fork":false,"url":"https://api.github.com/repos/link-foundation/link-cli","forks_url":"https://api.github.com/repos/link-foundation/link-cli/forks","keys_url":"https://api.github.com/repos/link-foundation/link-cli/keys{/key_id}","collaborators_url":"https://api.github.com/repos/link-foundation/link-cli/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/link-foundation/link-cli/teams","hooks_url":"https://api.github.com/repos/link-foundation/link-cli/hooks","issue_events_url":"https://api.github.com/repos/link-foundation/link-cli/issues/events{/number}","events_url":"https://api.github.com/repos/link-foundation/link-cli/events","assignees_url":"https://api.github.com/repos/link-foundation/link-cli/assignees{/user}","branches_url":"https://api.github.com/repos/link-foundation/link-cli/branches{/branch}","tags_url":"https://api.github.com/repos/link-foundation/link-cli/tags","blobs_url":"https://api.github.com/repos/link-foundation/link-cli/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/link-foundation/link-cli/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/link-foundation/link-cli/git/refs{/sha}","trees_url":"https://api.github.com/repos/link-foundation/link-cli/git/trees{/sha}","statuses_url":"https://api.github.com/repos/link-foundation/link-cli/statuses/{sha}","languages_url":"https://api.github.com/repos/link-foundation/link-cli/languages","stargazers_url":"https://api.github.com/repos/link-foundation/link-cli/stargazers","contributors_url":"https://api.github.com/repos/link-foundation/link-cli/contributors","subscribers_url":"https://api.github.com/repos/link-foundation/link-cli/subscribers","subscription_url":"https://api.github.com/repos/link-foundation/link-cli/subscription","commits_url":"https://api.github.com/repos/link-foundation/link-cli/commits{/sha}","git_commits_url":"https://api.github.com/repos/link-foundation/link-cli/git/commits{/sha}","comments_url":"https://api.github.com/repos/link-foundation/link-cli/comments{/number}","issue_comment_url":"https://api.github.com/repos/link-foundation/link-cli/issues/comments{/number}","contents_url":"https://api.github.com/repos/link-foundation/link-cli/contents/{+path}","compare_url":"https://api.github.com/repos/link-foundation/link-cli/compare/{base}...{head}","merges_url":"https://api.github.com/repos/link-foundation/link-cli/merges","archive_url":"https://api.github.com/repos/link-foundation/link-cli/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/link-foundation/link-cli/downloads","issues_url":"https://api.github.com/repos/link-foundation/link-cli/issues{/number}","pulls_url":"https://api.github.com/repos/link-foundation/link-cli/pulls{/number}","milestones_url":"https://api.github.com/repos/link-foundation/link-cli/milestones{/number}","notifications_url":"https://api.github.com/repos/link-foundation/link-cli/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/link-foundation/link-cli/labels{/name}","releases_url":"https://api.github.com/repos/link-foundation/link-cli/releases{/id}","deployments_url":"https://api.github.com/repos/link-foundation/link-cli/deployments","created_at":"2024-11-30T17:46:38Z","updated_at":"2026-05-12T22:15:24Z","pushed_at":"2026-05-12T22:39:11Z","git_url":"git://github.com/link-foundation/link-cli.git","ssh_url":"git@github.com:link-foundation/link-cli.git","clone_url":"https://github.com/link-foundation/link-cli.git","svn_url":"https://github.com/link-foundation/link-cli","homepage":"https://link-foundation.github.io/link-cli/","size":4564,"stargazers_count":10,"watchers_count":10,"language":"Rust","has_issues":true,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":true,"has_discussions":false,"forks_count":1,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":20,"license":{"key":"unlicense","name":"The Unlicense","spdx_id":"Unlicense","url":"https://api.github.com/licenses/unlicense","node_id":"MDc6TGljZW5zZTE1"},"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"has_pull_requests":true,"pull_request_creation_policy":"all","topics":[],"visibility":"public","forks":1,"open_issues":20,"watchers":10,"default_branch":"main"}},"_links":{"self":{"href":"https://api.github.com/repos/link-foundation/link-cli/pulls/90"},"html":{"href":"https://github.com/link-foundation/link-cli/pull/90"},"issue":{"href":"https://api.github.com/repos/link-foundation/link-cli/issues/90"},"comments":{"href":"https://api.github.com/repos/link-foundation/link-cli/issues/90/comments"},"review_comments":{"href":"https://api.github.com/repos/link-foundation/link-cli/pulls/90/comments"},"review_comment":{"href":"https://api.github.com/repos/link-foundation/link-cli/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/link-foundation/link-cli/pulls/90/commits"},"statuses":{"href":"https://api.github.com/repos/link-foundation/link-cli/statuses/1ea00b94d8e177b72468bbde435df763508ede2b"}},"author_association":"MEMBER","auto_merge":null,"assignee":{"login":"konard","id":1431904,"node_id":"MDQ6VXNlcjE0MzE5MDQ=","avatar_url":"https://avatars.githubusercontent.com/u/1431904?v=4","gravatar_id":"","url":"https://api.github.com/users/konard","html_url":"https://github.com/konard","followers_url":"https://api.github.com/users/konard/followers","following_url":"https://api.github.com/users/konard/following{/other_user}","gists_url":"https://api.github.com/users/konard/gists{/gist_id}","starred_url":"https://api.github.com/users/konard/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/konard/subscriptions","organizations_url":"https://api.github.com/users/konard/orgs","repos_url":"https://api.github.com/users/konard/repos","events_url":"https://api.github.com/users/konard/events{/privacy}","received_events_url":"https://api.github.com/users/konard/received_events","type":"User","user_view_type":"public","site_admin":false},"active_lock_reason":null,"merged":false,"mergeable":true,"rebaseable":true,"mergeable_state":"clean","merged_by":null,"comments":0,"review_comments":0,"maintainer_can_modify":false,"commits":1,"additions":2,"deletions":1,"changed_files":1} \ No newline at end of file diff --git a/docs/case-studies/issue-88/templates/csharp-ai-driven-development-pipeline-template/create-github-release.mjs b/docs/case-studies/issue-88/templates/csharp-ai-driven-development-pipeline-template/create-github-release.mjs new file mode 100644 index 0000000..75a90d3 --- /dev/null +++ b/docs/case-studies/issue-88/templates/csharp-ai-driven-development-pipeline-template/create-github-release.mjs @@ -0,0 +1,504 @@ +#!/usr/bin/env bun + +/** + * Create GitHub Release from CHANGELOG.md + * Usage: bun run scripts/create-github-release.mjs --release-version --repository [--tag-prefix ] [--language ] [--package-id ] [--assets-glob ] + */ + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + readFileSync, + readdirSync, + statSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const USAGE = + 'Usage: bun run scripts/create-github-release.mjs --release-version --repository [--tag-prefix ] [--language ] [--package-id ] [--assets-glob ]'; + +/** + * Parse CLI arguments. + * @param {string[]} argv + * @param {NodeJS.ProcessEnv} env + * @returns {{assetsGlob: string, releaseVersion: string, repository: string, tagPrefix: string, language: string, packageId: string}} + */ +export function parseArgs(argv, env = process.env) { + const config = { + assetsGlob: env.ASSETS_GLOB ?? '', + language: env.LANGUAGE ?? 'C#', + packageId: env.PACKAGE_ID ?? '', + releaseVersion: env.VERSION ?? '', + repository: env.REPOSITORY ?? '', + tagPrefix: env.TAG_PREFIX ?? 'csharp_v', + }; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + + if (arg === '--release-version' || arg === '--version') { + config.releaseVersion = readOptionValue(argv, index, arg); + index++; + } else if (arg.startsWith('--release-version=')) { + config.releaseVersion = arg.slice('--release-version='.length); + } else if (arg.startsWith('--version=')) { + config.releaseVersion = arg.slice('--version='.length); + } else if (arg === '--repository') { + config.repository = readOptionValue(argv, index, arg); + index++; + } else if (arg.startsWith('--repository=')) { + config.repository = arg.slice('--repository='.length); + } else if (arg === '--tag-prefix') { + config.tagPrefix = readOptionValue(argv, index, arg); + index++; + } else if (arg.startsWith('--tag-prefix=')) { + config.tagPrefix = arg.slice('--tag-prefix='.length); + } else if (arg === '--language') { + config.language = readOptionValue(argv, index, arg); + index++; + } else if (arg.startsWith('--language=')) { + config.language = arg.slice('--language='.length); + } else if (arg === '--package-id') { + config.packageId = readOptionValue(argv, index, arg); + index++; + } else if (arg.startsWith('--package-id=')) { + config.packageId = arg.slice('--package-id='.length); + } else if (arg === '--assets-glob') { + config.assetsGlob = readOptionValue(argv, index, arg); + index++; + } else if (arg.startsWith('--assets-glob=')) { + config.assetsGlob = arg.slice('--assets-glob='.length); + } + } + + return config; +} + +/** + * Read a CLI option value. + * @param {string[]} argv + * @param {number} index + * @param {string} optionName + * @returns {string} + */ +function readOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + + if (value === undefined || value.startsWith('--')) { + throw new Error(`Missing value for ${optionName}`); + } + + return value; +} + +/** + * Escape text for a regular expression. + * @param {string} value + * @returns {string} + */ +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Normalize release versions to bare semver. + * @param {string} releaseVersion + * @returns {string} + */ +export function normalizeReleaseVersion(releaseVersion) { + const trimmedVersion = String(releaseVersion ?? '').trim(); + const semverTagMatch = trimmedVersion.match( + /(?:^|[-_])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i + ); + + if (semverTagMatch) { + return semverTagMatch[1]; + } + + return trimmedVersion + .replace(/^[A-Za-z][A-Za-z0-9]*[-_]/, '') + .replace(/^v/i, ''); +} + +/** + * Build a release tag. + * @param {string} tagPrefix + * @param {string} releaseVersion + * @returns {string} + */ +export function buildReleaseTag(tagPrefix, releaseVersion) { + return `${tagPrefix}${normalizeReleaseVersion(releaseVersion)}`; +} + +/** + * Build a release title. + * @param {string} language + * @param {string} releaseVersion + * @returns {string} + */ +export function buildReleaseTitle(language, releaseVersion) { + const releaseLanguage = language.trim() || 'C#'; + return `[${releaseLanguage}] ${normalizeReleaseVersion(releaseVersion)}`; +} + +/** + * Build a NuGet badge markdown link. + * @param {string} packageId + * @returns {string} + */ +export function buildNuGetBadge(packageId) { + const encodedPackageId = encodeURIComponent(packageId); + return `[![NuGet](https://img.shields.io/nuget/v/${encodedPackageId}.svg)](https://www.nuget.org/packages/${encodedPackageId})`; +} + +/** + * Append a NuGet badge unless release notes already include a shields.io badge. + * @param {string} releaseNotes + * @param {string} packageId + * @returns {string} + */ +export function appendNuGetBadgeIfMissing(releaseNotes, packageId) { + if (!packageId || /img\.shields\.io/i.test(releaseNotes)) { + return releaseNotes; + } + + return `${releaseNotes}\n\n---\n\n${buildNuGetBadge(packageId)}`; +} + +/** + * Extract changelog content for a specific version + * @param {string} changelog + * @param {string} version + * @returns {string} + */ +export function extractReleaseNotes(changelog, version) { + const semver = normalizeReleaseVersion(version); + + // Find the section for this version + const escapedVersion = escapeRegex(semver); + const pattern = new RegExp( + `(?:^|\\n)## \\[?${escapedVersion}\\]?[^\\n]*\\n([\\s\\S]*?)(?=\\n## \\[?\\d|$)` + ); + const match = changelog.match(pattern); + + if (match) { + const releaseNotes = match[1].trim(); + return releaseNotes || `Release ${semver}`; + } + + return `Release ${semver}`; +} + +/** + * Find a package id by scanning project files. + * @param {string} rootDir + * @returns {string} + */ +export function findPackageId(rootDir = '.') { + const candidates = []; + + walkProjectFiles(rootDir, candidates); + + for (const csprojPath of candidates) { + const csproj = readFileSync(csprojPath, 'utf-8'); + const packageIdMatch = csproj.match(/([^<]+)<\/PackageId>/); + if (packageIdMatch) { + return packageIdMatch[1].trim(); + } + + const assemblyNameMatch = csproj.match( + /([^<]+)<\/AssemblyName>/ + ); + if (assemblyNameMatch) { + return assemblyNameMatch[1].trim(); + } + } + + if (candidates.length > 0) { + return path.basename(candidates[0], '.csproj'); + } + + return ''; +} + +/** + * Walk project files under a root directory. + * @param {string} dir + * @param {string[]} candidates + * @param {number} depth + */ +function walkProjectFiles(dir, candidates, depth = 0) { + if (depth > 4) { + return; + } + + let entries; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if ( + entry === '.git' || + entry === 'bin' || + entry === 'obj' || + entry === 'node_modules' + ) { + continue; + } + + const fullPath = path.join(dir, entry); + let stat; + try { + stat = statSync(fullPath); + } catch { + continue; + } + + if (stat.isDirectory()) { + walkProjectFiles(fullPath, candidates, depth + 1); + } else if (fullPath.endsWith('.csproj')) { + candidates.push(fullPath); + } + } +} + +/** + * Build the GitHub release API payload. + * @param {{changelog: string, language: string, packageId: string, releaseVersion: string, tagPrefix: string}} options + * @returns {string} + */ +export function buildReleasePayload({ + changelog, + language, + packageId, + releaseVersion, + tagPrefix, +}) { + const semver = normalizeReleaseVersion(releaseVersion); + const releaseNotes = appendNuGetBadgeIfMissing( + extractReleaseNotes(changelog, semver), + packageId + ); + + return JSON.stringify({ + tag_name: buildReleaseTag(tagPrefix, semver), + name: buildReleaseTitle(language, semver), + body: releaseNotes, + }); +} + +/** + * Create a GitHub release using gh. + * @param {{payload: string, repository: string, spawn?: typeof spawnSync}} options + * @returns {{alreadyExists: boolean}} + */ +export function createRelease({ payload, repository, spawn = spawnSync }) { + const result = spawn( + 'gh', + ['api', `repos/${repository}/releases`, '-X', 'POST', '--input', '-'], + { + encoding: 'utf-8', + input: payload, + } + ); + + if (result.error) { + throw new Error(`gh api failed to start: ${result.error.message}`); + } + + if (result.status === 0) { + return { alreadyExists: false }; + } + + const output = [result.stderr, result.stdout] + .filter((value) => typeof value === 'string' && value.trim()) + .join('\n'); + + if (/already_exists|already exists/i.test(output)) { + return { alreadyExists: true }; + } + + throw new Error(`gh api failed with code ${result.status}: ${output}`); +} + +/** + * Resolve a simple release asset glob. + * + * Supports exact file paths or `*` in the file name portion, such as + * `artifacts/*.nupkg`. Matches are returned in deterministic path order. + * + * @param {string} assetsGlob + * @param {string} cwd + * @returns {string[]} + */ +export function resolveReleaseAssets(assetsGlob, cwd = '.') { + const pattern = String(assetsGlob ?? '').trim(); + if (!pattern) { + return []; + } + + const absolutePattern = path.isAbsolute(pattern) + ? pattern + : path.resolve(cwd, pattern); + const assetDirectory = path.dirname(absolutePattern); + const filePattern = path.basename(absolutePattern); + + if (!filePattern.includes('*')) { + try { + return existsSync(absolutePattern) && statSync(absolutePattern).isFile() + ? [absolutePattern] + : []; + } catch { + return []; + } + } + + let entries; + try { + entries = readdirSync(assetDirectory, { withFileTypes: true }); + } catch { + return []; + } + + const filePatternRegex = new RegExp( + `^${escapeRegex(filePattern).replace(/\\\*/g, '.*')}$` + ); + + return entries + .filter((entry) => entry.isFile() && filePatternRegex.test(entry.name)) + .map((entry) => path.join(assetDirectory, entry.name)) + .sort(); +} + +/** + * Upload release assets using gh. + * @param {{assetPaths: string[], repository: string, tag: string, spawn?: typeof spawnSync}} options + * @returns {void} + */ +export function uploadReleaseAssets({ + assetPaths, + repository, + tag, + spawn = spawnSync, +}) { + if (assetPaths.length === 0) { + return; + } + + const result = spawn( + 'gh', + [ + 'release', + 'upload', + tag, + ...assetPaths, + '--clobber', + '--repo', + repository, + ], + { encoding: 'utf-8' } + ); + + if (result.error) { + throw new Error(`gh release upload failed to start: ${result.error.message}`); + } + + if (result.status !== 0) { + const output = [result.stderr, result.stdout] + .filter((value) => typeof value === 'string' && value.trim()) + .join('\n'); + + throw new Error( + `gh release upload failed with code ${result.status}: ${output}` + ); + } +} + +/** + * Run the CLI. + * @param {{argv?: string[], cwd?: string, env?: NodeJS.ProcessEnv, spawn?: typeof spawnSync, stderr?: typeof console.error, stdout?: typeof console.log}} options + * @returns {number} + */ +export function main({ + argv = process.argv.slice(2), + cwd = process.cwd(), + env = process.env, + spawn = spawnSync, + stderr = console.error, + stdout = console.log, +} = {}) { + try { + const { + assetsGlob, + language, + packageId, + releaseVersion, + repository, + tagPrefix, + } = + parseArgs(argv, env); + + if (!releaseVersion || !repository) { + stderr('Error: Missing required arguments'); + stderr(USAGE); + return 1; + } + + const changelogPath = path.join(cwd, 'CHANGELOG.md'); + const changelog = existsSync(changelogPath) + ? readFileSync(changelogPath, 'utf-8') + : ''; + const resolvedPackageId = packageId || findPackageId(cwd); + const tag = buildReleaseTag(tagPrefix, releaseVersion); + const payload = buildReleasePayload({ + changelog, + language, + packageId: resolvedPackageId, + releaseVersion, + tagPrefix, + }); + + stdout(`Creating GitHub release for ${tag}...`); + + const result = createRelease({ payload, repository, spawn }); + + if (result.alreadyExists) { + stdout(`GitHub release already exists: ${tag}, reconciling assets`); + } else { + stdout(`Created GitHub release: ${tag}`); + } + + if (assetsGlob) { + const assetPaths = resolveReleaseAssets(assetsGlob, cwd); + + if (assetPaths.length === 0) { + throw new Error(`No release assets matched ${assetsGlob}`); + } + + stdout(`Uploading ${assetPaths.length} release asset(s) to ${tag}...`); + uploadReleaseAssets({ assetPaths, repository, spawn, tag }); + stdout(`Uploaded ${assetPaths.length} release asset(s) to ${tag}`); + } + + return 0; + } catch (error) { + stderr(`Error creating release: ${error.message}`); + return 1; + } +} + +function isCliEntryPoint() { + return ( + typeof process !== 'undefined' && + process.argv?.[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) + ); +} + +if (isCliEntryPoint()) { + process.exitCode = main(); +}