From b3a19d43b3173990239c77c80476cb09b36c09fc Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Tue, 14 Apr 2026 04:10:29 -0700 Subject: [PATCH 1/4] chore: migrate to workspace structure Move atomic-rollback from root-crate to crates/atomic-rollback/ workspace member. Root Cargo.toml becomes a workspace manifest with resolver = "3" and one member. No functional change: binary builds to target/release/atomic-rollback as before; atomic-rollback.spec and .copr/Makefile reference paths that are unchanged by the move; CI workflow invokes cargo at workspace root and produces the same artifact. This commit prepares the tree for adding changelog-core and changelog crates in the next commit. --- Cargo.toml | 24 ++++--------------- crates/atomic-rollback/Cargo.toml | 19 +++++++++++++++ {src => crates/atomic-rollback/src}/check.rs | 0 {src => crates/atomic-rollback/src}/consts.rs | 0 {src => crates/atomic-rollback/src}/grub.rs | 0 .../atomic-rollback/src}/kernel_hook.rs | 0 {src => crates/atomic-rollback/src}/main.rs | 0 .../atomic-rollback/src}/migrate.rs | 0 {src => crates/atomic-rollback/src}/parse.rs | 0 .../atomic-rollback/src}/platform.rs | 0 {src => crates/atomic-rollback/src}/proof.rs | 0 .../atomic-rollback/src}/rollback.rs | 0 .../atomic-rollback/src}/snapshot.rs | 0 {src => crates/atomic-rollback/src}/swap.rs | 0 {src => crates/atomic-rollback/src}/tools.rs | 0 15 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 crates/atomic-rollback/Cargo.toml rename {src => crates/atomic-rollback/src}/check.rs (100%) rename {src => crates/atomic-rollback/src}/consts.rs (100%) rename {src => crates/atomic-rollback/src}/grub.rs (100%) rename {src => crates/atomic-rollback/src}/kernel_hook.rs (100%) rename {src => crates/atomic-rollback/src}/main.rs (100%) rename {src => crates/atomic-rollback/src}/migrate.rs (100%) rename {src => crates/atomic-rollback/src}/parse.rs (100%) rename {src => crates/atomic-rollback/src}/platform.rs (100%) rename {src => crates/atomic-rollback/src}/proof.rs (100%) rename {src => crates/atomic-rollback/src}/rollback.rs (100%) rename {src => crates/atomic-rollback/src}/snapshot.rs (100%) rename {src => crates/atomic-rollback/src}/swap.rs (100%) rename {src => crates/atomic-rollback/src}/tools.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 1f716ad..cef8dca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,5 @@ -[package] -name = "atomic-rollback" -version = "0.4.0" -edition = "2024" -description = "Atomic system rollback for Fedora via Btrfs RENAME_EXCHANGE subvolume swap" -license = "GPL-3.0-only" -repository = "https://github.com/rocketman-code/atomic-rollback" -keywords = ["btrfs", "rollback", "fedora", "snapshot", "atomic"] -categories = ["command-line-utilities", "filesystem"] - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)', 'cfg(verus_only)', 'cfg(verus_keep_ghost)'] } - -[dependencies] -libc = "0.2" -vstd = "=0.0.0-2026-03-29-0113" - -[package.metadata.verus] -verify = true +[workspace] +resolver = "3" +members = [ + "crates/atomic-rollback", +] diff --git a/crates/atomic-rollback/Cargo.toml b/crates/atomic-rollback/Cargo.toml new file mode 100644 index 0000000..1f716ad --- /dev/null +++ b/crates/atomic-rollback/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "atomic-rollback" +version = "0.4.0" +edition = "2024" +description = "Atomic system rollback for Fedora via Btrfs RENAME_EXCHANGE subvolume swap" +license = "GPL-3.0-only" +repository = "https://github.com/rocketman-code/atomic-rollback" +keywords = ["btrfs", "rollback", "fedora", "snapshot", "atomic"] +categories = ["command-line-utilities", "filesystem"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)', 'cfg(verus_only)', 'cfg(verus_keep_ghost)'] } + +[dependencies] +libc = "0.2" +vstd = "=0.0.0-2026-03-29-0113" + +[package.metadata.verus] +verify = true diff --git a/src/check.rs b/crates/atomic-rollback/src/check.rs similarity index 100% rename from src/check.rs rename to crates/atomic-rollback/src/check.rs diff --git a/src/consts.rs b/crates/atomic-rollback/src/consts.rs similarity index 100% rename from src/consts.rs rename to crates/atomic-rollback/src/consts.rs diff --git a/src/grub.rs b/crates/atomic-rollback/src/grub.rs similarity index 100% rename from src/grub.rs rename to crates/atomic-rollback/src/grub.rs diff --git a/src/kernel_hook.rs b/crates/atomic-rollback/src/kernel_hook.rs similarity index 100% rename from src/kernel_hook.rs rename to crates/atomic-rollback/src/kernel_hook.rs diff --git a/src/main.rs b/crates/atomic-rollback/src/main.rs similarity index 100% rename from src/main.rs rename to crates/atomic-rollback/src/main.rs diff --git a/src/migrate.rs b/crates/atomic-rollback/src/migrate.rs similarity index 100% rename from src/migrate.rs rename to crates/atomic-rollback/src/migrate.rs diff --git a/src/parse.rs b/crates/atomic-rollback/src/parse.rs similarity index 100% rename from src/parse.rs rename to crates/atomic-rollback/src/parse.rs diff --git a/src/platform.rs b/crates/atomic-rollback/src/platform.rs similarity index 100% rename from src/platform.rs rename to crates/atomic-rollback/src/platform.rs diff --git a/src/proof.rs b/crates/atomic-rollback/src/proof.rs similarity index 100% rename from src/proof.rs rename to crates/atomic-rollback/src/proof.rs diff --git a/src/rollback.rs b/crates/atomic-rollback/src/rollback.rs similarity index 100% rename from src/rollback.rs rename to crates/atomic-rollback/src/rollback.rs diff --git a/src/snapshot.rs b/crates/atomic-rollback/src/snapshot.rs similarity index 100% rename from src/snapshot.rs rename to crates/atomic-rollback/src/snapshot.rs diff --git a/src/swap.rs b/crates/atomic-rollback/src/swap.rs similarity index 100% rename from src/swap.rs rename to crates/atomic-rollback/src/swap.rs diff --git a/src/tools.rs b/crates/atomic-rollback/src/tools.rs similarity index 100% rename from src/tools.rs rename to crates/atomic-rollback/src/tools.rs From d98c56dd8babc640a804ac3a0791768a114db10b Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Tue, 14 Apr 2026 17:49:39 -0700 Subject: [PATCH 2/4] feat(changelog): back-fill history into source-of-truth crate Add changelog-core crate (Fragment + VersionId + Status + Section + generator) and changelog crate (binary + build.rs drift check). Back-fill all v0.1.1-v0.4.0 entries (56 fragments) so the existing CHANGELOG.md is now produced by the generator. CHANGELOG.md changes: - Add `## [Unreleased]` section header (always emitted per design; empty until the next user-facing change ships) - Reorder sections within v0.3.1 and v0.3.2 to KaC canonical order (Added/Changed/Deprecated/Removed/Fixed/Security) - Drop "Initial release." prose under v0.1.1 (no equivalent field in the data model; not load-bearing) - Add trailing newline Architecture: - Source of truth: Fragment enum variants + Status assignments - crates/changelog-core/src/lib.rs holds all changelog content - Adding/transitioning/removing fragments in lib.rs is the only way to change CHANGELOG.md - crates/changelog/build.rs runs at compile time, regenerates the expected CHANGELOG.md from source, panics on mismatch with the committed file - "CHANGELOG.md and source disagree" is now an unrepresentable state for any release build (cargo build fails) Manual workflow when fragments change: cargo run -p changelog > CHANGELOG.md git add CHANGELOG.md crates/changelog-core/src/lib.rs Status::InternalOnly variant exists for changes with no user-perceivable effect (escape hatch for pure private refactors); not emitted to CHANGELOG.md. --- CHANGELOG.md | 19 +- Cargo.lock | 11 + Cargo.toml | 2 + crates/changelog-core/Cargo.toml | 6 + crates/changelog-core/src/lib.rs | 526 ++++++++++++++++++++++++++ crates/changelog/Cargo.toml | 16 + crates/changelog/build.rs | 27 ++ crates/changelog/src/bin/changelog.rs | 3 + 8 files changed, 601 insertions(+), 9 deletions(-) create mode 100644 crates/changelog-core/Cargo.toml create mode 100644 crates/changelog-core/src/lib.rs create mode 100644 crates/changelog/Cargo.toml create mode 100644 crates/changelog/build.rs create mode 100644 crates/changelog/src/bin/changelog.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 838b67d..f5351ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to atomic-rollback are documented here. +## [Unreleased] + ## [0.4.0] - 2026-04-14 ### Added @@ -74,6 +76,10 @@ All notable changes to atomic-rollback are documented here. ## [0.3.2] - 2026-04-01 +### Changed + +- Internal architecture: all external tool output parsed through grammar-derived types at the boundary. Filesystem type comparisons use an enum instead of string matching. + ### Fixed - Subvolume names with spaces now parse correctly. The btrfs output parser used whitespace splitting which truncated paths containing spaces. @@ -82,11 +88,11 @@ All notable changes to atomic-rollback are documented here. - BLS root= parameter check accepts all kernel device formats (PARTUUID=, PARTLABEL=, /dev/). Previously only root=UUID= and root=/dev/ were accepted. - ESP grub.cfg migration renders from the generator template instead of line surgery, eliminating the double-prefix bug class by construction. -### Changed +## [0.3.1] - 2026-03-31 -- Internal architecture: all external tool output parsed through grammar-derived types at the boundary. Filesystem type comparisons use an enum instead of string matching. +### Changed -## [0.3.1] - 2026-03-31 +- Installation via COPR is the only supported method. The crate was removed from crates.io (binary alone is insufficient without the hook and plugin). ### Fixed @@ -94,10 +100,6 @@ All notable changes to atomic-rollback are documented here. - RPM spec rewritten for COPR vendored builds. The previous spec used %cargo_build which expects Fedora-packaged crates. - COPR Makefile builds from cloned source with correct outdir contract. -### Changed - -- Installation via COPR is the only supported method. The crate was removed from crates.io (binary alone is insufficient without the hook and plugin). - ## [0.3.0] - 2026-03-30 ### Added @@ -141,8 +143,6 @@ All notable changes to atomic-rollback are documented here. ## [0.1.1] - 2026-03-29 -Initial release. - ### Added - `check`, `migrate`, `rollback`, `snapshot` commands. @@ -159,3 +159,4 @@ Initial release. - All system-specific values (device ref, compression, subvol name) derived from fstab. - Bootability predicate derived from the actual Fedora boot chain. - RPM spec with kernel-install hook and dnf plugin. + diff --git a/Cargo.lock b/Cargo.lock index 4a23c85..d5a97fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "changelog" +version = "0.1.0" +dependencies = [ + "changelog-core", +] + +[[package]] +name = "changelog-core" +version = "0.1.0" + [[package]] name = "hashbrown" version = "0.12.3" diff --git a/Cargo.toml b/Cargo.toml index cef8dca..1d5e632 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,4 +2,6 @@ resolver = "3" members = [ "crates/atomic-rollback", + "crates/changelog-core", + "crates/changelog", ] diff --git a/crates/changelog-core/Cargo.toml b/crates/changelog-core/Cargo.toml new file mode 100644 index 0000000..e3f5417 --- /dev/null +++ b/crates/changelog-core/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "changelog-core" +version = "0.1.0" +edition = "2024" +publish = false +description = "Source-of-truth and generator for atomic-rollback's CHANGELOG.md" diff --git a/crates/changelog-core/src/lib.rs b/crates/changelog-core/src/lib.rs new file mode 100644 index 0000000..d825dcc --- /dev/null +++ b/crates/changelog-core/src/lib.rs @@ -0,0 +1,526 @@ +//! Source of truth for atomic-rollback's CHANGELOG.md. +//! +//! Every user-facing change is a `Fragment` variant with a classified +//! `Status` (Released in a version, Unreleased pending the next bump, +//! or InternalOnly for changes with no user-perceivable effect). +//! CHANGELOG.md is generated from this data by the `changelog` binary. +//! `crates/changelog/build.rs` enforces that CHANGELOG.md matches +//! generated output via rustc-time panic on drift. + +// --- Section --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Section { + Added, + Changed, + Deprecated, + Removed, + Fixed, + Security, +} + +impl Section { + pub const CANONICAL_ORDER: &'static [Section] = &[ + Section::Added, + Section::Changed, + Section::Deprecated, + Section::Removed, + Section::Fixed, + Section::Security, + ]; + + pub const fn heading(self) -> &'static str { + match self { + Section::Added => "Added", + Section::Changed => "Changed", + Section::Deprecated => "Deprecated", + Section::Removed => "Removed", + Section::Fixed => "Fixed", + Section::Security => "Security", + } + } +} + +// --- VersionId --- + +macro_rules! versions { + ($($v:ident),* $(,)?) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum VersionId { $($v,)* } + impl VersionId { + pub const ALL: &'static [Self] = &[$(Self::$v,)*]; + } + }; +} + +versions! { + V0_1_1, V0_1_3, V0_1_4, V0_2_0, + V0_3_0, V0_3_1, V0_3_2, V0_3_3, V0_3_4, V0_3_5, V0_3_6, V0_3_7, V0_3_8, + V0_4_0, +} + +impl VersionId { + pub const fn semver(self) -> &'static str { + match self { + VersionId::V0_1_1 => "0.1.1", + VersionId::V0_1_3 => "0.1.3", + VersionId::V0_1_4 => "0.1.4", + VersionId::V0_2_0 => "0.2.0", + VersionId::V0_3_0 => "0.3.0", + VersionId::V0_3_1 => "0.3.1", + VersionId::V0_3_2 => "0.3.2", + VersionId::V0_3_3 => "0.3.3", + VersionId::V0_3_4 => "0.3.4", + VersionId::V0_3_5 => "0.3.5", + VersionId::V0_3_6 => "0.3.6", + VersionId::V0_3_7 => "0.3.7", + VersionId::V0_3_8 => "0.3.8", + VersionId::V0_4_0 => "0.4.0", + } + } + + pub const fn date(self) -> &'static str { + match self { + VersionId::V0_1_1 => "2026-03-29", + VersionId::V0_1_3 => "2026-03-29", + VersionId::V0_1_4 => "2026-03-29", + VersionId::V0_2_0 => "2026-03-29", + VersionId::V0_3_0 => "2026-03-30", + VersionId::V0_3_1 => "2026-03-31", + VersionId::V0_3_2 => "2026-04-01", + VersionId::V0_3_3 => "2026-04-03", + VersionId::V0_3_4 => "2026-04-03", + VersionId::V0_3_5 => "2026-04-03", + VersionId::V0_3_6 => "2026-04-04", + VersionId::V0_3_7 => "2026-04-05", + VersionId::V0_3_8 => "2026-04-07", + VersionId::V0_4_0 => "2026-04-14", + } + } +} + +// --- Status --- + +#[derive(Debug, Clone, Copy)] +pub enum Status { + Released { + version: VersionId, + section: Section, + text: &'static str, + }, + Unreleased { + section: Section, + text: &'static str, + }, + /// Acknowledged change with no user-perceivable effect. + /// Description is for reviewer audit; not emitted to CHANGELOG.md. + /// Use sparingly; default to Unreleased when in doubt. + #[allow(dead_code)] + InternalOnly { + description: &'static str, + }, +} + +// --- Fragment --- + +macro_rules! fragments { + ($($v:ident),* $(,)?) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Fragment { $($v,)* } + impl Fragment { + pub const ALL: &'static [Self] = &[$(Self::$v,)*]; + } + }; +} + +fragments! { + // v0.1.1 (14 Added bullets, in CHANGELOG.md order) + CheckMigrateRollbackSnapshotCommands, + TenStepGatedMigration, + NineKaniVerifiedTheorems, + FifteenVerusParserConditions, + VerifyBeforeSwapForRenameExchange, + RollbackUndoesSwapOnSetDefaultFail, + WarnOutputForPartialBootEntries, + PlatformModuleCentralizesDistroPaths, + DnfPluginForPreTransactionSnapshots, + IdempotentSnapshotCommand, + ResolveFstabDeviceHandlesMultipleFormats, + SystemValuesDerivedFromFstab, + BootabilityPredicateFromBootChain, + RpmSpecWithHookAndPlugin, + + // v0.1.3 (2 Added bullets) + SyncfsAtExitPoints, + TenthKaniTheoremRebootSafe, + + // v0.1.4 (1 Added, 1 Fixed) + EleventhKaniTheoremDataSafety, + EspGrubCfgVerifiesAllProperties, + + // v0.2.0 (2 Added bullets) + SetupCommand, + TwelfthKaniTheoremSetupIsSafe, + + // v0.3.0 (4 Added, 1 Changed, 1 Fixed) + SnapshotCreateSubcommand, + SnapshotListSubcommand, + SnapshotDeleteSubcommand, + HelpFlagTopLevelAndSubcommands, + SnapshotNameReplacedBySnapshotCreate, + MigrationStep1AllFstabFormats, + + // v0.3.1 (3 Fixed, 1 Changed) + KernelHookUsesFullBinaryPath, + RpmSpecForCoprVendoredBuilds, + CoprMakefileBuildsFromClonedSource, + InstallationViaCoprOnly, + + // v0.3.2 (5 Fixed, 1 Changed) + SubvolumeNamesWithSpaces, + VerificationChainAllFstabFormats, + BlsInitrdValidationAllLines, + BlsRootParamAllDeviceFormats, + EspGrubCfgMigrationFromTemplate, + InternalArchitectureGrammarTypes, + + // v0.3.3 (1 Fixed) + CheckFailedOnVanillaFedoraCantLookupBlockdev, + + // v0.3.4 (1 Changed, 1 Removed) + LicenseChangedToGplV3, + ScriptsMonitorRedditRemoved, + + // v0.3.5 (1 Changed) + DeviceReferencesTyped, + + // v0.3.6 (1 Fixed) + CheckFailedOnAarch64ShimX64Missing, + + // v0.3.7 (1 Added, 2 Fixed) + RpmPluginUniversalPreTransactionSnapshot, + SnapshotNoContradictoryMessages, + SnapshotNoOpOnNonBtrfs, + + // v0.3.8 (1 Fixed) + CheckFailedOnNonDefaultRootSubvolName, + + // v0.4.0 (7 Added, 2 Changed, 1 Removed, 1 Fixed) + AutomaticSnapshotsRollingTimestampNames, + SnapshotRetention, + SnapshotListThreeColumnTable, + RollbackAndDeleteAcceptBtrfsIds, + SnapshotCreateShowsId, + CheckAndRollbackShowScope, + VersionFlag, + BootChainTerminology, + KernelHookOwnedByMigrate, + Libdnf5ActionsPluginRemoved, + LegacyRootPreUpdateRenamed, +} + +impl Fragment { + pub const fn status(self) -> Status { + match self { + // v0.1.1 Added + Self::CheckMigrateRollbackSnapshotCommands => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "`check`, `migrate`, `rollback`, `snapshot` commands.", + }, + Self::TenStepGatedMigration => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "10-step gated migration: /boot to Btrfs, /var separation, ESP update, grubenv NOCOW, save_env stripping, symlinks, kernel-install hook.", + }, + Self::NineKaniVerifiedTheorems => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "9 Kani-verified theorems: migration preserves bootability, rollback preserves bootability, step ordering, kernel installs, idempotency, GRUB Btrfs constraint, creation failure safety, all swaps require verification, /var config consistency.", + }, + Self::FifteenVerusParserConditions => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "15 Verus-verified parser conditions inline via `verus!` macro.", + }, + Self::VerifyBeforeSwapForRenameExchange => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "Verify-before-swap for all `RENAME_EXCHANGE` operations (rollback, migration, kernel hook).", + }, + Self::RollbackUndoesSwapOnSetDefaultFail => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "Rollback undoes swap if `set-default` fails.", + }, + Self::WarnOutputForPartialBootEntries => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "`WARN` output for partially valid boot entries (exit code 2).", + }, + Self::PlatformModuleCentralizesDistroPaths => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "`platform.rs` centralizes distro-specific paths.", + }, + Self::DnfPluginForPreTransactionSnapshots => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "`dnf` plugin for automatic pre-transaction snapshots via libdnf5 actions.", + }, + Self::IdempotentSnapshotCommand => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "Idempotent snapshot command (existing snapshot returns success).", + }, + Self::ResolveFstabDeviceHandlesMultipleFormats => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "`resolve_fstab_device` handles `UUID=`, `/dev/`, `LABEL=`.", + }, + Self::SystemValuesDerivedFromFstab => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "All system-specific values (device ref, compression, subvol name) derived from fstab.", + }, + Self::BootabilityPredicateFromBootChain => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "Bootability predicate derived from the actual Fedora boot chain.", + }, + Self::RpmSpecWithHookAndPlugin => Status::Released { + version: VersionId::V0_1_1, section: Section::Added, + text: "RPM spec with kernel-install hook and dnf plugin.", + }, + + // v0.1.3 Added + Self::SyncfsAtExitPoints => Status::Released { + version: VersionId::V0_1_3, section: Section::Added, + text: "`syncfs` at every exit point (migration, rollback, kernel hook). Btrfs `RENAME_EXCHANGE` and `set-default` use `btrfs_end_transaction` (in-memory journal only). Without `syncfs`, changes could be lost on power failure within 30 seconds of completion. Derived from kernel source (inode.c:8534, ioctl.c:2806).", + }, + Self::TenthKaniTheoremRebootSafe => Status::Released { + version: VersionId::V0_1_3, section: Section::Added, + text: "10th Kani theorem (`all_exit_points_are_reboot_safe`): every exit point is both bootable AND durable. The model tracks `durable: bool` and requires `sync_filesystem` before `reboot_safe` can hold.", + }, + + // v0.1.4 Added + Fixed + Self::EleventhKaniTheoremDataSafety => Status::Released { + version: VersionId::V0_1_4, section: Section::Added, + text: "11th Kani theorem (`data_safe_across_all_operations`): /home and /var are never modified by any operation (separate subvolumes, not part of any swap). After rollback, the old root is preserved at the snapshot name. No operation in the tool destroys user data.", + }, + Self::EspGrubCfgVerifiesAllProperties => Status::Released { + version: VersionId::V0_1_4, section: Section::Fixed, + text: "ESP grub.cfg substitution now verifies all three model properties (UUID, `btrfs_relative_path`, prefix path) on the output BEFORE the swap. Previously only the UUID was checked. If any property is missing, the swap is refused and the old ESP is preserved. Closes the gap that allowed prefix doubling to reach the swap during development.", + }, + + // v0.2.0 Added + Self::SetupCommand => Status::Released { + version: VersionId::V0_2_0, section: Section::Added, + text: "`setup` command: separates /var and enables root snapshots and rollback without touching /boot or the ESP. Works on stock Fedora partition layout. No GRUB Btrfs dependency. Closes #1.", + }, + Self::TwelfthKaniTheoremSetupIsSafe => Status::Released { + version: VersionId::V0_2_0, section: Section::Added, + text: "12th Kani theorem (`setup_is_safe`): setup preserves bootability, is reboot-safe after sync, data-safe, and rollback works on the setup'd system.", + }, + + // v0.3.0 Added + Changed + Fixed + Self::SnapshotCreateSubcommand => Status::Released { + version: VersionId::V0_3_0, section: Section::Added, + text: "`snapshot create [name]` subcommand: explicit snapshot creation with optional name.", + }, + Self::SnapshotListSubcommand => Status::Released { + version: VersionId::V0_3_0, section: Section::Added, + text: "`snapshot list` subcommand: shows available snapshots, excluding system subvolumes.", + }, + Self::SnapshotDeleteSubcommand => Status::Released { + version: VersionId::V0_3_0, section: Section::Added, + text: "`snapshot delete ` subcommand: refuses fstab-referenced system subvolumes (verified in VM that btrfs-progs does not check fstab). Mounted-subvolume and default-subvolume protection delegated to kernel and btrfs-progs respectively.", + }, + Self::HelpFlagTopLevelAndSubcommands => Status::Released { + version: VersionId::V0_3_0, section: Section::Added, + text: "`--help` and `-h` at top level and for snapshot subcommands.", + }, + Self::SnapshotNameReplacedBySnapshotCreate => Status::Released { + version: VersionId::V0_3_0, section: Section::Changed, + text: "`snapshot ` replaced by `snapshot create `. Bare `snapshot` (no args) still creates with the default name. Unrecognized snapshot subcommands are now rejected instead of silently treated as snapshot names.", + }, + Self::MigrationStep1AllFstabFormats => Status::Released { + version: VersionId::V0_3_0, section: Section::Fixed, + text: "Migration step 1 now handles all fstab device reference formats (UUID=, LABEL=, /dev/ paths). Previously only UUID= was supported.", + }, + + // v0.3.1 Fixed + Changed + Self::KernelHookUsesFullBinaryPath => Status::Released { + version: VersionId::V0_3_1, section: Section::Fixed, + text: "Kernel-install hook uses full binary path (/usr/bin/atomic-rollback). The bare command was not in RPM's scriptlet PATH, causing exit 127 on kernel upgrades.", + }, + Self::RpmSpecForCoprVendoredBuilds => Status::Released { + version: VersionId::V0_3_1, section: Section::Fixed, + text: "RPM spec rewritten for COPR vendored builds. The previous spec used %cargo_build which expects Fedora-packaged crates.", + }, + Self::CoprMakefileBuildsFromClonedSource => Status::Released { + version: VersionId::V0_3_1, section: Section::Fixed, + text: "COPR Makefile builds from cloned source with correct outdir contract.", + }, + Self::InstallationViaCoprOnly => Status::Released { + version: VersionId::V0_3_1, section: Section::Changed, + text: "Installation via COPR is the only supported method. The crate was removed from crates.io (binary alone is insufficient without the hook and plugin).", + }, + + // v0.3.2 Fixed + Changed + Self::SubvolumeNamesWithSpaces => Status::Released { + version: VersionId::V0_3_2, section: Section::Fixed, + text: "Subvolume names with spaces now parse correctly. The btrfs output parser used whitespace splitting which truncated paths containing spaces.", + }, + Self::VerificationChainAllFstabFormats => Status::Released { + version: VersionId::V0_3_2, section: Section::Fixed, + text: "Verification chain now handles all fstab device formats (PARTUUID=, PARTLABEL=, ID=). Previously only UUID= entries were verified; other formats silently passed without checking.", + }, + Self::BlsInitrdValidationAllLines => Status::Released { + version: VersionId::V0_3_2, section: Section::Fixed, + text: "BLS initrd validation checks all initrd lines. The verified parser previously returned only the first match; entries with multiple initrd lines had subsequent lines unchecked.", + }, + Self::BlsRootParamAllDeviceFormats => Status::Released { + version: VersionId::V0_3_2, section: Section::Fixed, + text: "BLS root= parameter check accepts all kernel device formats (PARTUUID=, PARTLABEL=, /dev/). Previously only root=UUID= and root=/dev/ were accepted.", + }, + Self::EspGrubCfgMigrationFromTemplate => Status::Released { + version: VersionId::V0_3_2, section: Section::Fixed, + text: "ESP grub.cfg migration renders from the generator template instead of line surgery, eliminating the double-prefix bug class by construction.", + }, + Self::InternalArchitectureGrammarTypes => Status::Released { + version: VersionId::V0_3_2, section: Section::Changed, + text: "Internal architecture: all external tool output parsed through grammar-derived types at the boundary. Filesystem type comparisons use an enum instead of string matching.", + }, + + // v0.3.3 Fixed + Self::CheckFailedOnVanillaFedoraCantLookupBlockdev => Status::Released { + version: VersionId::V0_3_3, section: Section::Fixed, + text: "`check` failed on vanilla Fedora 43 with \"Can't lookup blockdev.\" The root UUID extracted from BLS boot entries was passed to mount without the `UUID=` prefix, so mount received a bare UUID string instead of a valid device spec. All stock Fedora installs using `UUID=` in fstab were affected.", + }, + + // v0.3.4 Changed + Removed + Self::LicenseChangedToGplV3 => Status::Released { + version: VersionId::V0_3_4, section: Section::Changed, + text: "License changed from MIT OR Apache-2.0 to GPL-3.0-only. All future versions of this project are licensed under the GNU General Public License v3.0 only. Previously published versions (0.3.3 and earlier) remain under their original license. See LICENSE for the full text.", + }, + Self::ScriptsMonitorRedditRemoved => Status::Released { + version: VersionId::V0_3_4, section: Section::Removed, + text: "`scripts/monitor-reddit.sh` (one-time utility, not part of the distributed package).", + }, + + // v0.3.5 Changed + Self::DeviceReferencesTyped => Status::Released { + version: VersionId::V0_3_5, section: Section::Changed, + text: "All device references are now typed. Bare UUIDs, fstab device specs (UUID=, LABEL=, PARTUUID=, PARTLABEL=, ID=, /dev/ paths), resolved device paths, and subvolume names each have distinct types. Passing a bare UUID where a device spec is expected (the bug fixed in 0.3.3) is now a compile error. No behavior changes.", + }, + + // v0.3.6 Fixed + Self::CheckFailedOnAarch64ShimX64Missing => Status::Released { + version: VersionId::V0_3_6, section: Section::Fixed, + text: "`check`, `setup`, and `migrate` failed on aarch64 with \"shimx64.efi is missing.\" The EFI boot file check hardcoded x86_64 filenames instead of deriving them from the UEFI architecture suffix.", + }, + + // v0.3.7 Added + Fixed + Self::RpmPluginUniversalPreTransactionSnapshot => Status::Released { + version: VersionId::V0_3_7, section: Section::Added, + text: "RPM plugin: snapshots are now created before every RPM transaction regardless of frontend (dnf, PackageKit/Discover, bare rpm). Previously only dnf5 transactions triggered snapshots via the libdnf5 actions plugin.", + }, + Self::SnapshotNoContradictoryMessages => Status::Released { + version: VersionId::V0_3_7, section: Section::Fixed, + text: "`snapshot` no longer prints contradictory \"already exists\" and \"created\" messages on the same call. The function returns a typed result and the caller handles messaging.", + }, + Self::SnapshotNoOpOnNonBtrfs => Status::Released { + version: VersionId::V0_3_7, section: Section::Fixed, + text: "`snapshot` is now a safe no-op on non-btrfs systems instead of failing and blocking package transactions.", + }, + + // v0.3.8 Fixed + Self::CheckFailedOnNonDefaultRootSubvolName => Status::Released { + version: VersionId::V0_3_8, section: Section::Fixed, + text: "`check`, `setup`, and `migrate` failed at the baseline gate with \"Btrfs subvolume 'root' not found\" on systems with a non-default root subvolume name (e.g., the openSUSE Timeshift `@` layout). The root filesystem check hardcoded `\"root\"` instead of reading the `subvol=` mount option from `/etc/fstab`. Closes #15.", + }, + + // v0.4.0 Added + Self::AutomaticSnapshotsRollingTimestampNames => Status::Released { + version: VersionId::V0_4_0, section: Section::Added, + text: "Automatic snapshots use rolling timestamp names in `%Y-%m-%d_%H-%M-%S` format instead of a fixed `root.pre-update`. Every RPM transaction creates a new snapshot; the history is no longer overwritten on each upgrade. Closes #9.", + }, + Self::SnapshotRetention => Status::Released { + version: VersionId::V0_4_0, section: Section::Added, + text: "Snapshot retention: the tool keeps the most recent `MAX_SNAPSHOTS` automatic snapshots (default 50, configurable in `/etc/atomic-rollback.conf`) and evicts older ones. User-named snapshots are never counted against the limit and are never evicted; unbounded accumulation of user-named snapshots remains the expected behavior.", + }, + Self::SnapshotListThreeColumnTable => Status::Released { + version: VersionId::V0_4_0, section: Section::Added, + text: "`snapshot list` shows a three-column table: btrfs subvolume ID, name, and creation time. Sorted by ID (chronological).", + }, + Self::RollbackAndDeleteAcceptBtrfsIds => Status::Released { + version: VersionId::V0_4_0, section: Section::Added, + text: "`rollback [id|name]` and `snapshot delete ` accept btrfs subvolume IDs (integers) in addition to names. `rollback` with no arguments defaults to the most recent snapshot (highest ID). The IDs are btrfs filesystem primitives, monotonic and never reused, surfaced as the user-facing handle without any atomic-rollback state.", + }, + Self::SnapshotCreateShowsId => Status::Released { + version: VersionId::V0_4_0, section: Section::Added, + text: "`snapshot create` output includes the btrfs subvolume ID (e.g. `Snapshot 'foo' with ID 123 created.`) so the new snapshot can be referenced numerically in subsequent commands without running `list` first.", + }, + Self::CheckAndRollbackShowScope => Status::Released { + version: VersionId::V0_4_0, section: Section::Added, + text: "`check` and `rollback` show rollback scope: directories protected by separate btrfs subvolumes are listed as SAFE (unaffected by rollback), directories inside the root subvolume are listed as RISK (will revert on rollback). Derived from fstab `subvol=` entries and mountpoint checks on top-level directories.", + }, + Self::VersionFlag => Status::Released { + version: VersionId::V0_4_0, section: Section::Added, + text: "`--version` and `-V` print the installed version. Output format: `atomic-rollback v`, one line to stdout, matching the `btrfs-progs v6.17` convention.", + }, + + // v0.4.0 Changed + Self::BootChainTerminology => Status::Released { + version: VersionId::V0_4_0, section: Section::Changed, + text: "`check` output and user-facing documentation use \"boot chain\" terminology instead of \"system bootable.\" The tool verifies boot chain structural validity (the formal model's scope), not system bootability in the broader sense. Kernel bugs, runtime failures, and other post-boot problems are outside what the tool can prove, and the language now matches.", + }, + Self::KernelHookOwnedByMigrate => Status::Released { + version: VersionId::V0_4_0, section: Section::Changed, + text: "The kernel-install hook is owned by `migrate` as a migration artifact instead of being installed by the RPM. The hook now persists across atomic-rollback uninstall, so removing the tool on a migrated system does not leave future kernel installs with unbootable BLS entries. Closes #17.", + }, + + // v0.4.0 Removed + Self::Libdnf5ActionsPluginRemoved => Status::Released { + version: VersionId::V0_4_0, section: Section::Removed, + text: "The libdnf5 actions plugin (`/etc/dnf/libdnf5-plugins/actions.d/atomic-rollback.actions`) is no longer shipped. The RPM C plugin added in 0.3.7 covers every RPM-based frontend (dnf, rpm, PackageKit); keeping both caused duplicate snapshots on dnf5 transactions. Closes #16.", + }, + + // v0.4.0 Fixed + Self::LegacyRootPreUpdateRenamed => Status::Released { + version: VersionId::V0_4_0, section: Section::Fixed, + text: "Systems upgrading from any 0.3.x release have their legacy `root.pre-update` snapshot renamed to its creation timestamp (in the same `%Y-%m-%d_%H-%M-%S` format as new automatic snapshots) on upgrade. The renamed snapshot joins the rolling history and becomes eligible for retention; before this release it did not match the auto-name format and was treated as a user-named snapshot, persisting indefinitely on upgraded systems. The btrfs subvolume ID is preserved across the rename, so rollback targets that referenced the numeric ID are unaffected.", + }, + } + } +} + +// --- Generator --- + +pub fn generate() -> String { + let mut out = String::new(); + out.push_str("# Changelog\n\n"); + out.push_str("All notable changes to atomic-rollback are documented here.\n\n"); + + // Q13: Unreleased section always emitted. + out.push_str("## [Unreleased]\n\n"); + emit_sections_for(&mut out, None); + + // Releases, newest first (largest VersionId index in ALL). + for version in VersionId::ALL.iter().rev() { + out.push_str(&format!("## [{}] - {}\n\n", version.semver(), version.date())); + emit_sections_for(&mut out, Some(*version)); + } + + out +} + +fn emit_sections_for(out: &mut String, version: Option) { + for section in Section::CANONICAL_ORDER { + let entries: Vec<&'static str> = Fragment::ALL.iter() + .filter_map(|f| match (f.status(), version) { + (Status::Released { version: v, section: s, text }, Some(target)) if v == target && s == *section => Some(text), + (Status::Unreleased { section: s, text }, None) if s == *section => Some(text), + _ => None, + }) + .collect(); + + if entries.is_empty() { + continue; // D11: omit empty subsections + } + + out.push_str(&format!("### {}\n\n", section.heading())); + for text in entries { + out.push_str(&format!("- {}\n", text)); + } + out.push('\n'); + } +} diff --git a/crates/changelog/Cargo.toml b/crates/changelog/Cargo.toml new file mode 100644 index 0000000..0a514ce --- /dev/null +++ b/crates/changelog/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "changelog" +version = "0.1.0" +edition = "2024" +publish = false +description = "Generator binary and drift check for atomic-rollback's CHANGELOG.md" + +[[bin]] +name = "changelog" +path = "src/bin/changelog.rs" + +[dependencies] +changelog-core = { path = "../changelog-core" } + +[build-dependencies] +changelog-core = { path = "../changelog-core" } diff --git a/crates/changelog/build.rs b/crates/changelog/build.rs new file mode 100644 index 0000000..b04668d --- /dev/null +++ b/crates/changelog/build.rs @@ -0,0 +1,27 @@ +use std::fs; +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=../changelog-core/src/lib.rs"); + println!("cargo:rerun-if-changed=../../CHANGELOG.md"); + + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let changelog_path = PathBuf::from(&manifest_dir).join("../../CHANGELOG.md"); + + let committed = fs::read_to_string(&changelog_path) + .unwrap_or_else(|e| panic!("cannot read {}: {}", changelog_path.display(), e)); + + let generated = changelog_core::generate(); + + if committed != generated { + panic!( + "\n\ + CHANGELOG.md is out of sync with crates/changelog-core/src/lib.rs.\n\ + \n\ + Regenerate with:\n\ + \n cargo run -p changelog > CHANGELOG.md\n\ + \n\ + Then commit the updated CHANGELOG.md alongside your source changes.\n" + ); + } +} diff --git a/crates/changelog/src/bin/changelog.rs b/crates/changelog/src/bin/changelog.rs new file mode 100644 index 0000000..00beef1 --- /dev/null +++ b/crates/changelog/src/bin/changelog.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", changelog_core::generate()); +} From 7debefbab426b4681f23373bcaa2b927d3fe7b5d Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Tue, 14 Apr 2026 17:51:48 -0700 Subject: [PATCH 3/4] ci: add fragment-required check via structural paths-filter PR-time gate enforcing that any modification to crates/atomic-rollback/src/** also modifies crates/changelog-core/src/lib.rs. No commit-subject parsing, no self-reporting, no labels: the gate reads the diff directly and the diff cannot lie about what files changed. By the axiom (invalid states unrepresentable applied to git/PR domain): "PR modifies user-facing source without fragment" is the invalid state. Commit-type parsing allows "lied in subject" as a representable bypass; structural proxy eliminates that bypass entirely. Failure mode coverage: - New feat or fix touching user-facing code: gate fires; fragment required - Pure refactor in user-facing code: gate fires; Status::InternalOnly available as the explicit acknowledgment escape hatch - Doc-only or workflow-only change: gate does not fire; no fragment required (those changes do not modify crates/atomic-rollback/src/**) Error message includes copy-pasteable example for both Unreleased and InternalOnly variants, plus regen instructions and pointer to docs/standards/changelog-fragments.md (added in next commit). --- .github/workflows/pr-fragment.yml | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/pr-fragment.yml diff --git a/.github/workflows/pr-fragment.yml b/.github/workflows/pr-fragment.yml new file mode 100644 index 0000000..35f6ffa --- /dev/null +++ b/.github/workflows/pr-fragment.yml @@ -0,0 +1,72 @@ +name: PR Fragment + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + +jobs: + fragment-required: + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: dorny/paths-filter@v3 + id: paths + with: + filters: | + user_facing: + - 'crates/atomic-rollback/src/**' + fragment: + - 'crates/changelog-core/src/lib.rs' + + - name: Enforce fragment requirement + if: ${{ steps.paths.outputs.user_facing == 'true' && steps.paths.outputs.fragment == 'false' }} + run: | + cat <<'MSG' + ::error::This PR modifies crates/atomic-rollback/src/** but does not modify crates/changelog-core/src/lib.rs. Every change to user-facing source requires a Fragment. + + To fix: + + 1. Add a variant to the fragments! macro invocation in crates/changelog-core/src/lib.rs + 2. Add a match arm in Fragment::status() returning one of: + + - Status::Unreleased { section: ..., text: "..." } for changes that will appear in CHANGELOG.md + - Status::InternalOnly { description: "..." } for changes with no user-perceivable effect (pure private refactor, no perf change, no behavior change). Use sparingly and only after checking there is truly no user impact. + + Example (user-facing change): + + fragments! { /* existing variants, */ DescribeYourChangeHere } + + impl Fragment { + pub const fn status(self) -> Status { + match self { + /* existing arms, */ + Self::DescribeYourChangeHere => Status::Unreleased { + section: Section::Added, + text: "What users experience, in one sentence.", + }, + } + } + } + + Example (internal-only change): + + Self::RenamedPrivateHelperFoo => Status::InternalOnly { + description: "Renamed private helper Foo to Bar for consistency.", + }, + + After editing crates/changelog-core/src/lib.rs, regenerate CHANGELOG.md: + + cargo run -p changelog > CHANGELOG.md + + Then commit both files together. If you do not, build.rs panics on the next cargo build. + + If you are stuck, comment on this PR and a maintainer will add the Fragment for you. + + See docs/standards/changelog-fragments.md for the full contract, including when InternalOnly is appropriate. + MSG + exit 1 From 2adf03ecb3c6579b30c5e6b7fd3c440fea81ea39 Mon Sep 17 00:00:00 2001 From: Harnoor Lal Date: Tue, 14 Apr 2026 17:53:58 -0700 Subject: [PATCH 4/4] docs: add CONTRIBUTING.md and changelog-fragments standard CONTRIBUTING.md is the entry-point doc covering build, test, commits, PRs, and a brief on the changelog-fragments contract. Links out to the standards directory for depth. docs/standards/changelog-fragments.md is the full contract referenced by the pr-fragment workflow's error message. Documents: - when a fragment is required (structural proxy, no self-reporting) - how to add one (variant + match arm + regenerate CHANGELOG.md) - when Status::InternalOnly is appropriate (rare; explicit list of what does and does not qualify) - text conventions (matches existing CHANGELOG style) - variant naming (change-description PascalCase, not PR-prefixed) - how releases consume fragments at bump time The layered structure (CONTRIBUTING entry + docs/standards/ depth) gives the CI error message a stable URL anchor to link to and gives future standards (testing, code style, releases, etc.) a natural home without bloating CONTRIBUTING.md. --- CONTRIBUTING.md | 65 ++++++++++++ docs/standards/changelog-fragments.md | 140 ++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 docs/standards/changelog-fragments.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6551e15 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to atomic-rollback + +Thanks for considering a contribution. This guide covers the essentials. + +## Prerequisites + +- Rust toolchain (`cargo`, `rustc`) +- Git +- For VM integration testing: [lima](https://lima-vm.io/) with a Fedora 43 template + +## Build + +```sh +cargo build --release +``` + +The workspace builds all member crates. The `atomic-rollback` binary lands at `target/release/atomic-rollback`. + +## Test + +```sh +cargo test --release +``` + +Tests run on Linux and macOS in CI. Production code uses Unix APIs only, so tests are skipped on Windows. + +For VM integration testing of `atomic-rollback check` on a real Fedora environment, see `.github/workflows/ci.yml` (x86_64 lima job) for the canonical recipe. + +## Commit format + +[Conventional Commits](https://www.conventionalcommits.org/): + +``` +type(scope): lowercase imperative description +``` + +Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `style`, `perf`, `ci` + +Examples: + +``` +feat(snapshot): auto-named rolling snapshots +fix(hook): transfer kernel-install hook ownership to migrate +ci: add cargo test job on ubuntu + macos matrix +``` + +## Pull requests + +- All changes go through PRs; no exceptions +- Branch protection requires all CI checks to pass before merge +- Rebase merge only (linear history) + +PR test plans are mandatory. Unchecked task-list items block merge. Complete tests before merging; removing items is not a valid escape. + +## CHANGELOG fragments + +Every PR that modifies `crates/atomic-rollback/src/**` requires a Fragment in `crates/changelog-core/src/lib.rs`. Structural, not commit-type-based — the gate reads the diff directly so the requirement cannot be bypassed by relabeling commits. + +See [docs/standards/changelog-fragments.md](docs/standards/changelog-fragments.md) for the full contract, including when `Status::InternalOnly` is appropriate. + +## Project standards + +Detailed standards: + +- [CHANGELOG fragments](docs/standards/changelog-fragments.md) — how to add fragments, when they're required, when InternalOnly is appropriate, how releases consume them diff --git a/docs/standards/changelog-fragments.md b/docs/standards/changelog-fragments.md new file mode 100644 index 0000000..8922576 --- /dev/null +++ b/docs/standards/changelog-fragments.md @@ -0,0 +1,140 @@ +# CHANGELOG Fragments + +## What + +`CHANGELOG.md` is a generated artifact. The source of truth is the `Fragment` enum in `crates/changelog-core/src/lib.rs`. Each variant is one atomic CHANGELOG bullet. + +`cargo build` fails if `CHANGELOG.md` is out of sync with the Rust source (verified by `crates/changelog/build.rs`). + +## When a fragment is required + +Every PR that modifies `crates/atomic-rollback/src/**` must add at least one Fragment. This is a structural proxy for "user-facing change," enforced via `.github/workflows/pr-fragment.yml`. No commit-subject parsing, no self-reporting — the gate reads the diff directly. + +Changes that do NOT touch `crates/atomic-rollback/src/**` (CI workflow edits, doc-only changes, workspace restructuring that preserves source, test-only changes elsewhere) do not require fragments. + +## How to add a fragment + +Two edits in `crates/changelog-core/src/lib.rs`: + +1. Add a variant to the `fragments!` macro invocation: + +```rust +fragments! { + // ... existing variants ... + DescribeYourChangeInPascalCase, +} +``` + +2. Add a match arm in `Fragment::status()` returning one of three variants: + +For user-facing changes (the common case): + +```rust +Self::DescribeYourChangeInPascalCase => Status::Unreleased { + section: Section::Added, // or Changed, Fixed, Removed, Deprecated, Security + text: "What users experience, in one sentence.", +}, +``` + +For changes with no user-perceivable effect (rare): + +```rust +Self::RenamedPrivateHelperFoo => Status::InternalOnly { + description: "Renamed private helper Foo to Bar for consistency.", +}, +``` + +The compiler enforces exhaustiveness on `status()` — a variant without a match arm produces a compile error. + +After editing, regenerate `CHANGELOG.md`: + +```sh +cargo run -p changelog > CHANGELOG.md +``` + +Commit both files together. If you don't, the next `cargo build` panics with a drift error. + +## When InternalOnly is appropriate + +`Status::InternalOnly` is for changes that genuinely have NO user-perceivable effect: + +- Pure private renames (the symbol is private, no API change) +- Comment changes +- Test-only changes inside user-facing source (unusual) +- Whitespace/formatting + +It is NOT appropriate for: + +- Performance changes (user notices wall-clock) +- Behavior changes in private helpers reachable from pub API (user notices output) +- Error message tweaks (user notices) +- Dependency updates with any behavioral change +- Anything with an observable effect on the built binary + +If you're unsure whether a change is truly internal-only, it probably isn't. Default to `Status::Unreleased` with a user-facing description. `InternalOnly` exists as an escape hatch, not a default. + +Reviewers should challenge `InternalOnly` usage. The description field is for audit — "here's why this was user-invisible" — reviewers check whether the reasoning holds. + +## Text conventions + +Fragment text follows the existing CHANGELOG style: + +- User-perspective synthesis, not commit-subject paraphrase +- Use backticks for commands, flags, paths +- Include root cause when it illuminates scope +- Append `Closes #N` at the end for closed issues +- One sentence when possible; two when required for clarity + +## Section types + +Keep a Changelog v1.1.0 defines six sections: + +- `Added` — new features +- `Changed` — changes in existing functionality +- `Deprecated` — soon-to-be removed features +- `Removed` — now-removed features +- `Fixed` — bug fixes +- `Security` — security vulnerabilities + +Pick the section that best describes the change from the user's perspective. + +## Variant naming + +PascalCase describing the change, NOT the PR or commit. Examples: + +- `RollingTimestampNames` (not `Pr21Feature`) +- `BootChainTerminology` (not `Pr25Refactor`) +- `LegacyRootPreUpdateMigration` + +Names describe what the bullet IS, not where it came from. Provenance (which PR, which commit) lives in git history. + +## How releases consume fragments + +At release time, the bump commit transitions unreleased fragments to released: + +```rust +Self::DescribeYourChangeInPascalCase => Status::Released { + version: VersionId::V0_5_0, + section: Section::Added, + text: "What users experience, in one sentence.", +}, +``` + +The bump commit also adds a new `VersionId` variant if this is a new version (with semver and date in the corresponding match arms). + +After transition, regenerate `CHANGELOG.md` and commit. + +## Verification locally + +Before pushing, verify locally: + +```sh +cargo build --release # fails if CHANGELOG.md is out of sync +cargo run -p changelog > CHANGELOG.md # regenerate if needed +``` + +Then commit the regenerated `CHANGELOG.md` alongside your source changes. + +## If you're stuck + +Comment on your PR asking for help. A maintainer can add the Fragment for you. Rigor matters more than the contributor doing every step alone.