Skip to content

fix(ci): block major bumps before ccv pushes the tag - #25

Merged
clappingmonkey merged 1 commit into
mainfrom
fix/tag-major-bump-guard
Oct 22, 2026
Merged

clappingmonkey merged 1 commit into
mainfrom
fix/tag-major-bump-guard

Conversation

@clappingmonkey

Copy link
Copy Markdown
Owner

What does this PR do?

smlx/ccv creates and pushes the version tag inside its own step (write-tag defaults to true). The "block major bump" condition in tag.yaml only gated the release job, so a feat!: / fix!: / BREAKING CHANGE: commit would push an orphan vX.0.0 tag that never gets released. The v* tag ruleset blocks deletion, so that tag could never be removed.

This change makes ccv compute the version only and moves tagging into an explicit step that runs after the guard.

.github/workflows/tag.yaml

  • ccv runs with write-tag: false.
  • Validate computed version: fails closed if ccv returns a malformed tag/type (its entrypoint can mask errors).
  • Block major version bump: fails the run before any tag exists and writes a job summary with a SHA-pinned manual release runbook.
  • Push tag: main-only, pushes only refs/tags/<tag>, and sets pushed=true only when git push --porcelain reports a newly created ref (an already-existing tag does not trigger a second release).
  • release job is gated on pushed == 'true' instead of ccv's "would create" output.
  • New workflow_dispatch input dry_run: computes and reports the version (including majors) without pushing.
  • Job concurrency (no cancel), timeout-minutes, shell: bash, explicit persist-credentials: true, permission comments.

CONTRIBUTING.md

  • Rewrote Releasing: automated patch/minor flow, dry-run command, which commit prefixes ccv recognises, and the manual major-release procedure.

How was it tested?

  • actionlint clean; zizmor --persona=pedantic only reports self-repository (low, out of scope); pre-commit run --all-files passes.
  • End-to-end on a private sandbox repo running this exact workflow on hosted runners (release job stubbed):
    • docs-only commits → no tag, no release
    • fix: → patch tag pushed, release called exactly once (no extra push-triggered release)
    • feat: → minor tag
    • feat!: → run fails, no major tag on remote; a later fix: is still blocked
    • feat!: with dry_run → succeeds and reports the major version
    • manual runbook (git tag vX.0.0 <sha> + push) → release.yaml runs via tag push
    • two concurrent dispatches → serialized, one tag, one release
    • dispatch from a non-main branch → push refused, no tag
  • Dry run against this repo from this branch: result posted as a comment below.

Checklist

  • PR title follows conventional commit format
  • CI passes (check.yaml — lint + test matrix)
  • Tests added or updated for new/changed behavior
  • Documentation updated if user-facing (docstrings, README, CONTRIBUTING.md)
  • No breaking changes (or BREAKING CHANGE section below is filled out)

smlx/ccv defaults to write-tag: true, so the action created and pushed
the tag before the "skip release on major" condition ran. A breaking
commit would leave an orphan, unreleased major tag that cannot be
deleted (tag ruleset).

- Run ccv with write-tag: false and push the tag in a separate step,
  only after validating the version and blocking major bumps.
- Fail the run on a major bump, with a job summary that pins the commit
  SHA for the manual release.
- Push only from main, and set pushed=true only when the tag was newly
  created (git push --porcelain), so the release runs exactly once.
- Add a dry_run dispatch input, job-level concurrency, a timeout, and
  explicit persist-credentials/permission comments.
- Document automated and manual major releases in CONTRIBUTING.md.
@clappingmonkey

Copy link
Copy Markdown
Owner Author

Dry run from this branch against the real repo (run 39839164672): computed v0.4.2 (patch), Block/Push steps skipped, Trigger release skipped, no tag created on remote (latest remains v0.4.1).

@clappingmonkey
clappingmonkey merged commit d52682f into main Oct 22, 2026
16 checks passed
@clappingmonkey
clappingmonkey deleted the fix/tag-major-bump-guard branch October 22, 2026 19:32
clappingmonkey added a commit that referenced this pull request Oct 22, 2026
…majors (#26)

## What does this PR do?

Follow-up to #25. A dry run on `main` after #25 merged computed
`v1.0.0`. The squash commit body (the PR description) quoted ccv's
breaking-change footer text, and ccv v0.10.0 matches that text anywhere
in a commit message. The guard from #25 worked and nothing was tagged.
Still, the failure gave no hint about which commit caused the bump or
how to recover.

This PR changes:

- `tag.yaml`: when the computed bump is major, a new **List breaking
commits** step (best effort, `continue-on-error`) writes the likely
trigger commits since the last tag to the job summary, with the matching
body lines. Commit text is sanitized so it cannot break out of the code
fence.
- `tag.yaml`: the blocked-run summary prints two runbooks, both pinned
to the run SHA:
  - **Intended major**: tag the computed `vX.0.0`.
- **Accidental bump**: tag the next patch, which the workflow computes.
ccv stops at the first tagged commit it reaches, so later runs only
count commits after it.

Both use `git -c tag.gpgSign=false tag`, because ccv ignores annotated
and signed tags.
- `CONTRIBUTING.md`: the "Major releases" section explains which forms
ccv treats as breaking, warns that the footer text is matched anywhere
(including the PR description), and documents both recovery paths.
- `PULL_REQUEST_TEMPLATE.md`: tells authors to mark breaking changes
with `!` in the PR title, and not to write the footer text in the
description of a non-breaking PR.

The `ci:` title does not bump the version.

## How was it tested?

- `actionlint` is clean. `zizmor --persona=pedantic` only reports the
existing `self-repository` low finding. `pre-commit run --all-files`
passes.
- Sandbox repo (private, same workflow, stubbed release):
  - A dry run on a pending `feat!:` succeeds and lists the commit.
  - A normal run is blocked and no tag is created.
- A `fix:` commit whose body contains the footer text is blocked, and
the list shows it.
- Running the printed accidental-bump commands creates a lightweight
`v3.1.2`. The push triggers the release exactly once.
- The next `fix:` is auto-tagged `v3.1.3`, released once, and the list
step is skipped.
- I rendered the summary against this repo's `main`. It lists `d52682f`
with the matching body line and suggests `v0.4.2` as the recovery tag.
- Dry run of this branch on this repo: see the comment below.
- Reviewed by code-reviewer and change-validator. All findings were
addressed, and change-validator's final verdict is APPROVE.

## Checklist

- [x] PR title follows [conventional
commit](https://www.conventionalcommits.org/) format
- [ ] CI passes (`check.yaml` — lint + test matrix)
- [x] Tests added or updated for new/changed behavior (sandbox runs
above; workflow-only change)
- [x] Documentation updated if user-facing (CONTRIBUTING.md, PR
template)
- [x] No breaking changes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant