Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down
24 changes: 21 additions & 3 deletions scripts/create-release-pr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions scripts/finalize-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
78 changes: 78 additions & 0 deletions scripts/workspaces.sh
Original file line number Diff line number Diff line change
@@ -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 <dir>
is_private_package() {
local dir="$1"
[[ -f "$dir/package.json" ]] || return 1
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
process.exit(pkg.private === true ? 0 : 1);
' "$dir/package.json"
}

# Whether a package has a scoped name (@scope/name), which npm publishes as
# restricted by default — first publish needs --access public.
# Usage: is_scoped_package <dir>
is_scoped_package() {
local dir="$1"
[[ -f "$dir/package.json" ]] || return 1
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
process.exit(typeof pkg.name === "string" && pkg.name.startsWith("@") ? 0 : 1);
' "$dir/package.json"
}
117 changes: 117 additions & 0 deletions tests/workspaces.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env bats

setup() {
source "$BATS_TEST_DIRNAME/../scripts/workspaces.sh"

# Build a throwaway monorepo to exercise discovery against.
REPO="$(mktemp -d)"

mk_pkg() {
local dir="$1" json="$2"
mkdir -p "$REPO/$dir"
printf '%s' "$json" > "$REPO/$dir/package.json"
}
}

teardown() {
rm -rf "$REPO"
}

# ── read_workspace_patterns ──────────────────────────────────────

@test "read_workspace_patterns: array form" {
mk_pkg "." '{"name":"root","workspaces":["packages/*","tools/cli"]}'
run read_workspace_patterns "$REPO/package.json"
[[ "${lines[0]}" == "packages/*" ]]
[[ "${lines[1]}" == "tools/cli" ]]
}

@test "read_workspace_patterns: object form" {
mk_pkg "." '{"name":"root","workspaces":{"packages":["libs/*"]}}'
run read_workspace_patterns "$REPO/package.json"
[[ "${lines[0]}" == "libs/*" ]]
}

@test "read_workspace_patterns: no workspaces field emits nothing" {
mk_pkg "." '{"name":"root"}'
run read_workspace_patterns "$REPO/package.json"
[[ -z "$output" ]]
}

@test "read_workspace_patterns: missing file emits nothing" {
run read_workspace_patterns "$REPO/does-not-exist.json"
[[ -z "$output" ]]
}

# ── discover_packages ────────────────────────────────────────────

@test "discover_packages: expands a glob to each package dir" {
mk_pkg "." '{"name":"root","workspaces":["packages/*"]}'
mk_pkg "packages/a" '{"name":"a","version":"1.0.0"}'
mk_pkg "packages/b" '{"name":"b","version":"1.0.0"}'

run discover_packages "$REPO"
[[ "$output" == *"packages/a"* ]]
[[ "$output" == *"packages/b"* ]]
[[ "${#lines[@]}" -eq 2 ]]
}

@test "discover_packages: ignores glob matches without a package.json" {
mk_pkg "." '{"name":"root","workspaces":["packages/*"]}'
mk_pkg "packages/a" '{"name":"a","version":"1.0.0"}'
mkdir -p "$REPO/packages/not-a-pkg"

run discover_packages "$REPO"
[[ "$output" == *"packages/a"* ]]
[[ "$output" != *"not-a-pkg"* ]]
[[ "${#lines[@]}" -eq 1 ]]
}

@test "discover_packages: handles an explicit (non-glob) path" {
mk_pkg "." '{"name":"root","workspaces":["tools/cli"]}'
mk_pkg "tools/cli" '{"name":"cli","version":"1.0.0"}'

run discover_packages "$REPO"
[[ "$output" == *"tools/cli"* ]]
[[ "${#lines[@]}" -eq 1 ]]
}

@test "discover_packages: no workspaces field emits nothing" {
mk_pkg "." '{"name":"root","version":"1.0.0"}'
run discover_packages "$REPO"
[[ -z "$output" ]]
}

# ── is_private_package ───────────────────────────────────────────

@test "is_private_package: true when private is set" {
mk_pkg "p" '{"name":"p","private":true}'
run is_private_package "$REPO/p"
[[ "$status" -eq 0 ]]
}

@test "is_private_package: false when private is absent" {
mk_pkg "p" '{"name":"p","version":"1.0.0"}'
run is_private_package "$REPO/p"
[[ "$status" -ne 0 ]]
}

@test "is_private_package: false when private is explicitly false" {
mk_pkg "p" '{"name":"p","private":false}'
run is_private_package "$REPO/p"
[[ "$status" -ne 0 ]]
}

# ── is_scoped_package ────────────────────────────────────────────

@test "is_scoped_package: true for @scope/name" {
mk_pkg "p" '{"name":"@acme/widget","version":"1.0.0"}'
run is_scoped_package "$REPO/p"
[[ "$status" -eq 0 ]]
}

@test "is_scoped_package: false for an unscoped name" {
mk_pkg "p" '{"name":"widget","version":"1.0.0"}'
run is_scoped_package "$REPO/p"
[[ "$status" -ne 0 ]]
}