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
23 changes: 11 additions & 12 deletions actions/changeset/check-coverage/action.yaml
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
name: Check changeset coverage
author: 'MiLaboratories'
description: |
Fail if a PR's changesets don't cover every workspace package whose own
files it edits — e.g. editing block code under packages/<pkg>/ without
adding that package to the changeset.
Fail if a PR's changesets don't cover every published workspace package it
changes. A package is changed when the PR edits a file inside its directory,
or when the PR changes a catalog entry in pnpm-workspace.yaml that the
package references via `catalog:` from `dependencies` or `peerDependencies`.
A catalog entry referenced only from `devDependencies` or
`optionalDependencies` requires nothing.

Dependency-version bumps are intentionally NOT flagged, including catalog
bumps in pnpm-workspace.yaml. A catalog entry always pins an external
package, and `pnpm changeset` never requires a release for an external
dependency bump — it propagates versions through the internal dependency
chain automatically at `changeset version` time. Flagging consumers of a
bumped catalog dependency would over-report relative to `pnpm changeset`.
Changes are counted from the merge base with the base branch, so commits that
landed on the base branch after the PR branched off are not the PR's.

An empty changeset (`pnpm changeset --empty`) added in the branch is a
deliberate, in-repo "no release needed" opt-out and waives the requirement for
Expand All @@ -22,9 +21,9 @@ description: |
caller workflow's job-level `if` (PR labels are only available on
`pull_request` events).

Runs after `pnpm install`. Requires the runner to have `pnpm`, `jq` and `git`
on PATH — all pre-installed on GitHub-hosted ubuntu-latest images. `git` needs
full history (`fetch-depth: 0`) to see which changesets the branch added.
Runs after `pnpm install`. Requires `git`, `pnpm`, `jq` and `yq` (mikefarah,
v4+) on PATH. `git` needs full history (`fetch-depth: 0`) to find the merge
base with the base branch.

inputs:
base-branch:
Expand Down
257 changes: 190 additions & 67 deletions actions/changeset/check-coverage/check-coverage.sh
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
#!/usr/bin/env bash
#
# Verify the PR has a changeset for every workspace package whose own files
# it edits. Exit 1 on a coverage gap; exit 2 on tooling failure; exit 0
# Verify the PR has a changeset for every published workspace package it
# changes. Exit 1 on a coverage gap; exit 2 on tooling failure; exit 0
# otherwise.
#
# An empty changeset added in this branch (`pnpm changeset --empty`) waives the
# requirement — see section 4.
# A package is changed by the branch in two ways:
#
# direct edit — the branch changes a file inside the package directory. A
# file belongs to the deepest package directory containing
# it, so root-level paths (`.github/`, `docs/`,
# `pnpm-workspace.yaml`, `README.md`) belong to the workspace
# root, which is unnamed or private and never needs a bump.
#
# "Modified" means a direct edit to a workspace package's own files, detected
# via `pnpm --filter '[<base>]' list` — pnpm runs the per-package git-diff
# check itself. Root-level paths (`.github/`, `docs/`, `pnpm-workspace.yaml`,
# `README.md`) live in no package directory, so they never trigger inclusion.
# catalog bump — the branch changes a catalog entry in pnpm-workspace.yaml
# that the package references as `catalog:` or
# `catalog:<name>` from `dependencies` or `peerDependencies`.
# Those dependencies ship with the package, so a new version
# of one is a new version of the package. `devDependencies`
# and `optionalDependencies` do not count.
#
# Dependency-version bumps never require a changeset — including catalog
# bumps in pnpm-workspace.yaml. A catalog entry always pins an *external*
# package (workspace packages are referenced via `workspace:*`, never
# `catalog:`). `pnpm changeset` ignores such a bump twice over: it never
# releases a package because an external dependency's version changed, and it
# propagates internal bumps through the dependency chain automatically at
# `changeset version` time. So requiring a hand-written changeset for a
# package that only "changed" via a dependency bump over-reports relative to
# `pnpm changeset` — see platforma-open/clonotype-space#95, where a
# `@platforma-sdk/workflow-tengo` catalog bump spuriously failed the
# untouched `.workflow` package.
# "The branch changes" means the difference between the merge base with the
# base branch and HEAD: commits that landed on the base branch after the
# branch left it are not the branch's changes.
#
# Skips private (unpublished) workspace packages — they never appear in
# the changeset's release set.
# Private packages are skipped — they never appear in a changeset's release
# set.
#
# An empty changeset added in this branch (`pnpm changeset --empty`) waives the
# requirement — see section 4.
#
# Runs from the repo root after `pnpm install`.
# Runs from the repo root after `pnpm install`. Needs `git` with the base
# branch's history, `pnpm`, `jq` and `yq` (mikefarah, v4+).

set -o nounset
set -o errexit
Expand All @@ -38,43 +41,42 @@ set -o pipefail
log() { printf '%s\n' "$*" >&2; }
err() { printf '::error::%s\n' "$*" >&2; }

# Associative arrays (`declare -A`) require bash 4+. The GitHub-hosted
# ubuntu-latest runners ship bash 5+, so this guard is a no-op in CI today.
# It matters when running locally on macOS: /bin/bash is still 3.2, and
# without homebrew's bash earlier on PATH the script would otherwise abort
# at the first `declare -A` with a cryptic `invalid option`. Fail fast with
# a useful message instead.
# Associative arrays (`declare -A`) need bash 4+. macOS ships bash 3.2 as
# /bin/bash, where `declare -A` aborts with a cryptic `invalid option`.
if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then
err "check-coverage requires bash 4+ (found ${BASH_VERSION:-unknown})."
exit 2
fi

# Use cwd-relative paths for tmp files — changeset's --output mis-handles
# absolute paths on macOS (prepends cwd, causing ENOENT). Cwd-relative is
# safe on every platform.
for tool in git pnpm jq yq; do
if ! command -v "${tool}" >/dev/null 2>&1; then
err "${tool} not found on PATH."
exit 2
fi
done

# changeset's --output resolves an absolute path against the cwd on macOS, so
# the status file takes a cwd-relative path.
status_json=".changeset-coverage-status-$$.json"
trap 'rm -f "${status_json}"' EXIT

# ---------------------------------------------------------------------------
# 1. Bumped set from `changeset status --output=...`.
# 1. Bumped set: the packages the branch's changesets release.
# ---------------------------------------------------------------------------
trap 'rm -f "${status_json}"' EXIT

# Invoke the binary directly. `pnpm exec` and `pnpm <script>` spawn subshells
# that lose `node_modules/.bin` from PATH on repeated invocations. The action
# runs from the repo root after `pnpm install`, so the binary is at this
# fixed location in CI.
# `pnpm exec` and `pnpm <script>` drop `node_modules/.bin` from PATH on repeated
# invocations, so the binary is called by its path.
changeset_bin='./node_modules/.bin/changeset'
if [ ! -x "${changeset_bin}" ]; then
err 'changeset binary not found. Did `pnpm install` run before this step?'
exit 2
fi

# Capture stderr to distinguish the legitimate "no changesets yet" state
# (exit 1 + "no changesets were found" message) from a real tooling failure.
# The former is a coverage gap to report; the latter aborts with exit 2.
cs_stdouterr=''
# A branch without changesets makes `changeset status` exit 1 with "no
# changesets were found". That is a coverage question to answer, not a tooling
# failure, so it continues with an empty bumped set.
set +e
cs_stdouterr="$(
cs_output="$(
"${changeset_bin}" status \
--output="${status_json}" \
--since="origin/${BASE_BRANCH}" 2>&1
Expand All @@ -83,17 +85,12 @@ cs_exit=$?
set -e

if [ ! -s "${status_json}" ]; then
if printf '%s' "${cs_stdouterr}" | grep -q 'no changesets were found'; then
# Legitimate empty-changeset case — keep going so the coverage check
# surfaces every modified package as missing.
echo '{"releases":[]}' >"${status_json}"
elif [ "${cs_exit}" -ne 0 ]; then
if [ "${cs_exit}" -ne 0 ] && ! printf '%s' "${cs_output}" | grep -q 'no changesets were found'; then
err "changeset status failed (exit ${cs_exit}):"
printf '%s\n' "${cs_stdouterr}" | sed 's/^/ /' >&2
printf '%s\n' "${cs_output}" | sed 's/^/ /' >&2
exit 2
else
echo '{"releases":[]}' >"${status_json}"
fi
echo '{"releases":[]}' >"${status_json}"
fi

declare -A bumped_set=()
Expand All @@ -108,37 +105,163 @@ else
fi

# ---------------------------------------------------------------------------
# 2. Required-bump set.
# 2. Required set: the published packages the branch changes.
# ---------------------------------------------------------------------------
declare -A required_set=()
declare -A required_reason=()

require_pkg() {
local pkg="$1" reason="$2"
[ -z "${pkg}" ] && return 0
required_set["${pkg}"]=1
required_reason["${pkg}"]="${required_reason[${pkg}]:-}${reason}; "
}

# Direct workspace-package edits.
#
# `pnpm --filter '[<since>]' list` selects packages whose own files changed
# (pnpm runs the per-package `git diff` itself). Root-level paths like
# `.github/`, `docs/`, `pnpm-workspace.yaml`, or `README.md` are not in any
# package directory and so don't trigger inclusion — no manual ignore list
# needed, and a catalog bump in pnpm-workspace.yaml never reaches this filter.
#
# The jq filter drops private packages here (they never appear in a
# changeset's release set), so `require_pkg` doesn't need to re-check.
while IFS= read -r pkg; do
[ -n "${pkg}" ] && require_pkg "${pkg}" 'direct edit'
if ! merge_base="$(git merge-base "origin/${BASE_BRANCH}" HEAD 2>/dev/null)"; then
err "No merge base between origin/${BASE_BRANCH} and HEAD. Check out with full history (fetch-depth: 0)."
exit 2
fi

# Workspace packages: directory relative to the repo root ('' for the root
# itself) and the name to require ('' for an unnamed or private package).
if ! packages_json="$(pnpm -r list --depth -1 --json 2>/dev/null)" \
|| ! jq -e 'type == "array" and length > 0' >/dev/null <<<"${packages_json}"; then
err 'pnpm list did not return the workspace packages.'
exit 2
fi

pkg_dirs=()
pkg_names=()
while IFS=$'\t' read -r path name; do
rel=''
# pnpm prints absolute paths with symlinks resolved (macOS /tmp is
# /private/tmp), so either form of the cwd may prefix them.
for root in "$(pwd -P)" "$(pwd -L)"; do
case "${path}" in
"${root}") rel='.' ;;
"${root}"/*) rel="${path#"${root}"/}" ;;
esac
[ -n "${rel}" ] && break
done
[ -z "${rel}" ] && continue
[ "${rel}" = '.' ] && rel=''
pkg_dirs+=("${rel}")
pkg_names+=("${name}")
done < <(
pnpm -r --filter "[origin/${BASE_BRANCH}]" list --depth -1 --json 2>/dev/null \
| jq -r '.[] | select(.name != null and .name != "" and .private != true) | .name'
jq -r '.[] | [
(.path | rtrimstr("/")),
(if (.name // "") == "" or .private == true then "" else .name end)
] | @tsv' <<<"${packages_json}"
)

# 2a. Direct edits.
#
# `--no-renames` lists both sides of a move, so moving a file out of a package
# is an edit of that package.
if ! changed_files="$(git diff --name-only --no-renames "${merge_base}" HEAD)"; then
err "git diff against the merge base with origin/${BASE_BRANCH} failed."
exit 2
fi

while IFS= read -r file; do
[ -z "${file}" ] && continue
owner=-1
owner_len=-1
for i in "${!pkg_dirs[@]}"; do
dir="${pkg_dirs[${i}]}"
if [ -z "${dir}" ] || [[ "${file}" == "${dir}/"* ]]; then
if [ "${#dir}" -gt "${owner_len}" ]; then
owner="${i}"
owner_len="${#dir}"
fi
fi
done
[ "${owner}" -lt 0 ] && continue
pkg="${pkg_names[${owner}]}"
[ -z "${pkg}" ] && continue
[ -n "${required_set[${pkg}]:-}" ] && continue
require_pkg "${pkg}" 'direct edit'
done <<<"${changed_files}"

log "Direct-edit packages: ${#required_set[@]}"

# 2b. Catalog bumps.
#
# Both sides of pnpm-workspace.yaml are normalised to
# { "<catalog name>": { "<dependency>": "<version>" } }. The top-level
# `catalog:` is the catalog named `default`, the same one `catalogs.default`
# names.
catalogs_at() {
local rev="$1"
if ! git cat-file -e "${rev}:pnpm-workspace.yaml" 2>/dev/null; then
echo '{}'
return 0
fi
git show "${rev}:pnpm-workspace.yaml" \
| yq -o=json '.' \
| jq -c '
(. // {}) as $ws
| ($ws.catalogs // {}) + { default: (($ws.catalogs.default // {}) + ($ws.catalog // {})) }
| map_values(. // {} | map_values(tostring))
'
}

if ! base_catalogs="$(catalogs_at "${merge_base}")" \
|| ! head_catalogs="$(catalogs_at HEAD)"; then
err 'Could not read the catalogs in pnpm-workspace.yaml.'
exit 2
fi

# { "<catalog name>": ["<dependency>", ...] } for every entry added, removed or
# re-versioned.
changed_catalogs="$(
jq -cn --argjson base "${base_catalogs}" --argjson head "${head_catalogs}" '
[ ($base | keys[]), ($head | keys[]) ] | unique
| map(. as $name
| {
key: $name,
value: (
[ ($base[$name] // {} | keys[]), ($head[$name] // {} | keys[]) ] | unique
| map(select($base[$name][.] != $head[$name][.]))
)
})
| map(select(.value | length > 0))
| from_entries
'
)"

catalog_required=0
if [ "${changed_catalogs}" != '{}' ]; then
log "Changed catalog entries: $(jq -r 'to_entries | map("\(.key): \(.value | join(", "))") | join("; ")' <<<"${changed_catalogs}")"

for i in "${!pkg_dirs[@]}"; do
pkg="${pkg_names[${i}]}"
[ -z "${pkg}" ] && continue
manifest="${pkg_dirs[${i}]:-.}/package.json"
[ -f "${manifest}" ] || continue
if ! deps="$(
jq -r --argjson changed "${changed_catalogs}" '
[ .dependencies, .peerDependencies ]
| map(select(type == "object") | to_entries[]) | .[]
| select(.value | type == "string" and startswith("catalog:"))
| .key as $dep
| (.value | ltrimstr("catalog:") | if . == "" then "default" else . end) as $name
| select($changed[$name] // [] | any(. == $dep))
| $dep
' "${manifest}" | sort -u
)"; then
err "Could not read ${manifest}."
exit 2
fi
while IFS= read -r dep; do
[ -z "${dep}" ] && continue
require_pkg "${pkg}" "catalog bump: ${dep}"
catalog_required=$((catalog_required + 1))
done <<<"${deps}"
done
fi

log "Catalog-bump requirements: ${catalog_required}"

# ---------------------------------------------------------------------------
# 3. Compare required vs bumped.
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -188,7 +311,7 @@ while IFS= read -r f; do
exit 0
fi
done < <(
git diff --name-only --diff-filter=A "origin/${BASE_BRANCH}...HEAD" -- .changeset 2>/dev/null || true
git diff --name-only --diff-filter=A "${merge_base}" HEAD -- .changeset 2>/dev/null || true
)

err 'Changeset coverage gap. The following packages were modified but not bumped:'
Expand Down
11 changes: 5 additions & 6 deletions actions/changeset/check-coverage/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,17 @@ and a couple of git ops.
the tar copy), commits, and synthesizes an `origin/main` ref.
- Each test (`setup`) tars the base into `$BATS_TEST_TMPDIR/ws`, switches
to a `feature` branch, applies mutations via the helpers
(`touch_file`, `add_changeset`, `add_empty_changeset`, `bump_catalog`),
(`touch_file`, `add_changeset`, `add_empty_changeset`, `bump_catalog`,
`edit_workspace_yaml`),
and runs the script
with `BASE_BRANCH=main`. The test asserts on the script's exit code and
the captured `$output`.

## Fixture

`fixtures/minimal-workspace/` — small pnpm workspace:

- `packages/pkg-a`, `packages/pkg-b` — both consume `is-number` via `catalog:`
- `packages/pkg-c` — consumes `is-string` via `catalog:`
- `packages/pkg-private` — `"private": true`, never requires a bump
`fixtures/minimal-workspace/` — small pnpm workspace; its `README.md` lists
which package takes which dependency from which catalog, and in which
dependency field.

## CI

Expand Down
Loading
Loading