Publish Packages #22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish Package | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| package: | |
| description: 'Which package(s) to publish' | |
| required: true | |
| default: cli | |
| type: choice | |
| options: | |
| - all | |
| - workload-router | |
| - harness-kit | |
| - cli | |
| - agentworkforce | |
| version: | |
| description: 'Version bump type (ignored if custom_version is set)' | |
| required: true | |
| default: patch | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| - prepatch | |
| - preminor | |
| - premajor | |
| - prerelease | |
| - none | |
| custom_version: | |
| description: 'Exact version (e.g. 0.1.0). Overrides version type when set.' | |
| required: false | |
| prerelease_id: | |
| description: 'Prerelease identifier for pre* bumps (e.g. "next", "beta")' | |
| required: false | |
| default: next | |
| tag: | |
| description: 'npm dist-tag' | |
| required: true | |
| default: latest | |
| type: choice | |
| options: | |
| - latest | |
| - next | |
| - beta | |
| - alpha | |
| dry_run: | |
| description: 'Dry run (no actual publish, no version commit, no git tag)' | |
| required: true | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: write | |
| id-token: write | |
| concurrency: | |
| group: publish-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| publish: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| versions: ${{ steps.bump.outputs.versions }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@v4 | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22.14.0' | |
| registry-url: 'https://registry.npmjs.org' | |
| cache: 'pnpm' | |
| - name: Install deps | |
| run: pnpm install --frozen-lockfile | |
| # Build + test everything regardless of which package is being released — | |
| # the CLI depends on harness-kit which depends on workload-router via | |
| # workspace:*. `pnpm publish` rewrites those specs to concrete versions | |
| # at pack time, and we want every package's dist/ to be fresh when that | |
| # happens. | |
| - name: Build workspace | |
| run: pnpm -r run build | |
| - name: Run tests | |
| run: pnpm -r run test | |
| - name: Resolve target packages (dep order) | |
| id: targets | |
| run: | | |
| case "${{ github.event.inputs.package }}" in | |
| all) | |
| # Must be in dependency order: router → harness-kit → cli → | |
| # agentworkforce (the wrapper depends on cli). | |
| echo "packages=workload-router harness-kit cli agentworkforce" >> "$GITHUB_OUTPUT" | |
| ;; | |
| workload-router|harness-kit|cli|agentworkforce) | |
| echo "packages=${{ github.event.inputs.package }}" >> "$GITHUB_OUTPUT" | |
| ;; | |
| *) | |
| echo "Unknown package: ${{ github.event.inputs.package }}" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| # Catches the failure mode that bit us on 2026-04-23: a previous | |
| # publish run shipped @agentworkforce/*@0.3.0 to npm but failed at the | |
| # final `git push origin HEAD --follow-tags` step (a concurrent PR | |
| # merge made main non-fast-forwardable), so neither the *-v0.3.0 tags | |
| # nor the chore(release) commit landed on main. A naive re-run would | |
| # clone at 0.2.x, bump to 0.3.0 again, and hit npm's "cannot publish | |
| # over previously published versions: 0.3.0." | |
| # | |
| # If npm's highest stable version is greater than what package.json | |
| # has, abort here with a clear remediation message rather than | |
| # letting the bump produce a doomed version downstream. | |
| - name: Verify local versions are in sync with npm | |
| run: | | |
| set -euo pipefail | |
| for pkg in ${{ steps.targets.outputs.packages }}; do | |
| NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") | |
| LOCAL=$(node -p "require('./packages/$pkg/package.json').version") | |
| REMOTE=$(npm view "$NPM_NAME" versions --json 2>/dev/null \ | |
| | node -e ' | |
| const raw = require("fs").readFileSync(0, "utf8").trim() || "[]"; | |
| const parsed = JSON.parse(raw); | |
| const arr = Array.isArray(parsed) ? parsed : [parsed]; | |
| const stable = arr.filter((v) => typeof v === "string" && !v.includes("-")); | |
| stable.sort((a, b) => { | |
| const pa = a.split(".").map(Number); | |
| const pb = b.split(".").map(Number); | |
| for (let i = 0; i < 3; i++) { | |
| if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); | |
| } | |
| return 0; | |
| }); | |
| process.stdout.write(stable.length ? stable[stable.length - 1] : ""); | |
| ' || echo "") | |
| if [ -z "$REMOTE" ]; then | |
| echo "$NPM_NAME: no stable releases on npm yet — OK" | |
| continue | |
| fi | |
| CMP=$(node -e ' | |
| const [a, b] = process.argv.slice(1); | |
| const pa = a.split(".").map(Number); | |
| const pb = b.split(".").map(Number); | |
| for (let i = 0; i < 3; i++) { | |
| const da = pa[i] || 0; | |
| const db = pb[i] || 0; | |
| if (da !== db) { console.log(da < db ? -1 : 1); process.exit(0); } | |
| } | |
| console.log(0); | |
| ' "$LOCAL" "$REMOTE") | |
| if [ "$CMP" = "-1" ]; then | |
| echo "::error title=npm/git version drift::$NPM_NAME: package.json is at $LOCAL but npm has $REMOTE published. A previous publish run likely succeeded on npm but failed before tagging. Bump packages/$pkg/package.json to >= $REMOTE on a branch, push, then re-run this workflow. (You may also want to tag the prior release commit as $pkg-v$REMOTE so the changelog generator picks the right baseline.)" | |
| exit 1 | |
| fi | |
| echo "$NPM_NAME: local=$LOCAL, npm=$REMOTE — OK" | |
| done | |
| - name: Bump versions | |
| id: bump | |
| run: | | |
| VERSIONS="" | |
| CUSTOM='${{ github.event.inputs.custom_version }}' | |
| BUMP='${{ github.event.inputs.version }}' | |
| PREID='${{ github.event.inputs.prerelease_id }}' | |
| for pkg in ${{ steps.targets.outputs.packages }}; do | |
| pushd "packages/$pkg" > /dev/null | |
| if [ -n "$CUSTOM" ]; then | |
| npm version "$CUSTOM" --no-git-tag-version --allow-same-version | |
| elif [ "$BUMP" = "none" ]; then | |
| : # keep existing version (useful for first publish or re-publish) | |
| elif [[ "$BUMP" == pre* ]]; then | |
| npm version "$BUMP" --no-git-tag-version --preid="$PREID" | |
| else | |
| npm version "$BUMP" --no-git-tag-version | |
| fi | |
| NEW=$(node -p "require('./package.json').version") | |
| VERSIONS+=" $pkg:$NEW" | |
| popd > /dev/null | |
| done | |
| echo "versions=${VERSIONS# }" >> "$GITHUB_OUTPUT" | |
| # Belt-and-suspenders alongside the parity check above: even if the | |
| # local→npm baseline is in sync, the computed bump might still collide | |
| # with an existing version (e.g. a one-off publish from another | |
| # branch). Catch it before we waste a build + before npm rejects with | |
| # a less specific error. | |
| - name: Verify new versions are not yet published | |
| run: | | |
| set -euo pipefail | |
| for entry in ${{ steps.bump.outputs.versions }}; do | |
| pkg="${entry%%:*}" | |
| ver="${entry##*:}" | |
| NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") | |
| EXISTS=$(npm view "$NPM_NAME@$ver" version 2>/dev/null || true) | |
| if [ -n "$EXISTS" ]; then | |
| echo "::error title=Version already published::$NPM_NAME@$ver is already on npm. Pick a different bump type or set custom_version to a higher version." | |
| exit 1 | |
| fi | |
| echo "$NPM_NAME@$ver: unpublished — OK" | |
| done | |
| # Per-package CHANGELOG.md generation. For each package being published, | |
| # finds the last `<pkg>-v*` tag, collects Conventional Commits since then | |
| # that touched `packages/<pkg>/**`, buckets them, and prepends a new | |
| # versioned block. Skips silently for prereleases (version contains `-`) | |
| # and for first publishes (no prior tag). | |
| - name: Generate changelogs | |
| if: ${{ github.event.inputs.version != 'none' || github.event.inputs.custom_version != '' }} | |
| run: | | |
| TODAY=$(date -u +%Y-%m-%d) | |
| cat > /tmp/gen-changelog.mjs << 'GENEOF' | |
| import { execSync } from 'node:child_process'; | |
| import { readFileSync, writeFileSync, existsSync } from 'node:fs'; | |
| const [,, pkg, newVersion, today] = process.argv; | |
| const path = `packages/${pkg}/CHANGELOG.md`; | |
| const npmName = JSON.parse(readFileSync(`packages/${pkg}/package.json`, 'utf-8')).name; | |
| if (newVersion.includes('-')) { | |
| console.log(`prerelease ${pkg}@${newVersion}: skipping`); | |
| process.exit(0); | |
| } | |
| const tagPrefix = `${pkg}-v`; | |
| const tags = execSync(`git tag -l '${tagPrefix}*' --sort=-v:refname`, { encoding: 'utf-8' }) | |
| .trim().split('\n').filter(Boolean); | |
| const semverRe = new RegExp(`^${tagPrefix.replace(/\./g, '\\.')}\\d+\\.\\d+\\.\\d+$`); | |
| const lastTag = tags.find(t => semverRe.test(t)); | |
| if (!lastTag) { | |
| console.log(`${pkg}: no prior stable tag, skipping`); | |
| process.exit(0); | |
| } | |
| const existing = existsSync(path) ? readFileSync(path, 'utf-8') : ''; | |
| if (existing.includes(`## [${newVersion}]`)) { | |
| console.log(`${path} already has ${newVersion}, skipping`); | |
| process.exit(0); | |
| } | |
| const log = execSync( | |
| `git log ${lastTag}..HEAD --pretty=format:"%H|%s|%b%x00" --no-merges -- packages/${pkg}`, | |
| { encoding: 'utf-8' } | |
| ).trim(); | |
| if (!log) { | |
| console.log(`${pkg}: no commits since ${lastTag}, skipping`); | |
| process.exit(0); | |
| } | |
| const commits = log.split('\0').filter(Boolean).map(record => { | |
| const idx = record.indexOf('|'); | |
| const idx2 = record.indexOf('|', idx + 1); | |
| return { | |
| subject: record.slice(idx + 1, idx2).trim(), | |
| body: record.slice(idx2 + 1).trim(), | |
| }; | |
| }); | |
| const extractPR = (subject, body) => { | |
| const m = (subject + ' ' + body).match(/#(\d+)/); | |
| return m ? `(#${m[1]})` : ''; | |
| }; | |
| const formatTitle = (subject) => { | |
| const cleaned = subject | |
| .replace(/^(feat|fix|refactor|perf|chore|test|ci|docs|build|style)(\([^)]+\))?!?:\s*/i, '') | |
| .replace(/\s*\(#\d+\)\s*$/, ''); | |
| return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); | |
| }; | |
| const getType = (subject) => { | |
| const m = subject.match(/^(feat|fix|refactor|perf|chore|test|ci|docs|build|style)(\([^)]+\))?(!)?:/i); | |
| if (!m) return 'other'; | |
| const type = m[1].toLowerCase(); | |
| const scope = (m[2] || '').replace(/[()]/g, ''); | |
| if (m[3] === '!') return 'breaking'; | |
| if (type === 'feat') return 'feat'; | |
| if (type === 'fix') return 'fix'; | |
| if (type === 'refactor' || type === 'perf' || type === 'build') return 'changed'; | |
| if (type === 'test' || type === 'ci') return 'reliability'; | |
| if (type === 'chore' && scope === 'release') return 'release'; | |
| if (type === 'chore') return 'deps'; | |
| return 'other'; | |
| }; | |
| const cats = { breaking: [], feat: [], fix: [], changed: [], reliability: [], deps: [], release: [], other: [] }; | |
| for (const c of commits) { | |
| const type = getType(c.subject); | |
| cats[type].push({ title: formatTitle(c.subject), pr: extractPR(c.subject, c.body) }); | |
| } | |
| const sections = [ | |
| ['Breaking Changes', cats.breaking, true], | |
| ['Added', cats.feat, true], | |
| ['Fixed', cats.fix, false], | |
| ['Changed', cats.changed, false], | |
| ['Reliability', cats.reliability, false], | |
| ['Dependencies', cats.deps, false], | |
| ]; | |
| const lines = [`## [${newVersion}] - ${today}`, '']; | |
| let anyContent = false; | |
| for (const [header, bucket, bold] of sections) { | |
| if (bucket.length === 0) continue; | |
| anyContent = true; | |
| lines.push(`### ${header}`, ''); | |
| for (const c of bucket) { | |
| const title = bold ? `**${c.title}**` : c.title; | |
| lines.push(`- ${title}${c.pr ? ' ' + c.pr : ''}`); | |
| } | |
| lines.push(''); | |
| } | |
| if (!anyContent) { | |
| lines.push('### Released', '', `- v${newVersion}`, ''); | |
| } | |
| const newEntry = lines.join('\n'); | |
| if (!existing) { | |
| const header = `# Changelog\n\nAll notable changes to \`${npmName}\` will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n`; | |
| writeFileSync(path, header + newEntry + '\n'); | |
| } else { | |
| // Insert after the [Unreleased] block, before the first versioned entry. | |
| const match = existing.match(/\n## \[\d/); | |
| const insertAt = match ? match.index : -1; | |
| if (insertAt === -1) { | |
| writeFileSync(path, existing.trimEnd() + '\n\n' + newEntry + '\n'); | |
| } else { | |
| writeFileSync(path, existing.slice(0, insertAt + 1) + newEntry + '\n' + existing.slice(insertAt + 1)); | |
| } | |
| } | |
| console.log(`${path} updated with ${newVersion}`); | |
| GENEOF | |
| for entry in ${{ steps.bump.outputs.versions }}; do | |
| pkg="${entry%%:*}" | |
| version="${entry##*:}" | |
| node /tmp/gen-changelog.mjs "$pkg" "$version" "$TODAY" | |
| done | |
| - name: Commit version bumps | |
| if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add packages/*/package.json packages/*/CHANGELOG.md | |
| if git diff --cached --quiet; then | |
| echo "No version changes to commit." | |
| else | |
| MSG="chore(release):" | |
| for entry in ${{ steps.bump.outputs.versions }}; do | |
| pkg="${entry%%:*}" | |
| version="${entry##*:}" | |
| NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") | |
| MSG+=" $NPM_NAME@$version" | |
| done | |
| git commit -m "$MSG" | |
| fi | |
| # npm >= 11.5.1 is required for the OIDC trusted-publisher flow. | |
| - name: Install latest npm | |
| run: npm install -g npm@latest | |
| # Authentication note: this workflow does NOT use an NPM_TOKEN. It relies | |
| # on npm's OIDC trusted-publisher flow — the `id-token: write` permission | |
| # above lets `npm publish --provenance` exchange the GitHub workflow's | |
| # OIDC identity for a short-lived publish token. Each package must be | |
| # registered as a trusted publisher on npmjs.com under this | |
| # repo/workflow path for the first publish. | |
| # | |
| # Pipeline: `pnpm pack` rewrites workspace:* deps to concrete versions | |
| # inside the tarball's package.json, then `npm publish <tarball>` | |
| # uploads it using npm's native auth. This decouples workspace-aware | |
| # packing from publish-time auth and matches the agent-relay pattern. | |
| - name: Pack + publish | |
| run: | | |
| set -euo pipefail | |
| PACK_DIR="$RUNNER_TEMP/packs" | |
| mkdir -p "$PACK_DIR" | |
| COMMON_FLAGS="--access public --tag ${{ github.event.inputs.tag }}" | |
| if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then | |
| COMMON_FLAGS+=" --dry-run" | |
| else | |
| COMMON_FLAGS+=" --provenance" | |
| fi | |
| for pkg in ${{ steps.targets.outputs.packages }}; do | |
| NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") | |
| VERSION=$(node -p "require('./packages/$pkg/package.json').version") | |
| # `pnpm pack` writes <name-without-@>-<ver>.tgz with `/` → `-`. | |
| # @agentworkforce/cli@0.4.0 → agentworkforce-cli-0.4.0.tgz | |
| # agentworkforce@0.4.0 → agentworkforce-0.4.0.tgz | |
| TARBALL_BASENAME="$(echo "${NPM_NAME#@}" | tr '/' '-')-${VERSION}.tgz" | |
| echo "==> Packing $NPM_NAME" | |
| pnpm --filter "$NPM_NAME" pack --pack-destination "$PACK_DIR" | |
| TARBALL="$PACK_DIR/$TARBALL_BASENAME" | |
| if [ ! -f "$TARBALL" ]; then | |
| echo "::error::could not find packed tarball $TARBALL_BASENAME in $PACK_DIR" >&2 | |
| ls -la "$PACK_DIR" >&2 || true | |
| exit 1 | |
| fi | |
| echo "==> Publishing $TARBALL $COMMON_FLAGS" | |
| npm publish "$TARBALL" $COMMON_FLAGS | |
| done | |
| # Annotated tags (-a) so `git push --follow-tags` actually pushes them; | |
| # lightweight tags are skipped by --follow-tags. | |
| - name: Tag + push | |
| if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} | |
| run: | | |
| for entry in ${{ steps.bump.outputs.versions }}; do | |
| pkg="${entry%%:*}" | |
| version="${entry##*:}" | |
| NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") | |
| git tag -a "$pkg-v$version" -m "$NPM_NAME@$version" | |
| done | |
| git push origin HEAD --follow-tags | |
| - name: Summary | |
| run: | | |
| { | |
| echo "### Published" | |
| echo "" | |
| for entry in ${{ steps.bump.outputs.versions }}; do | |
| pkg="${entry%%:*}" | |
| version="${entry##*:}" | |
| NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") | |
| echo "- \`$NPM_NAME@$version\`" | |
| done | |
| echo "" | |
| echo "- **dist-tag**: \`${{ github.event.inputs.tag }}\`" | |
| echo "- **dry run**: \`${{ github.event.inputs.dry_run }}\`" | |
| echo "" | |
| if [ "${{ github.event.inputs.dry_run }}" != "true" ]; then | |
| echo "Next step: verify the published artifact by running the \`Verify Publish\` workflow." | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # One GitHub Release per publish run. Per-package git tags are still pushed | |
| # above (so the next publish's changelog generator can find them), but the | |
| # public GitHub Release is anchored to a single canonical tag — `agentworkforce` | |
| # if the wrapper was bumped, otherwise the first package in the run. The | |
| # release body lists every published package and inlines each one's | |
| # CHANGELOG block, so the releases page has one item per version instead of | |
| # 4 near-duplicate entries per `all` publish. | |
| create-release: | |
| name: Create GitHub Release | |
| needs: publish | |
| if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Resolve canonical release | |
| id: release | |
| run: | | |
| set -euo pipefail | |
| VERSIONS='${{ needs.publish.outputs.versions }}' | |
| canonical="" | |
| for entry in $VERSIONS; do | |
| pkg="${entry%%:*}" | |
| if [ -z "$canonical" ]; then | |
| canonical="$entry" | |
| fi | |
| if [ "$pkg" = "agentworkforce" ]; then | |
| canonical="$entry" | |
| break | |
| fi | |
| done | |
| if [ -z "$canonical" ]; then | |
| echo "::error title=Missing release target::publish job did not report any package versions" | |
| exit 1 | |
| fi | |
| pkg="${canonical%%:*}" | |
| ver="${canonical##*:}" | |
| echo "canonical_pkg=$pkg" >> "$GITHUB_OUTPUT" | |
| echo "version=$ver" >> "$GITHUB_OUTPUT" | |
| echo "tag_name=$pkg-v$ver" >> "$GITHUB_OUTPUT" | |
| if [[ "$ver" == *-* ]]; then | |
| echo "prerelease=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "prerelease=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| # Need the canonical tag that the publish job just pushed — it | |
| # points at the chore(release) commit with all bumped CHANGELOGs. | |
| ref: ${{ steps.release.outputs.tag_name }} | |
| - name: Build combined release notes | |
| id: notes | |
| run: | | |
| cat > /tmp/build-release-notes.mjs << 'GENEOF' | |
| import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; | |
| const versionsRaw = process.env.VERSIONS || ''; | |
| const canonicalPkg = process.env.CANONICAL_PKG; | |
| const canonicalVersion = process.env.CANONICAL_VERSION; | |
| const packageOrder = ['workload-router', 'harness-kit', 'cli', 'agentworkforce']; | |
| const entries = versionsRaw.trim().split(/\s+/).filter(Boolean).map((entry) => { | |
| const idx = entry.indexOf(':'); | |
| return { pkg: entry.slice(0, idx), ver: entry.slice(idx + 1) }; | |
| }).sort((a, b) => packageOrder.indexOf(a.pkg) - packageOrder.indexOf(b.pkg)); | |
| if (entries.length === 0) { | |
| throw new Error('publish job did not report any package versions'); | |
| } | |
| const packageInfo = entries.map(({ pkg, ver }) => { | |
| const pkgJson = JSON.parse(readFileSync(`packages/${pkg}/package.json`, 'utf8')); | |
| return { | |
| pkg, | |
| ver, | |
| npmName: pkgJson.name, | |
| tag: `${pkg}-v${ver}`, | |
| }; | |
| }); | |
| const canonical = | |
| packageInfo.find((entry) => entry.pkg === canonicalPkg && entry.ver === canonicalVersion) || | |
| packageInfo[0]; | |
| function escapeRegExp(value) { | |
| return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| } | |
| function extractChangelogBody(path, version) { | |
| if (!existsSync(path)) return ''; | |
| const raw = readFileSync(path, 'utf8'); | |
| const headerRe = new RegExp(`^## \\[${escapeRegExp(version)}\\][^\\n]*\\n`, 'm'); | |
| const header = raw.match(headerRe); | |
| if (!header || header.index === undefined) return ''; | |
| const start = header.index + header[0].length; | |
| const tail = raw.slice(start); | |
| const next = tail.match(/^## \[/m); | |
| const end = next && next.index !== undefined ? start + next.index : raw.length; | |
| return raw.slice(start, end).trim(); | |
| } | |
| // Per-package changelogs use ### headings; nest them one level deeper | |
| // so they live under the ### <package> heading we add below. | |
| function nestChangelogHeadings(notes) { | |
| return notes.replace(/^(#{3,5}) /gm, '#$1 '); | |
| } | |
| const lines = ['## Packages', '']; | |
| for (const entry of packageInfo) { | |
| lines.push(`- \`${entry.npmName}@${entry.ver}\` (tag: \`${entry.tag}\`)`); | |
| } | |
| const packageNotes = packageInfo | |
| .map((entry) => ({ | |
| ...entry, | |
| notes: extractChangelogBody(`packages/${entry.pkg}/CHANGELOG.md`, entry.ver), | |
| })) | |
| .filter((entry) => entry.notes.length > 0); | |
| if (packageNotes.length > 0) { | |
| lines.push('', '## Package Changelogs', ''); | |
| for (const entry of packageNotes) { | |
| lines.push(`### ${entry.npmName}`, '', nestChangelogHeadings(entry.notes), ''); | |
| } | |
| } else { | |
| lines.push('', '## Release Notes', '', '_No changelog entries were generated for this release._'); | |
| } | |
| writeFileSync('/tmp/release-notes.md', `${lines.join('\n').trimEnd()}\n`); | |
| // Display name on the releases page. Always anchor to the canonical | |
| // package so consumers see e.g. `agentworkforce@0.5.0` rather than | |
| // a bare scoped package version. | |
| const releaseName = `${canonical.npmName}@${canonical.ver}`; | |
| appendFileSync(process.env.GITHUB_OUTPUT, `release_name=${releaseName}\n`); | |
| GENEOF | |
| VERSIONS='${{ needs.publish.outputs.versions }}' \ | |
| CANONICAL_PKG='${{ steps.release.outputs.canonical_pkg }}' \ | |
| CANONICAL_VERSION='${{ steps.release.outputs.version }}' \ | |
| node /tmp/build-release-notes.mjs | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ steps.release.outputs.tag_name }} | |
| name: ${{ steps.notes.outputs.release_name }} | |
| body_path: /tmp/release-notes.md | |
| prerelease: ${{ steps.release.outputs.prerelease }} |