]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
ci: setup trusted publishing
authorEduardo San Martin Morote <posva13@gmail.com>
Thu, 11 Dec 2025 14:34:42 +0000 (15:34 +0100)
committerEduardo San Martin Morote <posva13@gmail.com>
Thu, 11 Dec 2025 14:34:42 +0000 (15:34 +0100)
.github/workflows/release.yml [new file with mode: 0644]
scripts/release.ts [moved from scripts/release.mjs with 65% similarity]

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644 (file)
index 0000000..8b61296
--- /dev/null
@@ -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="<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 }}
similarity index 65%
rename from scripts/release.mjs
rename to scripts/release.ts
index 74a32b72a9896723f3aae40b57c77c363333d699..b8ab48c78922aea742f65a28f783596fa1c3d1bd 100644 (file)
@@ -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<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) {
@@ -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<string> {
   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<PackageInfo[]> {
   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 => {