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- # secrets: inherit # # Erwartete Struktur im aufrufenden Repo: # - .php (Plugin-Haupt-PHP mit "Version: X.Y.Z" im Header) # - Irgendwo in einer PHP-Datei: define('IDF__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" # # 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 # wird erzeugt (sofern noch nicht vorhanden), Release wird gebaut. # Existiert der Tag bereits, beendet der Workflow still (kein Release). # - push eines Tags v.. → 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: .php)" required: false type: string default: "" version_constant: description: "Name der PHP-Versions-Konstante (default: 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" gefolgt von beliebigem Rest der Zeile (auch "— Datum"), # Inhalt ab nächster Zeile bis zum nächsten "## v" 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<> "$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" 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"