diff --git a/.claude/skills/bump-normalization-epoch/SKILL.md b/.claude/skills/bump-normalization-epoch/SKILL.md new file mode 100644 index 0000000..3c5a027 --- /dev/null +++ b/.claude/skills/bump-normalization-epoch/SKILL.md @@ -0,0 +1,91 @@ +--- +name: bump-normalization-epoch +description: >- + Update the content-normalization contract after changing how Confluence + storage-format is canonicalized or hashed, so persisted marker hashes don't + silently mismatch. Use whenever you edit XmlContentNormalizer, + RegexContentNormalizer, the HtmlEntities table, attribute-sort / whitespace / + volatile-artifact-stripping rules, the SHA-256 hashing, or switch the active + IContentNormalizer — or the moment ContentHasherTests.Golden_* fails. Keeps + NormalizationContract.CurrentEpoch and GoldenNormalized in lockstep. Trigger on + "нормализатор", "normalization changed", "golden vector failing", "bump epoch", + or any diff touching Services/*Normalizer*.cs or the hash recipe. +--- + +# Bump the normalization contract (epoch + golden) + +## Why this exists + +The normalized storage format of each page is hashed (SHA-256) and stored in its +`.id*` marker (`h` = hash, `ne` = epoch). On the next sync, `ContentHasher` +recomputes the hash and compares — that's how a *real* local edit is told apart +from an mtime-only touch (editor re-save, pretty-print, copy, `touch`, VCS +checkout), eliminating false "changed locally" conflicts. + +That only holds while the **recipe is stable**. If you change normalization so +its output differs, every marker written under the old recipe now hashes +differently → the tool either cries wolf on untouched pages or misses genuine +edits. The guard is a two-part contract in +[`NormalizationContract`](../../../src/ConfluencePageExporter/Services/NormalizationContract.cs): + +- **`CurrentEpoch`** — stamped into new markers. A marker whose epoch ≠ + `CurrentEpoch` is distrusted and the code safely falls back to mtime comparison + (see `ContentHasher.HasChanged`). Bumping the epoch is what actually protects + old markers — it makes them opt out instead of silently mismatching. +- **`GoldenNormalized`** — the expected normalization of `GoldenInput`. A runtime + self-check + the unit test + [`ContentHasherTests.Golden_DefaultNormalizerReproducesContract`](../../../tests/ConfluencePageExporter.Tests/Services/ContentHasherTests.cs) + fail the build the instant normalized output drifts. That red test is the + intended tripwire that sends you here. + +## When to run + +Any change that can alter normalized output: `XmlContentNormalizer`, +`RegexContentNormalizer`, the `HtmlEntities` table, attribute-sort / whitespace / +volatile-artifact-stripping rules, the hash algorithm, or switching the active +`IContentNormalizer`. + +## Steps + +1. **Make your normalizer change first.** Build. +2. **Run the golden tripwire** — it should now fail: + ``` + dotnet build tests/ConfluencePageExporter.Tests/ConfluencePageExporter.Tests.csproj + tests/ConfluencePageExporter.Tests/bin/Debug/net10.0/ConfluencePageExporter.Tests.exe --filter-method "*Golden_DefaultNormalizerReproducesContract*" + ``` + Shouldly prints the **actual** normalized string next to the expected one. +3. **Refresh `GoldenNormalized`** to the new normalized output. Two ways: + - Derive it by hand — apply your new rule to the existing `GoldenNormalized` + (usually the cleaner path, since the change is known and local); or + - Copy the **actual** string from the failing assertion. + ⚠️ `GoldenNormalized` contains a real **U+00A0 non-breaking space** (decoded + from ` ` in `GoldenInput`), *not* an ASCII space — preserve it exactly. + Console copies of nbsp are error-prone, so prefer deriving by hand and eyeball + that single character. Normally you do **not** touch `GoldenInput` — it's the + fixed canary; only `GoldenNormalized` changes. +4. **Increment `CurrentEpoch` by 1.** +5. **Add a history line** to the `CurrentEpoch` doc-comment, matching the existing + style (`History: 1 = initial; 2 = strip ac:macro-id …; 3 = …`), describing what + the new epoch changes. +6. **Re-run `ContentHasherTests` (and the full suite).** Golden passes; + `Xml_RegimeTrusted_AndEpochCurrent` confirms the regime is trusted at the new + epoch. +7. **CHANGELOG if user-visible.** An epoch bump that fixes phantom "local changed" + diffs *is* user-facing (e.g. epoch 2 shipped in v2.12.1 to fix Confluence + canonicalization noise) — add a `### Исправлено`/`### Изменено` entry to + `[Unreleased]` in **both** `CHANGELOG.md` and `CHANGELOG.en.md`. A pure internal + refactor with byte-identical output shouldn't have needed an epoch bump at all. + +## Gotchas + +- Bump **both** epoch and golden. The test enforces the golden; only *you* can + enforce the epoch bump — and the epoch is the half that actually shields + existing markers. +- Old markers aren't rewritten in place; they self-heal (distrusted → mtime + fallback) until the next sync re-stamps them at the new epoch. That's expected. +- If you *swap* the active normalizer (e.g. to `RegexContentNormalizer`) rather + than edit one, the same contract applies — its output must reproduce + `GoldenNormalized` or the regime goes untrusted. + +See also `.claude/rules/dotnet-maintenance.md` ("КРИТИЧНО: контракт +нормализации") and the phantom-diff history behind epochs 2–3. diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..74fcf2d --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,96 @@ +--- +name: release +description: >- + Cut and publish a new release of Confluence Page Exporter — bump the version + everywhere, roll the CHANGELOG, tag, and let CI build and publish the GitHub + Release. Use whenever the user wants to ship/cut/publish a release or bump the + version: "зарелизить", "выпустить версию", "сделать релиз 2.19.0", "release + this", "ship a new version", or right after a batch of user-facing changes has + landed on master and should go out. Handles the full ritual so nothing (version + strings, bilingual CHANGELOG, tag) drifts out of sync. +--- + +# Release Confluence Page Exporter + +## The model (read first) + +The **git tag `vX.Y.Z` is the source of truth for the released build** — +`.github/workflows/release.yml` triggers on `push` of a `v*` tag, extracts the +version from the tag name, and publishes self-contained single-file binaries for +`win-x64`, `linux-x64`, `osx-arm64` plus a GitHub Release with auto-generated +notes. Nothing you build locally is published; the tag drives everything. + +Even so, keep every in-repo version string in sync with the tag so the source is +honest (a README that says `v2.16.0` for a `v2.18.0` tool misleads users). This +skill bumps all five places and, if they had drifted, this run silently corrects +them. + +**Pushing the tag is irreversible and outward-facing** — it publishes a public +GitHub Release. Do the commit/PR/merge first; tag only at the very end, after +confirming the version with the user. + +## Preconditions + +1. On `master`, clean tree, up to date with origin. +2. Tests green. Run them (xunit.v3 on MTP — `dotnet test` prints nothing useful): + ``` + dotnet build tests/ConfluencePageExporter.Tests/ConfluencePageExporter.Tests.csproj + tests/ConfluencePageExporter.Tests/bin/Debug/net10.0/ConfluencePageExporter.Tests.exe + ``` +3. `CHANGELOG.md` has a non-empty `## [Unreleased]` section. If it's empty or + missing, there is nothing user-facing to release — stop and ask the user. + +## Choose the version + +Infer the next semver bump from what's under `[Unreleased]`, then **confirm with +the user before tagging**: + +- new features / commands / flags (`### Добавлено`) → **minor** (`2.18.0 → 2.19.0`) +- only fixes (`### Исправлено`) → **patch** (`2.18.0 → 2.18.1`) +- breaking changes → **major** + +History is almost all minor bumps with the occasional patch (e.g. `2.13.1`). + +## Steps + +Let `X.Y.Z` be the new version and `DATE` today's date as `YYYY-MM-DD`. + +1. **Branch:** `git checkout -b chore/release-X.Y.Z` +2. **Edit five files** (all in one commit): + - `CHANGELOG.md` — rename the heading `## [Unreleased]` → `## [X.Y.Z] — DATE`. + - `CHANGELOG.en.md` — same rename, mirroring the existing entry format exactly + (same `## [X.Y.Z] — DATE` shape). Per the bilingual rule both CHANGELOGs move + together. + - `Directory.Build.props` — `OLD` → `X.Y.Z`. + - `README.md` — first line `# Confluence Page Sync vOLD` → `vX.Y.Z`. + - `README.en.md` — first line, same bump. +3. **Commit** (message in Russian, per project convention): + ``` + chore(release): версия X.Y.Z + + <2–4 line summary of the release highlights, taken from the CHANGELOG entry> + + Co-Authored-By: Claude Opus 4.8 + ``` +4. **PR + self-merge** (the user's standing workflow — don't wait for review): + `git push -u origin chore/release-X.Y.Z`, `gh pr create --base master ...` + (Russian title/body), then `gh pr merge --merge --delete-branch`. +5. **Sync master:** `git checkout master && git pull --ff-only`. +6. **Tag on the merge commit and push — this publishes the release:** + ``` + git tag vX.Y.Z + git push origin vX.Y.Z + ``` +7. **Verify CI:** `gh run watch` (or `gh run list --workflow=Release`) until green, + then `gh release view vX.Y.Z` to confirm the three archives attached. + +## Notes / gotchas + +- **Date must be real** — use today's actual date, never a placeholder. +- **osx-arm64 ships uncompressed** on purpose (`EnableCompressionInSingleFile=false` + in `release.yml`) — a single-file/ARM extraction bug. Don't "fix" it. +- No CHANGELOG entry is needed *for the release commit itself* — rolling the + section IS the change. +- If a release build fails after the tag is pushed, delete the tag + (`git push --delete origin vX.Y.Z`), fix, and re-tag — the Release is only + created after all three builds succeed (`fail_on_unmatched_files: true`). diff --git a/.gitignore b/.gitignore index ad86652..634a97e 100644 --- a/.gitignore +++ b/.gitignore @@ -107,8 +107,9 @@ _ReSharper*/ # AI assistants ############################ .cursor/ -# .claude/ is local by default; the shared agent rules under .claude/rules/ are committed +# .claude/ is local by default; shared agent rules and project skills are committed .claude/* !.claude/rules/ +!.claude/skills/ # CLAUDE.md / AGENTS.md are committed project AI-agent instructions graphify-out/ diff --git a/src/ConfluencePageExporter/Services/NormalizationContract.cs b/src/ConfluencePageExporter/Services/NormalizationContract.cs index 8e2fc4e..895fe26 100644 --- a/src/ConfluencePageExporter/Services/NormalizationContract.cs +++ b/src/ConfluencePageExporter/Services/NormalizationContract.cs @@ -17,7 +17,8 @@ namespace ConfluencePageExporter.Services; /// algorithm, or switching the active IContentNormalizer. Stored hashes /// computed under the old recipe will otherwise silently mismatch. The /// golden-vector unit test fails until you update both, which is the intended -/// tripwire. See .cursor/rules/confluence-import-export.mdc §11. +/// tripwire. See .claude/rules/dotnet-maintenance.md, section +/// "КРИТИЧНО: контракт нормализации". /// public static class NormalizationContract { diff --git a/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs b/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs index 59b1e29..121e753 100644 --- a/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs +++ b/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs @@ -17,7 +17,8 @@ namespace ConfluencePageExporter.Services; /// behaviour (whitespace / attribute / entity handling), you MUST bump /// and refresh its golden value, /// or stored hashes silently mismatch. The golden-vector test fails until you do. -/// See .cursor/rules/confluence-import-export.mdc §11. +/// See .claude/rules/dotnet-maintenance.md ("КРИТИЧНО: контракт нормализации"). +/// The /bump-normalization-epoch skill walks through this update. /// public sealed class XmlContentNormalizer : IContentNormalizer {