diff --git a/.github/workflows/node-simple-pnpm.yaml b/.github/workflows/node-simple-pnpm.yaml index 46bc96ae..74e58eb7 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-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 }} 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() {