diff --git a/AGENTS.md b/AGENTS.md
index d4963d6..c34abb7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,3 +34,21 @@ dotnet bin/Release/net10.0/webstatic.dll serve --port 8000 # lokale V
- **Kein Commit ohne expliziten Wunsch.** Bei Release: signierte Commits.
- `dist/`, `bin/`, `obj/`, `.translation-drafts/` sind gitignored.
- Machine-Translation liefert nur **Entwürfe** (`.translation-drafts/`), nie Auto-Überschreibung.
+
+## Versions-Definition (Definition of Done für Releases)
+Die Versionsnummer wird an **drei Stellen synchron** gehalten — ein Release-Commit
+muss alle drei aktualisieren, andernfalls warnt `webstatic --version` vor einem
+Mismatch:
+
+1. **`README.md`** — Block-Quote `> **Version: X.Y.Z**` direkt unter dem Titel
+ (kanonisch; per `git show origin/main:README.md` remote sichtbar)
+2. **`GCBoK.WebStaticBuilder.csproj`** — `X.Y.Z` (Assembly)
+3. **`webstatic --version`** — gibt Assembly-Version aus, vergleicht mit README
+ und remote Commit-Hash (`git ls-remote origin HEAD`)
+
+Vor einem Release sind lokal und remote die Version feststellbar, ohne die CLI
+aufzurufen:
+```bash
+git show HEAD:README.md | head -3 # lokal
+git show origin/main:README.md | head -3 # remote
+```
diff --git a/README.md b/README.md
index c6c98be..d59d763 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# GCBoK.WebStaticBuilder
+> **Version: 1.0.0** — Diese Versionsnummer steht in `README.md` (kanonisch),
+> in `GCBoK.WebStaticBuilder.csproj` (``) und wird von `webstatic --version`
+> ausgegeben. Alle drei Quellen müssen synchron gehalten werden.
+
Eigenständiger, quelloffener **Static-Site-Generator** der GitCover Commons für
mehrsprachige, SEO-optimierte, compliance-freundliche Websites. Ersetzt den
früheren C#-Builderskript der GCBoK-Website durch ein generisches, für KMU
@@ -69,8 +73,46 @@ Siehe `webstatic.example/webstatic.json` und `docs/getting-started.md`.
## Version & Update
+Die Versionsnummer wird an drei Stellen synchron gehalten (Definition of Done
+für jeden Release-Commit):
+
+1. **`README.md`** — Block-Quote direkt unter dem Titel (kanonisch, per Remote
+ via `git show origin/main:README.md` sichtbar)
+2. **`GCBoK.WebStaticBuilder.csproj`** — `1.0.0` (Assembly)
+3. **`webstatic --version`** — gibt die Assembly-Version aus
+
+So kann vor einem Update sowohl lokal (im Installations-Repo) als auch remote
+(im Upstream-Repo) die Version festgestellt werden, ohne die CLI aufzurufen:
+
+```bash
+# Lokal installierte Version (Clone-Installation)
+git -C /opt/GitCover/webstatic show HEAD:README.md | head -3
+
+# Remote verfügbare Version (vor Pull)
+git -C /opt/GitCover/webstatic show origin/main:README.md | head -3
+```
+
+### Update-Check via CLI
+
+`webstatic --version` führt automatisch einen Remote-Check durch
+(`git ls-remote origin HEAD`) und vergleicht lokalen mit remote Commit-Hash:
+
+```bash
+webstatic --version
+# GCBoK.WebStaticBuilder 1.0.0.0
+# commit: 6ca8167 (local)
+# remote: ff634a3
+#
+# UPDATE AVAILABLE
+# Run: webstatic update
+```
+
+Bei Copy-Installationen (kein `.git`) entfällt der Remote-Check; das Tool weist
+auf `./install.sh` als Update-Weg hin.
+
+### Update durchführen
+
```bash
-webstatic --version # Assembly-Version + Git-Commit des Installations-Repos
webstatic update # Selbstaktualisierung (Clone-Installation): lokale
# Änderungen werden gestasht, git pull, gemerged, neu gebaut
```
diff --git a/src/Program.cs b/src/Program.cs
index aaf659e..353bfe5 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -24,10 +24,7 @@ if (argsList.Contains("--help") || argsList.Contains("-h") || argsList.Contains(
}
if (argsList.Contains("--version"))
{
- var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
- Console.WriteLine($"GCBoK.WebStaticBuilder {v?.ToString() ?? "0.0.0"}");
- var hash = GitShortHash(AppContext.BaseDirectory);
- if (hash != null) Console.WriteLine($"commit: {hash}");
+ PrintVersion(verbose);
return;
}
@@ -411,3 +408,115 @@ static void ShowStatus(string rootDir)
var markdown = File.ReadAllText(readme);
MarkdownConsoleRenderer.Render(markdown, Console.Out);
}
+
+static void PrintVersion(bool verbose)
+{
+ var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
+ var asmVersion = v?.ToString() ?? "0.0.0";
+ Console.WriteLine($"GCBoK.WebStaticBuilder {asmVersion}");
+
+ // README.md version check — canonical source is the block-quote
+ // "> **Version: X.Y.Z**" directly under the title.
+ var readmeVersion = ExtractReadmeVersion(AppContext.BaseDirectory);
+ if (readmeVersion != null)
+ {
+ // Assembly versions are 4-part (1.0.0.0); README uses 3-part (1.0.0).
+ // Compare the first 3 components to avoid trailing-zero noise.
+ var asmShort = v?.ToString(3) ?? asmVersion;
+ if (!string.Equals(asmShort, readmeVersion, StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine($"WARNING: version mismatch — README.md says {readmeVersion}, assembly says {asmShort}");
+ Console.WriteLine(" Update README.md and/or in the .csproj to keep them in sync.");
+ }
+ }
+
+ var repo = FindRepoRoot(AppContext.BaseDirectory);
+ if (repo == null)
+ {
+ Console.WriteLine("install: copy (no git repo — version check skipped)");
+ Console.WriteLine("update: run ./install.sh again to update");
+ return;
+ }
+
+ var localHash = GitShortHash(AppContext.BaseDirectory);
+ if (localHash != null) Console.WriteLine($"commit: {localHash} (local)");
+
+ // Remote check (best-effort, non-blocking on failure)
+ var remoteShort = CaptureRemoteHeadShort(repo);
+ if (remoteShort == null)
+ {
+ if (verbose) Console.WriteLine("(remote check failed — no network or no upstream)");
+ return;
+ }
+
+ Console.WriteLine($"remote: {remoteShort}");
+
+ if (localHash != null && !string.Equals(localHash, remoteShort, StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine();
+ Console.WriteLine(" UPDATE AVAILABLE");
+ Console.WriteLine(" Run: webstatic update");
+ }
+ else if (localHash != null)
+ {
+ Console.WriteLine(" up to date");
+ }
+}
+
+static string? ExtractReadmeVersion(string baseDir)
+{
+ // Search upwards for README.md (it lives at the repo root, not in bin/).
+ var dir = new DirectoryInfo(Path.GetFullPath(baseDir));
+ string? readmePath = null;
+ while (dir != null)
+ {
+ var candidate = Path.Combine(dir.FullName, "README.md");
+ if (File.Exists(candidate)) { readmePath = candidate; break; }
+ dir = dir.Parent;
+ }
+ if (readmePath == null) return null;
+
+ try
+ {
+ var lines = File.ReadAllLines(readmePath);
+ // Look for "> **Version: X.Y.Z**" within the first 10 lines
+ foreach (var line in lines.Take(10))
+ {
+ var m = System.Text.RegularExpressions.Regex.Match(
+ line, @"\*\*Version:\s*(\d+\.\d+(?:\.\d+)?)\*\*",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ if (m.Success) return m.Groups[1].Value;
+ }
+ }
+ catch { /* best-effort */ }
+ return null;
+}
+
+static string? CaptureRemoteHeadShort(string repoDir)
+{
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "git",
+ Arguments = "ls-remote origin HEAD",
+ WorkingDirectory = repoDir,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ using var p = Process.Start(psi);
+ if (p == null) return null;
+ var outp = p.StandardOutput.ReadToEnd().Trim();
+ p.WaitForExit(5000); // 5s timeout
+ if (p.ExitCode != 0 || string.IsNullOrEmpty(outp)) return null;
+ // Format: "\tHEAD"
+ var parts = outp.Split('\t', ' ');
+ return parts.Length > 0 && parts[0].Length >= 7 ? parts[0][..7] : null;
+ }
+ catch
+ {
+ return null;
+ }
+}