From be9855e1b97380ecdd2990324d390bf9cb6f3978 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 21 Aug 2026 12:00:40 +0000 Subject: [PATCH] feat: support independent versioning in monorepos --- README.md | 210 ++++++++++++- pack/action.yml | 13 +- pr/action.yml | 10 + publish/action.yml | 13 +- release/action.yml | 23 +- scripts/_dependency-graph.ts | 147 +++++++++ scripts/_independent.ts | 147 +++++++++ scripts/_workspaces.ts | 108 ++++++- scripts/pack.ts | 56 +++- scripts/publish.ts | 11 + scripts/tag-and-release.ts | 89 +++++- scripts/update-changelog.ts | 557 +++++++++++++++++++++++++++++++-- test/_dependency-graph.test.ts | 174 ++++++++++ test/_independent.test.ts | 142 +++++++++ test/_workspaces.test.ts | 105 +++++++ test/update-changelog.test.ts | 370 +++++++++++++++++++++- 16 files changed, 2114 insertions(+), 61 deletions(-) create mode 100644 scripts/_dependency-graph.ts create mode 100644 scripts/_independent.ts create mode 100644 test/_dependency-graph.test.ts create mode 100644 test/_independent.test.ts diff --git a/README.md b/README.md index a4dae37..2a2c3fa 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The aim of **uppt** is to make a very simple, secure release workflow for mainta ![a screenshot of npmjs.com](https://raw.githubusercontent.com/danielroe/uppt/main/assets/trusted-publisher.png) -2. Create a [GitHub environment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) named `npm`. You can scope it to `v*` tags, and configure any restrictions on it (such as requiring approvals if you want). +2. Create a [GitHub environment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) named `npm`, and configure any restrictions on it (such as requiring approvals if you want). If you limit which refs can deploy to it, allow both `v*` and `release-*`: the publish job runs on the release tag, which is `vX.Y.Z` for lockstep and single-package repos but a `release-*` coordination tag in independent mode. ![a screenshot of github environment configuration settings](https://raw.githubusercontent.com/danielroe/uppt/main/assets/github-environments.png) **3.** Allow GitHub Actions to create pull requests on your repo: under **Settings → Actions → General → Workflow permissions** (`https://github.com///settings/actions`), check **Allow GitHub Actions to create and approve pull requests**. Without this, `uppt/pr` fails with `403 Forbidden: GitHub Actions is not permitted to create or approve pull requests` when opening the release PR. @@ -39,8 +39,15 @@ on: pull_request: types: [closed] branches: [main] - # this is required to trigger releases when the release PR is merged, or to rerun a release if needed + # this is required to trigger releases when the release PR is merged, to + # rerun a release if needed, or to cut a prerelease (run from the default + # branch with `prerelease` set) workflow_dispatch: + inputs: + prerelease: + description: 'Cut a prerelease instead of a normal release, e.g. `beta`, `rc`, or `0`' + required: false + default: '' permissions: {} @@ -48,11 +55,22 @@ jobs: # Parse commits since the last tag, push a `release/vX.Y.Z` branch, open # or update a draft release PR, and close any superseded release PRs # (e.g. `release/v1.0.1` when the bump is now `release/v1.1.0`). + # + # Also runs on a manual dispatch from a branch, which is how you cut a + # prerelease. Manual dispatches on a `v*` tag are reruns of the publish + # path below and skip this job. pr: if: | - github.event_name == 'push' - && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) - && !github.event.repository.fork + !github.event.repository.fork + && ( + ( + github.event_name == 'push' + && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + ) || ( + github.event_name == 'workflow_dispatch' + && !startsWith(github.ref, 'refs/tags/') + ) + ) runs-on: ubuntu-latest permissions: contents: write # push the `release/vX.Y.Z` branch and delete superseded ones @@ -61,6 +79,7 @@ jobs: - uses: danielroe/uppt/pr@7bcfb5397c37202ef882363f755423130419d28a # v0.5.5 with: token: ${{ secrets.GITHUB_TOKEN }} + prerelease: ${{ inputs.prerelease }} # The release PR was merged: tag the squash commit, cut a GitHub release # from the PR body, and dispatch the publish workflow. The `release/v` @@ -154,6 +173,7 @@ When you merge a release PR, this subaction tags that commit, creates a GitHub R | `node-version` | `24` | Node version for the scripts. Needs `--experimental-strip-types` (Node 22.6+, 24+ recommended). | | `publish-workflow` | `release.yml` | Workflow filename to dispatch after tagging. Must declare `workflow_dispatch`. | | `checkout` | `true` | Set to `false` if the caller has already checked out `github.event.pull_request.merge_commit_sha`. | +| `mode` | `lockstep` | Monorepo versioning mode (`lockstep` or `independent`). Must match the value passed to `uppt/pr`. See [Independent versioning](#independent-versioning-experimental). | | `allow-forks` | `false` | Whether to run when the repository is a fork. By default the action skips (with a notice) so forks don't tag and publish releases of their own. | ### Packs a tarball (`danielroe/uppt/pack`) @@ -166,6 +186,7 @@ This subaction installs the package's dependencies, runs `pnpm pack --json` (if | `checkout` | `true` | Set to `false` if the caller has already checked out the tag ref. | | `install` | `true` | Set to `false` to handle `actions/setup-node` and dependency installation yourself. Useful when you want a pinned package manager version, a cached `node_modules`, or a hardened install policy. When `false`, the caller must put `node`, `npm`, and any package manager on PATH before `uppt/pack` runs. | | `packages` | _(unset)_ | Newline-separated list of publishable workspace directories (paths or globs). Must match the value passed to `uppt/pr`. See [Monorepo support](#monorepo-support). | +| `releases` | _(unset)_ | Independent-mode publish payload, from the workflow's `releases` dispatch input. Never set by hand. See [Independent versioning](#independent-versioning-experimental). | | Output | Description | | --- | --- | @@ -180,6 +201,7 @@ This subaction downloads the tarball uploaded by `uppt/pack` in the same workflo | `node-version` | `24` | Node version for the scripts and for `npm stage publish`. Needs `--experimental-strip-types` (Node 22.6+, 24+ recommended). | | `npm-access` | `public` | npm access level (`public` or `restricted`). | | `files` | _(scan artifact)_ | Optional JSON array of tarball filenames to publish, as emitted by `uppt/pack`'s `files` output. When omitted, every `*.tgz` in the downloaded artifact is published. | +| `releases` | _(unset)_ | Independent-mode publish payload, from the workflow's `releases` dispatch input. Never set by hand. See [Independent versioning](#independent-versioning-experimental). | ## Lifecycle scripts @@ -189,6 +211,18 @@ uppt runs your package's lifecycle scripts at one specific point and skips them - **During pack** (inside `uppt/pack`, after install): `prepack`, `prepare`, and `postpack` run. This is where your build belongs. - **During publish** (inside `uppt/publish`): nothing runs. `prepublishOnly` is **not** invoked; the prebuilt tarball is published with `--ignore-scripts`. Move any logic you previously had in `prepublishOnly` into `prepack` so it runs during `uppt/pack` and the output lands in the tarball. +## Prereleases + +The workflow above takes a `prerelease` input on manual dispatch, so to cut one you run the release workflow from your default branch and fill it in: + +```bash +gh workflow run release.yml -f prerelease=beta +``` + +From `4.5.2` with a breaking change that opens a PR for `5.0.0-beta.0`. Run it again and you get `5.0.0-beta.1`; pass a different identifier and the counter resets (`5.0.0-rc.0`). A bare number gives you the `5.0.0-0` style instead. It's a one-shot input, not a mode: nothing is written down anywhere, so each run either continues the line you're already on or starts a new one. + +The next push after a prerelease will create a PR for the next release (e.g. `5.0.0`). But if you want another prerelease, just run the workflow with `prerelease` set again and it'll be superseded by another prerelease PR. + ## Monorepo support uppt supports lockstep monorepos: every publishable package shares a single version, gets bumped together, lands under one `vX.Y.Z` tag, and is staged in one workflow run. @@ -234,8 +268,170 @@ The lockstep version comes from the workspaces themselves: every listed package > [!IMPORTANT] > If you use pnpm, every workspace you list under `packages:` must also be listed in your `pnpm-workspace.yaml`. `pnpm pack` resolves `workspace:` and `catalog:` specifiers via the workspace graph, so a directory missing from `pnpm-workspace.yaml` will produce a tarball with unresolved specifiers (or fail outright). +### Independent versioning (experimental) + +Lockstep is the wrong shape for some monorepos. If one package is on `0.8.0` and another is on `0.2.1`, you can set `mode: independent` alongside `packages:` and each package will advance versions on its own cadence. + +Which package a commit belongs to comes from its conventional-commit scope. By default a package claims the last segment of its name, so `feat(kit):` bumps `@nuxt/kit` and `fix(fontaine):` bumps `fontaine`. If that isn't the scope you actually write, or two packages would claim the same one, you can configure this with `scopes:` on `uppt/pr`: + +```yaml + scopes: | + @nuxt/kit: kit nuxt-kit + @nuxt/schema: schema +``` + +Commits with no scope, or a scope that doesn't match a package (`docs:`, `feat(playground):`), don't bump anything. + +Bumping a package also releases anything that depends on it. (Only `dependencies`, `peerDependencies` and `optionalDependencies` count.) + +Instead of one `release/vX.Y.Z` PR you'll get a single `release/-pending` PR covering every package with unreleased changes, with a section per package. + +On merge, uppt tags each released package as `@X.Y.Z` (`fontaine@0.9.0`, `@nuxt/kit@5.0.0`) on the merge commit, then creates one GitHub release on a `release-YYYY-MM-DD-` coordination tag rather than picking one package's tag to stand for the whole set. The publish workflow is dispatched on that coordination tag with a `releases` payload: a JSON array of `{ "name", "version", "dir" }`. `uppt/pack` packs exactly those workspaces, and `uppt/publish` stages them all, so they go live together when you approve them on npmjs.com. + +Independent mode needs a few workflow changes on top of the lockstep setup: a `releases` dispatch input to carry the payload from the chained dispatch, `mode: independent` on `uppt/pr` and `uppt/release`, a looser head-ref guard (the PR branch is `release/-pending`), and job conditions that accept the coordination tag. Here is the whole thing: + +```yaml +name: release + +on: + push: + branches: [main] + pull_request: + types: [closed] + branches: [main] + # this is required to trigger releases when the release PR is merged, to + # rerun a release if needed, or to cut a prerelease (run from the default + # branch with `prerelease` set) + workflow_dispatch: + inputs: + prerelease: + description: 'Cut a prerelease instead of a normal release, e.g. `beta`, `rc`, or `0`' + required: false + default: '' + releases: + description: 'Publish payload emitted by uppt/release; leave empty when rerunning a publish by hand' + required: false + default: '' + +permissions: {} + +jobs: + # Work out which packages have unreleased changes, push a + # `release/-pending` branch bumping each of them, and open or update + # a single draft release PR covering the lot. + # + # Also runs on a manual dispatch from a branch, which is how you cut a + # prerelease. Manual dispatches on a tag are reruns of the publish path + # below and skip this job. + pr: + if: | + !github.event.repository.fork + && ( + ( + github.event_name == 'push' + && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + ) || ( + github.event_name == 'workflow_dispatch' + && !startsWith(github.ref, 'refs/tags/') + ) + ) + runs-on: ubuntu-latest + permissions: + contents: write # push the `release/vX.Y.Z` branch and delete superseded ones + pull-requests: write # create a release PR, update its body, close superseded PRs + steps: + - uses: danielroe/uppt/pr@7bcfb5397c37202ef882363f755423130419d28a # v0.5.5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + prerelease: ${{ inputs.prerelease }} + mode: independent + packages: | + packages/* + + # The release PR was merged: tag each released package, cut one GitHub + # release on a coordination tag, and dispatch the publish workflow with + # the list of packages to publish. The `release/` head-ref guard keeps + # regular feature-PR merges from triggering this; + # the head-repo guard keeps merged fork PRs from triggering it, and the + # `repository.fork` guard keeps forks of this repo from releasing. + release: + if: | + github.event_name == 'pull_request' + && github.event.pull_request.merged == true + && startsWith(github.event.pull_request.head.ref, 'release/') + && github.event.pull_request.head.repo.full_name == github.repository + && !github.event.repository.fork + runs-on: ubuntu-latest + concurrency: + group: release-${{ github.event.pull_request.number }} + cancel-in-progress: false + permissions: + contents: write # push the `@X.Y.Z` tags and create the GitHub release + actions: write # chained dispatch of the publish run + steps: + - uses: danielroe/uppt/release@7bcfb5397c37202ef882363f755423130419d28a # v0.5.5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + mode: independent + packages: | + packages/* + + # The chained dispatch from `release` lands here on the coordination tag, + # carrying the `releases` payload. The `pack` job installs deps, packs each + # listed workspace, and uploads the tarballs as a workflow artifact. See + # "Lifecycle scripts" below for what runs where. Manual recovery uses the + # same path (Run workflow -> pick a `release-*` or `v*` tag). + pack: + if: | + github.event_name == 'workflow_dispatch' + && (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/release-')) + runs-on: ubuntu-latest + concurrency: + group: pack-${{ github.ref }} + cancel-in-progress: false + permissions: {} + outputs: + files: ${{ steps.pack.outputs.files }} + steps: + - id: pack + uses: danielroe/uppt/pack@7bcfb5397c37202ef882363f755423130419d28a # v0.5.5 + with: + releases: ${{ inputs.releases }} + packages: | + packages/* + + # `publish` downloads the prebuilt tarball from the pack job's + # artifact and stages it for publish. + publish: + if: | + github.event_name == 'workflow_dispatch' + && (startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/tags/release-')) + && needs.pack.outputs.files != '[]' + needs: pack + runs-on: ubuntu-latest + concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + permissions: + id-token: write # OIDC claim for npm trusted publisher + environment: npm # must match the trusted-publisher entry on npmjs.com + steps: + - uses: danielroe/uppt/publish@7bcfb5397c37202ef882363f755423130419d28a # v0.5.5 + with: + files: ${{ needs.pack.outputs.files }} + releases: ${{ inputs.releases }} +``` + +When `releases` is empty every action behaves exactly as it does in lockstep mode, so rerunning a publish by hand on a `v*` tag still works. + > [!NOTE] -> Independent versioning (per-package tags and cadence) is not yet supported. Track [#9](https://github.com/danielroe/uppt/issues/9) if you need it. +> Independent mode is new and hasn't been through many real releases yet. If something looks wrong, please open an issue. + +> [!TIP] +> Every package needs its own trusted-publisher entry on npmjs.com, all pointing at the same `release.yml` and `npm` environment. + +> [!IMPORTANT] +> Two things to check when switching an existing repo over. If your `npm` environment only allows `v*` tags to deploy, add `release-*`: independent-mode publishes run on the coordination tag, so a `v*`-only rule leaves the publish job waiting on an environment it can never enter. Tags are fine as they are, though. uppt reads `@X.Y.Z`, so anything you've released by hand or with `bumpp --tag @` is picked up as that package's last release, and a package with no tag of its own falls back to the newest `vX.Y.Z`, so the first independent run only releases what has actually changed. ## Prerequisites @@ -246,7 +442,7 @@ For `pr` to work you need: For `publish` to work end to end you need: - An npmjs.com trusted-publisher entry per package, pointing at the caller's `release.yml` and the `npm` environment, with the `npm stage publish` permission chip. -- A GitHub environment named `npm` (or whichever name you put on the publish job). +- A GitHub environment named `npm` (or whichever name you put on the publish job). If you restrict which refs may deploy to it, allow `release-*` as well as `v*`, or independent-mode publishes will never get in. - The package must already exist on npmjs.com; `npm stage publish` cannot stage a brand-new package. For the very first publish, [`setup-trusted-publishing`](https://github.com/ThisIsMissEm/setup-trusted-publishing) will publish a `0.0.0` stub so you can attach a trusted-publisher entry: `npx setup-trusted-publishing` (run once, from the package directory). ## Credits diff --git a/pack/action.yml b/pack/action.yml index 97cad68..d927974 100644 --- a/pack/action.yml +++ b/pack/action.yml @@ -23,6 +23,10 @@ inputs: description: 'Newline-separated list of publishable workspace directories, relative to the repo root. Each line is a path or glob (e.g. `packages/*`); `!`-prefixed entries are excluded; workspaces with `"private": true` are skipped. Must match the value passed to `uppt/pr`. Omit for single-package repos.' required: false default: '' + releases: + description: 'Independent-mode publish payload: the ordered JSON array of `{ name, version, dir }` entries emitted by `uppt/release` and delivered via the workflow''s `releases` dispatch input (`${{ inputs.releases }}`). Never set by hand. When present, exactly those workspaces are packed, in that order, and the run must be on the matching `release-YYYY-MM-DD-` coordination tag.' + required: false + default: '' outputs: files: @@ -36,6 +40,7 @@ runs: shell: bash env: EVENT_NAME: ${{ github.event_name }} + RELEASES: ${{ inputs.releases }} run: | set -euo pipefail case "$EVENT_NAME" in @@ -45,7 +50,12 @@ runs: exit 1 ;; esac - if [ "${GITHUB_REF:-}" = "${GITHUB_REF#refs/tags/v}" ]; then + if [ -n "${RELEASES:-}" ]; then + if ! printf '%s' "${GITHUB_REF:-}" | grep -Eq '^refs/tags/release-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]{7,40}$'; then + echo "::error::danielroe/uppt/pack got a 'releases' payload but the ref is not a 'refs/tags/release-YYYY-MM-DD-' coordination tag (got '${GITHUB_REF:-}')." + exit 1 + fi + elif [ "${GITHUB_REF:-}" = "${GITHUB_REF#refs/tags/v}" ]; then echo "::error::danielroe/uppt/pack expected a 'refs/tags/v*' ref, got '${GITHUB_REF:-}'." exit 1 fi @@ -104,6 +114,7 @@ runs: env: PACK_OUT_DIR: ${{ runner.temp }}/uppt-pack PACKAGES: ${{ inputs.packages }} + RELEASES: ${{ inputs.releases }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/pack.ts - name: Upload tarball artifact diff --git a/pr/action.yml b/pr/action.yml index bdc826e..145a0f0 100644 --- a/pr/action.yml +++ b/pr/action.yml @@ -27,6 +27,14 @@ inputs: description: 'Newline-separated list of publishable workspace directories, relative to the repo root. Each line is a path or glob (e.g. `packages/*`); `!`-prefixed entries are excluded; workspaces with `"private": true` are skipped. Omit for single-package repos.' required: false default: '' + mode: + description: 'Monorepo versioning mode: `lockstep` (default; every listed package shares one version) or `independent` (each package versions on its own cadence, tagged `@X.Y.Z`). Only meaningful when `packages` is set. Independent mode currently computes and logs the release plan without opening a PR.' + required: false + default: 'lockstep' + scopes: + description: 'Optional commit-scope overrides for `independent` mode, one per line as `: [ ...]`. By default a package claims the basename of its name (`@nuxt/kit` claims `kit`), so overrides are only needed to add aliases or to disambiguate two packages that would otherwise claim the same scope.' + required: false + default: '' prerelease: description: 'One-shot prerelease identifier (e.g. `beta`, `rc`, or a bare number like `0`). When set, this run cuts or continues a prerelease: from a stable version the normal bump is applied and the identifier attached at counter 0 (`4.5.2` → `5.0.0-beta.0`); from a prerelease with the same identifier the counter increments (`5.0.0-beta.0` → `5.0.0-beta.1`). Omit for normal releases; a prerelease version with this unset graduates to its stable version.' required: false @@ -81,4 +89,6 @@ runs: RELEASE_BASE: ${{ inputs.base-branch }} PACKAGES: ${{ inputs.packages }} PRERELEASE: ${{ inputs.prerelease }} + MODE: ${{ inputs.mode }} + SCOPES: ${{ inputs.scopes }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/update-changelog.ts diff --git a/publish/action.yml b/publish/action.yml index b6e3024..b8ac405 100644 --- a/publish/action.yml +++ b/publish/action.yml @@ -19,6 +19,10 @@ inputs: description: 'Optional JSON array of tarball filenames to publish, as emitted by `uppt/pack`''s `files` output. When omitted, the action publishes every `*.tgz` it finds in the downloaded artifact.' required: false default: '' + releases: + description: 'Independent-mode publish payload: the ordered JSON array of `{ name, version, dir }` entries emitted by `uppt/release` and delivered via the workflow''s `releases` dispatch input (`${{ inputs.releases }}`). Never set by hand. When present, the run must be on the matching `release-YYYY-MM-DD-` coordination tag, and (when `files` is omitted) tarballs are staged in the payload''s topological order.' + required: false + default: '' runs: using: composite @@ -27,6 +31,7 @@ runs: shell: bash env: EVENT_NAME: ${{ github.event_name }} + RELEASES: ${{ inputs.releases }} run: | set -euo pipefail case "$EVENT_NAME" in @@ -36,7 +41,12 @@ runs: exit 1 ;; esac - if [ "${GITHUB_REF:-}" = "${GITHUB_REF#refs/tags/v}" ]; then + if [ -n "${RELEASES:-}" ]; then + if ! printf '%s' "${GITHUB_REF:-}" | grep -Eq '^refs/tags/release-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9a-f]{7,40}$'; then + echo "::error::danielroe/uppt/publish got a 'releases' payload but the ref is not a 'refs/tags/release-YYYY-MM-DD-' coordination tag (got '${GITHUB_REF:-}')." + exit 1 + fi + elif [ "${GITHUB_REF:-}" = "${GITHUB_REF#refs/tags/v}" ]; then echo "::error::danielroe/uppt/publish expected a 'refs/tags/v*' ref, got '${GITHUB_REF:-}'." exit 1 fi @@ -64,4 +74,5 @@ runs: NPM_ACCESS: ${{ inputs.npm-access }} TARBALL_DIR: ${{ runner.temp }}/uppt-tarballs TARBALL_FILES: ${{ inputs.files }} + RELEASES: ${{ inputs.releases }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/publish.ts diff --git a/release/action.yml b/release/action.yml index 29a8acf..68e7db9 100644 --- a/release/action.yml +++ b/release/action.yml @@ -27,6 +27,10 @@ inputs: description: 'Newline-separated list of publishable workspace directories, relative to the repo root. Each line is a path or glob (e.g. `packages/*`); `!`-prefixed entries are excluded; workspaces with `"private": true` are skipped. Must match the value passed to `uppt/pr`. Omit for single-package repos.' required: false default: '' + mode: + description: 'Monorepo versioning mode: `lockstep` (default) or `independent`. Must match the value passed to `uppt/pr`. In independent mode the action tags each released package as `@X.Y.Z`, attaches the GitHub release to a `release-YYYY-MM-DD-` coordination tag, and dispatches the publish workflow with a `releases` payload input (which the caller''s workflow must declare under `workflow_dispatch`).' + required: false + default: 'lockstep' allow-forks: description: 'Whether to run when the repository is a fork. Defaults to `false` so a fork of a repo using uppt does not tag and publish releases of its own.' required: false @@ -43,6 +47,7 @@ runs: IS_FORK: ${{ github.event.repository.fork }} ALLOW_FORKS: ${{ inputs.allow-forks }} PR_MERGED: ${{ github.event.pull_request.merged }} + MODE: ${{ inputs.mode }} PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} BASE_REPO: ${{ github.repository }} @@ -61,10 +66,19 @@ runs: echo "::error::danielroe/uppt/release requires the PR to be merged." exit 1 fi - if [ "${PR_HEAD_REF#release/v}" = "$PR_HEAD_REF" ]; then - echo "::error::danielroe/uppt/release expected a 'release/v*' head ref, got '$PR_HEAD_REF'." - exit 1 - fi + case "$PR_HEAD_REF" in + release/v*) ;; + release/*-pending) + if [ "$MODE" != "independent" ]; then + echo "::error::danielroe/uppt/release got the independent-mode head ref '$PR_HEAD_REF' but 'mode' is not 'independent'." + exit 1 + fi + ;; + *) + echo "::error::danielroe/uppt/release expected a 'release/v*' or 'release/*-pending' head ref, got '$PR_HEAD_REF'." + exit 1 + ;; + esac if [ "$PR_HEAD_REPO" != "$BASE_REPO" ]; then echo "::error::danielroe/uppt/release requires the release PR to originate from this repository (got '$PR_HEAD_REPO', expected '$BASE_REPO'). Add 'github.event.pull_request.head.repo.full_name == github.repository' to the release job's if: condition to skip fork PRs at the workflow level." exit 1 @@ -94,4 +108,5 @@ runs: PR_BODY: ${{ github.event.pull_request.body }} PUBLISH_WORKFLOW: ${{ inputs.publish-workflow }} PACKAGES: ${{ inputs.packages }} + MODE: ${{ inputs.mode }} run: node --experimental-strip-types ${{ github.action_path }}/../scripts/tag-and-release.ts diff --git a/scripts/_dependency-graph.ts b/scripts/_dependency-graph.ts new file mode 100644 index 0000000..c6ff33d --- /dev/null +++ b/scripts/_dependency-graph.ts @@ -0,0 +1,147 @@ +// Workspace dependency graph for the independent-versioning path. +// +// When package A is bumped, any package B that pins A via a +// `workspace:` specifier must also be released, or B's next published +// tarball keeps resolving the previous A. This module builds the graph +// of such edges, orders packages for publishing, and expands a set of +// planned releases to include every affected dependent. + +import type { Workspace } from './_workspaces.ts' + +// devDependencies are deliberately excluded: `npm pack` strips them, so +// a `workspace:` devDependency can never leave a stale pin in the +// published tarball. Propagating on one would produce a release with no +// user-visible change. +const DEPENDENCY_FIELDS = [ + 'dependencies', + 'peerDependencies', + 'optionalDependencies', +] as const + +export interface DependencyGraph { + /** All workspaces, in the order they were given. */ + workspaces: Workspace[] + /** Package name → names of in-list packages it `workspace:`-depends on. */ + dependencies: Map> + /** Package name → names of in-list packages that `workspace:`-depend on it. */ + dependents: Map> +} + +/** + * Build the `workspace:` dependency graph over the given workspaces. + * An edge B → A exists when B declares A with a `workspace:` specifier + * in `dependencies`, `peerDependencies`, or `optionalDependencies`. + * `devDependencies` do not create edges: they are stripped from the + * published manifest, so they cannot cause a stale pin for consumers. A `workspace:` dependency on a package that + * is not in `workspaces` (e.g. a private playground excluded from the + * `packages` input) is ignored. + */ +export function buildDependencyGraph (workspaces: Workspace[]): DependencyGraph { + const names = new Set(workspaces.map(ws => ws.name)) + const dependencies = new Map>() + const dependents = new Map>() + for (const ws of workspaces) { + dependencies.set(ws.name, new Set()) + dependents.set(ws.name, new Set()) + } + + for (const ws of workspaces) { + for (const field of DEPENDENCY_FIELDS) { + const deps = ws.pkg[field] + if (!deps || typeof deps !== 'object') continue + for (const [dep, spec] of Object.entries(deps as Record)) { + if (typeof spec !== 'string' || !spec.startsWith('workspace:')) continue + if (!names.has(dep) || dep === ws.name) continue + dependencies.get(ws.name)!.add(dep) + dependents.get(dep)!.add(ws.name) + } + } + } + + return { workspaces, dependencies, dependents } +} + +/** + * Order package names so that every dependency comes before the packages + * that depend on it. + * + * This is what lets `propagateReleases` expand the release set in a single + * pass: a package is visited only once everything it depends on has been + * considered, so a chain of dependents is picked up without iterating to a + * fixed point. Publishing does not need the order, since `uppt/publish` + * stages every version and they go live together on approval. + * + * Cycles are unlikely with devDependencies excluded, but are not an error + * and do not block a release. Tie-break: whenever no package is free of + * unsatisfied dependencies, the cycle is broken at the member that appears + * earliest in the input workspace order. Since `resolveWorkspaces` returns + * a sorted, stable list, the result is deterministic across runs. A broken + * cycle means one member is ordered before a dependency it pins, which no + * ordering can avoid; propagation still reaches every member because each + * is visited once. + */ +export function topologicalOrder (graph: DependencyGraph): string[] { + const remaining = graph.workspaces.map(ws => ws.name) + const pending = new Map>() + for (const name of remaining) { + pending.set(name, new Set(graph.dependencies.get(name))) + } + + const order: string[] = [] + const placed = new Set() + while (order.length < remaining.length) { + let next = remaining.find(name => !placed.has(name) && pending.get(name)!.size === 0) + next ??= remaining.find(name => !placed.has(name))! + order.push(next) + placed.add(next) + for (const deps of pending.values()) deps.delete(next) + } + return order +} + +export type BumpLevel = 'major' | 'minor' | 'patch' + +export interface PlannedRelease { + /** Package name. */ + name: string + /** Bump level for this release. */ + bump: BumpLevel + /** + * `true` when the package is released because of its own commits; + * `false` when it is released only because a `workspace:` dependency + * was bumped. + */ + ownCommits: boolean +} + +/** + * Expand a set of planned releases to every dependent that needs a + * release as a consequence, transitively. A dependent not already + * being released gets a `patch` bump (the maintainer can escalate in + * the PR); a dependent already slated keeps its existing bump level. + * Results are returned in topological (publish) order. + */ +export function propagateReleases ( + graph: DependencyGraph, + planned: Array<{ name: string, bump: BumpLevel }>, +): PlannedRelease[] { + const releases = new Map() + for (const { name, bump } of planned) { + releases.set(name, { name, bump, ownCommits: true }) + } + + const order = topologicalOrder(graph) + for (const name of order) { + if (releases.has(name)) continue + const deps = graph.dependencies.get(name) + if (!deps) continue + for (const dep of deps) { + if (releases.has(dep)) { + releases.set(name, { name, bump: 'patch', ownCommits: false }) + break + } + } + } + + return order.filter(name => releases.has(name)).map(name => releases.get(name)!) +} diff --git a/scripts/_independent.ts b/scripts/_independent.ts new file mode 100644 index 0000000..a98cdb8 --- /dev/null +++ b/scripts/_independent.ts @@ -0,0 +1,147 @@ +// Independent-versioning release helpers shared by `tag-and-release.ts` +// (derive the release set and fan out tags), `pack.ts` (pack exactly the +// dispatched packages) and `publish.ts` (stage them). +// +// The release set is derived from the checked-out tree, never from the +// PR body: each workspace's `package.json` version is compared against +// the version it was last released at, so maintainer-editable markdown +// can never decide what gets published. + +import { isSemver, type Workspace } from './_workspaces.ts' +import { buildDependencyGraph, topologicalOrder } from './_dependency-graph.ts' +import { latestLockstepTag, latestTagForPackage } from './update-changelog.ts' + +/** + * One entry of the `releases` dispatch payload. The array is the + * contract between `uppt/release` and `uppt/pack` / `uppt/publish`: + * one entry per released package. + */ +export interface ReleaseEntry { + /** Package name as declared in its `package.json` (e.g. `@nuxt/kit`). */ + name: string + /** Strict semver version being released. */ + version: string + /** Workspace directory relative to the repo root, forward slashes. */ + dir: string +} + +// npm's own naming rule, minus legacy uppercase names. Doubles as an +// argv/ref-injection guard: no leading `-`, no whitespace, no `..`. +const PACKAGE_NAME_RE = /^(?:@[a-z0-9~][a-z0-9-._~]*\/)?[a-z0-9~][a-z0-9-._~]*$/ + +export function isValidPackageName (name: string): boolean { + return PACKAGE_NAME_RE.test(name) +} + +function isSafeRelDir (dir: string): boolean { + if (dir === '.') return true + if (!dir.length || dir.startsWith('/') || dir.includes('\\')) return false + return dir.split('/').every(seg => /^[\w.-]+$/.test(seg) && seg !== '.' && seg !== '..') +} + +/** + * Derive the set of packages being released at the current commit: + * every workspace whose `package.json` version differs from the version + * it was last released at. + * + * A package's last release is its newest `@X.Y.Z` tag, falling + * back to the newest lockstep `vX.Y.Z` tag. Without that fallback the + * first independent release after a lockstep history would re-release + * every package at its current, already-published version. + */ +export function deriveReleaseSet (workspaces: Workspace[], tags: string[]): ReleaseEntry[] { + const byName = new Map(workspaces.map(ws => [ws.name, ws])) + const order = topologicalOrder(buildDependencyGraph(workspaces)) + const lockstepTag = latestLockstepTag(tags) + + const releases: ReleaseEntry[] = [] + for (const name of order) { + const ws = byName.get(name)! + if (ws.version === null) continue + if (!isSemver(ws.version)) { + throw new Error(`Workspace ${name} has non-semver version "${ws.version}"; refusing to derive a release set.`) + } + const tag = latestTagForPackage(name, tags) + const releasedVersion = tag + ? tag.name.slice(name.length + 1) + : lockstepTag?.name.slice(1) ?? null + if (releasedVersion === ws.version) continue + releases.push({ name, version: ws.version, dir: ws.relDir }) + } + return releases +} + +/** Per-package git tag name: `@X.Y.Z`. */ +export function packageTag (entry: Pick): string { + return `${entry.name}@${entry.version}` +} + +export const COORDINATION_TAG_RE = /^release-\d{4}-\d{2}-\d{2}-[0-9a-f]{7,40}$/ + +/** + * Release-bearing tag for an independent merge: `release-YYYY-MM-DD-` + * (UTC date). The GitHub release attaches here so no single package's tag + * is privileged as "the" release. + */ +export function coordinationTag (sha: string, date: Date = new Date()): string { + if (!/^[0-9a-f]{40}$/.test(sha)) { + throw new Error(`Expected a full commit sha, got "${sha}"`) + } + return `release-${date.toISOString().slice(0, 10)}-${sha.slice(0, 7)}` +} + +export function serialiseReleases (releases: ReleaseEntry[]): string { + return JSON.stringify(releases) +} + +/** + * Parse the `releases` env plumbed from the workflow_dispatch input. + * Returns `null` when absent or blank (lockstep / single-package mode); + * throws on anything malformed rather than silently ignoring it. + */ +export function releasesFromEnv (raw: string | undefined): ReleaseEntry[] | null { + const trimmed = raw?.trim() + if (!trimmed) return null + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } + catch (err) { + throw new Error(`RELEASES is not valid JSON: ${(err as Error).message}`) + } + if (!Array.isArray(parsed) || !parsed.length) { + throw new Error('RELEASES must be a non-empty JSON array of { name, version, dir } entries') + } + const releases: ReleaseEntry[] = [] + const seen = new Set() + for (const entry of parsed) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`RELEASES entry is not an object: ${JSON.stringify(entry)}`) + } + const { name, version, dir } = entry as Record + if (typeof name !== 'string' || !isValidPackageName(name)) { + throw new Error(`RELEASES entry has an invalid package name: ${JSON.stringify(name)}`) + } + if (typeof version !== 'string' || !isSemver(version)) { + throw new Error(`RELEASES entry for ${name} has a non-semver version: ${JSON.stringify(version)}`) + } + if (typeof dir !== 'string' || !isSafeRelDir(dir)) { + throw new Error(`RELEASES entry for ${name} has an unsafe dir: ${JSON.stringify(dir)}`) + } + if (seen.has(name)) { + throw new Error(`RELEASES lists ${name} more than once`) + } + seen.add(name) + releases.push({ name, version, dir }) + } + return releases +} + +/** + * Tarball filename `npm pack` / `pnpm pack` produce for a package + * (`@nuxt/kit@5.0.0` → `nuxt-kit-5.0.0.tgz`). + */ +export function expectedTarballName (name: string, version: string): string { + return `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz` +} diff --git a/scripts/_workspaces.ts b/scripts/_workspaces.ts index dbf2aed..23e0717 100644 --- a/scripts/_workspaces.ts +++ b/scripts/_workspaces.ts @@ -168,8 +168,8 @@ export function lockstepVersionFromWorkspaces (workspaces: Workspace[]): string .map(ws => ` - ${ws.name}: ${ws.version ?? ''}`) .join('\n') throw new Error( - 'Workspaces do not agree on a single version. uppt currently supports lockstep releases only: ' - + 'every listed package must share the same semver version. Reconcile them before releasing.\n' + 'Workspaces do not agree on a single version, which lockstep mode requires. ' + + 'Reconcile them to a single version, or set `mode: independent` to version each package on its own cadence.\n' + detail, ) } @@ -197,3 +197,107 @@ export function resolveCurrentVersion (rootDir: string, packagesInput: string): } return pkg.version } + +/** + * Parse a newline-separated `scopes:` input into a map of package name + * → declared commit scopes. Each non-blank, non-comment line takes the + * shape `: [ ...]`. Whitespace is + * flexible; comments start with `#`. + * + * Used by `buildScopeMap` to override the default basename-derived + * scope for any package the maintainer wants to disambiguate or alias. + */ +export function parseScopesInput (raw: string): Map { + const out = new Map() + const lines = raw.split(/\r?\n/) + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.replace(/#.*$/, '').trim() + if (!line) continue + const colon = line.indexOf(':') + if (colon === -1) { + throw new Error(`\`scopes\` entry on line ${i + 1} is missing a colon. Expected ": [ ...]", got "${line}".`) + } + const name = line.slice(0, colon).trim() + if (!name) { + throw new Error(`\`scopes\` entry on line ${i + 1} has an empty package name.`) + } + const scopes = line.slice(colon + 1).split(/\s+/).filter(Boolean) + if (!scopes.length) { + throw new Error(`\`scopes\` entry for "${name}" lists no scopes. Drop the line or add at least one scope.`) + } + if (out.has(name)) { + throw new Error(`\`scopes\` entry for "${name}" appears more than once. Combine the scopes onto a single line.`) + } + out.set(name, scopes) + } + return out +} + +/** + * Inverse view of the workspace→scopes mapping: given a commit scope, + * return the workspaces that own it. Built once per release run. + */ +export interface ScopeMap { + /** Resolve a commit scope to the workspace that owns it, or `null`. */ + resolve (scope: string): Workspace | null + /** Every (workspace, declared scopes) pair, in the input workspace order. */ + entries (): Array<{ workspace: Workspace, scopes: string[] }> +} + +/** + * Build the bidirectional scope routing map. For each workspace, the + * declared scopes come from `overrides` if present, otherwise from + * the basename of the package name (`@nuxt/kit` -> `kit`, `nuxt` -> + * `nuxt`). + * + * Throws when: + * - an override references a package not in `workspaces` (likely a + * stale entry from a rename); + * - two workspaces end up claiming the same scope (routing would be + * ambiguous; the maintainer must disambiguate with overrides). + */ +export function buildScopeMap ( + workspaces: Workspace[], + overrides: Map, +): ScopeMap { + const byName = new Map(workspaces.map(ws => [ws.name, ws])) + + for (const name of overrides.keys()) { + if (!byName.has(name)) { + throw new Error( + `\`scopes\` entry references "${name}", which is not in the resolved \`packages\` list. ` + + 'Remove the entry or add the package to `packages`.', + ) + } + } + + const perWorkspace: Array<{ workspace: Workspace, scopes: string[] }> = [] + const inverse = new Map() + + for (const ws of workspaces) { + const scopes = overrides.get(ws.name) ?? [defaultScopeForName(ws.name)] + perWorkspace.push({ workspace: ws, scopes }) + for (const scope of scopes) { + const existing = inverse.get(scope) + if (existing && existing !== ws) { + throw new Error( + `Commit scope "${scope}" is claimed by both "${existing.name}" and "${ws.name}". ` + + 'Disambiguate via the `scopes` input.', + ) + } + inverse.set(scope, ws) + } + } + + return { + resolve: scope => inverse.get(scope) ?? null, + entries: () => perWorkspace, + } +} + +function defaultScopeForName (name: string): string { + // `@nuxt/kit` -> `kit`; `nuxt` -> `nuxt`; `@scope/foo/bar` -> `bar` + // (unlikely shape but handle it without crashing). + const slash = name.lastIndexOf('/') + return slash === -1 ? name : name.slice(slash + 1) +} diff --git a/scripts/pack.ts b/scripts/pack.ts index 56f15a9..1d7974f 100644 --- a/scripts/pack.ts +++ b/scripts/pack.ts @@ -18,14 +18,21 @@ // PACKAGES newline-separated list of publishable workspace // paths/globs; when set, the script packs each // listed workspace instead of the root. +// RELEASES optional JSON payload emitted by `uppt/release` in +// independent mode: an ordered array of +// `{ name, version, dir }`. When set, exactly those +// workspaces are packed, in that order, and the ref +// must be a `release-YYYY-MM-DD-` coordination +// tag instead of `vX.Y.Z`. import process from 'node:process' import { execFileSync } from 'node:child_process' -import { appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs' +import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { resolve } from 'node:path' import { parseFilenames } from './_pack-json.ts' import { resolveWorkspaces } from './_workspaces.ts' +import { COORDINATION_TAG_RE, releasesFromEnv, type ReleaseEntry } from './_independent.ts' function runCapture (cmd: string, args: string[], cwd?: string): string { console.log('$', cmd, ...args, cwd ? `(cwd: ${cwd})` : '') @@ -37,15 +44,44 @@ function runCapture (cmd: string, args: string[], cwd?: string): string { }) } +function releaseTargets (rootDir: string, releases: ReleaseEntry[]): Array<{ name: string, cwd: string }> { + return releases.map((entry) => { + const dir = resolve(rootDir, entry.dir) + const pkgPath = resolve(dir, 'package.json') + if (!existsSync(pkgPath)) { + throw new Error(`RELEASES entry for ${entry.name} points at ${entry.dir}, which has no package.json`) + } + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string, version?: string, private?: boolean } + if (pkg.private === true) { + throw new Error(`RELEASES entry for ${entry.name} points at a private package (${entry.dir})`) + } + if (pkg.name !== entry.name || pkg.version !== entry.version) { + throw new Error(`RELEASES entry ${entry.name}@${entry.version} does not match ${entry.dir}/package.json (${pkg.name}@${pkg.version}). Is the workflow running on the right ref?`) + } + return { name: entry.name, cwd: dir } + }) +} + function main () { const ref = process.env.GITHUB_REF ?? '' - if (!ref.startsWith('refs/tags/v')) { - throw new Error(`GITHUB_REF must be a 'refs/tags/v*' ref, got '${ref || ''}'`) + const releases = releasesFromEnv(process.env.RELEASES) + + if (releases) { + const tag = ref.startsWith('refs/tags/') ? ref.slice('refs/tags/'.length) : '' + if (!COORDINATION_TAG_RE.test(tag)) { + throw new Error(`RELEASES is set, so GITHUB_REF must be a 'refs/tags/release-YYYY-MM-DD-' coordination tag, got '${ref || ''}'`) + } } - const tag = ref.slice('refs/tags/'.length) - if (!/^v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(tag)) { - throw new Error(`Refusing to pack: tag "${tag}" is not strict semver`) + else { + if (!ref.startsWith('refs/tags/v')) { + throw new Error(`GITHUB_REF must be a 'refs/tags/v*' ref, got '${ref || ''}'`) + } + const tag = ref.slice('refs/tags/'.length) + if (!/^v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(tag)) { + throw new Error(`Refusing to pack: tag "${tag}" is not strict semver`) + } } + const tag = ref.slice('refs/tags/'.length) const outDir = process.env.PACK_OUT_DIR if (!outDir) throw new Error('PACK_OUT_DIR is required') @@ -53,9 +89,11 @@ function main () { const hasPnpmLock = existsSync(resolve(process.cwd(), 'pnpm-lock.yaml')) const packagesInput = process.env.PACKAGES?.trim() ?? '' - const targets = packagesInput.length - ? resolveWorkspaces(process.cwd(), packagesInput).map(ws => ({ name: ws.name, cwd: ws.dir })) - : [{ name: '', cwd: process.cwd() }] + const targets = releases + ? releaseTargets(process.cwd(), releases) + : packagesInput.length + ? resolveWorkspaces(process.cwd(), packagesInput).map(ws => ({ name: ws.name, cwd: ws.dir })) + : [{ name: '', cwd: process.cwd() }] const filenames: string[] = [] for (const target of targets) { diff --git a/scripts/publish.ts b/scripts/publish.ts index bc08974..df7e424 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -17,12 +17,18 @@ // NPM_ACCESS `public` (default) or `restricted` // TARBALL_DIR directory holding the prebuilt `*.tgz` files // TARBALL_FILES optional JSON array of filenames within TARBALL_DIR +// RELEASES optional JSON payload emitted by `uppt/release` in +// independent mode: an array of +// `{ name, version, dir }`. Names the tarballs to +// stage when TARBALL_FILES is absent. import process from 'node:process' import { execFileSync } from 'node:child_process' import { existsSync, readdirSync } from 'node:fs' import { resolve } from 'node:path' +import { expectedTarballName, releasesFromEnv } from './_independent.ts' + function run (cmd: string, args: string[]) { console.log('$', cmd, ...args) execFileSync(cmd, args, { stdio: 'inherit' }) @@ -54,6 +60,8 @@ function main () { if (!dir) throw new Error('TARBALL_DIR is required') if (!existsSync(dir)) throw new Error(`TARBALL_DIR does not exist: ${dir}`) + const releases = releasesFromEnv(process.env.RELEASES) + const filesEnv = process.env.TARBALL_FILES?.trim() let tarballs: string[] if (filesEnv) { @@ -62,6 +70,9 @@ function main () { throw new Error('TARBALL_FILES was provided but is empty') } } + else if (releases) { + tarballs = releases.map(entry => expectedTarballName(entry.name, entry.version)) + } else { tarballs = readdirSync(dir).filter(f => f.endsWith('.tgz')).sort() if (!tarballs.length) { diff --git a/scripts/tag-and-release.ts b/scripts/tag-and-release.ts index 8c4ee7f..1065d09 100644 --- a/scripts/tag-and-release.ts +++ b/scripts/tag-and-release.ts @@ -12,10 +12,18 @@ // PUBLISH_WORKFLOW workflow filename to dispatch (default: release.yml) // PACKAGES newline-separated list of publishable workspace // dirs/globs (monorepo); omit for single-package repos +// MODE "lockstep" (default) or "independent". Independent +// mode derives the release set from the tree (each +// workspace's version vs its latest `@X.Y.Z` +// tag), fans out one tag per released package, cuts a +// single release on a `release-YYYY-MM-DD-` +// coordination tag, and dispatches the publish +// workflow with a `releases` payload input. import process from 'node:process' import { execFileSync } from 'node:child_process' -import { isSemver, resolveCurrentVersion } from './_workspaces.ts' +import { isSemver, resolveCurrentVersion, resolveWorkspaces } from './_workspaces.ts' +import { coordinationTag, deriveReleaseSet, packageTag, serialiseReleases } from './_independent.ts' function run (cmd: string, args: string[], opts: { env?: NodeJS.ProcessEnv } = {}) { execFileSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...opts.env } }) @@ -41,12 +49,81 @@ function tagExists (repo: string, tag: string, env: NodeJS.ProcessEnv): boolean } } +function createTag (repo: string, tag: string, sha: string, env: NodeJS.ProcessEnv) { + // Create the tag via the GitHub API instead of `git push`, so this step + // doesn't need git-level write credentials baked into the runner. + run('gh', [ + 'api', '-X', 'POST', + '-H', 'Accept: application/vnd.github+json', + `/repos/${repo}/git/refs`, + '-f', `ref=refs/tags/${tag}`, + '-f', `sha=${sha}`, + ], { env }) +} + +function allTags (): string[] { + return capture('git', ['for-each-ref', '--sort=-creatordate', '--format=%(refname:strip=2)', 'refs/tags']) + .split('\n').map(s => s.trim()).filter(Boolean) +} + +function mainIndependent (repo: string, ghEnv: NodeJS.ProcessEnv) { + const packagesInput = process.env.PACKAGES?.trim() ?? '' + if (!packagesInput) throw new Error('`mode: independent` requires the `packages` input.') + + const workspaces = resolveWorkspaces(process.cwd(), packagesInput) + const releases = deriveReleaseSet(workspaces, allTags()) + if (!releases.length) { + throw new Error('No workspace version differs from its latest tag; nothing to release. Was the release PR merged without version bumps?') + } + + const sha = capture('git', ['rev-parse', 'HEAD']) + const coordTag = coordinationTag(sha) + const tags = [...releases.map(packageTag), coordTag] + + const existing = tags.filter(tag => tagExists(repo, tag, ghEnv)) + if (existing.length) { + throw new Error(`Refusing to tag: ${existing.join(', ')} already exist${existing.length === 1 ? 's' : ''} on ${repo}. If this is a rerun, delete the tags (and any release) from the previous attempt first.`) + } + + const created: string[] = [] + for (const tag of tags) { + try { + createTag(repo, tag, sha, ghEnv) + created.push(tag) + } + catch (err) { + throw new Error( + `Failed to create tag ${tag} (created so far: ${created.join(', ') || ''}). ` + + 'No release was created and no publish was dispatched. Delete the created tags and rerun, or create the remaining tags manually.', + { cause: err }, + ) + } + } + + const body = process.env.PR_BODY ?? '' + run('gh', ['release', 'create', coordTag, '--title', coordTag, '--notes', body], { env: ghEnv }) + + const workflow = process.env.PUBLISH_WORKFLOW || 'release.yml' + run('gh', ['workflow', 'run', workflow, '--ref', coordTag, '-f', `releases=${serialiseReleases(releases)}`], { env: ghEnv }) + + console.log(`Tagged ${releases.length} package${releases.length === 1 ? '' : 's'} (${releases.map(packageTag).join(', ')}) plus ${coordTag}, created release, dispatched ${workflow}.`) +} + function main () { const token = process.env.GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN is required') const repo = process.env.GITHUB_REPOSITORY if (!repo || !repo.includes('/')) throw new Error('GITHUB_REPOSITORY is required') + const mode = process.env.MODE?.trim() || 'lockstep' + if (mode !== 'lockstep' && mode !== 'independent') { + throw new Error(`Invalid \`mode\` input "${mode}": expected "lockstep" or "independent".`) + } + if (mode === 'independent') { + mainIndependent(repo, { GH_TOKEN: token }) + return + } + const version = resolveCurrentVersion(process.cwd(), process.env.PACKAGES?.trim() ?? '') // `version` flows into ref names and `gh` argv. Pin to strict semver to // rule out flag-injection (`--upload-pack=...`) and ref-confusion attacks. @@ -60,16 +137,8 @@ function main () { throw new Error(`Refusing to tag: ${tag} already exists on ${repo}. If this is a rerun, delete the tag and the release first, or bump the version.`) } - // Create the tag via the GitHub API instead of `git push`, so this step - // doesn't need git-level write credentials baked into the runner. const sha = capture('git', ['rev-parse', 'HEAD']) - run('gh', [ - 'api', '-X', 'POST', - '-H', 'Accept: application/vnd.github+json', - `/repos/${repo}/git/refs`, - '-f', `ref=refs/tags/${tag}`, - '-f', `sha=${sha}`, - ], { env: ghEnv }) + createTag(repo, tag, sha, ghEnv) const body = process.env.PR_BODY ?? '' run('gh', ['release', 'create', tag, '--title', tag, '--notes', body], { env: ghEnv }) diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index c3034d7..790b36a 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -19,6 +19,12 @@ // PRERELEASE one-shot prerelease identifier (e.g. "beta", "rc", // "0"); when set, the release cuts or continues a // prerelease instead of a stable version +// MODE "lockstep" (default) or "independent"; independent +// mode computes a per-package release plan instead of +// bumping every workspace to one shared version +// SCOPES newline-separated ": ..." +// overrides for routing commit scopes to workspaces +// in independent mode import process from 'node:process' import { execFileSync } from 'node:child_process' @@ -27,9 +33,11 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { makePkgFormatter } from './pkg-format.ts' -import { resolveCurrentVersion, resolveWorkspaces, type Workspace } from './_workspaces.ts' +import { buildScopeMap, parseScopesInput, resolveCurrentVersion, resolveWorkspaces, type Workspace } from './_workspaces.ts' +import { buildDependencyGraph, propagateReleases, type BumpLevel } from './_dependency-graph.ts' -interface Commit { +export interface Commit { + hash: string shortHash: string message: string type: string @@ -82,7 +90,19 @@ function getCurrentBranch (): string { return process.env.RELEASE_BASE || git('rev-parse', '--abbrev-ref', 'HEAD') } -interface Tag { name: string, ref: string } +export interface Tag { name: string, ref: string } + +function getAllTags (): string[] { + try { + return execFileSync( + 'git', + ['for-each-ref', '--sort=-creatordate', '--format=%(refname:strip=2)', 'refs/tags'], + { encoding: 'utf8' }, + ).split('\n').map(s => s.trim()).filter(Boolean) + } catch { + return [] + } +} function getLatestTag (): Tag | null { // Pick the most recent semver-shaped tag by creation date. We deliberately @@ -93,26 +113,72 @@ function getLatestTag (): Tag | null { // We return both the short name (for display / URLs) and the fully // qualified ref (`refs/tags/...`) so subsequent git calls aren't confused // by branches sharing the tag name. - try { - const stdout = execFileSync( - 'git', - ['for-each-ref', '--sort=-creatordate', '--format=%(refname:strip=2)', 'refs/tags'], - { encoding: 'utf8' }, - ) - const name = stdout.split('\n').map(s => s.trim()).find(t => /^v?\d+\.\d+\.\d+/.test(t)) - return name ? { name, ref: `refs/tags/${name}` } : null - } catch { - return null + const name = getAllTags().find(t => /^v?\d+\.\d+\.\d+/.test(t)) + return name ? { name, ref: `refs/tags/${name}` } : null +} + +// Numeric semver comparison over the `X.Y.Z` core, with a bare version +// sorting above any suffixed one (prerelease or build metadata) at the +// same core. Suffixes at the same core version are not further ordered; +// tag selection only needs "highest stable wins", not full semver +// precedence. +function compareVersions (a: string, b: string): number { + const parse = (v: string) => { + const m = v.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/)! + return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] !== '' } + } + const pa = parse(a) + const pb = parse(b) + for (let i = 0; i < 3; i++) { + if (pa.core[i]! !== pb.core[i]!) return pa.core[i]! - pb.core[i]! } + return Number(pb.pre) - Number(pa.pre) +} + +function highestVersionTag (tags: string[], extractVersion: (tag: string) => string | null): Tag | null { + let best: { name: string, version: string } | null = null + for (const tag of tags) { + const version = extractVersion(tag) + if (version === null) continue + if (!best || compareVersions(version, best.version) > 0) best = { name: tag, version } + } + return best ? { name: best.name, ref: `refs/tags/${best.name}` } : null +} + +/** + * Latest release tag for a specific package, using the `@X.Y.Z` + * convention (`fontaine@0.8.0`, `@nuxt/kit@5.0.0`). "Latest" means the + * highest version by numeric semver comparison, regardless of the order + * `tags` is supplied in: tag creation date can diverge from version order + * (retagging, backported releases, tag imports). Bare version-shaped tags + * (`0.2.3`) and lockstep tags (`v0.6.0`) never match a package. + */ +export function latestTagForPackage (pkgName: string, tags: string[]): Tag | null { + const escaped = pkgName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const re = new RegExp(`^${escaped}@(\\d+\\.\\d+\\.\\d+(?:[-+].*)?)$`) + return highestVersionTag(tags, tag => tag.match(re)?.[1] ?? null) +} + +/** + * Latest lockstep `vX.Y.Z` tag. Used as the commit-range fallback for a + * package with no `@*` tag yet, i.e. the first independent release + * after a lockstep history. "Latest" is the highest version by numeric + * semver comparison, not list order. Bare version-shaped tags are + * deliberately excluded: they can't be attributed to any package or + * release mode. + */ +export function latestLockstepTag (tags: string[]): Tag | null { + return highestVersionTag(tags, tag => tag.match(/^v(\d+\.\d+\.\d+(?:[-+].*)?)$/)?.[1] ?? null) } function parseCommit (raw: string): Commit | null { - const [shortHash, authorName, authorEmail, subject, body] = raw.split('\x1f') - if (!shortHash || !subject) return null + const [hash, shortHash, authorName, authorEmail, subject, body] = raw.split('\x1f') + if (!hash || !shortHash || !subject) return null const header = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/) if (!header) { return { + hash, shortHash, message: subject, type: '', @@ -138,6 +204,7 @@ function parseCommit (raw: string): Commit | null { const description = rawDescription!.replace(/\s*\(#\d+\)\s*$/, '').trim() return { + hash, shortHash, message: subject, type: type!.toLowerCase(), @@ -153,7 +220,7 @@ function getCommitsSince (tag: Tag | null): Commit[] { const range = tag ? `${tag.ref}..HEAD` : 'HEAD' const stdout = execFileSync( 'git', - ['log', range, `--pretty=format:%h%x1f%an%x1f%ae%x1f%s%x1f%b%x1e`], + ['log', range, `--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%s%x1f%b%x1e`], { encoding: 'utf8' }, ) return stdout @@ -164,7 +231,7 @@ function getCommitsSince (tag: Tag | null): Commit[] { .filter((c): c is Commit => c !== null) } -function determineBump (commits: Commit[]): 'major' | 'minor' | 'patch' { +export function determineBump (commits: Commit[]): BumpLevel { if (commits.some(c => c.isBreaking)) return 'major' if (commits.some(c => c.type === 'feat')) return 'minor' return 'patch' @@ -231,7 +298,105 @@ export function incVersion (version: string, bump: 'major' | 'minor' | 'patch', return `${x}.${y}.${z}-${prerelease}.0` } -function formatChangelog ( +export interface PackageRelease { + /** Package name, as declared in its `package.json`. */ + name: string + /** The resolved workspace record. */ + workspace: Workspace + /** + * The tag this package's changelog range starts from: its latest + * `@X.Y.Z` tag, else the latest lockstep `vX.Y.Z` tag, else + * `null` when the repo has neither (full history). + */ + fromTag: Tag | null + /** Version in the workspace `package.json` right now. */ + currentVersion: string + /** Version this release bumps the package to. */ + newVersion: string + bump: BumpLevel + /** + * `true` when the package releases on its own routed commits; + * `false` when it releases only because a `workspace:` dependency + * was bumped. + */ + ownCommits: boolean + /** Commits routed to this package. Empty for propagated-only releases. */ + commits: Commit[] +} + +export interface IndependentReleasePlan { + /** Packages to release, in topological (publish) order. */ + releases: PackageRelease[] + /** Commits with no scope, or a scope no package claims. They bump nothing. */ + unrouted: Commit[] +} + +/** + * Compute the per-package release plan for independent-versioning mode. + * + * `commits` is the union commit range covering every package's last + * release; `isCommitSince` narrows it per package (a commit already + * shipped in `fontaine@0.8.0` must not count toward fontaine again just + * because fontless released longer ago). When omitted, every commit + * counts for every package. + */ +export function computeIndependentPlan (opts: { + workspaces: Workspace[] + /** `scopes:` input overrides, from `parseScopesInput`. */ + scopeOverrides: Map + /** All tag names, sorted newest-first. */ + tags: string[] + /** Commits over the union range, already filtered to release-worthy types. */ + commits: Commit[] + isCommitSince?: (commit: Commit, tag: Tag) => boolean + prerelease?: string +}): IndependentReleasePlan { + const scopeMap = buildScopeMap(opts.workspaces, opts.scopeOverrides) + + const routed = new Map(opts.workspaces.map(ws => [ws.name, []])) + const unrouted: Commit[] = [] + for (const commit of opts.commits) { + const ws = commit.scope ? scopeMap.resolve(commit.scope) : null + if (ws) routed.get(ws.name)!.push(commit) + else unrouted.push(commit) + } + + const lockstepTag = latestLockstepTag(opts.tags) + const fromTags = new Map() + const planned: Array<{ name: string, bump: BumpLevel }> = [] + for (const ws of opts.workspaces) { + const fromTag = latestTagForPackage(ws.name, opts.tags) ?? lockstepTag + fromTags.set(ws.name, fromTag) + const commits = (fromTag && opts.isCommitSince) + ? routed.get(ws.name)!.filter(c => opts.isCommitSince!(c, fromTag)) + : routed.get(ws.name)! + routed.set(ws.name, commits) + if (commits.length) planned.push({ name: ws.name, bump: determineBump(commits) }) + } + + const graph = buildDependencyGraph(opts.workspaces) + const byName = new Map(opts.workspaces.map(ws => [ws.name, ws])) + const releases = propagateReleases(graph, planned).map((release) => { + const workspace = byName.get(release.name)! + if (!workspace.version) { + throw new Error(`Cannot release "${release.name}": its package.json has no \`version\` field.`) + } + return { + name: release.name, + workspace, + fromTag: fromTags.get(release.name)!, + currentVersion: workspace.version, + newVersion: incVersion(workspace.version, release.bump, opts.prerelease), + bump: release.bump, + ownCommits: release.ownCommits, + commits: release.ownCommits ? routed.get(release.name)! : [], + } + }) + + return { releases, unrouted } +} + +export function formatChangelog ( commits: Commit[], opts: { owner: string, repo: string, fromRef: Tag | null, toRef: string }, ): string { @@ -465,6 +630,35 @@ async function commitFilesToBranch ( }) } +/** + * Point `opts.branch` at `opts.base`'s current tip, creating it if it + * doesn't exist. Force-updates: any previous bump commit on the branch + * is discarded, ready to be replaced by a fresh one. + */ +async function resetBranchToBase ( + repo: { owner: string, repo: string }, + opts: { base: string, branch: string }, +): Promise { + const baseInfo = await gh<{ commit: { sha: string } }>( + `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.base)}`, + { requireAuth: true }, + ) + try { + await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${opts.branch}`, { + method: 'PATCH', + requireAuth: true, + body: JSON.stringify({ sha: baseInfo.commit.sha, force: true }), + }) + } catch (err) { + if (!(err instanceof Error) || !/-> (?:404|422)\b/.test(err.message)) throw err + await gh(`/repos/${repo.owner}/${repo.repo}/git/refs`, { + method: 'POST', + requireAuth: true, + body: JSON.stringify({ ref: `refs/heads/${opts.branch}`, sha: baseInfo.commit.sha }), + }) + } +} + async function isReleaseMergeCommit ( repo: { owner: string, repo: string }, sha: string, @@ -474,7 +668,7 @@ async function isReleaseMergeCommit ( const prs = await gh>( `/repos/${repo.owner}/${repo.repo}/commits/${sha}/pulls`, ) - return prs.some(pr => pr.merged_at && pr.head.ref.startsWith('release/v')) + return prs.some(pr => pr.merged_at && (pr.head.ref.startsWith('release/v') || /^release\/.+-pending$/.test(pr.head.ref))) } catch { return false } @@ -489,8 +683,7 @@ async function isReleaseMergeCommit ( * The root is included only if its current `version` exactly matches * the lockstep version; otherwise it's left untouched (it might be * `0.0.0`, absent, or deliberately frozen, and none of those are uppt's - * business). When independent versioning lands, this is the place that - * decides which workspaces get a bump on a given release. + * business). */ export function buildBumpFileSet (opts: { monorepo: boolean @@ -525,6 +718,117 @@ export function buildBumpFileSet (opts: { return files } +/** + * Build the set of `package.json` files to write in an independent-mode + * release commit: exactly the planned releases' manifests, each bumped + * to its own `newVersion`. `workspace:` specifiers are left as-is (the + * package manager resolves them at pack time), and the root manifest is + * only included if it is itself a planned release. + */ +export function buildIndependentBumpFileSet (plan: IndependentReleasePlan): FileToCommit[] { + return plan.releases.map((release) => { + const pkg = { ...release.workspace.pkg, version: release.newVersion } + const path = release.workspace.relDir === '.' ? 'package.json' : `${release.workspace.relDir}/package.json` + return { path, content: makePkgFormatter(release.workspace.source)(pkg) } + }) +} + +/** + * Maintainer-editable preamble of a release PR body: everything above + * the first generated `## 👉` heading. Returns `null` when the body is + * empty or starts with a generated heading. + */ +export function extractPreamble (body: string | null | undefined): string | null { + if (!body) return null + const match = body.match(/^## 👉 .*$/m) + const preamble = (match ? body.slice(0, match.index) : body).trim() + return preamble || null +} + +const DEPENDENCY_FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies'] as const + +function propagationCauses (release: PackageRelease, releasedNames: Set): string[] { + const causes = new Set() + for (const field of DEPENDENCY_FIELDS) { + const deps = release.workspace.pkg[field] + if (!deps || typeof deps !== 'object') continue + for (const [dep, spec] of Object.entries(deps as Record)) { + if (typeof spec !== 'string' || !spec.startsWith('workspace:')) continue + if (dep !== release.name && releasedNames.has(dep)) causes.add(dep) + } + } + return [...causes] +} + +/** + * Render the full body of an independent-mode release PR from the plan. + * The body is regenerated wholesale on every push (only the preamble + * carries over), so packages that drop out of the plan disappear. + */ +export function buildIndependentBody ( + plan: IndependentReleasePlan, + opts: { + owner: string + repo: string + /** Release branch name, used as the `compare` target for changelog links. */ + branch: string + preamble: string + contributors?: Contributor[] + }, +): string { + const releasedNames = new Set(plan.releases.map(r => r.name)) + const lines: string[] = [opts.preamble, '', '## 👉 Pending releases', ''] + + for (const release of plan.releases) { + const suffix = release.ownCommits ? '' : ', dependency bump only' + lines.push(`- ${release.name}: ${release.currentVersion} → ${release.newVersion} (${release.bump}${suffix})`) + } + + lines.push('', '## 👉 Changelog', '') + for (const release of plan.releases) { + lines.push(`### ${release.name} (${release.currentVersion} → ${release.newVersion})`, '') + if (release.ownCommits) { + lines.push(formatChangelog(release.commits, { + owner: opts.owner, + repo: opts.repo, + fromRef: release.fromTag, + toRef: opts.branch, + }), '') + } else { + const causes = propagationCauses(release, releasedNames) + const note = causes.length + ? `_Released because ${causes.map(c => `\`${c}\``).join(' and ')} was bumped; no direct changes._` + : '_Released because a `workspace:` dependency was bumped; no direct changes._' + lines.push(note, '') + } + } + + if (plan.unrouted.length) { + const commitUrl = (sha: string) => `https://github.com/${opts.owner}/${opts.repo}/commit/${sha}` + lines.push('### 🧭 Unrouted commits', '', '_These commits were not routed to any package and do not bump any version._', '') + for (const c of plan.unrouted) { + lines.push(`- ${c.message} ([\`${c.shortHash}\`](${commitUrl(c.shortHash)}))`) + } + lines.push('') + } + + if (opts.contributors) { + const newContributors = opts.contributors.filter(c => c.isFirstTime) + if (newContributors.length) { + lines.push('### 🎉 New Contributors', '', newContributors.map(c => `- ${c.name} (@${c.username})`).join('\n'), '') + } + lines.push( + '### ❤️ Contributors', + '', + opts.contributors.length + ? opts.contributors.map(c => `- ${c.name} (@${c.username})`).join('\n') + : '_no contributors yet_', + ) + } + + return lines.join('\n').trimEnd() +} + async function main () { const dryRun = Boolean(process.env.DRY_RUN) const repo = getRepo() @@ -536,6 +840,21 @@ async function main () { return } + const packagesInput = process.env.PACKAGES?.trim() ?? '' + const monorepo = packagesInput.length > 0 + + const mode = process.env.MODE?.trim() || 'lockstep' + if (mode !== 'lockstep' && mode !== 'independent') { + throw new Error(`Invalid \`mode\` input "${mode}": expected "lockstep" or "independent".`) + } + if (mode === 'independent') { + if (!monorepo) { + throw new Error('`mode: independent` requires the `packages` input.') + } + await runIndependent(packagesInput) + return + } + const latestTag = getLatestTag() const commits = getCommitsSince(latestTag).filter( @@ -547,8 +866,6 @@ async function main () { return } - const packagesInput = process.env.PACKAGES?.trim() ?? '' - const monorepo = packagesInput.length > 0 const workspaces: Workspace[] = monorepo ? resolveWorkspaces(process.cwd(), packagesInput) : [] @@ -597,14 +914,14 @@ async function main () { const stale = openReleasePRs .filter(pr => pr.head.repo?.full_name === sameRepo - && pr.head.ref.startsWith('release/v') + && (pr.head.ref.startsWith('release/v') || pr.head.ref === `release/${baseBranch}-pending`) && pr.head.ref !== releaseBranch && pr.base.ref === baseBranch, ) .sort((a, b) => b.updated_at.localeCompare(a.updated_at)) for (const pr of stale) { console.log(`Closing superseded release PR #${pr.number} (${pr.head.ref})`) - const preamble = pr.body?.replace(/## 👉 Changelog[\s\S]*$/, '').trim() + const preamble = extractPreamble(pr.body) if (preamble && !seedPreamble) seedPreamble = preamble await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${pr.number}`, { method: 'PATCH', @@ -670,7 +987,7 @@ async function main () { `/repos/${repo.owner}/${repo.repo}/pulls?head=${repo.owner}:${releaseBranch}&state=open`, ) const currentPR = existing[0] - const preamble = currentPR?.body?.replace(/## 👉 Changelog[\s\S]*$/, '').trim() + const preamble = extractPreamble(currentPR?.body) || seedPreamble || `> v${newVersion} is the next ${bump} release.\n>\n> **Timetable**: to be announced.` @@ -728,6 +1045,194 @@ async function main () { } } +async function runIndependent (packagesInput: string): Promise { + const workspaces = resolveWorkspaces(process.cwd(), packagesInput) + const scopeOverrides = parseScopesInput(process.env.SCOPES ?? '') + const tags = getAllTags() + + const lockstepTag = latestLockstepTag(tags) + const fromTags = workspaces.map(ws => latestTagForPackage(ws.name, tags) ?? lockstepTag) + + // One `git log` over the union range: the oldest per-package boundary, + // i.e. the highest index in the newest-first tag list. Any package with + // no boundary at all forces full history. + let unionFrom: Tag | null = null + if (fromTags.every(t => t !== null)) { + let oldestIndex = -1 + for (const tag of fromTags) { + const index = tags.indexOf(tag!.name) + if (index > oldestIndex) { + oldestIndex = index + unionFrom = tag + } + } + } + + const commits = getCommitsSince(unionFrom).filter( + c => KNOWN_TYPES.has(c.type) && !(c.type === 'chore' && c.scope === 'deps'), + ) + + const sinceSets = new Map>() + const isCommitSince = (commit: Commit, tag: Tag): boolean => { + let set = sinceSets.get(tag.ref) + if (!set) { + set = new Set(git('rev-list', `${tag.ref}..HEAD`).split('\n').filter(Boolean)) + sinceSets.set(tag.ref, set) + } + return set.has(commit.hash) + } + + const plan = computeIndependentPlan({ + workspaces, + scopeOverrides, + tags, + commits, + isCommitSince, + prerelease: process.env.PRERELEASE?.trim() || undefined, + }) + + if (plan.unrouted.length) { + console.log(`Unrouted commits (${plan.unrouted.length}, bump nothing):`) + for (const commit of plan.unrouted) { + console.log(` ${commit.shortHash} ${commit.message}`) + } + } + if (!plan.releases.length) { + console.log('Independent release plan: no packages to release.') + return + } + + console.log(`Independent release plan (${plan.releases.length} package${plan.releases.length === 1 ? '' : 's'}):`) + for (const release of plan.releases) { + const reason = release.ownCommits + ? `${release.commits.length} commit${release.commits.length === 1 ? '' : 's'}` + : 'dependency bump only' + const from = release.fromTag ? ` since ${release.fromTag.name}` : '' + console.log(` ${release.name}: ${release.currentVersion} -> ${release.newVersion} (${release.bump}, ${reason}${from})`) + } + + const dryRun = Boolean(process.env.DRY_RUN) + const repo = getRepo() + const baseBranch = getCurrentBranch() + const releaseBranch = `release/${baseBranch}-pending` + const title = `chore(release): ${plan.releases.length} package${plan.releases.length === 1 ? '' : 's'}` + + console.log(`Base branch: ${baseBranch}`) + console.log(`Release branch: ${releaseBranch}`) + + // A stale lockstep PR (`release/vX.Y.Z`) on the same base means the repo + // switched to independent mode with a lockstep release still open; the two + // would bump the same manifests to conflicting versions, so close it and + // lift its preamble into the pending PR. + let seedPreamble: string | null = null + if (!dryRun && process.env.GITHUB_TOKEN) { + const openReleasePRs = await gh>( + `/repos/${repo.owner}/${repo.repo}/pulls?state=open&per_page=100&base=${encodeURIComponent(baseBranch)}&head=${repo.owner}:`, + { requireAuth: true }, + ) + const sameRepo = `${repo.owner}/${repo.repo}` + const stale = openReleasePRs + .filter(pr => + pr.head.repo?.full_name === sameRepo + && pr.head.ref.startsWith('release/v') + && pr.base.ref === baseBranch, + ) + .sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + for (const pr of stale) { + console.log(`Closing superseded release PR #${pr.number} (${pr.head.ref})`) + const preamble = extractPreamble(pr.body) + if (preamble && !seedPreamble) seedPreamble = preamble + await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${pr.number}`, { + method: 'PATCH', + body: JSON.stringify({ state: 'closed' }), + requireAuth: true, + }) + try { + await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${pr.head.ref}`, { + method: 'DELETE', + requireAuth: true, + }) + } + catch (err) { + console.warn(` could not delete branch ${pr.head.ref}:`, err) + } + } + } + + if (!dryRun) { + if (!process.env.GITHUB_TOKEN) { + throw new Error('GITHUB_TOKEN is required to create the release branch') + } + // The pending branch name never changes, but the plan behind it does + // (packages join and drop as commits land on base). Reset the branch to + // the base tip and land one fresh bump commit so the branch is always + // exactly base + one commit, with no stale manifests left behind. + await resetBranchToBase(repo, { base: baseBranch, branch: releaseBranch }) + await commitFilesToBranch(repo, { + base: baseBranch, + branch: releaseBranch, + message: title, + files: buildIndependentBumpFileSet(plan), + }) + } + + const hasToken = Boolean(process.env.GITHUB_TOKEN) + if (!hasToken && !dryRun) throw new Error('GITHUB_TOKEN is required to create or update the PR') + + const ownCommits = plan.releases.flatMap(r => r.commits) + const seen = new Set() + const uniqueCommits = ownCommits.filter(c => !seen.has(c.hash) && Boolean(seen.add(c.hash))) + const cutoff = unionFrom ? git('log', '-1', '--format=%aI', unionFrom.ref) : null + const contributors = await getContributors(uniqueCommits, repo, cutoff) + + const existing = await gh>( + `/repos/${repo.owner}/${repo.repo}/pulls?head=${repo.owner}:${releaseBranch}&state=open`, + ) + const currentPR = existing[0] + const preamble = extractPreamble(currentPR?.body) + || seedPreamble + || `> The next independent release, covering every package with unreleased changes.\n>\n> **Timetable**: to be announced.` + + const body = buildIndependentBody(plan, { + owner: repo.owner, + repo: repo.repo, + branch: releaseBranch, + preamble, + contributors, + }) + + if (dryRun) { + console.log('\n--- DRY RUN: PR body ---\n') + console.log(body) + return + } + + if (currentPR) { + await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${currentPR.number}`, { + method: 'PATCH', + body: JSON.stringify({ title, body }), + requireAuth: true, + }) + console.log(`Updated PR #${currentPR.number}`) + } else { + const created = await gh<{ number: number, html_url: string }>( + `/repos/${repo.owner}/${repo.repo}/pulls`, + { + method: 'POST', + requireAuth: true, + body: JSON.stringify({ + title, + head: releaseBranch, + base: baseBranch, + body, + draft: true, + }), + }, + ) + console.log(`Created PR #${created.number}: ${created.html_url}`) + } +} + // Run as a script, not when imported by tests. if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { diff --git a/test/_dependency-graph.test.ts b/test/_dependency-graph.test.ts new file mode 100644 index 0000000..7b095ad --- /dev/null +++ b/test/_dependency-graph.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' + +import type { Workspace } from '../scripts/_workspaces.ts' +import { + buildDependencyGraph, + propagateReleases, + topologicalOrder, +} from '../scripts/_dependency-graph.ts' + +function ws (name: string, pkg: Record = {}): Workspace { + const full = { name, version: '1.0.0', ...pkg } + return { + dir: `/repo/packages/${name}`, + relDir: `packages/${name}`, + name, + version: '1.0.0', + pkg: full, + source: JSON.stringify(full, null, 2), + } +} + +describe('buildDependencyGraph', () => { + it('detects edges across published dependency fields', () => { + const graph = buildDependencyGraph([ + ws('a'), + ws('c'), + ws('d'), + ws('e', { + dependencies: { a: 'workspace:*' }, + peerDependencies: { c: 'workspace:*' }, + optionalDependencies: { d: 'workspace:*' }, + }), + ]) + expect(graph.dependencies.get('e')).toEqual(new Set(['a', 'c', 'd'])) + expect(graph.dependents.get('a')).toEqual(new Set(['e'])) + }) + + it('does not create edges from workspace: devDependencies', () => { + const graph = buildDependencyGraph([ + ws('a'), + ws('b', { devDependencies: { a: 'workspace:*' } }), + ]) + expect(graph.dependencies.get('b')).toEqual(new Set()) + expect(graph.dependents.get('a')).toEqual(new Set()) + }) + + it('accepts all workspace: specifier flavours and rejects registry specifiers', () => { + const graph = buildDependencyGraph([ + ws('a'), + ws('b'), + ws('c'), + ws('d'), + ws('e'), + ws('consumer', { + dependencies: { + a: 'workspace:*', + b: 'workspace:^', + c: 'workspace:~', + d: 'workspace:1.2.3', + e: '^1.2.3', + }, + }), + ]) + expect(graph.dependencies.get('consumer')).toEqual(new Set(['a', 'b', 'c', 'd'])) + expect(graph.dependents.get('e')).toEqual(new Set()) + }) + + it('ignores workspace: deps on packages outside the given list', () => { + const graph = buildDependencyGraph([ + ws('a', { dependencies: { playground: 'workspace:*' } }), + ]) + expect(graph.dependencies.get('a')).toEqual(new Set()) + }) +}) + +describe('topologicalOrder', () => { + it('orders a linear chain dependencies-first', () => { + const graph = buildDependencyGraph([ + ws('c', { dependencies: { b: 'workspace:*' } }), + ws('b', { dependencies: { a: 'workspace:*' } }), + ws('a'), + ]) + expect(topologicalOrder(graph)).toEqual(['a', 'b', 'c']) + }) + + it('orders a diamond dependencies-first', () => { + const graph = buildDependencyGraph([ + ws('d', { dependencies: { b: 'workspace:*', c: 'workspace:*' } }), + ws('b', { dependencies: { a: 'workspace:*' } }), + ws('c', { dependencies: { a: 'workspace:*' } }), + ws('a'), + ]) + const order = topologicalOrder(graph) + expect(order.indexOf('a')).toBeLessThan(order.indexOf('b')) + expect(order.indexOf('a')).toBeLessThan(order.indexOf('c')) + expect(order.indexOf('b')).toBeLessThan(order.indexOf('d')) + expect(order.indexOf('c')).toBeLessThan(order.indexOf('d')) + }) + + it('preserves input order for independent packages', () => { + const graph = buildDependencyGraph([ws('b'), ws('a'), ws('c')]) + expect(topologicalOrder(graph)).toEqual(['b', 'a', 'c']) + }) + + it('breaks cycles deterministically without throwing', () => { + const workspaces = [ + ws('a', { dependencies: { b: 'workspace:*' } }), + ws('b', { dependencies: { a: 'workspace:*' } }), + ] + const graph = buildDependencyGraph(workspaces) + expect(topologicalOrder(graph)).toEqual(['a', 'b']) + expect(topologicalOrder(buildDependencyGraph([...workspaces].reverse()))).toEqual(['b', 'a']) + }) +}) + +describe('propagateReleases', () => { + it('propagates transitively along a linear chain', () => { + const graph = buildDependencyGraph([ + ws('a'), + ws('b', { dependencies: { a: 'workspace:*' } }), + ws('c', { dependencies: { b: 'workspace:*' } }), + ]) + expect(propagateReleases(graph, [{ name: 'a', bump: 'minor' }])).toEqual([ + { name: 'a', bump: 'minor', ownCommits: true }, + { name: 'b', bump: 'patch', ownCommits: false }, + { name: 'c', bump: 'patch', ownCommits: false }, + ]) + }) + + it('is a no-op when nothing depends on the bumped package', () => { + const graph = buildDependencyGraph([ + ws('a'), + ws('b', { dependencies: { a: 'workspace:*' } }), + ]) + expect(propagateReleases(graph, [{ name: 'b', bump: 'patch' }])).toEqual([ + { name: 'b', bump: 'patch', ownCommits: true }, + ]) + }) + + it('does not downgrade an existing bump on an incoming propagation', () => { + const graph = buildDependencyGraph([ + ws('a'), + ws('b', { dependencies: { a: 'workspace:*' } }), + ]) + expect(propagateReleases(graph, [ + { name: 'a', bump: 'major' }, + { name: 'b', bump: 'minor' }, + ])).toEqual([ + { name: 'a', bump: 'major', ownCommits: true }, + { name: 'b', bump: 'minor', ownCommits: true }, + ]) + }) + + it('does not propagate through a workspace: devDependency', () => { + const graph = buildDependencyGraph([ + ws('a'), + ws('b', { devDependencies: { a: 'workspace:*' } }), + ]) + expect(propagateReleases(graph, [{ name: 'a', bump: 'major' }])).toEqual([ + { name: 'a', bump: 'major', ownCommits: true }, + ]) + }) + + it('handles the fontaine shape', () => { + const graph = buildDependencyGraph([ + ws('fontaine'), + ws('fontless', { dependencies: { fontaine: 'workspace:*' } }), + ]) + expect(propagateReleases(graph, [{ name: 'fontaine', bump: 'minor' }])).toEqual([ + { name: 'fontaine', bump: 'minor', ownCommits: true }, + { name: 'fontless', bump: 'patch', ownCommits: false }, + ]) + }) +}) diff --git a/test/_independent.test.ts b/test/_independent.test.ts new file mode 100644 index 0000000..c80bf00 --- /dev/null +++ b/test/_independent.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import type { Workspace } from '../scripts/_workspaces.ts' +import { + COORDINATION_TAG_RE, + coordinationTag, + deriveReleaseSet, + expectedTarballName, + packageTag, + releasesFromEnv, + serialiseReleases, +} from '../scripts/_independent.ts' + +function ws (name: string, version: string, pkg: Record = {}): Workspace { + const full = { name, version, ...pkg } + const relDir = `packages/${name.split('/').pop()}` + return { + dir: `/repo/${relDir}`, + relDir, + name, + version, + pkg: full, + source: JSON.stringify(full, null, 2), + } +} + +describe('deriveReleaseSet', () => { + it('includes packages whose version moved and excludes unchanged ones', () => { + const set = deriveReleaseSet( + [ws('a', '1.1.0'), ws('b', '2.0.0')], + ['a@1.0.0', 'b@2.0.0'], + ) + expect(set).toEqual([{ name: 'a', version: '1.1.0', dir: 'packages/a' }]) + }) + + it('includes packages with no prior tag of any kind', () => { + const set = deriveReleaseSet([ws('a', '0.1.0')], []) + expect(set).toEqual([{ name: 'a', version: '0.1.0', dir: 'packages/a' }]) + }) + + it('falls back to the lockstep tag when a package has no tag of its own', () => { + const workspaces = [ws('@nuxt/kit', '4.2.0'), ws('nuxt', '4.2.0')] + expect(deriveReleaseSet(workspaces, ['v4.2.0', 'v4.1.0'])).toEqual([]) + expect(deriveReleaseSet(workspaces, ['v4.1.0']).map(e => e.name)).toEqual(['@nuxt/kit', 'nuxt']) + }) + + it('prefers a per-package tag over the lockstep fallback', () => { + const workspaces = [ws('a', '2.0.0')] + expect(deriveReleaseSet(workspaces, ['a@2.0.0', 'v9.9.9'])).toEqual([]) + expect(deriveReleaseSet(workspaces, ['a@1.0.0', 'v2.0.0']).map(e => e.name)).toEqual(['a']) + }) + + it('handles scoped names and messy tag histories', () => { + const set = deriveReleaseSet( + [ws('@nuxt/kit', '5.0.0'), ws('fontaine', '0.8.0')], + ['fontaine@0.8.0', '@nuxt/kit@4.9.0', 'v0.6.0', '0.2.3'], + ) + expect(set).toEqual([{ name: '@nuxt/kit', version: '5.0.0', dir: 'packages/kit' }]) + }) + + it('returns releases with dependencies before their dependents', () => { + const set = deriveReleaseSet( + [ + ws('app', '1.0.1', { dependencies: { kit: 'workspace:*' } }), + ws('kit', '2.1.0'), + ], + ['app@1.0.0', 'kit@2.0.0'], + ) + expect(set.map(r => r.name)).toEqual(['kit', 'app']) + }) + + // Depends on `latestTagForPackage` selecting the highest version among + // matches, not the first in creation-date order: a retag or backported + // patch release can make an older version's tag newer than the latest. + it('excludes an already-tagged version even when tag creation order contradicts version order', () => { + const set = deriveReleaseSet( + [ws('fontaine', '0.8.1'), ws('fontless', '0.2.1')], + ['fontless@0.2.1', 'fontaine@0.8.0', 'fontaine@0.8.1'], + ) + expect(set).toEqual([]) + }) + + it('rejects non-semver workspace versions', () => { + expect(() => deriveReleaseSet([ws('a', 'not-a-version')], [])).toThrow(/non-semver/) + }) +}) + +describe('tag names', () => { + it('builds per-package tags including scoped names', () => { + expect(packageTag({ name: '@nuxt/kit', version: '5.0.0' })).toBe('@nuxt/kit@5.0.0') + expect(packageTag({ name: 'fontaine', version: '0.8.0' })).toBe('fontaine@0.8.0') + }) + + it('builds the coordination tag from the UTC date and short sha', () => { + const sha = 'abcdef0123456789abcdef0123456789abcdef01' + const tag = coordinationTag(sha, new Date('2026-02-03T23:59:00Z')) + expect(tag).toBe('release-2026-02-03-abcdef0') + expect(tag).toMatch(COORDINATION_TAG_RE) + }) + + it('refuses a non-sha input for the coordination tag', () => { + expect(() => coordinationTag('main')).toThrow(/full commit sha/) + }) +}) + +describe('releases payload', () => { + const entries = [ + { name: '@nuxt/kit', version: '5.0.0', dir: 'packages/kit' }, + { name: 'nuxt', version: '4.2.0', dir: 'packages/nuxt' }, + ] + + it('round-trips through serialise/parse preserving order', () => { + expect(releasesFromEnv(serialiseReleases(entries))).toEqual(entries) + }) + + it('returns null when absent or blank', () => { + expect(releasesFromEnv(undefined)).toBeNull() + expect(releasesFromEnv('')).toBeNull() + expect(releasesFromEnv(' \n')).toBeNull() + }) + + it('rejects malformed payloads with a clear error', () => { + expect(() => releasesFromEnv('not json')).toThrow(/not valid JSON/) + expect(() => releasesFromEnv('{}')).toThrow(/non-empty JSON array/) + expect(() => releasesFromEnv('[]')).toThrow(/non-empty JSON array/) + expect(() => releasesFromEnv('["a@1.0.0"]')).toThrow(/not an object/) + expect(() => releasesFromEnv('[{"name":"--flag","version":"1.0.0","dir":"a"}]')).toThrow(/invalid package name/) + expect(() => releasesFromEnv('[{"name":"a","version":"latest","dir":"a"}]')).toThrow(/non-semver version/) + expect(() => releasesFromEnv('[{"name":"a","version":"1.0.0","dir":"../a"}]')).toThrow(/unsafe dir/) + expect(() => releasesFromEnv('[{"name":"a","version":"1.0.0","dir":"/a"}]')).toThrow(/unsafe dir/) + expect(() => releasesFromEnv('[{"name":"a","version":"1.0.0"}]')).toThrow(/unsafe dir/) + expect(() => releasesFromEnv(JSON.stringify([entries[0], entries[0]]))) + .toThrow(/more than once/) + }) +}) + +describe('expectedTarballName', () => { + it('matches npm pack filenames for plain and scoped names', () => { + expect(expectedTarballName('fontaine', '0.8.0')).toBe('fontaine-0.8.0.tgz') + expect(expectedTarballName('@nuxt/kit', '5.0.0')).toBe('nuxt-kit-5.0.0.tgz') + }) +}) diff --git a/test/_workspaces.test.ts b/test/_workspaces.test.ts index 2d8e170..3a902ba 100644 --- a/test/_workspaces.test.ts +++ b/test/_workspaces.test.ts @@ -4,10 +4,12 @@ import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + buildScopeMap, expandPackagePatterns, isSemver, lockstepVersionFromWorkspaces, parsePackagesInput, + parseScopesInput, resolveCurrentVersion, resolveWorkspaces, } from '../scripts/_workspaces.ts' @@ -270,3 +272,106 @@ describe('resolveCurrentVersion', () => { expect(() => resolveCurrentVersion(tmp, '')).toThrow(/no `version` field/) }) }) + +describe('parseScopesInput', () => { + it('parses one package per line', () => { + const map = parseScopesInput('@nuxt/kit: kit\n@nuxt/schema: schema') + expect(map.get('@nuxt/kit')).toEqual(['kit']) + expect(map.get('@nuxt/schema')).toEqual(['schema']) + }) + + it('parses multiple scopes per package', () => { + const map = parseScopesInput('@nuxt/kit: kit nuxt-kit') + expect(map.get('@nuxt/kit')).toEqual(['kit', 'nuxt-kit']) + }) + + it('ignores comments and blank lines', () => { + const input = ` + # routing for the nuxt org + @nuxt/kit: kit + + @nuxt/schema: schema # canonical scope + ` + const map = parseScopesInput(input) + expect([...map.keys()]).toEqual(['@nuxt/kit', '@nuxt/schema']) + }) + + it('throws when a line is missing a colon', () => { + expect(() => parseScopesInput('@nuxt/kit kit')).toThrowError(/missing a colon/) + }) + + it('throws when the package name is empty', () => { + expect(() => parseScopesInput(': kit')).toThrowError(/empty package name/) + }) + + it('throws when no scopes are listed', () => { + expect(() => parseScopesInput('@nuxt/kit:')).toThrowError(/lists no scopes/) + }) + + it('throws when the same package is listed twice', () => { + expect(() => parseScopesInput('@nuxt/kit: kit\n@nuxt/kit: nuxt-kit')) + .toThrowError(/appears more than once/) + }) +}) + +describe('buildScopeMap', () => { + it('auto-detects scopes from the basename of the package name', () => { + writePackage('packages/kit', { name: '@nuxt/kit', version: '1.0.0' }) + writePackage('packages/nuxt', { name: 'nuxt', version: '1.0.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + + const map = buildScopeMap(workspaces, new Map()) + expect(map.resolve('kit')?.name).toBe('@nuxt/kit') + expect(map.resolve('nuxt')?.name).toBe('nuxt') + expect(map.resolve('schema')).toBeNull() + }) + + it('honours overrides over auto-detect', () => { + writePackage('packages/kit', { name: '@nuxt/kit', version: '1.0.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/kit') + + const map = buildScopeMap(workspaces, new Map([['@nuxt/kit', ['kit', 'nuxt-kit']]])) + expect(map.resolve('kit')?.name).toBe('@nuxt/kit') + expect(map.resolve('nuxt-kit')?.name).toBe('@nuxt/kit') + }) + + it('throws when an override references an unknown package', () => { + writePackage('packages/kit', { name: '@nuxt/kit', version: '1.0.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/kit') + + expect(() => buildScopeMap(workspaces, new Map([['@nuxt/gone', ['gone']]]))) + .toThrowError(/references "@nuxt\/gone"/) + }) + + it('throws when two workspaces claim the same scope via auto-detect', () => { + writePackage('packages/a-utils', { name: '@orgA/utils', version: '1.0.0' }) + writePackage('packages/b-utils', { name: '@orgB/utils', version: '1.0.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + + expect(() => buildScopeMap(workspaces, new Map())) + .toThrowError(/"utils" is claimed by both "@orgA\/utils" and "@orgB\/utils"/) + }) + + it('throws when two workspaces claim the same scope via overrides', () => { + writePackage('packages/a', { name: '@x/a', version: '1.0.0' }) + writePackage('packages/b', { name: '@x/b', version: '1.0.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + + expect(() => buildScopeMap(workspaces, new Map([ + ['@x/a', ['shared']], + ['@x/b', ['shared']], + ]))).toThrowError(/"shared" is claimed by both/) + }) + + it('exposes entries in workspace order', () => { + writePackage('packages/a', { name: '@x/a', version: '1.0.0' }) + writePackage('packages/b', { name: '@x/b', version: '1.0.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + + const map = buildScopeMap(workspaces, new Map([['@x/a', ['alpha', 'a']]])) + expect(map.entries()).toEqual([ + { workspace: workspaces[0], scopes: ['alpha', 'a'] }, + { workspace: workspaces[1], scopes: ['b'] }, + ]) + }) +}) diff --git a/test/update-changelog.test.ts b/test/update-changelog.test.ts index 8aafab6..f0e305b 100644 --- a/test/update-changelog.test.ts +++ b/test/update-changelog.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { buildBumpFileSet, incVersion } from '../scripts/update-changelog.ts' +import { buildBumpFileSet, buildIndependentBody, buildIndependentBumpFileSet, computeIndependentPlan, extractPreamble, incVersion, latestLockstepTag, latestTagForPackage, type Commit } from '../scripts/update-changelog.ts' import { resolveWorkspaces } from '../scripts/_workspaces.ts' let tmp: string @@ -270,3 +270,371 @@ describe('buildBumpFileSet', () => { }) }) }) + +describe('latestTagForPackage', () => { + const fontaineTags = [ + 'fontaine@0.8.0', + 'fontless@0.2.1', + 'fontaine@0.7.0', + 'fontless@0.2.0', + 'v0.6.0', + 'v0.5.0', + '0.2.3', + '0.2.2', + ] + + it('matches only the package own @X.Y.Z tags', () => { + expect(latestTagForPackage('fontaine', fontaineTags)?.name).toBe('fontaine@0.8.0') + expect(latestTagForPackage('fontless', fontaineTags)?.name).toBe('fontless@0.2.1') + }) + + it('never matches bare or lockstep tags to a package', () => { + expect(latestTagForPackage('foo', ['0.2.3', 'v0.6.0'])).toBeNull() + }) + + it('parses scoped package names', () => { + const tag = latestTagForPackage('@nuxt/kit', ['@nuxt/kit@5.0.0', 'nuxt@5.0.0', 'v4.2.0']) + expect(tag).toEqual({ name: '@nuxt/kit@5.0.0', ref: 'refs/tags/@nuxt/kit@5.0.0' }) + }) + + it('does not match a longer package name sharing a prefix', () => { + expect(latestTagForPackage('font', fontaineTags)).toBeNull() + }) + + it('picks the highest version even when list order contradicts it', () => { + expect(latestTagForPackage('fontaine', ['fontaine@0.8.0', 'fontaine@0.8.1'])?.name).toBe('fontaine@0.8.1') + expect(latestTagForPackage('fontaine', ['fontaine@1.0.0', 'fontaine@0.9.9'])?.name).toBe('fontaine@1.0.0') + expect(latestTagForPackage('fontaine', ['fontaine@0.9.9', 'fontaine@1.0.0'])?.name).toBe('fontaine@1.0.0') + }) + + it('compares version components numerically, not lexically', () => { + expect(latestTagForPackage('fontaine', ['fontaine@0.8.0', 'fontaine@0.8.10', 'fontaine@0.8.9'])?.name).toBe('fontaine@0.8.10') + }) + + it('sorts a stable version above its own prereleases', () => { + expect(latestTagForPackage('fontaine', ['fontaine@1.0.0-beta.1', 'fontaine@1.0.0'])?.name).toBe('fontaine@1.0.0') + expect(latestTagForPackage('fontaine', ['fontaine@1.0.0', 'fontaine@1.0.1-beta.1'])?.name).toBe('fontaine@1.0.1-beta.1') + }) +}) + +describe('latestLockstepTag', () => { + it('finds the newest v-prefixed tag, skipping bare and per-package tags', () => { + const tag = latestLockstepTag(['fontaine@0.8.0', '0.2.3', 'v0.6.0', 'v0.5.0']) + expect(tag?.name).toBe('v0.6.0') + }) + + it('returns null when no lockstep tag exists', () => { + expect(latestLockstepTag(['fontaine@0.8.0', '0.2.3'])).toBeNull() + }) + + it('picks the highest version regardless of list order', () => { + expect(latestLockstepTag(['v0.5.0', 'v0.6.0'])?.name).toBe('v0.6.0') + expect(latestLockstepTag(['v0.8.9', 'v0.8.10'])?.name).toBe('v0.8.10') + }) +}) + +describe('computeIndependentPlan', () => { + let hashCounter = 0 + function commit (subject: string): Commit { + const header = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/) + const hash = (++hashCounter).toString(16).padStart(40, '0') + return { + hash, + shortHash: hash.slice(0, 7), + message: subject, + type: header?.[1]?.toLowerCase() ?? '', + scope: header?.[2] ?? '', + description: header?.[4] ?? subject, + isBreaking: Boolean(header?.[3]), + author: { name: 'Test', email: 'test@example.com' }, + references: [], + } + } + + function fontaineWorkspaces () { + writePackage('packages/fontaine', { name: 'fontaine', version: '0.8.0' }) + writePackage('packages/fontless', { + name: 'fontless', + version: '0.2.1', + dependencies: { fontaine: 'workspace:*' }, + }) + return resolveWorkspaces(tmp, 'packages/*') + } + + const fontaineTags = ['fontaine@0.8.0', 'fontless@0.2.1', 'v0.6.0', '0.2.3'] + + it('releases fontaine on its own commit and fontless via propagation', () => { + const plan = computeIndependentPlan({ + workspaces: fontaineWorkspaces(), + scopeOverrides: new Map(), + tags: fontaineTags, + commits: [commit('feat(fontaine): add metric overrides')], + }) + + expect(plan.releases.map(r => r.name)).toEqual(['fontaine', 'fontless']) + const [fontaine, fontless] = plan.releases + expect(fontaine).toMatchObject({ + currentVersion: '0.8.0', + newVersion: '0.8.1', + bump: 'minor', + ownCommits: true, + fromTag: { name: 'fontaine@0.8.0', ref: 'refs/tags/fontaine@0.8.0' }, + }) + expect(fontaine!.commits).toHaveLength(1) + expect(fontless).toMatchObject({ + currentVersion: '0.2.1', + newVersion: '0.2.2', + bump: 'patch', + ownCommits: false, + commits: [], + }) + expect(plan.unrouted).toEqual([]) + }) + + it('routes unscoped and unknown-scope commits to unrouted and bumps nothing', () => { + const unscoped = commit('fix: something repo-wide') + const unknown = commit('feat(playground): shiny demo') + const plan = computeIndependentPlan({ + workspaces: fontaineWorkspaces(), + scopeOverrides: new Map(), + tags: fontaineTags, + commits: [unscoped, unknown], + }) + + expect(plan.releases).toEqual([]) + expect(plan.unrouted).toEqual([unscoped, unknown]) + }) + + it('bumps each package from its own commits only', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/b', { name: 'b', version: '2.0.0' }) + const plan = computeIndependentPlan({ + workspaces: resolveWorkspaces(tmp, 'packages/*'), + scopeOverrides: new Map(), + tags: ['a@1.0.0', 'b@2.0.0'], + commits: [commit('feat(a): new thing'), commit('fix(b): small thing')], + }) + + const byName = Object.fromEntries(plan.releases.map(r => [r.name, r])) + expect(byName.a).toMatchObject({ newVersion: '1.1.0', bump: 'minor', ownCommits: true }) + expect(byName.b).toMatchObject({ newVersion: '2.0.1', bump: 'patch', ownCommits: true }) + }) + + it('falls back to the lockstep tag for a package with no per-package tag', () => { + const plan = computeIndependentPlan({ + workspaces: fontaineWorkspaces(), + scopeOverrides: new Map(), + tags: ['fontaine@0.8.0', 'v0.6.0', '0.2.3'], + commits: [commit('fix(fontless): resolve fallback fonts')], + }) + + expect(plan.releases).toHaveLength(1) + expect(plan.releases[0]).toMatchObject({ + name: 'fontless', + currentVersion: '0.2.1', + newVersion: '0.2.2', + fromTag: { name: 'v0.6.0', ref: 'refs/tags/v0.6.0' }, + }) + }) + + it('filters each package commits by its own tag boundary', () => { + const old = commit('feat(fontaine): already shipped in 0.8.0') + const fresh = commit('fix(fontless): new since fontless@0.2.1') + const plan = computeIndependentPlan({ + workspaces: fontaineWorkspaces(), + scopeOverrides: new Map(), + tags: fontaineTags, + commits: [fresh, old], + isCommitSince: c => c === fresh, + }) + + expect(plan.releases.map(r => r.name)).toEqual(['fontless']) + expect(plan.releases[0]!.commits).toEqual([fresh]) + }) + + it('honours scope overrides when routing', () => { + const plan = computeIndependentPlan({ + workspaces: fontaineWorkspaces(), + scopeOverrides: new Map([['fontaine', ['core']]]), + tags: fontaineTags, + commits: [commit('feat(core): overridden scope'), commit('feat(fontaine): now unrouted')], + }) + + const fontaine = plan.releases.find(r => r.name === 'fontaine') + expect(fontaine?.commits.map(c => c.scope)).toEqual(['core']) + expect(plan.unrouted.map(c => c.scope)).toEqual(['fontaine']) + }) + + it('applies the prerelease identifier to every computed version', () => { + const plan = computeIndependentPlan({ + workspaces: fontaineWorkspaces(), + scopeOverrides: new Map(), + tags: fontaineTags, + commits: [commit('feat(fontaine): big change')], + prerelease: 'beta', + }) + + const byName = Object.fromEntries(plan.releases.map(r => [r.name, r.newVersion])) + expect(byName.fontaine).toBe('0.8.1-beta.0') + expect(byName.fontless).toBe('0.2.2-beta.0') + }) +}) + +describe('extractPreamble', () => { + it('returns everything above the first generated heading', () => { + expect(extractPreamble('> intro\n\n## 👉 Changelog\n\nstuff')).toBe('> intro') + expect(extractPreamble('> intro\n\n## 👉 Pending releases\n\n- a\n\n## 👉 Changelog\n\nstuff')).toBe('> intro') + }) + + it('returns null for empty bodies or bodies starting with a generated heading', () => { + expect(extractPreamble(null)).toBeNull() + expect(extractPreamble('')).toBeNull() + expect(extractPreamble('## 👉 Changelog\n\nstuff')).toBeNull() + }) + + it('returns the whole body when no generated heading exists', () => { + expect(extractPreamble('> just a note')).toBe('> just a note') + }) +}) + +describe('independent release PR', () => { + let hashCounter = 0 + function commit (subject: string): Commit { + const header = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/) + const hash = (++hashCounter).toString(16).padStart(40, '0') + return { + hash, + shortHash: hash.slice(0, 7), + message: subject, + type: header?.[1]?.toLowerCase() ?? '', + scope: header?.[2] ?? '', + description: header?.[4] ?? subject, + isBreaking: Boolean(header?.[3]), + author: { name: 'Test', email: 'test@example.com' }, + references: [], + } + } + + function fontaineWorkspaces () { + writePackage('packages/fontaine', { name: 'fontaine', version: '0.8.0' }, { indent: '\t', trailingNewline: '\n' }) + writePackage('packages/fontless', { + name: 'fontless', + version: '0.2.1', + dependencies: { fontaine: 'workspace:*' }, + }, { trailingNewline: '\n' }) + writePackage('.', { name: 'root', private: true, version: '0.0.0' }) + return resolveWorkspaces(tmp, 'packages/*') + } + + const fontaineTags = ['fontaine@0.8.0', 'fontless@0.2.1'] + const bodyOpts = { owner: 'unjs', repo: 'fontaine', branch: 'release/main-pending', preamble: '> intro' } + + function fontainePlan (commits: Commit[]) { + return computeIndependentPlan({ + workspaces: fontaineWorkspaces(), + scopeOverrides: new Map(), + tags: fontaineTags, + commits, + }) + } + + it('renders one changelog section per released package, in publish order', () => { + const body = buildIndependentBody(fontainePlan([commit('feat(fontaine): add metric overrides')]), bodyOpts) + + const fontaineIndex = body.indexOf('### fontaine (0.8.0 → 0.8.1)') + const fontlessIndex = body.indexOf('### fontless (0.2.1 → 0.2.2)') + expect(fontaineIndex).toBeGreaterThan(-1) + expect(fontlessIndex).toBeGreaterThan(fontaineIndex) + expect(body).toContain('- fontaine: 0.8.0 → 0.8.1 (minor)') + expect(body).toContain('- fontless: 0.2.1 → 0.2.2 (patch, dependency bump only)') + expect(body).toContain('add metric overrides') + }) + + it('notes the propagation cause for dependency-only releases', () => { + const body = buildIndependentBody(fontainePlan([commit('feat(fontaine): add metric overrides')]), bodyOpts) + + expect(body).toContain('_Released because `fontaine` was bumped; no direct changes._') + expect(body).not.toMatch(/### fontless[\s\S]*compare changes/) + }) + + it('renders unrouted commits in their own section', () => { + const plan = fontainePlan([commit('feat(fontaine): thing'), commit('docs: update readme')]) + const body = buildIndependentBody(plan, bodyOpts) + + expect(body).toContain('### 🧭 Unrouted commits') + expect(body).toContain('not routed to any package') + expect(body).toContain('docs: update readme') + }) + + it('omits the unrouted section when every commit is routed', () => { + const body = buildIndependentBody(fontainePlan([commit('feat(fontaine): thing')]), bodyOpts) + expect(body).not.toContain('Unrouted commits') + }) + + it('preserves the preamble across regenerations', () => { + const plan = fontainePlan([commit('feat(fontaine): thing')]) + const first = buildIndependentBody(plan, { ...bodyOpts, preamble: '> hand-written notes' }) + const second = buildIndependentBody(plan, { ...bodyOpts, preamble: extractPreamble(first)! }) + expect(second).toBe(first) + }) + + it('shrinks when a package drops out of the plan', () => { + const workspaces = fontaineWorkspaces() + const full = buildIndependentBody(computeIndependentPlan({ + workspaces, + scopeOverrides: new Map(), + tags: fontaineTags, + commits: [commit('feat(fontaine): thing')], + }), bodyOpts) + const shrunk = buildIndependentBody(computeIndependentPlan({ + workspaces, + scopeOverrides: new Map(), + tags: fontaineTags, + commits: [commit('fix(fontless): only fontless now')], + }), bodyOpts) + + expect(full).toContain('### fontaine') + expect(shrunk).not.toContain('### fontaine') + expect(shrunk).toContain('### fontless (0.2.1 → 0.2.2)') + }) + + it('renders the contributor sections when contributors are provided', () => { + const body = buildIndependentBody(fontainePlan([commit('feat(fontaine): thing')]), { + ...bodyOpts, + contributors: [ + { name: 'Ada', username: 'ada', isFirstTime: true }, + { name: 'Grace', username: 'grace', isFirstTime: false }, + ], + }) + + expect(body).toContain('### 🎉 New Contributors\n\n- Ada (@ada)') + expect(body).toContain('### ❤️ Contributors\n\n- Ada (@ada)\n- Grace (@grace)') + }) + + it('commits exactly the released manifests, preserving formatting and skipping the root', () => { + const files = buildIndependentBumpFileSet(fontainePlan([commit('feat(fontaine): thing')])) + + expect(files.map(f => f.path).sort()).toEqual(['packages/fontaine/package.json', 'packages/fontless/package.json']) + const byPath = Object.fromEntries(files.map(f => [f.path, f.content])) + expect(byPath['packages/fontaine/package.json']).toBe('{\n\t"name": "fontaine",\n\t"version": "0.8.1"\n}\n') + expect(JSON.parse(byPath['packages/fontless/package.json']!)).toEqual({ + name: 'fontless', + version: '0.2.2', + dependencies: { fontaine: 'workspace:*' }, + }) + expect(byPath['packages/fontless/package.json']!.endsWith('\n')).toBe(true) + }) + + it('bumps only the released package when nothing propagates', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/b', { name: 'b', version: '2.0.0' }) + const files = buildIndependentBumpFileSet(computeIndependentPlan({ + workspaces: resolveWorkspaces(tmp, 'packages/*'), + scopeOverrides: new Map(), + tags: ['a@1.0.0', 'b@2.0.0'], + commits: [commit('fix(a): thing')], + })) + + expect(files.map(f => f.path)).toEqual(['packages/a/package.json']) + }) +}) -- 2.51.2