deploy: „fertig" wird erst beim Deploy „in Produktion" (D30)

Der mitgelieferte Werkbaum-Plan behauptete [^] für Funktionen, die nur auf der
automatisch deployten Pages-Instanz lagen, nicht auf werkbaum.javagil.de
(manueller Deploy, D16). Ausgerechnet das Dokument, das den Stand beschreiben
soll, war ungenau — und „Was ist neu?" (D28) meldete Dinge als live, die es
dort nicht waren.

Die Unterscheidung gibt es längst: SPEC §4 trennt [x] fertig von [^] in
Produktion. Der Plan hat sie für sich selbst nie benutzt. Konvention ab jetzt:
beim Mergen [x], der Deploy befördert — nur er weiß, wann die Aussage wahr wird.

- scripts/promote-shipped.sh schreibt Statusboxen am Zeilenanfang von [x] auf
  [^] und hält das als eigenen Commit fest (-n zeigt nur, -y ohne Rückfrage).
  Bricht ab, wenn die Plandatei uncommittete Änderungen hat; committet nur
  diesen einen Pfad; pusht nicht.
- deploy-prod.sh ruft es als Schritt 0 auf (--no-promote schaltet es ab) und
  warnt, wenn HEAD noch nicht auf origin liegt — der Footer-Versionslink zeigt
  sonst auf einen Commit, den GitHub nicht kennt.

Warum ein Commit und kein Rewrite beim Bauen: Ein Rewrite macht GENAU EINE
Installation ehrlich; Pages untertriebe dauerhaft und die Neu-Anzeige wäre dort
für immer stumm. Der Commit wird von beiden Pipelines gesehen (Pages beim Push,
prod beim rsync), das Artefakt bleibt inhaltsgleich mit dem Repo — die
vorhandenen sed-Regeln (D16) fassen nur Pfade und Version an, Infrastruktur,
kein Rewrite dessen, was das Dokument aussagt. Außerdem Präzedenzfall D16:
VERSION per bewusstem Bump-Commit, „vollständig aus dem Repo reproduzierbar".

Einmalige Nachholung, exakt statt geschätzt: Der Footer der stabilen Instanz
verlinkt den deployten Commit (4061362); alles danach ist dort nicht drin. Es
sind GENAU ZWEI Knoten — „Optional nodes" (D29) und „Show what is new since
your last visit" (D28) —, nicht das Dutzend, das ich vorher grob geschätzt
hatte. Beide stehen jetzt auf [x] und leuchten beim nächsten Prod-Deploy als
neu auf. Eine Demotion [^]→[x] löst kein Falschleuchten aus: freshProdSet
meldet nur Knoten, die JETZT [^] sind (test-abgedeckt).

Umfang: nur example-werkbaum.werkbaum. Die übrigen Beispieldateien sind
erfunden und sagen nichts über ein Deployment aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-28 12:38:21 +02:00
co-authored by Claude Opus 4.8
parent 56c18cb8cb
commit 6836352e84
6 changed files with 214 additions and 10 deletions
+5
View File
@@ -27,3 +27,8 @@ Integrations-Backend.
- Keine neuen Laufzeit-Abhängigkeiten ohne Rückfrage (gilt in beiden Teilen). - Keine neuen Laufzeit-Abhängigkeiten ohne Rückfrage (gilt in beiden Teilen).
- Der Notationstext ist das führende Datenformat; kein Teil erfindet ein - Der Notationstext ist das führende Datenformat; kein Teil erfindet ein
eigenes Speicherformat für die Struktur. eigenes Speicherformat für die Struktur.
- Im mitgelieferten Plan `docs/examples/example-werkbaum.werkbaum` bekommt eine
fertige Funktion beim Mergen **`[x]`**, nicht `[^]`. Auf „in Produktion"
befördert erst der Deploy der stabilen Instanz
(`scripts/promote-shipped.sh`, von `deploy-prod.sh` aufgerufen) — nur er macht
die Aussage wahr. Siehe D30; SPEC §4 trennt beide Zustände ohnehin.
+17
View File
@@ -197,6 +197,23 @@ repository, while the **version number** links to that exact commit
"GitHub Actions". The repo must be **public** for this (GitHub Pages via Actions "GitHub Actions". The repo must be **public** for this (GitHub Pages via Actions
is only available for private repos on a paid plan). is only available for private repos on a paid plan).
### Stable instance
`scripts/deploy-prod.sh` mirrors a badge-free production build to a server over
SSH (target in `.env`, template `.env.example`). Unlike Pages this is a
**deliberate** step, so it is also the moment a feature actually goes live.
The script therefore starts by running `scripts/promote-shipped.sh`, which turns
`[x]` (done) into `[^]` (in production) in `docs/examples/example-werkbaum.werkbaum`
— the shipped plan describing Werkbaum itself — and records that as its own
commit. The convention is: mark a finished feature `[x]` when it is merged and
let the deploy promote it. That keeps the plan honest on both instances and
keeps the "what's new" highlighting meaningful. Skip it with `--no-promote`; see
[docs/DECISIONS.md](docs/DECISIONS.md) D30.
The commit is **not** pushed automatically — run `git push` afterwards, or the
footer version link points at a commit GitHub does not know yet.
## License ## License
MIT — see [LICENSE](LICENSE). © 2026 Michael Hönnig. The bundled IBM Plex fonts MIT — see [LICENSE](LICENSE). © 2026 Michael Hönnig. The bundled IBM Plex fonts
+59
View File
@@ -904,3 +904,62 @@ wie am Bildschirm.
Geprüft wurde im Browser — auch der Moduswechsel hin und zurück, mehrfach: Die Geprüft wurde im Browser — auch der Moduswechsel hin und zurück, mehrfach: Die
Gruppe entsteht und löst sich rückstandsfrei auf, Knotenzahl und Dokumentordnung Gruppe entsteht und löst sich rückstandsfrei auf, Knotenzahl und Dokumentordnung
bleiben in allen drei Modi gleich. bleiben in allen drei Modi gleich.
## D30 — `[x]` → `[^]` per Beförderungs-Commit vor dem Deploy
Der mitgelieferte Werkbaum-Plan (D27) behauptete `[^]` („in Produktion") für
Funktionen, die nur auf der automatisch deployten Pages-Instanz lagen, nicht auf
der stabilen Installation `werkbaum.javagil.de` (manueller Deploy, D16). Damit
war ausgerechnet das Dokument ungenau, das den Stand beschreiben soll — und die
„Was ist neu?"-Anzeige (D28) meldete Dinge als live, die es dort nicht waren.
**Die Unterscheidung gibt es längst.** SPEC §4 trennt `[x]` fertig
(abgeschlossen) von `[^]` in Produktion (deployed/live). Der Plan hat diese
Trennung ausgerechnet für sich selbst nie benutzt und sprang direkt auf `[^]`.
Die Konvention lautet daher ab jetzt: **beim Mergen `[x]`**, und der Deploy
befördert. Nur der Deploy weiß, wann die Aussage wahr wird.
**Umgesetzt als Commit, nicht als Rewrite beim Bauen.** `scripts/promote-shipped.sh`
schreibt die Statusboxen am Zeilenanfang von `[x]` auf `[^]` und hält das in
einem eigenen Commit fest; `scripts/deploy-prod.sh` ruft es als Schritt 0 auf
(abschaltbar mit `--no-promote`). Gründe:
- Ein Rewrite beim Bauen macht **genau eine** Installation ehrlich. Pages
untertriebe dauerhaft, und die Neu-Anzeige wäre dort **für immer stumm**
das Feature ließe sich nur noch künstlich prüfen. Der Commit dagegen wird von
**beiden** Pipelines gesehen: Pages baut ihn beim Push, prod beim nächsten
rsync.
- Das Deployment-Artefakt bleibt **inhaltsgleich mit dem Repo**. Die
vorhandenen `sed`-Regeln (D16) fassen nur Pfade und die Versionsnummer an —
Infrastruktur. Ein Status-Rewrite wäre der erste Schritt, der ändert, was das
Dokument *aussagt*; die per `?sourceUrl=` geladene Rohdatei wäre dann eine
dritte, wieder abweichende Fassung.
- Es folgt dem Präzedenzfall aus D16: Die Version wird per **bewusstem
Bump-Commit** gepflegt, „vollständig aus dem Repo reproduzierbar". Ein Deploy
ist ein bewusster Akt; ihn in der Historie festzuhalten passt dazu — und der
Plan bekommt nebenbei eine Chronik, wann was live ging.
**Verworfen:** Rewrite nur im Prod-Deploy (siehe oben) und Rewrite in **beiden**
Pipelines. Letzteres wäre testbar, aber `[^]` hieße auf Pages faktisch nur
„gemerged", und das kollidiert damit, dass D16 die Pages-Instanz bewusst als
nicht-produktiven Build markiert (🚧-Badge).
**Bekannter Preis:** Zwischen Beförderungs-Commit und rsync sagt Pages `[^]` für
etwas, das auf prod noch nicht liegt. Das Fenster ist kurz und liegt in der Hand
dessen, der deployt — gegenüber einer *dauerhaften* Ungenauigkeit bei der
Build-Rewrite-Variante der bessere Tausch.
**Einmalige Nachholung.** Welche Knoten zu früh `[^]` trugen, ließ sich exakt
bestimmen statt zu schätzen: Der Footer der stabilen Instanz verlinkt den
deployten Commit (`4061362`), und alles danach ist dort nicht enthalten. Es
waren **genau zwei** Knoten — „Optional nodes" (D29) und „Show what is new since
your last visit" (D28) —, nicht das Dutzend, das vorher grob geschätzt worden
war. Beide stehen jetzt auf `[x]` und leuchten beim nächsten Prod-Deploy als neu
auf, was genau der Wahrheit entspricht.
**Umfang:** Nur `docs/examples/example-werkbaum.werkbaum`. Die übrigen
Beispieldateien sind erfunden und sagen nichts über ein Deployment aus.
**Nicht gepusht.** Das Skript committet, pusht aber nicht — Veröffentlichen
bleibt eine bewusste Handlung. `deploy-prod.sh` warnt stattdessen, wenn HEAD
noch nicht auf `origin` liegt: der Footer-Versionslink zeigt sonst auf einen
Commit, den GitHub nicht kennt.
+2 -2
View File
@@ -8,7 +8,7 @@
- [^] T-shirt size and the "decompose from M" rule (S) - [^] T-shirt size and the "decompose from M" rule (S)
- [^] People tags, bare URLs, %% comments (XS) - [^] People tags, bare URLs, %% comments (XS)
- [^] And/or decomposition (S) - [^] And/or decomposition (S)
- [^] Optional nodes — neither required nor an alternative (S) - [x] Optional nodes — neither required nor an alternative (S)
- [-] A separate storage format for the structure (L) %% the text is the format - [-] A separate storage format for the structure (L) %% the text is the format
- [ ] Ticket references #123 (S) - [ ] Ticket references #123 (S)
- [?] Benefit per node, not only cost (M) - [?] Benefit per node, not only cost (M)
@@ -38,7 +38,7 @@
- [^] Load a document from ?sourceUrl= (S) - [^] Load a document from ?sourceUrl= (S)
- [^] Jump between diagram and text (S) - [^] Jump between diagram and text (S)
- [ ] Open and save .werkbaum files (S) - [ ] Open and save .werkbaum files (S)
+ [^] Show what is new since your last visit (S) + [x] Show what is new since your last visit (S)
+ [?] A pastel colour per person (S) + [?] A pastel colour per person (S)
- [?] Dates and milestones (M) - [?] Dates and milestones (M)
| [?] An attribute in the line (S) | [?] An attribute in the line (S)
+32 -8
View File
@@ -2,16 +2,21 @@
# #
# Werkbaum — Produktions-Deploy via rsync/SSH. # Werkbaum — Produktions-Deploy via rsync/SSH.
# #
# Baut das badge-freie Prod-Bundle (`npm run build:prod`, ohne den # Befördert zuerst die fertigen Knoten des mitgelieferten Werkbaum-Plans auf
# Entwicklungs-Hinweis hinter dem Titel), stellt es lokal genauso zusammen wie # „in Produktion" (scripts/promote-shipped.sh, D30 — genau dieser Deploy macht
# der GitHub-Pages-Workflow (LICENSE-Link geradeziehen + Footer-Version/Commit) # die Aussage `[^]` wahr), baut dann das badge-freie Prod-Bundle
# und spiegelt es per rsync in ein Zielverzeichnis. `--delete`: am Ziel bleibt # (`npm run build:prod`, ohne den Entwicklungs-Hinweis hinter dem Titel), stellt
# nichts Altes stehen. # es lokal genauso zusammen wie der GitHub-Pages-Workflow (LICENSE-Link
# geradeziehen + Footer-Version/Commit) und spiegelt es per rsync in ein
# Zielverzeichnis. `--delete`: am Ziel bleibt nichts Altes stehen.
# #
# Verwendung: # Verwendung:
# scripts/deploy-prod.sh [-y] [rsync-ziel] # scripts/deploy-prod.sh [-y] [--no-promote] [rsync-ziel]
# #
# -y ohne Rückfrage spiegeln (sonst erst Vorschau via --dry-run + Nachfrage) # -y ohne Rückfrage befördern und spiegeln (sonst erst Vorschau
# via --dry-run + Nachfrage)
# --no-promote Beförderungsschritt überspringen (z. B. Wiederholung eines
# Deploys, der schon befördert hat)
# #
# Das Ziel ist entweder das Argument ODER — wenn keins angegeben ist — die # Das Ziel ist entweder das Argument ODER — wenn keins angegeben ist — die
# Variable DEPLOY_TARGET aus der git-ignorierten Datei .env im Repo-Wurzelordner # Variable DEPLOY_TARGET aus der git-ignorierten Datei .env im Repo-Wurzelordner
@@ -26,16 +31,18 @@
# ACHTUNG: Das Zielverzeichnis wird als exklusiv für Werkbaum angenommen — # ACHTUNG: Das Zielverzeichnis wird als exklusiv für Werkbaum angenommen —
# `--delete` entfernt dort ALLES, was nicht zum Bundle gehört. # `--delete` entfernt dort ALLES, was nicht zum Bundle gehört.
# #
# Siehe README (Abschnitt Deployment) und docs/DECISIONS.md D16/D19. # Siehe README (Abschnitt Deployment) und docs/DECISIONS.md D16/D19/D30.
set -euo pipefail set -euo pipefail
# ---- Argumente ---- # ---- Argumente ----
YES=0 YES=0
PROMOTE=1
TARGET="" TARGET=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
-y|--yes) YES=1 ;; -y|--yes) YES=1 ;;
--no-promote) PROMOTE=0 ;;
-h|--help) -h|--help)
awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0" awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0"
exit 0 ;; exit 0 ;;
@@ -72,6 +79,17 @@ if [ -z "$TARGET" ]; then
exit 2 exit 2
fi fi
# ---- 0) Fertiges auf „in Produktion" befördern (D30) ----
# MUSS vor dem Build laufen: der Plan ist Build-Eingabe (?raw-Import, D27), und
# der Commit soll derjenige sein, auf den der Footer-Versionslink zeigt.
if [ "$PROMOTE" -eq 1 ]; then
PROMOTE_ARGS=()
[ "$YES" -eq 1 ] && PROMOTE_ARGS+=(-y)
"$ROOT/scripts/promote-shipped.sh" "${PROMOTE_ARGS[@]+"${PROMOTE_ARGS[@]}"}"
else
echo "==> Beförderung übersprungen (--no-promote)"
fi
# ---- 1) Prod-Build (ohne Build-Hinweis) ---- # ---- 1) Prod-Build (ohne Build-Hinweis) ----
if [ ! -d frontend/node_modules ]; then if [ ! -d frontend/node_modules ]; then
echo "==> node_modules fehlt — npm ci" echo "==> node_modules fehlt — npm ci"
@@ -100,6 +118,12 @@ if git -C "$ROOT" rev-parse HEAD >/dev/null 2>&1; then
BUILD_VERSION="${MAJORMINOR}.${MICRO}" BUILD_VERSION="${MAJORMINOR}.${MICRO}"
COMMIT_URL="https://github.com/mhoennig/werkbaum/commit/$(git -C "$ROOT" rev-parse HEAD)" COMMIT_URL="https://github.com/mhoennig/werkbaum/commit/$(git -C "$ROOT" rev-parse HEAD)"
echo "==> Footer-Version ${BUILD_VERSION} -> ${COMMIT_URL}" echo "==> Footer-Version ${BUILD_VERSION} -> ${COMMIT_URL}"
# Der Link zeigt ins Leere, solange der Commit nicht auf GitHub liegt — nach
# einer Beförderung (Schritt 0) ist das der Normalfall.
if [ -z "$(git -C "$ROOT" branch -r --contains HEAD 2>/dev/null)" ]; then
echo " ! HEAD liegt noch nicht auf origin — der Footer-Versionslink läuft" >&2
echo " ins Leere, bis 'git push' nachgeholt ist." >&2
fi
SED_ARGS+=(-e "s#\(<a class=\"ver\" href=\"\)[^\"]*#\1${COMMIT_URL}#") SED_ARGS+=(-e "s#\(<a class=\"ver\" href=\"\)[^\"]*#\1${COMMIT_URL}#")
SED_ARGS+=(-e "s#\(<a class=\"ver\"[^>]*>\)[0-9.]\+</a>#\1${BUILD_VERSION}</a>#") SED_ARGS+=(-e "s#\(<a class=\"ver\"[^>]*>\)[0-9.]\+</a>#\1${BUILD_VERSION}</a>#")
else else
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# Werkbaum — „fertig" auf „in Produktion" befördern.
#
# Der mitgelieferte Plan docs/examples/example-werkbaum.werkbaum beschreibt
# Werkbaum selbst. SPEC §4 unterscheidet `[x]` fertig (abgeschlossen) von `[^]`
# in Produktion (deployed/live) — und die zweite Aussage kann erst der Deploy
# wahr machen. Konvention deshalb: beim Mergen `[x]`, unmittelbar VOR dem
# Deploy dieser Lauf, der daraus `[^]` macht und das als Commit festhält.
#
# Warum ein Commit und kein Rewrite beim Bauen: siehe docs/DECISIONS.md D30.
# Kurz — der Commit wird von BEIDEN Pipelines gesehen (GitHub Pages baut ihn
# beim Push, prod beim nächsten rsync), das Deployment-Artefakt bleibt
# inhaltsgleich mit dem Repo, und die „Was ist neu?"-Anzeige (D28) bleibt auf
# der Pages-Instanz erlebbar statt dort für immer stumm zu sein.
#
# Verwendung:
# scripts/promote-shipped.sh [-n] [-y]
#
# -n, --dry-run nur zeigen, was befördert würde
# -y, --yes ohne Rückfrage befördern und committen
#
# Ohne zu befördernde Knoten endet der Lauf mit 0 und ändert nichts.
# Gepusht wird NICHT — das bleibt eine bewusste Handlung.
set -euo pipefail
DRY=0
YES=0
for arg in "$@"; do
case "$arg" in
-n|--dry-run) DRY=1 ;;
-y|--yes) YES=1 ;;
-h|--help)
awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0"
exit 0 ;;
*) echo "Unbekannte Option: $arg" >&2; exit 2 ;;
esac
done
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
PLAN="docs/examples/example-werkbaum.werkbaum"
[ -f "$PLAN" ] || { echo "Plan nicht gefunden: $PLAN" >&2; exit 1; }
# Nur Statusboxen am Zeilenanfang (nach optionalem Zeichen -/+/|), damit ein
# „[x]" mitten im Label unangetastet bleibt. `x` auch als `X` (SPEC §4).
MATCH='^([[:space:]]*([-+|][[:space:]]*)?)\[[xX]\]'
mapfile -t HITS < <(grep -nE "$MATCH" "$PLAN" || true)
if [ "${#HITS[@]}" -eq 0 ]; then
echo "==> Nichts zu befördern — kein [x] im Plan."
exit 0
fi
echo "==> ${#HITS[@]} Knoten würden auf [^] befördert:"
printf ' %s\n' "${HITS[@]}"
[ "$DRY" -eq 1 ] && exit 0
# Der Commit fasst genau diese eine Datei an. Ist sie schon geändert, lässt sich
# die Beförderung nicht sauber von der Änderung trennen — dann lieber abbrechen.
if git rev-parse HEAD >/dev/null 2>&1 && [ -n "$(git status --porcelain -- "$PLAN")" ]; then
echo " ! $PLAN hat uncommittete Änderungen — erst committen, dann befördern." >&2
exit 1
fi
if [ "$YES" -ne 1 ]; then
printf '==> Befördern und committen? [y/N] '
read -r ANS
case "$ANS" in y|Y|j|J) : ;; *) echo "Abgebrochen."; exit 1 ;; esac
fi
# Labels für den Commit-Text sammeln (ohne Zeichen, Statusbox und Größe).
mapfile -t LABELS < <(printf '%s\n' "${HITS[@]}" \
| sed -E 's/^[0-9]+:[[:space:]]*([-+|][[:space:]]*)?\[[xX]\][[:space:]]*//' \
| sed -E 's/[[:space:]]*\((XS|S|M|L|XL|XXL)\)[[:space:]]*$//')
sed -i -E "s/$MATCH/\1[^]/" "$PLAN"
if ! git rev-parse HEAD >/dev/null 2>&1; then
echo "==> Kein Git-Repo — Datei geändert, nicht committet."
exit 0
fi
{
echo "docs: Werkbaum-Plan — ${#HITS[@]} Knoten in Produktion"
echo
echo "Beim Deploy der stabilen Instanz gehen diese Knoten live; SPEC §4"
echo "unterscheidet [x] fertig von [^] in Produktion (D30):"
echo
printf -- '- %s\n' "${LABELS[@]}"
} | git commit -q -F - -- "$PLAN"
echo "==> Committet: $(git log --oneline -1)"
echo " Hinweis: nicht gepusht. Ohne Push zeigt der Footer-Versionslink des"
echo " Deploys auf einen Commit, den GitHub noch nicht kennt."