diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7c7b0c69a..c8f5632b27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,88 +8,450 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +permissions: {} + env: - NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + # Must match scripts/release/published-packages.json. + PUBLISHED_PACKAGES_SHA256: '42960df4a8848378e251c847b201826dc2990e64c6c3c76b8769e02deb35087c' SERVER_PRESET: 'node-server' -permissions: - contents: write - id-token: write - pull-requests: write - jobs: - release: - name: Release - if: "!contains(github.event.head_commit.message, 'ci: changeset release')" + preflight: + name: Preflight runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should_release: ${{ steps.release.outputs.should_release }} + has_changesets: ${{ steps.release.outputs.has_changesets }} + release_mode: ${{ steps.release.outputs.release_mode }} + dist_tag: ${{ steps.release.outputs.dist_tag }} + latest: ${{ steps.release.outputs.latest }} + prerelease: ${{ steps.release.outputs.prerelease }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 0 - persist-credentials: true # release job pushes version changes - - name: Check for changesets - id: changesets + fetch-depth: 1 + persist-credentials: false + + - name: Determine release inputs + id: release + env: + HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} run: | - CHANGESET_FILES=$(ls .changeset/*.md 2>/dev/null | grep -v README.md || true) - if [ -z "$CHANGESET_FILES" ]; then - echo "has_changesets=false" >> "$GITHUB_OUTPUT" - else - echo "has_changesets=true" >> "$GITHUB_OUTPUT" + set -euo pipefail + + SHOULD_RELEASE=true + if [[ "${HEAD_COMMIT_MESSAGE:-}" == *"ci: changeset release"* ]]; then + SHOULD_RELEASE=false fi - - name: Start Nx Agents - if: steps.changesets.outputs.has_changesets == 'true' - run: npx nx-cloud start-ci-run --distribute-on=".nx/workflows/dynamic-changesets.yaml" + + RELEASE_MODE= + DIST_TAG= + LATEST=false + PRERELEASE=false + + case "$GITHUB_REF_NAME" in + main) + RELEASE_MODE=latest + DIST_TAG=latest + LATEST=true + ;; + *-pre) + RELEASE_MODE=pre + PRERELEASE=true + if [ -f .changeset/pre.json ]; then + DIST_TAG="$(jq -r '.tag' .changeset/pre.json)" + else + DIST_TAG=pre + fi + ;; + *-maint) + RELEASE_MODE=maint + DIST_TAG=maint + ;; + *) + echo "Unexpected release branch: $GITHUB_REF_NAME" >&2 + exit 1 + ;; + esac + + HAS_CHANGESETS=false + for file in .changeset/*.md; do + [ -e "$file" ] || continue + [ "$file" = ".changeset/README.md" ] && continue + HAS_CHANGESETS=true + break + done + + echo "should_release=$SHOULD_RELEASE" >> "$GITHUB_OUTPUT" + echo "has_changesets=$HAS_CHANGESETS" >> "$GITHUB_OUTPUT" + echo "release_mode=$RELEASE_MODE" >> "$GITHUB_OUTPUT" + echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT" + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" + + test: + name: Test + needs: preflight + if: needs.preflight.outputs.should_release == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + env: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - name: Setup Tools + if: needs.preflight.outputs.has_changesets == 'true' uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main + + - name: Start Nx Agents + if: needs.preflight.outputs.has_changesets == 'true' + run: pnpm exec nx-cloud start-ci-run --distribute-on=".nx/workflows/dynamic-changesets.yaml" + - name: Run Tests - if: steps.changesets.outputs.has_changesets == 'true' + if: needs.preflight.outputs.has_changesets == 'true' run: pnpm run test:ci --parallel=3 + - name: Stop Nx Agents - if: ${{ always() && steps.changesets.outputs.has_changesets == 'true' }} - run: npx nx-cloud stop-all-agents + if: ${{ always() && needs.preflight.outputs.has_changesets == 'true' }} + run: pnpm exec nx-cloud stop-all-agents + + - name: No changesets + if: needs.preflight.outputs.has_changesets != 'true' + run: | + echo "No changesets found; skipping release tests." + + version: + name: Version + needs: [preflight, test] + if: needs.preflight.outputs.should_release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + outputs: + committed: ${{ steps.commit.outputs.committed }} + release_commit: ${{ steps.commit.outputs.release_commit }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@739bfe42ca9233c5e6aca07c1a25a9d34aca49b0 # v6.0.7 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + + - name: Install dependencies without lifecycle scripts + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Enter Pre-Release Mode - if: "contains(github.ref_name, '-pre') && !hashFiles('.changeset/pre.json')" - run: pnpm changeset pre enter pre + if: needs.preflight.outputs.release_mode == 'pre' + run: | + if [ ! -f .changeset/pre.json ]; then + pnpm changeset pre enter pre + fi + - name: Version Packages run: pnpm run changeset:version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + npm_config_ignore_scripts: 'true' + PNPM_CONFIG_IGNORE_SCRIPTS: 'true' + - name: Commit and Push Version Changes id: commit + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail + git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add . - if git commit -m "ci: changeset release"; then - git push origin "HEAD:${GITHUB_REF_NAME}" - echo "committed=true" >> "$GITHUB_OUTPUT" + + if git diff --cached --quiet; then + echo "committed=false" >> "$GITHUB_OUTPUT" + echo "release_commit=" >> "$GITHUB_OUTPUT" + exit 0 fi - - name: Determine dist-tag - if: steps.commit.outputs.committed == 'true' - id: dist-tag + + git commit -m "ci: changeset release" + RELEASE_COMMIT="$(git rev-parse HEAD)" + git -c "http.https://github.com/.extraheader=AUTHORIZATION: bearer ${GITHUB_TOKEN}" push origin "HEAD:${GITHUB_REF_NAME}" + + echo "committed=true" >> "$GITHUB_OUTPUT" + echo "release_commit=$RELEASE_COMMIT" >> "$GITHUB_OUTPUT" + + package: + name: Package + needs: [preflight, version] + if: needs.preflight.outputs.should_release == 'true' && needs.version.outputs.committed == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + env: + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + outputs: + package_count: ${{ steps.manifest.outputs.package_count }} + steps: + - name: Checkout release commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.version.outputs.release_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Tools + uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main + + - name: Build Packages + run: pnpm run build:all + + - name: Create release tarballs and manifest + id: manifest + env: + DIST_TAG: ${{ needs.preflight.outputs.dist_tag }} + RELEASE_MODE: ${{ needs.preflight.outputs.release_mode }} + HAS_CHANGESETS: ${{ needs.preflight.outputs.has_changesets }} run: | - BRANCH="${GITHUB_REF_NAME}" - if [[ "$BRANCH" == *-pre ]]; then - echo "prerelease=true" >> "$GITHUB_OUTPUT" - elif [[ "$BRANCH" == *-maint ]]; then - echo "tag=maint" >> "$GITHUB_OUTPUT" - else - echo "latest=true" >> "$GITHUB_OUTPUT" - fi - - name: Publish Packages - if: steps.commit.outputs.committed == 'true' + set -euo pipefail + + node scripts/release/create-release-manifest.mjs + PACKAGE_COUNT="$(jq -r '.packageCount' release-artifacts/release-manifest.json)" + echo "package_count=$PACKAGE_COUNT" >> "$GITHUB_OUTPUT" + + - name: Upload release artifacts + if: steps.manifest.outputs.package_count != '0' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-artifacts + path: | + release-artifacts/release-manifest.json + release-artifacts/published-packages.json + release-artifacts/tarballs/*.tgz + retention-days: 2 + if-no-files-found: error + + review: + name: Review Tarballs + needs: [preflight, version, package] + if: needs.package.outputs.package_count != '0' + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - name: Checkout release commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.version.outputs.release_commit }} + fetch-depth: 1 + persist-credentials: false + + - name: Download release artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: release-artifacts + path: release-artifacts + + - name: Review packed tarballs + run: node scripts/release/review-tarballs.mjs release-artifacts + + - name: Upload review report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-review + path: release-artifacts/release-review.md + retention-days: 2 + if-no-files-found: error + + publish: + name: Publish + needs: [preflight, version, package, review] + if: needs.package.outputs.package_count != '0' + runs-on: ubuntu-latest + environment: npm-publish + permissions: + actions: read + id-token: write + steps: + - name: Download release artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: release-artifacts + path: release-artifacts + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.8.0 + package-manager-cache: false + + - name: Verify release manifest env: - DIST_TAG: ${{ steps.dist-tag.outputs.tag }} + EXPECTED_REF_NAME: ${{ github.ref_name }} + EXPECTED_COMMIT: ${{ needs.version.outputs.release_commit }} + EXPECTED_RELEASE_MODE: ${{ needs.preflight.outputs.release_mode }} + EXPECTED_DIST_TAG: ${{ needs.preflight.outputs.dist_tag }} + EXPECTED_ALLOWLIST_SHA256: ${{ env.PUBLISHED_PACKAGES_SHA256 }} run: | - if [ -n "$DIST_TAG" ]; then - pnpm run changeset:publish --tag "$DIST_TAG" - else - pnpm run changeset:publish + set -euo pipefail + + test "$(jq -r '.schemaVersion' release-artifacts/release-manifest.json)" = "1" + test "$(jq -r '.refName' release-artifacts/release-manifest.json)" = "$EXPECTED_REF_NAME" + test "$(jq -r '.commit' release-artifacts/release-manifest.json)" = "$EXPECTED_COMMIT" + test "$(jq -r '.releaseMode' release-artifacts/release-manifest.json)" = "$EXPECTED_RELEASE_MODE" + test "$(jq -r '.distTag' release-artifacts/release-manifest.json)" = "$EXPECTED_DIST_TAG" + test "$(jq -r '.hasChangesets' release-artifacts/release-manifest.json)" = "true" + test "$(jq -r '.allowlistSha256' release-artifacts/release-manifest.json)" = "$EXPECTED_ALLOWLIST_SHA256" + test "$(sha256sum release-artifacts/published-packages.json | cut -d ' ' -f 1)" = "$EXPECTED_ALLOWLIST_SHA256" + + PACKAGE_COUNT="$(jq -r '.packageCount' release-artifacts/release-manifest.json)" + test "$PACKAGE_COUNT" != "0" + test "$PACKAGE_COUNT" = "$(jq -r '.packages | length' release-artifacts/release-manifest.json)" + + DUPLICATE_PACKAGES="$(jq -r '.packages | group_by(.name)[] | select(length > 1) | .[0].name' release-artifacts/release-manifest.json)" + if [ -n "$DUPLICATE_PACKAGES" ]; then + echo "Duplicate packages in manifest:" >&2 + echo "$DUPLICATE_PACKAGES" >&2 + exit 1 fi + + while IFS=$'\t' read -r name package_path dist_tag tarball expected_sha; do + if [ "$(jq -r --arg name "$name" '.[$name] // empty' release-artifacts/published-packages.json)" != "$package_path" ]; then + echo "Unexpected package in manifest: $name" >&2 + exit 1 + fi + + if [ "$dist_tag" != "$EXPECTED_DIST_TAG" ]; then + echo "Unexpected dist-tag for $name: $dist_tag" >&2 + exit 1 + fi + + tarball_path="release-artifacts/tarballs/$tarball" + if [ ! -f "$tarball_path" ]; then + echo "Missing tarball: $tarball" >&2 + exit 1 + fi + + actual_sha="$(sha256sum "$tarball_path" | cut -d ' ' -f 1)" + if [ "$actual_sha" != "$expected_sha" ]; then + echo "SHA256 mismatch for $tarball" >&2 + exit 1 + fi + done < <(jq -r '.packages[] | [.name, .path, .distTag, .tarball, .sha256] | @tsv' release-artifacts/release-manifest.json) + + - name: Publish verified tarballs + env: + npm_config_ignore_scripts: 'true' + run: | + set -euo pipefail + + jq -r '.packages[] | [.name, .version, .distTag, .tarball] | @tsv' release-artifacts/release-manifest.json > /tmp/packages-to-publish.tsv + + while IFS=$'\t' read -r name version dist_tag tarball; do + if npm view "${name}@${version}" version >/dev/null 2>&1; then + echo "${name}@${version} already exists on npm" >&2 + exit 1 + fi + done < /tmp/packages-to-publish.tsv + + while IFS=$'\t' read -r name version dist_tag tarball; do + echo "Publishing ${name}@${version} with dist-tag ${dist_tag}" + npm publish "release-artifacts/tarballs/${tarball}" --tag "$dist_tag" --access public --provenance --ignore-scripts + done < /tmp/packages-to-publish.tsv + + while IFS=$'\t' read -r name version dist_tag tarball; do + published_version= + for attempt in 1 2 3 4 5; do + published_version="$(npm view "${name}@${version}" version 2>/dev/null || true)" + if [ "$published_version" = "$version" ]; then + break + fi + sleep 10 + done + + if [ "$published_version" != "$version" ]; then + echo "Failed to verify ${name}@${version} on npm" >&2 + exit 1 + fi + done < /tmp/packages-to-publish.tsv + + tag-packages: + name: Tag Packages + needs: [version, package, publish] + if: needs.package.outputs.package_count != '0' + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + steps: + - name: Checkout release commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.version.outputs.release_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Download release artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: release-artifacts + path: release-artifacts + + - name: Create and push package tags + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/release/tag-packages.mjs release-artifacts/release-manifest.json + + github-release: + name: GitHub Release + needs: [preflight, version, package, publish, tag-packages] + if: needs.package.outputs.package_count != '0' + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + steps: + - name: Checkout release commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.version.outputs.release_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Download release artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: release-artifacts + path: release-artifacts + + - name: Download review report + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: release-review + path: release-artifacts + - name: Create GitHub Release - if: steps.commit.outputs.committed == 'true' - run: node scripts/create-github-release.mjs ${{ steps.dist-tag.outputs.prerelease == 'true' && '--prerelease' }} ${{ steps.dist-tag.outputs.latest == 'true' && '--latest' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PRERELEASE_FLAG: ${{ needs.preflight.outputs.prerelease == 'true' && '--prerelease' || '' }} + LATEST_FLAG: ${{ needs.preflight.outputs.latest == 'true' && '--latest' || '' }} + run: node scripts/create-github-release.mjs --manifest release-artifacts/release-manifest.json --review release-artifacts/release-review.md $PRERELEASE_FLAG $LATEST_FLAG diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs index 1ddcac4d4f..7f08435599 100644 --- a/scripts/create-github-release.mjs +++ b/scripts/create-github-release.mjs @@ -1,15 +1,71 @@ -// @ts-nocheck import fs from 'fs' import path from 'node:path' import { globSync } from 'node:fs' import { execSync, execFileSync } from 'node:child_process' import { tmpdir } from 'node:os' +import { parseArgs } from 'node:util' + +/** + * @typedef {object} ManifestPackage + * @property {string} name + * @property {string} version + * @property {string | null} previousVersion + * @property {string} path + */ + +/** + * @typedef {object} ReleaseManifest + * @property {string} commit + * @property {string} refName + * @property {string} distTag + * @property {Array} packages + */ const rootDir = path.join(import.meta.dirname, '..') const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN +const { values } = parseArgs({ + options: { + latest: { type: 'boolean', default: false }, + manifest: { type: 'string' }, + prerelease: { type: 'boolean', default: false }, + review: { type: 'string' }, + }, +}) + +/** + * @param {Array} args + * @param {import('node:child_process').ExecFileSyncOptions=} options + */ +function gitPush(args, options = {}) { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN + const gitArgs = token + ? [ + '-c', + `http.https://github.com/.extraheader=AUTHORIZATION: bearer ${token}`, + 'push', + ...args, + ] + : ['push', ...args] + + execFileSync('git', gitArgs, { + cwd: rootDir, + stdio: options.stdio ?? 'inherit', + }) +} + +const manifestPath = values.manifest ?? null +const reviewPath = values.review ?? null +/** @type {ReleaseManifest | null} */ +const manifest = manifestPath + ? JSON.parse(fs.readFileSync(path.resolve(rootDir, manifestPath), 'utf-8')) + : null // Resolve GitHub usernames from commit author emails const usernameCache = {} +/** + * @param {string} email + * @returns {Promise} + */ async function resolveUsername(email) { if (!ghToken || !email) return null if (usernameCache[email] !== undefined) return usernameCache[email] @@ -30,6 +86,10 @@ async function resolveUsername(email) { // Resolve author from a PR number via GitHub API const prAuthorCache = {} +/** + * @param {string} prNumber + * @returns {Promise} + */ async function resolveAuthorForPR(prNumber) { if (prAuthorCache[prNumber] !== undefined) return prAuthorCache[prNumber] @@ -64,38 +124,55 @@ const releaseLogs = execSync( .split('\n') .filter(Boolean) -const currentRelease = releaseLogs[0] || 'HEAD' -const previousRelease = releaseLogs[1] +const currentRelease = manifest?.commit || releaseLogs[0] || 'HEAD' +const previousRelease = releaseLogs.find((hash) => hash !== currentRelease) // Find packages that were actually bumped by comparing versions const packagesDir = path.join(rootDir, 'packages') const allPkgJsonPaths = globSync('*/package.json', { cwd: packagesDir }) -const bumpedPackages = [] -for (const relPath of allPkgJsonPaths) { - const fullPath = path.join(packagesDir, relPath) - const currentPkg = JSON.parse(fs.readFileSync(fullPath, 'utf-8')) - if (currentPkg.private) continue - - // Get the version from the previous release commit - if (previousRelease) { - try { - const prevContent = execFileSync( - 'git', - ['show', `${previousRelease}:packages/${relPath}`], - { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }, - ) - const prevPkg = JSON.parse(prevContent) - if (prevPkg.version !== currentPkg.version) { +let bumpedPackages = [] +if (manifest) { + bumpedPackages = manifest.packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + prevVersion: pkg.previousVersion, + dir: pkg.path.replace(/^packages\//, ''), + })) +} else { + for (const relPath of allPkgJsonPaths) { + const fullPath = path.join(packagesDir, relPath) + const currentPkg = JSON.parse(fs.readFileSync(fullPath, 'utf-8')) + if (currentPkg.private) continue + + // Get the version from the previous release commit + if (previousRelease) { + try { + const prevContent = execFileSync( + 'git', + ['show', `${previousRelease}:packages/${relPath}`], + { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }, + ) + const prevPkg = JSON.parse(prevContent) + if (prevPkg.version !== currentPkg.version) { + bumpedPackages.push({ + name: currentPkg.name, + version: currentPkg.version, + prevVersion: prevPkg.version, + dir: path.dirname(relPath), + }) + } + } catch { + // Package didn't exist in previous release — it's new bumpedPackages.push({ name: currentPkg.name, version: currentPkg.version, - prevVersion: prevPkg.version, + prevVersion: null, dir: path.dirname(relPath), }) } - } catch { - // Package didn't exist in previous release — it's new + } else { + // No previous release — include all non-private packages bumpedPackages.push({ name: currentPkg.name, version: currentPkg.version, @@ -103,14 +180,6 @@ for (const relPath of allPkgJsonPaths) { dir: path.dirname(relPath), }) } - } else { - // No previous release — include all non-private packages - bumpedPackages.push({ - name: currentPkg.name, - version: currentPkg.version, - prevVersion: null, - dir: path.dirname(relPath), - }) } } @@ -215,11 +284,21 @@ const time = now.toISOString().slice(11, 16).replace(':', '') const tagName = `release-${date}-${time}` const titleDate = `${date} ${now.toISOString().slice(11, 16)}` -const isPrerelease = process.argv.includes('--prerelease') -const isLatest = process.argv.includes('--latest') +const isPrerelease = values.prerelease +const isLatest = values.latest +const releaseMetadata = manifest + ? `## Release Metadata + +- Branch: ${manifest.refName} +- Commit: ${manifest.commit} +- Dist tag: ${manifest.distTag} + +` + : '' const body = `Release ${titleDate} +${releaseMetadata} ## Changes ${changelogMd} @@ -241,17 +320,32 @@ try { if (!tagExists) { execSync(`git tag -a -m "${tagName}" ${tagName}`) - execSync('git push --tags') + gitPush(['origin', `refs/tags/${tagName}`]) } -const prereleaseFlag = isPrerelease ? '--prerelease' : '' -const latestFlag = isLatest ? ' --latest' : '' const tmpFile = path.join(tmpdir(), `release-notes-${tagName}.md`) fs.writeFileSync(tmpFile, body) try { - execSync( - `gh release create ${tagName} ${prereleaseFlag} --title "Release ${titleDate}" --notes-file ${tmpFile}${latestFlag}`, + const releaseAssets = [manifestPath, reviewPath] + .filter(Boolean) + .map((assetPath) => path.resolve(rootDir, assetPath)) + .filter((assetPath) => fs.existsSync(assetPath)) + + execFileSync( + 'gh', + [ + 'release', + 'create', + tagName, + ...releaseAssets, + ...(isPrerelease ? ['--prerelease'] : []), + '--title', + `Release ${titleDate}`, + '--notes-file', + tmpFile, + ...(isLatest ? ['--latest'] : []), + ], { stdio: 'inherit' }, ) console.info(`GitHub release ${tagName} created.`) @@ -260,7 +354,7 @@ try { if (!tagExists) { console.info(`Release creation failed, cleaning up tag ${tagName}...`) try { - execSync(`git push --delete origin ${tagName}`, { stdio: 'ignore' }) + gitPush(['--delete', 'origin', tagName], { stdio: 'ignore' }) execSync(`git tag -d ${tagName}`, { stdio: 'ignore' }) } catch { // Best effort cleanup diff --git a/scripts/release/create-release-manifest.mjs b/scripts/release/create-release-manifest.mjs new file mode 100644 index 0000000000..038a766ca0 --- /dev/null +++ b/scripts/release/create-release-manifest.mjs @@ -0,0 +1,345 @@ +import { hash } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +/** + * @typedef {object} PackageJson + * @property {string} name + * @property {string} version + * @property {boolean=} private + * @property {Record=} scripts + */ + +/** + * @typedef {object} WorkspacePackage + * @property {string} dirName + * @property {string} path + * @property {string} packageJsonPath + * @property {PackageJson} packageJson + */ + +/** + * @typedef {WorkspacePackage & { previousVersion: string | null }} ReleasePackage + */ + +const rootDir = path.join(import.meta.dirname, '..', '..') +const packagesDir = path.join(rootDir, 'packages') +const artifactsDir = path.join(rootDir, 'release-artifacts') +const tarballsDir = path.join(artifactsDir, 'tarballs') +const allowlistPath = path.join(import.meta.dirname, 'published-packages.json') + +const blockedLifecycleScripts = [ + 'prepublish', + 'prepublishOnly', + 'prepack', + 'prepare', + 'postpack', + 'publish', + 'postpublish', +] + +/** + * @template T + * @param {string} filePath + * @returns {T} + */ +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) +} + +/** + * @param {Array} args + * @param {import('node:child_process').ExecFileSyncOptionsWithStringEncoding=} options + * @returns {string} + */ +function git(args, options = {}) { + return execFileSync('git', args, { + cwd: rootDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }).trim() +} + +/** + * @param {string} filePath + * @returns {string} + */ +function normalizePath(filePath) { + return filePath.split(path.sep).join('/') +} + +/** + * @param {string} output + * @returns {any} + */ +function parsePackOutput(output) { + const trimmed = output.trim() + + try { + return JSON.parse(trimmed) + } catch { + const match = trimmed.match(/(\{[\s\S]*\}|\[[\s\S]*\])\s*$/) + if (!match) throw new Error(`Unable to parse pnpm pack output:\n${output}`) + return JSON.parse(match[1]) + } +} + +/** + * @param {string} filename + * @param {string} packageDir + * @returns {string} + */ +function resolvePackedTarball(filename, packageDir) { + const candidates = [] + + if (path.isAbsolute(filename)) { + candidates.push(filename) + } else { + candidates.push(path.join(tarballsDir, filename)) + candidates.push(path.join(tarballsDir, path.basename(filename))) + candidates.push(path.join(packageDir, filename)) + } + + const packedPath = candidates.find((candidate) => fs.existsSync(candidate)) + if (!packedPath) { + throw new Error( + `Could not locate tarball from pnpm pack output: ${filename}`, + ) + } + + return packedPath +} + +/** + * @returns {Array} + */ +function getWorkspacePackages() { + return fs + .readdirSync(packagesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const packageJsonPath = path.join(packagesDir, entry.name, 'package.json') + if (!fs.existsSync(packageJsonPath)) return null + + const packageJson = readJson(packageJsonPath) + if (packageJson.private) return null + + return { + dirName: entry.name, + path: normalizePath( + path.relative(rootDir, path.dirname(packageJsonPath)), + ), + packageJsonPath, + packageJson, + } + }) + .filter(Boolean) + .sort((a, b) => a.packageJson.name.localeCompare(b.packageJson.name)) +} + +/** + * @param {Array} packages + */ +function validateAllowlist(packages) { + const allowlist = readJson(allowlistPath) + const workspaceByName = new Map( + packages.map((pkg) => [pkg.packageJson.name, pkg.path]), + ) + + for (const pkg of packages) { + const allowedPath = allowlist[pkg.packageJson.name] + if (!allowedPath) { + throw new Error( + `${pkg.packageJson.name} is publishable but is missing from ${normalizePath( + path.relative(rootDir, allowlistPath), + )}`, + ) + } + + if (allowedPath !== pkg.path) { + throw new Error( + `${pkg.packageJson.name} is allowlisted for ${allowedPath}, but package is at ${pkg.path}`, + ) + } + } + + for (const [packageName, packagePath] of Object.entries(allowlist)) { + if (workspaceByName.get(packageName) !== packagePath) { + throw new Error( + `${packageName} is allowlisted for ${packagePath}, but no matching publishable package exists`, + ) + } + } +} + +/** + * @param {Array} packages + */ +function validateLifecycleScripts(packages) { + const violations = [] + + for (const pkg of packages) { + const scripts = pkg.packageJson.scripts ?? {} + for (const scriptName of blockedLifecycleScripts) { + if (scripts[scriptName]) { + violations.push(`${pkg.packageJson.name}: ${scriptName}`) + } + } + } + + if (violations.length) { + throw new Error( + `Publish-blocking lifecycle scripts were found:\n${violations.join('\n')}`, + ) + } +} + +/** + * @param {string} parentCommit + * @param {string} packagePath + * @returns {PackageJson | null} + */ +function getPreviousPackageJson(parentCommit, packagePath) { + try { + return JSON.parse( + git(['show', `${parentCommit}:${packagePath}/package.json`]), + ) + } catch { + return null + } +} + +/** + * @param {Array} packages + * @param {string} parentCommit + * @returns {Array} + */ +function getReleasePackages(packages, parentCommit) { + return packages + .map((pkg) => { + const previousPackageJson = getPreviousPackageJson(parentCommit, pkg.path) + const previousVersion = previousPackageJson?.private + ? null + : previousPackageJson?.version + + if (previousVersion === pkg.packageJson.version) return null + + return { + ...pkg, + previousVersion, + } + }) + .filter(Boolean) +} + +/** + * @param {ReleasePackage} pkg + */ +function packPackage(pkg) { + const packageDir = path.join(rootDir, pkg.path) + const output = execFileSync( + 'pnpm', + ['--dir', packageDir, 'pack', '--pack-destination', tarballsDir, '--json'], + { + cwd: rootDir, + encoding: 'utf-8', + env: { + ...process.env, + npm_config_ignore_scripts: 'true', + PNPM_CONFIG_IGNORE_SCRIPTS: 'true', + }, + stdio: ['ignore', 'pipe', 'inherit'], + }, + ) + + const packResult = parsePackOutput(output) + const packRecord = Array.isArray(packResult) ? packResult[0] : packResult + const filename = packRecord.filename ?? packRecord.name + + if (!filename) { + throw new Error( + `pnpm pack did not report a filename for ${pkg.packageJson.name}`, + ) + } + + const packedPath = resolvePackedTarball(filename, packageDir) + const tarball = path.basename(packedPath) + + return { + name: pkg.packageJson.name, + version: pkg.packageJson.version, + previousVersion: pkg.previousVersion, + path: pkg.path, + distTag, + tarball, + sha256: hash('sha256', fs.readFileSync(packedPath), 'hex'), + size: fs.statSync(packedPath).size, + } +} + +const distTag = process.env.DIST_TAG +const releaseMode = process.env.RELEASE_MODE +const hasChangesets = process.env.HAS_CHANGESETS === 'true' + +if (!distTag) throw new Error('DIST_TAG is required') +if (!releaseMode) throw new Error('RELEASE_MODE is required') + +const commit = git(['rev-parse', 'HEAD']) +const parentCommit = git(['rev-parse', 'HEAD^']) +const packages = getWorkspacePackages() + +validateAllowlist(packages) +validateLifecycleScripts(packages) + +const releasePackages = getReleasePackages(packages, parentCommit) + +if (releasePackages.length > 0 && !hasChangesets) { + throw new Error( + 'Package versions changed, but no changeset files were present', + ) +} + +fs.rmSync(artifactsDir, { recursive: true, force: true }) +fs.mkdirSync(tarballsDir, { recursive: true }) +fs.copyFileSync( + allowlistPath, + path.join(artifactsDir, 'published-packages.json'), +) + +const packedPackages = releasePackages.map(packPackage) + +const manifest = { + schemaVersion: 1, + createdAt: new Date().toISOString(), + repository: process.env.GITHUB_REPOSITORY ?? null, + workflow: process.env.GITHUB_WORKFLOW ?? null, + runId: process.env.GITHUB_RUN_ID ?? null, + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null, + ref: process.env.GITHUB_REF ?? null, + refName: process.env.GITHUB_REF_NAME ?? null, + releaseMode, + distTag, + hasChangesets, + allowlistSha256: hash('sha256', fs.readFileSync(allowlistPath), 'hex'), + commit, + parentCommit, + packageCount: packedPackages.length, + packages: packedPackages, +} + +fs.writeFileSync( + path.join(artifactsDir, 'release-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, +) + +console.info( + `Created release manifest for ${packedPackages.length} package${ + packedPackages.length === 1 ? '' : 's' + }`, +) + +for (const pkg of packedPackages) { + console.info(`${pkg.name}@${pkg.version} -> ${pkg.tarball}`) +} diff --git a/scripts/release/published-packages.json b/scripts/release/published-packages.json new file mode 100644 index 0000000000..7604aa6c07 --- /dev/null +++ b/scripts/release/published-packages.json @@ -0,0 +1,44 @@ +{ + "@tanstack/arktype-adapter": "packages/arktype-adapter", + "@tanstack/eslint-plugin-router": "packages/eslint-plugin-router", + "@tanstack/eslint-plugin-start": "packages/eslint-plugin-start", + "@tanstack/history": "packages/history", + "@tanstack/nitro-v2-vite-plugin": "packages/nitro-v2-vite-plugin", + "@tanstack/react-router": "packages/react-router", + "@tanstack/react-router-devtools": "packages/react-router-devtools", + "@tanstack/react-router-ssr-query": "packages/react-router-ssr-query", + "@tanstack/react-start": "packages/react-start", + "@tanstack/react-start-client": "packages/react-start-client", + "@tanstack/react-start-rsc": "packages/react-start-rsc", + "@tanstack/react-start-server": "packages/react-start-server", + "@tanstack/router-cli": "packages/router-cli", + "@tanstack/router-core": "packages/router-core", + "@tanstack/router-devtools": "packages/router-devtools", + "@tanstack/router-devtools-core": "packages/router-devtools-core", + "@tanstack/router-generator": "packages/router-generator", + "@tanstack/router-plugin": "packages/router-plugin", + "@tanstack/router-ssr-query-core": "packages/router-ssr-query-core", + "@tanstack/router-utils": "packages/router-utils", + "@tanstack/router-vite-plugin": "packages/router-vite-plugin", + "@tanstack/solid-router": "packages/solid-router", + "@tanstack/solid-router-devtools": "packages/solid-router-devtools", + "@tanstack/solid-router-ssr-query": "packages/solid-router-ssr-query", + "@tanstack/solid-start": "packages/solid-start", + "@tanstack/solid-start-client": "packages/solid-start-client", + "@tanstack/solid-start-server": "packages/solid-start-server", + "@tanstack/start-client-core": "packages/start-client-core", + "@tanstack/start-fn-stubs": "packages/start-fn-stubs", + "@tanstack/start-plugin-core": "packages/start-plugin-core", + "@tanstack/start-server-core": "packages/start-server-core", + "@tanstack/start-static-server-functions": "packages/start-static-server-functions", + "@tanstack/start-storage-context": "packages/start-storage-context", + "@tanstack/valibot-adapter": "packages/valibot-adapter", + "@tanstack/virtual-file-routes": "packages/virtual-file-routes", + "@tanstack/vue-router": "packages/vue-router", + "@tanstack/vue-router-devtools": "packages/vue-router-devtools", + "@tanstack/vue-router-ssr-query": "packages/vue-router-ssr-query", + "@tanstack/vue-start": "packages/vue-start", + "@tanstack/vue-start-client": "packages/vue-start-client", + "@tanstack/vue-start-server": "packages/vue-start-server", + "@tanstack/zod-adapter": "packages/zod-adapter" +} diff --git a/scripts/release/review-tarballs.mjs b/scripts/release/review-tarballs.mjs new file mode 100644 index 0000000000..6271dfd0e5 --- /dev/null +++ b/scripts/release/review-tarballs.mjs @@ -0,0 +1,230 @@ +import fs from 'node:fs' +import path from 'node:path' +import { hash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { tmpdir } from 'node:os' + +/** + * @typedef {object} ManifestPackage + * @property {string} name + * @property {string} version + * @property {string | null} previousVersion + * @property {string} distTag + * @property {string} tarball + * @property {string} sha256 + * @property {number} size + */ + +/** + * @typedef {object} ReleaseManifest + * @property {string} commit + * @property {string} refName + * @property {string} distTag + * @property {number} packageCount + * @property {Array} packages + */ + +const rootDir = path.join(import.meta.dirname, '..', '..') +const artifactsDir = path.resolve(process.argv[2] ?? 'release-artifacts') +const manifestPath = path.join(artifactsDir, 'release-manifest.json') +const tarballsDir = path.join(artifactsDir, 'tarballs') + +/** + * @template T + * @param {string} filePath + * @returns {T} + */ +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) +} + +/** + * @param {string} command + * @param {Array} args + * @param {import('node:child_process').ExecFileSyncOptionsWithStringEncoding=} options + * @returns {string} + */ +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: rootDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }).trim() +} + +/** + * @param {string} command + * @param {Array} args + * @param {import('node:child_process').ExecFileSyncOptionsWithStringEncoding=} options + * @returns {string} + */ +function runAllowFailure(command, args, options = {}) { + try { + return run(command, args, options) + } catch (error) { + return error.stdout?.toString().trim() ?? '' + } +} + +/** + * @param {string} url + * @param {string} destination + */ +async function download(url, destination) { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status}`) + } + + const buffer = Buffer.from(await response.arrayBuffer()) + fs.writeFileSync(destination, buffer) +} + +/** + * @param {string} name + * @param {string} versionOrTag + */ +function packageSpecifier(name, versionOrTag) { + return `${name}@${versionOrTag}` +} + +/** + * @param {string} value + */ +function codeBlock(value) { + return `\n\`\`\`\n${value || '(no output)'}\n\`\`\`\n` +} + +/** @type {ReleaseManifest} */ +const manifest = readJson(manifestPath) +const lines = [] +const summaryPackages = [] + +lines.push('# Release Tarball Review') +lines.push('') +lines.push(`- Commit: \`${manifest.commit}\``) +lines.push(`- Branch: \`${manifest.refName}\``) +lines.push(`- Dist tag: \`${manifest.distTag}\``) +lines.push(`- Packages: ${manifest.packageCount}`) +lines.push('') + +for (const pkg of manifest.packages) { + const tarballPath = path.join(tarballsDir, pkg.tarball) + const actualSha = hash('sha256', fs.readFileSync(tarballPath), 'hex') + + if (actualSha !== pkg.sha256) { + throw new Error( + `${pkg.tarball} SHA256 mismatch: expected ${pkg.sha256}, got ${actualSha}`, + ) + } + + const fileList = run('tar', ['-tzf', tarballPath]) + const fileCount = fileList ? fileList.split('\n').length : 0 + summaryPackages.push( + `- ${pkg.name}@${pkg.version} (${fileCount} files, ${pkg.size} bytes)`, + ) + + lines.push(`## ${pkg.name}@${pkg.version}`) + lines.push('') + lines.push(`- Previous version: ${pkg.previousVersion ?? '(new package)'}`) + lines.push(`- Tarball: \`${pkg.tarball}\``) + lines.push(`- SHA256: \`${pkg.sha256}\``) + lines.push(`- Size: ${pkg.size} bytes`) + lines.push(`- Files: ${fileCount}`) + lines.push('') + lines.push('
') + lines.push('Tarball files') + lines.push(codeBlock(fileList)) + lines.push('
') + lines.push('') + + let previousTarballUrl = '' + try { + previousTarballUrl = run('npm', [ + 'view', + packageSpecifier(pkg.name, pkg.distTag), + 'dist.tarball', + '--silent', + ]) + } catch { + lines.push( + `No existing \`${pkg.distTag}\` tarball found on npm for comparison.`, + ) + lines.push('') + continue + } + + if (!previousTarballUrl) { + lines.push( + `No existing \`${pkg.distTag}\` tarball found on npm for comparison.`, + ) + lines.push('') + continue + } + + const compareDir = fs.mkdtempSync(path.join(tmpdir(), 'release-review-')) + const previousTarballPath = path.join(compareDir, 'previous.tgz') + const previousDir = path.join(compareDir, 'previous') + const proposedDir = path.join(compareDir, 'proposed') + + fs.mkdirSync(previousDir) + fs.mkdirSync(proposedDir) + + await download(previousTarballUrl, previousTarballPath) + run('tar', ['-xzf', previousTarballPath, '-C', previousDir]) + run('tar', ['-xzf', tarballPath, '-C', proposedDir]) + + const nameStatus = runAllowFailure('git', [ + 'diff', + '--no-index', + '--name-status', + path.join(previousDir, 'package'), + path.join(proposedDir, 'package'), + ]) + + const packageJsonDiff = runAllowFailure('git', [ + 'diff', + '--no-index', + path.join(previousDir, 'package', 'package.json'), + path.join(proposedDir, 'package', 'package.json'), + ]) + + lines.push('
') + lines.push(`Changed files from npm \`${pkg.distTag}\``) + lines.push(codeBlock(nameStatus)) + lines.push('
') + lines.push('') + lines.push('
') + lines.push('package.json diff') + lines.push(codeBlock(packageJsonDiff)) + lines.push('
') + lines.push('') +} + +const markdown = `${lines.join('\n')}\n` +const reviewPath = path.join(artifactsDir, 'release-review.md') +fs.writeFileSync(reviewPath, markdown) + +if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + [ + '# Release Tarball Review', + '', + `- Commit: \`${manifest.commit}\``, + `- Branch: \`${manifest.refName}\``, + `- Dist tag: \`${manifest.distTag}\``, + `- Packages: ${manifest.packageCount}`, + '', + '## Packages', + '', + ...summaryPackages, + '', + 'Full tarball details were uploaded as the release-review artifact.', + '', + ].join('\n'), + ) +} + +console.info(markdown) diff --git a/scripts/release/tag-packages.mjs b/scripts/release/tag-packages.mjs new file mode 100644 index 0000000000..3379862286 --- /dev/null +++ b/scripts/release/tag-packages.mjs @@ -0,0 +1,107 @@ +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +/** + * @typedef {object} ManifestPackage + * @property {string} name + * @property {string} version + */ + +/** + * @typedef {object} ReleaseManifest + * @property {number} packageCount + * @property {string} commit + * @property {Array} packages + */ + +const rootDir = path.join(import.meta.dirname, '..', '..') +const manifestPath = path.resolve( + process.argv[2] ?? 'release-artifacts/release-manifest.json', +) +/** @type {ReleaseManifest} */ +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) + +/** + * @param {Array} args + * @param {import('node:child_process').ExecFileSyncOptionsWithStringEncoding=} options + * @returns {string} + */ +function git(args, options = {}) { + return execFileSync('git', args, { + cwd: rootDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }).trim() +} + +/** + * @param {Array} args + */ +function gitInherited(args) { + execFileSync('git', args, { + cwd: rootDir, + encoding: 'utf-8', + stdio: 'inherit', + }) +} + +/** + * @param {Array} args + */ +function gitPush(args) { + const token = process.env.GITHUB_TOKEN + + if (token) { + gitInherited([ + '-c', + `http.https://github.com/.extraheader=AUTHORIZATION: bearer ${token}`, + 'push', + ...args, + ]) + return + } + + gitInherited(['push', ...args]) +} + +/** + * @param {string} tagName + */ +function remoteTagExists(tagName) { + return Boolean(git(['ls-remote', '--tags', 'origin', `refs/tags/${tagName}`])) +} + +if (manifest.packageCount === 0) { + console.info('No package tags to create.') + process.exit(0) +} + +gitInherited(['config', 'user.name', 'github-actions[bot]']) +gitInherited([ + 'config', + 'user.email', + 'github-actions[bot]@users.noreply.github.com', +]) + +const createdTags = [] + +for (const pkg of manifest.packages) { + const tagName = `${pkg.name}@${pkg.version}` + + if (remoteTagExists(tagName)) { + console.info(`Tag ${tagName} already exists; skipping.`) + continue + } + + gitInherited(['tag', '-a', tagName, '-m', tagName, manifest.commit]) + createdTags.push(tagName) +} + +if (createdTags.length === 0) { + console.info('No new package tags to push.') + process.exit(0) +} + +gitPush(['origin', ...createdTags.map((tagName) => `refs/tags/${tagName}`)])