diff --git a/.github/workflows/bot-automerge.yml b/.github/workflows/bot-automerge.yml
index 33bff9c..0a40abe 100644
--- a/.github/workflows/bot-automerge.yml
+++ b/.github/workflows/bot-automerge.yml
@@ -289,6 +289,12 @@ jobs:
# exercised either. release-tag.yml is the one that matters: it is part of the
# release path.
#
+ # The two prose-body workflows are held for the same reason, re-derived
+ # when they were added and again when the report moved to
+ # `pull_request_target`. Both now trigger on that event, so both run
+ # from the default branch and a pull request never executes the version
+ # it proposes -- green here would say nothing about either.
+ #
# Listed rather than inferred, and that is this branch's known weakness: a
# workflow's triggers can change without this file noticing, and a stale list
# fails by arming. Re-derive it when a trigger changes.
@@ -312,6 +318,8 @@ jobs:
-e '.github/workflows/pre-commit-autoupdate.yml' \
-e '.github/workflows/delete-bot-branches-for-closed-prs.yml' \
-e '.github/workflows/bot-automerge-disarm.yml' \
+ -e '.github/workflows/prose-body.yml' \
+ -e '.github/workflows/prose-body-write.yml' \
|| true)
if [ "$held" -ne 0 ]; then
echo "::warning::actions major reaches a workflow no PR runs; holding."
diff --git a/.github/workflows/prose-body-write.yml b/.github/workflows/prose-body-write.yml
new file mode 100644
index 0000000..08fec36
--- /dev/null
+++ b/.github/workflows/prose-body-write.yml
@@ -0,0 +1,31 @@
+---
+# Rewrites a hard-wrapped pull request body rather than reporting it, and after
+# a rewrite deletes any report prose-body.yml left. The report cannot withdraw
+# its own: GitHub starts no workflow run for an event caused by its own
+# `GITHUB_TOKEN`, so nothing tells it the body has changed.
+#
+# Only on open, reopen and ready-for-review. Running it on every push would
+# overwrite whatever the author had typed since. Drafts are not edited, which
+# is what `ready_for_review` is here for.
+#
+# `pull_request_target` is the only trigger this one accepts, and it is why the
+# file has to be on the default branch: under `pull_request` the workflow file
+# would come from the pull request itself, and that file is what grants the
+# token. It checks out nothing. A consequence worth knowing when editing it --
+# a pull request cannot try this out; it takes effect once merged.
+#
+# Unlike a file, a body carries no evidence of intent: there is no hard-break
+# marker to distinguish a break the author wanted from one their editor made,
+# so this can join a break that was meant to stay. `` on
+# the line above a paragraph keeps it.
+name: Prose body write
+
+on:
+ pull_request_target:
+ types: [opened, reopened, ready_for_review]
+
+jobs:
+ edit:
+ permissions:
+ pull-requests: write
+ uses: michen00/markdown-prose-hooks/.github/workflows/unwrap-pr-body.yml@v0.4.0
diff --git a/.github/workflows/prose-body.yml b/.github/workflows/prose-body.yml
new file mode 100644
index 0000000..4da5a09
--- /dev/null
+++ b/.github/workflows/prose-body.yml
@@ -0,0 +1,46 @@
+---
+# Reports a hard-wrapped pull request body. GitHub renders every newline in a
+# body as a `
`, so a body wrapped in an editor or a heredoc reaches readers
+# at whatever width it was typed to. A file has the opposite problem -- the
+# break renders as a space and costs a reflowed diff -- and the pre-commit hook
+# covers that side.
+#
+# Deliberately narrower than the upstream example, which also carries `opened`
+# and `reopened`. Those belong to prose-body-write.yml: sharing an event fires
+# both at once, and this report would then describe a body the rewrite is about
+# to replace. `edited` is not in the default type set and is the one that
+# matters -- editing a body fires it alone, so without it a report would stand
+# on a body the author has already fixed.
+#
+# `pull_request_target`, not `pull_request`, and the difference is who the
+# report reaches. A fork's token is read-only whatever a caller's
+# `permissions:` block asks for, so under `pull_request` the comment is refused
+# for every outside contributor -- the people least likely to know this
+# repository's prose convention, and the ones the report exists for. The run
+# does not fail there and the report is not lost: the reusable workflow falls
+# back to the job summary. But a summary is something you have to open a check
+# to find, and a comment is not.
+#
+# What makes that safe here is that the reusable workflow checks out nothing --
+# it has no `actions/checkout` step at all. It reads the body from the event
+# payload and posts a comment, so the usual `pull_request_target` hazard, a
+# writable token handed to unreviewed code, has nothing to attach to. Keep it
+# that way: adding a checkout of the pull request head under this trigger is
+# what would turn it into the vulnerability the pattern is known for.
+#
+# The cost is the same one prose-body-write.yml pays. `pull_request_target`
+# reads the workflow from the default branch, so a pull request cannot test a
+# change to this file; it takes effect once merged.
+name: Prose body
+
+on:
+ pull_request_target:
+ types: [synchronize, edited]
+
+jobs:
+ report:
+ # The reusable workflow declares no permissions of its own; only the
+ # comment needs one, and the scope belongs on the calling job.
+ permissions:
+ pull-requests: write
+ uses: michen00/markdown-prose-hooks/.github/workflows/unwrap-pr-body-check.yml@v0.4.0
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8249d45..d51fca8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -65,6 +65,18 @@ repos:
hooks:
- id: trailing-whitespace
exclude: ^tests/test-conventional-merge-commit\.csv$
+ # Before prettier, not after: this rewrites prose, and prettier is what
+ # normalizes the result. markdownlint's `line-length` is `false` here and
+ # prettier's `proseWrap` is left at `preserve`, so nothing downstream wraps
+ # the prose back -- which is the one thing that would make these fight.
+ #
+ # The `-py` mirror rather than `-rs`: a `language: rust` hook builds from
+ # source, so pre-commit would install a whole Rust toolchain before checking
+ # the first commit, and nothing here needs cargo otherwise.
+ - repo: https://github.com/michen00/markdown-prose-hooks-py
+ rev: v0.4.0
+ hooks:
+ - id: unwrap-markdown-prose-py
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.9.6
hooks:
diff --git a/CLAUDE.md b/CLAUDE.md
index 5da78b2..9781d22 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,10 +37,7 @@ See @README.md for project overview and features.
- Common types: `feat`, `fix`, `docs`, `chore`, `test`, `refactor`
- Merge commits should be prefixed with `chore: merge`
- Keep commit messages clear and descriptive
-- **Land PRs with squash merge only** — the repo allows no other method. Rebase merge
- replays commits unsigned, which loses the signature the `main-protect` ruleset asks for;
- GitHub signs the commit it creates for a squash. Write PR titles squash-ready, since the
- title becomes the squash subject and the branch's commit messages become its body.
+- **Land PRs with squash merge only** — the repo allows no other method. Rebase merge replays commits unsigned, which loses the signature the `main-protect` ruleset asks for; GitHub signs the commit it creates for a squash. Write PR titles squash-ready, since the title becomes the squash subject and the branch's commit messages become its body.
## Testing Requirements
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1c4c5d2..48ec0a7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -73,23 +73,12 @@ Default flow (automated):
1. **Release PR** (`.github/workflows/release-pr.yml`) opens the release PR by itself when a commit worth releasing lands on `main`. Every conventional type bumps at least the patch version, so the version cannot decide that on its own; the workflow gates on the group `cliff.toml` parsed each commit into — features, fixes, performance and reverts, plus anything marked breaking. A `chore`, `docs`, `build`, `ci`, `test`, `refactor` or `style` merge — the weekly hook autoupdate and Dependabot among them — rides along in the next release without proposing one. Run the workflow by hand, or `make release-pr`, to pin the version or to release a batch containing none of those types; a manual run skips the worthiness gate. Leave `version` empty to derive it via `git cliff --bumped-version`, or pass `X.Y.Z` / `vX.Y.Z`.
1. Review and merge the generated PR (`chore(release): prepare vX.Y.Z`).
-1. **Release Tag** workflow (`.github/workflows/release-tag.yml`) runs on merge of a
- `release/*` branch. It creates a GPG-signed annotated tag, pushes it, and dispatches
- **Release Publish**.
-1. **Release Publish** workflow (`.github/workflows/release-publish.yml`) builds and
- uploads signed artifacts to the GitHub release.
-
-No local step is required after the release PR merges, but the release is not automatic:
-**Release Tag** runs in the protected `release` environment and waits for a maintainer to
-approve the run. Approve from the run page, or from the PR's checks tab, to mint the tag and
-publish. Rejecting the approval leaves no tag behind.
-
-That is the only approval a normal release needs. **Release Publish** also declares the
-`release` environment, but when **Release Tag** dispatches it the deployment is created by
-`github-actions[bot]` and the reviewer rule is skipped, so it proceeds without a second
-prompt. The declaration still gates a Release Publish run dispatched by hand, which is the
-manual fallback path. Treat the Release Tag approval as the release decision — no tag means
-no publish.
+1. **Release Tag** workflow (`.github/workflows/release-tag.yml`) runs on merge of a `release/*` branch. It creates a GPG-signed annotated tag, pushes it, and dispatches **Release Publish**.
+1. **Release Publish** workflow (`.github/workflows/release-publish.yml`) builds and uploads signed artifacts to the GitHub release.
+
+No local step is required after the release PR merges, but the release is not automatic: **Release Tag** runs in the protected `release` environment and waits for a maintainer to approve the run. Approve from the run page, or from the PR's checks tab, to mint the tag and publish. Rejecting the approval leaves no tag behind.
+
+That is the only approval a normal release needs. **Release Publish** also declares the `release` environment, but when **Release Tag** dispatches it the deployment is created by `github-actions[bot]` and the reviewer rule is skipped, so it proceeds without a second prompt. The declaration still gates a Release Publish run dispatched by hand, which is the manual fallback path. Treat the Release Tag approval as the release decision — no tag means no publish.
Two guards follow from that. Because the tag is what marks a release finished, **Release PR** refuses to prepare a second one while the last prepared version is still untagged — on a push it says so and stops, and a manual run fails. That covers the approval window: a `fix` merged while **Release Tag** waits would otherwise propose a duplicate PR for the version already on its way out. It also latches when an approval is _rejected_, since that leaves a prepared version that never gets a tag; clear it with the manual fallback below, which both publishes that release and satisfies the check.
@@ -100,34 +89,25 @@ Manual fallback:
1. Tag and push by hand from `main`:
- `git switch main && git pull`
- `git tag -a vX.Y.Z -m vX.Y.Z -s`
- - `git push origin vX.Y.Z`
- A tag pushed this way triggers **Release Publish** directly on tag push.
+ - `git push origin vX.Y.Z` A tag pushed this way triggers **Release Publish** directly on tag push.
1. If needed, run **Release Publish** via `workflow_dispatch` with an existing `tag`.
-> **Note:** Release Tag dispatches Release Publish explicitly rather than relying on the
-> tag push, because a tag pushed with `GITHUB_TOKEN` does not trigger `on: push: tags`.
+> **Note:** Release Tag dispatches Release Publish explicitly rather than relying on the tag push, because a tag pushed with `GITHUB_TOKEN` does not trigger `on: push: tags`.
Signing model:
- Sigstore keyless signatures are generated in CI for every release artifact.
- GPG detached signatures are also generated for compatibility.
-- Release tags are annotated and GPG-signed. When **Release Tag** creates the tag, it is
- signed with the CI release key rather than a maintainer's personal key. The protected
- `release` environment is what keeps that key from being usable by anyone who merges a
- `release/*` PR: the tagging job waits for maintainer approval before it runs.
+- Release tags are annotated and GPG-signed. When **Release Tag** creates the tag, it is signed with the CI release key rather than a maintainer's personal key. The protected `release` environment is what keeps that key from being usable by anyone who merges a `release/*` PR: the tagging job waits for maintainer approval before it runs.
- Required repository secrets for GPG signing in CI:
- `RELEASE_GPG_PRIVATE_KEY` (ASCII-armored private key)
- `RELEASE_GPG_PASSPHRASE` (passphrase for the private key)
-Without both secrets, **Release Tag** and **Release Publish** fail at the GPG import step,
-so no tag is created and no artifacts are published. A GitHub App token does not substitute
-for them: a token authenticates git and API calls but cannot produce a GPG signature, and
-GitHub signs only commits it creates via the API, never annotated tag objects.
+Without both secrets, **Release Tag** and **Release Publish** fail at the GPG import step, so no tag is created and no artifacts are published. A GitHub App token does not substitute for them: a token authenticates git and API calls but cannot produce a GPG signature, and GitHub signs only commits it creates via the API, never annotated tag objects.
##### One-time release key setup
-Run these locally as a maintainer; never paste private key material into an issue, a PR, or
-a chat transcript.
+Run these locally as a maintainer; never paste private key material into an issue, a PR, or a chat transcript.
```bash
# 1. Pick a passphrase and generate a dedicated release key (not your personal key).
@@ -158,12 +138,9 @@ fi
gpg --armor --export
```
-Confirm both secrets landed with `gh secret list`. The key expires in two years; rotate by
-repeating these steps.
+Confirm both secrets landed with `gh secret list`. The key expires in two years; rotate by repeating these steps.
-Neither removal above guarantees the bytes are gone: copy-on-write filesystems and SSD wear
-levelling can leave the export recoverable. Treat the passphrase as the real protection for
-that file, and prefer a passphrase over an empty one for exactly this reason.
+Neither removal above guarantees the bytes are gone: copy-on-write filesystems and SSD wear levelling can leave the export recoverable. Treat the passphrase as the real protection for that file, and prefer a passphrase over an empty one for exactly this reason.
Verification examples: