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
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
- name: Set up current Go version
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.22.x'
go-version: '1.26.5'
cache: true
- name: Verify required tools
run: |
Expand All @@ -40,3 +41,10 @@ jobs:
if-no-files-found: error
- name: Verify shell syntax
run: bash -n scripts/*.sh
- name: Set up supported minimum Go version
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.22.12'
cache: true
- name: Build and test the supported minimum Go version
run: go test ./cmd/patchlog ./internal/... ./pkg/... -count=1
57 changes: 49 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ on:
required: true
type: string

permissions:
contents: write
id-token: write
attestations: write

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
outputs:
tag: ${{ steps.release.outputs.tag }}
tag_commit: ${{ steps.release.outputs.tag_commit }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -41,8 +43,8 @@ jobs:
release_tag="${PUSH_TAG}"
fi

if [[ ! "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
echo "invalid release tag: ${release_tag}" >&2
if [[ ! "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "release workflow accepts only stable vMAJOR.MINOR.PATCH tags: ${release_tag}" >&2
exit 1
fi

Expand All @@ -67,9 +69,10 @@ jobs:
fi

echo "tag=${release_tag}" >> "${GITHUB_OUTPUT}"
echo "tag_commit=${tag_commit}" >> "${GITHUB_OUTPUT}"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.22.x'
go-version: '1.26.5'
cache: true
- name: Run required quality gate
run: bash scripts/gate.sh
Expand All @@ -93,6 +96,18 @@ jobs:
done
cd dist
sha256sum *.tar.gz > SHA256SUMS
- name: Render package-manager manifests
env:
VERSION: ${{ steps.release.outputs.tag }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail

bash scripts/render-package-manifests.sh
(
cd dist
sha256sum patchlog.rb patchlog.json >> SHA256SUMS
)
- name: Verify installation artifact and checksums
env:
VERSION: ${{ steps.release.outputs.tag }}
Expand Down Expand Up @@ -125,6 +140,8 @@ jobs:
"dist/patchlog_${RELEASE_TAG}_darwin_arm64.tar.gz"
"dist/patchlog_${RELEASE_TAG}_windows_amd64.tar.gz"
"dist/SHA256SUMS"
"dist/patchlog.rb"
"dist/patchlog.json"
)
for asset in "${assets[@]}"; do
if [ ! -f "${asset}" ]; then
Expand All @@ -133,3 +150,27 @@ jobs:
fi
done
gh release create "${RELEASE_TAG}" "${assets[@]}" --verify-tag --generate-notes

verify-release:
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
attestations: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.26.5'
cache: false
- name: Verify the published release trust loop
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
REPOSITORY: ${{ github.repository }}
SOURCE_DIGEST: ${{ needs.release.outputs.tag_commit }}
VERIFY_SOURCE_IDENTITY: ${{ github.event_name == 'push' }}
VERIFY_MODULE_INSTALLS: ${{ github.event_name == 'push' }}
run: bash scripts/verify-release.sh
40 changes: 30 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
# Patchlog

Patchlog turns Git history into release notes and can safely coordinate version bumps, tags, atomic pushes, provider releases, changelogs, and Confluence pages.
Patchlog is a safe release coordinator for Git repositories. It turns commit
history into a deterministic release plan, validates every local and remote
action before mutation, then transactionally bumps, tags, and atomically pushes.

## Safe release in two commands

From a clean repository with a version file:

```bash
# Immutable planning and preflight: no files, refs, caches, or remotes change.
patchlog release --dry-run

# Apply the same safe core workflow: auto-bump, annotated tag, atomic push.
patchlog release
```

The default path does not call AI, publish a provider release, write a
changelog, or contact Confluence. Add those extensions only when you need them.

## Install

Expand All @@ -13,26 +30,29 @@ patchlog --version

Release archives and `SHA256SUMS` are published on the [GitHub releases page](https://github.com/fxdv/patchlog/releases). Archives also receive signed Sigstore provenance attestations, verifiable with `gh attestation verify <archive> --repo fxdv/patchlog`.

## Quick start
Homebrew is the first planned package-manager channel, followed by Scoop.
Checksum-pinned manifests are already produced by the release workflow, but the
tap and bucket are not advertised as live until their external repositories and
automated installation checks are operational.

## Read-only notes

```bash
# Generate Markdown release notes without mutating the repository.
patchlog --from v0.1.0 --to HEAD

# Inspect the complete release plan without changing local or remote state.
patchlog release --bump auto --tag --push --publish --dry-run

# Apply the reviewed plan. Provider configuration is required for --publish.
patchlog release --bump auto --tag --push --publish
```

Publishing is intentionally strict: `--publish` requires `--tag --push`, and Patchlog verifies that the remote tag resolves to the local release commit before creating the provider release.
Plain `patchlog` is reporting-only. Provider releases, changelogs, AI-assisted
writing, Confluence analytics, metrics, and experimental labs are optional
advanced workflows. Publishing remains strict: `--publish` requires an
immutable, remotely verified tag.

## Documentation

- [Quick start](docs/QUICK_START.md)
- [Configuration and security](docs/CONFIGURATION.md)
- [Advanced workflows](docs/ADVANCED_WORKFLOWS.md)
- [0.2.0 stability contract](docs/STABILITY.md)
- [Package-manager publishing](packaging/README.md)
- [Complete CLI and feature reference](docs/REFERENCE.md)
- [Security policy](SECURITY.md)

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.2
0.1.3
45 changes: 45 additions & 0 deletions cmd/patchlog/actionable_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"errors"
"fmt"
"io"
)

type hintedError struct {
err error
hint string
}

func (e *hintedError) Error() string { return e.err.Error() }

func (e *hintedError) Unwrap() error { return e.err }

func withHint(err error, hint string) error {
if err == nil {
return nil
}
return &hintedError{err: err, hint: hint}
}

func errorHint(err error) string {
var hinted *hintedError
if errors.As(err, &hinted) {
return hinted.hint
}
return ""
}

func writeConfigError(out io.Writer, path string, err error) {
fmt.Fprintf(out, "Configuration error in %s:\n %v\n", path, err)
fmt.Fprintln(out, "Next: run `patchlog init` to create a valid configuration, or fix the field above and retry `patchlog release --dry-run`.")
}

func writeReleasePlanError(out io.Writer, err error) {
fmt.Fprintf(out, "Release plan error:\n %v\n", err)
hint := errorHint(err)
if hint == "" {
hint = "fix the error above, then rerun `patchlog release --dry-run` before applying"
}
fmt.Fprintf(out, "Next: %s.\n", hint)
}
31 changes: 31 additions & 0 deletions cmd/patchlog/actionable_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"bytes"
"errors"
"strings"
"testing"
)

func TestWriteConfigErrorIncludesRecoveryCommand(t *testing.T) {
var out bytes.Buffer
writeConfigError(&out, "patchlog.yaml", errors.New("field provider.tokne not found"))
got := out.String()
for _, want := range []string{"patchlog.yaml", "provider.tokne", "patchlog init", "patchlog release --dry-run"} {
if !strings.Contains(got, want) {
t.Fatalf("error output %q does not contain %q", got, want)
}
}
}

func TestWriteReleasePlanErrorUsesSpecificHint(t *testing.T) {
var out bytes.Buffer
err := withHint(errors.New("missing provider.token"), "set `provider.token` and rerun `patchlog release --dry-run`")
writeReleasePlanError(&out, err)
got := out.String()
for _, want := range []string{"missing provider.token", "set `provider.token`", "patchlog release --dry-run"} {
if !strings.Contains(got, want) {
t.Fatalf("error output %q does not contain %q", got, want)
}
}
}
35 changes: 29 additions & 6 deletions cmd/patchlog/cli_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,44 @@ func parseCLI(args []string, stderr io.Writer) (cliOptions, []string, error) {
fs.BoolVar(&opts.infer, "infer", false, "Use AI to infer conventional commit types for uncategorized commits")
fs.BoolVar(&opts.semantic, "semantic", false, "Use AI to generate semantic summaries of actual code diffs")
fs.BoolVar(&opts.drift, "drift", false, "Compare planned Jira tickets vs delivered (plan-vs-actual)")
fs.BoolVar(&opts.gamify, "gamify", false, "Add experimental contributor achievements (requires --labs)")
fs.BoolVar(&opts.gamify, "gamify", false, "[experimental] Add contributor achievements (requires --labs)")
fs.BoolVar(&opts.html, "html", false, "Generate standalone HTML report with charts")
fs.BoolVar(&opts.labs, "labs", false, "Enable experimental people analytics and gamification")
fs.BoolVar(&opts.labs, "labs", false, "[experimental] Enable DPI, health signals, people analytics, and gamification")
fs.BoolVar(&opts.deps, "deps", false, "Detect dependency version bumps and fetch upstream changelogs")
fs.Usage = func() { printCLIUsage(fs, stderr) }
if err := fs.Parse(args); err != nil {
return cliOptions{}, nil, err
}
applySafeReleaseDefaults(&opts, fs)
return opts, fs.Args(), nil
}

func applySafeReleaseDefaults(opts *cliOptions, fs *flag.FlagSet) {
if opts == nil || !opts.releaseMode {
return
}
explicitReleaseAction := false
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "bump", "tag", "push", "publish", "confluence", "changelog", "trends":
explicitReleaseAction = true
}
})
if explicitReleaseAction {
return
}
opts.bumpLevel = "auto"
opts.tag = true
opts.push = true
}

func printCLIUsage(fs *flag.FlagSet, out io.Writer) {
printBanner()
fmt.Fprintln(out, "\nUsage: patchlog [flags]")
fmt.Fprintln(out, "\nUsage:")
fmt.Fprintln(out, " patchlog [flags] Generate release notes without mutations")
fmt.Fprintln(out, " patchlog release [flags] Safely bump, tag, and atomically push")
fmt.Fprintln(out, "\nSubcommands:")
fmt.Fprintln(out, " patchlog release Plan, review, gate, and apply release mutations")
fmt.Fprintln(out, " patchlog release Safe release; defaults to --bump auto --tag --push")
fmt.Fprintln(out, " patchlog init Interactive setup wizard")
fmt.Fprintln(out, " patchlog lint Lint commits against conventional commit standards")
fmt.Fprintln(out, " patchlog audit Audit changelog against git history")
Expand All @@ -114,7 +136,8 @@ func printCLIUsage(fs *flag.FlagSet, out io.Writer) {
fmt.Fprintln(out, "\nFlags:")
fs.PrintDefaults()
fmt.Fprintln(out, "\nExamples:")
fmt.Fprintln(out, " patchlog release --dry-run Plan and preflight the safe default release")
fmt.Fprintln(out, " patchlog release Apply the reviewed default release")
fmt.Fprintln(out, " patchlog --from v1.0.0 --to v1.1.0")
fmt.Fprintln(out, " patchlog release --bump auto --tag --push")
fmt.Fprintln(out, " patchlog release --bump auto --tag --push --publish")
fmt.Fprintln(out, " patchlog release --bump minor --tag --push --publish")
}
65 changes: 65 additions & 0 deletions cmd/patchlog/cli_options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package main

import (
"io"
"testing"
)

func TestParseCLIReleaseDryRunAppliesSafeDefaults(t *testing.T) {
opts, args, err := parseCLI([]string{"release", "--dry-run"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if len(args) != 0 {
t.Fatalf("args = %v, want none", args)
}
if !opts.releaseMode || !opts.dryRun {
t.Fatalf("releaseMode=%v dryRun=%v, want both true", opts.releaseMode, opts.dryRun)
}
if opts.bumpLevel != "auto" || !opts.tag || !opts.push {
t.Fatalf("safe release defaults = bump %q, tag=%v, push=%v", opts.bumpLevel, opts.tag, opts.push)
}
if opts.publish || opts.confluence || opts.aiEnhance || opts.metrics || opts.labs {
t.Fatal("safe release defaults enabled an optional extension")
}
}

func TestParseCLIReleaseApplyAppliesSafeDefaults(t *testing.T) {
opts, _, err := parseCLI([]string{"release"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if opts.bumpLevel != "auto" || !opts.tag || !opts.push {
t.Fatalf("safe release defaults = bump %q, tag=%v, push=%v", opts.bumpLevel, opts.tag, opts.push)
}
}

func TestParseCLIExplicitReleaseActionPreservesUserSelection(t *testing.T) {
opts, _, err := parseCLI([]string{"release", "--bump", "minor"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if opts.bumpLevel != "minor" || opts.tag || opts.push {
t.Fatalf("explicit release selection changed: bump %q, tag=%v, push=%v", opts.bumpLevel, opts.tag, opts.push)
}
}

func TestParseCLIForceModifiesSafeDefaultsInsteadOfSelectingAnEmptyWorkflow(t *testing.T) {
opts, _, err := parseCLI([]string{"release", "--force"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if opts.bumpLevel != "auto" || !opts.tag || !opts.push || !opts.force {
t.Fatalf("forced safe release = bump %q, tag=%v, push=%v, force=%v", opts.bumpLevel, opts.tag, opts.push, opts.force)
}
}

func TestParseCLINoteGenerationDoesNotApplyReleaseDefaults(t *testing.T) {
opts, _, err := parseCLI(nil, io.Discard)
if err != nil {
t.Fatal(err)
}
if opts.releaseMode || opts.bumpLevel != "" || opts.tag || opts.push {
t.Fatalf("read-only defaults changed: release=%v, bump=%q, tag=%v, push=%v", opts.releaseMode, opts.bumpLevel, opts.tag, opts.push)
}
}
Loading
Loading