--- /dev/null
+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="<main>"
+ 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 }}
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()
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
// 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<string, string>
+ peerDependencies?: Record<string, string>
+ [key: string]: any
+}
+
+interface PackageInfo {
+ name: string
+ path: string
+ relativePath: string
+ version: string
+ pkg: PackageJson
+ start: string
+}
async function main() {
if (!skipCleanGitCheck) {
)
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) {
`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) {
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) {
'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
})
)
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...')
}
step('\nCreating tags...')
- const versionsToPush = []
+ const versionsToPush: string[] = []
for (const pkg of pkgWithVersions) {
const tagName =
pkg.name === MAIN_PKG_NAME
])
}
- 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
)
}
-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)}...`)
})
}
-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<string> {
try {
const { stdout } = await run(
'git',
}
)
- 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...`
['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<PackageInfo[]> {
const pkgs = await Promise.all(
folders.map(async folder => {
if (!(await fs.lstat(folder)).isDirectory()) {
return null
}
- const pkg = JSON.parse(
+ const pkg: PackageJson = JSON.parse(
await fs.readFile(join(folder, 'package.json'), 'utf-8')
)
if (pkg.private) {
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) {
})
)
- return pkgs.filter(p => p)
+ return pkgs.filter((p): p is PackageInfo => !!p)
}
main().catch(error => {