Files
Pavel LavrukhinandClaude Opus 5 cb53065994 feat(changelog): migrate to Keep a Changelog 1.1.0 (#3506)
* docs(adr): resolve the A-13 collision, zero-pad numbers and add an index

Two records carried the number A-13:

  docs/adr/A-13-expired-gpg-key-handling.md
  docs/adr/A-13-screenshot-build-tag.md

An ADR number is a stable identifier, so a duplicate makes every citation
ambiguous. A-13-expired-gpg-key-handling.md keeps the number: it is cited
from docs/commands/recipients.md, docs/usecases/team-workflows.md and
docs/adr/A-14-team-workflows.md. The screenshot record has no inbound
citations and is renumbered to A-15.

Zero-pad A-3 through A-9 to A-03 through A-09 so the directory sorts
correctly now that the set has passed ten entries. None of these has an
inbound citation from another document; the single reference in
internal/backend/storage/fs/rcs.go is updated in this commit.

Add docs/adr/README.md as the index, recording the naming rules, the status
and authoring date of every record, and three facts that are otherwise only
discoverable from git history:

- A-01 and A-02 are cited from the CHANGELOG unreleased section but no file
  was ever written for either; the numbers stay reserved.
- The A-13 collision and which record was renumbered.
- SECURITY_AUDIT_REPORT.md and CODE_QUALITY_REPORT.md, cited as the Source
  of A-03 through A-10, were removed in 77894053 and are not in the tree.

No record content is changed apart from the H1 lines, which must match the
file names.

Signed-off-by: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: add docs/conventions.md and adopt Conventional Commits

CONTRIBUTING.md required a bracketed [TAG] prefix on every commit subject.
That has not matched practice for some time: of the 344 commit subjects
since 2025-01-01, 225 are Conventional Commits and 67 use a [TAG]. The
CHANGELOG unreleased section uses a third set including [SECURITY] and
[PKG-BREAK], neither of which CONTRIBUTING.md lists.

Replace the [TAG] rule with Conventional Commits and add
docs/conventions.md as the single normative reference for:

- commit types (a closed list) and the scopes derived from the package
  layout, including the five values that appear as types in the history but
  are scopes: otp, age, bug, fscopy, openbsd;
- the distinction between a CLI break, which uses "!" and a
  BREAKING CHANGE: footer and forces a major release, and a break confined
  to pkg/gopass, which uses a PKG-BREAK: footer and does not (ADR A-12);
- Semantic Versioning, and which surfaces it does and does not cover;
- branch and tag names, including the release/ and prep/ prefixes owned by
  the release automation;
- file naming for ADRs, documentation and Go sources.

The Developer Certificate of Origin requirement is unchanged. Conventional
Commits governs the subject line and the DCO adds a trailer, so the two are
independent.

Also correct the API Stability section of ARCHITECTURE.md, which still
described pkg/gopass/doc.go as carrying "an explicit instability warning"
and instructed consumers to "treat any pkg/ type or function change as
potentially breaking". Both statements predate ADR A-12: doc.go now
declares the package best-effort stable and permits additive changes in any
release. The section also referred to issue #3414 as an open decision; that
decision is recorded in A-12 with status accepted.

Extend the folder list in AGENTS.md with the five pkg/ directories it does
not mention (otp, passkey, pinentry/cli, protect, qrcon), using each
package's own doc comment as the description.

Signed-off-by: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(changelog): migrate to Keep a Changelog 1.1.0

CHANGELOG.md used a bespoke format with three overlapping entry conventions:
the bracketed [TAG] prefixes CONTRIBUTING.md mandated, the Conventional
Commit subjects that have been in use since 2025, and a third set in the
unreleased section including [SECURITY] and [PKG-BREAK] that appeared in
neither. Release 1.16.0 contains the first two mixed together.

Adopt Keep a Changelog 1.1.0 and generate the entries from commit subjects.

New package helpers/commitmsg

  Parse and classify commit subjects. It is the single source of truth for
  both the changelog generator and any future commit linting, so the two
  cannot disagree about what a valid subject is. It implements the closed
  type list from docs/conventions.md, the Changelog-Section:, PKG-BREAK:,
  BREAKING CHANGE: and RELEASE_NOTES= footers, and the legacy [TAG] and
  bracketed-type forms so a release spanning the transition still classifies
  its older commits.

  Classify returns a disposition rather than a bare boolean, distinguishing
  a deliberate omission -- a dependency bump, a CI change -- from a subject
  it could not recognise. That distinction matters: over the 137 commits
  since v1.16.1 it classifies 47, omits 60 and cannot recognise 30. Among
  the 30 are real user-facing changes such as "otp: hide --snip flag when
  built with noscreenshot tag", which uses a scope where a type belongs.
  The release helper now prints those subjects instead of dropping them
  silently.

helpers/release: fix the section ordering defect

  writeChangelog inserted the new release before the first "## " heading.
  Once an unreleased section existed, that heading was the unreleased one,
  so the release landed above it and the hand-written entries below were
  orphaned -- never published, and silently carried into every subsequent
  release. The 30 entries currently under "## Next" are in exactly that
  state.

  The new implementation in helpers/release/changelog.go parses the file
  into header, unreleased block, released body and link references; merges
  the hand-written entries with the generated ones and de-duplicates;
  renders the release with only its non-empty subsections, in Keep a
  Changelog order; leaves an empty Unreleased section behind; and rewrites
  the two link references a release changes.

helpers/changelog: skip the unreleased section

  The extractor printed everything between the first and second "## "
  heading. After the migration the first heading is an empty
  "## [Unreleased]", which would have produced empty GitHub release notes.
  It now extracts the first *versioned* section.

CHANGELOG.md data migration

  The 30 entries under "## Next" move into "## [Unreleased]" and are
  distributed by their existing tags. Three are placed by meaning rather
  than by tag, because Keep a Changelog has sections their tags do not:
  the two [UX] entries that remove a CLI alias go to Removed, and the [UX]
  entry about the GOPASS_AUTOSYNC_INTERVAL deprecation goes to Deprecated.
  The bracketed prefixes are dropped; the trailing audit identifiers such
  as (A-1) and (B-8) are kept, since they are the only trace back to the
  audit that produced those entries.

  All 86 released headings become "## [X.Y.Z] - YYYY-MM-DD" with their
  entry text untouched. The two headings that carried no date, 1.10.0 and
  1.10.1, take theirs from their git tags. A link reference block is
  appended; every one of the 86 versions has a matching tag.

  The bullet count is unchanged at 837.

Verified end to end: "go run ./helpers/changelog" against the migrated file
extracts the 1.16.1 section and does not capture [Unreleased].

Signed-off-by: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(release): correct the swapped Next/Prev labels and fold duplicate entries

Two follow-ups found while verifying the changelog migration end to end.

The "Version overview" block printed by every release run labels its third
and fourth values "Next version flag" and "Prev version flag", but passes
them as prevVerFlag, nextVerFlag. Running

    go run helpers/release/main.go --dry-run v1.17.0

therefore reported "Next version flag: ''" and "Prev version flag: '1.17.0'"
for a flag that sets the next version. The values are swapped to match the
labels. Output only; no behaviour depends on it.

Changelog entries are now de-duplicated case-insensitively. A hand-written
unreleased entry and the subject of the commit that implemented it commonly
differ only in their first letter, for example "Add gopass doctor diagnostic
command (I-4)" against "add gopass doctor diagnostic command (I-4)". This
cannot catch two genuinely different wordings of the same change; those
still need a human pass before the release.

Signed-off-by: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr): align A-12 with the Keep a Changelog format

Three statements in A-12 no longer hold once CHANGELOG.md follows Keep a
Changelog:

- The entry goes in "## [Unreleased]", not "## Next".
- "The existing helpers/changelog generator reads CHANGELOG.md verbatim; no
  changes to that tool are needed" is false. The extractor had to learn to
  skip the unreleased section, and the release helper now classifies commits
  rather than copying subjects.
- "[SECURITY], [BUGFIX] and [FEATURE]" are no longer tags. They are the
  Security, Fixed and Added subsections.

State the mechanism instead: a PKG-BREAK: commit footer, which
helpers/commitmsg reads and helpers/release renders as a [PKG-BREAK]-prefixed
bullet inside the appropriate subsection. [PKG-BREAK] survives as a bullet
prefix because it qualifies an entry rather than categorising it, which is
exactly what a Keep a Changelog subsection cannot express.

Writing the prefix by hand into "## [Unreleased]" still works; the release
helper preserves it.

Signed-off-by: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:05:23 +02:00

402 lines
12 KiB
Go

// Copyright 2026 The gopass Authors. All rights reserved.
// Use of this source code is governed by the MIT license,
// that can be found in the LICENSE file.
package commitmsg
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseConventional(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
subject string
want Commit
}{
{
name: "type only",
subject: "fix: avoid NPE when editing a non-existing secret",
want: Commit{
Type: "fix",
Description: "avoid NPE when editing a non-existing secret",
Section: Fixed,
},
},
{
name: "type and scope",
subject: "fix(age): expand ~/ in age.ssh-key-path",
want: Commit{
Type: "fix",
Scope: "age",
Description: "expand ~/ in age.ssh-key-path",
Section: Fixed,
},
},
{
name: "breaking marker",
subject: "feat(show)!: drop the -f alias",
want: Commit{
Type: "feat",
Scope: "show",
Breaking: true,
Description: "drop the -f alias",
Section: Added,
},
},
{
name: "slash in scope",
subject: "refactor(pkg/gopass): drop Store.GetRevision",
want: Commit{
Type: "refactor",
Scope: "pkg/gopass",
Description: "drop Store.GetRevision",
Section: Changed,
},
},
{
name: "dependabot subject",
subject: "chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#3485)",
want: Commit{
Type: "chore",
Scope: "deps",
Description: "bump actions/checkout from 6.0.3 to 7.0.0 (#3485)",
Section: Omit,
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, ok := Parse(tc.subject)
require.True(t, ok, tc.subject)
assert.Equal(t, tc.want, got)
})
}
}
func TestParseLegacy(t *testing.T) {
t.Parallel()
got, ok := Parse("[BUGFIX] reorg: List all secrets instead of just top-level folders (#3245)")
require.True(t, ok)
assert.True(t, got.Legacy)
assert.Equal(t, Fixed, got.Section)
assert.Equal(t, "reorg: List all secrets instead of just top-level folders (#3245)", got.Description)
assert.Empty(t, got.Type)
}
func TestParseRejects(t *testing.T) {
t.Parallel()
// These appear as commit types in the history but are scopes, so they must
// not parse as Conventional Commits.
for _, subject := range []string{
"otp: hide --snip flag when built with noscreenshot tag (#3445)",
"age: fix YubiKey identity persistence via raw-append (ADR-0002) (#3399)",
"bug: reload identities on unlock command (#3430)",
"fscopy: derive copy direction from the source argument (#3462)",
"openbsd: something",
"[NOSUCHTAG] whatever",
"Improve Team Workflows (#3460)",
"",
} {
_, ok := Parse(subject)
assert.False(t, ok, subject)
}
}
func TestText(t *testing.T) {
t.Parallel()
c, ok := Parse("fix(age): expand ~/ in age.ssh-key-path")
require.True(t, ok)
assert.Equal(t, "age: expand ~/ in age.ssh-key-path", c.Text())
c, ok = Parse("fix: restore clip flag through fuzzy search")
require.True(t, ok)
assert.Equal(t, "restore clip flag through fuzzy search", c.Text())
}
func TestClassify(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
subject string
body string
want Entry
wantOK bool
wantDisp Disposition
}{
{
name: "feat to Added",
subject: "feat(show): add configurable fuzzy-search fallback toggle",
want: Entry{Section: Added, Text: "show: add configurable fuzzy-search fallback toggle"},
wantOK: true,
},
{
name: "fix to Fixed",
subject: "fix: restore clip flag through fuzzy search",
want: Entry{Section: Fixed, Text: "restore clip flag through fuzzy search"},
wantOK: true,
},
{
name: "security to Security",
subject: "security: bound symlink traversal to the store root",
want: Entry{Section: Security, Text: "bound symlink traversal to the store root"},
wantOK: true,
},
{
name: "refactor to Changed",
subject: "refactor(store): split the leaf recipient handling",
want: Entry{Section: Changed, Text: "store: split the leaf recipient handling"},
wantOK: true,
},
{
name: "deps to Changed",
subject: "deps: bump golang.org/x/crypto to 0.54.0",
want: Entry{Section: Changed, Text: "bump golang.org/x/crypto to 0.54.0"},
wantOK: true,
},
{
name: "docs omitted",
wantDisp: Omitted,
subject: "docs(adr): add the MCP server record",
wantOK: false,
},
{
name: "chore omitted",
wantDisp: Omitted,
subject: "chore(deps): bump actions/checkout from 6.0.3 to 7.0.0",
wantOK: false,
},
{
name: "ci omitted",
wantDisp: Omitted,
subject: "ci: bump golangci-lint to v2.12.2",
wantOK: false,
},
{
name: "breaking marker prefixes the text",
subject: "feat(show)!: drop the -f alias",
want: Entry{Section: Added, Text: "**BREAKING** show: drop the -f alias"},
wantOK: true,
},
{
name: "breaking footer prefixes the text",
subject: "feat(show): drop the -f alias",
body: "BREAKING CHANGE: -f no longer aliases --unsafe\n",
want: Entry{Section: Added, Text: "**BREAKING** show: drop the -f alias"},
wantOK: true,
},
{
name: "hyphenated breaking footer is accepted",
subject: "feat(show): drop the -f alias",
body: "BREAKING-CHANGE: -f no longer aliases --unsafe\n",
want: Entry{Section: Added, Text: "**BREAKING** show: drop the -f alias"},
wantOK: true,
},
{
name: "pkg break prefixes the text without forcing a major",
subject: "refactor(pkg/gopass): drop Store.GetRevision",
body: "PKG-BREAK: pkg/gopass: remove Store.GetRevision, use Store.History\n",
want: Entry{Section: Changed, Text: "[PKG-BREAK] pkg/gopass: drop Store.GetRevision"},
wantOK: true,
},
{
name: "pkg break forces an entry for an otherwise omitted type",
subject: "chore(pkg/gopass): tidy up",
body: "PKG-BREAK: pkg/gopass: remove Store.Foo\n",
want: Entry{Section: Changed, Text: "[PKG-BREAK] pkg/gopass: tidy up"},
wantOK: true,
},
{
name: "section footer overrides the type",
subject: "refactor(hook): delete the dead hook system",
body: "Changelog-Section: Removed\n",
want: Entry{Section: Removed, Text: "hook: delete the dead hook system"},
wantOK: true,
},
{
name: "section footer reaches Deprecated",
subject: "refactor(env): mark GOPASS_AUTOSYNC_INTERVAL as going away",
body: "Changelog-Section: Deprecated\n",
want: Entry{Section: Deprecated, Text: "env: mark GOPASS_AUTOSYNC_INTERVAL as going away"},
wantOK: true,
},
{
name: "section footer rescues an omitted type",
subject: "chore(env): drop the legacy variable",
body: "Changelog-Section: Removed\n",
want: Entry{Section: Removed, Text: "env: drop the legacy variable"},
wantOK: true,
},
{
name: "invalid section footer falls back to the type",
subject: "fix(env): correct the lookup order",
body: "Changelog-Section: Nonsense\n",
want: Entry{Section: Fixed, Text: "env: correct the lookup order"},
wantOK: true,
},
{
name: "release notes override the text",
subject: "fix: something terse",
body: "RELEASE_NOTES=A much better description\n",
want: Entry{Section: Fixed, Text: "A much better description"},
wantOK: true,
},
{
name: "release notes n/a suppresses the entry",
wantDisp: Omitted,
subject: "feat(show): add a thing",
body: "RELEASE_NOTES=n/a\n",
wantOK: false,
},
{
name: "release notes rescue an unparseable subject",
subject: "Improve Team Workflows (#3460)",
body: "RELEASE_NOTES=Improve team workflows\n",
want: Entry{Section: Changed, Text: "Improve team workflows"},
wantOK: true,
},
{
name: "revert is prefixed",
subject: "revert: undo the fuzzy search toggle",
want: Entry{Section: Changed, Text: "Revert: undo the fuzzy search toggle"},
wantOK: true,
},
{
name: "legacy BUGFIX to Fixed",
subject: "[BUGFIX] reorg: List all secrets instead of just top-level folders",
want: Entry{Section: Fixed, Text: "reorg: List all secrets instead of just top-level folders"},
wantOK: true,
},
{
name: "legacy SECURITY to Security",
subject: "[SECURITY] Fix path traversal vulnerability in fs storage layer (C-1)",
want: Entry{Section: Security, Text: "Fix path traversal vulnerability in fs storage layer (C-1)"},
wantOK: true,
},
{
name: "legacy DOCUMENTATION omitted",
wantDisp: Omitted,
subject: "[DOCUMENTATION] Fix documentation vs. implementation mismatches",
wantOK: false,
},
{
name: "legacy BREAKING is prefixed",
subject: "[BREAKING] Remove the old config format",
want: Entry{Section: Changed, Text: "**BREAKING** Remove the old config format"},
wantOK: true,
},
{
name: "unrecognised subject produces nothing",
wantDisp: Unrecognised,
subject: "Improve Team Workflows (#3460)",
wantOK: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, disp := Classify(tc.subject, tc.body)
assert.Equal(t, tc.wantOK, disp == Include, tc.subject)
if !tc.wantOK {
assert.Equal(t, tc.wantDisp, disp, tc.subject)
return
}
assert.Equal(t, tc.want, got)
})
}
}
// TestEveryTypeHasASection guards against adding a type to docs/conventions.md
// without deciding where its entries go.
func TestEveryTypeHasASection(t *testing.T) {
t.Parallel()
want := []string{
"build", "chore", "ci", "deps", "docs", "feat",
"fix", "perf", "refactor", "revert", "security", "test",
}
assert.Len(t, Types, len(want))
for _, ty := range want {
sec, ok := Types[ty]
require.True(t, ok, ty)
if sec == Omit {
continue
}
assert.Contains(t, Sections, sec, ty)
}
}
func TestSectionsAreKeepAChangelogOrder(t *testing.T) {
t.Parallel()
assert.Equal(
t,
[]Section{Added, Changed, Deprecated, Removed, Fixed, Security},
Sections,
)
}
// TestParseBracketedType covers the hybrid form found in the history, which
// puts a Conventional Commit type in brackets rather than a legacy tag.
func TestParseBracketedType(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
subject string
wantSec Section
wantTxt string
}{
{"[fix] Support HW Age identities (#3389)", Fixed, "Support HW Age identities (#3389)"},
{"[feat] Add --safe flag to set safecontent on demand (#3318)", Added, "Add --safe flag to set safecontent on demand (#3318)"},
{"[chore] Update gopasspw/clipboard (#3436)", Omit, ""},
} {
c, ok := Parse(tc.subject)
require.True(t, ok, tc.subject)
assert.Equal(t, tc.wantSec, c.Section, tc.subject)
e, disp := Classify(tc.subject, "")
if tc.wantSec == Omit {
assert.Equal(t, Omitted, disp, tc.subject)
continue
}
require.Equal(t, Include, disp, tc.subject)
assert.Equal(t, tc.wantTxt, e.Text)
}
}
// TestUnrecognisedIsReported guards the distinction that keeps real changes
// from vanishing: a scope written where a type belongs must be reported, not
// silently dropped like an intentional omission.
func TestUnrecognisedIsReported(t *testing.T) {
t.Parallel()
for _, subject := range []string{
"otp: hide --snip flag when built with noscreenshot tag (#3445)",
"fscopy: derive copy direction from the source argument (#3462)",
"Improve Team Workflows (#3460)",
} {
_, disp := Classify(subject, "")
assert.Equal(t, Unrecognised, disp, subject)
}
// An intentional omission must not be reported.
_, disp := Classify("chore(deps): bump actions/checkout from 6.0.3 to 7.0.0", "")
assert.Equal(t, Omitted, disp)
}