Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ git merge --no-ff feature-branch

## Release

- Releases use **Release PR** then **Release Publish** workflows; artifacts are signed with Sigstore and GPG.
- See [CONTRIBUTING](CONTRIBUTING.md#creating-a-release) for steps and verification. Do not change `scripts/release/` or release workflows without checking CONTRIBUTING.
- Releases run **Release PR** -> **Release Tag** -> **Release Publish**; **Release Tag** waits on the protected `release` environment for maintainer approval. Artifacts are signed with Sigstore and GPG.
- See [RELEASING](../RELEASING.md) for steps and verification. Do not change `scripts/release/` or release workflows without checking it.

## Boundaries

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ git cliff --tag v1.0.0 --output CHANGELOG.md
- Scripts: `scripts/release/build-artifacts.sh`, `scripts/release/sign-artifacts.sh`, `scripts/release/parse-version.sh`, `scripts/release/stamp-changelog.sh`, `scripts/update-unreleased.sh`.
- Two changelog scripts, and they are not interchangeable. `update-unreleased.sh` refreshes the Unreleased section and belongs to the weekly autoupdate. `release/stamp-changelog.sh` writes the pending version as its own `## [X.Y.Z]` section and is what a release must use: once the tag exists, `git cliff --unreleased` no longer reports the commits it covers, so anything that only ever lived under Unreleased is dropped by the next refresh. That is how v0.1.0 shipped without a changelog section and nearly took 134 lines of history with it.
- All three workflows validate versions through `scripts/release/parse-version.sh`; it is covered by `tests/test-parse-version.sh`. Do not replace it with a `case` glob such as `v[0-9]*.[0-9]*.[0-9]*`, which also matches `v1.0.0; rm -rf /`.
- Full steps and verification commands: see [CONTRIBUTING](CONTRIBUTING.md#creating-a-release).
- Full steps and verification commands: see [RELEASING](RELEASING.md).

## Testing

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ See @README.md for project overview and features.
- `.pre-commit-hooks.yaml` - Hook definitions for pre-commit framework
- `cliff.toml` - Configuration for git-cliff changelog generation
- `tests/` - Hook tests and test utilities (including `tests/test-unit.sh`)
- `scripts/release/` - Release artifact build and signing, plus `parse-version.sh` (shared strict `vX.Y.Z` validation used by all release workflows); `stamp-changelog.sh` writes the pending version as its own changelog section during a release, while `scripts/update-unreleased.sh` only refreshes Unreleased for the weekly autoupdate — a release must use the former, or entries that never got a versioned section are dropped by the next refresh. Release process and verification: see CONTRIBUTING.
- `scripts/release/` - Release artifact build and signing, plus `parse-version.sh` (shared strict `vX.Y.Z` validation used by all release workflows); `stamp-changelog.sh` writes the pending version as its own changelog section during a release, while `scripts/update-unreleased.sh` only refreshes Unreleased for the weekly autoupdate — a release must use the former, or entries that never got a versioned section are dropped by the next refresh. Release process and verification: see RELEASING.md.

## Code Style

Expand Down
86 changes: 3 additions & 83 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Welcome! We're happy to have you here. All types of contributions are encouraged

See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. We look forward to your contributions!

The project has defined a [code of conduct](CODE_OF_CONDUCT.md) to ensure a welcoming and friendly environment. Please adhere to it in all interactions.
The project has defined a [code of conduct](https://github.com/michen00/.github/blob/main/CODE_OF_CONDUCT.md) to ensure a welcoming and friendly environment. Please adhere to it in all interactions.

<!-- omit in toc -->

Expand All @@ -18,6 +18,7 @@ The project has defined a [code of conduct](CODE_OF_CONDUCT.md) to ensure a welc
- [How do I submit a good enhancement suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
- [Your first code contribution](#your-first-code-contribution)
- [Contribution workflow](#contribution-workflow)
- [Creating a release](#creating-a-release)
- [Recommended VSCode extensions](#recommended-vscode-extensions)

## I want to contribute
Expand Down Expand Up @@ -69,88 +70,7 @@ Using the web-based interface to make changes is fine too, and will help you by

#### Creating a release

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.

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.

And if the version moves while a release PR is open — a `feat` landing on top of a pending patch — the next run opens a PR for the new version and closes the superseded one. **Release Tag** reads the version it mints from the `release/*` branch name, so leaving the stale PR open would leave a merge path that tags the wrong version.

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.
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`.

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.
- 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.

##### 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.

```bash
# 1. Pick a passphrase and generate a dedicated release key (not your personal key).
PASSPHRASE='<choose-a-strong-passphrase>'
gpg --batch --passphrase "$PASSPHRASE" \
--quick-generate-key 'custom-commit-hooks release <[email protected]>' rsa4096 sign 2y

# 2. Note the fingerprint of the key you just made.
gpg --list-secret-keys --keyid-format=long

# 3. Export the private key, ASCII-armored.
gpg --armor --export-secret-keys <FINGERPRINT> >release-key.asc

# 4. Store both secrets on the repository.
gh secret set RELEASE_GPG_PRIVATE_KEY <release-key.asc
gh secret set RELEASE_GPG_PASSPHRASE --body "$PASSPHRASE"

# 5. Remove the local export. shred is GNU coreutils and absent on macOS;
# BSD rm -P is rejected by GNU rm, so branch instead of assuming either.
if command -v shred >/dev/null 2>&1; then
shred -u release-key.asc
else
rm -f release-key.asc
fi

# 6. Optional: register the public key so signed tags display as Verified on GitHub.
# Paste the output at https://github.com/settings/gpg/new
gpg --armor --export <FINGERPRINT>
```

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.

Verification examples:

```bash
# Sigstore
cosign verify-blob --signature artifact.sig --certificate artifact.pem --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/.+' artifact

# GPG
gpg --verify artifact.asc artifact
```
Releases are cut by maintainers and are automated end to end except for a single approval step. See [RELEASING](RELEASING.md) for the release flow, the signing model, one-time release key setup, and verification commands.

### Recommended VSCode extensions

Expand Down
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ release-pr: ## Trigger Release PR workflow (usage: make release-pr [VERSION=1.2.
release-pr-watch: ## Watch the latest workflow run (run after make release-pr)
@gh run watch

.PHONY: release-status
release-status: ## Show latest release, any open release PR, and any run awaiting approval
@sh scripts/release/approve.sh --status

.PHONY: release-approve
release-approve: ## Approve the waiting release (usage: make release-approve [YES=1] to skip the prompt)
@if [ -n "$(YES)" ]; then \
sh scripts/release/approve.sh --yes; \
else \
sh scripts/release/approve.sh; \
fi

.PHONY: enable-pre-commit
enable-pre-commit: ## Enable pre-commit hooks (along with commit-msg and pre-push hooks)
@if command -v pre-commit >/dev/null 2>&1; then \
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,4 @@ chore: merge branch 'feature/new-api' into main

## Documentation: [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/michen00/custom-commit-hooks)

Releases are signed (Sigstore + GPG). For verification commands and release process, see [CONTRIBUTING](CONTRIBUTING.md#creating-a-release).
Releases are signed (Sigstore + GPG). For verification commands and release process, see [RELEASING](RELEASING.md).
Loading
Loading