diff --git a/.changeset/streamlined-release-actions.md b/.changeset/streamlined-release-actions.md new file mode 100644 index 000000000..236664f07 --- /dev/null +++ b/.changeset/streamlined-release-actions.md @@ -0,0 +1,4 @@ +--- +--- + +ci: Use streamlined release actions diff --git a/.github/workflows/publish-stable-release.yaml b/.github/workflows/publish-stable-release.yaml index 3745e2f1b..22d742086 100644 --- a/.github/workflows/publish-stable-release.yaml +++ b/.github/workflows/publish-stable-release.yaml @@ -16,6 +16,8 @@ env: HUSKY: "0" NPM_CONFIG_PROVENANCE: "true" +# sdk-actions PR #64 merge commit. Keep release action references immutable. + jobs: validate-release-sha: runs-on: ubuntu-latest @@ -69,18 +71,24 @@ jobs: echo "Diff to latest release: $diff_url" } | tee -a "$GITHUB_STEP_SUMMARY" - publish-stable: + build-package-artifacts: needs: - validate-release-sha - print-changelog-links runs-on: ubuntu-latest timeout-minutes: 30 permissions: - contents: write - issues: write + contents: read id-token: write - pull-requests: read - environment: npm-publish + attestations: write + outputs: + has_work: ${{ steps.detect.outputs.has_work }} + manifest_json: ${{ steps.detect.outputs.manifest_json }} + release_sha: ${{ needs.validate-release-sha.outputs.release_sha }} + branch: ${{ steps.release-context.outputs.branch }} + on_release_branch: ${{ steps.release-context.outputs.on_release_branch }} + # Displayed in the approval summary so reviewers can identify the selected SHA. + commit_message: ${{ steps.release-context.outputs.commit_message }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -97,6 +105,27 @@ jobs: echo "Checked out $checked_out_sha, expected $RELEASE_SHA." >&2 exit 1 fi + - name: Read release context + id: release-context + run: | + set -euo pipefail + + containing_branches="$( + git branch -r --contains HEAD --format='%(refname:short)' | + sed '/^origin\/HEAD$/d; s|^origin/||' + )" + branch="${containing_branches%%$'\n'*}" + branch="${branch:-unknown}" + on_release_branch=false + case "$branch" in + main | prepare-release/*) on_release_branch=true ;; + esac + + { + echo "branch=$branch" + echo "on_release_branch=$on_release_branch" + echo "commit_message=$(git log -1 --format='%s' HEAD)" + } >> "$GITHUB_OUTPUT" - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -111,25 +140,121 @@ jobs: id: detect run: node scripts/release/release-manifest.mjs --mode stable --output .release-manifest.json - name: Build packages - if: steps.detect.outputs.needs_publish == 'true' + if: steps.detect.outputs.needs_artifacts == 'true' run: pnpm run build - - name: Publish stable packages to npm - if: steps.detect.outputs.needs_publish == 'true' - env: - NODE_AUTH_TOKEN: "" - NPM_TOKEN: "" - run: node scripts/release/publish-release-manifest.mjs --manifest .release-manifest.json + - name: Pack packages and generate SBOMs + if: steps.detect.outputs.needs_artifacts == 'true' + run: node scripts/release/pack-publishable-packages.mjs --manifest .release-manifest.json --output-dir artifacts/release-packages --report artifacts/release-packages/pack-report.json + - name: Stage release manifest + if: steps.detect.outputs.needs_artifacts == 'true' + run: cp .release-manifest.json artifacts/release-packages/release-manifest.json + - name: Attest tarball build provenance + if: steps.detect.outputs.needs_artifacts == 'true' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: artifacts/release-packages/*.tgz + - name: Upload release package artifacts + if: steps.detect.outputs.needs_artifacts == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: stable-release-packages + path: artifacts/release-packages/ + retention-days: 1 + if-no-files-found: error + + attest-package-sboms: + name: Attest SBOM (${{ matrix.package.name }}) + needs: + - build-package-artifacts + if: needs.build-package-artifacts.outputs.has_work == 'true' + strategy: + matrix: + package: ${{ fromJSON(needs.build-package-artifacts.outputs.manifest_json).packages }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Download release package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: stable-release-packages + path: release-artifacts + - name: Attest package SBOM + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: release-artifacts/${{ matrix.package.tarball_asset }} + sbom-path: release-artifacts/${{ matrix.package.sbom_asset }} + + request-approval: + needs: + - build-package-artifacts + - attest-package-sboms + if: needs.build-package-artifacts.outputs.has_work == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Request stable release approval + uses: braintrustdata/sdk-actions/actions/release/request-approval@337c5475631d724441403f5a4d8ad0b32ce12e29 + with: + packages: ${{ needs.build-package-artifacts.outputs.manifest_json }} + title: Braintrust JavaScript SDK stable release + sha: ${{ needs.build-package-artifacts.outputs.release_sha }} + branch: ${{ needs.build-package-artifacts.outputs.branch }} + on_release_branch: ${{ needs.build-package-artifacts.outputs.on_release_branch }} + commit_message: ${{ needs.build-package-artifacts.outputs.commit_message }} + slack_token: ${{ secrets.SLACK_BOT_TOKEN }} + slack_channel: C0ABHT0SWA2 + + publish-stable: + needs: + - build-package-artifacts + - request-approval + if: | + always() && + needs.build-package-artifacts.outputs.has_work == 'true' && + needs.request-approval.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + issues: write + id-token: write + pull-requests: read + attestations: read + environment: npm-publish + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ needs.build-package-artifacts.outputs.release_sha }} + - name: Download release package artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: stable-release-packages + path: release-artifacts + - name: Publish stable package tarballs + uses: braintrustdata/sdk-actions/actions/release/lang/js/publish-npm-tarballs@337c5475631d724441403f5a4d8ad0b32ce12e29 + with: + manifest: release-artifacts/release-manifest.json + tarballs: release-artifacts/*.tgz + repo: ${{ github.repository }} + slack_token: ${{ secrets.SLACK_BOT_TOKEN }} + slack_channel: C0ABHT0SWA2 - name: Push Changesets release tags - if: steps.detect.outputs.has_work == 'true' run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - target_commit="$(jq -r '.commit // empty' .release-manifest.json)" + manifest="release-artifacts/release-manifest.json" + target_commit="$(jq -r '.commit // empty' "$manifest")" mapfile -t tags < <( - jq -r '.packages[]? | .tag // "\(.name)@\(.version)"' .release-manifest.json + jq -r '.packages[]? | .tag // "\(.name)@\(.version)"' "$manifest" ) if [ "${#tags[@]}" -eq 0 ]; then @@ -174,44 +299,60 @@ jobs: git push origin "${to_push[@]}" - name: Create GitHub Releases - if: steps.detect.outputs.has_work == 'true' + uses: braintrustdata/sdk-actions/actions/release/create-package-github-releases@337c5475631d724441403f5a4d8ad0b32ce12e29 + with: + manifest: release-artifacts/release-manifest.json + repo: ${{ github.repository }} + - name: Ensure GitHub release SBOM assets env: - GITHUB_TOKEN: ${{ github.token }} - run: node scripts/release/create-github-releases.mjs --manifest .release-manifest.json + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + manifest="release-artifacts/release-manifest.json" + manifest_dir="$(dirname "$manifest")" + while IFS= read -r encoded; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" + sbom_asset="$(jq -r '.sbom_asset // empty' <<< "$package_json")" + if [ -z "$sbom_asset" ] || [ "$(basename "$sbom_asset")" != "$sbom_asset" ]; then + echo "Invalid SBOM asset for $tag: $sbom_asset" >&2 + exit 1 + fi + + sbom_path="$manifest_dir/$sbom_asset" + if [ ! -f "$sbom_path" ]; then + echo "SBOM asset does not exist for $tag: $sbom_path" >&2 + exit 1 + fi + + gh release upload "$tag" "$sbom_path" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + done < <(jq -r '.packages[] | @base64' "$manifest") - name: Comment on issues closed by released PRs - if: steps.detect.outputs.has_work == 'true' env: GITHUB_TOKEN: ${{ github.token }} - run: node scripts/release/comment-release-issues.mjs - - name: Post stable release to Slack - if: steps.detect.outputs.has_work == 'true' - uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload: | - channel: C0ABHT0SWA2 - text: "✅ Packages published" - blocks: - - type: "header" - text: - type: "plain_text" - text: "✅ Packages published" - - type: "section" - text: - type: "mrkdwn" - text: "*Release SHA:* `${{ needs.validate-release-sha.outputs.release_sha }}`\n\n*Packages:*\n${{ steps.detect.outputs.markdown }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>" + run: | + cp release-artifacts/release-manifest.json .release-manifest.json + node scripts/release/comment-release-issues.mjs notify-failure: needs: - validate-release-sha - print-changelog-links + - build-package-artifacts + - attest-package-sboms + - request-approval - publish-stable if: | always() && ( needs.validate-release-sha.result == 'failure' || needs.print-changelog-links.result == 'failure' || + needs.build-package-artifacts.result == 'failure' || + needs.attest-package-sboms.result == 'failure' || + needs.request-approval.result == 'failure' || needs.publish-stable.result == 'failure' ) runs-on: ubuntu-latest diff --git a/scripts/release/_shared.mjs b/scripts/release/_shared.mjs index f9f2186ea..95cceb485 100644 --- a/scripts/release/_shared.mjs +++ b/scripts/release/_shared.mjs @@ -65,7 +65,7 @@ export function getApprovedPackageByName(name) { return PUBLISHABLE_PACKAGE_MAP.get(name); } -export function escapeRegExp(value) { +function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -191,6 +191,97 @@ export function formatPackageList(packages) { return packages.map((pkg) => `- ${pkg.name}@${pkg.version}`).join("\n"); } +export function extractReleaseNotes(relativeDir, packageName, version) { + const fallback = `Published ${packageName}@${version}.`; + const changelogPath = repoPath(relativeDir, "CHANGELOG.md"); + if (!existsSync(changelogPath)) { + return fallback; + } + + const changelog = readFileSync(changelogPath, "utf8"); + const heading = new RegExp(`^##\\s+${escapeRegExp(version)}\\s*$`, "m"); + const match = heading.exec(changelog); + if (!match) { + return fallback; + } + + const afterHeading = changelog.slice(match.index); + const nextHeading = afterHeading.slice(match[0].length).search(/^##\s+/m); + const section = + nextHeading === -1 + ? afterHeading + : afterHeading.slice(0, match[0].length + nextHeading); + + return `# ${packageName}\n\n${section.trim()}`; +} + +export function packageArtifactBase(name, version) { + return `${name.replace(/^@/, "").replace(/[\\/@]/g, "-")}-${version}`; +} + +export function orderPackagesForPublish(packages) { + const packageMap = new Map( + packages.map((pkg) => [ + pkg.name, + { ...pkg, packageJson: readPackage(pkg.dir) }, + ]), + ); + const visiting = new Set(); + const visited = new Set(); + const ordered = []; + + function visit(pkg) { + if (visited.has(pkg.name)) { + return; + } + + if (visiting.has(pkg.name)) { + throw new Error( + `Detected a publish dependency cycle involving ${pkg.name}`, + ); + } + + visiting.add(pkg.name); + + for (const dependencyName of getWorkspaceReleaseDependencies( + pkg.packageJson, + )) { + const dependency = packageMap.get(dependencyName); + if (dependency) { + visit(dependency); + } + } + + visiting.delete(pkg.name); + visited.add(pkg.name); + ordered.push(pkg); + } + + for (const pkg of packageMap.values()) { + visit(pkg); + } + + return ordered.map(({ packageJson: _packageJson, ...pkg }) => pkg); +} + +function getWorkspaceReleaseDependencies(packageJson) { + const dependencyNames = new Set(); + + for (const field of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", + ]) { + for (const dependencyName of Object.keys(packageJson[field] ?? {})) { + dependencyNames.add(dependencyName); + } + } + + dependencyNames.delete(packageJson.name); + return dependencyNames; +} + export function isPublishedToNpm(name, version) { const result = spawnSync( "npm", diff --git a/scripts/release/comment-release-issues.mjs b/scripts/release/comment-release-issues.mjs index 37a31944a..8b3b48ef1 100644 --- a/scripts/release/comment-release-issues.mjs +++ b/scripts/release/comment-release-issues.mjs @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; const { packages = [] } = JSON.parse( @@ -123,6 +124,15 @@ let posted = 0; for (const [issueNumber, comment] of [...issueComments].sort( ([left], [right]) => left - right, )) { + const releaseTags = [...comment.releases.keys()].sort(); + const marker = ``; + if (await hasReleaseComment(issueNumber, marker)) { + console.log(`Issue #${issueNumber} already has this release comment.`); + continue; + } + const releases = [...comment.releases.values()] .sort((left, right) => left.label.localeCompare(right.label)) .map((release) => `- [${release.label}](${release.url})`) @@ -141,7 +151,9 @@ for (const [issueNumber, comment] of [...issueComments].sort( ${releases} -Included via ${pullRequests}.`, +Included via ${pullRequests}. + +${marker}`, }), }, ); @@ -151,6 +163,27 @@ Included via ${pullRequests}.`, console.log(`Posted ${posted} release issue comment(s).`); +async function hasReleaseComment(issueNumber, marker) { + for (let page = 1; ; page += 1) { + const response = await fetchGithub( + `/repos/braintrustdata/braintrust-sdk-javascript/issues/${issueNumber}/comments?per_page=100&page=${page}`, + { method: "GET" }, + ); + const comments = await response.json(); + if (!Array.isArray(comments)) { + throw new Error( + `Expected an array of comments for issue #${issueNumber}`, + ); + } + if (comments.some((comment) => comment.body?.includes(marker))) { + return true; + } + if (comments.length < 100) { + return false; + } + } +} + async function fetchGithub(endpoint, options) { const response = await fetch(`https://api.github.com${endpoint}`, { method: options.method, diff --git a/scripts/release/create-github-releases.mjs b/scripts/release/create-github-releases.mjs deleted file mode 100644 index 3975ebcf3..000000000 --- a/scripts/release/create-github-releases.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; - -import { escapeRegExp, parseArgs } from "./_shared.mjs"; - -const args = parseArgs(); -const manifestPath = args.manifest ?? ".release-manifest.json"; -const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); -const token = process.env.GITHUB_TOKEN; -const repository = process.env.GITHUB_REPOSITORY; - -if (!token || !repository) { - throw new Error("GITHUB_TOKEN and GITHUB_REPOSITORY must be set"); -} - -if ((manifest.packages ?? []).length === 0) { - console.log("No GitHub releases to create."); - process.exit(0); -} - -for (const pkg of manifest.packages) { - const tag = pkg.tag ?? `${pkg.name}@${pkg.version}`; - const existing = await fetchGithub( - `/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, - token, - { method: "GET", allow404: true }, - ); - - if (existing.status === 200) { - console.log(`GitHub release already exists for ${tag}`); - continue; - } - - await fetchGithub(`/repos/${repository}/releases`, token, { - method: "POST", - body: JSON.stringify({ - tag_name: tag, - name: tag, - body: extractReleaseNotes(pkg.dir, pkg.name, pkg.version), - draft: false, - prerelease: false, - generate_release_notes: false, - }), - }); - - console.log(`Created GitHub release for ${tag}`); -} - -function extractReleaseNotes(relativeDir, packageName, version) { - const changelogPath = `${relativeDir}/CHANGELOG.md`; - if (!existsSync(changelogPath)) { - return `Published ${packageName}@${version}.`; - } - - const changelog = readFileSync(changelogPath, "utf8"); - const heading = new RegExp(`^##\\s+${escapeRegExp(version)}\\s*$`, "m"); - const match = heading.exec(changelog); - if (!match) { - return `Published ${packageName}@${version}.`; - } - - const start = match.index; - const afterHeading = changelog.slice(start); - const nextHeading = afterHeading.slice(match[0].length).search(/^##\s+/m); - const section = - nextHeading === -1 - ? afterHeading - : afterHeading.slice(0, match[0].length + nextHeading); - - return `# ${packageName}\n\n${section.trim()}`; -} - -async function fetchGithub(endpoint, authToken, options) { - const response = await fetch(`https://api.github.com${endpoint}`, { - method: options.method, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${authToken}`, - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - }, - body: options.body, - }); - - if (options.allow404 && response.status === 404) { - return response; - } - - if (!response.ok) { - const body = await response.text(); - throw new Error( - `${options.method} ${endpoint} failed: ${response.status} ${body}`, - ); - } - - return response; -} diff --git a/scripts/release/pack-publishable-packages.mjs b/scripts/release/pack-publishable-packages.mjs index fe73eeff8..fbf0ff86e 100644 --- a/scripts/release/pack-publishable-packages.mjs +++ b/scripts/release/pack-publishable-packages.mjs @@ -1,11 +1,23 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; import path from "node:path"; import { PUBLISHABLE_PACKAGES, appendSummary, + orderPackagesForPublish, + packageArtifactBase, parseArgs, + readPackage, repoPath, } from "./_shared.mjs"; @@ -14,42 +26,100 @@ const outputDir = args["output-dir"] ?? "artifacts/release-packages"; const manifestPath = args.manifest; const reportPath = args.report ?? path.posix.join(outputDir, "pack-report.json"); +const PACKAGING_PNPM_VERSION = "11.9.0"; const targets = getTargets(manifestPath); -mkdirSync(repoPath(outputDir), { recursive: true }); - -const tarballs = []; -for (const target of targets) { - const relativeOutputDir = path.posix.relative(target.dir, outputDir); - const tarball = execFileSync( - "npm", - ["pack", "--pack-destination", relativeOutputDir], - { - cwd: repoPath(target.dir), - encoding: "utf8", - }, - ).trim(); - - tarballs.push({ - name: target.name, - dir: target.dir, - version: target.version, - tarball, - }); -} +const absoluteOutputDir = repoPath(outputDir); +mkdirSync(absoluteOutputDir, { recursive: true }); + +const artifacts = targets.map(packPackage); writeFileSync( repoPath(reportPath), - `${JSON.stringify({ tarballs }, null, 2)}\n`, + `${JSON.stringify({ artifacts }, null, 2)}\n`, "utf8", ); -console.log(`Packed ${tarballs.length} package(s) into ${outputDir}`); +console.log(`Packed ${artifacts.length} package(s) into ${outputDir}`); appendSummary( - `## Packed publishable packages\n\n${tarballs - .map((entry) => `- ${entry.name}@${entry.version}: ${entry.tarball}`) + `## Packed publishable packages\n\n${artifacts + .map( + (entry) => + `- ${entry.name}@${entry.version}: ${entry.tarball}, ${entry.sbom}`, + ) .join("\n")}`, ); +function packPackage(target) { + const packDir = mkdtempSync(path.join(os.tmpdir(), "braintrust-pack-")); + try { + runPackagingPnpm(["pack", "--pack-destination", packDir], { + cwd: repoPath(target.dir), + }); + + const packedTarballs = readdirSync(packDir).filter((file) => + file.endsWith(".tgz"), + ); + if (packedTarballs.length !== 1) { + throw new Error( + `Expected pnpm pack for ${target.name} to create one tarball, found ${packedTarballs.length}`, + ); + } + + const tarballAsset = + target.tarball_asset ?? + `${packageArtifactBase(target.name, target.version)}.tgz`; + const sbomAsset = + target.sbom_asset ?? + `${packageArtifactBase(target.name, target.version)}.sbom.json`; + for (const asset of [tarballAsset, sbomAsset]) { + if (path.basename(asset) !== asset) { + throw new Error(`Release artifact must be a basename: ${asset}`); + } + } + + const tarballPath = path.join(absoluteOutputDir, tarballAsset); + const sbomPath = path.join(absoluteOutputDir, sbomAsset); + renameSync(path.join(packDir, packedTarballs[0]), tarballPath); + runPackagingPnpm( + [ + "--filter", + target.name, + "sbom", + "--sbom-format", + "cyclonedx", + "--prod", + "--out", + sbomPath, + ], + { cwd: repoPath() }, + ); + + const sbom = JSON.parse(readFileSync(sbomPath, "utf8")); + const rootComponent = sbom.metadata?.component; + const rootPackageName = rootComponent?.group + ? `${rootComponent.group}/${rootComponent.name}` + : rootComponent?.name; + if ( + rootPackageName !== target.name || + rootComponent?.version !== target.version + ) { + throw new Error( + `Expected SBOM for ${target.name}@${target.version}, found ${rootPackageName}@${rootComponent?.version}`, + ); + } + + return { + name: target.name, + dir: target.dir, + version: target.version, + tarball: path.relative(repoPath(), tarballPath), + sbom: path.relative(repoPath(), sbomPath), + }; + } finally { + rmSync(packDir, { force: true, recursive: true }); + } +} + function getTargets(maybeManifestPath) { if (!maybeManifestPath) { return PUBLISHABLE_PACKAGES.map((pkg) => @@ -57,26 +127,42 @@ function getTargets(maybeManifestPath) { ); } - const manifest = JSON.parse( + const releaseManifest = JSON.parse( readFileSync(repoPath(maybeManifestPath), "utf8"), ); - return manifest.packages.map((pkg) => readPackageInfo(pkg.dir, pkg.name)); + return orderPackagesForPublish(releaseManifest.packages ?? []).map((pkg) => + readPackageInfo(pkg.dir, pkg.name, pkg), + ); } -function readPackageInfo(relativeDir, expectedName) { - const manifest = JSON.parse( - readFileSync(repoPath(relativeDir, "package.json"), "utf8"), - ); +function readPackageInfo(relativeDir, expectedName, releasePackage = {}) { + const packageJson = readPackage(relativeDir); - if (manifest.name !== expectedName) { + if (packageJson.name !== expectedName) { throw new Error( - `Expected ${relativeDir} to be ${expectedName}, found ${manifest.name}`, + `Expected ${relativeDir} to be ${expectedName}, found ${packageJson.name}`, ); } return { + ...releasePackage, dir: relativeDir, - name: manifest.name, - version: manifest.version, + name: packageJson.name, + version: packageJson.version, }; } + +function runPackagingPnpm(pnpmArgs, options) { + execFileSync( + "corepack", + [`pnpm@${PACKAGING_PNPM_VERSION}`, "--pm-on-fail=ignore", ...pnpmArgs], + { + ...options, + env: { + ...process.env, + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + }, + stdio: "inherit", + }, + ); +} diff --git a/scripts/release/publish-release-manifest.mjs b/scripts/release/publish-release-manifest.mjs index 22631221a..44500a5dd 100644 --- a/scripts/release/publish-release-manifest.mjs +++ b/scripts/release/publish-release-manifest.mjs @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { isPublishedToNpm, + orderPackagesForPublish, parseArgs, readPackage, repoPath, @@ -49,66 +50,3 @@ for (const pkg of packages) { stdio: "inherit", }); } - -function orderPackagesForPublish(packages) { - const packageMap = new Map( - packages.map((pkg) => [ - pkg.name, - { ...pkg, manifest: readPackage(pkg.dir) }, - ]), - ); - const visiting = new Set(); - const visited = new Set(); - const ordered = []; - - for (const pkg of packageMap.values()) { - visit(pkg); - } - - return ordered.map(({ manifest: _manifest, ...pkg }) => pkg); - - function visit(pkg) { - if (visited.has(pkg.name)) { - return; - } - - if (visiting.has(pkg.name)) { - throw new Error( - `Detected a publish dependency cycle involving ${pkg.name}`, - ); - } - - visiting.add(pkg.name); - - for (const dependencyName of getWorkspaceReleaseDependencies( - pkg.manifest, - )) { - const dependency = packageMap.get(dependencyName); - if (dependency) { - visit(dependency); - } - } - - visiting.delete(pkg.name); - visited.add(pkg.name); - ordered.push(pkg); - } -} - -function getWorkspaceReleaseDependencies(manifest) { - const dependencyNames = new Set(); - - for (const field of [ - "dependencies", - "optionalDependencies", - "peerDependencies", - "devDependencies", - ]) { - for (const dependencyName of Object.keys(manifest[field] ?? {})) { - dependencyNames.add(dependencyName); - } - } - - dependencyNames.delete(manifest.name); - return dependencyNames; -} diff --git a/scripts/release/release-manifest.mjs b/scripts/release/release-manifest.mjs index 65f6e4a8a..50f2c5073 100644 --- a/scripts/release/release-manifest.mjs +++ b/scripts/release/release-manifest.mjs @@ -1,13 +1,16 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { PUBLISHABLE_PACKAGES, + extractReleaseNotes, filterPublishableReleases, formatPackageList, getApprovedPackageByName, getReleaseTag, isPublishedToNpm, + orderPackagesForPublish, + packageArtifactBase, parseArgs, readPackage, writeGithubOutput, @@ -99,50 +102,136 @@ function handleStableManifest({ outputPath: currentOutputPath, title: currentTitle, }) { - const packages = getUnpublishedPackages(); + const packages = orderPackagesForPublish(getStableReleasePackages()); const hasWork = packages.length > 0; - - writeManifestFile(currentOutputPath, { + const releaseManifest = { mode: currentMode, commit: headCommit, packages, - }); + }; + + writeManifestFile(currentOutputPath, releaseManifest); const markdownList = packagesToMarkdown(packages); const plainList = packagesToPlain(packages); writeGithubOutput("has_work", hasWork); - writeGithubOutput("needs_publish", hasWork); + writeGithubOutput("needs_artifacts", hasWork); writeGithubOutput("package_count", packages.length); + writeGithubOutput("manifest_json", JSON.stringify(releaseManifest)); writeGithubOutput("title", currentTitle); writeGithubOutput("markdown", markdownList); writeGithubOutput("plain", plainList); if (!hasWork) { - console.log("No unpublished package versions were found at this ref."); + console.log("No stable release work was found at this ref."); process.exit(0); } console.log( - `Stable release work detected for ${packages.length} unpublished package version(s):\n${formatPackageList(packages)}`, + `Stable release work detected for ${packages.length} package version(s):\n${formatPackageList(packages)}`, ); } -function getUnpublishedPackages() { - return PUBLISHABLE_PACKAGES.map((approved) => { - const manifest = readPackage(approved.dir); - if (isPublishedToNpm(manifest.name, manifest.version)) { - return null; +function getStableReleasePackages() { + const packages = []; + + for (const approvedPackage of PUBLISHABLE_PACKAGES) { + const packageJson = readPackage(approvedPackage.dir); + const alreadyPublished = isPublishedToNpm( + packageJson.name, + packageJson.version, + ); + const tag = getReleaseTag(packageJson.name, packageJson.version); + const tagCommit = getTagCommit(tag); + + if (tagCommit && tagCommit !== headCommit) { + if (!alreadyPublished) { + throw new Error( + `${tag} points to ${tagCommit}, but ${packageJson.name}@${packageJson.version} is not published`, + ); + } + continue; } - return { - dir: approved.dir, - name: manifest.name, - version: manifest.version, - tag: getReleaseTag(manifest.name, manifest.version), - published: false, - }; - }).filter(Boolean); + if (!tagCommit && alreadyPublished) { + console.warn( + `Skipping ${tag}: the package is already published but the release tag is missing`, + ); + continue; + } + + packages.push( + buildStablePackageEntry( + approvedPackage.dir, + packageJson, + alreadyPublished, + ), + ); + } + + return packages; +} + +function getTagCommit(tag) { + const tagRef = `refs/tags/${tag}`; + const tagExists = spawnSync( + "git", + ["show-ref", "--verify", "--quiet", tagRef], + { + encoding: "utf8", + }, + ); + if (tagExists.error) { + throw tagExists.error; + } + if (tagExists.status === 1) { + return null; + } + if (tagExists.status !== 0) { + throw new Error(`Failed to inspect release tag ${tag}`); + } + + return execFileSync("git", ["rev-list", "-n", "1", tagRef], { + encoding: "utf8", + }).trim(); +} + +function buildStablePackageEntry(dir, packageJson, alreadyPublished) { + const tag = getReleaseTag(packageJson.name, packageJson.version); + const artifactBase = packageArtifactBase( + packageJson.name, + packageJson.version, + ); + const channel = "latest"; + const npmVersionUrl = `https://www.npmjs.com/package/${encodeURIComponent(packageJson.name)}/v/${packageJson.version}`; + + return { + dir, + name: packageJson.name, + version: packageJson.version, + label: packageJson.name, + tag, + tarball_asset: `${artifactBase}.tgz`, + sbom_asset: `${artifactBase}.sbom.json`, + release_title: tag, + release_body: extractReleaseNotes( + dir, + packageJson.name, + packageJson.version, + ), + channel, + provenance: packageJson.publishConfig?.provenance ?? true, + registries: { + npm: { + name: packageJson.name, + version: packageJson.version, + version_url: npmVersionUrl, + channel, + already_published: alreadyPublished, + }, + }, + }; } function writeManifestFile(currentOutputPath, manifest) {