From ed7a6ceb1754fcdb3e264f2134fda7bcc755f4aa Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Thu, 11 Dec 2025 15:34:42 +0100 Subject: [PATCH] ci: setup trusted publishing --- .github/workflows/release.yml | 133 ++++++++++ scripts/{release.mjs => release.ts} | 374 +++++++++++++--------------- 2 files changed, 312 insertions(+), 195 deletions(-) create mode 100644 .github/workflows/release.yml rename scripts/{release.mjs => release.ts} (65%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..8b612966 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Publish to npm + +# When releasing for the first time, you first need to publish manually a 0.0.0 +# +# npm login +# npm publish +# +# Add "repository": "posva/npm-posva", to package.json +# Go to the settings page on npm: https://www.npmjs.com/package/@pinia/colada/access +# Set the Trusted Published, add an environment if added on GitHub repository settings / Environments +# Disallow tokens +# +# Update this file: +# - use environment if set +# - Adapt the if condition that prevents running on forks +# - Update the MAP variable to match your packages + +on: + push: + tags: + - '**' # we filter manually otherwise it doesn't work well + +jobs: + release: + runs-on: ubuntu-latest + # prevent runing on forks and with other tags + if: | + (startsWith(github.ref_name, 'v') || + contains(github.ref_name, '@')) && + github.repository == 'vuejs/router' + + environment: release + + permissions: + contents: write # for the GitHub Changelog + id-token: write # required for npm trusted publishing + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 # needed for changelogithub + + - name: Resolve tag info + id: resolve + run: | + echo "Resolving tag info…" + + TAG="${GITHUB_REF#refs/tags/}" + echo "Current tag: $TAG" + echo "TAG=$TAG" >> $GITHUB_OUTPUT + + # ========= Determine package type ========= + if [[ "$TAG" =~ ^v[0-9] ]]; then + # Main package + PKG_NAME="
" + PACKAGE_PATH="." + GIT_DESCRIBE_MATCH="v*" + else + # Scoped package: extract prefix before last @ + PKG_NAME="${TAG%@*}" + GIT_DESCRIBE_MATCH="${PKG_NAME}@*" + + # Package folder mapping + declare -A MAP=( + # ["@posva/mono-repo-test-plugin-a"]="plugins/test-a" + # ["@posva/mono-repo-test-package"]="test-package" + ) + + if [[ -z "${MAP[$PKG_NAME]}" ]]; then + echo "❌ Unknown package name '$PKG_NAME'" >&2 + exit 1 + fi + + PACKAGE_PATH="${MAP[$PKG_NAME]}" + fi + + echo "PKG_NAME=$PKG_NAME" >> $GITHUB_OUTPUT + echo "PACKAGE_PATH=$PACKAGE_PATH" >> $GITHUB_OUTPUT + echo "GIT_DESCRIBE_MATCH=$GIT_DESCRIBE_MATCH" >> $GITHUB_OUTPUT + + # ========= Compute previous tag (skip current tag) ========= + echo "Finding previous tag using: git describe --match '$GIT_DESCRIBE_MATCH'" + + set +e + PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match "$GIT_DESCRIBE_MATCH" "${TAG}^" 2>/dev/null) + STATUS=$? + set -e + + if [[ $STATUS -eq 0 ]]; then + echo "Found previous tag: $PREVIOUS_TAG" + else + echo "No previous tag found. Using first commit." + PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD) + fi + + echo "PREVIOUS_TAG=$PREVIOUS_TAG" >> $GITHUB_OUTPUT + + echo "Resolved:" + echo " TAG=$TAG" + echo " PKG_NAME=$PKG_NAME" + echo " PACKAGE_PATH=$PACKAGE_PATH" + echo " PREVIOUS_TAG=$PREVIOUS_TAG" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Generate GitHub Changelog + run: pnpx changelogithub --from "$FROM_TAG" --to "$TAG" + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.resolve.outputs.TAG }} + FROM_TAG: ${{ steps.resolve.FROM_TAG }} + + - name: Build + run: pnpm build + working-directory: ${{ steps.resolve.outputs.PACKAGE_PATH }} + + - name: Publish to NPM + run: pnpm publish --access public --no-git-checks + working-directory: ${{ steps.resolve.outputs.PACKAGE_PATH }} diff --git a/scripts/release.mjs b/scripts/release.ts similarity index 65% rename from scripts/release.mjs rename to scripts/release.ts index 74a32b72..b8ab48c7 100644 --- a/scripts/release.mjs +++ b/scripts/release.ts @@ -1,42 +1,49 @@ import fs from 'node:fs/promises' import { existsSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' +import { parseArgs } from 'node:util' import { fileURLToPath } from 'node:url' -import minimist from 'minimist' import chalk from 'chalk' import semver from 'semver' import prompts from '@posva/prompts' -import { execa } from 'execa' -import pSeries from 'p-series' +import { execa, type Options as ExecaOptions } from 'execa' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const args = minimist(process.argv.slice(2)) const { - skipBuild, - tag: optionTag, - dry: isDryRun, - skipCleanCheck: skipCleanGitCheck, - noDepsUpdate, - noPublish, - noLockUpdate, - all: skipChangeCheck, -} = args - -if (args.h || args.help) { + values: { + tag: optionTag, + dry: isDryRun, + skipCleanCheck: skipCleanGitCheck, + noDepsUpdate, + noLockUpdate, + all: skipChangeCheck, + help: showHelp, + }, +} = parseArgs({ + options: { + tag: { type: 'string' }, + dry: { type: 'boolean', default: false }, + skipCleanCheck: { type: 'boolean', default: false }, + noDepsUpdate: { type: 'boolean', default: false }, + noLockUpdate: { type: 'boolean', default: false }, + all: { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, +}) + +if (showHelp) { console.log( ` -Usage: node release.mjs [flags] - node release.mjs [ -h | --help ] +Usage: node release.ts [flags] + node release.ts [ -h | --help ] Flags: - --skipBuild Skip building packages --tag Publish under a given npm dist tag --dry Dry run --skipCleanCheck Skip checking if the git repo is clean --noDepsUpdate Skip updating dependencies in package.json files - --noPublish Skip publishing packages --noLockUpdate Skips updating the lock with "pnpm install" --all Skip checking if the packages have changed since last release `.trim() @@ -50,8 +57,8 @@ Flags: const EXPECTED_BRANCH = 'main' // this package will use tags like v1.0.0 while the rest will use the full package name like @pinia/testing@1.0.0 const MAIN_PKG_NAME = 'vue-router' -// whether the main package is at the root of the mono repo or this is not a mono repo -const IS_MAIN_PKG_ROOT = false +// whether the main package is at the root of the mono repo or true if this is not a mono repo +const IS_MAIN_PKG_AT_ROOT = false // array of folders of packages to release const PKG_FOLDERS = [ // comment for multiline format @@ -63,28 +70,45 @@ const PKG_FOLDERS = [ // files to add and commit after building a new version const FILES_TO_COMMIT = [ // comment for multiline format - 'packages/*/package.json', - 'packages/*/CHANGELOG.md', + 'packages/router/package.json', + 'packages/router/CHANGELOG.md', + + 'plugins/*/package.json', + 'plugins/*/CHANGELOG.md', + + 'test-package/package.json', + 'test-package/CHANGELOG.md', + // 'packages/*/package.json', + // 'packages/*/CHANGELOG.md', ] -/** - * @type {typeof execa} - */ -const run = (bin, args, opts = {}) => +const run = (bin: string, args: string[], opts: ExecaOptions = {}) => execa(bin, args, { stdio: 'inherit', ...opts }) -/** - * @param bin {string} - * @param args {string[]} - * @param opts {import('execa').Options} - */ -const dryRun = async (bin, args, opts = {}) => + +const dryRun = async (bin: string, args: string[], opts: unknown = {}) => console.log(chalk.blue(`[dry-run] ${bin} ${args.join(' ')}`), opts) + const runIfNotDry = isDryRun ? dryRun : run -/** - * @param msg {string[]} - */ -const step = (...msg) => console.log(chalk.cyan(...msg)) +const step = (...msg: string[]) => console.log(chalk.cyan(...msg)) + +interface PackageJson { + name: string + version: string + private?: boolean + dependencies?: Record + peerDependencies?: Record + [key: string]: any +} + +interface PackageInfo { + name: string + path: string + relativePath: string + version: string + pkg: PackageJson + start: string +} async function main() { if (!skipCleanGitCheck) { @@ -120,7 +144,8 @@ async function main() { ) const isOutdatedGit = isOutdatedRE.test( - (await run('git', ['remote', 'show', 'origin'], { stdio: 'pipe' })).stdout + (await run('git', ['remote', 'show', 'origin'], { stdio: 'pipe' })) + .stdout as string ) if (isOutdatedGit) { @@ -168,70 +193,79 @@ async function main() { `Ready to release ${packagesToRelease.map(({ name }) => chalk.bold.white(name)).join(', ')}` ) - const pkgWithVersions = await pSeries( - packagesToRelease.map(({ name, path, pkg, relativePath }) => async () => { - let { version } = pkg - - const prerelease = semver.prerelease(version) - const preId = prerelease && prerelease[0] - - const versionIncrements = [ - 'patch', - 'minor', - 'major', - ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []), - ] - - const betaVersion = semver.inc(version, 'prerelease', 'beta') - - const { release } = await prompts({ - type: 'select', - name: 'release', - message: `Select release type for ${chalk.bold.white(name)}`, - choices: versionIncrements - .map(release => { - const newVersion = semver.inc(version, release, preId) - return { - value: newVersion, - title: `${release}: ${name} (${newVersion})`, - } - }) - .concat( - optionTag === 'beta' - ? [ - { - title: `beta: ${name} (${betaVersion})`, - value: betaVersion, - }, - ] - : [] - ) - .concat([{ value: 'custom', title: 'custom' }]), - }) - - if (release === 'custom') { - version = ( - await prompts({ - type: 'text', - name: 'version', - message: `Input custom version (${chalk.bold.white(name)})`, - initial: version, - }) - ).version - } else { - version = release - } + const pkgWithVersions: PackageInfo[] = [] + for (const { name, path, pkg, relativePath } of packagesToRelease) { + let { version } = pkg + + const prerelease = semver.prerelease(version) + const preId = prerelease && prerelease[0] + + const versionIncrements = [ + 'patch', + 'minor', + 'major', + ...(preId + ? (['prepatch', 'preminor', 'premajor', 'prerelease'] as const) + : []), + ] + + const betaVersion = semver.inc(version, 'prerelease', 'beta') + + const { release } = await prompts({ + type: 'select', + name: 'release', + message: `Select release type for ${chalk.bold.white(name)}`, + choices: versionIncrements + .map(release => { + const newVersion = semver.inc(version, release, preId as string) + return { + value: newVersion, + title: `${release}: ${name} (${newVersion})`, + } + }) + .concat( + optionTag === 'beta' + ? [ + { + title: `beta: ${name} (${betaVersion})`, + value: betaVersion, + }, + ] + : [] + ) + .concat([{ value: 'custom', title: 'custom' }]), + }) - if (!semver.valid(version)) { - throw new Error(`invalid target version: ${version}`) - } + if (release === 'custom') { + version = ( + await prompts({ + type: 'text', + name: 'version', + message: `Input custom version (${chalk.bold.white(name)})`, + initial: version, + }) + ).version + } else { + version = release + } + + if (!semver.valid(version)) { + throw new Error(`invalid target version: ${version}`) + } - return { name, path, relativePath, version, pkg } + pkgWithVersions.push({ + name, + path, + relativePath, + version, + pkg, + // start is set later + start: '', }) - ) + } // put the main package first as others might depend on it - const mainPkgIndex = packagesToRelease.find( + const mainPkgIndex = packagesToRelease.findIndex( ({ name }) => name === MAIN_PKG_NAME ) if (mainPkgIndex > 0) { @@ -256,13 +290,13 @@ async function main() { step('\nUpdating versions in package.json files...') await updateVersions(pkgWithVersions) - if (!IS_MAIN_PKG_ROOT) { + if (!IS_MAIN_PKG_AT_ROOT) { step('\nCopying README from root to main package...') const originalReadme = resolve(__dirname, '../README.md') const targetReadme = resolve( __dirname, '../', - pkgWithVersions.find(p => p.name === MAIN_PKG_NAME).relativePath, + pkgWithVersions.find(p => p.name === MAIN_PKG_NAME)!.relativePath, 'README.md' ) if (!isDryRun) { @@ -296,29 +330,28 @@ async function main() { 'CHANGELOG.md', '--same-file', '-p', - 'conventionalcommits', + 'angular', '-r', changelogExists ? '1' : '0', '--commit-path', // in the case of a mono repo with the main package at the root // using `.` would add all the changes of all packages - ...(pkg.name === MAIN_PKG_NAME && IS_MAIN_PKG_ROOT + ...(pkg.name === MAIN_PKG_NAME && IS_MAIN_PKG_AT_ROOT ? [join(pkg.path, 'src'), join(pkg.path, 'package.json')] : ['.']), - ...(pkg.name === MAIN_PKG_NAME ? [] : ['--lerna-package', pkg.name]), + ...(pkg.name === MAIN_PKG_NAME && IS_MAIN_PKG_AT_ROOT + ? [] + : ['--lerna-package', pkg.name]), ...(pkg.name === MAIN_PKG_NAME ? [] : ['--tag-prefix', `${pkg.name}@`]), ], { cwd: pkg.path } ) - await runIfNotDry( - `pnpm`, - ['exec', 'prettier', '--write', 'CHANGELOG.md'], - { - cwd: pkg.path, - } - ) + // NOTE: lint-staged is set up to format the markdown + // await runIfNotDry(`pnpm`, ['exec', 'prettier', '--write', 'CHANGELOG.md'], { + // cwd: pkg.path, + // }) // NOTE: pnpm publish automatically copies the LICENSE file }) ) @@ -334,13 +367,6 @@ async function main() { return } - step('\nBuilding all packages...') - if (!skipBuild) { - await runIfNotDry('pnpm', ['run', 'build']) - } else { - console.log(`(skipped)`) - } - const { stdout } = await run('git', ['diff'], { stdio: 'pipe' }) if (stdout) { step('\nCommitting changes...') @@ -355,7 +381,7 @@ async function main() { } step('\nCreating tags...') - const versionsToPush = [] + const versionsToPush: string[] = [] for (const pkg of pkgWithVersions) { const tagName = pkg.name === MAIN_PKG_NAME @@ -372,25 +398,12 @@ async function main() { ]) } - if (!noPublish) { - step('\nPublishing packages...') - for (const pkg of pkgWithVersions) { - await publishPackage(pkg) - } - - step('\nPushing to Github...') - await runIfNotDry('git', ['push', 'origin', ...versionsToPush]) - await runIfNotDry('git', ['push']) - } else { - console.log(chalk.bold.white(`Skipping publishing...`)) - } + step('\nPushing to Github...') + await runIfNotDry('git', ['push', 'origin', ...versionsToPush]) + await runIfNotDry('git', ['push']) } -/** - * - * @param packageList {{ name: string; path: string; version: string, pkg: any }[]} - */ -async function updateVersions(packageList) { +async function updateVersions(packageList: PackageInfo[]) { return Promise.all( packageList.map(({ pkg, version, path, name }) => { pkg.version = version @@ -410,7 +423,11 @@ async function updateVersions(packageList) { ) } -function updateDeps(pkg, depType, updatedPackages) { +function updateDeps( + pkg: PackageJson, + depType: 'dependencies' | 'peerDependencies', + updatedPackages: PackageInfo[] +) { const deps = pkg[depType] if (!deps) return step(`Updating ${chalk.bold(depType)} for ${chalk.bold.white(pkg.name)}...`) @@ -437,45 +454,10 @@ function updateDeps(pkg, depType, updatedPackages) { }) } -async function publishPackage(pkg) { - step(`Publishing ${pkg.name}...`) - - try { - await runIfNotDry( - 'pnpm', - [ - 'publish', - ...(optionTag ? ['--tag', optionTag] : []), - ...(skipCleanGitCheck ? ['--no-git-checks'] : []), - '--access', - 'public', - // only needed for branches other than main - '--publish-branch', - EXPECTED_BRANCH, - ], - { - cwd: pkg.path, - stdio: 'pipe', - } - ) - console.log( - chalk.green(`Successfully published ${pkg.name}@${pkg.version}`) - ) - } catch (e) { - if (e.stderr.match(/previously published/)) { - console.log(chalk.red(`Skipping already published: ${pkg.name}`)) - } else { - throw e - } - } -} - /** * Get the last tag published for a package or null if there are no tags - * - * @param {string} pkgName - package name */ -async function getLastTag(pkgName) { +async function getLastTag(pkgName: string): Promise { try { const { stdout } = await run( 'git', @@ -491,8 +473,8 @@ async function getLastTag(pkgName) { } ) - return stdout - } catch (error) { + return stdout as string + } catch (error: any) { console.log( chalk.dim( `Couldn't get "${chalk.bold(pkgName)}" last tag, using first commit...` @@ -508,17 +490,16 @@ async function getLastTag(pkgName) { ['rev-list', '--max-parents=0', 'HEAD'], { stdio: 'pipe' } ) - return stdout + return stdout as string } } /** * Get the packages that have changed. Based on `lerna changed` but without lerna. - * - * @param {string[]} folders - * @returns {Promise<{ name: string; path: string; relativePath: string; pkg: any; version: string; start: string }[]} a promise of changed packages */ -async function getChangedPackages(...folders) { +async function getChangedPackages( + ...folders: string[] +): Promise { const pkgs = await Promise.all( folders.map(async folder => { if (!(await fs.lstat(folder)).isDirectory()) { @@ -526,7 +507,7 @@ async function getChangedPackages(...folders) { return null } - const pkg = JSON.parse( + const pkg: PackageJson = JSON.parse( await fs.readFile(join(folder, 'package.json'), 'utf-8') ) if (pkg.private) { @@ -536,21 +517,24 @@ async function getChangedPackages(...folders) { const lastTag = await getLastTag(pkg.name) - const { stdout: hasChanges } = await run( - 'git', - [ - 'diff', - '--name-only', - lastTag, - '--', - // TODO: should allow build files tsdown.config.ts - // apparently {src,package.json} doesn't work - join(folder, 'src'), - // TODO: should not check dev deps and should compare to last tag changes - join(folder, 'package.json'), - ], - { stdio: 'pipe' } - ) + const hasChanges = ( + await run( + 'git', + [ + 'diff', + '--name-only', + lastTag, + '--', + // TODO: should allow build files tsdown.config.ts + // apparently {src,package.json} doesn't work + join(folder, 'src'), + join(folder, 'index.js'), + // TODO: should not check dev deps and should compare to last tag changes + join(folder, 'package.json'), + ], + { stdio: 'pipe' } + ) + ).stdout as string const relativePath = relative(join(__dirname, '..'), folder) if (hasChanges || skipChangeCheck) { @@ -581,7 +565,7 @@ async function getChangedPackages(...folders) { }) ) - return pkgs.filter(p => p) + return pkgs.filter((p): p is PackageInfo => !!p) } main().catch(error => { -- 2.47.3