Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .claude/skills/bump-normalization-epoch/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 96 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -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` — `<Version>OLD</Version>` → `<Version>X.Y.Z</Version>`.
- `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 <noreply@anthropic.com>
```
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 <n> --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`).
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 2 additions & 1 deletion src/ConfluencePageExporter/Services/NormalizationContract.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// "КРИТИЧНО: контракт нормализации".
/// </summary>
public static class NormalizationContract
{
Expand Down
3 changes: 2 additions & 1 deletion src/ConfluencePageExporter/Services/XmlContentNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ namespace ConfluencePageExporter.Services;
/// behaviour (whitespace / attribute / entity handling), you MUST bump
/// <see cref="NormalizationContract.CurrentEpoch"/> 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.
/// </remarks>
public sealed class XmlContentNormalizer : IContentNormalizer
{
Expand Down