Context
The release workflow (release.yml) runs on every merge to main with no label gate (we dropped the version bump label in PR #41). It then walks the merge diff for has_changesets — a non-empty changesets diff triggers the version + publish flow, an empty one short-circuits the whole job into a no-op (no publish, no tag, no GitHub Release).
This is correct for normal release PRs (the release engineer cherry-picks a batch that already includes the changesets in the diff), but fragile for hotfix PRs: a hotfix directly to main — typically branched from main, often under time pressure — can be merged without a .changeset/*.md file. The workflow runs, sees no changeset, becomes a silent no-op, and the fix never reaches npm. Downstream consumers discover the missing fix only when their issue is not resolved.
docs/internal/engineering/plans/release-system.md and CONTRIBUTING.md already document hotfix conventions, but neither is enforced. We have observed this failure mode in practice: PR #44 → #47 of the @deessejs/errors release stack hit several related issues, including a tag drift on @deessejs/errors@1.1.1 where the workflow silently shipped without a tag at the version-bump commit. The fixes hardened the release side, but the hotfix side is still a contract by convention only.
Proposed approach
Add a targeted CI check, separate from the existing lint on staging:
# .github/workflows/hotfix-ci.yml (new)
name: Hotfix CI
on:
pull_request:
branches: [main]
types: [opened, synchronize]
jobs:
require-changeset-on-hotfix:
if: contains(github.event.pull_request.labels.*.name, 'hotfix')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Require changeset
run: |
if ! git diff --name-only origin/main...HEAD | grep -q '^\.changeset/.*\.md$'; then
echo "::error::Hotfix PRs require a .changeset/*.md."
exit 1
fi
Update CONTRIBUTING.md to document the contract: hotfix PRs must carry the hotfix label AND a .changeset/*.md file.
Why this scope
- Targeted, not general. A general lint on every PR to
main would block the release engineer cherry-pick release PRs, which often have changesets committed in upstream PRs rather than the cherry-pick diff. Tying the check to the hotfix label preserves the label-less release flow while gating the case where fail-fast is most needed.
- Tiny footprint. One small workflow file (~25 lines) and one documentation update.
- Label as intent. The
hotfix label is already in CONTRIBUTING.md as the convention for urgent fixes. Anchoring the CI on it makes the contract explicit at the GitHub UI level (a maintainer will see the failing check on the PR).
Acceptance criteria
Related
- Release system plan:
docs/internal/engineering/plans/release-system.md (Section 1 Rules of the road, Section 7 post-plan additions).
- Release process runbook:
docs/internal/engineering/process/releasing-a-new-version.md (Failure modes and recoveries).
- Past incident: tag drift on
@deessejs/errors@1.1.1 documented in the same plan Appendix A.
Context
The release workflow (
release.yml) runs on every merge tomainwith no label gate (we dropped theversion bumplabel in PR #41). It then walks the merge diff forhas_changesets— a non-empty changesets diff triggers the version + publish flow, an empty one short-circuits the whole job into a no-op (no publish, no tag, no GitHub Release).This is correct for normal release PRs (the release engineer cherry-picks a batch that already includes the changesets in the diff), but fragile for hotfix PRs: a hotfix directly to
main— typically branched frommain, often under time pressure — can be merged without a.changeset/*.mdfile. The workflow runs, sees no changeset, becomes a silent no-op, and the fix never reaches npm. Downstream consumers discover the missing fix only when their issue is not resolved.docs/internal/engineering/plans/release-system.mdandCONTRIBUTING.mdalready document hotfix conventions, but neither is enforced. We have observed this failure mode in practice: PR #44 → #47 of the@deessejs/errorsrelease stack hit several related issues, including a tag drift on@deessejs/errors@1.1.1where the workflow silently shipped without a tag at the version-bump commit. The fixes hardened the release side, but the hotfix side is still a contract by convention only.Proposed approach
Add a targeted CI check, separate from the existing lint on
staging:Update
CONTRIBUTING.mdto document the contract: hotfix PRs must carry thehotfixlabel AND a.changeset/*.mdfile.Why this scope
mainwould block the release engineer cherry-pick release PRs, which often have changesets committed in upstream PRs rather than the cherry-pick diff. Tying the check to thehotfixlabel preserves the label-less release flow while gating the case where fail-fast is most needed.hotfixlabel is already inCONTRIBUTING.mdas the convention for urgent fixes. Anchoring the CI on it makes the contract explicit at the GitHub UI level (a maintainer will see the failing check on the PR).Acceptance criteria
.github/workflows/hotfix-ci.ymlexists and runs on PRs tomainwith labelhotfix.hotfixPR is opened/reopened againstmainwithout a.changeset/*.mdin its diff.hotfixlabel.CONTRIBUTING.mddocuments thehotfixlabel as a required gate for hotfix PRs, alongside the existing changeset requirement.hotfixlabel + changeset merges cleanly through the existingrelease.ymlonmain.Related
docs/internal/engineering/plans/release-system.md(Section 1Rules of the road, Section 7 post-plan additions).docs/internal/engineering/process/releasing-a-new-version.md(Failure modes and recoveries).@deessejs/errors@1.1.1documented in the same plan Appendix A.