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.rsrefers to the Knope repo.crates/knope/is the CLI,crates/knope-versioning/is the core versioning library, andcrates/knope-config/holds the config types shared with Knope Bot.
Table of contents
- Architecture and Go project layout
- Milestone plan (feature order)
- F1: Semantic versions
- F2: Config file (
knope.toml) - F3: CLI, run modes, logging, errors
- F4: Workflows, state and variables
- F5:
Commandstep - F6: Versioned files (per format)
- F7: Packages and version resolution
- F8:
BumpVersionstep - F9: Git integration
- F10: Conventional commits
- F11: Changesets and the
CreateChangeFilestep - F12: Release notes and changelogs
- F13:
PrepareReleasestep - F14:
Releasestep (Git tags) - F15: HTTP client and credentials
- F16: GitHub integration
- F17: Gitea integration
- F18:
CreatePullRequeststep - F19: Issue steps (GitHub / Gitea / Jira / branch)
- F20: Default config, auto-detection and
--generate - F21:
--upgradeand--validate - Phase 2: Extensions beyond Knope
- Testing strategy
- Quirks register
- 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 theActions produced. It does no I/O: it takes file contents and tag lists as input and returnsActions (WriteToFile,RemoveFile,AddTag,CreateRelease). - CLI (
knope): config, steps, Git, HTTP forges, prompts. It runs theActions, either for real or as a dry run. - RunType: every step gets
DryRun(state)orReal(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, so1.2.3vs1.2.3+b1is 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 as1.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.Nshape 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
stableand stop (only pre-releases newer than the latest stable matter). - Pre:
update(version).
- Stable: set
update(version):
- Stable
new: ifstable >= newdo nothing. Otherwise setstable = newand clear all pre-releases. - Pre
(s, pre): if a pre with the samesand label already exists and is>= pre(byCompare), do nothing. Otherwise insert or overwriteprereleases[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):
base = bump_stable(stable, r)if stable exists, else the largest pre-release stable key, else0.0.0.n = counter + 1ifprereleases[base][label]exists and has a counter;0if it exists without a counter (so1.2.4-betais followed by1.2.4-beta.0, which is higher by SemVer precedence); else0.- Return
base-label.n.
Examples to test (from the docs and code):
1.2.3, Pre(rc, Patch) →1.2.4-rc.0. Then with1.2.4-rc.0tagged →1.2.4-rc.1.- Stable
1.2.3, tags1.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
Releaseon1.2.3-rc.4→1.2.3.
Acceptance (F1)
- Port every unit test in
semver/*.rsandpackage_versions.rsas 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 thatParse(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):
[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.- Each versioned file:
regexpresent → Format = RegexFile. Otherwise the file name (last path component, case-sensitive) must be one of the supported names (F6.1), elseUnknown file: {path}(help lists the supported formats). The file must exist on disk, elseFile {path} does not existwith the span labelledDeclared here. [gitea]present and any package hasassets→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.
-
Key delimiter. koanf splits keys on its delimiter (
.by default). Package names ([packages."foo.bar"],[packages."@scope/pkg.js"]) andvariableskeys (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}). -
Order.
parsers/toml/v2decodes intomap[string]any, so the declaration order of[packages.*]andvariablesis 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 withgo-toml/v2/unstableand build atomlIndexthat holds:- the ordered keys of
packagesand of everyvariablestable, 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
packagesandvariablesmaps using the index. - the ordered keys of
-
Decoding.
k.UnmarshalWithConf("", &raw, koanf.UnmarshalConf{Tag: "toml", DecoderConfig: …})withmapstructuredecode hooks for the untagged and tagged unions:versioned_files[]: string →{path}; table →{path, dependency}or{path, regex}, whereregexis a string or[]string;assets: string (glob) or a list of{path, name?};workflows[].steps[]: switch ontypeinto the concrete step struct (F4.2), andBumpVersiononrule;Variablevalues: exactlyVersion|ChangelogEntry|IssueBranch.
Leave
ErrorUnusedoff, so unknown keys are ignored as in Knope (Quirk Q1). Decode errors are wrapped as diagnostics, with spans looked up in the index. -
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 exampleremeris.tomloverrides orREMERIS_*env vars) are where koanf pays off (see D1:knope.tomlis 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 thepackage/packagesconflict.
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_textis its description. - No subcommand (and no
--validate) → errorNo 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 nosuchfails). A flag that is not defined (--validatewithout aknope.toml) isunexpected argument '--validate' found, as clap says it. --helpon the root lists workflows.<workflow> --helpshows 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):
-
Typed options struct with defaults. Runtime options live in plain structs with
mapstructuretags 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"` } -
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 fromREMERIS_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--helpdefaults or process listings. The flag wins over the env vars (urfave’s precedence). -
One generic loader, copied from readykit’s
app.LoadConfigwith 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 isWorkflow names may only contain letters, digits, '-' and '_': {name}. -
Command tree. As in readykit,
mainbuilds a rootcli.CommandwithName: "remeris",Version: version.Full(),Suggest: true,EnableShellCompletion: true, rootFlags, and aBeforehook that runsLoadConfig(cmd, globalOpts)and sets upslog(verbose → debug level). Command constructors take the shared state as an argument and close over it (workflowCommand(cfg, w) *cli.Command), like readykit’sdaemonCommand(cfg). Each workflowActionrunsLoadConfig(cmd, &WorkflowOptions{})and then the workflow. -
Project config is loaded before the tree is built, because workflows become subcommands.
knope.tomluses 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. -
Flag details:
--dry-runand--verbose(aliasv) are rootBoolFlags. v3 root flags are persistent by default, soremeris release --dry-runworks, as in clap’sglobal(true).--generate,--upgradeand--validateare rootBoolFlags withLocal: true, added only for the matching ConfigSource. The rootActionhandles them, and errors withNo workflow selected…when none is set.--override-version:StringSliceFlagin both cases. With one package, exactly one bare<version>is allowed. With several, each value must bename=version.root.Versiongives--version. Help formatting differs from clap, so golden tests don’t compare--help.
-
Errors. Readykit logs
slog.Errorand exits 1. Remeris instead renders theDiagnostic(F3.3) to stderr and exits 1, because diagnostics have help text and nested errors that a log line would lose. Steps never callcli.Exit.
Decision (D1): the program is
remeris: binary name,--help, log and error text (for exampleRun 'remeris --help'), diagnostic codes (remeris::inconsistent_versions),User-Agent: Remerisand 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 fileknope.toml(read and written by--generate/--upgrade) andKNOPE_PRERELEASE_LABEL. The code that implements a configuration owns its names (the file name lives ininternal/config); there is no global name package. A setting that is also useful to other configurations getsREMERIS_*as its own name, which takes precedence. Golden logs vendored from Knope are rewrittenknope→remeriswhere the text names the program;knope.tomlitself 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-subscriberfmt 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"forSelectGitHubIssue. The code field islabels(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 definedif more,No packages to operate onif none). Value =package.LatestVersion(), else errorCould not determine version of package.ChangelogEntry: same single-package rule. IfPendingActionshas aCreateRelease, use the first one’snotes. Otherwise uselatestVersionand 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
- Substitute variables (F4.4) into
command. - Dry run: log
Would run {command}and return. - 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$VARexpansion; see 0.3) and run it directly (no shell). - Inherit stdin, stdout and stderr, and the working directory.
- 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.jsoncis not in the name table, so it cannot be used as an explicitversioned_filesentry. It is only reached through Deno workspace discovery, which keeps the real config path (F20.4). Treat this as a Knope bug: adddeno.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'. ErrorInvalid TOML in {path}on parse failure. - Write (no dependency): if
package.versionexists and is a string, set it. Otherwise (for exampleversion.workspace = true), ifworkspace.package.versionexists, set that. Diffversion = {v}. - Write (dependency
d): in each ofdependencies.d,dev-dependencies.dandworkspace.dependencies.d: if the entry is a table ([dependencies.d]) → set/insertversion. If it is an inline table → set/insertversion. If it is a plain value (d = "1.0") → replace it with the version string. Diff{d}.version = {v}. - Helpers:
NameFromDocumentreturnspackage.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
versionis not 3 or 4 (Unsupported version of Cargo.lock: …/Unknown version of Cargo.lock…). - Write: for each
[[package]]withname == d, set/insertversion. Diff{name} = {v}, joined with", ". - Errors: missing
[[package]]array, or a package without a stringname.
gleam.toml
- Read top-level
version. Write/insert top-levelversion. Diffversion = {v}. No dependency support.
pyproject.toml
- Read
project.version(PEP 621) andtool.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 isvN. - Read: if the module comment has a version, use it. Otherwise take it from git tags:
parent= the directory of go.mod. Ifmajoris set and parent ends inv{major}, drop that segment (major-version subdirectory).- Tag prefix =
"{parent}/", or""at the repo root. Candidates = tags with that prefix and thenv, 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
/vNand 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.
- If the module has no
- 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) andAddTag:tag = "{prefix}/v{version}", where prefix = parent dir without a trailingv{major}and/. At the root it isv{version}.GoVersioning:Standard(default),IgnoreMajorRules(packageignore_go_major_versioning = true), andBumpMajor(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. Diffversion = {v}. - Write (dependency
d): independenciesand thendevDependencies, ifdis 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, helpEither add a `version` field or specify a `dependency` to update). - Warn if
lockfileVersionis not 2 or 3. - Write (no dependency): set top-level
versionandpackages[""].version. Diffversion = {v}. - Write (dependency
d): for every value inpackages: ifname == d, setversion. Ifdependencies[d]ordevDependencies[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
versionand 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: withhujson, patch only theversionvalue (or insert the member) and keep everything else, as the docs promise. Update the Deno fixtures and record the deviation. Diffversion = {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:- For each
specifiersentry whose key parses as a JSR/npm package request (jsr:@scope/name@<req>ornpm:name@<req>) with named: record the old resolved value and the old req text, record the kind (jsr/npm), and set the value to{v}. - None updated → no-op.
- For each kind seen, rename keys in the
jsr/npmsection:name@old→name@{v}when the old version is in the recorded set. - In
workspace(and eachworkspace.members.*, and each key ofworkspace.links, including renaming the link key): rewrite entries independencies,packageJson.dependencies,optionalDependenciesandpeerDependenciesthat matchdwith a recorded version, to{scheme:}{d}@{v}. - Diff
{d} = {v}. Serialise like deno_lockfile 0.49.0print_v5_content(printer.rs, called fromLockfile::as_json_stringinlib.rs): 2-space pretty with a trailing newline, the top-level keys in the fixed orderversion,specifiers,jsr,npm,redirects,remote,workspace, empty sections left out, and the maps sorted:specifiersand the dependency arrays by the normalised requirement text (JsrDepPackageReq::to_string_normalized:^1.0.0prints as1, see deno_semver 0.9.1range.rs),jsrbyPackageNvorder (name, then version precedence),npm,membersandlinksby key text. Two more printer rules (printer.rs183-271): ajsrpackage’s dependency prints as barescheme:namewhen that name has a single specifier inspecifiers, otherwise as the normalised requirement; annpmdependency prints asname@npm:pkg@verfor an alias and as the bare name when the package has a single version (thenpmdependency 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); annpmpackage’sosandcpuarrays print on one line.JsrDepPackageReq::cmpis not the print order. The Go port of the requirement and version types ispkg/versioning/internal/denosemver, and the lockfile model, reader and printer arepkg/versioning/internal/denolock. Check against fixturestests/prepare_release/deno.
- For each
pubspec.yaml
- Read top-level
version(YAML). - Write: replace only the bytes of the top-level
versionscalar (found from the position yaml.v3 reports for the first top-levelversionkey), so comments, quote style, line endings and nestedversion: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 noversionkey (unreachable, since loading requires the key) gets aversion: {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). Readproject > versiontext. - Write: set the text of
<version>, or insert<version>right after<artifactId>(or at the end). Diffproject.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 setversion, 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 groupversion(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 capturedversionof 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:
- Find the parsed file with the same path (the error
Versioned file not foundmeans a bug). - Dependency validation:
- Cargo.lock with no dependency: default it to the
package.nameof the first non-dependencyCargo.tomlin the same package. None →Cargo.lock must specify a dependency. - Dependency on an unsupported format → error (F6.3).
- Cargo.lock with no dependency: default it to the
- 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}(coderemeris::inconsistent_versions, helpAll files in a package must have the same version). - 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:
gitCLI (primary): used whenexec.LookPath("git")succeeds. Every method runsgitthroughos/execwith-C <cwd>,GIT_TERMINAL_PROMPT=0andLC_ALL=C(stable output to parse).- go-git fallback:
github.com/go-git/go-git/v5(v5.19.2, 2026-07-29) whengitis not onPATH. 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
- Map commit OID → the tag names pointing at it (tags peeled to commits, both annotated and lightweight), in reference-iteration order.
- Walk from HEAD using libgit2’s default revwalk order (reverse chronological by commit time; Knope sets no sorting). For each commit, append its tags.
- 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=%Hfrom HEAD, joined withgit 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 withNot 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 remoteorder).
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 isBREAKING 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).
- If the commit has a scope and
scopesis set: skip the commit unless some scope matches, compared ASCII case-insensitively. Commits with no scope always count. - For each footer:
- Breaking footer →
Change{Breaking, summary: footer.value}and rememberhasBreakingFooter. - Else if some section lists this footer token (case-insensitive) →
Change{Custom(Footer token), value}. - Else skip it.
- Breaking footer →
- Description change:
Breakingifbreaking && !hasBreakingFooter. ElseFeatureif the type isfeat. ElseFixif the type isfix. Else no description change. Summary = description. - The footer changes come before the description change in the output.
- Every change gets
GitInfofrom the commit andChangeSource.ConventionalCommit{description}. Heredescription = "{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. Fixturestests/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:
- The first line trimmed must be
---(MissingFrontMatter). - Then lines until one that trims to
---: each isname: 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. - The closing
---must be there. - Summary = the rest, with leading blank lines skipped, joined with
\n. - Change type:
patch|minor|major, anything else isCustom(s)(case-sensitive). unique_id= the file name without.md. Only regular files ending in.mddirectly 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 aschangeset {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
-
Dry run → log
Would create a new change fileand return. -
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). -
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}?, orWhat type of change is this?for the default package. -
Text prompt
What is a short summary of this change?, helpThis will be used as a header in the changelog. -
Create
.changeset/if it is missing. Write.changeset/{normalize(summary)}.mdwith 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_hashneed GitInfo.$pr_author_login/$pr_numberneed the PR fields (F16.4).$detailsneeds 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
\nadded (so CRLF becomes LF). Returnsnewas the diff, and the action isWriteToFile{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 example1.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= stepignore_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.Sourcevalues (Phase 2 hook, E3), run in that fixed order in Phase 1.
F13.2 Pre-releases
prerelease_labelcomes from the step, overridden at runtime by--prerelease-label/REMERIS_PRERELEASE_LABEL(orKNOPE_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.
AddTagandCreateReleaseare returned (they become the newPendingActions).- If
stageToGit: dry run logsWould 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):
current := pkg.LatestVersion(). None → skip.lastTag := PackageVersions.FromTags(pkg, AllGitTags).Latest(). IflastTag == current→ nothing to release.- Release =
changelog.GetRelease(current)if found. OtherwiseRelease{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: warnAPI rate limited; retrying in {n} seconds(orretrying after {value}). If unauthenticated, also warnTo 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 namesGitHuborthe Gitea instance at {host}(httpx.NewDecodeErrorFortakes the two names apart). httpx.Guardandhttpx.URLPolicyhold 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:
- 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-githubauthto 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. GITHUB_TOKENenv var.- Stored token (F15.2).
- 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:
- Resolve assets: glob → every match (name = file name), or the explicit list (name =
name, else the file name). - For each: read the file (
Could not read asset file {path}: … Release has been created but not published!), then upload it withUploadReleaseAsset(content typeapplication/octet-stream). - Publish:
EditReleasewithdraft: 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;httpis accepted only forlocalhostand other loopback addresses (remeris::gitea::insecure_url, andremeris::gitea::invalid_urlfor any other scheme); - a redirect that changes the host or the port, or goes from https to http, is refused (
remeris::gitea::redirect), andAuthorizationis never sent to another origin. These rules are those ofhttpx.Guardandhttpx.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, withdraft=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 noheadfilter (its query parameters arestate,sort,milestone,labels,poster,page,limitandbase_branch), so a server ignoresheadand Knope may update an unrelated open pull request. remeris sendsGET …/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 whosehead.refis the branch, whosehead.repo.owner.loginis the configured owner (case-insensitive; a nullhead.repo, a deleted fork, never matches) and whosebase.refis the base. Unknown or missing JSON fields are tolerated. ThenPATCH …/pulls/{number}{body,title}, orPOST …/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 unlesstype=issuesis sent (Knope does not send it: deviation). TryFromRemote(url):git@host:owner/repo(.git)→ hosthttps://host.https://host/owner/repo(.git)→ the same. Anything else → none. Known public Gitea hosts for auto-detection:codeberg.org.
F18: CreatePullRequest step
- Substitute variables into
titleandbody(F4.4). - No
[github]and no[gitea]→No forge has been configured. - 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.ListwithHead: owner+":"+branch,Base: base. If one exists →PullRequests.Editwith{title, body}. Else →PullRequests.Createwith{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). LogsWould query configured GitHub instance for issues with labels a, b(orwith any labels), thenWould 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,ownerandlabelsare variables,labelsis[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 noorderBy(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 Serverhttps://HOST/api/v3/->https://HOST/api/graphql; anapi.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 GraphQLerrorsarray givesCould not communicate with GitHub while loading issues: {error}(coderemeris::issues::github::api). Show a SelectSelect an Issuewith items"{key}: {summary}". LogSelected 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/jqlwith{"jql": "status = {status} AND project = {project}", "fields": ["summary"], "maxResults": 50}. Response:issues[].{key, fields.summary}(plusnextPageToken, ignored: like Knope, only the first page is shown) → Select. Quotestatusin 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 issueand 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}. ElseBad branch name. LogAuto-selecting issue {key} from ref {branch}. - Quirk Q3: Knope parses the full ref (
refs/heads/ABC-123-x), which gives keyrefs/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}→ SelectWhich 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) andtomust be an existing local branch (remotes are not checked). Whentohas commits that are not in the current branch it fails withCannot fast-forward branch {to} to {cur}: {to} has commits that are not in {cur}(coderemeris::git::not_fast_forward, help: bring those commits into the current branch, then run the step again). Otherwisetois moved to HEAD’s commit and checked out, and the step logsRebased current branch onto {to}andSwitched 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 andtestdata/deviations.md. - Docs: describe the step as “move
toforward 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):
workspacemust be a standard table (an inlineworkspace = { ... }is not a workspace), andmembersan 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.tomlmust exist, parse, and havepackage.name(Could not find a package.name in {path}). - For each member M, the package is named
M.namewithscopes = [M.name]andversioned_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-dependenciesorworkspace.dependencies). {Cargo.toml, dependency: M.name}if the root depends on M.Cargo.lockif it exists (the dependency defaults to M’s name, F7.2).- There is no changelog.
- for each member O in order: O’s Cargo.toml if O is M. Otherwise
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 ofdependenciesordevDependencies(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
workspacesof a rootpackage.jsonstay with the npm detector (F20.3). - The root
deno.jsonordeno.jsoncmust exist (deno.jsonwins when both do; one that does not parse is an error). It needs noworkspacearray: theworkspacearray (or themembersarray 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 rootdeno.json(c)with anameand aversionis 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/beforea/). A member that is missing, outside the root, the root itself, listed twice, or whose config does not parse, and twodeno.json(c)with the samename, are errorsFailed to discover Deno workspace: …. - For each member folder: if
deno.json(c)hasnameandversion, use it (along with anypackage.jsonnext to it). Otherwise usepackage.jsonwithnameandversion. Otherwise skip the folder. - The lockfile is
deno.locknext to the root config (or the rootlocksetting: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 annpm:requirement (T has a package.json). - D’s package.json deps (
dependenciesanddevDependenciesonly; 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), orworkspace:with alias == T.name.
- D’s deno.json imports have a
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/*andtests/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 agit@github.com:prefix. repo = the last part without.git. - Otherwise, a known Gitea host (
codeberg.org):TryFromRemote(F17). Knope testsremote.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 everyPrepareReleasestep withignore_conventional_commits = true, clear it and set the top-level[changes] ignore_conventional_commits = true. If anything changed, rewriteknope.tomlwith 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 logNothing 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 workflowProblem with workflow {name}). Exit 0 if there are none. Config load errors (F2.2) and package errors are reported before validation starts;--override-versionand--prerelease-labelare 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(envREMERIS_CHANNEL) replaces--prerelease-labelwhenchannelsis 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 stablegives1.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 = trueremoves 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 < rccorrectly as strings, but the configured order (not string order) is what the promotion rule uses, so custom names likepreviewwork.
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):
- Precondition: the local branch must equal the remote branch (
git fetchthen compare SHAs). Otherwise fail:Local branch is not in sync with origin; push or pull first. - 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. Git.CreateTreewithbase_tree= the HEAD tree. Keep file modes (executable bit, symlinks).Git.CreateCommitwith the message and parent = HEAD, no author or committer (so GitHub signs it).Git.UpdateRef("heads/{branch}", sha, force=false). A race with someone else’s push fails here, with no force.- Sync locally:
git fetch origin {branch}thengit reset --soft {sha}(the index already matches the commit). - Tags:
Releasewith a forge creates the tag attarget_commitish= this SHA, so no local tag or push is needed. Signed annotated tags are only supported withsign = "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:
VersionandChangelogEntryonly 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.- 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.
CommandandCreatePullRequestgetfor_each = "released_package"(only packages released in this run, i.e. with a pendingCreateRelease) orfor_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) andReleaseNotes(each released package as## {package} {version}followed by its notes, ready for a PR body). The default multi-package commit message becomeschore: 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"(defaultscopes, Knope’s behaviour). Requires theGitinterface 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
-
Unit tests for
pkg/versioning/*. Port every#[cfg(test)]module fromknope-versioningand from the CLI (git.rsbranch parsing,config/toml.rsGitea remote parsing, etc.). -
Golden fixture tests (key to parity). Knope has about 920 fixture files under
crates/knope/tests/**. Each case has:in/: the starting files (includingknope.toml,.changeset/).- A
mod.rswith the Git setup (Commit("msg"),Tag("v1.2.3"), optional remote, env vars) and the command (such asrelease,--dry-run). out/: the expected files after a real run.dryrun_stdout.log/stdout.log/stderr.loghold the expected output, with[DATE]standing in for today’s date.
Use
github.com/rogpeppe/go-internal/testscriptas the harness. Convert each Knope case with a one-off script into a.txtarfile undertestdata/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 throughenv GIT_AUTHOR_NAME=…); exec remeris --dry-run releasewithcmp stdout dryrun_stdout.log, then a real run andcmpagainst eachout/file;[DATE]handled by a customdatecommand, or by settingREMERIS_FAKE_DATEin tests (read only by a test hook).
testscript.Mainregisters the binary in-process throughTestMain, so no separate build step is needed. Credit the fixtures to Knope (MIT-licensed). Enable them one milestone at a time. Keep atestdata/deviations.mdlisting each fixture changed for a Quirk decision. -
HTTP tests: use
httptest.Serverwith a configurable API base URL (GitHub base URL and Gitea host), and check the request bodies against F16/F17. The Knopeintegration_github/integration_giteasuites hit real services, so do not port those. -
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 |