Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1f7eba7
Add fix-beta.sh: re-sync v4-beta with v4 (inverse of merge-beta.sh)
dbolotin May 25, 2026
234cf21
ci: add changeset coverage check
PaulNewling May 26, 2026
5f79cb0
ci: point new step's self-reference at this PR branch for canary testing
PaulNewling May 26, 2026
d5f16a1
ci: restore third-party @v4 refs in node-simple-pnpm.yaml for canary
PaulNewling May 26, 2026
335ff48
ci: restore third-party @v4 refs in prepare-pnpm and cache-pnpm
PaulNewling May 26, 2026
20b88fc
Revert canary self-reference; restore @v4-beta for review
PaulNewling May 26, 2026
3efe19d
Revert third-party @v4 ref restorations
PaulNewling May 26, 2026
bff4168
Fix incomplete revert: flip remaining two @v4 checkout refs back to @…
PaulNewling May 26, 2026
f266633
ci(changeset/check-coverage): simplify with pnpm filter + yq, add bat…
PaulNewling May 27, 2026
978fede
ci(changeset/check-coverage): drop pnpm version pin — repo's packageM…
PaulNewling May 27, 2026
36cf08b
ci(changeset/check-coverage): scope test workflow to action changes only
PaulNewling May 27, 2026
c26ec32
ci(changeset/check-coverage): fail fast on bash < 4
PaulNewling May 27, 2026
7f82c48
ci(changeset/check-coverage): clarify bash-guard comment
PaulNewling May 27, 2026
5baba62
Revert "Fix incomplete revert: flip remaining two @v4 checkout refs b…
PaulNewling May 27, 2026
06ff85c
ci(changeset/check-coverage): address review feedback
PaulNewling May 27, 2026
5789c1e
ci(changeset/check-coverage): clarify description — direct edit is th…
PaulNewling May 27, 2026
9c30b0e
Merge pull request #168 from milaboratory/feat/changeset-coverage-check
PaulNewling May 28, 2026
59b1aab
fix: restore third-party action refs mangled by sed-scope bug
PaulNewling May 28, 2026
9666dba
fix: anchor merge-beta.sh / fix-beta.sh sed to self-ref path
PaulNewling May 28, 2026
d7e256c
Merge pull request #169 from milaboratory/fix/sed-scope-third-party-refs
PaulNewling May 28, 2026
cbd15d6
ci(changeset): make coverage gaps a visible non-blocking check
PaulNewling May 28, 2026
66d444e
Merge pull request #170 from milaboratory/paulnewling/changeset-cover…
PaulNewling May 28, 2026
80e2a31
Merge v4-beta into v4
PaulNewling May 28, 2026
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
41 changes: 41 additions & 0 deletions .github/workflows/0-test-changeset-coverage.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: 'Test: changeset/check-coverage'

on:
workflow_call:
workflow_dispatch: # manual escape hatch — also lets us re-run after a workflow edit
pull_request:
paths:
- 'actions/changeset/check-coverage/**'
push:
paths:
- 'actions/changeset/check-coverage/**'
branches: ['v4', 'v4-beta']

jobs:
bats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# No `version:` — the repo's root package.json sets `packageManager`,
# and pnpm/action-setup errors if both are specified.
- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install bats
run: sudo apt-get update && sudo apt-get install -y bats

- name: Verify required tools are present
run: |
which jq yq pnpm bats
jq --version
yq --version
pnpm --version
bats --version

- name: Run changeset/check-coverage bats suite
working-directory: actions/changeset/check-coverage
run: bats test/coverage.bats
49 changes: 49 additions & 0 deletions .github/workflows/node-simple-pnpm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,55 @@ jobs:
run: |
pnpm changeset status --since="origin/${BRANCH_NAME}"

changeset-coverage:
name: changeset coverage (diagnostic)
runs-on: ubuntu-latest
# Diagnostic only: surface per-package changeset gaps as a red check, never block.
# The check-coverage step omits continue-on-error, so a gap (exit 1) fails this
# job and its check turns red. Job-level continue-on-error keeps the run green and
# the merge available. Separate from check-changesets so the native
# `changeset status` gate keeps blocking. Keep this job out of required checks.
continue-on-error: true
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
needs:
- metadata
steps:
- id: context
uses: milaboratory/github-ci/actions/context@v4

- uses: milaboratory/github-ci/actions/env@v4
with:
inputs: ${{ inputs.env }}
secrets: ${{ secrets.env }}

- uses: actions/checkout@v4
with:
lfs: ${{ inputs.checkout-git-lfs }}
submodules: ${{ inputs.checkout-submodules }}
fetch-depth: '0'

- name: Prepare environment for building a NodeJS application
uses: milaboratory/github-ci/actions/node/prepare-pnpm@v4
env:
PNPM_VERSION: ${{ needs.metadata.outputs.pnpm-version }}
with:
node-version: ${{ inputs.node-version }}
cache-version: ${{ inputs.cache-version }}
pnpm-version: ${{ env.PNPM_VERSION || inputs.pnpm-version }}
cache-hashfiles-search-path: ${{ inputs.cache-hashfiles-search-path }}
npmrc-config: ${{ inputs.npmrc-config }}

- name: Install NodeJS packages with pnpm
uses: milaboratory/github-ci/actions/shell@v4
with:
run: |
pnpm install --frozen-lockfile --prefer-offline

- name: Check changeset coverage
uses: milaboratory/github-ci/actions/changeset/check-coverage@v4
with:
base-branch: ${{ inputs.changeset-default-branch }}

pre-calculated-build:
name: pre-build
runs-on: ${{ inputs.gha-runner-label }}
Expand Down
30 changes: 30 additions & 0 deletions actions/changeset/check-coverage/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Check changeset coverage
author: 'MiLaboratories'
description: |
Fail if a PR's changesets don't cover every workspace package it modifies.

Catches two common gaps:
- editing files inside a workspace package (e.g. block code under
packages/<pkg>/) without adding that package to the changeset;
- bumping a catalog version in pnpm-workspace.yaml without a matching
bump for the workflow packages that consume it via `catalog:`.

Runs after `pnpm install`. Requires the runner to have `pnpm`, `jq`, and
`yq` (mikefarah, v4+) on PATH — all pre-installed on GitHub-hosted
ubuntu-latest images.

inputs:
base-branch:
description: Base branch to diff against (e.g. main).
required: true

runs:
using: 'composite'

steps:
- name: Check changeset coverage
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
BASE_BRANCH: ${{ inputs.base-branch }}
run: '${ACTION_PATH}/check-coverage.sh'
222 changes: 222 additions & 0 deletions actions/changeset/check-coverage/check-coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
#
# Verify the PR's changesets bump every workspace package the PR modifies.
# Exit 1 on a coverage gap; exit 2 on tooling failure; exit 0 otherwise.
#
# Two sources of "modified":
#
# 1. Direct edits to a workspace package's files, detected via
# `pnpm --filter '[<base>]' list` — pnpm runs the per-package
# git-diff check itself.
#
# 2. Catalog version bumps in pnpm-workspace.yaml: for each touched
# catalog key, find workspace packages that consume it via
# `"<key>": "catalog:..."` and require those packages to bump.
#
# Skips private (unpublished) workspace packages — they never appear in
# the changeset's release set.
#
# Runs from the repo root after `pnpm install`.

set -o nounset
set -o errexit
set -o pipefail

: "${BASE_BRANCH:?BASE_BRANCH required}"

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.
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.
status_json=".changeset-coverage-status-$$.json"
pkg_list_json=".changeset-coverage-pkgs-$$.json"

# ---------------------------------------------------------------------------
# 1. Bumped set from `changeset status --output=...`.
# ---------------------------------------------------------------------------
trap 'rm -f "${status_json}" "${pkg_list_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.
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=''
set +e
cs_stdouterr="$(
"${changeset_bin}" status \
--output="${status_json}" \
--since="origin/${BASE_BRANCH}" 2>&1
)"
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
err "changeset status failed (exit ${cs_exit}):"
printf '%s\n' "${cs_stdouterr}" | sed 's/^/ /' >&2
exit 2
else
echo '{"releases":[]}' >"${status_json}"
fi
fi

declare -A bumped_set=()
while IFS= read -r pkg; do
[ -n "${pkg}" ] && bumped_set["${pkg}"]=1
done < <(jq -r '.releases[]?.name // empty' "${status_json}")

if [ "${#bumped_set[@]}" -eq 0 ]; then
log 'Changeset bumps: <none>'
else
log "Changeset bumps: ${!bumped_set[*]}"
fi

# ---------------------------------------------------------------------------
# 2. Required-bump set.
# ---------------------------------------------------------------------------
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}; "
}

# 2a. 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.
#
# 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'
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'
)

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

# 2b. Catalog version bumps in pnpm-workspace.yaml.
#
# Only runs when the workspace yaml itself changed. Builds a full
# workspace map so we can find every consumer of each touched catalog key.
if git diff --name-only "origin/${BASE_BRANCH}...HEAD" | grep -qx 'pnpm-workspace.yaml'; then
pnpm -r list --depth -1 --json >"${pkg_list_json}"

# pnpm returns canonical absolute paths; strip the workspace root via
# `pwd -P` so the result matches the workspace's view on either platform.
workspace_root="$(pwd -P)"
declare -A pkg_name_to_dir=()
declare -A pkg_is_private=()

while IFS=$'\t' read -r pkg_name pkg_path pkg_private; do
[ -z "${pkg_name}" ] && continue # workspace root has no name
rel="${pkg_path#${workspace_root}/}"
[ "${rel}" = "${pkg_path}" ] && continue # workspace root itself
pkg_name_to_dir["${pkg_name}"]="${rel}"
pkg_is_private["${pkg_name}"]="${pkg_private}"
done < <(jq -r '
.[]
| select(.name != null and .name != "")
| [.name, .path, (.private // false | tostring)]
| @tsv
' "${pkg_list_json}")

# Extract catalog keys whose value changed (added, removed, or bumped).
# Parse both the base and head versions structurally with yq, flatten the
# default catalog and any named catalogs to `key=value` lines, then take
# entries unique to either side. Avoids the false-positive surface of a
# line-level regex on the raw diff.
cat_pairs() {
yq -e '
[
(.catalog // {} | to_entries[]),
(.catalogs // {} | to_entries[].value // {} | to_entries[])
] | .[] | .key + "=" + .value
' 2>/dev/null || true
}
old_pairs="$(git show "origin/${BASE_BRANCH}:pnpm-workspace.yaml" 2>/dev/null | cat_pairs || true)"
new_pairs="$(cat_pairs <pnpm-workspace.yaml || true)"
mapfile -t catalog_keys < <(
{ printf '%s\n' "${old_pairs}"; printf '%s\n' "${new_pairs}"; } \
| sed '/^$/d' \
| sort | uniq -u \
| sed -E 's/=.*//' \
| sort -u
)

if [ "${#catalog_keys[@]}" -gt 0 ]; then
log "Catalog keys touched: ${catalog_keys[*]}"
# For each catalog key, find workspace pkgs that depend on it with "catalog:".
for key in "${catalog_keys[@]}"; do
for name in "${!pkg_name_to_dir[@]}"; do
[ "${pkg_is_private[${name}]:-false}" = 'true' ] && continue
pj="${pkg_name_to_dir[${name}]}/package.json"
[ -f "${pj}" ] || continue
if jq -e --arg k "${key}" '
[.dependencies?, .devDependencies?, .peerDependencies?, .optionalDependencies?]
| map(select(. != null) | to_entries) | add // []
| map(select(.key == $k and ((.value // "") | startswith("catalog:"))))
| length > 0
' "${pj}" >/dev/null 2>&1; then
require_pkg "${name}" "catalog dep '${key}' bumped"
fi
done
done
fi
fi

# ---------------------------------------------------------------------------
# 3. Compare required vs bumped.
# ---------------------------------------------------------------------------
missing=()
for pkg in "${!required_set[@]}"; do
[ -z "${bumped_set[${pkg}]:-}" ] && missing+=("${pkg}")
done

if [ "${#missing[@]}" -eq 0 ]; then
log '✓ Changeset covers every modified package.'
exit 0
fi

err 'Changeset coverage gap. The following packages were modified but not bumped:'
for pkg in "${missing[@]}"; do
reason="${required_reason[${pkg}]%; }"
err " - ${pkg} (${reason})"
done
err ''
err "Add a changeset entry — run \`pnpm changeset\` and select the missing packages."
exit 1
Loading
Loading