Skip to content
Closed
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
89 changes: 32 additions & 57 deletions .github/workflows/node-simple-pnpm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -358,14 +358,30 @@ on:

require-package-path-bump:
description: |
Opt-in gate (block repos): require the package at `package-path` to
be DIRECTLY bumped by a changeset on `pull_request` events. When true
and the package is not bumped — no empty changeset present and no
`skip-changelog` label on the PR — the `check for changesets` check
goes RED and blocks merge via branch protection. Sibling/devDependency
cascade bumps do NOT satisfy it. Not enforced in the merge queue; the
build/test jobs keep running regardless so CI stays usable while
iterating.
Opt-in gate (block repos): on `pull_request` events, require both
halves of the release contract to hold.

1. The package at `package-path` is DIRECTLY bumped by a changeset.
Required on every PR, whether or not that package's own files
changed. Sibling/devDependency cascade bumps do NOT satisfy it.
2. Every other workspace package whose own files the PR edits is
bumped too. Private packages are skipped — they are never
published — and a dependency or catalog version bump does not
count as editing a package.

Neither half implies the other: half 1 ignores the diff, half 2
ignores untouched packages. Half 2 only bites in a repo with more than
one publishable package — in a block repo the private
`model`/`ui`/`workflow`/`test` siblings are skipped and `block` is
already covered by half 1.

Either half failing turns the `check for changesets` check RED, which
blocks merge via branch protection. Two ways out, each waiving the gate
as a whole: an empty changeset added in the branch
(`pnpm changeset --empty`), which is the in-repo "no release needed"
statement, or the `skip-changelog` label on the PR. Not enforced in the
merge queue; the build/test jobs keep running regardless so CI stays
usable while iterating.
type: boolean
required: false
default: false
Expand Down Expand Up @@ -613,8 +629,9 @@ jobs:
run: |
pnpm changeset status --since="origin/${BRANCH_NAME}"

# Block mode: the `package-path` package must be bumped DIRECTLY (empty
# changeset waives; the bypass label is handled by the job `if` above).
# Block mode, half 1: the `package-path` package must be bumped DIRECTLY,
# on every PR, whether or not its own files changed (empty changeset
# waives; the bypass label is handled by the job `if` above).
# PR-only — merge_group / push need no enforcement here.
- name: Require package bump by changeset
if: ${{ inputs.require-package-path-bump && github.event_name == 'pull_request' }}
Expand All @@ -623,53 +640,11 @@ jobs:
package-path: ${{ inputs.package-path }}
base-branch: ${{ inputs.changeset-default-branch }}

changeset-coverage:
name: changeset coverage (diagnostic)
runs-on: ${{ inputs.gha-runner-label }}
# 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-beta

- uses: milaboratory/github-ci/actions/env@v4-beta
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-beta
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 }}
cache-s3-access-key: ${{ env.HZ_CI_CACHE_S3_ACCESS_KEY }}
cache-s3-secret-key: ${{ env.HZ_CI_CACHE_S3_SECRET_KEY }}

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

- name: Check changeset coverage
# Block mode, half 2: every other workspace package the PR edits must be
# bumped as well. Runs even when half 1 already failed, so one red check
# reports the complete set of missing bumps instead of one at a time.
- name: Require changeset coverage for every edited package
if: ${{ !cancelled() && inputs.require-package-path-bump && github.event_name == 'pull_request' }}
uses: milaboratory/github-ci/actions/changeset/check-coverage@v4-beta
with:
base-branch: ${{ inputs.changeset-default-branch }}
Expand Down
15 changes: 13 additions & 2 deletions actions/changeset/check-coverage/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,19 @@ description: |
chain automatically at `changeset version` time. Flagging consumers of a
bumped catalog dependency would over-report relative to `pnpm changeset`.

Runs after `pnpm install`. Requires the runner to have `pnpm` and `jq` on
PATH — both pre-installed on GitHub-hosted ubuntu-latest images.
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
every missing package at once — the same rule require-package-bump applies to
its own half of the gate. A stale empty changeset already on the base branch
does not count.

This action is label-agnostic; the `skip-changelog` bypass is handled by the
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.

inputs:
base-branch:
Expand Down
47 changes: 46 additions & 1 deletion actions/changeset/check-coverage/check-coverage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
# it edits. 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.
#
# "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`,
Expand Down Expand Up @@ -149,11 +152,53 @@ if [ "${#missing[@]}" -eq 0 ]; then
exit 0
fi

# ---------------------------------------------------------------------------
# 4. Empty-changeset opt-out.
# ---------------------------------------------------------------------------
# A changeset ADDED in this branch whose front matter names no package
# (`pnpm changeset --empty`) is a deliberate, committed "no release needed"
# statement and waives the requirement for every missing package at once.
#
# Scoped to ADDED files, so a stale empty changeset sitting on the base branch
# cannot silently satisfy a new PR.
#
# require-package-bump.sh applies the same rule to its own half of the gate.
# Both halves must accept the same declaration — an author who states "no
# release" should not be failed by one half and passed by the other. Change one
# and change the other.
is_empty_changeset() {
awk '
NR==1 && /^---[[:space:]]*$/ { infm=1; next }
infm && /^---[[:space:]]*$/ { exit (found ? 1 : 0) }
infm && /[^[:space:]]/ { found=1 }
END { if (!infm) exit 2; exit (found ? 1 : 0) }
' "$1"
}

while IFS= read -r f; do
[ -z "${f}" ] && continue
case "${f}" in
.changeset/README.md) continue ;;
.changeset/*.md) ;;
*) continue ;;
esac
[ -f "${f}" ] || continue
if is_empty_changeset "${f}"; then
log "✓ Empty changeset added in this branch: ${f} — coverage requirement waived."
exit 0
fi
done < <(
git diff --name-only --diff-filter=A "origin/${BASE_BRANCH}...HEAD" -- .changeset 2>/dev/null || true
)

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."
err 'Do one of:'
err " - Bump them: run \`pnpm changeset\` and select the missing packages."
err ' - Deliberately skip a release: `pnpm changeset --empty` (an empty changeset).'
err ' - Bypass this check: add the `skip-changelog` label to the PR.'
exit 1
3 changes: 2 additions & 1 deletion actions/changeset/check-coverage/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ 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`, `bump_catalog`), and runs the script
(`touch_file`, `add_changeset`, `add_empty_changeset`, `bump_catalog`),
and runs the script
with `BASE_BRANCH=main`. The test asserts on the script's exit code and
the captured `$output`.

Expand Down
72 changes: 72 additions & 0 deletions actions/changeset/check-coverage/test/coverage.bats
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,78 @@ setup() {
[[ "${output}" == *'@check-coverage-test/pkg-c'* ]]
}

# ---------------------------------------------------------------------------
# Empty-changeset opt-out.
#
# `pnpm changeset --empty` is the committed "no release needed" statement. It
# waives coverage the same way it waives require-package-bump's half of the
# gate — an author who declares "no release" must not be failed by one half and
# passed by the other.
# ---------------------------------------------------------------------------

@test "an empty changeset added in this branch waives a coverage gap" {
touch_file 'packages/pkg-a/index.js'
add_empty_changeset
run_check
[ "${status}" -eq 0 ]
[[ "${output}" == *'coverage requirement waived'* ]]
}

@test "an empty changeset waives every missing package at once" {
touch_file 'packages/pkg-a/index.js'
touch_file 'packages/pkg-b/index.js'
add_empty_changeset
run_check
[ "${status}" -eq 0 ]
}

@test "an empty changeset alongside a partial real changeset waives the rest" {
touch_file 'packages/pkg-a/index.js'
touch_file 'packages/pkg-b/index.js'
add_changeset '"@check-coverage-test/pkg-a": patch' 'edit pkg-a'
add_empty_changeset
run_check
[ "${status}" -eq 0 ]
}

# The opt-out is scoped to changesets ADDED in the branch. An empty changeset
# inherited from the base branch is present in the tree but was not added here,
# so it must not satisfy a new PR — otherwise one forgotten empty changeset on
# main would disable the gate for every branch cut afterwards.
@test "an empty changeset inherited from the base branch does not waive" {
git -C "${WORKSPACE}" checkout --quiet main
add_empty_changeset 'stale opt-out'
git -C "${WORKSPACE}" update-ref refs/remotes/origin/main main
git -C "${WORKSPACE}" checkout --quiet -B feature main
touch_file 'packages/pkg-a/index.js'
run_check
[ "${status}" -eq 1 ]
[[ "${output}" == *'@check-coverage-test/pkg-a'* ]]
}

# A real changeset added in the branch is not empty and must not be read as the
# opt-out, or naming one package would waive the requirement for all the others.
@test "a non-empty changeset is not treated as the opt-out" {
touch_file 'packages/pkg-a/index.js'
touch_file 'packages/pkg-b/index.js'
add_changeset '"@check-coverage-test/pkg-a": patch' 'edit pkg-a'
run_check
[ "${status}" -eq 1 ]
[[ "${output}" == *'@check-coverage-test/pkg-b'* ]]
}

# Exit 2 (tooling broken) must win over the waiver: with no changeset binary the
# coverage question is unanswerable, and an empty changeset must not convert
# that into a pass.
@test "an empty changeset does not mask a tooling failure" {
rm -f "${WORKSPACE}/node_modules/.bin/changeset"
touch_file 'packages/pkg-a/index.js'
add_empty_changeset
run_check
[ "${status}" -eq 2 ]
[[ "${output}" == *'changeset binary not found'* ]]
}

# ---------------------------------------------------------------------------
# Base-branch input.
# ---------------------------------------------------------------------------
Expand Down
12 changes: 12 additions & 0 deletions actions/changeset/check-coverage/test/helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ EOF
git -C "${WORKSPACE}" commit --quiet -m "add changeset: ${title}"
}

# Drop an empty changeset — the shape `pnpm changeset --empty` produces: front
# matter naming no package. Args: title (optional, only used for the filename
# and commit message).
add_empty_changeset() {
local title="${1:-no release needed}"
local slug
slug="$(printf '%s' "${title}" | tr -cs 'a-z0-9' '-' | sed 's/^-\|-$//g')"
printf -- '---\n---\n' >"${WORKSPACE}/.changeset/${slug}.md"
git -C "${WORKSPACE}" add ".changeset/${slug}.md"
git -C "${WORKSPACE}" commit --quiet -m "add empty changeset: ${title}"
}

# Edit a file under $WORKSPACE, stage and commit. Args: relpath, content (or
# defaults to appending a comment).
touch_file() {
Expand Down
Loading