From 2c6c6476b5c112cb6df1bbde4cdfe7e649f417fb Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sat, 1 Aug 2026 13:54:06 +0200 Subject: [PATCH] docs: add a release-algorithm diagram --- docs/diagrams/README.md | 13 ++++ docs/diagrams/release-algorithm.md | 103 +++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 docs/diagrams/README.md create mode 100644 docs/diagrams/release-algorithm.md diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md new file mode 100644 index 0000000..9e270ca --- /dev/null +++ b/docs/diagrams/README.md @@ -0,0 +1,13 @@ +# Diagrams + +| Diagram | Covers | +|---|---| +| [release-algorithm.md](release-algorithm.md) | Commit analysis → bump → manifest writes → commit/tag/push/publish, the crash-resume checkpoint, and why the step order is what it is | + +Mermaid, so GitHub renders inline. + +**Keep it current.** Read it before changing the release path, and update it in the same PR. + +The ordering claims are the load-bearing part — "git lands before the forge", the lock taken before +any mutating step, the checkpoint refusing a stale HEAD. Those read as incidental and are not: each +one is what keeps a failed release recoverable instead of leaving a half-published mess. diff --git a/docs/diagrams/release-algorithm.md b/docs/diagrams/release-algorithm.md new file mode 100644 index 0000000..5937770 --- /dev/null +++ b/docs/diagrams/release-algorithm.md @@ -0,0 +1,103 @@ +# The release algorithm + +What `ferrflow` does between "read the commits" and "the release exists". The ordering here is not +arbitrary — several steps are sequenced specifically so a crash or a failure leaves the repository +in a state you can resume from rather than repair by hand. + +## Overall shape + +```mermaid +flowchart TB + START(["ferrflow release"]) --> LOCK["acquire release lock
(RAII, skipped on dry-run)"] + LOCK --> GRAPH["write commit-graph if missing
(fresh clones walk tags + commits repeatedly)"] + GRAPH --> TAGS["pre-collect every tag + commit OID
in one tag_foreach scan"] + TAGS --> ANALYSE["per package: find last tag,
read commits since,
classify conventional commits"] + ANALYSE --> PLAN["compute the bump
→ snapshot of every package to change"] + PLAN --> CL["build the changelog section once"] + CL --> WRITE["write versions into the manifests"] + WRITE --> EXEC["execute_release"] +``` + +The tag pre-collection matters more than it looks: without it, every package does its own tag walk, +and lookups go from `O(1)` against a prepared map to `O(tags)` callbacks plus an `O(commits)` walk — +per package. On a monorepo with many tags that is the difference between seconds and minutes. + +The changelog section is built **once** and reused: hooks receive it (`ctx.changelog` / +`FERRFLOW_CHANGELOG`) and the tag body uses the same text, so the release notes, the tag annotation +and the hook input cannot disagree. + +## Execution, and why the order is what it is + +```mermaid +flowchart TB + subgraph exec["execute_release"] + PC["pre-commit hooks"] --> CM["commit — or release branch + PR"] + CM --> TG["create release tags
+ move floating tags"] + TG --> PUSH["push refs"] + PUSH --> REL["create forge releases"] + REL --> POST["post-publish hooks"] + end + + PC -.->|"may append files"| CM +``` + +**Git lands before the forge.** Creating a GitHub release for a tag that was never pushed produces a +release pointing at a ref nobody else can resolve. Pushing first means the worst case is a pushed +tag with no release yet — recoverable, and visible. + +**`files_to_commit` is snapshotted after the pre-commit hooks run**, because a hook may generate +files (a lockfile, a formatted manifest) that belong in the release commit. + +**PR mode uses one stable branch per target** (`ferrflow/`), so repeated runs reuse the same +pull request instead of opening a new one each time. The branch is force-pushed — rebuilt from the +fresh release commit — but the code refuses to clobber commits a human pushed onto it. + +## Crash resume + +A release is a sequence of irreversible side effects. The checkpoint records how far it got. + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> CommitDone + CommitDone --> TagsCreated + TagsCreated --> Pushed + Pushed --> ReleasesCreated + ReleasesCreated --> PostPublishDone + PostPublishDone --> [*] +``` + +Written to `.git/ferrflow.checkpoint.json`, pinned to the HEAD it was created against. + +- Resuming skips every phase already marked done — it will not double-tag or double-publish. +- A checkpoint pinned to a **different commit** means the repository moved since the interrupted + run. That is treated as stale and refused rather than resumed, because silently continuing would + apply half a release to the wrong tree. +- Dry-run records nothing. + +## Writing versions into manifests + +```mermaid +flowchart LR + V["new version"] --> D{"file kind"} + D --> TOML["Cargo.toml, pyproject"] + D --> JSON["package.json"] + D --> XML["csproj, pom"] + D --> YAML["Chart.yaml, pubspec"] + D --> OTHER["mix.exs, gemspec, gomod,
gradle, cmake, cabal,
package.swift, txt"] + D --> LOCK["lockfiles"] +``` + +18 modules under `src/formats/`, each owning one file shape and each carrying its own tests. A +format writer edits **in place** rather than reserialising — reformatting a user's manifest is a +bug, not a side effect, so the writers splice the version and leave everything else byte-identical. + +Adding a format means a new module there plus its tests; nothing else in the pipeline changes. + +## Invariants + +- **The lock is taken before any mutating step** and released by RAII. Two concurrent releases in + one repository would interleave tags and commits. +- **Dry-run writes nothing** — no lock, no checkpoint, no refs. It prints the diffs it would make. +- **Never reformat a manifest.** Only the version bytes change. +- **Push before publish.** Always.