Format: arc42 (light) — a trimmed subset of the arc42 template that keeps the sections worth maintaining for a small, single-binary tool and drops the ceremony.
Important
This document is a living contract and must stay in sync with the code. Any change that alters the structure described here — adding/removing/renaming a package, changing a port (interface) or adapter, changing the end-to-end flow, adding a dependency, or changing the runtime/deployment story — must update this file in the same change. See §12 Keeping this document in sync for the exact map of code → section. Cosmetic edits (renaming a local variable, fixing a typo, internal refactors that don't cross a package or port boundary) do not require an update.
shipnotes generates a Markdown release-notes file from git history, annotating each commit with the status of its linked Jira issue.
Top quality goals (in priority order):
| # | Goal | What it means in practice |
|---|---|---|
| 1 | Readability for non-Go readers | A few obvious lines beat one clever line. Every exported symbol has a plain-English doc comment. |
| 2 | Zero runtime dependencies | One static binary; needs only the git command and network access to the Jira REST API. No external Go modules (go.mod has no require block). |
| 3 | Stable output contract | The exact Markdown produced is locked by golden-file tests; rendering changes are deliberate, reviewed diffs. |
| 4 | Workflow-agnostic | Grouping has no built-in notion of "done": issues are grouped by whatever status names they have. The opinions are opt-in: --checked-statuses (a configurable regexp) marks matching statuses as completed checkboxes (defaults to a "done"-like pattern, emptyable to disable), and --exclude-commits (a regexp, empty by default) drops matching commits from the notes into an "Excluded commits" section. Works on any repo and any Jira setup with zero config. |
Primary use case: a developer runs shipnotes <commit_hash> and gets a SHIPNOTES.md summarizing everything between that commit and HEAD.
- Language/toolchain: Go (see
go.modfor the version).gofmt/gofumpt,go vet, andgolangci-lint(all linters on, a documented few off) are the arbiters of style; they must report zero issues. - Standard library only at runtime. Each capability maps to a Go built-in: templating →
text/template, HTTP →net/http, URL/email validation →net/url/net/mail, CLI flags →flag..envparsing and ANSI color are tiny hand-written helpers. Adding a dependency requires real justification. - No stateful globals. Package-level
vars only for compiled regexps and the parsed template (immutable, shared). - Errors, not panics (the lone exception is
template.Mustat startup).
┌──────────────────────────────────────────────┐
user ─▶ │ shipnotes │ ─▶ SHIPNOTES.md
(CLI args) │ (single binary; reads git, calls Jira REST) │ (Markdown file)
└───────────────┬───────────────────┬──────────┘
│ │
`git` command Jira REST API
(local subprocess) (network, read-only)
External interfaces:
| Partner | Direction | Via |
|---|---|---|
| The user | in: commit hash + flags; out: a Markdown file + console progress | flag, stdout/stdin |
| A git repository | in: refs, commit log | the git CLI as a subprocess |
| Jira | in: issue fields (summary, status) | Jira REST API, read-only, over HTTPS |
| Configuration | in: 3 Jira values + an optional GitHub repo (the repo inferable from the git remote) | flags + real env + nearest .env |
- DDD / hexagonal (ports-and-adapters). A pure domain core surrounded by adapters. Dependencies point only inward:
cli → application → domain ← infrastructure. - Ports are Go interfaces owned by inner layers; adapters are structs in
infrastructure/clithat satisfy them. This keeps the domain testable with fakes and free of I/O. - One composition root (
internal/cli/cli.go,buildService) is the single place that wires concrete adapters into the application service. - Golden-file tests lock the rendered Markdown so output changes are deliberate.
cli ──▶ application ──▶ domain ◀── infrastructure
(driving adapter + (entities + ports; (adapters that
composition root) no I/O) implement the ports)
| Layer | Package(s) | Responsibility | May import |
|---|---|---|---|
| domain | internal/domain/{commit,issue,notes,report} |
Entities, domain rules, and the ports (interfaces) the core needs. | stdlib + other domain packages only |
| application | internal/application |
Orchestrates the use case through ports. Owns the Writer and IssueSearcher ports. |
domain |
| infrastructure | internal/infrastructure/{git,jira,markdown,config,terminal,fileoutput} |
Adapters implementing the ports (git, Jira, Markdown, config, terminal, file output). | domain |
| cli | internal/cli |
Interface layer + composition root: parse args, build adapters, run, present. The only package that imports every layer. | all |
| Port (interface) | Declared in | Adapter (implementation) |
|---|---|---|
commit.Repository (Validate, Log) |
domain/commit/repository.go |
infrastructure/git (repository.go, parser.go) |
issue.Provider (LoadByKeys) |
domain/issue/provider.go |
infrastructure/jira (client.go, types.go, errors.go) |
application.IssueSearcher (SearchByJQL) |
application/app.go |
infrastructure/jira (client.go) |
notes.Renderer (Render) |
domain/notes/renderer.go |
infrastructure/markdown (renderer.go + templates/shipnotes.tmpl) |
report.Reporter |
domain/report/reporter.go |
infrastructure/terminal (terminal.go) |
application.Writer (Write) |
application/app.go |
infrastructure/fileoutput (writer.go) |
notes.Builder(domain/notes/builder.go) — the domain service that turns commits + issues into thenotes.ReleaseNotesmodel. Depends onissue.Providerandreport.Reporterports, and holds anotes.StatusMatcher(marks completed issues) and anotes.CommitMatcher(excludes commits). Exclusion is the first gate: a matched commit leaves the history and summary and is listed only under "Excluded commits", even if it is also a revert/reapply.notes.StatusMatcher(domain/notes/status.go) — a domain value wrapping a compiled, case-insensitive, fully-anchored regexp; decides which issue statuses render as completed ([x]) checklist items. Its zero value (and an empty pattern) matches nothing, keeping checking opt-in.notes.CommitMatcher(domain/notes/exclude.go) — a domain value wrapping a compiled, case-insensitive, unanchored regexp (it matches a prefix/substring, not a whole value); decides which commits--exclude-commitsdrops, testing the commit subject (which carries the Jira key, so one pattern excludes by type or by ticket). Its zero value (and an empty pattern) matches nothing, keeping exclusion opt-in.notesmodel (domain/notes/model.go) —ReleaseNotes,HeaderData,CommitView,IssueView(with aCheckedflag for the checkbox state),StatusGroup,SummaryData(including theExcludedcommit list), plusCoordinates(repo/Jira base URLs as a domain value object). The JSON tags double as the on-disk shape of the golden-test fixtures.commit/issueentities —Commit(with revert/reapply/key rules) andIssue(the loaded issue's key, title, and status).
infrastructure/git/remote.go—InferRemoteBaseURLderives the GitHub base URL from the git remote (origin, thenupstream), resolving SSH host aliases viassh -G;ParseGithubSpecturns an explicit--github-repo/SHIPNOTES_GITHUB_REPOvalue (a URL, an SSH remote, or the bareorg/reposhorthand →github.com) into the same base URL.infrastructure/config/{config.go,dotenv.go}—LoadDotEnvreads the.env;Loadvalidates the three required Jira values (a flag overrides the environment) and records the already-resolved, optional GitHub base URL. Hand-written.envreader.
The end-to-end flow (start reading at internal/application/app.go, Run):
flags ─▶ validate commit ─▶ git log ─▶ parse commits ─▶ load Jira issues
─▶ build data model ─▶ render template ─▶ write Markdown file
Step by step:
cli.Runparses args (including the optional--jqlquery), compiles the--checked-statusesand--exclude-commitsregexps into anotes.StatusMatcherand anotes.CommitMatcher(a bad pattern fails fast before any git/Jira work), resolves the repo dir, and sets up a signal-cancellablecontext.Context.loadSettingsloads the.env, then resolves the optional GitHub base URL (--github-repo/SHIPNOTES_GITHUB_REPO, else the inferred git remote — a flag/env value that cannot be parsed is fatal; it warns when none resolves or the host is notgithub.com), thenconfig.Loadvalidates the Jira values (flag over env, real env over.env).buildService(composition root) wires the concrete adapters intoapplication.Service, passing thenotes.StatusMatcherandnotes.CommitMatcherintonotes.NewBuilder.Service.Run:repo.Validate→repo.Log→ (if--jqlgiven)searcher.SearchByJQL→builder.Build(callsissue.Provider.LoadByKeys) →renderer.Render→writer.Write.cli.generatepresents the result (commit count + output path) and returns the process exit code.
Cancellation: SIGINT/SIGTERM cancels the context, which propagates into git and Jira calls.
- Build:
go build -o shipnotes .→ one static binary, no install step. - Run anywhere Go-built binaries run, provided
gitis onPATHand the Jira host is reachable. - Configuration at runtime: three required Jira values plus an optional GitHub repository (see
.env.example). Each is settable as a flag (--jira-base-url,--jira-email,--jira-token,--github-repo) or an environment variable; precedence is flag > real env > nearest.env(walking up from the working dir, or an explicit--env-file). The GitHub repository is inferred from the git remote when unset and is optional — the notes still render (with commit/PR links omitted) when none can be determined. - Release & distribution: pushing a
v*tag triggers.github/workflows/release.yml, which runs GoReleaser (.goreleaser.yaml) to cross-compile the binary for linux/darwin/windows × amd64/arm64, package the archives plus achecksums.txt, and publish a GitHub release. The release tag is stamped into the binary via-ldflags -X main.version=…and reported byshipnotes --version. GoReleaser is dev/CI-only tooling — it never ships in the binary, so goal 2 (zero runtime dependencies) still holds.
- Configuration & inference — precedence is flag > real env >
.env; the optional GitHub repository additionally falls back to the inferred git remote. See §7 andinfrastructure/config,infrastructure/git/remote.go. - Error handling — return errors with descriptive, user-facing prose; handle with the early-return guard pattern; no panics (except
template.Must). - Progress reporting — the
report.Reporterport keeps the domain free of the terminal;infrastructure/terminalprovides colored output via a small ANSI helper. - Output stability — the Markdown template (
markdown/templates/shipnotes.tmpl) plus golden tests are the output contract (see §11 / the testing strategy). - Workflow-agnosticism — no hard-coded "done" statuses in grouping; issues are grouped by their status text, sorted alphabetically. The two opt-in opinions are
--checked-statuses(anotes.StatusMatcher, sets the checkbox state) and--exclude-commits(anotes.CommitMatcher, filters commits out); both are disabled by an empty pattern, restoring fully neutral output.
| Decision | Rationale |
|---|---|
| Hexagonal / DDD layering | Keeps a pure, testable domain and isolates git/Jira/FS/terminal behind ports. |
| Zero external dependencies | Simplicity, security, and a trivial build/run story; the stdlib covers every need. |
Shell out to git rather than a Go git library |
Avoids a heavy dependency; matches the "use what's installed" philosophy. |
| Golden-file tests for output | Treats rendered Markdown as a contract; any change is a reviewed diff. |
| Infer the GitHub repository from the git remote | Minimizes required configuration to the three Jira variables in the common case. |
One optional SHIPNOTES_GITHUB_REPO / --github-repo, replacing the old org/repo/URL trio |
The renderer only ever needed the GitHub base URL, so the three coordinate variables collapsed into one. It accepts a URL, an SSH remote, or the bare org/repo (assumed github.com), parsed by git.ParseGithubSpec. It is optional: when neither flag, env, nor git remote yields one, the cli warns and renders the notes with commit/PR links omitted — the template degrades to plain hashes and leaves (#123) references literal; a non-github.com host warns too (the links use GitHub's /commit/ and /pull/ scheme). An explicit but unparseable value is fatal, since the user named a specific repo. |
Every config value also settable as a flag (--jira-*, --github-repo) |
Lets the tool run with no .env file (e.g. CI) — a flag takes precedence over the environment. Resolution lives in the cli composition root, which keeps config and git as independent sibling adapters: config validates the Jira values, the cli orchestrates the git-backed GitHub resolution and its warnings. |
| Workflow-agnostic status grouping | Works on any Jira setup without per-project config. |
--jql as the sole release-issue source (no --ids, no interactive prompt) |
One non-interactive, script-friendly input; JQL expresses both explicit key lists (key IN (…)) and richer selections (fixVersion, project). The Jira search reuses the existing /search/jql adapter. Omitting --jql falls back to summarizing every issue in the commit range. |
Warn (don't fail) when --jql matches no issues |
A zero-result query is valid but rarely intended, so the Jira adapter emits a warning and the run continues with the commit-range fallback. The builder distinguishes "no selection" (nil list → fallback note) from "selection matched nothing" (non-nil empty list → adapter already warned), so the user sees one clear message, not two. |
--checked-statuses as an opt-in regexp marking completed issues |
Status grouping stays workflow-agnostic, but the checklist boxes carry one configurable opinion: issues whose full status text matches a case-insensitive regexp render as [x]. The match is a domain value (notes.StatusMatcher), anchored (^(?:…)$) so a pattern matches a whole status not a substring, defaulting to done|ready to release|ready for release. An empty pattern disables checking and restores fully status-neutral output. Reverted/reapplied lists have no status, so they stay unchecked. |
--exclude-commits as an opt-in regexp filtering commits |
Lets a release manager drop noise (docs/chore/etc.) without hiding it: matched commits are relocated, not deleted, to an "Excluded commits" section so the notes stay auditable (it renders even when the range has no Jira issues). The match is a domain value (notes.CommitMatcher), unanchored (it matches a prefix/substring like ^(chore|docs):, unlike the whole-value StatusMatcher), tested against the commit subject — which carries the Jira key, so one pattern excludes by type or by ticket. It is the first gate (an excluded commit never also appears under reverted/reapplied or in the summary, and its author drops from Participants), and is empty by default so the standard output is unchanged. |
| Status-specific Jira API error messages with Jira's own detail | infrastructure/jira parses the error body (errorMessages) and tailors the "Possible causes / Troubleshooting" guidance per HTTP status (400 → JQL, 401 → credentials, 403 → permissions, 404 → endpoint, 429 → rate limit), so a malformed JQL points at the query instead of generic credential advice. |
| Release via GoReleaser + GitHub Actions (tag-triggered) | Reproducible cross-platform binaries with checksums on every v* tag, with no hand-built release steps; the tooling is CI-only and keeps the binary dependency-free. |
Single # (h1) per document, no --- rules |
# Release Notes is the lone document title; "Participants" and "Commit history" render as ## siblings of "Release summary" rather than competing h1s. One h1 is the canonical Markdown title convention — it keeps the heading outline well-formed for tables of contents, anchor generation, and Markdown linters, and reflects that the whole file is one release-notes document with several sections. The horizontal rules that previously separated those h1 blocks were dropped: an ## heading already reads as a section break in every Markdown viewer, so the rules added visual noise without semantic value. |
- Correctness gate:
go build,go vet,go test ./..., andgolangci-lint run ./...must all pass with zero issues before any task is considered complete. - Readability: code understandable by people who have never written Go (§1).
- Output stability: golden tests must pass; intentional changes regenerate goldens (
go test ./internal/infrastructure/markdown -update) and review the diff.
- Dependence on the external
gitCLI — behavior/format differences across git versions are a latent risk; mitigated by parsing a stablegit logformat. - Jira REST API coupling — API/auth changes can break issue loading; isolated to
infrastructure/jira. - Golden tests are strict — any unintended whitespace/template change fails the build (this is by design, but means template edits need care).
This file is not auto-generated; it is maintained by whoever (human or AI) changes the code. The contract: when a change crosses one of the boundaries below, update the named section in the same commit/PR.
| If you change… | Update section(s) |
|---|---|
| A package: add / remove / rename, or move responsibility between packages | §5 Building block view (and the directory map in CLAUDE.md) |
A port (interface in domain/* or application/app.go) or its adapter |
§5 (Level 2 port/adapter tables) |
The end-to-end flow / order of steps in application/app.go or cli/cli.go |
§6 Runtime view |
Runtime requirements, build command, or configuration (env vars, .env, inference) |
§2 Constraints, §7 Deployment, §8 Crosscutting |
Add or remove a dependency (go.mod) |
§1 (goal 2), §2 Constraints, §9 Decisions |
| A significant design decision or trade-off | §9 Architecture decisions |
| The output contract (template / golden files) | §1 (goal 3), §10, §11 |
If a change makes a statement here false, fix the statement — a drifted architecture doc is worse than none. Keep edits surgical: change the affected rows/sections, not the whole document.