diff --git a/README.md b/README.md index 3ace170..d178904 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ bumps, changelogs, tags, and GitHub releases. Optionally gate releases behind a - **Optional release gating** — require a label on a PR before it triggers a release - **GitHub releases** — auto-generated release notes, draft or published - **npm publishing** — token-based or [npm Trusted Publishers](https://docs.npmjs.com/trusted-publishers) (OIDC) +- **Monorepo support** — publish every npm-workspaces package together with fixed (locked) versioning - **GitHub Packages support** — point at any npm-compatible registry - **Composite action** — runs as bash on the consumer's runner, no Docker pull @@ -118,6 +119,7 @@ jobs: | `npm-token` | Auth token for the npm registry. Omit to use [Trusted Publishers](#publish-to-npm-with-trusted-publishers) (OIDC) | No | — | | `npm-registry` | npm registry URL (set to `https://npm.pkg.github.com` for GitHub Packages) | No | `https://registry.npmjs.org` | | `npm-build-command` | Build command to run before `npm publish` (e.g., `npm run build`) | No | — | +| `npm-workspaces` | Publish every npm workspace package (monorepo). All packages share one version — see [Publish a monorepo](#publish-a-monorepo-npm-workspaces) | No | `false` | ## Outputs @@ -279,6 +281,44 @@ jobs: npm-build-command: 'npm run build' ``` +### Publish a monorepo (npm workspaces) + +Set `npm-workspaces: true` to publish every package in an npm-workspaces monorepo. Release Champion uses +**fixed (locked) versioning**: it computes a single version from your conventional commits, then bumps and publishes +**every** package to that same version. There is still one tag, one release PR, and one root `CHANGELOG.md`. + +```yaml +- uses: offload-project/release-champion@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + publish-npm: true + npm-workspaces: true + npm-token: ${{ secrets.NPM_TOKEN }} + npm-build-command: 'npm run build' +``` + +Packages are discovered from the `workspaces` field in your root `package.json` (both the array and +`{ "packages": [...] }` forms are supported): + +```json +{ + "name": "my-monorepo", + "private": true, + "workspaces": ["packages/*"] +} +``` + +Behavior notes: + +- **One version for all.** A change to any package bumps and republishes every package — even packages that didn't + change. (If you need each package to version independently, that isn't supported yet.) +- **Private packages are skipped.** Any package with `"private": true` is bumped but never published — so a private + root `package.json` that only declares `workspaces` is handled correctly. +- **Scoped packages publish publicly.** Packages with a scoped name (`@scope/name`) are published with + `--access public` so first publishes don't fail. Restricted scoped packages aren't supported. +- **Trusted Publishers per package.** OIDC publishing works, but each package must be registered as its own trusted + publisher on npm. + ### Tag only, no GitHub release ```yaml diff --git a/action.yml b/action.yml index 701ae36..cd78aa2 100644 --- a/action.yml +++ b/action.yml @@ -50,6 +50,10 @@ inputs: npm-build-command: description: 'Build command to run before npm publish (e.g., npm run build)' required: false + npm-workspaces: + description: 'Publish every npm workspace package (monorepo, fixed/locked versioning). All packages share the single computed version. Discovered from the root package.json "workspaces" field.' + required: false + default: 'false' outputs: version: @@ -84,6 +88,7 @@ runs: INPUT_NPM_TOKEN: ${{ inputs.npm-token }} INPUT_NPM_REGISTRY: ${{ inputs.npm-registry }} INPUT_NPM_BUILD_COMMAND: ${{ inputs.npm-build-command }} + INPUT_NPM_WORKSPACES: ${{ inputs.npm-workspaces }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_MERGED: ${{ github.event.pull_request.merged }} diff --git a/scripts/create-release-pr.sh b/scripts/create-release-pr.sh index f21307f..309929a 100755 --- a/scripts/create-release-pr.sh +++ b/scripts/create-release-pr.sh @@ -6,6 +6,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/conventional.sh" source "$SCRIPT_DIR/changelog.sh" +source "$SCRIPT_DIR/workspaces.sh" main() { echo "::group::Analyzing commits for version bump" @@ -66,9 +67,26 @@ main() { echo "::error::publish-npm is enabled but no package.json found" exit 1 fi - echo "Bumping package.json to ${new_version}" - npm version "$new_version" --no-git-tag-version - git add package.json + + # In fixed/locked mode every workspace package shares the new version. The + # root package.json is always bumped (it carries the workspaces field); each + # discovered workspace package is bumped to the same version. + local bump_dirs=(".") + if [[ "${INPUT_NPM_WORKSPACES:-false}" == "true" ]]; then + local pkg_dir + while IFS= read -r pkg_dir; do + [[ -n "$pkg_dir" ]] && bump_dirs+=("$pkg_dir") + done < <(discover_packages ".") + echo "Workspaces mode: bumping ${#bump_dirs[@]} package(s) to ${new_version}" + fi + + local dir + for dir in "${bump_dirs[@]}"; do + echo "Bumping ${dir}/package.json to ${new_version}" + ( cd "$dir" && npm version "$new_version" --no-git-tag-version --allow-same-version ) + git add "$dir/package.json" + done + if [[ -f package-lock.json ]] && ! git diff --staged --quiet package-lock.json 2>/dev/null; then git add package-lock.json fi diff --git a/scripts/finalize-release.sh b/scripts/finalize-release.sh index 24c4122..3c35bce 100755 --- a/scripts/finalize-release.sh +++ b/scripts/finalize-release.sh @@ -5,6 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/conventional.sh" +source "$SCRIPT_DIR/workspaces.sh" main() { # Extract version from branch name (release/v1.2.3 → v1.2.3) @@ -87,13 +88,40 @@ main() { eval "$INPUT_NPM_BUILD_COMMAND" fi - npm publish + # Build the list of package directories to publish. In fixed/locked + # workspaces mode this is the root plus every discovered workspace package; + # otherwise it is just the root. + local publish_dirs=(".") + if [[ "${INPUT_NPM_WORKSPACES:-false}" == "true" ]]; then + local pkg_dir + while IFS= read -r pkg_dir; do + [[ -n "$pkg_dir" ]] && publish_dirs+=("$pkg_dir") + done < <(discover_packages ".") + fi - rm -f .npmrc + # Publish each package from the repo root so the root .npmrc auth applies + # (npm only reads the project .npmrc from the current directory). Private + # packages are skipped; scoped names get --access public for first publish. + local dir + for dir in "${publish_dirs[@]}"; do + if is_private_package "$dir"; then + echo "Skipping ${dir} (private package)" + continue + fi + + local publish_flags=("$dir") + if is_scoped_package "$dir"; then + publish_flags+=(--access public) + fi - local pkg_name - pkg_name=$(node -p "require('./package.json').name") - echo "Published ${pkg_name}@${version}" + npm publish "${publish_flags[@]}" + + local pkg_name + pkg_name=$(node -e 'const fs=require("fs");console.log(JSON.parse(fs.readFileSync(process.argv[1],"utf8")).name)' "$dir/package.json") + echo "Published ${pkg_name}@${version}" + done + + rm -f .npmrc echo "::endgroup::" fi diff --git a/scripts/workspaces.sh b/scripts/workspaces.sh new file mode 100644 index 0000000..dba42f1 --- /dev/null +++ b/scripts/workspaces.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Workspace discovery for monorepo (fixed/locked) publishing. +# +# "Fixed mode" means every package shares the single version computed from +# conventional commits — there is still one tag and one CHANGELOG.md. These +# helpers only answer two questions: which package directories exist, and which +# of those are publishable. + +set -euo pipefail + +# Read the workspace glob patterns from a package.json's "workspaces" field. +# Handles both the array form (["packages/*"]) and the object form +# ({ "packages": ["packages/*"] }). Emits one pattern per line; emits nothing +# when there is no workspaces field. +# Usage: read_workspace_patterns [package.json path] +read_workspace_patterns() { + local pkg_json="${1:-package.json}" + [[ -f "$pkg_json" ]] || return 0 + + node -e ' + const fs = require("fs"); + const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + let ws = pkg.workspaces; + if (!ws) process.exit(0); + if (!Array.isArray(ws)) ws = ws.packages || []; + for (const p of ws) console.log(p); + ' "$pkg_json" +} + +# Discover all workspace package directories under a root. +# Expands each workspace glob and emits every directory that contains a +# package.json. Emits nothing when the root declares no workspaces. +# Usage: discover_packages [root dir] +discover_packages() { + local root="${1:-.}" + local patterns + patterns=$(read_workspace_patterns "$root/package.json") + [[ -z "$patterns" ]] && return 0 + + local pattern dir + # nullglob so a pattern that matches nothing expands to nothing rather than + # the literal glob string. + shopt -s nullglob + while IFS= read -r pattern; do + [[ -z "$pattern" ]] && continue + for dir in "$root"/$pattern/; do + dir="${dir%/}" + [[ -f "$dir/package.json" ]] && echo "$dir" + done + done <<< "$patterns" + shopt -u nullglob +} + +# Whether a package directory is marked private (and therefore must not be +# published). Returns success (0) when private. +# Usage: is_private_package