From d252f7c358a35fbfc967a88716c8cbe9a7f3ef92 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 23 Apr 2026 12:27:05 -0500 Subject: [PATCH] feat: add dev-release workflow with cascade channel pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build ZIP and upload to Gitea development release on push to dev - Sync updates.xml to main via API (development channel only) - Parse manifest metadata dynamically (not hardcoded) - Document cascade pattern: dev→dev, alpha→dev+alpha, etc. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/dev-release.yml | 310 ++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 .github/workflows/dev-release.yml diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 00000000..e1049de6 --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,310 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoWaaS +# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS +# PATH: /.github/workflows/dev-release.yml +# VERSION: 02.01.18 +# BRIEF: Dev channel release — build ZIP, upload to Gitea, sync updates.xml to main +# +# +========================================================================+ +# | DEV RELEASE PIPELINE | +# +========================================================================+ +# | | +# | Triggers on push to dev (src/** or README.md changes): | +# | | +# | 1. Read version from README.md | +# | 2. Parse extension metadata from XML manifest | +# | 3. Build ZIP from src/ | +# | 4. Upload to Gitea "development" release | +# | 5. Write updates.xml (development channel only) | +# | 6. Sync updates.xml to main via Gitea API | +# | | +# | Cascade: dev writes ONLY the development channel. | +# | Higher streams (alpha, beta, rc, stable) cascade downward: | +# | alpha → development, alpha | +# | beta → development, alpha, beta | +# | rc → development, alpha, beta, rc | +# | stable → development, alpha, beta, rc, stable | +# | | +# +========================================================================+ + +name: Dev Release + +on: + push: + branches: [dev] + paths: + - 'src/**' + - 'README.md' + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +permissions: + contents: write + +jobs: + dev-release: + name: Dev Release + runs-on: release + steps: + - name: Checkout dev branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.GA_TOKEN }} + ref: dev + + # -- Read version ---------------------------------------------------------- + - name: Read version from README.md + id: version + run: | + VERSION=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md | head -1) + if [ -z "$VERSION" ]; then + echo "No VERSION in README.md — skipping" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + + # -- Parse manifest -------------------------------------------------------- + - name: Parse extension metadata + if: steps.version.outputs.skip != 'true' + id: manifest + run: | + MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "No Joomla XML manifest found" && exit 1 + fi + + EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1) + EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1) + EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_CLIENT=$(sed -n 's/.*]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1) + TARGET_PLATFORM=$(sed -n 's/.*\(\).*/\1/p' "$MANIFEST" | head -1) + PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1) + + [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}" + [ -z "$EXT_TYPE" ] && EXT_TYPE="component" + if [ -z "$EXT_ELEMENT" ]; then + EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]') + fi + + echo "name=$EXT_NAME" >> "$GITHUB_OUTPUT" + echo "element=$EXT_ELEMENT" >> "$GITHUB_OUTPUT" + echo "type=$EXT_TYPE" >> "$GITHUB_OUTPUT" + echo "client=$EXT_CLIENT" >> "$GITHUB_OUTPUT" + echo "folder=$EXT_FOLDER" >> "$GITHUB_OUTPUT" + echo "target_platform=$TARGET_PLATFORM" >> "$GITHUB_OUTPUT" + echo "php_minimum=$PHP_MINIMUM" >> "$GITHUB_OUTPUT" + + # -- Build ZIP ------------------------------------------------------------- + - name: Build ZIP package + if: steps.version.outputs.skip != 'true' + id: build + run: | + VERSION="${{ steps.version.outputs.version }}" + ELEMENT="${{ steps.manifest.outputs.element }}" + ZIP_NAME="${ELEMENT}-${VERSION}.zip" + + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/"; exit 1; } + + cd "$SOURCE_DIR" + zip -r "/tmp/${ZIP_NAME}" . -x "*.git*" ".ftpignore" "sftp-config*" "*.ppk" "*.pem" "*.key" ".env*" + cd .. + + SHA256=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1) + SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}") + + echo "zip_name=$ZIP_NAME" >> "$GITHUB_OUTPUT" + echo "sha256=$SHA256" >> "$GITHUB_OUTPUT" + echo "size=$SIZE" >> "$GITHUB_OUTPUT" + + # -- Upload to Gitea ------------------------------------------------------- + - name: Update Gitea development release + if: steps.version.outputs.skip != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + ZIP_NAME="${{ steps.build.outputs.zip_name }}" + SHA256="${{ steps.build.outputs.sha256 }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + AUTH="Authorization: token ${{ secrets.GA_TOKEN }}" + + # Find or create the development release + RELEASE_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/releases/tags/development" 2>/dev/null || echo "") + RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -z "$RELEASE_ID" ]; then + RELEASE_JSON=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ + "${API_BASE}/releases" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'tag_name': 'development', + 'name': 'development (${VERSION}-dev)', + 'body': '## ${VERSION} (development)\n\n### SHA-256\n\`${SHA256}\`', + 'target_commitish': 'dev', + 'prerelease': True + }))")") + RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") + else + curl -sf -X PATCH -H "$AUTH" -H "Content-Type: application/json" \ + "${API_BASE}/releases/${RELEASE_ID}" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'name': 'development (${VERSION}-dev)', + 'body': '## ${VERSION} (development)\n\n### SHA-256\n\`${SHA256}\`', + 'target_commitish': 'dev', + 'prerelease': True + }))")" > /dev/null + fi + + # Delete all existing assets + ASSETS=$(curl -sf -H "$AUTH" "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]") + echo "$ASSETS" | python3 -c " + import sys, json + for a in json.load(sys.stdin): + print(a['id']) + " 2>/dev/null | while read -r AID; do + curl -sf -X DELETE -H "$AUTH" "${API_BASE}/releases/${RELEASE_ID}/assets/${AID}" 2>/dev/null || true + done + + # Upload new ZIP + curl -sf -X POST -H "$AUTH" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"/tmp/${ZIP_NAME}" \ + "${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null + + # -- Write & sync updates.xml ---------------------------------------------- + - name: Sync updates.xml to main + if: steps.version.outputs.skip != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + ZIP_NAME="${{ steps.build.outputs.zip_name }}" + SHA256="${{ steps.build.outputs.sha256 }}" + EXT_NAME="${{ steps.manifest.outputs.name }}" + EXT_ELEMENT="${{ steps.manifest.outputs.element }}" + EXT_TYPE="${{ steps.manifest.outputs.type }}" + EXT_CLIENT="${{ steps.manifest.outputs.client }}" + EXT_FOLDER="${{ steps.manifest.outputs.folder }}" + TARGET_PLATFORM="${{ steps.manifest.outputs.target_platform }}" + PHP_MINIMUM="${{ steps.manifest.outputs.php_minimum }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + AUTH="Authorization: token ${{ secrets.GA_TOKEN }}" + DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/development/${ZIP_NAME}" + INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/development" + + # Build optional tags + CLIENT_TAG="" + if [ -n "$EXT_CLIENT" ]; then + CLIENT_TAG="${EXT_CLIENT}" + elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then + CLIENT_TAG="site" + fi + + FOLDER_TAG="" + if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then + FOLDER_TAG="${EXT_FOLDER}" + fi + + if [ -z "$TARGET_PLATFORM" ]; then + TARGET_PLATFORM='' + fi + + PHP_TAG="" + if [ -n "$PHP_MINIMUM" ]; then + PHP_TAG="${PHP_MINIMUM}" + fi + + # -- Build a single update entry for a given channel tag + build_entry() { + local TAG_NAME="$1" + printf '%s\n' ' ' + printf '%s\n' " ${EXT_NAME}" + printf '%s\n' " ${EXT_NAME} (${TAG_NAME})" + printf '%s\n' " ${EXT_ELEMENT}" + printf '%s\n' " ${EXT_TYPE}" + printf '%s\n' " ${VERSION}-dev" + [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}" + [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}" + printf '%s\n' " ${TAG_NAME}" + printf '%s\n' " ${INFO_URL}" + printf '%s\n' ' ' + printf '%s\n' " ${DOWNLOAD_URL}" + printf '%s\n' ' ' + printf '%s\n' " ${SHA256}" + printf '%s\n' " ${TARGET_PLATFORM}" + [ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}" + printf '%s\n' ' Moko Consulting' + printf '%s\n' ' https://mokoconsulting.tech' + printf '%s\n' ' ' + } + + # -- Cascade: dev stream writes ONLY the development channel + { + printf '%s\n' "" + printf '%s\n' "" + printf '%s\n' "" + printf '%s\n' '' + build_entry "development" + printf '%s\n' '' + } > /tmp/updates.xml + + # -- Push updates.xml to main via Gitea API + FILE_SHA=$(curl -sf -H "$AUTH" \ + "${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true) + + CONTENT=$(base64 -w0 /tmp/updates.xml) + + if [ -n "$FILE_SHA" ]; then + curl -sf -X PUT -H "$AUTH" -H "Content-Type: application/json" \ + "${API_BASE}/contents/updates.xml" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'content': '${CONTENT}', + 'sha': '${FILE_SHA}', + 'message': 'chore: sync updates.xml ${VERSION}-dev [skip ci]', + 'branch': 'main' + }))")" > /dev/null \ + && echo "updates.xml synced to main (development channel)" \ + || echo "WARNING: failed to sync updates.xml to main" + else + curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \ + "${API_BASE}/contents/updates.xml" \ + -d "$(python3 -c "import json; print(json.dumps({ + 'content': '${CONTENT}', + 'message': 'chore: add updates.xml ${VERSION}-dev [skip ci]', + 'branch': 'main' + }))")" > /dev/null \ + && echo "updates.xml created on main" \ + || echo "WARNING: failed to create updates.xml on main" + fi + + # -- Summary --------------------------------------------------------------- + - name: Summary + if: always() + run: | + VERSION="${{ steps.version.outputs.version }}" + if [ "${{ steps.version.outputs.skip }}" = "true" ]; then + echo "## Dev Release Skipped" >> $GITHUB_STEP_SUMMARY + else + echo "## Dev Release — ${VERSION}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Detail | Value |" >> $GITHUB_STEP_SUMMARY + echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}-dev\` |" >> $GITHUB_STEP_SUMMARY + echo "| Channels | \`development\` |" >> $GITHUB_STEP_SUMMARY + echo "| Asset | \`${{ steps.build.outputs.zip_name }}\` (${{ steps.build.outputs.size }} bytes) |" >> $GITHUB_STEP_SUMMARY + echo "| SHA-256 | \`${{ steps.build.outputs.sha256 }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| updates.xml | synced to main |" >> $GITHUB_STEP_SUMMARY + fi