diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml
index 6a8e641..aba9b12 100644
--- a/.gitea/workflows/auto-release.yml
+++ b/.gitea/workflows/auto-release.yml
@@ -151,6 +151,15 @@ jobs:
sed -i "s|[^<]*|${TODAY}|" "$MANIFEST"
fi
+
+ # Promote [Unreleased] section in CHANGELOG.md to new version
+ if [ -f "CHANGELOG.md" ] && grep -qi "Unreleased" CHANGELOG.md; then
+ sed -i "s|## \[Unreleased\]|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md
+ sed -i "s|## Unreleased|## [${VERSION}] --- ${TODAY}|" CHANGELOG.md
+ sed -i "2i ## [Unreleased]" CHANGELOG.md
+ sed -i "3i \ " CHANGELOG.md
+ echo "CHANGELOG promoted to [${VERSION}]"
+ fi
# Commit and push
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
diff --git a/.gitea/workflows/cascade-dev.yml b/.gitea/workflows/cascade-dev.yml
new file mode 100644
index 0000000..d4780b1
--- /dev/null
+++ b/.gitea/workflows/cascade-dev.yml
@@ -0,0 +1,213 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Maintenance
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/cascade-dev.yml.template
+# VERSION: 02.00.00
+# BRIEF: Forward-merge main → all open branches after every push to main
+#
+# +========================================================================+
+# | CASCADE MAIN → ALL BRANCHES |
+# +========================================================================+
+# | |
+# | Triggers on every push to main (PR merges, bot commits, etc.) |
+# | |
+# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
+# | 2. For each: create PR (main → branch), auto-merge if clean |
+# | 3. On conflict: leave PR open for manual resolution |
+# | |
+# +========================================================================+
+
+name: Cascade Main → Dev
+
+on:
+ push:
+ branches:
+ - main
+ 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
+ pull-requests: write
+
+jobs:
+ cascade:
+ name: Cascade main → branches
+ runs-on: ubuntu-latest
+ if: >-
+ !contains(github.event.head_commit.message, '[skip ci]') &&
+ !contains(github.event.head_commit.message, '[skip cascade]')
+
+ steps:
+ - name: Discover target branches
+ id: branches
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ # Fetch all branches (paginated)
+ PAGE=1
+ ALL_BRANCHES=""
+ while true; do
+ BATCH=$(curl -sS \
+ -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/branches?page=${PAGE}&limit=50" \
+ | jq -r '.[].name // empty')
+ [ -z "$BATCH" ] && break
+ ALL_BRANCHES="$ALL_BRANCHES $BATCH"
+ PAGE=$((PAGE + 1))
+ done
+
+ # Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
+ TARGETS=""
+ for BRANCH in $ALL_BRANCHES; do
+ case "$BRANCH" in
+ dev|dev/*|rc/*|beta/*|alpha/*)
+ TARGETS="$TARGETS $BRANCH"
+ ;;
+ esac
+ done
+
+ TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
+
+ if [ -z "$TARGETS" ]; then
+ echo "targets=" >> "$GITHUB_OUTPUT"
+ echo "ℹ️ No cascade target branches found"
+ else
+ echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
+ COUNT=$(echo "$TARGETS" | wc -w)
+ echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
+ fi
+
+ - name: Cascade to all target branches
+ if: steps.branches.outputs.targets != ''
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ SHORT_SHA="${GITHUB_SHA:0:7}"
+ TARGETS="${{ steps.branches.outputs.targets }}"
+
+ SUCCESS=0
+ CONFLICTS=0
+ SKIPPED=0
+ FAILED=0
+
+ for BRANCH in $TARGETS; do
+ echo ""
+ echo "═══ main → ${BRANCH} ═══"
+
+ # Check if branch is already up to date
+ ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
+ RESPONSE=$(curl -sS \
+ -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/compare/${ENCODED_BRANCH}...main")
+
+ AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
+
+ if [ "$AHEAD" -eq 0 ]; then
+ echo " ✅ Already up to date"
+ SKIPPED=$((SKIPPED + 1))
+ continue
+ fi
+
+ echo " ℹ️ main is ${AHEAD} commit(s) ahead"
+
+ # Check for existing cascade PR
+ EXISTING=$(curl -sS \
+ -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
+
+ EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
+ PR_NUMBER=""
+
+ if [ "$EXISTING_COUNT" -gt 0 ]; then
+ PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
+ echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
+ else
+ # Create cascade PR
+ PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
+ -X POST \
+ -H "Authorization: token ${GA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
+ \"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
+ \"head\": \"main\",
+ \"base\": \"${BRANCH}\"
+ }" \
+ "${API}/pulls")
+
+ HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
+ BODY=$(echo "$PR_RESPONSE" | sed '$d')
+ PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
+
+ if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
+ MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
+ echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
+ FAILED=$((FAILED + 1))
+ continue
+ fi
+
+ echo " ✅ Created PR #${PR_NUMBER}"
+ fi
+
+ # Try auto-merge
+ PR_DATA=$(curl -sS \
+ -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/pulls/${PR_NUMBER}")
+
+ MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
+
+ if [ "$MERGEABLE" != "true" ]; then
+ echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
+ CONFLICTS=$((CONFLICTS + 1))
+ continue
+ fi
+
+ MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
+ -X POST \
+ -H "Authorization: token ${GA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"Do\": \"merge\",
+ \"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
+ \"delete_branch_after_merge\": false
+ }" \
+ "${API}/pulls/${PR_NUMBER}/merge")
+
+ MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
+
+ if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
+ echo " ✅ Merged — ${BRANCH} is in sync"
+ SUCCESS=$((SUCCESS + 1))
+ else
+ MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
+ echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
+ CONFLICTS=$((CONFLICTS + 1))
+ fi
+ done
+
+ # Summary
+ echo ""
+ echo "════════════════════════════════════════"
+ echo " ✅ Merged: ${SUCCESS}"
+ echo " ⚠️ Conflicts: ${CONFLICTS}"
+ echo " ⏭️ Up to date: ${SKIPPED}"
+ echo " ❌ Failed: ${FAILED}"
+ echo "════════════════════════════════════════"
+
+ if [ "$FAILED" -gt 0 ]; then
+ exit 1
+ fi
diff --git a/.gitea/workflows/ci-joomla.yml b/.gitea/workflows/ci-joomla.yml
index 17284d1..28cee48 100644
--- a/.gitea/workflows/ci-joomla.yml
+++ b/.gitea/workflows/ci-joomla.yml
@@ -375,3 +375,76 @@ jobs:
else
echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY
fi
+
+ static-analysis:
+ name: PHPStan Analysis
+ runs-on: ubuntu-latest
+ needs: lint-and-validate
+ continue-on-error: true
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Setup PHP
+ run: php -v && composer --version
+
+ - name: Install dependencies
+ env:
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
+ run: |
+ if [ -f "composer.json" ]; then
+ composer install --no-interaction --prefer-dist --optimize-autoloader
+ fi
+
+ - name: Install PHPStan
+ run: |
+ if ! command -v vendor/bin/phpstan &> /dev/null; then
+ composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \
+ composer global require phpstan/phpstan --no-interaction
+ fi
+
+ - name: Run PHPStan
+ run: |
+ echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY
+ PHPSTAN="vendor/bin/phpstan"
+ if [ ! -f "$PHPSTAN" ]; then
+ PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan
+ fi
+
+ # Determine source directory
+ SRC_DIR=""
+ for DIR in src/ htdocs/ lib/; do
+ if [ -d "$DIR" ]; then
+ SRC_DIR="$DIR"
+ break
+ fi
+ done
+
+ if [ -z "$SRC_DIR" ]; then
+ echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY
+ exit 0
+ fi
+
+ # Use repo phpstan.neon if present, otherwise use baseline config
+ ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table"
+ if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then
+ echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY
+ else
+ ARGS="$ARGS --level=3"
+ echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ $PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt
+ EXIT=${PIPESTATUS[0]}
+
+ if [ $EXIT -eq 0 ]; then
+ echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY
+ else
+ ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some")
+ echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ fi
+ exit $EXIT
diff --git a/.gitea/workflows/gitleaks.yml b/.gitea/workflows/gitleaks.yml
new file mode 100644
index 0000000..b29f881
--- /dev/null
+++ b/.gitea/workflows/gitleaks.yml
@@ -0,0 +1,96 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Security
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/gitleaks.yml.template
+# VERSION: 01.00.00
+# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
+#
+# +========================================================================+
+# | SECRET SCANNING |
+# +========================================================================+
+# | |
+# | Scans commits for leaked secrets using Gitleaks. |
+# | |
+# | - PR scan: only new commits in the PR |
+# | - Scheduled: full repo scan weekly |
+# | - Alerts via ntfy on findings |
+# | |
+# +========================================================================+
+
+name: Secret Scanning
+
+on:
+ pull_request:
+ branches:
+ - main
+ - 'dev/**'
+ schedule:
+ - cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
+ NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
+
+jobs:
+ gitleaks:
+ name: Gitleaks Secret Scan
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Install Gitleaks
+ run: |
+ GITLEAKS_VERSION="8.21.2"
+ curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
+ | tar -xz -C /usr/local/bin gitleaks
+ gitleaks version
+
+ - name: Scan for secrets
+ id: scan
+ run: |
+ echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
+ ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
+
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ # Scan only PR commits
+ ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
+ echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ if gitleaks detect $ARGS 2>&1; then
+ echo "result=clean" >> "$GITHUB_OUTPUT"
+ echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "result=found" >> "$GITHUB_OUTPUT"
+ FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
+ echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+
+ - name: Notify on findings
+ if: failure() && steps.scan.outputs.result == 'found'
+ run: |
+ REPO="${{ github.event.repository.name }}"
+ curl -sS \
+ -H "Title: ${REPO} — secrets detected in code" \
+ -H "Tags: rotating_light,key" \
+ -H "Priority: urgent" \
+ -d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
+ "${NTFY_URL}/${NTFY_TOPIC}" || true
diff --git a/.gitea/workflows/notify.yml b/.gitea/workflows/notify.yml
index 4413a05..8cc8382 100644
--- a/.gitea/workflows/notify.yml
+++ b/.gitea/workflows/notify.yml
@@ -18,6 +18,7 @@ on:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
+ - "Cascade Main → Dev"
types:
- completed
diff --git a/.gitea/workflows/pre-release.yml b/.gitea/workflows/pre-release.yml
index 5131837..30c9bcf 100644
--- a/.gitea/workflows/pre-release.yml
+++ b/.gitea/workflows/pre-release.yml
@@ -278,7 +278,7 @@ jobs:
f.write(content)
PYEOF
- # Commit and push
+ # Commit and push to current branch
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
@@ -293,6 +293,7 @@ jobs:
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
+ # Sync updates.xml to main and dev (whichever isn't current)
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue