Skip to content
remeris
Esc
↑↓navigate↵open⌘Jpreview
On this page

Remeris: spec for a Go reimplementation of Knope

The specification for remeris, a Go reimplementation of Knope: milestones, feature behaviour, the quirks register and the design decisions.

Source analysed: knope-dev/knope at af540f3, Knope CLI 0.23.1, knope-versioning 0.8.x, knope-config 0.4.x, plus the changesets crate it depends on.

Scope:

  • Phase 1 (parity): rebuild Knope’s features in Go, one at a time (milestones M0–M12; M0–M11 are parity). Two foundations go beyond Knope from the start because the rest builds on them: full SemVer 2.0.0 (F1) and extension hooks for Phase 2.
  • Phase 2 (extensions): pre-release channels, signed commits (git or GitHub App), more changelog sources, and better monorepo support with per-package variables. See Phase 2.

Goal: Remeris supports multiple release configurations. The first one is Knope’s, and Knope is a peer of the configurations that follow, not a legacy format.

Goal of Phase 1: Rebuild Knope in Go, one feature at a time. On Knope’s configuration the Go binary (remeris) should be a drop-in replacement: it reads the same knope.toml, uses the same .changeset/ files and Git tag formats, writes the same file edits and changelog output, and prints the same messages. Each section below is one unit of work. It lists the behaviour to reproduce, the algorithm (taken from the Rust source, not only the docs), edge cases, and acceptance criteria.

Conventions in this document

  • MUST: required for compatibility with Knope.
  • Quirk: surprising Knope behaviour found in the source. Each one says whether to keep it or change it.
  • path/to/file.rs refers to the Knope repo. crates/knope/ is the CLI, crates/knope-versioning/ is the core versioning library, and crates/knope-config/ holds the config types shared with Knope Bot.

Table of contents

  1. Architecture and Go project layout
  2. Milestone plan (feature order)
  3. F1: Semantic versions
  4. F2: Config file (knope.toml)
  5. F3: CLI, run modes, logging, errors
  6. F4: Workflows, state and variables
  7. F5: Command step
  8. F6: Versioned files (per format)
  9. F7: Packages and version resolution
  10. F8: BumpVersion step
  11. F9: Git integration
  12. F10: Conventional commits
  13. F11: Changesets and the CreateChangeFile step
  14. F12: Release notes and changelogs
  15. F13: PrepareRelease step
  16. F14: Release step (Git tags)
  17. F15: HTTP client and credentials
  18. F16: GitHub integration
  19. F17: Gitea integration
  20. F18: CreatePullRequest step
  21. F19: Issue steps (GitHub / Gitea / Jira / branch)
  22. F20: Default config, auto-detection and --generate
  23. F21: --upgrade and --validate
  24. Phase 2: Extensions beyond Knope
  25. Testing strategy
  26. Quirks register
  27. Decisions

0. Architecture and Go project layout

0.1 How Knope is built

main ─► config.Load() ─► ConfigSource{Default|File|Hybrid}
     ─► build CLI from config (workflows become subcommands)
     ─► create State (packages loaded, versioned files parsed, git tags read)
     ─► run Workflow: for each Step: state = step.Run(RunType[State])
  • Core library (knope-versioning): semver, versioned files, packages, changes, release notes, and the Actions produced. It does no I/O: it takes file contents and tag lists as input and returns Actions (WriteToFile, RemoveFile, AddTag, CreateRelease).
  • CLI (knope): config, steps, Git, HTTP forges, prompts. It runs the Actions, either for real or as a dry run.
  • RunType: every step gets DryRun(state) or Real(state). A dry run must do no writes, no network calls and no external commands. It logs what it would have done instead.

Keep this split in Go. It keeps the core easy to unit-test with no filesystem or network.

0.2 Proposed Go layout

cmd/remeris/main.go            # entry point
internal/app/                  # readykit pattern: GlobalOptions/WorkflowOptions, DefaultXxx(), LoadConfig[T] (F3.1)
internal/cli/                  # command tree built from config, flags, run/validate/generate/upgrade
internal/config/               # knope.toml loading, defaults, discovery, serialisation (generate/upgrade)
internal/workflow/             # Workflow, Step (tagged union), runner, State, RunType
internal/steps/                # one file per step
internal/variables/            # Template + Variable substitution
internal/git/                  # Git interface + implementation
internal/forge/github/         # go-github client, auth (token or GitHub App), releases, PRs, issues
internal/forge/gitea/          # Gitea client on net/http (leaf): header auth, hardened host, releases, PRs, issues
internal/forge/jira/            # Jira client on net/http (leaf): Basic auth, hardened url, issues and transitions
internal/httpx/                # client with retry/backoff/rate-limit logging, ApiRequestError
internal/credentials/          # token/email store in user config dir, prompting
internal/prompt/               # select, multiselect, text, password
internal/fsx/                  # dry-run aware write/remove/mkdir
internal/signing/              # Phase 2 (E2): signed commits/tags via git or the GitHub API
pkg/versioning/                # PURE core (the knope-versioning equivalent)
    semver/                    # full SemVer 2.0.0 Version, Rule, PackageVersions
    changes/                   # Change, ChangeType, Source interface (E3)
    changes/conventional/      # conventional commit parsing (a Source)
    changesets/                # .changeset file format (the changesets crate equivalent)
    releasenotes/              # Sections, ChangeTemplate, Changelog, Release
    versionedfile/             # one file per format + Config/Format
    package.go                 # Package, ChangeConfig, apply changes → []Action
    action.go                  # Action, ReleaseTag

0.3 Go version and dependencies

Go version: go 1.27 in go.mod (the latest toolchain is 1.27.1). Decided (2026-10-02): Go 1.27 is the minimum from day one. Go 1.27 is the first release where encoding/json/v2 and encoding/json/jsontext are generally available, and the spec relies on them. It also covers (?<name>…) regex groups (Go ≥ 1.22) and hujson’s minimum of Go 1.26.

How dependencies were chosen: prefer the standard library. Add a third-party module only when it has had a release in the last ~12 months and is not archived or marked unmaintained. CLI and config use the libraries chosen by the project: urfave/cli v3 and koanf v2. Versions were checked against proxy.golang.org on 2026-10-02.

Need Knope uses Go choice Latest release (checked 2026-10-02) Notes
CLI clap github.com/urfave/cli/v3 v3.14.0, 2026-10-02 Workflows become []*cli.Command, built at runtime from the config. See F3.1.
CLI flags → typed options — github.com/knadh/koanf/providers/cliflagv3 v1.1.1, 2026-03-20 The readykit pattern (D9): flags are loaded into koanf and unmarshalled into option structs. See F3.1.
Config loading toml (serde) github.com/knadh/koanf/v2 + providers/file + parsers/toml/v2 v2.3.7, 2026-09-24 (toml parser v2.2.2, 2026-08-04) Decodes with go-viper/mapstructure/v2. koanf loses key order and byte positions, so it is paired with a TOML index. See F2.4.
TOML index (key order + spans) and format-preserving edits toml_edit, serde Spanned github.com/pelletier/go-toml/v2/unstable (already a dependency through koanf’s TOML parser) go-toml v2.4.3, 2026-07-05 No maintained Go module offers a toml_edit equivalent. Write a small span-based editor on this parser. MUST keep comments and formatting in Cargo.toml, Cargo.lock, gleam.toml, pyproject.toml. See F6.
TOML encode (--generate, --upgrade) toml Hand-written emitter (the schema is small) n/a Golden output needs exact control over quoting and inline tables (F20.7). go-toml/v2 Marshal is only used in tests, to check the output round-trips.
JSON with key order kept serde_json + preserve_order stdlib encoding/json/jsontext (Go 1.27) stdlib Edit package.json, package-lock.json, tauri*.conf.json and deno.lock as a token stream, or as a small ordered tree built from jsontext.Decoder. Members keep their original order. Output: jsontext.Value.Indent("", " "), no trailing newline (matches serde to_string_pretty). Set jsontext.AllowDuplicateNames(true) to accept the same input serde does. v2 does not escape <>& by default, which matches serde. Replaces iancoleman/orderedmap (no release since 2023).
JSONC jsonc-parser github.com/tailscale/hujson pseudo-version 2026-07-27 (no tags; actively committed, needs Go 1.26) Parse deno.json(c). hujson keeps comments, so the version can be edited in place by patching the value. This fixes Quirk Q6.
YAML serde_yaml go.yaml.in/yaml/v3 v3.0.5, 2026-07-26 The YAML org’s maintained continuation of gopkg.in/yaml.v3, whose README now says “THIS PROJECT IS UNMAINTAINED”. Drop-in API. Only used to read version and for the rare whole-file fallback. Considered: github.com/goccy/go-yaml (v1.19.2, 2026-01).
XML xmltree stdlib encoding/xml stdlib Use Decoder.Token + InputOffset() to find the byte range of the direct-child <version> text, then patch that range in place (fixes Quirk Q7).
Regex regex stdlib regexp stdlib RE2 semantics in both, so behaviour matches.
Glob glob stdlib io/fs (fs.WalkDir + path.Match per segment, with ** handled by the walker) stdlib About 60 lines. filepath.Glob alone has no **. Fallback if edge cases pile up: github.com/bmatcuk/doublestar/v4 (v4.10.2, 2026-09-20).
Prompts inquire charm.land/huh/v2 v2.0.3, 2026-03-10 (needs Go 1.25.8, built on charm.land/bubbletea/v2 v2.0.10, 2026-09-24) Select, MultiSelect with a “select at least one” validator, Input, and Input in password mode. Replaces AlecAivazis/survey (no release since 2022). Put it behind internal/prompt so tests can script answers.
Git git2 (libgit2) stdlib os/exec calling the git CLI, with github.com/go-git/go-git/v5 as the fallback when git is not installed (D2, F9.0) go-git v5.19.2, 2026-07-29 The CLI gives exact behaviour. RebaseBranch is fast-forward only on both backends (D6), so go-git needs no rebase. v6 is still alpha.
HTTP + retry reqwest-middleware + reessaie stdlib net/http with a retrying RoundTripper (also used as go-github’s transport) stdlib About 80 lines: exponential backoff with jitter, 5 retries, honours Retry-After (seconds or HTTP date), logs rate-limit warnings (F15.1). Considered: hashicorp/go-retryablehttp (last release v0.7.8, 2025-06).
GitHub API reqwest (hand-written) github.com/google/go-github/v92 v92.0.0, 2026-09-14 (needs Go 1.26) All GitHub calls (D10, F16.0). Also handles the asset upload URL, so no URI-template library is needed.
GitHub GraphQL reqwest (hand-written query) github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed (no tagged release; uses shurcooL/graphql) SelectGitHubIssue only (F19.1): Knope’s query as a struct, with variables, on go-github’s HTTP client. go-github is REST-only and the REST issues endpoint cannot do any-of labels.
GitHub App auth — github.com/jferrl/go-githubauth v1.9.1, 2026-09-19 (needs Go 1.26; uses golang-jwt/jwt/v5, x/oauth2) App JWT → installation token as an oauth2.TokenSource (F16.1, E2). Rejected: bradleyfalzon/ghinstallation/v2 (v2.19.0) because it pins go-github/v88 and jwt/v4, which would add a second go-github major.
Commit signing (go-git fallback, E2) — go-git’s signing support (OpenPGP via github.com/ProtonMail/go-crypto v1.5.2, 2026-09-25, already a go-git dependency) — Only for the go-git backend. The git CLI backend uses the user’s own git signing config.
Command splitting execute Hand-written tokenizer in internal/steps/command n/a About 50 lines: whitespace separates words, '…' and "…" quote, backslash escapes, no $VAR expansion (matches Knope). Rejected: google/shlex (no change since 2019); mvdan.cc/sh/v3/shell.Fields (expands variables); mattn/go-shellwords (recent tag, but go 1.13 module and adds little).
Base64 (Jira auth) base64 stdlib encoding/base64 stdlib
Logging tracing stdlib log/slog with a custom plain handler stdlib No level, time or target prefix. See F3.
Diagnostics miette Custom Diagnostic interface: Code(), Help(), URL(), optional source span n/a See F3.
User config dir dirs stdlib os.UserConfigDir() stdlib Same locations as Rust dirs::config_dir().
Dates time stdlib time (time.Now().UTC().Format(time.DateOnly)) stdlib
Conventional commits git-conventional Hand-written parser in pkg/versioning/changes n/a Must match git-conventional’s grammar exactly (F10.1). Considered: github.com/leodido/go-conventionalcommits (v0.13.0, 2026-07). Its grammar and footer rules differ, so it would need adapting anyway.
Semver own Own type in pkg/versioning/semver n/a Full SemVer 2.0.0 (D8, F1). Own type because the bump logic needs Knope’s (label, counter) view of pre-releases and exact control of precedence and errors; about 200 lines plus tests.
CLI golden tests snapbox github.com/rogpeppe/go-internal/testscript v1.16.0, 2026-07-01 (needs Go 1.25) Runs the binary against .txtar scripts. See Testing strategy.
Test diffs pretty_assertions github.com/google/go-cmp v0.7.0, 2025-01 Stable, maintained by Google. Optional: stdlib testing is enough for most tests.

Removed from the earlier draft: spf13/cobra (replaced by urfave/cli), iancoleman/orderedmap (stdlib jsontext), gopkg.in/yaml.v3 (unmaintained), bmatcuk/doublestar (stdlib, kept as fallback), google/shlex (dead), hashicorp/go-retryablehttp (stdlib), charmbracelet/huh v1 (moved to charm.land/huh/v2).


1. Milestone plan (feature order)

Each milestone ships something users can run, and its tests pass on its own. Later milestones only add to earlier ones.

# Milestone Features Knope user value unlocked
M0 Skeleton F3 (CLI, logging, errors, dry-run), F2 (parse config, workflows only), F4, F5 remeris <workflow> runs Command steps
M1 Semver core F1 Library only, fully unit-tested
M2 Versioned files v1 F6: Cargo.toml, package.json, pyproject.toml, go.mod, regex Read and write versions
M3 Packages + BumpVersion F7, F8, $version variable, F9 (read tags) BumpVersion with Major/Minor/Patch/Pre/Release, --override-version
M4 Changes F10, F11 (parse + CreateChangeFile), F9 (commit walk) document-change workflow
M5 PrepareRelease F12, F13, F9 (stage files) Changelog and version bump from commits and changesets
M6 Release (git) F14, F9 (create tag), default workflows (F20 partial) Full offline release loop, zero-config mode
M7 GitHub F15, F16 (releases, assets), F18 (GitHub), PR-info enrichment GitHub release flow
M8 Gitea F17, F18 (Gitea) Codeberg/Gitea flow
M9 Remaining formats F6: Cargo.lock, gleam.toml, package-lock.json, pubspec.yaml, pom.xml, tauri*.conf.json, deno.json(c), deno.lock Full format parity
M10 Discovery + tooling F20 (Cargo/npm/Deno workspaces, --generate), F21 (--upgrade, --validate) Zero-config monorepos
M11 Issues F19 (GitHub/Gitea/Jira issues, SwitchBranches, RebaseBranch, SelectIssueFromBranch, TransitionJiraIssue), IssueBranch variable Issue-driven dev workflows
M12 Parity polish Verbose debug-line parity, fixture and doc audit, user docs Output and docs match Knope line for line

Phase 2 milestones (P2.1–P2.6) are listed at the end of Phase 2.


F1: Semantic versions

Source: knope-versioning/src/semver/{mod,rule,package_versions,prerelease_map}.rs

F1.1 Version grammar: full SemVer 2.0.0 (decision D8)

Knope accepts only a subset of SemVer (M.m.p or M.m.p-label.N). Remeris implements the full SemVer 2.0.0 grammar from the start, because it is the base for the pre-release channel work (E1).

version     := core ["-" prerelease] ["+" build]
core        := numeric "." numeric "." numeric            ; uint64, no leading zeros ("0" is fine)
prerelease  := pre-id *("." pre-id)
pre-id      := numeric | alphanumeric                     ; numeric: no leading zeros
alphanumeric:= 1*[0-9A-Za-z-] containing at least one non-digit
build       := build-id *("." build-id)
build-id    := 1*[0-9A-Za-z-]                             ; leading zeros allowed

No v prefix in a Version (tag prefixes such as v / name/v are stripped before parsing, F7.5). Error message stays Knope’s: Found invalid semantic version {detail}, help The version must be a valid Semantic Version.

type Version struct {
    Major, Minor, Patch uint64
    Pre   []PreID   // empty = stable
    Build []string  // kept for display and writing; ignored for precedence
}
type PreID struct { Str string; Num uint64; IsNum bool }

func Parse(s string) (Version, error)
func (v Version) String() string           // canonical form, including build metadata
func Compare(a, b Version) int             // SemVer §11 precedence, build ignored
func (v Version) Equal(o Version) bool     // identical, including build
func (v Version) IsPrerelease() bool

Precedence (SemVer §11): compare major, minor and patch numerically. A version with a pre-release is lower than the same core without one. Pre-release identifiers are compared left to right: two numeric ids compare numerically, two alphanumeric ids compare in ASCII order, numeric is lower than alphanumeric, and if all shared ids are equal the longer list is higher. Build metadata is ignored.

Which comparison to use where:

  • Ordering, Latest(), “is the tag already released” (F14), bump calculations → Compare.
  • The “inconsistent versions” check across a package’s files (F7.2) → Equal, so 1.2.3 vs 1.2.3+b1 is reported (Knope could not express build metadata at all).

Knope’s pre-release model on top of SemVer. Knope’s bumping logic needs a (label, counter) pair. Any pre-release is mapped onto that pair like this:

Pre-release label counter
rc.4 rc 4
alpha.beta.2 alpha.beta 2
rc4, beta rc4, beta none
1 (single numeric id) "" 1

The rule: if there are at least 2 identifiers and the last one is numeric, it is the counter and the rest (joined with .) is the label. Otherwise the whole pre-release is the label and there is no counter. Versions produced by a bump are always core-label.N with no build metadata.

Differences from Knope (record in testdata/deviations.md):

  • Accepted now, rejected by Knope: 1.2.3-rc4, 1.2.3-rc.4.5, 1.2.3+build.7, 1.2.3-alpha.beta.1. Tags like these were silently skipped by Knope and now count as releases.
  • Rejected now, accepted by Knope: leading zeros (01.2.3 → Knope read it as 1.2.3; 1.2.3-rc.01).
  • Ordering between pre-releases follows SemVer precedence instead of Knope’s “label string, then number” (Quirk Q2). For the label.N shape both give the same order, so only exotic versions are affected.

Display: String() prints the canonical form, so 1.2.3+build keeps its build metadata when written to files.

F1.2 Rules

type StableRule int // Patch < Minor < Major (ordering used for "max")
type Rule struct {
    Kind       RuleKind   // Stable | Pre | Release
    Stable     StableRule // for Kind==Stable, and the base rule for Kind==Pre
    Label      string     // for Kind==Pre
}

StableRule from a list of changes = the max over all changes of: Breaking→Major, Feature→Minor, Fix/Custom→Patch. It is Patch when there are no changes.

bump_stable(v, rule):

rule major ≥ 1 major == 0
Major M+1.0.0 0.m+1.0
Minor M.m+1.0 0.m.p+1
Patch M.m.p+1 0.m.p+1

F1.3 PackageVersions: the version history of a package

State: stable *Core and prereleases map[Core]map[Label]Version (Core = major.minor.patch; Label as defined in F1.1). Keep both maps ordered by key (Go: sort keys when needed). There is always at least one label per entry.

FromTags(prefix *string, tags []string): tags must already be in newest-first order (see F9.3).

  • The pattern is "v", or "{prefix}/v" when a prefix is given. Only tags starting with the pattern are used.
  • For each matching tag, remove the pattern (first occurrence only) and try to parse a Version (skip it on error):
    • Stable: set stable and stop (only pre-releases newer than the latest stable matter).
    • Pre: update(version).

update(version):

  • Stable new: if stable >= new do nothing. Otherwise set stable = new and clear all pre-releases.
  • Pre (s, pre): if a pre with the same s and label already exists and is >= pre (by Compare), do nothing. Otherwise insert or overwrite prereleases[s][label(pre)] = pre.

Latest(): if there are pre-releases, take the largest stable key and return the highest-precedence pre-release in its map (Compare; Knope used the largest label string). Otherwise return stable. Otherwise return nothing.

CalculateNewVersion(rule):

rule stable exists result
Stable(r) yes bump_stable(stable, r)
Stable(r) no largest pre-release stable key as-is, else 0.0.0
Pre{label, r} any calculatePre (below)
Release any largest pre-release stable key, else error PreReleaseNotFound (No prerelease version found, but a Release rule was requested)

calculatePre(label, r):

  1. base = bump_stable(stable, r) if stable exists, else the largest pre-release stable key, else 0.0.0.
  2. n = counter + 1 if prereleases[base][label] exists and has a counter; 0 if it exists without a counter (so 1.2.4-beta is followed by 1.2.4-beta.0, which is higher by SemVer precedence); else 0.
  3. Return base-label.n.

Examples to test (from the docs and code):

  • 1.2.3, Pre(rc, Patch) → 1.2.4-rc.0. Then with 1.2.4-rc.0 tagged → 1.2.4-rc.1.
  • Stable 1.2.3, tags 1.2.4-rc.0, then a Minor change with Pre(alpha) → 1.3.0-alpha.0.
  • 0.1.2 + Major → 0.2.0. 0.2.0 + Minor → 0.2.1.
  • BumpVersion Release on 1.2.3-rc.4 → 1.2.3.

Acceptance (F1)

  • Port every unit test in semver/*.rs and package_versions.rs as a Go table test (adjusting the few that expect Knope’s subset errors, see F1.1).
  • Add SemVer 2.0.0 conformance tests: the valid/invalid examples and the precedence chain from the spec (1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0), plus a fuzz test that Parse(v.String()) round-trips.

F2: Config file (knope.toml)

Sources: crates/knope/src/config/{mod,toml,package}.rs, crates/knope-config/src/*

F2.1 Top-level schema

[changes]                    # optional
ignore_conventional_commits = false
ignore_conventional_merge_commits = false   # skip merge commits (more than one parent) when reading commits

[release_notes]              # optional
change_templates = ["..."]   # array of strings (F12.3)

[package]                    # EITHER [package] ...
# or
[packages."<name>"]          # ... OR one or more [packages.<name>]. Both at once is an error.

[[workflows]]                # optional (defaults apply when absent, F20)
name = "release"             # required, unique name = CLI subcommand
help_text = "..."            # optional
[[workflows.steps]]
type = "<StepName>"          # tagged union, F4.2

[github]  owner = "", repo = ""
[gitea]   owner = "", repo = "", host = "https://codeberg.org"
[jira]    url = "https://x.atlassian.net", project = "PRJ"

Unknown keys: Knope’s serde structs do not use deny_unknown_fields, so unknown keys are ignored. Copy this.

F2.2 Package schema

key type default notes
versioned_files array of string | {path, dependency} | {path, regex} [] regex may be a string or an array of strings. dependency and regex together is an error (Cannot specify both 'dependency' and 'regex' for the same file: {path}).
changelog relative path none If the file is missing it is treated as empty and created on write.
scopes []string none (= all scopes)
extra_changelog_sections [{name, footers?, types?}] [] F12.1
assets string (glob) | [{path, name?}] none GitHub only
ignore_go_major_versioning bool false

Validation when loading (in this order):

  1. [package] and [packages] both present → You cannot define both 'packages' and 'package', with source-span labels on both definitions. [packages] present but empty → The 'packages' key is defined but does not contain any packages.
  2. Each versioned file: regex present → Format = RegexFile. Otherwise the file name (last path component, case-sensitive) must be one of the supported names (F6.1), else Unknown file: {path} (help lists the supported formats). The file must exist on disk, else File {path} does not exist with the span labelled Declared here.
  3. [gitea] present and any package has assets → Asset uploads for Gitea are not supported.

Package names: [package] gives the Default name (shown as default, no tag prefix). [packages.X] gives Custom(X). Keep the order packages are declared in (use an ordered map).

F2.3 Config sources

Load():
  if knope.toml can't be read → ConfigSource.Default(generate())         # F20
  else parse → ConfigSource.File(cfg) → fillInGaps():
      if cfg.packages empty  → cfg.packages = findInWorkingDir()          # F20
      if cfg.workflows empty → cfg.workflows = defaultWorkflows(hasForge, packages)
      → ConfigSource.Hybrid(cfg)

Forges are never auto-detected when knope.toml exists. The source type decides which CLI flags exist (F3).

F2.4 Loading with koanf (Go design)

koanf does the loading and decoding. Two of its defaults don’t fit knope.toml, so a TOML index fills the gaps.

  1. Key delimiter. koanf splits keys on its delimiter (. by default). Package names ([packages."foo.bar"], [packages."@scope/pkg.js"]) and variables keys (such as "{version}", or anything with a dot) can contain dots. Create the instance with a delimiter that can’t appear in TOML keys: koanf.NewWithConf(koanf.Conf{Delim: "\x1f", StrictMerge: true}).

  2. Order. parsers/toml/v2 decodes into map[string]any, so the declaration order of [packages.*] and variables is lost. Both orders matter (package order drives action and tag order, and variable order drives replacement order, F4.4). Read the original bytes a second time with go-toml/v2/unstable and build a tomlIndex that holds:

    • the ordered keys of packages and of every variables table, keyed by their TOML path;
    • the byte span of every table and array element (package, packages.X, versioned_files[i], workflows[i]), used for diagnostics (F2.2, F3.3).

    After decoding, re-order the packages and variables maps using the index.

  3. Decoding. k.UnmarshalWithConf("", &raw, koanf.UnmarshalConf{Tag: "toml", DecoderConfig: …}) with mapstructure decode hooks for the untagged and tagged unions:

    • versioned_files[]: string → {path}; table → {path, dependency} or {path, regex}, where regex is a string or []string;
    • assets: string (glob) or a list of {path, name?};
    • workflows[].steps[]: switch on type into the concrete step struct (F4.2), and BumpVersion on rule;
    • Variable values: exactly Version | ChangelogEntry | IssueBranch.

    Leave ErrorUnused off, so unknown keys are ignored as in Knope (Quirk Q1). Decode errors are wrapped as diagnostics, with spans looked up in the index.

  4. Layers. Only one source, file.Provider("knope.toml"), is needed for parity. Defaults for Hybrid configs (F2.3) are applied to the decoded structs, not merged as koanf layers, because “missing” means “detect from the working directory”, not a fixed value. Extra layers later (for example remeris.toml overrides or REMERIS_* env vars) are where koanf pays off (see D1: knope.toml is the only file for now).

If knope.toml is missing, skip koanf entirely and go to ConfigSource.Default (F20).

Acceptance (F2)

  • Fixtures: tests/prepare_release/{invalid_versioned_files,missing_versioned_files,unknown_versioned_file_format}, and the package/packages conflict.

F3: CLI, run modes, logging, errors

Source: crates/knope/src/main.rs

F3.1 Command line

remeris [--dry-run] [-v|--verbose] [--generate | --upgrade | --validate] [<workflow> [workflow flags]]
remeris --help | --version
Flag Available when Behaviour
--dry-run (global) always Run the workflow in DryRun mode.
-v, --verbose (global) always Log level DEBUG instead of INFO.
--generate no knope.toml Log Generating a knope.toml file, write the generated config (F20.7), exit. Never overwrites; with --dry-run it prints the file instead.
--upgrade knope.toml exists F21
--validate knope.toml exists F21. All log output is suppressed while validating.
--override-version (workflow flag) workflow has BumpVersion or PrepareRelease and ≥1 package With 1 package: single <version>, parsed with F1. With ≥2 packages: repeatable name=version. Unknown names → error Unknown package(s) to override: a, b. Bad format → package override should be formatted like package_name=version.
--prerelease-label (workflow flag, env REMERIS_PRERELEASE_LABEL or KNOPE_PRERELEASE_LABEL, the first one set wins) workflow has PrepareRelease Sets prerelease_label on every PrepareRelease step in every workflow. The flag wins over the env var.
  • Each workflow is a subcommand, and help_text is its description.
  • No subcommand (and no --validate) → error No workflow selected. Run 'remeris --help' for a list of options.
  • Unknown workflow names are rejected by the CLI parser. Knope also has a fallback No workflow named {name}. Remeris gives that error for the name, before any mode flag (--validate nosuch fails). A flag that is not defined (--validate without a knope.toml) is unexpected argument '--validate' found, as clap says it.
  • --help on the root lists workflows. <workflow> --help shows the flags for that workflow.

urfave/cli v3 + koanf: the readykit pattern (decision D9). Reuse the structure of celest-io/readykit (cmd/readykit/main.go, pkg/app/config.go):

  1. Typed options struct with defaults. Runtime options live in plain structs with mapstructure tags whose names match the flag names, plus a constructor for defaults:

    // internal/app/options.go
    type GlobalOptions struct {
       DryRun   bool   `mapstructure:"dry-run"`
       Verbose  bool   `mapstructure:"verbose"`
       Generate bool   `mapstructure:"generate"`
       Upgrade  bool   `mapstructure:"upgrade"`
       Validate bool   `mapstructure:"validate"`
       // GitHub App auth (E2), never read from knope.toml:
       GitHubAppID          int64  `mapstructure:"github-app-id"`
       GitHubInstallationID int64  `mapstructure:"github-installation-id"`
       GitHubAppKeyPath     string `mapstructure:"github-app-private-key-path"`
    }
    func DefaultGlobalOptions() *GlobalOptions { return &GlobalOptions{} }
    
    type WorkflowOptions struct {
       OverrideVersion []string `mapstructure:"override-version"`  // "1.2.3" or "pkg=1.2.3"
       PrereleaseLabel string   `mapstructure:"prerelease-label"`
    }
  2. Flags declare their env vars through Sources: cli.EnvVars(...), as in readykit: --prerelease-label → cli.EnvVars("REMERIS_PRERELEASE_LABEL", "KNOPE_PRERELEASE_LABEL"), --github-app-id → REMERIS_GITHUB_APP_ID, --github-installation-id → REMERIS_GITHUB_INSTALLATION_ID, --github-app-private-key-path → REMERIS_GITHUB_APP_PRIVATE_KEY_PATH. The private key itself can also come from REMERIS_GITHUB_APP_PRIVATE_KEY (PEM content, for CI secrets); that one is read directly, never through a flag, so it doesn’t show up in --help defaults or process listings. The flag wins over the env vars (urfave’s precedence).

  3. One generic loader, copied from readykit’s app.LoadConfig with one fix:

    // internal/app/config.go
    func LoadConfig[T any](cmd *cli.Command, cfg *T) error {
       k := koanf.New(".")
       if err := k.Load(cliflagv3.Provider(cmd, "."), nil); err != nil { return err }
       return k.UnmarshalWithConf(commandPath(cmd), cfg, koanf.UnmarshalConf{Tag: "mapstructure"})
    }
    // commandPath returns "remeris" or "remeris.release": cliflagv3 keys every flag by the
    // command lineage, so readykit's cmd.Name prefix only works for the root command.
    func commandPath(cmd *cli.Command) string { /* join Lineage() names root→cmd with "." */ }

    Only flags that were set (on the command line or through their env var) reach koanf, so values not set keep the defaults from the constructor. Workflow names are validated against ^[A-Za-z0-9_-]+$ at config load time, because a . in a workflow name would break the koanf key path. Knope allows any string, so this is a small deviation; the error is Workflow names may only contain letters, digits, '-' and '_': {name}.

  4. Command tree. As in readykit, main builds a root cli.Command with Name: "remeris", Version: version.Full(), Suggest: true, EnableShellCompletion: true, root Flags, and a Before hook that runs LoadConfig(cmd, globalOpts) and sets up slog (verbose → debug level). Command constructors take the shared state as an argument and close over it (workflowCommand(cfg, w) *cli.Command), like readykit’s daemonCommand(cfg). Each workflow Action runs LoadConfig(cmd, &WorkflowOptions{}) and then the workflow.

  5. Project config is loaded before the tree is built, because workflows become subcommands. knope.toml uses its own koanf instance (F2.4, file provider + TOML parser + TOML index). It is not merged with the CLI koanf instance: CLI options and project config have different shapes and lifetimes.

  6. Flag details:

    • --dry-run and --verbose (alias v) are root BoolFlags. v3 root flags are persistent by default, so remeris release --dry-run works, as in clap’s global(true).
    • --generate, --upgrade and --validate are root BoolFlags with Local: true, added only for the matching ConfigSource. The root Action handles them, and errors with No workflow selected… when none is set.
    • --override-version: StringSliceFlag in both cases. With one package, exactly one bare <version> is allowed. With several, each value must be name=version.
    • root.Version gives --version. Help formatting differs from clap, so golden tests don’t compare --help.
  7. Errors. Readykit logs slog.Error and exits 1. Remeris instead renders the Diagnostic (F3.3) to stderr and exits 1, because diagnostics have help text and nested errors that a log line would lose. Steps never call cli.Exit.

Decision (D1): the program is remeris: binary name, --help, log and error text (for example Run 'remeris --help'), diagnostic codes (remeris::inconsistent_versions), User-Agent: Remeris and the credential directory (F15.2). Knope’s release configuration is the first of several supported ones, not a legacy format, so it keeps the names its own format defines: the file knope.toml (read and written by --generate/--upgrade) and KNOPE_PRERELEASE_LABEL. The code that implements a configuration owns its names (the file name lives in internal/config); there is no global name package. A setting that is also useful to other configurations gets REMERIS_* as its own name, which takes precedence. Golden logs vendored from Knope are rewritten knope → remeris where the text names the program; knope.toml itself is left alone.

F3.2 Logging

  • Plain lines with no timestamp, level or target. INFO by default, DEBUG with --verbose.
  • Only messages from the app itself are shown (Knope filters to targets starting with knope).
  • Knope’s tracing-subscriber fmt layer writes to stdout. Warnings (warn!) also go to stdout, with no prefix.
  • Diagnostics (errors) go to stderr. Exit code 1 on error.

F3.3 Error rendering

Knope uses miette. Each error has a message, and optionally a code (such as knope_versioning::inconsistent_versions), help text, a docs URL, a source snippet with labelled spans (config errors), and related/nested errors (workflow errors wrap step errors). Go design:

type Diagnostic interface {
    error
    Code() string
    Help() string
    URL() string
    Labels() []Label            // {Span [start,end), Text}, rendered against SourceCode()
    SourceCode() string
    Related() []error
}

Workflow failure wraps the step error as Problem with workflow {name}. Validation collects them as There are problems with the defined workflows, listing all.

Decision (D4): v1 renders the message and help text only (plus nested related errors), matching Knope’s wording with knope → remeris. No source snippets, no docs URL: the Knope URLs point at knope.tech and the remeris docs will be written later. Keep URL(), Labels() and SourceCode() on the interface (and keep the spans in the TOML index, F2.4) so snippets and links can be turned on later without touching every error.

Rendering:

Error: {message}
  help: {help}
  ╰─▶ {related message}      # one line per nested error, indented per level

Golden stderr.log files from Knope contain miette’s ASCII graphics. The fixture conversion script reduces them to the message and help lines, and tests compare only those.


F4: Workflows, state and variables

Sources: workflow.rs, state.rs, step/mod.rs, variables.rs, knope-config/src/template.rs

F4.1 Runner

func Run(w Workflow, s RunType[State]) error {
    for _, step := range w.Steps { s, err = step.Run(s); if err != nil { return WorkflowError{w.Name, err} } }
}

Steps run in order. Stop at the first error. State moves from step to step.

F4.2 Steps (TOML type = "...")

type fields Feature
Command command (string), variables (ordered map string→Variable, optional), shell (bool, optional) F5
BumpVersion rule = Major | Minor | Patch | Pre | Release, plus label for Pre F8
PrepareRelease prerelease_label?, allow_empty (false), ignore_conventional_commits (false, deprecated) F13
Release none F14
CreateChangeFile none F11
CreatePullRequest base, title = {template, variables?}, body = {template, variables?} F18
SelectJiraIssue status F19
TransitionJiraIssue status F19
SelectGitHubIssue labels? ([]string) F19
SelectGiteaIssue labels? ([]string) F19
SelectIssueFromBranch none F19
SwitchBranches none F19
RebaseBranch to F19

The docs examples show label = "selected" for SelectGitHubIssue. The code field is labels (an array), and unknown keys are ignored, so the docs example is effectively “no filter”. Copy the code.

In verbose mode, log Running step {debug repr} before each step.

F4.3 State

type State struct {
    JiraConfig   *JiraConfig
    GitHubConfig *GitHubConfig; GitHub GitHubAuthState   // New | Authenticated{token,client} | Unauthenticated{client}
    GiteaConfig  *GiteaConfig;  Gitea  GiteaAuthState    // New | Initialized{token,client}
    Issue        *Issue                                  // nil = Initial
    Packages     []*Package
    AllVersionedFiles []versionedfile.File                // parsed ONCE at startup, shared by packages
    PendingActions    []Action                            // produced by PrepareRelease, consumed by Release
    AllGitTags        []string                            // newest-first, current branch only (F9.3)
    IgnoreConventionalCommits bool                        // from [changes]
}

Git tags are read only when there is at least one package. If reading fails, the tag list is empty.

F4.4 Templates and variables

A Template is {template string, variables ordered-map[string]Variable}. Default variables, used only when the variables key is absent, are {"$version": Version, "$changelog": ChangelogEntry}. variables = {} turns substitution off.

replace(template): for each (key, var) in declared order, skip it if key does not occur in the current string (so unused, invalid variables never error). Otherwise look up the value and do strings.ReplaceAll. Later replacements can change text produced by earlier ones. Copy this.

Variables:

  • Version: needs exactly one package (Too many packages defined if more, No packages to operate on if none). Value = package.LatestVersion(), else error Could not determine version of package.
  • ChangelogEntry: same single-package rule. If PendingActions has a CreateRelease, use the first one’s notes. Otherwise use latestVersion and find that release in the changelog (F12.5). If that fails: Could not find a changelog entry for version {v}.
  • IssueBranch: needs a selected issue (No issue selected). Value = branchNameFromIssue(issue) (F19.6).

Phase 2 hook: implement lookup as Resolve(v Variable, scope *PackageScope). Phase 1 always passes nil, which gives the single-package rules above. E4 passes a package for qualified variables and for_each loops.


F5: Command step

Source: step/command.rs

  1. Substitute variables (F4.4) into command.
  2. Dry run: log Would run {command} and return.
  3. If shell == true: run through the platform shell (sh -c "<cmd>" on Unix, cmd /C "<cmd>" on Windows). Otherwise split the string into argv with the hand-written tokenizer (whitespace separates words, '…' and "…" quote, backslash escapes, no $VAR expansion; see 0.3) and run it directly (no shell).
  4. Inherit stdin, stdout and stderr, and the working directory.
  5. Non-zero exit → Command returned non-zero exit code. Spawn failure → I/O error: {err}.

Default release workflow command: git commit -m "chore: prepare release $version". Argv splitting must give ["git","commit","-m","chore: prepare release 1.2.3"].

Acceptance (F5)

  • Fixtures tests/command/* (variables, shell mode, default variables, disabled defaults).

F6: Versioned files (per format)

Source: knope-versioning/src/versioned_file/*.rs

F6.1 Common model

type Format int // Cargo, CargoLock, DenoJson, DenoLock, Gleam, GoMod, PackageJson, PackageLockJson,
                // MavenPom, PubSpec, PyProject, TauriConf, RegexFile

// Supported file names, matched EXACTLY on the last path component. This order is also the order
// used by default detection (F20).
var FileNames = []struct{ Name string; Format Format }{
  {"Cargo.toml", Cargo}, {"Cargo.lock", CargoLock}, {"deno.json", DenoJson}, {"deno.lock", DenoLock},
  {"gleam.toml", Gleam}, {"go.mod", GoMod}, {"package.json", PackageJson},
  {"package-lock.json", PackageLockJson}, {"pom.xml", MavenPom}, {"pubspec.yaml", PubSpec},
  {"pyproject.toml", PyProject}, {"tauri.conf.json", TauriConf}, {"tauri.macos.conf.json", TauriConf},
  {"tauri.windows.conf.json", TauriConf}, {"tauri.linux.conf.json", TauriConf},
}

type Config struct { Path string; Format Format; Dependency *string; Regex []string }

type File interface {
    Path() string
    Version() (semver.Version, error)            // ErrNoVersion for lockfiles
    SetVersion(v semver.Version, dependency *string, gv GoVersioning) (File, error)
    Write() []Action                             // nil if nothing changed
}

deno.jsonc is not in the name table, so it cannot be used as an explicit versioned_files entry. It is only reached through Deno workspace discovery, which keeps the real config path (F20.4). Treat this as a Knope bug: add deno.jsonc → DenoJson to the table.

Parse once, edit many times. Every file in every package is parsed once at startup into State.AllVersionedFiles. SetVersion is applied once for each config entry that matches the path, in package order and then config order. So one file can hold its own version and dependency versions for several packages (see the docs example with package.json listed twice). The diff strings from each edit are joined with ", ".

Action.WriteToFile{Path, Content, Diff}: Diff is what dry run prints (Would add the following to {path}: {diff}).

F6.2 Format details

For each format: read (where the version comes from), write (what is edited and how formatting is kept), diff string, and dependency support.

Cargo.toml (TOML, keep formatting)

  • Read: package.version (string). If absent, workspace.package.version. Else error {path} was missing required property 'package.version' or 'workspace.package.version'. Error Invalid TOML in {path} on parse failure.
  • Write (no dependency): if package.version exists and is a string, set it. Otherwise (for example version.workspace = true), if workspace.package.version exists, set that. Diff version = {v}.
  • Write (dependency d): in each of dependencies.d, dev-dependencies.d and workspace.dependencies.d: if the entry is a table ([dependencies.d]) → set/insert version. If it is an inline table → set/insert version. If it is a plain value (d = "1.0") → replace it with the version string. Diff {d}.version = {v}.
  • Helpers: NameFromDocument returns package.name. ContainsDependency(doc, d) checks any of the three tables.

Cargo.lock (TOML, keep formatting)

  • Read: none (This file can't contain a version). It must have a dependency (F7.2 fills in a default).
  • Warn if the top-level version is not 3 or 4 (Unsupported version of Cargo.lock: … / Unknown version of Cargo.lock…).
  • Write: for each [[package]] with name == d, set/insert version. Diff {name} = {v}, joined with ", ".
  • Errors: missing [[package]] array, or a package without a string name.

gleam.toml

  • Read top-level version. Write/insert top-level version. Diff version = {v}. No dependency support.

pyproject.toml

  • Read project.version (PEP 621) and tool.poetry.version. If both exist they must be equal (Found conflicting versions {project} and {poetry} in {path}). If neither: No versions were found in {path}.
  • Write: replace the text between the quotes at each version’s byte span. Every other byte is kept. Diff = {v} (version only).

go.mod

  • Parse the first line starting with module : module <path>[/vN] [// v<full-version>]. major = N when the path’s last segment is vN.
  • Read: if the module comment has a version, use it. Otherwise take it from git tags:
    • parent = the directory of go.mod. If major is set and parent ends in v{major}, drop that segment (major-version subdirectory).
    • Tag prefix = "{parent}/", or "" at the repo root. Candidates = tags with that prefix and then v, parsed as Version, with major ∈ {major} if set, else {0,1}. Take the first match in tag order (newest first). None → No matching tag found for the go.mod file. Searched for a tag with the prefix {prefix} and a major version of {filter}.
  • Write with GoVersioning:
    • Set the comment version to the new version.
    • If newMajor > 1 && newMajor != (major or 0) && mode != IgnoreMajorRules:
      • If the module has no /vN and mode != BumpMajor → Will not bump Go modules to 2.0.0.
      • If the old major is set and parent ends with v{oldMajor} → Cannot bump major versions of directory-based go modules.
      • Otherwise set /v{newMajor} on the module path.
    • Rebuild the line: module {path} + (/v{N} if N>1) + // v{version}. Replace the original line in the raw text.
  • Write() returns two actions: WriteToFile (diff = version) and AddTag: tag = "{prefix}/v{version}", where prefix = parent dir without a trailing v{major} and /. At the root it is v{version}.
  • GoVersioning: Standard (default), IgnoreMajorRules (package ignore_go_major_versioning = true), and BumpMajor (used when the version is overridden).

package.json

  • Read: top-level version (must parse, else a deserialise error). Help: knope expects the package.json file to be an object with a top level 'version' property.
  • Write (no dependency): parse the raw text into an ordered map and set version (inserted at the end if missing). Re-serialise pretty with 2 spaces, no trailing newline. Diff version = {v}.
  • Write (dependency d): in dependencies and then devDependencies, if d is a string value:
    • workspace:<prefix><digits…> → workspace:<prefix>{v}, where prefix is everything before the first ASCII digit (such as ^ or ~). workspace:*, workspace:^ and other values with no digit are left unchanged.
    • Any other string → {v}.
    • Only when the value changes: re-serialise and add diff {d} = {newValue}.
  • Diffs are joined with ", ".

package-lock.json

  • Read top-level version ({path} did not have a `version` field, help Either add a `version` field or specify a `dependency` to update).
  • Warn if lockfileVersion is not 2 or 3.
  • Write (no dependency): set top-level version and packages[""].version. Diff version = {v}.
  • Write (dependency d): for every value in packages: if name == d, set version. If dependencies[d] or devDependencies[d] exists, set it to {v}. Diff {d} = {v} (always added).
  • Serialise pretty with 2 spaces, no trailing newline.

deno.json / deno.jsonc

  • Parse as JSONC. Read version (missing or invalid → no version).
  • Write (no dependency): Knope sets version and re-serialises the whole file as plain pretty JSON. Quirk Q6: comments and formatting are lost, even though the docs say they are kept. Decision: with hujson, patch only the version value (or insert the member) and keep everything else, as the docs promise. Update the Deno fixtures and record the deviation. Diff version = {v}.
  • Write with a dependency: no-op (deno.lock owns dependency versions).

deno.lock

  • Only lockfile "version": "5" is supported. Versions 1–4 or missing → Lockfile version {v} in {path} requires npm package metadata which is not supported yet. Other values → Unsupported lockfile version {v} in {path}.
  • Write needs dependency d. Algorithm:
    1. For each specifiers entry whose key parses as a JSR/npm package request (jsr:@scope/name@<req> or npm:name@<req>) with name d: record the old resolved value and the old req text, record the kind (jsr/npm), and set the value to {v}.
    2. None updated → no-op.
    3. For each kind seen, rename keys in the jsr / npm section: name@old → name@{v} when the old version is in the recorded set.
    4. In workspace (and each workspace.members.*, and each key of workspace.links, including renaming the link key): rewrite entries in dependencies, packageJson.dependencies, optionalDependencies and peerDependencies that match d with a recorded version, to {scheme:}{d}@{v}.
    5. Diff {d} = {v}. Serialise like deno_lockfile 0.49.0 print_v5_content (printer.rs, called from Lockfile::as_json_string in lib.rs): 2-space pretty with a trailing newline, the top-level keys in the fixed order version, specifiers, jsr, npm, redirects, remote, workspace, empty sections left out, and the maps sorted: specifiers and the dependency arrays by the normalised requirement text (JsrDepPackageReq::to_string_normalized: ^1.0.0 prints as 1, see deno_semver 0.9.1 range.rs), jsr by PackageNv order (name, then version precedence), npm, members and links by key text. Two more printer rules (printer.rs 183-271): a jsr package’s dependency prints as bare scheme:name when that name has a single specifier in specifiers, otherwise as the normalised requirement; an npm dependency prints as name@npm:pkg@ver for an alias and as the bare name when the package has a single version (the npm dependency arrays are in the order of the dependency names, not of the printed text; a name used in both registries counts as having several specifiers); an npm package’s os and cpu arrays print on one line. JsrDepPackageReq::cmp is not the print order. The Go port of the requirement and version types is pkg/versioning/internal/denosemver, and the lockfile model, reader and printer are pkg/versioning/internal/denolock. Check against fixtures tests/prepare_release/deno.

pubspec.yaml

  • Read top-level version (YAML).
  • Write: replace only the bytes of the top-level version scalar (found from the position yaml.v3 reports for the first top-level version key), so comments, quote style, line endings and nested version: keys stay. Quoted values keep their quotes. A block scalar, anchor, tag, escaped or multi-line value fails with a diagnostic and is not written. The file is never re-serialised: a mapping with no version key (unreachable, since loading requires the key) gets a version: {v} line appended. Knope replaces the whole line, everywhere it occurs. Diff {v}, replacing any earlier diff.

pom.xml

  • The root element must be <project> ({path} was missing required property project). Read project > version text.
  • Write: set the text of <version>, or insert <version> right after <artifactId> (or at the end). Diff project.version = {v}.
  • Knope re-emits the whole XML tree with indentation and no XML declaration (Quirk Q7, it loses the original formatting). Decision: do a surgical replacement of the direct-child <version> text in Go. This is better behaviour. Refresh the fixtures to match, and record it as an intended deviation.

tauri.conf.json (+ .macos, .windows, .linux variants)

  • Read top-level version. Write: ordered-map set version, pretty, no trailing newline. Diff {v}.

Regex files ({ path, regex })

  • Each pattern must compile (Invalid regex pattern '{re}' for {path}: {err}) and must contain the named group version (Regex pattern '{re}' must contain at least one named capture group called 'version').
  • Read: every pattern must match (Regex '{re}' did not match any content in {path}). The captured version of each must parse as a Version (Matched version '{v}' in {path} is not a valid semantic version). The file’s version is the first pattern’s match.
  • Write: apply each pattern in turn with replace-all. For each match, take the full match text and replace every occurrence of the captured version substring inside the match with {v} (Quirk Q8: copy this).
  • Diff = the new lines that changed, compared line by line (zip old and new lines), trimmed and joined with \n. No changes → no write.

F6.3 Dependency support matrix

Allowed with dependency: Cargo.toml, package.json, package-lock.json, deno.json (no-op), deno.lock, Cargo.lock (required). All others → Dependencies are not supported in {file_name} files.

Acceptance (F6)

  • Port the unit tests in each versioned_file/*.rs.
  • Fixtures: tests/prepare_release/{cargo,npm,deno,gleam_toml,go_modules,pubspec_yaml,pyproject_toml,tauri_conf_json,readme_version}, tests/bump_version/*.

F7: Packages and version resolution

Sources: knope-versioning/src/package.rs, crates/knope/src/step/releases/package.rs

F7.1 Loading

AllVersionedFiles = for each package, for each versioned_file config: read the file, then versionedfile.New(config, content, gitTags)
for each package:
    validated, versionFromFiles = validateVersionedFiles(pkg.configs, AllVersionedFiles)
    versions = PackageVersions.FromTags(pkg.name.customOrNil, gitTags)
    if versionFromFiles != nil { versions.update(versionFromFiles) }
    releaseNotes = {Sections: convertSections(pkg.extra_changelog_sections),
                    Changelog: loadChangelog(pkg.changelog), ChangeTemplates: cfg.release_notes.change_templates}
    goVersioning = IgnoreMajorRules if pkg.ignore_go_major_versioning else Standard

Note: the same path listed in two packages is parsed twice into AllVersionedFiles (once per entry). Edits still apply by path through the SetVersion chaining.

F7.2 validateVersionedFiles

For each config, in order:

  1. Find the parsed file with the same path (the error Versioned file not found means a bug).
  2. Dependency validation:
    • Cargo.lock with no dependency: default it to the package.name of the first non-dependency Cargo.toml in the same package. None → Cargo.lock must specify a dependency.
    • Dependency on an unsupported format → error (F6.3).
  3. Dependency entries don’t count toward the package version. For the rest, file.Version() must equal the first-seen version, else: Found inconsistent versions in package: {p1} had {v1} and {p2} had {v2} (code remeris::inconsistent_versions, help All files in a package must have the same version).
  4. Return the validated configs and the first version (if any).

F7.3 Package version

LatestVersion() = versions.Latest(). So a package’s “current version” is the newest of: files version, latest stable tag, and pre-release tags after it.

F7.4 Setting a version

SetVersion(version, goVersioning, allFiles):
    for each file in allFiles:
        for each config in pkg.configs where config.Path == file.Path(): file = file.SetVersion(version, config.Dependency, goVersioning)
    pkg.versions = PackageVersions.From(version)   // resets history to just this version

F7.5 Tag format (ReleaseTag)

  • Default package: v{version}. Named package: {name}/v{version}.
  • IsReleaseTag(tag, pkg) = the tag has the package prefix and the rest parses as a Version.

F8: BumpVersion step

Source: step/releases/semver.rs

TOML: rule = "Major"|"Minor"|"Patch"|"Release" or rule = "Pre" with label = "rc". Pre maps to Rule{Pre, label, stable: Patch}.

for each package:
    if pkg.overrideVersion != nil { v, gv = *override, BumpMajor }
    else { v = pkg.versions.CalculateNewVersion(rule); gv = pkg.goVersioning }   // error PreReleaseNotFound for Release w/o pre
    AllVersionedFiles = pkg.SetVersion(v, gv, AllVersionedFiles)
actions = flatten(file.Write() for file in AllVersionedFiles)   // drained
execute(actions, stageToGit=false)   // F13.4, AddTag/CreateRelease are ignored here

No package → nothing happens (no error). BumpVersion does not stage files to git and does not write a changelog.

Acceptance (F8)

  • Fixtures tests/bump_version/*.

F9: Git integration

Source: integrations/git.rs. Define an interface so it can be faked in tests:

type Git interface {
    AllTagsOnBranch() ([]string, error)
    CommitMessagesAfterTag(tag *string) ([]Commit, error)
    FirstCommitsForFiles(paths []string) map[string]*GitInfo
    AddFiles(paths []string) error
    CreateTag(name string) error         // lightweight? see F9.5
    HeadCommitSHA() (string, error)
    CurrentBranch() (string, error)
    FirstRemoteURL() (string, bool)
    // F19:
    LocalBranches() ([]string, error); BranchExists(name string) bool
    CreateBranch(name, from string) error; Checkout(name string) error
    HasUncommittedChanges() (bool, error); Rebase(ctx, to string) error   // F19.8
}

F9.0 Backends (decision D2)

Two implementations of the Git interface, chosen once at startup:

  1. git CLI (primary): used when exec.LookPath("git") succeeds. Every method runs git through os/exec with -C <cwd>, GIT_TERMINAL_PROMPT=0 and LC_ALL=C (stable output to parse).
  2. go-git fallback: github.com/go-git/go-git/v5 (v5.19.2, 2026-07-29) when git is not on PATH. Log at debug: git not found on PATH, using built-in Git support.
Method go-git fallback
AllTagsOnBranch, CommitMessagesAfterTag, FirstCommitsForFiles, HeadCommitSHA, CurrentBranch, FirstRemoteURL, LocalBranches, BranchExists Full support (Repository.Tags, Log, Tree.File, Head, Remotes, Branches)
AddFiles Worktree.Add per path; Worktree.Remove for deleted paths
CreateTag Repository.CreateTag with CreateTagOptions{Message: "", Tagger: …}. The tagger comes from git config (user.name/user.email, read with config.LoadConfig(GlobalScope) + repo config), else the same Could not determine Git committer… error
CreateBranch, Checkout, HasUncommittedChanges Repository.CreateBranch + Storer.SetReference, Worktree.Checkout{Force: true}, Worktree.Status (respects .gitignore)
Rebase Same as the CLI backend: fast-forward only on both backends (D6). It refuses a dirty tree, a missing to and a to that is not an ancestor of (or equal to) HEAD (remeris::git::not_fast_forward). Otherwise it sets refs/heads/<to> to HEAD’s commit (a compare-and-set on the old tip) and switches to to; a failed switch after the move is remeris::git::switch_after_move. A to that is a symbolic ref is remeris::git::branch_alias.

Both backends must pass the same contract test suite (internal/git/contract_test.go), run against temporary repositories. The go-git run sets PATH to an empty dir to force the fallback.

F9.1 AllTagsOnBranch

  1. Map commit OID → the tag names pointing at it (tags peeled to commits, both annotated and lightweight), in reference-iteration order.
  2. Walk from HEAD using libgit2’s default revwalk order (reverse chronological by commit time; Knope sets no sorting). For each commit, append its tags.
  3. Tags not reachable from HEAD are dropped (logged at debug: Skipping relevant tags that are not on the current branch: …). Result: tags newest first. git log --format=%H from HEAD, joined with git for-each-ref refs/tags --format='%(objectname) %(*objectname) %(refname:strip=2)', gives the same result.

F9.2 CommitMessagesAfterTag(tag)

  • If the tag is given and exists: collect all ancestors of the tag’s commit (including it) into an exclude set. If it is missing, log at debug and use all commits.
  • Walk from HEAD. Keep the commits not in the exclude set. Record the full message, the number of parents and GitInfo{hash: first 7 hex chars, author name}. A commit with more than one parent is a merge commit (F13.1).
  • Reverse the list so it is oldest first.
  • Equivalent to git log --reverse <tag>..HEAD.

Which tag is used: PackageVersions.FromTags(pkg).stable turned back into a tag (v1.2.3 / name/v1.2.3). That is the last stable release. Pre-releases don’t reset the commit window, so 1.3.0-rc.1 → final 1.3.0 includes every change since 1.2.x.

F9.3 FirstCommitsForFiles(paths)

Walk commits topologically in reverse (oldest first). For each commit, for each path not found yet: if the commit’s tree has the path, record GitInfo from that commit. Paths never found → nil. Equivalent to git log --diff-filter=A --format=... -- path (take the oldest). Errors are swallowed and give an empty result.

F9.4 AddFiles = git add of all paths (the index is written). No-op when empty. Paths that were removed must

be staged as deletions too (libgit2 add_all does this for tracked deleted files; with the CLI use git add -A -- <paths>).

F9.5 CreateTag(name)

Knope calls repo.tag(name, HEAD, signature, "", force=false). That makes an annotated tag with an empty message, using the configured committer (user.name and user.email must be set, else Could not determine Git committer…). Go: git tag -a <name> -m "" (use --cleanup=verbatim so an empty message is allowed). It fails if the tag already exists.

F9.6 Misc

  • HeadCommitSHA: full 40-char SHA.
  • CurrentBranch: fails with Not on the tip of a Git branch. when HEAD is detached. Quirk Q3: Knope returns the full ref name (refs/heads/main). See F18 and F19 for where this leaks.
  • FirstRemoteURL: URL of the first remote (git remote order).

F10: Conventional commits

Sources: knope-versioning/src/changes/{mod,conventional_commit}.rs. Knope uses the git-conventional crate. Copy its grammar.

F10.1 Parsing (on strings.TrimSpace(message))

first line := type ["(" scope ")"] ["!"] ": " description
type       := 1+ chars, not whitespace, "(", ")", ":" or "!"   (git-conventional: a "noun")
body       := optional paragraphs after a blank line
footers    := the final paragraph, if EVERY line in it is a footer:
              token ": " value   |   token " #" value
              token := "BREAKING CHANGE" | "BREAKING-CHANGE" | [A-Za-z0-9-]+  (no spaces)
              a value may continue onto following lines that are not footers
  • Messages that don’t parse are not changes at all. Their footers are ignored as well.
  • breaking = ! present OR any footer token is BREAKING CHANGE / BREAKING-CHANGE.
  • Type comparison (feat, fix) and footer-token comparison are case-insensitive (unicase).
  • Pick a Go library carefully, or hand-write the parser. Test it against git-conventional’s own test cases.

F10.2 From commit to changes

Inputs: commits (oldest first), package scopes, and sections (F12.1).

  1. If the commit has a scope and scopes is set: skip the commit unless some scope matches, compared ASCII case-insensitively. Commits with no scope always count.
  2. For each footer:
    • Breaking footer → Change{Breaking, summary: footer.value} and remember hasBreakingFooter.
    • Else if some section lists this footer token (case-insensitive) → Change{Custom(Footer token), value}.
    • Else skip it.
  3. Description change: Breaking if breaking && !hasBreakingFooter. Else Feature if the type is feat. Else Fix if the type is fix. Else no description change. Summary = description.
  4. The footer changes come before the description change in the output.
  5. Every change gets GitInfo from the commit and ChangeSource.ConventionalCommit{description}. Here description = "{type}{(scope)}{!}: {desc}" (the ! only if breaking came from ! and not from a footer). Footer changes get "{summary}\n\tContaining footer {token}{sep} {value}". These are used in verbose logs: {source}\n\timplies rule {MAJOR|MINOR|PATCH}.

Note: custom types (such as docs: listed in extra_changelog_sections.types) do not create changes from commits. They only apply to change files. Only custom footers apply to commits.

Acceptance (F10)

  • Port the unit tests in conventional_commit.rs. Fixtures tests/prepare_release/{scopes,verbose,...}.

F11: Changesets and the CreateChangeFile step

Sources: the changesets crate (change.rs, changeset.rs, versioning.rs), step/create_change_file.rs

F11.1 Change file format (.changeset/<id>.md)

---
<package-name>: <change-type>
<package-name>: <change-type>
---

<summary: markdown, usually starting with "# Title">

Parsing:

  1. The first line trimmed must be --- (MissingFrontMatter).
  2. Then lines until one that trims to ---: each is name: type (split on the first :, trim both sides). A line without : → InvalidFrontMatter. Names are used as written: no quote stripping. Duplicate names: the last one wins (it is a map). An empty map → Versioning needs to contain at least one item.
  3. The closing --- must be there.
  4. Summary = the rest, with leading blank lines skipped, joined with \n.
  5. Change type: patch | minor | major, anything else is Custom(s) (case-sensitive).
  6. unique_id = the file name without .md. Only regular files ending in .md directly in .changeset/ are read.

ChangeSet.FromDirectory: group by package name into Release{package, changes} (in the order first seen). Within a package, sort changes by unique_id (plain string compare, so uppercase comes before lowercase).

Map to a package: find the release whose package_name == pkg.name (default for the single [package]).

F11.2 Change file → Change

  • summary = the first non-empty line of the trimmed summary, with leading # and space characters removed.
  • details = the remaining lines, leading blank lines skipped, joined with \n. Empty → nil.
  • Type: major → Breaking, minor → Feature, patch → Fix, custom → Custom(ChangeType(s)).
  • GitInfo: from FirstCommitsForFiles(".changeset/<id>.md"). It can be nil when the file is uncommitted.
  • Source: ChangeFile{id} (displayed as changeset {id}.md).

F11.3 UniqueId.normalize(summary)

For each character: ASCII alphanumeric → lowercase it. ' ', '_' or '-' → _, but only if the previous output character was not _ from this rule. Anything else is dropped (and does not reset the underscore flag). Example: `[i carry your heart with me(i carry it in]` → i_carry_your_heart_with_mei_carry_it_in.

F11.4 CreateChangeFile step

  1. Dry run → log Would create a new change file and return.

  2. Packages: if there is exactly 1, use it. Otherwise MultiSelect Which packages does this change affect? (at least one: At least one package must be selected).

  3. For each chosen package, Select a change type from the package’s sections, in section order, keeping only the sources that map to a changeset type (Breaking → major, Feature → minor, Fix → patch, custom type → the name; footers are left out). Prompt: What type of change is this for {name}?, or What type of change is this? for the default package.

  4. Text prompt What is a short summary of this change?, help This will be used as a header in the changelog.

  5. Create .changeset/ if it is missing. Write .changeset/{normalize(summary)}.md with this content:

    ---
    {pkg}: {type}      # one line per package (Knope: HashMap order, i.e. random; Go: use selection order)
    ---
    
    # {summary}

    (the file ends with a newline). Overwrites a file with the same name if one exists.


F12: Release notes and changelogs

Sources: knope-versioning/src/release_notes/*, knope-config/src/changelog_section.rs

F12.1 Sections

Defaults, in order:

name source
Breaking Changes Breaking
Features Feature
Fixes Fix
Notes footer Changelog-Note

convertSections(extra):

defaults := the default list above
sections := []
for each {name, footers, types} in extra (in order):
    sources := footers (as Custom(Footer)) followed by types (as Custom(Type), where major/minor/patch map to the built-ins)
    remove from defaults any entry whose source is in sources          // override
    while a default with the same name exists: add its source to sources, remove it   // merge by name
    sections = append(sections, (name, sources))
return remaining defaults (in original order) + sections

So custom sections always come after the remaining defaults. Overriding a default source moves it to the new section.

F12.2 Rendering a release (CreateRelease)

notes := ""
for (name, sources) in sections:
    cs := changes whose type is in sources, STABLE-sorted so that no-details comes before has-details
    if len(cs)==0: continue
    if notes != "": notes += "\n\n"
    notes += "## " + name + "\n\n"
    for i, c in cs:
        writeChange(c)
        if next exists: notes += "\n" if next has no details else "\n\n"
title := "{version} ({YYYY-MM-DD, UTC today})"

The notes are at “H1 level”: sections use ## and complex changes use ###. Input change order: commit changes (oldest commit first), then changeset changes (sorted by id).

writeChange: try each template in order (F12.3). If none applies, use the default: complex → ### {summary}\n\n{details}, simple → - {summary}.

F12.3 Change templates ([release_notes].change_templates)

Variables: $summary, $details, $commit_author_name, $commit_hash, $pr_number, $pr_author_login. A template applies only if every variable it contains can be filled:

  • $commit_author_name / $commit_hash need GitInfo.
  • $pr_author_login / $pr_number need the PR fields (F16.4).
  • $details needs details. Substitution order: author/hash → pr login → pr number → details → summary (plain replace-all). Templates that don’t apply are skipped. The first one that applies wins.

Forge-data check: if any template contains $pr_author_login or $pr_number (checked in that order), then PrepareRelease needs [github] (F13.2).

F12.4 Changelog writing

Changelog{path, content, releaseHeaderLevel}:

  • releaseHeaderLevel: take the second line starting with #. If it starts with ## → H2, else if it starts with # → H1. If there is no second header → H2. (The first # line is assumed to be the title.)

  • WithRelease(release):

    body := release.notes, with one extra "#" added to each line starting with "#" when the level is H2
    new  := "{level} {title}\n\n{body}"
    insert `new + "\n\n"` right before the first line that parses as a release title (F12.5 parseTitle)
    (checks every line, header level ignored); if there is none, append `new` at the end
    keep the trailing newline: if the original ended with "\n" or was empty, make sure the output ends with "\n"

    Each original line is copied with \n added (so CRLF becomes LF). Returns new as the diff, and the action is WriteToFile{diff: "\n" + new + "\n"}.

F12.5 Reading a release from a changelog (GetRelease(version))

  • parseTitle(line): whitespace tokens. tok0 ∈ {#, ##}, tok1 = a Version. Other tokens may contain a (YYYY-MM-DD) date.
  • Scan for a line starting with "{level} {version}" that parses with the same level and an equal version. Title = the line with leading # removed, trimmed (for example 1.2.3 (2023-01-01)).
  • Notes = the following lines up to (not including) the next line starting with "{level} ". For H2 changelogs, remove one # from lines starting with ##. Join with \n. Empty → not found.

F12.6 Actions produced for a package release

[WriteToFile(changelog) if changelog is configured, CreateRelease(Release{title, version, notes, package})].


F13: PrepareRelease step

Source: step/releases/mod.rs::prepare_release, package.rs

if no packages → error "No packages to operate on"   (code package::no_defined_packages)
needsForge := first template variable needing forge data (F12.3)
changeset  := ChangeSet.FromDirectory(".changeset") if the dir exists, else empty   // parse errors → CouldNotReadChangeSet
for pkg in packages:
    changes := gatherChanges(pkg)
    if len(changes)>0 && needsForge != "":
        if no [github] → error "You must configure [github] to use the variable {var} in a change template"
        enrich via GitHub (F16.4); on error only warn "Failed to enrich git info from GitHub: {e}"
    if len(changes)==0 && pkg.override == nil: continue      // no actions for this package
    cfg := Force(override) (consumes it) or Calculate{prerelease_label, pkg.goVersioning}
    version := Force → override ; Calculate → versions.CalculateNewVersion(Pre{label, maxRule} or Stable(maxRule))
    AllVersionedFiles = pkg.SetVersion(version, gv, AllVersionedFiles)
    actions += RemoveFile(".changeset/{id}.md") for each change-file change, ONLY IF version is NOT a pre-release
    actions += releaseNotes.CreateRelease(version, changes, pkg.name)          // F12.6 (mutates changelog content)
    PendingActions += actions
all := dedupe(flatten(file.Write() for file in AllVersionedFiles) ++ PendingActions)   // exact-equality dedupe, keep first
PendingActions = execute(all, stageToGit=true)
if REAL run && !allow_empty && len(PendingActions)==0 → error "No packages are ready to release"
   (help: "The `PrepareRelease` step will not complete if no changes cause a package's version to be increased.")

F13.1 gatherChanges(pkg)

  • ignore = step ignore_conventional_commits (log the deprecation warning when it is set) OR [changes].ignore_conventional_commits.
  • commits = ignore ? none : CommitMessagesAfterTag(lastStableTag(pkg)) (F9.2). With [changes].ignore_conventional_merge_commits, the commits with more than one parent are dropped from that list, whatever their message says (Knope: commit.parent_count() > 1); the commits they merge in still count.
  • Change files for this package (sorted by id), each with GitInfo (F11.2).
  • Result = commit changes (F10.2) followed by change-file changes (F11.2).
  • Implement both as changes.Source values (Phase 2 hook, E3), run in that fixed order in Phase 1.

F13.2 Pre-releases

  • prerelease_label comes from the step, overridden at runtime by --prerelease-label / REMERIS_PRERELEASE_LABEL (or KNOPE_PRERELEASE_LABEL).
  • Change files are kept for pre-releases so that the final release picks them up again.
  • Commits are always counted from the last stable tag, so each pre-release changelog entry lists all changes since that stable release.

F13.3 Changesets shared between packages

One change file can name several packages. The RemoveFile action is created for each package, and duplicates are removed by the dedupe step. A remove error is ignored, because another package may have deleted the file already.

F13.4 Executing actions (execute(actions, stageToGit))

In order:

  • WriteToFile: dry run → Would add the following to {path}: {diff}. Real → write the file (bytes as given, no newline added).
  • RemoveFile: dry run → Would delete {path}. Real → remove the file, ignoring errors.
  • The path of every write and remove goes onto the staging list, in order.
  • AddTag and CreateRelease are returned (they become the new PendingActions).
  • If stageToGit: dry run logs Would add files to git: and then {path} for each path (only if there are any). Real → git add (F9.4).

The action order in the dry-run output is therefore: versioned files (in the order parsed) → go.mod AddTag is not printed → change-file deletes → changelog writes → staging list. Check against tests/prepare_release/changesets/happy/dryrun_stdout.log.

Acceptance (F13)

All of tests/prepare_release/* (more than 40 scenarios): allow_empty, branching_history, enable_prerelease, second_prerelease, release_after_prerelease, prerelease_after_release, handle_pre_versions_that_are_too_new, override_version(+multiple), override_prerelease_label, scopes, multiple_packages, package_selection, no_version_change, custom_change_entries, ignore_conventional_commits(_toplevel), etc.


F14: Release step (Git tags)

Source: step/releases/mod.rs::release

if len(PendingActions)==0:                                  // Release run WITHOUT PrepareRelease in this workflow
    for pkg in packages:
        rel := findPreparedRelease(pkg); if nil continue
        PendingActions += CreateRelease(rel)
        // Go-module tags: re-apply the version to get the go.mod AddTag actions
        files := pkg.SetVersion(rel.version, pkg.goVersioning, clone(AllVersionedFiles))  (errors → none)
        PendingActions += the first AddTag action from each file.Write()
for action in PendingActions (drain):
    AddTag{tag}: create the tag UNLESS it is a package release tag of any package (IsReleaseTag, F7.5)
    CreateRelease{rel}:
        tag := ReleaseTag(rel.version, rel.package)
        if [github]: github release (F16.2) with the assets of the matching package
        if [gitea]:  gitea release (F17.2)
        if neither:  CreateTag(tag)        // dry run: "Would create Git tag {tag}"

findPreparedRelease(pkg):

  1. current := pkg.LatestVersion(). None → skip.
  2. lastTag := PackageVersions.FromTags(pkg, AllGitTags).Latest(). If lastTag == current → nothing to release.
  3. Release = changelog.GetRelease(current) if found. Otherwise Release{title: current.String(), notes: "", ...} (the forge then auto-generates notes, F16.2).

Forge release name: "{pkgName} {title}" for named packages, else title. Body = notes trimmed. Pre-release flag = version.IsPrerelease(). With forges configured, no local tag is created: the forge creates it at target_commitish = HEAD SHA.

Acceptance (F14)

  • Fixtures tests/git_release/*, tests/default_workflows/*.

F15: HTTP client and credentials

Sources: integrations/http.rs, app_config.rs, prompt.rs

F15.1 Client

  • User-Agent: Remeris.
  • Retry: exponential backoff, up to 5 retries on transient errors (connect errors, 5xx, 408, 429). Honour Retry-After (seconds or HTTP date).
  • On 429 with Retry-After: warn API rate limited; retrying in {n} seconds (or retrying after {value}). If unauthenticated, also warn To increase your rate limit, set the GITHUB_TOKEN environment variable.
  • Status errors are wrapped as ApiRequestError: Trouble communicating with {service} while {activity}: {status error} with body: {body}. JSON decode errors: Trouble decoding the response from {GitHub|Gitea} while {activity}: …, whose help names GitHub or the Gitea instance at {host} (httpx.NewDecodeErrorFor takes the two names apart).
  • httpx.Guard and httpx.URLPolicy hold the credential-host rules both forges share: credentials only go to the allowed origins, a redirect to another host or port or from https to http is refused, and plain http is accepted for loopback hosts only. The service name and the diagnostic code prefix (remeris::github, remeris::gitea) are parameters.

F15.2 Credential store

Directory: os.UserConfigDir()/remeris/ (Knope used …/knope/, the same base as Rust dirs::config_dir()). One file per key, holding the raw value. Migration: when a key is missing from remeris/, read it from knope/ and copy it into remeris/, so existing Knope users are not prompted again.

key env override prompt
github_token GITHUB_TOKEN No GitHub token found, generate one from https://github.com/settings/tokens with 'repo' permissions and input here
gitea_token GITEA_TOKEN No Gitea token found, generate one from {host}/user/settings/applications with\n'repository' permissions set to 'Read and Write' and 'issue' permissions set to 'Read' and input here
jira_token (none) No Jira token found, generate one from https://id.atlassian.com/manage-profile/security/api-tokens and input here
email (none) Input your email address

Order: env var → stored file → prompt (masked, with display toggle, no confirmation) → save to file (create the dir). Note: the stored gitea token is not per host (binding it to a host is deferred). A Gitea token is trimmed of surrounding white space before it goes into a header; an empty one is No Gitea token was given. Prompt failure / non-interactive → Failed to get user input (help: This step requires user input, but no user input was provided. Try running the step again.).


F16: GitHub integration

F16.0 Client: google/go-github (decision D10)

Every GitHub call goes through github.com/google/go-github/v92 (v92.0.0, 2026-09-14, needs Go 1.26). No hand-written REST calls. The HTTP behaviour from F15.1 (User-Agent, retries, rate-limit warnings) lives in the http.Client handed to go-github:

httpClient := &http.Client{Transport: httpx.NewRetryTransport(authTransport)}   // F15.1
gh := github.NewClient(httpClient)
gh.UserAgent = "Remeris"
if cfg.GitHub.APIURL != "" { gh, _ = gh.WithEnterpriseURLs(cfg.GitHub.APIURL, cfg.GitHub.UploadURL) }

APIURL/UploadURL are new optional [github] keys (api_url, upload_url). They give GitHub Enterprise support and let tests point the client at an httptest.Server. Unknown to Knope, but ignored by it, so the file stays compatible.

The two keys are not trusted on their own, because a repository’s configuration must not choose where the GitHub token goes. The URLs that may receive credentials come from --github-api-url / REMERIS_GITHUB_API_URL and --github-upload-url / REMERIS_GITHUB_UPLOAD_URL. With only the flag or variable, it is used as is. A configured api_url or upload_url is used only when it equals the trusted value after normalisation (lowercase scheme and host, default port removed, one trailing slash); otherwise building the client fails with remeris::github::untrusted_api_url or remeris::github::untrusted_upload_url. Credentials are attached only to requests for the scheme, host and port of the API and upload URLs, a redirect to another host or from https to http is refused, and plain http is accepted only for loopback hosts (remeris::github::insecure_url).

Knope call go-github method
Create release Repositories.CreateRelease(ctx, o, r, &github.RepositoryRelease{TagName, Name, Body, Prerelease, Draft, GenerateReleaseNotes, TargetCommitish})
Upload asset Repositories.UploadReleaseAsset(ctx, o, r, releaseID, &github.UploadOptions{Name: name}, file). go-github expands the upload URL, so no URI-template code is needed.
Publish draft Repositories.EditRelease(ctx, o, r, id, &github.RepositoryRelease{Draft: github.Ptr(false)})
Find existing PR PullRequests.List(ctx, o, r, &github.PullRequestListOptions{Head: owner+":"+branch, Base: base})
Update PR PullRequests.Edit(ctx, o, r, number, &github.PullRequest{Title, Body})
Create PR PullRequests.Create(ctx, o, r, &github.NewPullRequest{Title, Body, Head: branch, Base: base})
PRs for a commit (F16.4) PullRequests.ListPullRequestsWithCommit(ctx, o, r, sha, nil)
List issues (F19.1) GraphQL through githubv4 (0.3), on the HTTP client of the go-github client; no go-github method.

Errors: wrap *github.ErrorResponse / *github.RateLimitError / *github.AbuseRateLimitError into the same ApiRequestError message (Trouble communicating with GitHub while {activity}: {status} with body: {message}).

F16.1 Auth

Token sources, in order:

  1. GitHub App (E2, available from M7 because it is just another token source): when an App ID and a private key are configured (F3.1 flags/env vars), use github.com/jferrl/go-githubauth to mint an installation token. githubauth.NewApplicationTokenSource(appID, pem) → githubauth.NewInstallationTokenSource(installationID, appTS) → oauth2.NewClient. If no installation ID is given, look it up once with the app JWT (Apps.FindRepositoryInstallation(ctx, owner, repo)). Tokens refresh automatically.
  2. GITHUB_TOKEN env var.
  3. Stored token (F15.2).
  4. Prompt (only for RequireAuthentication).
  • RequireAuthentication: sources 1 → 4.
  • MaybeAuthenticated: sources 1 → 3, no prompt. Anonymous is allowed (used for PR lookups on public repos).
  • The client is built once and cached in State.
  • Log at debug which source was used (Using GitHub App installation {id} / Using GITHUB_TOKEN), never the token.

F16.2 Create release

The wire payload (built by go-github from RepositoryRelease) must be:

{"tag_name": "...", "name": "...", "body": "... (omitted when empty)", "prerelease": false,
 "generate_release_notes": <body empty>, "draft": <assets configured>, "target_commitish": "<HEAD sha, omitted if unknown>"}

With assets:

  1. Resolve assets: glob → every match (name = file name), or the explicit list (name = name, else the file name).
  2. For each: read the file (Could not read asset file {path}: … Release has been created but not published!), then upload it with UploadReleaseAsset (content type application/octet-stream).
  3. Publish: EditRelease with draft: false.

Dry run: Would create a {release|prerelease} on GitHub with name {name} and tag {tag}[ at commit {sha}] and {body:\n<notes> | autogenerated body}, then Would upload assets to GitHub: and - {name} from {path} for each asset.

F16.3 Create or update PR, see F18

F16.4 PR info enrichment (for $pr_number / $pr_author_login)

Dry run: log Would fetch Pull Request info from GitHub repo {owner}/{repo} for {n} changes, and set each change that has GitInfo to pr_number=1234, pr_author_login="some-user". Real: for each change with GitInfo, ListPullRequestsWithCommit with the short hash (Knope sends X-GitHub-Api-Version: 2026-03-10; go-github sets its own API version header). Take the first PR: number and user.login. Errors for one commit → warn and continue. Nothing found → debug log.

F16.5 Issues, see F19.1


F17: Gitea integration

Config: {owner, repo, host}. Base = {host}/api/v1, with the trailing slashes of host removed (a path prefix such as https://example.com/gitea is kept). The code is in internal/forge/gitea, a leaf package.

Auth (decision, changes Knope): the header Authorization: token {token}, not the access_token query parameter Knope sends: a token in a URL ends up in logs, proxies and error messages. The token is never part of a URL. It comes from GITEA_TOKEN, the stored token or a prompt (F15.2), is asked for at most once per run, and never in a dry run. It is also replaced by [REDACTED] in every error message the package produces.

The host is trusted, but hardened (decision): it is a required key of [gitea], so unlike GitHub’s api_url it needs no confirmation on the command line. When a client is built (a real run only; a dry run prints host as written):

  • it must be a URL with a scheme: otherwise remeris::gitea::invalid_host, as for userinfo (user:pw@), a query or a fragment, spaces and control characters. The message never repeats a host that may hold a password;
  • the scheme is https; http is accepted only for localhost and other loopback addresses (remeris::gitea::insecure_url, and remeris::gitea::invalid_url for any other scheme);
  • a redirect that changes the host or the port, or goes from https to http, is refused (remeris::gitea::redirect), and Authorization is never sent to another origin. These rules are those of httpx.Guard and httpx.URLPolicy, shared with GitHub.

Requests send Accept: application/json, a JSON body when there is one, and User-Agent: Remeris. The retry policy is F15.1’s, without the GITHUB_TOKEN hint. Errors name the service as the host written in the configuration: Trouble communicating with {host} while {activity}: {status error} with body: {body}. A response that does not decode gives Trouble decoding the response from Gitea while {activity}: {error} (code remeris::gitea::api_response_error, help Failure to decode a response from the Gitea instance at {host} is probably a bug. Please report it at {issue tracker}). The activities are creating a release, fetching existing pull requests, updating pull request and creating pull request.

  • Releases: POST {base}/repos/{o}/{r}/releases. Same JSON as GitHub, with draft=false (assets are not supported). Dry run: Would create a {kind} on Gitea [{host}] with name … and tag …[ at commit …] and ….
  • Pulls: Knope sends GET {base}/repos/{o}/{r}/pulls?state=open&head={owner}:{branch}&base={base} and takes the first result. Deviation: Gitea’s list endpoint has no head filter (its query parameters are state, sort, milestone, labels, poster, page, limit and base_branch), so a server ignores head and Knope may update an unrelated open pull request. remeris sends GET …/pulls?state=open&base_branch={base}&limit=50&page={n}, reads pages until an empty one (a server may cap the page size below 50) (at most 20 pages, then it creates), and takes the first pull request whose head.ref is the branch, whose head.repo.owner.login is the configured owner (case-insensitive; a null head.repo, a deleted fork, never matches) and whose base.ref is the base. Unknown or missing JSON fields are tolerated. Then PATCH …/pulls/{number} {body,title}, or POST …/pulls {title,body,head,base} (any JSON answer is accepted).
  • Issues: GET {base}/repos/{o}/{r}/issues?labels={comma-joined}&state=open&limit=30&type=issues → [{number,title}]. Gitea’s endpoint also returns pull requests unless type=issues is sent (Knope does not send it: deviation).
  • TryFromRemote(url): git@host:owner/repo(.git) → host https://host. https://host/owner/repo(.git) → the same. Anything else → none. Known public Gitea hosts for auto-detection: codeberg.org.

F18: CreatePullRequest step

  1. Substitute variables into title and body (F4.4).
  2. No [github] and no [gitea] → No forge has been configured.
  3. For each configured forge (GitHub first, then Gitea):
    • branch = CurrentBranch(). Dry run: Would create or update a pull request from {branch} to {base}:, \tTitle: {title}, \tBody: {body}.
    • GitHub (go-github, F16.0): PullRequests.List with Head: owner+":"+branch, Base: base. If one exists → PullRequests.Edit with {title, body}. Else → PullRequests.Create with {title, body, head: branch, base}.
    • Gitea: as in F17 (Pulls), with the full short branch name as branch (Q3).

Quirk Q3: for GitHub, Knope sends head=owner:refs/heads/<branch> and "head": "refs/heads/<branch>", and the dry-run text shows refs/heads/main. Decision: use the short branch name everywhere in Go (this fixes a real bug). Update the three tests/create_pull_requests/*/stdout.log golden files and record the deviation. Using the last path segment (as Knope’s Gitea code does) is also wrong for branches that contain /, so Gitea gets the full short name too, in the dry-run text, the lookup and the head of the new pull request (git rev-parse --abbrev-ref HEAD).


F19: Issue steps

F19.1 SelectGitHubIssue { labels? }

  • Dry run: needs [github] (NotConfigured). Logs Would query configured GitHub instance for issues with labels a, b (or with any labels), then Would prompt user to select an issue and move workflow to IssueSelected state. Sets the fake issue {key:"123", summary:"Test issue"}.
  • Real: needs auth. Send Knope’s GraphQL query unchanged, with shurcooL/githubv4 (a struct query; repo, owner and labels are variables, labels is [String!] and null without labels): repository(name: $repo, owner: $owner) { issues(states: OPEN, first: 30, labels: $labels) { nodes { number title } } }. Labels match any of the values; there is no orderBy (GitHub’s default order, as in Knope), no paging and no pull requests. The request uses the HTTP client of the go-github client, so the auth chain and the credential guard apply. The endpoint follows the API URL: https://api.github.com/graphql; GitHub Enterprise Server https://HOST/api/v3/ -> https://HOST/api/graphql; an api. host without path (data residency) -> /graphql; any other shape is refused (remeris::issues::github::graphql_url). A failed request, a non-200 status or a GraphQL errors array gives Could not communicate with GitHub while loading issues: {error} (code remeris::issues::github::api). Show a Select Select an Issue with items "{key}: {summary}". Log Selected item : {issue}.

F19.2 SelectGiteaIssue { labels? }

The same flow with the Gitea API (F17). Dry-run text says Gitea instance. Fake issue {123, "Test issue"}.

F19.3 SelectJiraIssue { status }

  • Needs [jira]. Dry run: Would query configured Jira instance for issues with status {status} + the prompt line, and the fake issue {FAKE-123, Test issue}.
  • Real: auth = Basic base64("{email}:{jira_token}") (both from the credential store, F15.2). Decision (D5): use the current endpoint, not Knope’s deprecated /rest/api/3/search: POST {url}/rest/api/3/search/jql with {"jql": "status = {status} AND project = {project}", "fields": ["summary"], "maxResults": 50}. Response: issues[].{key, fields.summary} (plus nextPageToken, ignored: like Knope, only the first page is shown) → Select. Quote status in the JQL (status = "In Progress") so multi-word statuses work; Knope did not.

F19.4 TransitionJiraIssue { status }

Needs a selected issue (NoIssueSelected, help You must use the SelectJiraIssue step before TransitionJiraIssue in the same workflow) and [jira]. Dry run: Would transition currently selected issue to status {status}. Real: GET {url}/rest/api/3/issue/{key}/transitions → find the transition whose name == status (exact match), else Transition error. Then POST the same URL with {"transition": {"id": id}}. Log {key} transitioned to {status}.

F19.5 SelectIssueFromBranch

  • Dry run: log Would attempt to parse current branch name to select current issue and set {123, Fake Issue}.
  • Real: split the branch name on -. If the first part parses as an integer → GitHub-style {key: n, summary: rest joined "-"}. Otherwise the second part must be an integer → Jira-style {key: "{p0}-{p1}", summary: rest}. Else Bad branch name. Log Auto-selecting issue {key} from ref {branch}.
  • Quirk Q3: Knope parses the full ref (refs/heads/ABC-123-x), which gives key refs/heads/ABC-123, and a GitHub-style branch never matches. Go uses the short name.

F19.6 branchNameFromIssue

strings.ReplaceAll(fmt.Sprintf("%s-%s", key, asciiLower(summary)), " ", "-"). Only spaces are replaced. Other characters are kept (they may be invalid in Git refs, and Knope does not clean them either).

F19.7 SwitchBranches

Needs a selected issue. Dry run: Would switch to or create a branch named {name}. Real:

  • The local branch exists → Found existing branch named {name}, switching to it. → checkout.
  • Otherwise Creating a new branch called {name} → Select Which branch do you want to base off of? from the local branches → create the branch at the chosen branch’s commit → checkout.
  • Checkout refuses when there are uncommitted changes (any status entry for a path that is not ignored) → Uncommitted changes. Checkout is a forced checkout of HEAD after setting HEAD to the branch.

F19.8 RebaseBranch { to }

What Knope actually does (its docs and log text say otherwise): it calls libgit2 repo.rebase(branch = to, upstream = None, onto = HEAD) and finishes it. That never replays a commit. After it refuses a dirty tree it sets refs/heads/<to> to HEAD’s commit and switches to to, so the commits that are only on to are lost. “Don’t forget to push!” means push to. Knope has no tests for this step.

Decision (D6, revised by the user in the M11 kickoff, tracking issue #96): fast-forward only, on both Git backends.

  • Dry run: Would rebase current branch onto {to} (Knope’s wording). It checks nothing and opens no Git.
  • Real: Git.Rebase(ctx, to). HEAD must be on a branch, the working tree must be clean (Uncommitted changes) and to must be an existing local branch (remotes are not checked). When to has commits that are not in the current branch it fails with Cannot fast-forward branch {to} to {cur}: {to} has commits that are not in {cur} (code remeris::git::not_fast_forward, help: bring those commits into the current branch, then run the step again). Otherwise to is moved to HEAD’s commit and checked out, and the step logs Rebased current branch onto {to} and Switched to branch {to}, don't forget to push!. The files do not change. Other refusals (branch_alias, switch_after_move, a branch checked out in another worktree) are in F9.0 and testdata/deviations.md.
  • Docs: describe the step as “move to forward to the current commit and switch to it”.

F20: Default config, auto-detection and --generate

Sources: config/mod.rs::{generate, generate_workflows}, config/package.rs::find_in_working_dir

F20.1 Package discovery (findInWorkingDir)

Run all three detectors and concatenate the results: Cargo workspace members, then npm workspaces, then Deno workspaces. If any found packages, return them. Otherwise build a single default package: versioned_files = every name from the F6.1 table that exists in the CWD (in table order), and changelog = "CHANGELOG.md" if that file exists. No files at all → no packages.

F20.2 Cargo workspace

Read ./Cargo.toml; a missing (unreadable) file means no packages, but a file that is not valid TOML is an error (Could not parse TOML in Cargo.toml: {err}, code workspace::toml). If it has workspace.members (an array of literal strings, no glob support; a non-string → The Cargo workspace members array should contain only strings):

  • workspace must be a standard table (an inline workspace = { ... } is not a workspace), and members an array. An empty array is no workspace. A member that is empty, absolute or has a .. component is an error (deviation from Knope); manifests are read only if regular files of at most 8 MiB.
  • Each member: {member}/Cargo.toml must exist, parse, and have package.name (Could not find a package.name in {path}).
  • For each member M, the package is named M.name with scopes = [M.name] and versioned_files =
    • for each member O in order: O’s Cargo.toml if O is M. Otherwise {O's Cargo.toml, dependency = M.name} if O depends on M (dependencies, dev-dependencies or workspace.dependencies).
    • {Cargo.toml, dependency: M.name} if the root depends on M.
    • Cargo.lock if it exists (the dependency defaults to M’s name, F7.2).
    • There is no changelog.

F20.3 npm workspaces

Read ./package.json workspaces (an array of strings; the object form {packages: [...]} and non-string entries give nothing). Expand each pattern (in order, F-glob semantics; a !negation is a literal pattern that matches nothing; a pattern that does not compile is Could not process workspaces glob pattern {p} in package.json: {e}, remeris::workspaces::npm_glob). For each match with a readable, parseable {dir}/package.json (a file match or a directory without one is skipped), add a workspace; a directory matched twice is added once (Knope adds it twice). Patterns that leave the project lexically (.., absolute, NUL, and on Windows backslash, colon, device names) are glob errors; symbolic links are still followed, as in Knope. A package.json over 8 MiB is an error (remeris::workspace::too_large). Once all patterns are expanded, each workspace W must have a string name, else Could not find a name in {path} (remeris::workspaces::npm_no_name). For each workspace W:

  • versioned_files = [W/package.json], plus {package-lock.json, dependency: W.name} if the root lock exists (yarn and pnpm locks are ignored), plus {O/package.json, dependency: W.name} for every other workspace O that has W.name as a key of dependencies or devDependencies (not peer or optional dependencies).
  • changelog = {W dir}/CHANGELOG.md (always set, even if the file is missing; it gets created).
  • scopes = [W.name].

F20.4 Deno workspaces

Knope uses deno_config’s workspace discovery. Simplified version for Go (differences in testdata/deviations.md):

  • Only the working directory is looked at: no walk up to a parent workspace, and the workspaces of a root package.json stay with the npm detector (F20.3).
  • The root deno.json or deno.jsonc must exist (deno.json wins when both do; one that does not parse is an error). It needs no workspace array: the workspace array (or the members array of the object form) lists member dirs, globs (*, ?) and ! exclusions allowed, and without it only the root folder is considered. No root Deno config → no packages. A lone root deno.json(c) with a name and a version is one package (the root folder, see the last bullet): versioned_files = [that config], scopes = [name], changelog = CHANGELOG.md, and the tag format of a named package (name/v{version}, F7.5). The workspace is used only if at least one folder (the root or a member) has a usable config; otherwise discovery falls back to the default package (F20.1).
  • Order: the root folder, then the members sorted by directory URL string (a-b/ before a/). A member that is missing, outside the root, the root itself, listed twice, or whose config does not parse, and two deno.json(c) with the same name, are errors Failed to discover Deno workspace: ….
  • For each member folder: if deno.json(c) has name and version, use it (along with any package.json next to it). Otherwise use package.json with name and version. Otherwise skip the folder.
  • The lockfile is deno.lock next to the root config (or the root lock setting: false, a path or {path}), only if it exists; a path outside the project is an error.
  • The package for member T: versioned_files = [T.config], plus {lock, dependency: T.name} if there is a lockfile, plus {D.config, dependency: T.name} for each other member D that depends on T:
    • D’s deno.json imports have a jsr: requirement named T.name (T has a deno.json), or an npm: requirement (T has a package.json).
    • D’s package.json deps (dependencies and devDependencies only; an entry that does not parse is skipped): file: path resolving to T’s dir or config, npm:@jsr/{scope}__{pkg} for a JSR T, a plain name equal to T.name (T has a package.json), or workspace: with alias == T.name.
  • changelog = {T dir}/CHANGELOG.md, scopes = [T.name].
  • The root folder is included too if it has a name and version. Check against tests/prepare_release/deno/* and tests/generate/*.

F20.5 Default workflows (hasForge, packages)

release (help "Prepare and create a new release (update changelog, bump version, create release)"):
  PrepareRelease
  Command `git commit -m "chore: prepare release $version"`   ← `"chore: prepare releases"` when ≥2 packages
  if hasForge: Command `git push`, Release
  else:        Release, Command `git push`, Command `git push --tags`
document-change (help "Create a new change file to be included in the next release"):
  CreateChangeFile
get-version (help "Get the current version of the project")      ← only when EXACTLY 1 package
  Command `echo "$version"`

F20.6 Forge detection (only when there is no knope.toml)

First remote URL:

  • Contains github.com: split on /. owner = the second-to-last part without a git@github.com: prefix. repo = the last part without .git.
  • Otherwise, a known Gitea host (codeberg.org): TryFromRemote (F17). Knope tests remote.contains(host); remeris requires the parsed host to equal the known host and drops the userinfo (testdata/deviations.md).

F20.7 --generate output

Serialise:

[changes]            (only when ignore_conventional_commits = true)
[package] or [packages.<name>]   (package for the one unnamed default package; a named package is always [packages.<name>], unlike Knope)
[[workflows]] ...
[github] / [gitea]   (when set)
[jira] / [release_notes]   (remeris only, when set: Knope's writer drops them)

Config.Document() (internal/config/document.go) builds this with internal/tomlwrite. Formatting must match the golden files in tests/generate/*/out/knope.toml. Details: inline arrays on one line, versioned files as bare strings, or { path = "...", dependency = "..." } / { path, regex } tables, which are written inline when the array also holds a bare path and as [[package.versioned_files]] sections when every entry is a table; a single regex is a plain string. Template variables of CreatePullRequest are always written (the defaults when the key was absent). Strings that contain " use literal single quotes ('git commit -m "..."'). Empty or false optional fields are omitted (allow_empty, ignore_conventional_commits, shell, prerelease_label, empty versioned_files, extra_changelog_sections). A step table is [[workflows.steps]] with type = "…" first. A blank line separates tables. The file ends with a single \n.

The mode (internal/cli/generate.go): a package discovery error aborts first, then Generating a knope.toml file is logged, then the document of the configuration that was already loaded is written to knope.toml in the working directory. Remeris never overwrites (Knope overwrites whenever it could not read the file): if the path exists in any form, a directory or a symbolic link included, the error is knope.toml already exists (remeris::generate::exists). The file is created with fsx.WriteNew (temporary file, then hard link, so it appears whole or not at all). With --dry-run nothing is written (Knope ignores the flag here): Would generate a knope.toml file: and the file are printed, or the same error if the path exists. See testdata/deviations.md.


F21: --upgrade and --validate

  • --upgrade: for every PrepareRelease step with ignore_conventional_commits = true, clear it and set the top-level [changes] ignore_conventional_commits = true. If anything changed, rewrite knope.toml with the F20.7 serialiser (Quirk Q9: the whole file is regenerated, so comments and formatting are lost, and packages or workflows that were auto-detected in Hybrid mode get written out). Otherwise log Nothing to upgrade.
  • --validate: build the state and run every workflow in dry-run mode with logs suppressed. Collect the errors (There are problems with the defined workflows, one related error per workflow Problem with workflow {name}). Exit 0 if there are none. Config load errors (F2.2) and package errors are reported before validation starts; --override-version and --prerelease-label are applied first. A dry run reads no credentials and never prompts. The issue steps run in a dry run like any step: they read no credentials, call no network, prompt for nothing and change no Git state. Their configuration and issue checks still fail as in Knope (GitHub is not configured, No issue selected), and the fake issues they select flow on to the steps after them.

Fixtures: tests/upgrade/*, tests/validate/*.


Phase 2: Extensions beyond Knope

Phase 1 (M0–M12) reaches Knope parity. Phase 2 adds features Knope doesn’t have. Each extension below is a design sketch: enough to keep Phase 1 from closing doors, and a starting point for its own detailed spec when we get there. All new config keys are optional, and Knope ignores unknown keys, so a knope.toml that uses them still loads in Knope (it just won’t do the new things).

Phase 1 hooks (build these during parity work)

These cost little now and avoid rewrites later:

Hook Where Why
Full SemVer 2.0.0 Version F1 (D8) Base for E1 channels.
changes.Source interface; conventional commits and changesets implemented as sources F10, F11, F13.1 E3 adds sources without touching PrepareRelease.
Variable lookup takes a scope: Resolve(v Variable, scope *PackageScope). Phase 1 always passes nil (= Knope’s single-package rule). F4.4 E4 per-package variables.
Git.Commit(msg string, opts CommitOptions) and Git.CreateTag(name string, opts TagOptions) with a Sign field, unused in Phase 1 F9 E2 signing.
GitHub client built from an auth provider (token or App), not from a raw token F16.0, F16.1 E2 commits through the API.
Config decoding ignores unknown keys F2.4 Phase 2 keys can be added to knope.toml without breaking Phase 1 builds.

E1: Pre-release channels (alpha → beta → rc → stable)

Knope supports one pre-release label at a time, chosen per run (prerelease_label, --prerelease-label). There is no notion of an ordered progression, so nothing stops going from rc back to alpha.

Sketch:

[release]
channels = ["alpha", "beta", "rc"]   # ordered, lowest first; any SemVer alphanumeric identifiers
  • remeris release --channel beta (env REMERIS_CHANNEL) replaces --prerelease-label when channels is set. The label must be one of the channels.
  • Promotion rule: for the same base version, a channel lower than the latest existing pre-release is an error (Cannot release alpha after rc for 1.3.0), unless --allow-channel-downgrade. Moving up starts that channel’s counter at 0 (1.3.0-rc.2 → --channel stable gives 1.3.0).
  • Base version: the change set since the last stable tag decides the base (as today). If the bump rule grows mid-cycle (a breaking change lands during beta), the base moves (1.3.0-beta.2 → 2.0.0-beta.0), and the channel order check applies to the new base.
  • Counter start: option counter_start = 0 | 1 (Knope uses 0).
  • Changelog: option collapse_prereleases = true removes the pre-release sections of a cycle from the changelog when the stable version is released, leaving one combined entry.
  • Precedence: SemVer compares alpha < beta < rc correctly as strings, but the configured order (not string order) is what the promotion rule uses, so custom names like preview work.

E2: Signed commits and tags

Knope never commits: the default workflows run git commit through a Command step. E2 adds first-class steps:

[[workflows.steps]]
type = "Commit"
message = "chore: prepare release $version"   # a Template (F4.4)
sign = "auto"        # "auto" | "git" | "github" | "none"
push = true          # push the branch (and new tags) after committing

[[workflows.steps]]
type = "Release"
sign_tags = true     # annotated, signed tags for git-only releases

sign = "git": use the user’s own git signing setup. CLI backend: git commit -S -m … / git tag -s. This respects gpg.format (openpgp, ssh, x509), user.signingkey and gpg.program. go-git fallback: OpenPGP keys through CommitOptions.SignKey, and SSH keys through go-git’s signer support (check which go-git v5 release has it during the spike); x509 is CLI-only. If signing fails, the step fails; it never silently falls back to unsigned.

sign = "github": GitHub App, commits made through the API. GitHub signs commits created through the API by an App and shows them as Verified, as long as the author/committer are not overridden (confirm this, and the exact conditions, in a spike before building on it). Flow, all through go-github with the App installation token (F16.1):

  1. Precondition: the local branch must equal the remote branch (git fetch then compare SHAs). Otherwise fail: Local branch is not in sync with origin; push or pull first.
  2. Read the staged changes (git diff --cached --name-status -z). For each added/modified file: Git.CreateBlob (base64). Deleted files become tree entries with a nil SHA.
  3. Git.CreateTree with base_tree = the HEAD tree. Keep file modes (executable bit, symlinks).
  4. Git.CreateCommit with the message and parent = HEAD, no author or committer (so GitHub signs it).
  5. Git.UpdateRef("heads/{branch}", sha, force=false). A race with someone else’s push fails here, with no force.
  6. Sync locally: git fetch origin {branch} then git reset --soft {sha} (the index already matches the commit).
  7. Tags: Release with a forge creates the tag at target_commitish = this SHA, so no local tag or push is needed. Signed annotated tags are only supported with sign = "git" (verify whether App-created tag objects are signed).

sign = "auto": github when GitHub App credentials are configured and [github] is set; else git when git’s commit.gpgsign is true; else none.

Dry run: Would create a signed commit via the GitHub API on {branch}: {message} (or …with git), and list the files.

E3: More changelog sources

Today a change comes from either a conventional commit or a change file. E3 turns these into pluggable sources:

// pkg/versioning/changes
type Source interface {
    Name() string
    // Changes returns the changes for one package since its last stable release.
    Changes(ctx context.Context, pkg PackageInfo, since ReleaseRef) ([]Change, error)
}

Config:

[changes]
sources = ["conventional_commits", "changesets"]          # default; order = order in the changelog
ignore_conventional_commits = true                        # still works: removes that source (Knope compatibility)

[changes.github_pull_requests]                            # example of a new source
labels = { major = ["breaking"], minor = ["feature", "enhancement"], patch = ["bug", "fix"] }
skip_labels = ["no-changelog"]

Candidate sources:

Source Change from Notes
conventional_commits commit messages Phase 1 (F10)
changesets .changeset/*.md Phase 1 (F11)
github_pull_requests merged PRs since the last tag Type from labels; summary = title; details = a ## Changelog section of the body, if present. Uses go-github.
commits every commit subject For repos without conventions; all patch unless configured.
command an external program Runs a command that prints a JSON array [{type, summary, details?, packages?}]. Escape hatch for custom systems.
jira Jira issues in a fix version Uses the existing Jira client (F19.3).

Dedupe: when two sources describe the same work (a PR and its squash commit), keep the first source’s change and drop others with the same commit hash or PR number. Controlled by dedupe = true (default).

E4: Monorepo releases with per-package variables

Knope already releases several packages from one repo with their own versions and {name}/v{version} tags. Two gaps:

  1. Version and ChangelogEntry only work with a single package (Too many packages defined), so with several packages you can’t put versions in commit messages, PR titles or commands.
  2. Commits are assigned to packages only by conventional-commit scope.

Sketch:

  • Qualified variables. A variable value can name a package:

    variables = { "$api_version" = { variable = "Version", package = "api" },
                "$web_notes"   = { variable = "ChangelogEntry", package = "web" } }

    The plain string form ("Version") keeps Knope’s single-package rule.

  • Per-package iteration. Command and CreatePullRequest get for_each = "released_package" (only packages released in this run, i.e. with a pending CreateRelease) or for_each = "package" (all packages). Inside the loop these default variables apply: $package, $version, $changelog, $tag. Example:

    [[workflows.steps]]
    type = "Command"
    for_each = "released_package"
    command = "npm publish --workspace $package"
  • Aggregate variables for multi-package text: ReleasedPackages (api@1.2.0, web@0.4.1) and ReleaseNotes (each released package as ## {package} {version} followed by its notes, ready for a PR body). The default multi-package commit message becomes chore: prepare releases $released_packages (opt-in, to keep parity by default).

  • Path-based attribution. Package option paths = ["packages/api/**"]: a commit counts for the package if it touches a matching file. commit_filter = "scopes" | "paths" | "both" (default scopes, Knope’s behaviour). Requires the Git interface to return changed paths per commit.

  • Tag format. Option tag_format = "{name}@{version}" per package (default Knope’s {name}/v{version}), for ecosystems like npm that use @. Tag parsing (F7.5) must use the same format.

Phase 2 order (proposal)

# Extension Depends on
P2.1 E4 per-package variables and for_each Phase 1 F4.4 scope hook
P2.2 E2 signed commits: Commit step with sign = "git" F9
P2.3 E2 GitHub App commits (sign = "github") F16.1, spike on verification rules
P2.4 E1 pre-release channels F1
P2.5 E3 change sources: github_pull_requests, command F13.1 Source interface
P2.6 E4 path-based attribution, tag formats F9 changed-paths API

Testing strategy

  1. Unit tests for pkg/versioning/*. Port every #[cfg(test)] module from knope-versioning and from the CLI (git.rs branch parsing, config/toml.rs Gitea remote parsing, etc.).

  2. Golden fixture tests (key to parity). Knope has about 920 fixture files under crates/knope/tests/**. Each case has:

    • in/: the starting files (including knope.toml, .changeset/).
    • A mod.rs with the Git setup (Commit("msg"), Tag("v1.2.3"), optional remote, env vars) and the command (such as release, --dry-run).
    • out/: the expected files after a real run. dryrun_stdout.log / stdout.log / stderr.log hold the expected output, with [DATE] standing in for today’s date.

    Use github.com/rogpeppe/go-internal/testscript as the harness. Convert each Knope case with a one-off script into a .txtar file under testdata/script/<suite>/<case>.txtar, holding:

    • the in/ files as txtar sections;
    • the Git setup as commands (git init, git commit --allow-empty -m '…', git tag …, with a fixed author through env GIT_AUTHOR_NAME=…);
    • exec remeris --dry-run release with cmp stdout dryrun_stdout.log, then a real run and cmp against each out/ file;
    • [DATE] handled by a custom date command, or by setting REMERIS_FAKE_DATE in tests (read only by a test hook).

    testscript.Main registers the binary in-process through TestMain, so no separate build step is needed. Credit the fixtures to Knope (MIT-licensed). Enable them one milestone at a time. Keep a testdata/deviations.md listing each fixture changed for a Quirk decision.

  3. HTTP tests: use httptest.Server with a configurable API base URL (GitHub base URL and Gitea host), and check the request bodies against F16/F17. The Knope integration_github / integration_gitea suites hit real services, so do not port those.

  4. Prompt tests: put prompts behind an interface and script them in tests (CreateChangeFile, Select*).


Quirks register

ID Quirk Where Decision
Q1 Unknown TOML keys are silently ignored (for example the docs’ label vs the code’s labels) F2, F19 Keep. Maybe add a --validate warning later.
Q2 Pre-release ordering compares labels as strings F1 Change: SemVer precedence (D8)
Q3 CurrentBranch returns refs/heads/.... This breaks the GitHub PR head and SelectIssueFromBranch F9.6, F18, F19.5 Fix (short name). Update the golden logs.
Q4 Changeset package names are not unquoted, so "pkg": minor gives name "pkg" F11.1 Keep (compatibility). Consider stripping quotes later.
Q5 Change-file front-matter order comes from a HashMap (random) F11.4 Use selection order (deterministic)
Q6 deno.json(c) edits drop comments, despite the docs F6.2 Fix with an in-place hujson edit
Q7 pom.xml is fully re-emitted and loses formatting F6.2 Fix with a surgical edit
Q8 Regex write replaces every occurrence of the version text inside the match F6.2 Keep
Q9 --upgrade regenerates the whole file (comments lost, hybrid defaults written out) F21 Keep for v1. Later: edit in place.
Q10 JSON outputs lose the trailing newline (package.json etc.) F6.2 Keep (golden parity). Maybe keep the original trailing newline later.
Q11 deno.jsonc is missing from the explicit file-name table F6.1 Fix (add it)
Q12 CreateTag makes annotated tags with empty messages F9.5 Keep
Q13 The npm workspace changelog is always set, even when the file doesn’t exist F20.3 Keep
Q14 RebaseBranch never replays commits: it points to at HEAD and switches to it, dropping the commits only on to (docs say it rebases) F19.8 Change: fast-forward only (D6)

Decisions

Recorded 2026-10-02.

ID Question Decision Where
D1 Name and config file The program is remeris (binary, messages, codes, User-Agent, credential dir). Knope’s configuration is the first supported one, not a legacy format: it keeps its own names (knope.toml, KNOPE_*), owned by the code that implements it. No global name package. F3.1, F15
D2 Git backend git CLI first, go-git v5 fallback when git is not installed F9.0
D3 Knope Bot (GitHub App) Out of scope for now —
D4 Diagnostics Match Knope’s message and help text. No source snippets or docs URLs until the remeris docs exist. F3.3
D5 Jira search endpoint Use POST /rest/api/3/search/jql F19.3
D6 RebaseBranch Fast-forward only on both backends: fail with not_fast_forward when to has commits that are not in the current branch (revised in the M11 kickoff, issue #96) F19.8
D7 Minimum Go version Go 1.27 0.3
D8 Version grammar Full SemVer 2.0.0 from Phase 1 (pre-release ids, build metadata, §11 precedence). Bumps keep Knope’s label.N shape. F1
D9 CLI flags and config Reuse readykit’s urfave/cli v3 + koanf (cliflagv3) pattern, with a lineage-based key prefix for subcommands F3.1
D10 GitHub client google/go-github (v92) for every GitHub call F16.0
D11 GitHub App auth jferrl/go-githubauth; App credentials only from flags/env, never knope.toml F16.1, E2
D12 Phases Knope parity first (Phase 1), then E1 channels, E2 signed commits, E3 change sources, E4 monorepo variables (Phase 2) Phase 2

Was this page helpful?