diff --git a/.github/publish-packages.json b/.github/publish-packages.json index ee70c046..00d8e164 100644 --- a/.github/publish-packages.json +++ b/.github/publish-packages.json @@ -6,7 +6,9 @@ "versionFile": "src/package.json", "tagPrefix": "compose", "needsFoundry": true, - "check": "npm run compose@check && npm run compose@pack:check" + "check": "npm run compose@check && npm run compose@pack:check", + "releaseNotesPaths": ["src", "test", "foundry.toml", "CHANGELOG.md"], + "releaseNotesMode": "pr" }, { "id": "cli", @@ -14,7 +16,9 @@ "versionFile": "cli/package.json", "tagPrefix": "compose-cli", "needsFoundry": false, - "check": "npm run cli@check && npm run cli@pack:check" + "check": "npm run cli@check && npm run cli@pack:check", + "releaseNotesPaths": ["cli"], + "releaseNotesMode": "pr" } ] } diff --git a/.github/scripts/publish-plan.js b/.github/scripts/publish-plan.js index 747a7bac..cf886c3d 100644 --- a/.github/scripts/publish-plan.js +++ b/.github/scripts/publish-plan.js @@ -53,6 +53,19 @@ function validateEntries(packages) { if (typeof p.needsFoundry !== 'boolean') { throw new Error(`needsFoundry must be boolean for id "${p.id}"`); } + if ('releaseNotesPaths' in p) { + if (!Array.isArray(p.releaseNotesPaths) || p.releaseNotesPaths.length === 0) { + throw new Error(`releaseNotesPaths must be a non-empty string array for id "${p.id}"`); + } + for (const rp of p.releaseNotesPaths) { + if (typeof rp !== 'string' || !rp.trim()) { + throw new Error(`releaseNotesPaths entries must be non-empty strings for id "${p.id}"`); + } + } + } + if ('releaseNotesMode' in p && p.releaseNotesMode !== 'pr' && p.releaseNotesMode !== 'commits') { + throw new Error(`releaseNotesMode must be "pr" or "commits" for id "${p.id}"`); + } if (ids.has(p.id)) { throw new Error(`Duplicate id: ${p.id}`); } @@ -82,14 +95,19 @@ function main() { console.log(`${p.id}: package.json=${localVer} npm=${npmVer || ''}`); if (needsPublish) { - include.push({ + const entry = { id: p.id, workspace: p.workspace, versionFile: p.versionFile, tagPrefix: p.tagPrefix, needsFoundry: p.needsFoundry, check: p.check, - }); + }; + if (p.releaseNotesPaths) { + entry.releaseNotesPaths = p.releaseNotesPaths; + } + entry.releaseNotesMode = p.releaseNotesMode === 'commits' ? 'commits' : 'pr'; + include.push(entry); } } diff --git a/.github/scripts/release-notes-tags.js b/.github/scripts/release-notes-tags.js new file mode 100644 index 00000000..b6474827 --- /dev/null +++ b/.github/scripts/release-notes-tags.js @@ -0,0 +1,40 @@ +/** + * Shared helpers for release note tag ranges (same tagPrefix family). + */ + +const { execFileSync } = require('child_process'); + +function execGit(args) { + return execFileSync('git', args, { encoding: 'utf8' }).trim(); +} + +/** + * Lists tags matching `${tagPrefix}@*`, newest first (Git version sort). + */ +function listTagsForPrefix(tagPrefix) { + const pattern = `${tagPrefix}@*`; + try { + return execGit(['tag', '-l', pattern, '--sort=-version:refname']) + .split('\n') + .filter(Boolean); + } catch { + return []; + } +} + +/** + * Returns the older tag adjacent to `currentTag` in the sorted list, or null. + */ +function previousTag(tags, currentTag) { + const i = tags.indexOf(currentTag); + if (i === -1 || i >= tags.length - 1) { + return null; + } + return tags[i + 1]; +} + +module.exports = { + execGit, + listTagsForPrefix, + previousTag, +}; diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js new file mode 100644 index 00000000..76e146a0 --- /dev/null +++ b/.github/scripts/release-notes.js @@ -0,0 +1,277 @@ +/** + * Prints Markdown release notes for a Git tag. + * + * Modes (RELEASE_NOTES_MODE or 4th argv: "pr" | "commits"): + * - pr: merged PRs linked to commits in range, filtered by changed files vs releaseNotesPaths + * - commits: path-filtered git log (escape hatch) + * + * Usage: + * node release-notes.js [mode] + * Env: GITHUB_REPOSITORY=owner/name, RELEASE_NOTES_MODE, GH_TOKEN or GITHUB_TOKEN + */ + +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const { + execGit, + listTagsForPrefix, + previousTag, +} = require('./release-notes-tags.js'); + +const execFileAsync = promisify(execFile); + +/** + * Path rules match git pathspec-style prefixes: exact file or directory prefix. + * e.g. "cli" matches "cli/foo"; "foundry.toml" matches only root foundry.toml. + */ +function matchesReleasePath(filePath, rules) { + const p = filePath.replace(/\\/g, '/'); + for (const r of rules) { + const rule = r.replace(/\\/g, '/'); + if (p === rule || p.startsWith(`${rule}/`)) { + return true; + } + } + return false; +} + +function parseRepo() { + const raw = process.env.GITHUB_REPOSITORY; + if (!raw || !raw.includes('/')) { + throw new Error( + 'GITHUB_REPOSITORY (owner/repo) is required for PR-based release notes', + ); + } + const [owner, repo] = raw.split('/'); + return { owner, repo }; +} + +async function ghApiJson(apiPath) { + const { stdout } = await execFileAsync('gh', ['api', apiPath], { + maxBuffer: 50 * 1024 * 1024, + encoding: 'utf8', + }); + return JSON.parse(stdout); +} + +async function fetchAllPullFiles(owner, repo, pullNumber) { + const files = []; + for (let page = 1; page <= 500; page += 1) { + const path = `repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100&page=${page}`; + const pageFiles = await ghApiJson(path); + if (!Array.isArray(pageFiles) || pageFiles.length === 0) { + break; + } + files.push(...pageFiles); + if (pageFiles.length < 100) { + break; + } + } + return files; +} + +async function mapLimit(items, limit, fn) { + const out = []; + for (let i = 0; i < items.length; i += limit) { + const slice = items.slice(i, i + limit); + out.push(...(await Promise.all(slice.map(fn)))); + } + return out; +} + +function gitRevListShas(prev, tag) { + const args = prev + ? ['rev-list', `${prev}..${tag}`] + : ['rev-list', '--max-count=400', tag]; + try { + const raw = execGit(args); + return raw ? raw.split('\n').filter(Boolean) : []; + } catch { + return []; + } +} + +function commitTouchesPaths(sha, rules) { + try { + const names = execGit([ + 'diff-tree', + '--no-commit-id', + '--name-only', + '-r', + sha, + ]); + const files = names.split('\n').filter(Boolean); + return files.some(f => matchesReleasePath(f, rules)); + } catch { + return false; + } +} + +function commitSubject(sha) { + try { + return execGit(['log', '-1', '--pretty=format:%s', sha]); + } catch { + return sha.slice(0, 7); + } +} + +async function fetchPullsForCommit(owner, repo, sha) { + const path = `repos/${owner}/${repo}/commits/${sha}/pulls`; + try { + const data = await ghApiJson(path); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +function renderCommitMode(paths, prev, tag) { + const rangeArgs = prev ? [`${prev}..${tag}`] : [tag, '--max-count=400']; + const logArgs = [ + 'log', + ...rangeArgs, + '--pretty=format:- %s (%h)', + '--', + ...paths, + ]; + let lines; + try { + lines = execGit(logArgs); + } catch (e) { + console.error(e.message || e); + process.exit(1); + } + const body = + lines.length > 0 + ? lines + : '_No commits touched the configured paths in this range._'; + return `## Changes (paths: ${paths.join(', ')})\n\n${body}\n`; +} + +async function renderPrMode(tag, tagPrefix, paths) { + const { owner, repo } = parseRepo(); + const tags = listTagsForPrefix(tagPrefix); + const prev = previousTag(tags, tag); + + const shas = gitRevListShas(prev, tag); + if (shas.length === 0) { + return `## Merged pull requests (paths: ${paths.join(', ')})\n\n_No commits in this tag range._\n`; + } + + const pullsArrays = await mapLimit(shas, 12, async sha => + fetchPullsForCommit(owner, repo, sha), + ); + + const prNumbers = new Set(); + for (const pulls of pullsArrays) { + for (const pr of pulls) { + if (pr && typeof pr.number === 'number') { + prNumbers.add(pr.number); + } + } + } + + const sortedNums = [...prNumbers].sort((a, b) => a - b); + + const included = []; + for (const num of sortedNums) { + const files = await fetchAllPullFiles(owner, repo, num); + if (!files.some(f => f.filename && matchesReleasePath(f.filename, paths))) { + continue; + } + const detail = await ghApiJson(`repos/${owner}/${repo}/pulls/${num}`); + included.push({ + number: num, + title: detail.title || `PR #${num}`, + html_url: + detail.html_url || + `https://github.com/${owner}/${repo}/pull/${num}`, + merged_at: detail.merged_at || null, + }); + } + + included.sort((a, b) => { + if (a.merged_at && b.merged_at) { + return new Date(b.merged_at) - new Date(a.merged_at); + } + return b.number - a.number; + }); + + const lines = included.map( + p => `- ${p.title} ([#${p.number}](${p.html_url}))`, + ); + + const orphanLines = []; + for (let i = 0; i < shas.length; i += 1) { + const sha = shas[i]; + const pulls = pullsArrays[i]; + if (pulls.length > 0) { + continue; + } + if (commitTouchesPaths(sha, paths)) { + orphanLines.push(`- ${commitSubject(sha)} (\`${sha.slice(0, 7)}\`)`); + } + } + + let md = `## Merged pull requests (paths: ${paths.join(', ')})\n\n`; + if (lines.length > 0) { + md += `${lines.join('\n')}\n`; + } else { + md += '_No merged PRs in this range matched these paths._\n'; + } + + if (orphanLines.length > 0) { + md += `\n### Other commits (no linked PR)\n\n${orphanLines.join('\n')}\n`; + } + + return md; +} + +function resolveMode(argvMode, envMode) { + const v = (argvMode || envMode || 'pr').toLowerCase(); + if (v === 'commits') { + return 'commits'; + } + return 'pr'; +} + +async function main() { + const [tag, tagPrefix, pathsJson, argvMode] = process.argv.slice(2); + if (!tag || !tagPrefix || !pathsJson) { + console.error( + 'Usage: release-notes.js [pr|commits]', + ); + process.exit(1); + } + + let paths; + try { + paths = JSON.parse(pathsJson); + } catch { + console.error('pathsJson must be valid JSON array of path strings'); + process.exit(1); + } + if (!Array.isArray(paths) || paths.length === 0) { + console.error('paths must be a non-empty array'); + process.exit(1); + } + + const mode = resolveMode(argvMode, process.env.RELEASE_NOTES_MODE); + + if (mode === 'commits') { + const tags = listTagsForPrefix(tagPrefix); + const prev = previousTag(tags, tag); + process.stdout.write(renderCommitMode(paths, prev, tag)); + return; + } + + try { + const md = await renderPrMode(tag, tagPrefix, paths); + process.stdout.write(md); + } catch (e) { + console.error(e.message || e); + process.exit(1); + } +} + +main(); diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a10808d2..02e3bbd8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -106,8 +106,11 @@ jobs: - name: Create git tag and GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} VERSION_FILE: ${{ matrix.versionFile }} TAG_PREFIX: ${{ matrix.tagPrefix }} + RELEASE_NOTES_PATHS: ${{ toJSON(matrix.releaseNotesPaths) }} + RELEASE_NOTES_MODE: ${{ matrix.releaseNotesMode }} run: | set -euo pipefail VERSION=$(jq -r .version "$VERSION_FILE") @@ -126,5 +129,12 @@ jobs: if gh release view "$TAG" >/dev/null 2>&1; then echo "GitHub Release for $TAG already exists, skipping." else - gh release create "$TAG" --title "$TAG" --generate-notes + if [ "${RELEASE_NOTES_PATHS:-}" != "null" ] && [ -n "${RELEASE_NOTES_PATHS:-}" ] && [ "${RELEASE_NOTES_PATHS}" != "[]" ]; then + NOTES_FILE="$(mktemp)" + node .github/scripts/release-notes.js "$TAG" "$TAG_PREFIX" "$RELEASE_NOTES_PATHS" "${RELEASE_NOTES_MODE:-pr}" > "$NOTES_FILE" + gh release create "$TAG" --title "$TAG" --notes-file "$NOTES_FILE" + rm -f "$NOTES_FILE" + else + gh release create "$TAG" --title "$TAG" --generate-notes + fi fi diff --git a/package-lock.json b/package-lock.json index 7f4fae84..8f0fb3f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "picocolors": "^1.1.1" }, "bin": { - "compose": "index.js" + "compose": "bin/compose.js" }, "devDependencies": { "@eslint/js": "^10.0.1",