From 59a61515128a9bd8b013b795944f5d5b78e71301 Mon Sep 17 00:00:00 2001 From: Alexandr Date: Wed, 5 Aug 2026 16:19:01 +0200 Subject: [PATCH 1/2] MILAB-6707: honour the empty-changeset opt-out in check-coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm changeset --empty` already waived require-package-bump's half of the gate but not coverage, so an author who declared "no release needed" was passed by one half and failed by the other. Coverage now accepts the same declaration, waiving every missing package at once. Scoped to changesets ADDED in the branch: an empty changeset inherited from the base branch must not disable the gate for every branch cut after it. Exit 2 (tooling broken) still wins over the waiver. The rule is duplicated rather than shared — there is no bash-sharing precedent between actions here, and refactoring the working, tested require-package-bump.sh was not worth the risk. Both copies state the rule in place. Six cases added to the bats suite. --- actions/changeset/check-coverage/action.yaml | 15 +++- .../check-coverage/check-coverage.sh | 47 +++++++++++- .../changeset/check-coverage/test/README.md | 3 +- .../check-coverage/test/coverage.bats | 72 +++++++++++++++++++ .../check-coverage/test/helpers.bash | 12 ++++ 5 files changed, 145 insertions(+), 4 deletions(-) diff --git a/actions/changeset/check-coverage/action.yaml b/actions/changeset/check-coverage/action.yaml index 17ec6121..515557e0 100644 --- a/actions/changeset/check-coverage/action.yaml +++ b/actions/changeset/check-coverage/action.yaml @@ -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: diff --git a/actions/changeset/check-coverage/check-coverage.sh b/actions/changeset/check-coverage/check-coverage.sh index 0014ab86..25c04187 100755 --- a/actions/changeset/check-coverage/check-coverage.sh +++ b/actions/changeset/check-coverage/check-coverage.sh @@ -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 '[]' list` — pnpm runs the per-package git-diff # check itself. Root-level paths (`.github/`, `docs/`, `pnpm-workspace.yaml`, @@ -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 diff --git a/actions/changeset/check-coverage/test/README.md b/actions/changeset/check-coverage/test/README.md index e959a052..39d6445c 100644 --- a/actions/changeset/check-coverage/test/README.md +++ b/actions/changeset/check-coverage/test/README.md @@ -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`. diff --git a/actions/changeset/check-coverage/test/coverage.bats b/actions/changeset/check-coverage/test/coverage.bats index 313fdf35..fc42fc11 100644 --- a/actions/changeset/check-coverage/test/coverage.bats +++ b/actions/changeset/check-coverage/test/coverage.bats @@ -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. # --------------------------------------------------------------------------- diff --git a/actions/changeset/check-coverage/test/helpers.bash b/actions/changeset/check-coverage/test/helpers.bash index 3df0476e..95071aa7 100644 --- a/actions/changeset/check-coverage/test/helpers.bash +++ b/actions/changeset/check-coverage/test/helpers.bash @@ -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() { From 860bff1d7bbbaa60388e94c23d05648d14b5f465 Mon Sep 17 00:00:00 2001 From: Alexandr Date: Wed, 5 Aug 2026 16:19:12 +0200 Subject: [PATCH 2/2] MILAB-6707: fold changeset coverage into the check-changesets gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-package coverage check ran as its own always-green job, so a red result cost nothing and nobody had to act on it. It now runs as a second step inside `check for changesets`, under the existing require-package-path-bump toggle, and the separate diagnostic job is gone. Neither half implies the other, so both run and both must pass: require-package-bump ignores the diff and demands a bump of `package-path` on every PR, while check-coverage ignores untouched packages and demands a bump for each edited publishable one. Replacing the first with the second would have weakened the gate — in a block repo the model/ui/workflow/test siblings are private and so skipped by coverage, leaving a ui-only PR unchecked. The second step runs under `!cancelled()` so one red check reports the complete set of missing bumps instead of one per run. No new input and no new check name. With the toggle off — every consumer today — `check for changesets` behaves exactly as before; what those repos give up is the never-actionable diagnostic check. --- .github/workflows/node-simple-pnpm.yaml | 89 +++++++++---------------- 1 file changed, 32 insertions(+), 57 deletions(-) diff --git a/.github/workflows/node-simple-pnpm.yaml b/.github/workflows/node-simple-pnpm.yaml index 08f8f5a5..d86a9e50 100644 --- a/.github/workflows/node-simple-pnpm.yaml +++ b/.github/workflows/node-simple-pnpm.yaml @@ -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 @@ -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' }} @@ -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 - - - 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 }} - 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 - 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 with: base-branch: ${{ inputs.changeset-default-branch }}