- Trigger-Erkennung im "Variablen ableiten"-Step (refs/tags/* vs refs/heads/*)
- Auto-Modus: Header-Version aus Plugin-PHP lesen, Tag v{version} prüfen
→ wenn Tag existiert: stiller Skip; sonst Pre-Flight + Build + Release
- Release-API-Call mit target_commitish, damit Tag im Auto-Modus aus
dem aktuellen HEAD-Commit erzeugt wird
- Klassischer Tag-Push-Modus bleibt unverändert
- Nachfolgende Steps mit if: steps.vars.outputs.skip != 'true' geguarded
391 lines
16 KiB
YAML
391 lines
16 KiB
YAML
name: Release Plugin
|
|
|
|
# Reusable Workflow für WordPress-Plugin-Releases.
|
|
# Aufruf aus einem Plugin-Repo via:
|
|
#
|
|
# jobs:
|
|
# release:
|
|
# uses: ideenfabrik/idf-ci/.gitea/workflows/release-plugin.yml@v1
|
|
# with:
|
|
# slug: idf-<plugin-slug>
|
|
# secrets: inherit
|
|
#
|
|
# Erwartete Struktur im aufrufenden Repo:
|
|
# - <slug>.php (Plugin-Haupt-PHP mit "Version: X.Y.Z" im Header)
|
|
# - Irgendwo in einer PHP-Datei: define('IDF_<UPPER>_VERSION', 'X.Y.Z')
|
|
# - readme.txt im Repo-Root (WP-Standard-Metadaten inkl. "Stable tag: X.Y.Z")
|
|
# - CHANGELOG.md im Repo-Root mit Block "## v<X.Y.Z> — <Datum>"
|
|
#
|
|
# CHANGELOG.md ist SSOT für den Changelog. readme.txt wird nicht
|
|
# manuell mit Changelog-Einträgen gepflegt — der Abschnitt
|
|
# "== Changelog ==" in readme.txt wird beim Build aus CHANGELOG.md
|
|
# generiert und ist nur im fertigen ZIP vollständig.
|
|
#
|
|
# Trigger im aufrufenden Repo:
|
|
# - push auf main → Auto-Modus: Header-Version wird ausgelesen, Tag v<X.Y.Z>
|
|
# wird erzeugt (sofern noch nicht vorhanden), Release wird gebaut.
|
|
# Existiert der Tag bereits, beendet der Workflow still (kein Release).
|
|
# - push eines Tags v<MAJOR>.<MINOR>.<PATCH> → klassischer Modus: Tag wird
|
|
# direkt für den Release verwendet.
|
|
# Ausführung auf dem IDF-Runner (self-hosted, Host-Modus).
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
slug:
|
|
description: "Plugin-Slug (Top-Level-Ordner im ZIP, Dateiname-Basis)"
|
|
required: true
|
|
type: string
|
|
main_file:
|
|
description: "Plugin-Haupt-PHP (default: <slug>.php)"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
version_constant:
|
|
description: "Name der PHP-Versions-Konstante (default: IDF_<UPPER_SLUG_OHNE_IDF_>_VERSION)"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
|
|
jobs:
|
|
release:
|
|
runs-on: self-hosted
|
|
|
|
steps:
|
|
- name: Checkout Tag
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Variablen ableiten (Auto-Modus oder Tag-Modus)
|
|
id: vars
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
SLUG="${{ inputs.slug }}"
|
|
MAIN_FILE="${{ inputs.main_file }}"
|
|
[[ -z "$MAIN_FILE" ]] && MAIN_FILE="${SLUG}.php"
|
|
CONST="${{ inputs.version_constant }}"
|
|
if [[ -z "$CONST" ]]; then
|
|
base="${SLUG#idf-}"
|
|
base_upper="$(echo "$base" | tr '[:lower:]-' '[:upper:]_')"
|
|
CONST="IDF_${base_upper}_VERSION"
|
|
fi
|
|
|
|
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
|
# Klassischer Modus — Tag-Push
|
|
MODE="tag"
|
|
TAG="${GITHUB_REF_NAME}"
|
|
VERSION="${TAG#v}"
|
|
else
|
|
# Auto-Modus — Push auf Branch (z.B. main)
|
|
MODE="auto"
|
|
if [[ ! -f "$MAIN_FILE" ]]; then
|
|
echo "::error::Plugin-Haupt-PHP nicht gefunden: $MAIN_FILE"
|
|
exit 1
|
|
fi
|
|
HEADER_VERSION="$(grep -iE '^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*' "$MAIN_FILE" | head -n1 | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*$/\1/')"
|
|
if [[ -z "$HEADER_VERSION" ]]; then
|
|
echo "::error::Header-Version in $MAIN_FILE nicht lesbar"
|
|
exit 1
|
|
fi
|
|
VERSION="$HEADER_VERSION"
|
|
TAG="v$VERSION"
|
|
fi
|
|
|
|
# Skip-Check (nur Auto-Modus): existiert der Tag schon, kein neues Release
|
|
SKIP=false
|
|
if [[ "$MODE" == "auto" ]]; then
|
|
if git ls-remote --tags origin "refs/tags/${TAG}" 2>/dev/null | grep -q "refs/tags/${TAG}"; then
|
|
echo "Tag $TAG existiert bereits — Skip (kein neues Release)."
|
|
SKIP=true
|
|
fi
|
|
fi
|
|
|
|
ZIPNAME="${SLUG}_v${VERSION}.zip"
|
|
{
|
|
echo "slug=$SLUG"
|
|
echo "tag=$TAG"
|
|
echo "version=$VERSION"
|
|
echo "main_file=$MAIN_FILE"
|
|
echo "const=$CONST"
|
|
echo "zipname=$ZIPNAME"
|
|
echo "mode=$MODE"
|
|
echo "skip=$SKIP"
|
|
} >> "$GITHUB_OUTPUT"
|
|
echo "Ermittelt: mode=$MODE tag=$TAG version=$VERSION main_file=$MAIN_FILE const=$CONST skip=$SKIP"
|
|
|
|
- name: Pre-Flight — Tag-Format
|
|
if: steps.vars.outputs.skip != 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${{ steps.vars.outputs.tag }}"
|
|
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "::error::Tag '$TAG' entspricht nicht vMAJOR.MINOR.PATCH"
|
|
exit 1
|
|
fi
|
|
echo "Tag-Format OK: $TAG"
|
|
|
|
- name: Pre-Flight — Pflichtdateien
|
|
if: steps.vars.outputs.skip != 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
missing=()
|
|
[[ -f CHANGELOG.md ]] || missing+=("CHANGELOG.md")
|
|
[[ -f readme.txt ]] || missing+=("readme.txt")
|
|
if ((${#missing[@]})); then
|
|
echo "::error::Pflichtdateien fehlen im Repo-Root: ${missing[*]}"
|
|
exit 1
|
|
fi
|
|
echo "Pflichtdateien OK"
|
|
|
|
- name: Pre-Flight — Plugin-Header-Version
|
|
if: steps.vars.outputs.skip != 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
MAIN="${{ steps.vars.outputs.main_file }}"
|
|
VERSION="${{ steps.vars.outputs.version }}"
|
|
if [[ ! -f "$MAIN" ]]; then
|
|
echo "::error::Plugin-Haupt-PHP nicht gefunden: $MAIN"
|
|
exit 1
|
|
fi
|
|
HEADER_VERSION="$(grep -iE '^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*' "$MAIN" | head -n1 | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*$/\1/')"
|
|
if [[ -z "$HEADER_VERSION" ]]; then
|
|
echo "::error::Konnte 'Version: …' Zeile in $MAIN nicht finden"
|
|
exit 1
|
|
fi
|
|
if [[ "$HEADER_VERSION" != "$VERSION" ]]; then
|
|
echo "::error::Plugin-Header-Version '$HEADER_VERSION' != Tag '$VERSION'"
|
|
exit 1
|
|
fi
|
|
echo "Header-Version OK: $HEADER_VERSION"
|
|
|
|
- name: Pre-Flight — Versions-Konstante
|
|
if: steps.vars.outputs.skip != 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
CONST="${{ steps.vars.outputs.const }}"
|
|
VERSION="${{ steps.vars.outputs.version }}"
|
|
CONST_LINE="$(grep -rhE "define[[:space:]]*\([[:space:]]*['\"]${CONST}['\"]" --include='*.php' . | head -n1 || true)"
|
|
if [[ -z "$CONST_LINE" ]]; then
|
|
echo "::error::Versions-Konstante $CONST nicht in PHP-Dateien gefunden"
|
|
exit 1
|
|
fi
|
|
CONST_VERSION="$(echo "$CONST_LINE" | sed -E "s/.*['\"]([0-9]+\.[0-9]+\.[0-9]+)['\"].*/\1/")"
|
|
if [[ "$CONST_VERSION" != "$VERSION" ]]; then
|
|
echo "::error::Konstante $CONST='$CONST_VERSION' != Tag '$VERSION'"
|
|
exit 1
|
|
fi
|
|
echo "Konstante OK: $CONST=$CONST_VERSION"
|
|
|
|
- name: Pre-Flight — readme.txt Stable tag
|
|
if: steps.vars.outputs.skip != 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${{ steps.vars.outputs.version }}"
|
|
STABLE_TAG="$(grep -iE '^[[:space:]]*Stable tag:[[:space:]]*' readme.txt | head -n1 | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*$/\1/')"
|
|
if [[ -z "$STABLE_TAG" ]]; then
|
|
echo "::error::Konnte 'Stable tag: …' Zeile in readme.txt nicht finden"
|
|
exit 1
|
|
fi
|
|
if [[ "$STABLE_TAG" != "$VERSION" ]]; then
|
|
echo "::error::readme.txt Stable tag '$STABLE_TAG' != Tag '$VERSION'"
|
|
exit 1
|
|
fi
|
|
echo "Stable tag OK: $STABLE_TAG"
|
|
|
|
- name: Changelog-Block aus CHANGELOG.md extrahieren
|
|
if: steps.vars.outputs.skip != 'true'
|
|
id: changelog
|
|
shell: bash
|
|
env:
|
|
VERSION: ${{ steps.vars.outputs.version }}
|
|
run: |
|
|
set -euo pipefail
|
|
BLOCK="$(python3 - <<'PY'
|
|
import re, sys, os
|
|
version = os.environ["VERSION"]
|
|
with open("CHANGELOG.md", encoding="utf-8") as f:
|
|
content = f.read()
|
|
# "## v<version>" gefolgt von beliebigem Rest der Zeile (auch "— Datum"),
|
|
# Inhalt ab nächster Zeile bis zum nächsten "## v<x.y.z>" oder "---" oder EOF.
|
|
# [^\n]* statt .*, damit die erste Zeile nicht mit DOTALL bis EOF gefressen wird.
|
|
pat = re.compile(
|
|
r"^##\s+v" + re.escape(version) + r"[^\n]*\n(.*?)(?=^##\s+v\d|^---\s*$|\Z)",
|
|
re.MULTILINE | re.DOTALL,
|
|
)
|
|
m = pat.search(content)
|
|
if not m:
|
|
sys.stderr.write(f"No block for v{version} found in CHANGELOG.md\n")
|
|
sys.exit(1)
|
|
print(m.group(1).strip())
|
|
PY
|
|
)" || { echo "::error::Kein Changelog-Block für v${VERSION} in CHANGELOG.md"; exit 1; }
|
|
{
|
|
echo "body<<CHANGELOG_EOF"
|
|
echo "$BLOCK"
|
|
echo "CHANGELOG_EOF"
|
|
} >> "$GITHUB_OUTPUT"
|
|
echo "Changelog-Block extrahiert (${#BLOCK} Zeichen)."
|
|
|
|
- name: ZIP bauen (readme.txt Changelog-Section aus CHANGELOG.md generieren)
|
|
if: steps.vars.outputs.skip != 'true'
|
|
id: zip
|
|
shell: bash
|
|
env:
|
|
SLUG: ${{ steps.vars.outputs.slug }}
|
|
ZIPNAME: ${{ steps.vars.outputs.zipname }}
|
|
run: |
|
|
set -euo pipefail
|
|
WORK="/tmp/build"
|
|
rm -rf "$WORK"
|
|
mkdir -p "$WORK/$SLUG"
|
|
rsync -a \
|
|
--exclude='.git' \
|
|
--exclude='.gitea' \
|
|
--exclude='.github' \
|
|
--exclude='.gitignore' \
|
|
--exclude='.gitattributes' \
|
|
--exclude='.editorconfig' \
|
|
--exclude='.vscode' \
|
|
--exclude='.idea' \
|
|
--exclude='.DS_Store' \
|
|
--exclude='node_modules' \
|
|
--exclude='tests' \
|
|
--exclude='phpunit.xml*' \
|
|
--exclude='phpcs.xml*' \
|
|
--exclude='*.zip' \
|
|
./ "$WORK/$SLUG/"
|
|
# Pflicht-Artefakte im Build
|
|
for f in readme.txt CHANGELOG.md; do
|
|
if [[ ! -f "$WORK/$SLUG/$f" ]]; then
|
|
echo "::error::$f fehlt im gebauten Plugin-Ordner"
|
|
exit 1
|
|
fi
|
|
done
|
|
# == Changelog == Section in readme.txt aus CHANGELOG.md generieren
|
|
python3 - "$WORK/$SLUG/readme.txt" "$WORK/$SLUG/CHANGELOG.md" <<'PY'
|
|
import re, sys, pathlib
|
|
|
|
readme_path = pathlib.Path(sys.argv[1])
|
|
changelog_path = pathlib.Path(sys.argv[2])
|
|
|
|
# CHANGELOG.md parsen: Einträge "## v<x.y.z> — <Datum?>" bis zum nächsten "## v…" oder "---" oder EOF.
|
|
# [^\n]* für die erste Zeile, damit DOTALL nicht greedy über Zeilen frisst.
|
|
entry_pat = re.compile(
|
|
r"^##\s+v(\d+\.\d+\.\d+)(?:\s+—\s+(\S+))?[^\n]*\n(.*?)(?=^##\s+v\d|^---\s*$|\Z)",
|
|
re.MULTILINE | re.DOTALL,
|
|
)
|
|
content = changelog_path.read_text(encoding="utf-8")
|
|
entries = [(m.group(1), m.group(2), m.group(3).strip()) for m in entry_pat.finditer(content)]
|
|
if not entries:
|
|
sys.stderr.write("Keine Versions-Einträge in CHANGELOG.md gefunden\n")
|
|
sys.exit(1)
|
|
|
|
# CHANGELOG-Body in WP-readme.txt-Style konvertieren:
|
|
# "### Untertitel" → "**Untertitel**"
|
|
# "- Text" → "* Text"
|
|
# Mehrfach-Leerzeilen → eine Leerzeile
|
|
def to_readme(body):
|
|
out = []
|
|
for raw in body.split("\n"):
|
|
line = raw.rstrip()
|
|
if line.startswith("### "):
|
|
out.append(f"**{line[4:].strip()}**")
|
|
elif line.startswith("- "):
|
|
out.append(f"* {line[2:]}")
|
|
else:
|
|
out.append(line)
|
|
cleaned, blank = [], False
|
|
for line in out:
|
|
if line == "":
|
|
if not blank:
|
|
cleaned.append(line)
|
|
blank = True
|
|
else:
|
|
cleaned.append(line)
|
|
blank = False
|
|
return "\n".join(cleaned).strip()
|
|
|
|
section_lines = ["== Changelog =="]
|
|
for version, date, body in entries:
|
|
section_lines.append("")
|
|
header = f"= {version} ="
|
|
section_lines.append(header)
|
|
if date:
|
|
section_lines.append(f"*{date}*")
|
|
section_lines.append("")
|
|
section_lines.append(to_readme(body))
|
|
new_section = "\n".join(section_lines).strip() + "\n"
|
|
|
|
readme = readme_path.read_text(encoding="utf-8")
|
|
# Vorhandene == Changelog == Section ersetzen (bis zum nächsten == Section == oder EOF)
|
|
sec_pat = re.compile(
|
|
r"^==\s*Changelog\s*==\s*$.*?(?=^==[^=\n]+==\s*$|\Z)",
|
|
re.MULTILINE | re.DOTALL | re.IGNORECASE,
|
|
)
|
|
if sec_pat.search(readme):
|
|
readme_new = sec_pat.sub(new_section + "\n", readme)
|
|
else:
|
|
readme_new = readme.rstrip() + "\n\n" + new_section
|
|
readme_path.write_text(readme_new, encoding="utf-8")
|
|
print(f"readme.txt Changelog-Section aktualisiert ({len(entries)} Versionen).")
|
|
PY
|
|
(cd "$WORK" && zip -qr "/tmp/$ZIPNAME" "$SLUG")
|
|
ls -la "/tmp/$ZIPNAME"
|
|
# Validierung: Top-Level im ZIP ist genau $SLUG/
|
|
TOP_DIRS="$(unzip -l "/tmp/$ZIPNAME" | awk 'NR>3 && NF>=4 {print $4}' | grep -E '^[^/]+/' | awk -F/ '{print $1}' | sort -u)"
|
|
if [[ "$TOP_DIRS" != "$SLUG" ]]; then
|
|
echo "::error::Unerwartete Top-Level-Ordner im ZIP: '$TOP_DIRS' (erwartet: '$SLUG')"
|
|
exit 1
|
|
fi
|
|
echo "ZIP-Top-Level OK: $SLUG/"
|
|
echo "zippath=/tmp/$ZIPNAME" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Gitea-Release anlegen und Asset anhängen
|
|
if: steps.vars.outputs.skip != 'true'
|
|
shell: bash
|
|
env:
|
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
|
TAG: ${{ steps.vars.outputs.tag }}
|
|
ZIPPATH: ${{ steps.zip.outputs.zippath }}
|
|
ZIPNAME: ${{ steps.vars.outputs.zipname }}
|
|
BODY: ${{ steps.changelog.outputs.body }}
|
|
run: |
|
|
set -euo pipefail
|
|
SERVER="${GITHUB_SERVER_URL:-https://git.ihre-ideenfabrik.de}"
|
|
REPO="${GITHUB_REPOSITORY}"
|
|
# target_commitish setzt den Commit, aus dem der Tag erzeugt wird,
|
|
# falls er noch nicht existiert (Auto-Modus). Bei Tag-Push ist es egal,
|
|
# weil der Tag schon den Commit kennt — Gitea ignoriert das Feld dann.
|
|
TARGET_SHA="${GITHUB_SHA}"
|
|
PAYLOAD="$(jq -n \
|
|
--arg tag "$TAG" \
|
|
--arg name "$TAG" \
|
|
--arg body "$BODY" \
|
|
--arg target "$TARGET_SHA" \
|
|
'{tag_name:$tag, name:$name, body:$body, target_commitish:$target, draft:false, prerelease:false}')"
|
|
RESP="$(curl -sSf \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-X POST \
|
|
-d "$PAYLOAD" \
|
|
"$SERVER/api/v1/repos/$REPO/releases")"
|
|
REL_ID="$(echo "$RESP" | jq -r '.id')"
|
|
if [[ -z "$REL_ID" || "$REL_ID" == "null" ]]; then
|
|
echo "::error::Release-Anlage fehlgeschlagen: $RESP"
|
|
exit 1
|
|
fi
|
|
echo "Release angelegt: id=$REL_ID"
|
|
curl -sSf \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@$ZIPPATH" \
|
|
-X POST \
|
|
"$SERVER/api/v1/repos/$REPO/releases/$REL_ID/assets?name=$ZIPNAME" \
|
|
> /dev/null
|
|
echo "Asset hochgeladen: $ZIPNAME"
|