From 88ad85dd722720c57b3581ee6ccdac6aa34791f1 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Fri, 1 May 2026 16:27:42 +0000
Subject: [PATCH 001/114] chore(version): auto-bump patch 01.00.27 [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 627aafa..235f6ea 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 01.00.26
+ VERSION: 01.00.27
BRIEF: Documentation for MokoOnyx template
-->
--
2.52.0
From 8d0d0f29dbadc5e8f43eb596c644b2a316dfa08e Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Fri, 1 May 2026 16:27:43 +0000
Subject: [PATCH 002/114] chore: update updates.xml (development: 01.00.27-dev)
[skip ci]
---
updates.xml | 39 ++++++++++++++++++++-------------------
1 file changed, 20 insertions(+), 19 deletions(-)
diff --git a/updates.xml b/updates.xml
index 5b000ea..96466c2 100644
--- a/updates.xml
+++ b/updates.xml
@@ -7,25 +7,6 @@
-
- MokoOnyx
- MokoOnyx development build — unstable.
- mokoonyx
- template
- site
- 01.00.26
- 2026-04-26
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
-
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-01.00.26-dev.zip
-
-
- development
- Moko Consulting
- https://mokoconsulting.tech
-
- 8.1
-
@@ -111,4 +92,24 @@
8.1
+
+ MokoOnyx
+ MokoOnyx (development)
+ mokoonyx
+ template
+ 01.00.27-dev
+ 2026-05-01
+ site
+
+ development
+
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
+
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-01.00.27-dev.zip
+
+ 87ed27f8b3431b1ed94dabee9b5e96b5a0f00a3d373668aea3714a9bb6c2ee72
+
+ Moko Consulting
+ https://mokoconsulting.tech
+
--
2.52.0
From f51472e57152fff6d950a11347eff10c5029de54 Mon Sep 17 00:00:00 2001
From: Moko Standards Bot
Date: Thu, 7 May 2026 14:57:12 -0500
Subject: [PATCH 003/114] ci: add Gitleaks secret scanning + Renovate
dependency config [skip ci]
---
.gitea/workflows/gitleaks.yml | 96 +++++++++++++++++++++++++++++++++++
renovate.json | 26 ++++++++++
2 files changed, 122 insertions(+)
create mode 100644 .gitea/workflows/gitleaks.yml
create mode 100644 renovate.json
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/renovate.json b/renovate.json
new file mode 100644
index 0000000..15c6a10
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:recommended",
+ "schedule:weekly",
+ ":disableDependencyDashboard"
+ ],
+ "labels": ["dependencies"],
+ "automerge": false,
+ "platformAutomerge": false,
+ "rangeStrategy": "bump",
+ "packageRules": [
+ {
+ "matchUpdateTypes": ["patch"],
+ "automerge": true
+ },
+ {
+ "matchManagers": ["composer"],
+ "enabled": true
+ },
+ {
+ "matchManagers": ["npm"],
+ "enabled": true
+ }
+ ]
+}
--
2.52.0
From 1dfde9461187ad06e920dd9da259a2c84383691b Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Thu, 7 May 2026 15:08:34 -0500
Subject: [PATCH 004/114] ci: add PHPStan static analysis to CI pipeline [skip
ci]
---
.gitea/workflows/ci-joomla.yml | 73 ++++++++++++++++++++++++++++++++++
1 file changed, 73 insertions(+)
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
--
2.52.0
From bd77de46766b920c5bfd75801f02f437fa9079a2 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Thu, 7 May 2026 20:35:08 -0500
Subject: [PATCH 005/114] =?UTF-8?q?feat:=20add=20CSS=20sync=20workflow=20?=
=?UTF-8?q?=E2=80=94=20syncs=20to=20template,=20checks=20client=20variable?=
=?UTF-8?q?=20coverage?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On push to main with CSS changes:
1. Syncs base CSS to Template-Client-WaaS
2. Checks each client repo's custom files for missing variables
3. Creates issue if any base variables are absent from client overrides
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.gitea/workflows/dispatch-css-sync.yml | 111 +++++++++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 .gitea/workflows/dispatch-css-sync.yml
diff --git a/.gitea/workflows/dispatch-css-sync.yml b/.gitea/workflows/dispatch-css-sync.yml
new file mode 100644
index 0000000..fe233e7
--- /dev/null
+++ b/.gitea/workflows/dispatch-css-sync.yml
@@ -0,0 +1,111 @@
+# When MokoOnyx CSS changes hit main:
+# 1. Sync base CSS to Template-Client-WaaS (the single source for clients)
+# 2. If new CSS variables were added, create issues on individual client repos
+name: Sync CSS to Client Template
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'src/media/templates/site/mokoonyx/css/**'
+ - 'media/templates/site/mokoonyx/css/**'
+
+permissions:
+ contents: read
+
+jobs:
+ sync:
+ name: Sync to Template and Notify Clients
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout MokoOnyx
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+
+ - name: Sync CSS to Template-Client-WaaS
+ env:
+ GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ API="${{ github.server_url }}/api/v1"
+ AUTH="Authorization: token ${GITEA_TOKEN}"
+ TEMPLATE="MokoConsulting/Template-Client-WaaS"
+
+ CSS_DIR="src/media/templates/site/mokoonyx/css"
+ [ ! -d "$CSS_DIR" ] && CSS_DIR="media/templates/site/mokoonyx/css"
+
+ # Sync base CSS files only (user.css and *.custom.css are client-owned)
+ find "$CSS_DIR" -name "*.css" -not -name "user.css" -not -name "*.custom.css" | while read -r file; do
+ rel_path="src/media/templates/site/mokoonyx/css/${file#${CSS_DIR}/}"
+ content_b64=$(base64 -w0 "$file")
+ sha=$(curl -sf -H "$AUTH" "${API}/repos/${TEMPLATE}/contents/${rel_path}" | jq -r '.sha // empty')
+
+ if [ -n "$sha" ]; then
+ curl -sf -X PUT -H "$AUTH" -H "Content-Type: application/json" \
+ "${API}/repos/${TEMPLATE}/contents/${rel_path}" \
+ -d "{\"content\": \"${content_b64}\", \"sha\": \"${sha}\", \"message\": \"chore: sync CSS from MokoOnyx\"}" \
+ -o /dev/null && echo "Updated: ${rel_path}"
+ else
+ curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
+ "${API}/repos/${TEMPLATE}/contents/${rel_path}" \
+ -d "{\"content\": \"${content_b64}\", \"message\": \"chore: sync CSS from MokoOnyx\"}" \
+ -o /dev/null && echo "Created: ${rel_path}"
+ fi
+ done
+
+ - name: Extract all CSS variables from MokoOnyx base
+ id: vars
+ env:
+ GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ API="${{ github.server_url }}/api/v1"
+ AUTH="Authorization: token ${GITEA_TOKEN}"
+
+ CSS_DIR="src/media/templates/site/mokoonyx/css"
+ [ ! -d "$CSS_DIR" ] && CSS_DIR="media/templates/site/mokoonyx/css"
+
+ # Get ALL variables defined in MokoOnyx base CSS (excluding custom files)
+ ALL_VARS=$(find "$CSS_DIR" -name "*.css" -not -name "*.custom.css" -not -name "user.css" -exec grep -ohE '\-\-[a-z][a-z0-9-]+' {} \; | sort -u)
+ echo "$ALL_VARS" > /tmp/all_vars.txt
+ echo "Total base variables: $(wc -l < /tmp/all_vars.txt)"
+
+ # Check each client repo for missing variables
+ CLIENTS=(
+ "ClarksvilleFurs/client-waas-clarksvillefurs"
+ "KiddieLand/client-waas-kiddieland"
+ "VexCreations/client-waas-vexcreations"
+ )
+
+ for repo in "${CLIENTS[@]}"; do
+ echo "=== Checking ${repo} ==="
+ MISSING=""
+
+ for theme in "dark" "light"; do
+ FILE_PATH="src/media/templates/site/mokoonyx/css/theme/${theme}.custom.css"
+ CLIENT_CSS=$(curl -sf -H "$AUTH" "${API}/repos/${repo}/contents/${FILE_PATH}" | jq -r '.content // empty' | base64 -d 2>/dev/null || echo "")
+
+ if [ -z "$CLIENT_CSS" ]; then
+ MISSING="$MISSING\nAll variables missing from ${theme}.custom.css (file not found)"
+ continue
+ fi
+
+ # Find variables in base that are NOT in client custom file
+ while read -r var; do
+ [ -z "$var" ] && continue
+ if ! echo "$CLIENT_CSS" | grep -qF "$var"; then
+ MISSING="$MISSING\n- \`${var}\` missing from ${theme}.custom.css"
+ fi
+ done < /tmp/all_vars.txt
+ done
+
+ if [ -n "$MISSING" ]; then
+ BODY="Your theme custom files are missing CSS variables defined in MokoOnyx base.\n\n## Missing Variables\n${MISSING}\n\n## Action\n\nAdd these variables to your \`dark.custom.css\` and/or \`light.custom.css\` with appropriate values for your theme.\n\nBase CSS reference: ${{ github.server_url }}/MokoConsulting/MokoOnyx/src/branch/main/src/media/templates/site/mokoonyx/css"
+
+ curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
+ "${API}/repos/${repo}/issues" \
+ -d "$(jq -n --arg t "chore: CSS variables out of sync with MokoOnyx" --arg b "$BODY" '{title:$t,body:$b}')" \
+ -o /dev/null && echo "Issue created: ${repo}"
+ else
+ echo " All variables present"
+ fi
+ done
--
2.52.0
From 95c42c91a5683560e78bd5e1e954b02a45baa254 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Sat, 9 May 2026 16:32:28 -0500
Subject: [PATCH 006/114] feat: wire minification into CI/release pipeline
- Makefile: new `minify` target using moko-platform/build/minify.js
- auto-release.yml: Step 7.5 minifies before ZIP packaging
- .gitignore: exclude *.min.css and *.min.js from git
- Minified files ship in release ZIPs but not in the repo
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.gitea/workflows/auto-release.yml | 16 ++++++++++++++++
.gitignore | 2 ++
Makefile | 16 +++++++++++++++-
3 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml
index 0bc7775..f3ce70a 100644
--- a/.gitea/workflows/auto-release.yml
+++ b/.gitea/workflows/auto-release.yml
@@ -510,6 +510,22 @@ jobs:
}))")"
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
+ # -- STEP 7.5: Minify CSS/JS assets (build-time only, not committed) ------
+ - name: "Step 7.5: Minify assets"
+ if: >-
+ steps.version.outputs.skip != 'true'
+ run: |
+ npm install --no-save terser clean-css 2>/dev/null || true
+ MINIFY=""
+ for p in "../moko-platform/build/minify.js" "scripts/minify.js"; do
+ [ -f "$p" ] && MINIFY="$p" && break
+ done
+ if [ -n "$MINIFY" ]; then
+ node "$MINIFY" src
+ else
+ echo "No minify script found — skipping"
+ fi
+
# -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
- name: "Step 8: Build Joomla package and update checksum"
if: >-
diff --git a/.gitignore b/.gitignore
index d89e74c..4756361 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,6 +117,8 @@ site/
*.map
*.css.map
*.js.map
+*.min.css
+*.min.js
*.tsbuildinfo
# ============================================================
diff --git a/Makefile b/Makefile
index 2263186..16e381c 100644
--- a/Makefile
+++ b/Makefile
@@ -121,8 +121,22 @@ clean: ## Clean build artifacts
@rm -rf $(BUILD_DIR) $(DIST_DIR)
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)"
+MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
+MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
+
+.PHONY: minify
+minify: ## Minify CSS/JS assets (requires terser + clean-css)
+ @echo "$(COLOR_BLUE)Minifying assets...$(COLOR_RESET)"
+ @if [ -f "$(MINIFY_SCRIPT)" ]; then \
+ node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
+ elif [ -f "scripts/minify.js" ]; then \
+ node scripts/minify.js; \
+ else \
+ echo "$(COLOR_YELLOW)⚠ No minify script found$(COLOR_RESET)"; \
+ fi
+
.PHONY: build
-build: clean ## Build template installable ZIP from src/
+build: clean minify ## Build template installable ZIP from src/
@echo "$(COLOR_BLUE)Building $(EXTENSION_NAME) v$(EXTENSION_VERSION)...$(COLOR_RESET)"
@mkdir -p $(BUILD_DIR)/package $(DIST_DIR)
@cp -r $(SRC_DIR)/* $(BUILD_DIR)/package/
--
2.52.0
From 264ff9d3dabf673d64a66ae457cf8fe877f3b4cd Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Sat, 9 May 2026 16:44:21 -0500
Subject: [PATCH 007/114] fix: remove duplicate .min asset entries from
joomla.asset.json
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Joomla's Web Asset Manager auto-serves .min.css/.min.js when debug mode
is off — no need for separate registered assets. Duplicate entries were
preventing the auto-switching behavior.
Now: register source files only. Joomla finds .min versions automatically
when $debug = false in configuration.php.
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/joomla.asset.json | 66 -------------------------------------------
1 file changed, 66 deletions(-)
diff --git a/src/joomla.asset.json b/src/joomla.asset.json
index d926bd6..2bd1946 100644
--- a/src/joomla.asset.json
+++ b/src/joomla.asset.json
@@ -28,48 +28,24 @@
"uri": "media/templates/site/mokoonyx/css/template.css",
"attributes": {"media": "all"}
},
- {
- "name": "template.base.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/css/template.min.css",
- "attributes": {"media": "all"}
- },
{
"name": "template.offline",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/offline.css",
"attributes": {"media": "all"}
},
- {
- "name": "template.offline.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/css/offline.min.css",
- "attributes": {"media": "all"}
- },
{
"name": "template.user",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/user.css",
"attributes": {"media": "all"}
},
- {
- "name": "template.user.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/css/user.min.css",
- "attributes": {"media": "all"}
- },
{
"name": "user.js",
"type": "script",
"uri": "media/templates/site/mokoonyx/js/user.js",
"attributes": {"defer": true}
},
- {
- "name": "user.js.min",
- "type": "script",
- "uri": "media/templates/site/mokoonyx/js/user.min.js",
- "attributes": {"defer": true}
- },
{
"name": "template.font.osaka",
"type": "style",
@@ -82,60 +58,30 @@
"uri": "media/templates/site/mokoonyx/css/editor.css",
"attributes": {"media": "all"}
},
- {
- "name": "template.editor.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/css/editor.min.css",
- "attributes": {"media": "all"}
- },
{
"name": "template.light.standard",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/light.standard.css",
"attributes": {"media": "all"}
},
- {
- "name": "template.light.standard.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/css/theme/light.standard.min.css",
- "attributes": {"media": "all"}
- },
{
"name": "template.light.custom",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/light.custom.css",
"attributes": {"media": "all"}
},
- {
- "name": "template.light.custom.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/css/theme/light.custom.min.css",
- "attributes": {"media": "all"}
- },
{
"name": "template.dark.standard",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/dark.standard.css",
"attributes": {"media": "all"}
},
- {
- "name": "template.dark.standard.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/css/theme/dark.standard.min.css",
- "attributes": {"media": "all"}
- },
{
"name": "template.dark.custom",
"type": "style",
"uri": "media/templates/site/mokoonyx/css/theme/dark.custom.css",
"attributes": {"media": "all"}
},
- {
- "name": "template.dark.custom.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/css/theme/dark.custom.min.css",
- "attributes": {"media": "all"}
- },
{
"name": "template.a11y-high-contrast",
"type": "style",
@@ -148,24 +94,12 @@
"uri": "media/templates/site/mokoonyx/js/template.js",
"attributes": {"defer": true}
},
- {
- "name": "template.js.min",
- "type": "script",
- "uri": "media/templates/site/mokoonyx/js/template.min.js",
- "attributes": {"defer": true}
- },
{
"name": "gtm.js",
"type": "script",
"uri": "media/templates/site/mokoonyx/js/gtm.js",
"attributes": {"defer": true}
},
- {
- "name": "gtm.min.js",
- "type": "script",
- "uri": "media/templates/site/mokoonyx/js/gtm.min.js",
- "attributes": {"defer": true}
- },
{
"name": "vendor.fa7free.all",
"type": "style",
--
2.52.0
From e023411f746aa2e1f520afe541f4b9c0aebee26f Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:45:03 +0000
Subject: [PATCH 008/114] chore(version): auto-bump patch 02.01.01 [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 017d977..2d42d25 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 02.01.00
+ VERSION: 02.01.01
BRIEF: Documentation for MokoOnyx template
-->
--
2.52.0
From 03a7dcc5034e9dd09764fad90f53d75bab4f9f23 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:45:05 +0000
Subject: [PATCH 009/114] chore: update updates.xml (development: 02.01.01-dev)
[skip ci]
---
updates.xml | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/updates.xml b/updates.xml
index 4dc3a3d..14a72f3 100644
--- a/updates.xml
+++ b/updates.xml
@@ -7,20 +7,21 @@
MokoOnyx
- MokoOnyx update
+ MokoOnyx development build.
mokoonyx
template
- 02.01.00
site
- development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/stable
+ 02.01.01
+ 2026-05-09
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/stable/-02.01.00.zip
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.01-dev.zip
- e86c851b2b6e49ce46abd76235a1193fe98a0d0c82133356dfaaa28328ceb2b9
-
+ 42dfc2c4d8e307b5079fc54391b3cca13374c5b9bdde13725c34f883ac224b42
+ development
Moko Consulting
https://mokoconsulting.tech
+
MokoOnyx
--
2.52.0
From a6348168134440552a731d6f266e4d9d263f99e1 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Sat, 9 May 2026 16:48:36 -0500
Subject: [PATCH 010/114] feat: auto-minify user.css, user.js, and custom theme
files on page load
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- MokoMinifyHelper now includes user.css, user.js, gtm.js, and all
custom theme files in its watch list
- When debug is off: auto-regenerates .min files if source is newer
- When debug is on: deletes .min files so source is served directly
- Removed manual .min asset switching from index.php — Joomla's Web
Asset Manager handles it automatically when .min files exist on disk
- Removed duplicate .min entries from joomla.asset.json
This means editable files (user.css, user.js, custom themes) get
auto-minified on the server after any edit — no build step needed.
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/helper/minify.php | 3 +++
src/index.php | 22 ++++++++--------------
2 files changed, 11 insertions(+), 14 deletions(-)
diff --git a/src/helper/minify.php b/src/helper/minify.php
index 5189837..9ffbe2f 100644
--- a/src/helper/minify.php
+++ b/src/helper/minify.php
@@ -25,6 +25,7 @@ class MokoMinifyHelper
'css/offline.css',
'css/editor.css',
'css/a11y-high-contrast.css',
+ 'css/user.css',
'css/theme/light.standard.css',
'css/theme/dark.standard.css',
'css/theme/light.custom.css',
@@ -33,6 +34,8 @@ class MokoMinifyHelper
private const JS_FILES = [
'js/template.js',
+ 'js/gtm.js',
+ 'js/user.js',
];
/**
diff --git a/src/index.php b/src/index.php
index 00a1653..d717743 100644
--- a/src/index.php
+++ b/src/index.php
@@ -110,14 +110,9 @@ if ($params_favicon_source) {
require_once __DIR__ . '/helper/minify.php';
MokoMinifyHelper::sync(JPATH_ROOT . '/' . $templatePath, (bool) $params_developmentmode);
-// Core template CSS + JS — use minified when not in development mode
-if ($params_developmentmode) {
- $wa->useStyle('template.base'); // css/template.css
- $wa->useScript('template.js'); // js/template.js
-} else {
- $wa->useStyle('template.base.min'); // css/template.min.css
- $wa->useScript('template.js.min'); // js/template.min.js
-}
+// Core template CSS + JS — Joomla auto-serves .min when debug is off
+$wa->useStyle('template.base'); // css/template.css (or .min.css)
+$wa->useScript('template.js'); // js/template.js (or .min.js)
// Load Osaka font for site title
$wa->useStyle('template.font.osaka');
@@ -258,19 +253,18 @@ if ($faKitCode !== '') {
$params_leftIcon = htmlspecialchars($this->params->get('drawerLeftIcon', 'fa-solid fa-chevron-left'), ENT_COMPAT, 'UTF-8');
$params_rightIcon = htmlspecialchars($this->params->get('drawerRightIcon', 'fa-solid fa-chevron-right'), ENT_COMPAT, 'UTF-8');
-// Load theme palette stylesheets — minified when not in development mode
-$suffix = $params_developmentmode ? '' : '.min';
-$wa->useStyle('template.light.standard' . $suffix);
-$wa->useStyle('template.dark.standard' . $suffix);
+// Load theme palette stylesheets — Joomla auto-serves .min when debug is off
+$wa->useStyle('template.light.standard');
+$wa->useStyle('template.dark.standard');
// Load custom palettes only if selected in template configuration AND files exist
if ($params_LightColorName === 'custom' && file_exists(JPATH_ROOT . '/media/templates/site/mokoonyx/css/theme/light.custom.css'))
{
- $wa->useStyle('template.light.custom' . $suffix);
+ $wa->useStyle('template.light.custom');
}
if ($params_DarkColorName === 'custom' && file_exists(JPATH_ROOT . '/media/templates/site/mokoonyx/css/theme/dark.custom.css'))
{
- $wa->useStyle('template.dark.custom' . $suffix);
+ $wa->useStyle('template.dark.custom');
}
// Load user assets last (after all other styles and scripts)
--
2.52.0
From 135e9e1a6e29c2c09e3dc832a04fbaa54e941219 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:49:37 +0000
Subject: [PATCH 011/114] chore(version): auto-bump patch 02.01.02 [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 2d42d25..7a0f6f1 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 02.01.01
+ VERSION: 02.01.02
BRIEF: Documentation for MokoOnyx template
-->
--
2.52.0
From 5b8315b2211fa5a4814521afdd3ed0275615c8cb Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:49:37 +0000
Subject: [PATCH 012/114] chore: update updates.xml (development: 02.01.02-dev)
[skip ci]
---
updates.xml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/updates.xml b/updates.xml
index 14a72f3..46c3e03 100644
--- a/updates.xml
+++ b/updates.xml
@@ -11,13 +11,13 @@
mokoonyx
template
site
- 02.01.01
+ 02.01.02
2026-05-09
https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.01-dev.zip
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.02-dev.zip
- 42dfc2c4d8e307b5079fc54391b3cca13374c5b9bdde13725c34f883ac224b42
+ 4baa2a53831cd29690fac0a1d2c461e41da1f8f720aa25b63222db2d6bd48b9a
development
Moko Consulting
https://mokoconsulting.tech
--
2.52.0
From 847e44f2652d14d4a23f20937a789ab852eacf1b Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Sat, 9 May 2026 16:49:37 -0500
Subject: [PATCH 013/114] chore: remove unminified Font Awesome CSS (vendor
ships minified only)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Library files don't need source versions — only .min.css is served.
Saves ~500KB from the release package.
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/media/vendor/fa7free/css/all.css | 10663 -----------------
src/media/vendor/fa7free/css/brands.css | 2219 ----
src/media/vendor/fa7free/css/fontawesome.css | 8361 -------------
src/media/vendor/fa7free/css/regular.css | 31 -
src/media/vendor/fa7free/css/solid.css | 31 -
5 files changed, 21305 deletions(-)
delete mode 100644 src/media/vendor/fa7free/css/all.css
delete mode 100644 src/media/vendor/fa7free/css/brands.css
delete mode 100644 src/media/vendor/fa7free/css/fontawesome.css
delete mode 100644 src/media/vendor/fa7free/css/regular.css
delete mode 100644 src/media/vendor/fa7free/css/solid.css
diff --git a/src/media/vendor/fa7free/css/all.css b/src/media/vendor/fa7free/css/all.css
deleted file mode 100644
index 70bbfcc..0000000
--- a/src/media/vendor/fa7free/css/all.css
+++ /dev/null
@@ -1,10663 +0,0 @@
-/*!
- * Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
- * Copyright 2025 Fonticons, Inc.
- */
-.fa-solid,
-.fa-regular,
-.fa-brands,
-.fa-classic,
-.fas,
-.far,
-.fab,
-.fa {
- --_fa-family: var(--fa-family, var(--fa-style-family, "Font Awesome 7 Free"));
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- display: var(--fa-display, inline-block);
- font-family: var(--_fa-family);
- font-feature-settings: normal;
- font-style: normal;
- font-synthesis: none;
- font-variant: normal;
- font-weight: var(--fa-style, 900);
- line-height: 1;
- text-align: center;
- text-rendering: auto;
- width: var(--fa-width, 1.25em);
-}
-
-:is(.fas,
-.far,
-.fab,
-.fa-solid,
-.fa-regular,
-.fa-brands,
-.fa-classic,
-.fa)::before {
- content: var(--fa)/"";
-}
-
-@supports not (content: ""/"") {
- :is(.fas,
- .far,
- .fab,
- .fa-solid,
- .fa-regular,
- .fa-brands,
- .fa-classic,
- .fa)::before {
- content: var(--fa);
- }
-}
-.fa-1x {
- font-size: 1em;
-}
-
-.fa-2x {
- font-size: 2em;
-}
-
-.fa-3x {
- font-size: 3em;
-}
-
-.fa-4x {
- font-size: 4em;
-}
-
-.fa-5x {
- font-size: 5em;
-}
-
-.fa-6x {
- font-size: 6em;
-}
-
-.fa-7x {
- font-size: 7em;
-}
-
-.fa-8x {
- font-size: 8em;
-}
-
-.fa-9x {
- font-size: 9em;
-}
-
-.fa-10x {
- font-size: 10em;
-}
-
-.fa-2xs {
- font-size: calc(10 / 16 * 1em); /* converts a 10px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 10 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 10 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-xs {
- font-size: calc(12 / 16 * 1em); /* converts a 12px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 12 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 12 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-sm {
- font-size: calc(14 / 16 * 1em); /* converts a 14px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 14 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 14 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-lg {
- font-size: calc(20 / 16 * 1em); /* converts a 20px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 20 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 20 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-xl {
- font-size: calc(24 / 16 * 1em); /* converts a 24px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 24 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 24 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-2xl {
- font-size: calc(32 / 16 * 1em); /* converts a 32px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 32 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 32 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-width-auto {
- --fa-width: auto;
-}
-
-.fa-fw,
-.fa-width-fixed {
- --fa-width: 1.25em;
-}
-
-.fa-ul {
- list-style-type: none;
- margin-inline-start: var(--fa-li-margin, 2.5em);
- padding-inline-start: 0;
-}
-.fa-ul > li {
- position: relative;
-}
-
-.fa-li {
- inset-inline-start: calc(-1 * var(--fa-li-width, 2em));
- position: absolute;
- text-align: center;
- width: var(--fa-li-width, 2em);
- line-height: inherit;
-}
-
-/* Heads Up: Bordered Icons will not be supported in the future!
- - This feature will be deprecated in the next major release of Font Awesome (v8)!
- - You may continue to use it in this version *v7), but it will not be supported in Font Awesome v8.
-*/
-/* Notes:
-* --@{v.$css-prefix}-border-width = 1/16 by default (to render as ~1px based on a 16px default font-size)
-* --@{v.$css-prefix}-border-padding =
- ** 3/16 for vertical padding (to give ~2px of vertical whitespace around an icon considering it's vertical alignment)
- ** 4/16 for horizontal padding (to give ~4px of horizontal whitespace around an icon)
-*/
-.fa-border {
- border-color: var(--fa-border-color, #eee);
- border-radius: var(--fa-border-radius, 0.1em);
- border-style: var(--fa-border-style, solid);
- border-width: var(--fa-border-width, 0.0625em);
- box-sizing: var(--fa-border-box-sizing, content-box);
- padding: var(--fa-border-padding, 0.1875em 0.25em);
-}
-
-.fa-pull-left,
-.fa-pull-start {
- float: inline-start;
- margin-inline-end: var(--fa-pull-margin, 0.3em);
-}
-
-.fa-pull-right,
-.fa-pull-end {
- float: inline-end;
- margin-inline-start: var(--fa-pull-margin, 0.3em);
-}
-
-.fa-beat {
- animation-name: fa-beat;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, ease-in-out);
-}
-
-.fa-bounce {
- animation-name: fa-bounce;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1));
-}
-
-.fa-fade {
- animation-name: fa-fade;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
-}
-
-.fa-beat-fade {
- animation-name: fa-beat-fade;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
-}
-
-.fa-flip {
- animation-name: fa-flip;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, ease-in-out);
-}
-
-.fa-shake {
- animation-name: fa-shake;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, linear);
-}
-
-.fa-spin {
- animation-name: fa-spin;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 2s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, linear);
-}
-
-.fa-spin-reverse {
- --fa-animation-direction: reverse;
-}
-
-.fa-pulse,
-.fa-spin-pulse {
- animation-name: fa-spin;
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, steps(8));
-}
-
-@media (prefers-reduced-motion: reduce) {
- .fa-beat,
- .fa-bounce,
- .fa-fade,
- .fa-beat-fade,
- .fa-flip,
- .fa-pulse,
- .fa-shake,
- .fa-spin,
- .fa-spin-pulse {
- animation: none !important;
- transition: none !important;
- }
-}
-@keyframes fa-beat {
- 0%, 90% {
- transform: scale(1);
- }
- 45% {
- transform: scale(var(--fa-beat-scale, 1.25));
- }
-}
-@keyframes fa-bounce {
- 0% {
- transform: scale(1, 1) translateY(0);
- }
- 10% {
- transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
- }
- 30% {
- transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
- }
- 50% {
- transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
- }
- 57% {
- transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
- }
- 64% {
- transform: scale(1, 1) translateY(0);
- }
- 100% {
- transform: scale(1, 1) translateY(0);
- }
-}
-@keyframes fa-fade {
- 50% {
- opacity: var(--fa-fade-opacity, 0.4);
- }
-}
-@keyframes fa-beat-fade {
- 0%, 100% {
- opacity: var(--fa-beat-fade-opacity, 0.4);
- transform: scale(1);
- }
- 50% {
- opacity: 1;
- transform: scale(var(--fa-beat-fade-scale, 1.125));
- }
-}
-@keyframes fa-flip {
- 50% {
- transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
- }
-}
-@keyframes fa-shake {
- 0% {
- transform: rotate(-15deg);
- }
- 4% {
- transform: rotate(15deg);
- }
- 8%, 24% {
- transform: rotate(-18deg);
- }
- 12%, 28% {
- transform: rotate(18deg);
- }
- 16% {
- transform: rotate(-22deg);
- }
- 20% {
- transform: rotate(22deg);
- }
- 32% {
- transform: rotate(-12deg);
- }
- 36% {
- transform: rotate(12deg);
- }
- 40%, 100% {
- transform: rotate(0deg);
- }
-}
-@keyframes fa-spin {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
-}
-.fa-rotate-90 {
- transform: rotate(90deg);
-}
-
-.fa-rotate-180 {
- transform: rotate(180deg);
-}
-
-.fa-rotate-270 {
- transform: rotate(270deg);
-}
-
-.fa-flip-horizontal {
- transform: scale(-1, 1);
-}
-
-.fa-flip-vertical {
- transform: scale(1, -1);
-}
-
-.fa-flip-both,
-.fa-flip-horizontal.fa-flip-vertical {
- transform: scale(-1, -1);
-}
-
-.fa-rotate-by {
- transform: rotate(var(--fa-rotate-angle, 0));
-}
-
-.fa-stack {
- display: inline-block;
- height: 2em;
- line-height: 2em;
- position: relative;
- vertical-align: middle;
- width: 2.5em;
-}
-
-.fa-stack-1x,
-.fa-stack-2x {
- --fa-width: 100%;
- inset: 0;
- position: absolute;
- text-align: center;
- width: var(--fa-width);
- z-index: var(--fa-stack-z-index, auto);
-}
-
-.fa-stack-1x {
- line-height: inherit;
-}
-
-.fa-stack-2x {
- font-size: 2em;
-}
-
-.fa-inverse {
- color: var(--fa-inverse, #fff);
-}
-
-/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen
- readers do not read off random characters that represent icons */
-
-.fa-0 {
- --fa: "\30 ";
-}
-
-.fa-1 {
- --fa: "\31 ";
-}
-
-.fa-2 {
- --fa: "\32 ";
-}
-
-.fa-3 {
- --fa: "\33 ";
-}
-
-.fa-4 {
- --fa: "\34 ";
-}
-
-.fa-5 {
- --fa: "\35 ";
-}
-
-.fa-6 {
- --fa: "\36 ";
-}
-
-.fa-7 {
- --fa: "\37 ";
-}
-
-.fa-8 {
- --fa: "\38 ";
-}
-
-.fa-9 {
- --fa: "\39 ";
-}
-
-.fa-exclamation {
- --fa: "\!";
-}
-
-.fa-hashtag {
- --fa: "\#";
-}
-
-.fa-dollar-sign {
- --fa: "\$";
-}
-
-.fa-dollar {
- --fa: "\$";
-}
-
-.fa-usd {
- --fa: "\$";
-}
-
-.fa-percent {
- --fa: "\%";
-}
-
-.fa-percentage {
- --fa: "\%";
-}
-
-.fa-asterisk {
- --fa: "\*";
-}
-
-.fa-plus {
- --fa: "\+";
-}
-
-.fa-add {
- --fa: "\+";
-}
-
-.fa-less-than {
- --fa: "\<";
-}
-
-.fa-equals {
- --fa: "\=";
-}
-
-.fa-greater-than {
- --fa: "\>";
-}
-
-.fa-question {
- --fa: "\?";
-}
-
-.fa-at {
- --fa: "\@";
-}
-
-.fa-a {
- --fa: "A";
-}
-
-.fa-b {
- --fa: "B";
-}
-
-.fa-c {
- --fa: "C";
-}
-
-.fa-d {
- --fa: "D";
-}
-
-.fa-e {
- --fa: "E";
-}
-
-.fa-f {
- --fa: "F";
-}
-
-.fa-g {
- --fa: "G";
-}
-
-.fa-h {
- --fa: "H";
-}
-
-.fa-i {
- --fa: "I";
-}
-
-.fa-j {
- --fa: "J";
-}
-
-.fa-k {
- --fa: "K";
-}
-
-.fa-l {
- --fa: "L";
-}
-
-.fa-m {
- --fa: "M";
-}
-
-.fa-n {
- --fa: "N";
-}
-
-.fa-o {
- --fa: "O";
-}
-
-.fa-p {
- --fa: "P";
-}
-
-.fa-q {
- --fa: "Q";
-}
-
-.fa-r {
- --fa: "R";
-}
-
-.fa-s {
- --fa: "S";
-}
-
-.fa-t {
- --fa: "T";
-}
-
-.fa-u {
- --fa: "U";
-}
-
-.fa-v {
- --fa: "V";
-}
-
-.fa-w {
- --fa: "W";
-}
-
-.fa-x {
- --fa: "X";
-}
-
-.fa-y {
- --fa: "Y";
-}
-
-.fa-z {
- --fa: "Z";
-}
-
-.fa-faucet {
- --fa: "\e005";
-}
-
-.fa-faucet-drip {
- --fa: "\e006";
-}
-
-.fa-house-chimney-window {
- --fa: "\e00d";
-}
-
-.fa-house-signal {
- --fa: "\e012";
-}
-
-.fa-temperature-arrow-down {
- --fa: "\e03f";
-}
-
-.fa-temperature-down {
- --fa: "\e03f";
-}
-
-.fa-temperature-arrow-up {
- --fa: "\e040";
-}
-
-.fa-temperature-up {
- --fa: "\e040";
-}
-
-.fa-trailer {
- --fa: "\e041";
-}
-
-.fa-bacteria {
- --fa: "\e059";
-}
-
-.fa-bacterium {
- --fa: "\e05a";
-}
-
-.fa-box-tissue {
- --fa: "\e05b";
-}
-
-.fa-hand-holding-medical {
- --fa: "\e05c";
-}
-
-.fa-hand-sparkles {
- --fa: "\e05d";
-}
-
-.fa-hands-bubbles {
- --fa: "\e05e";
-}
-
-.fa-hands-wash {
- --fa: "\e05e";
-}
-
-.fa-handshake-slash {
- --fa: "\e060";
-}
-
-.fa-handshake-alt-slash {
- --fa: "\e060";
-}
-
-.fa-handshake-simple-slash {
- --fa: "\e060";
-}
-
-.fa-head-side-cough {
- --fa: "\e061";
-}
-
-.fa-head-side-cough-slash {
- --fa: "\e062";
-}
-
-.fa-head-side-mask {
- --fa: "\e063";
-}
-
-.fa-head-side-virus {
- --fa: "\e064";
-}
-
-.fa-house-chimney-user {
- --fa: "\e065";
-}
-
-.fa-house-laptop {
- --fa: "\e066";
-}
-
-.fa-laptop-house {
- --fa: "\e066";
-}
-
-.fa-lungs-virus {
- --fa: "\e067";
-}
-
-.fa-people-arrows {
- --fa: "\e068";
-}
-
-.fa-people-arrows-left-right {
- --fa: "\e068";
-}
-
-.fa-plane-slash {
- --fa: "\e069";
-}
-
-.fa-pump-medical {
- --fa: "\e06a";
-}
-
-.fa-pump-soap {
- --fa: "\e06b";
-}
-
-.fa-shield-virus {
- --fa: "\e06c";
-}
-
-.fa-sink {
- --fa: "\e06d";
-}
-
-.fa-soap {
- --fa: "\e06e";
-}
-
-.fa-stopwatch-20 {
- --fa: "\e06f";
-}
-
-.fa-shop-slash {
- --fa: "\e070";
-}
-
-.fa-store-alt-slash {
- --fa: "\e070";
-}
-
-.fa-store-slash {
- --fa: "\e071";
-}
-
-.fa-toilet-paper-slash {
- --fa: "\e072";
-}
-
-.fa-users-slash {
- --fa: "\e073";
-}
-
-.fa-virus {
- --fa: "\e074";
-}
-
-.fa-virus-slash {
- --fa: "\e075";
-}
-
-.fa-viruses {
- --fa: "\e076";
-}
-
-.fa-vest {
- --fa: "\e085";
-}
-
-.fa-vest-patches {
- --fa: "\e086";
-}
-
-.fa-arrow-trend-down {
- --fa: "\e097";
-}
-
-.fa-arrow-trend-up {
- --fa: "\e098";
-}
-
-.fa-arrow-up-from-bracket {
- --fa: "\e09a";
-}
-
-.fa-austral-sign {
- --fa: "\e0a9";
-}
-
-.fa-baht-sign {
- --fa: "\e0ac";
-}
-
-.fa-bitcoin-sign {
- --fa: "\e0b4";
-}
-
-.fa-bolt-lightning {
- --fa: "\e0b7";
-}
-
-.fa-book-bookmark {
- --fa: "\e0bb";
-}
-
-.fa-camera-rotate {
- --fa: "\e0d8";
-}
-
-.fa-cedi-sign {
- --fa: "\e0df";
-}
-
-.fa-chart-column {
- --fa: "\e0e3";
-}
-
-.fa-chart-gantt {
- --fa: "\e0e4";
-}
-
-.fa-clapperboard {
- --fa: "\e131";
-}
-
-.fa-clover {
- --fa: "\e139";
-}
-
-.fa-code-compare {
- --fa: "\e13a";
-}
-
-.fa-code-fork {
- --fa: "\e13b";
-}
-
-.fa-code-pull-request {
- --fa: "\e13c";
-}
-
-.fa-colon-sign {
- --fa: "\e140";
-}
-
-.fa-cruzeiro-sign {
- --fa: "\e152";
-}
-
-.fa-display {
- --fa: "\e163";
-}
-
-.fa-dong-sign {
- --fa: "\e169";
-}
-
-.fa-elevator {
- --fa: "\e16d";
-}
-
-.fa-filter-circle-xmark {
- --fa: "\e17b";
-}
-
-.fa-florin-sign {
- --fa: "\e184";
-}
-
-.fa-folder-closed {
- --fa: "\e185";
-}
-
-.fa-franc-sign {
- --fa: "\e18f";
-}
-
-.fa-guarani-sign {
- --fa: "\e19a";
-}
-
-.fa-gun {
- --fa: "\e19b";
-}
-
-.fa-hands-clapping {
- --fa: "\e1a8";
-}
-
-.fa-house-user {
- --fa: "\e1b0";
-}
-
-.fa-home-user {
- --fa: "\e1b0";
-}
-
-.fa-indian-rupee-sign {
- --fa: "\e1bc";
-}
-
-.fa-indian-rupee {
- --fa: "\e1bc";
-}
-
-.fa-inr {
- --fa: "\e1bc";
-}
-
-.fa-kip-sign {
- --fa: "\e1c4";
-}
-
-.fa-lari-sign {
- --fa: "\e1c8";
-}
-
-.fa-litecoin-sign {
- --fa: "\e1d3";
-}
-
-.fa-manat-sign {
- --fa: "\e1d5";
-}
-
-.fa-mask-face {
- --fa: "\e1d7";
-}
-
-.fa-mill-sign {
- --fa: "\e1ed";
-}
-
-.fa-money-bills {
- --fa: "\e1f3";
-}
-
-.fa-naira-sign {
- --fa: "\e1f6";
-}
-
-.fa-notdef {
- --fa: "\e1fe";
-}
-
-.fa-panorama {
- --fa: "\e209";
-}
-
-.fa-peseta-sign {
- --fa: "\e221";
-}
-
-.fa-peso-sign {
- --fa: "\e222";
-}
-
-.fa-plane-up {
- --fa: "\e22d";
-}
-
-.fa-rupiah-sign {
- --fa: "\e23d";
-}
-
-.fa-stairs {
- --fa: "\e289";
-}
-
-.fa-timeline {
- --fa: "\e29c";
-}
-
-.fa-truck-front {
- --fa: "\e2b7";
-}
-
-.fa-turkish-lira-sign {
- --fa: "\e2bb";
-}
-
-.fa-try {
- --fa: "\e2bb";
-}
-
-.fa-turkish-lira {
- --fa: "\e2bb";
-}
-
-.fa-vault {
- --fa: "\e2c5";
-}
-
-.fa-wand-magic-sparkles {
- --fa: "\e2ca";
-}
-
-.fa-magic-wand-sparkles {
- --fa: "\e2ca";
-}
-
-.fa-wheat-awn {
- --fa: "\e2cd";
-}
-
-.fa-wheat-alt {
- --fa: "\e2cd";
-}
-
-.fa-wheelchair-move {
- --fa: "\e2ce";
-}
-
-.fa-wheelchair-alt {
- --fa: "\e2ce";
-}
-
-.fa-bangladeshi-taka-sign {
- --fa: "\e2e6";
-}
-
-.fa-bowl-rice {
- --fa: "\e2eb";
-}
-
-.fa-person-pregnant {
- --fa: "\e31e";
-}
-
-.fa-house-chimney {
- --fa: "\e3af";
-}
-
-.fa-home-lg {
- --fa: "\e3af";
-}
-
-.fa-house-crack {
- --fa: "\e3b1";
-}
-
-.fa-house-medical {
- --fa: "\e3b2";
-}
-
-.fa-cent-sign {
- --fa: "\e3f5";
-}
-
-.fa-plus-minus {
- --fa: "\e43c";
-}
-
-.fa-sailboat {
- --fa: "\e445";
-}
-
-.fa-section {
- --fa: "\e447";
-}
-
-.fa-shrimp {
- --fa: "\e448";
-}
-
-.fa-brazilian-real-sign {
- --fa: "\e46c";
-}
-
-.fa-chart-simple {
- --fa: "\e473";
-}
-
-.fa-diagram-next {
- --fa: "\e476";
-}
-
-.fa-diagram-predecessor {
- --fa: "\e477";
-}
-
-.fa-diagram-successor {
- --fa: "\e47a";
-}
-
-.fa-earth-oceania {
- --fa: "\e47b";
-}
-
-.fa-globe-oceania {
- --fa: "\e47b";
-}
-
-.fa-bug-slash {
- --fa: "\e490";
-}
-
-.fa-file-circle-plus {
- --fa: "\e494";
-}
-
-.fa-shop-lock {
- --fa: "\e4a5";
-}
-
-.fa-virus-covid {
- --fa: "\e4a8";
-}
-
-.fa-virus-covid-slash {
- --fa: "\e4a9";
-}
-
-.fa-anchor-circle-check {
- --fa: "\e4aa";
-}
-
-.fa-anchor-circle-exclamation {
- --fa: "\e4ab";
-}
-
-.fa-anchor-circle-xmark {
- --fa: "\e4ac";
-}
-
-.fa-anchor-lock {
- --fa: "\e4ad";
-}
-
-.fa-arrow-down-up-across-line {
- --fa: "\e4af";
-}
-
-.fa-arrow-down-up-lock {
- --fa: "\e4b0";
-}
-
-.fa-arrow-right-to-city {
- --fa: "\e4b3";
-}
-
-.fa-arrow-up-from-ground-water {
- --fa: "\e4b5";
-}
-
-.fa-arrow-up-from-water-pump {
- --fa: "\e4b6";
-}
-
-.fa-arrow-up-right-dots {
- --fa: "\e4b7";
-}
-
-.fa-arrows-down-to-line {
- --fa: "\e4b8";
-}
-
-.fa-arrows-down-to-people {
- --fa: "\e4b9";
-}
-
-.fa-arrows-left-right-to-line {
- --fa: "\e4ba";
-}
-
-.fa-arrows-spin {
- --fa: "\e4bb";
-}
-
-.fa-arrows-split-up-and-left {
- --fa: "\e4bc";
-}
-
-.fa-arrows-to-circle {
- --fa: "\e4bd";
-}
-
-.fa-arrows-to-dot {
- --fa: "\e4be";
-}
-
-.fa-arrows-to-eye {
- --fa: "\e4bf";
-}
-
-.fa-arrows-turn-right {
- --fa: "\e4c0";
-}
-
-.fa-arrows-turn-to-dots {
- --fa: "\e4c1";
-}
-
-.fa-arrows-up-to-line {
- --fa: "\e4c2";
-}
-
-.fa-bore-hole {
- --fa: "\e4c3";
-}
-
-.fa-bottle-droplet {
- --fa: "\e4c4";
-}
-
-.fa-bottle-water {
- --fa: "\e4c5";
-}
-
-.fa-bowl-food {
- --fa: "\e4c6";
-}
-
-.fa-boxes-packing {
- --fa: "\e4c7";
-}
-
-.fa-bridge {
- --fa: "\e4c8";
-}
-
-.fa-bridge-circle-check {
- --fa: "\e4c9";
-}
-
-.fa-bridge-circle-exclamation {
- --fa: "\e4ca";
-}
-
-.fa-bridge-circle-xmark {
- --fa: "\e4cb";
-}
-
-.fa-bridge-lock {
- --fa: "\e4cc";
-}
-
-.fa-bridge-water {
- --fa: "\e4ce";
-}
-
-.fa-bucket {
- --fa: "\e4cf";
-}
-
-.fa-bugs {
- --fa: "\e4d0";
-}
-
-.fa-building-circle-arrow-right {
- --fa: "\e4d1";
-}
-
-.fa-building-circle-check {
- --fa: "\e4d2";
-}
-
-.fa-building-circle-exclamation {
- --fa: "\e4d3";
-}
-
-.fa-building-circle-xmark {
- --fa: "\e4d4";
-}
-
-.fa-building-flag {
- --fa: "\e4d5";
-}
-
-.fa-building-lock {
- --fa: "\e4d6";
-}
-
-.fa-building-ngo {
- --fa: "\e4d7";
-}
-
-.fa-building-shield {
- --fa: "\e4d8";
-}
-
-.fa-building-un {
- --fa: "\e4d9";
-}
-
-.fa-building-user {
- --fa: "\e4da";
-}
-
-.fa-building-wheat {
- --fa: "\e4db";
-}
-
-.fa-burst {
- --fa: "\e4dc";
-}
-
-.fa-car-on {
- --fa: "\e4dd";
-}
-
-.fa-car-tunnel {
- --fa: "\e4de";
-}
-
-.fa-child-combatant {
- --fa: "\e4e0";
-}
-
-.fa-child-rifle {
- --fa: "\e4e0";
-}
-
-.fa-children {
- --fa: "\e4e1";
-}
-
-.fa-circle-nodes {
- --fa: "\e4e2";
-}
-
-.fa-clipboard-question {
- --fa: "\e4e3";
-}
-
-.fa-cloud-showers-water {
- --fa: "\e4e4";
-}
-
-.fa-computer {
- --fa: "\e4e5";
-}
-
-.fa-cubes-stacked {
- --fa: "\e4e6";
-}
-
-.fa-envelope-circle-check {
- --fa: "\e4e8";
-}
-
-.fa-explosion {
- --fa: "\e4e9";
-}
-
-.fa-ferry {
- --fa: "\e4ea";
-}
-
-.fa-file-circle-exclamation {
- --fa: "\e4eb";
-}
-
-.fa-file-circle-minus {
- --fa: "\e4ed";
-}
-
-.fa-file-circle-question {
- --fa: "\e4ef";
-}
-
-.fa-file-shield {
- --fa: "\e4f0";
-}
-
-.fa-fire-burner {
- --fa: "\e4f1";
-}
-
-.fa-fish-fins {
- --fa: "\e4f2";
-}
-
-.fa-flask-vial {
- --fa: "\e4f3";
-}
-
-.fa-glass-water {
- --fa: "\e4f4";
-}
-
-.fa-glass-water-droplet {
- --fa: "\e4f5";
-}
-
-.fa-group-arrows-rotate {
- --fa: "\e4f6";
-}
-
-.fa-hand-holding-hand {
- --fa: "\e4f7";
-}
-
-.fa-handcuffs {
- --fa: "\e4f8";
-}
-
-.fa-hands-bound {
- --fa: "\e4f9";
-}
-
-.fa-hands-holding-child {
- --fa: "\e4fa";
-}
-
-.fa-hands-holding-circle {
- --fa: "\e4fb";
-}
-
-.fa-heart-circle-bolt {
- --fa: "\e4fc";
-}
-
-.fa-heart-circle-check {
- --fa: "\e4fd";
-}
-
-.fa-heart-circle-exclamation {
- --fa: "\e4fe";
-}
-
-.fa-heart-circle-minus {
- --fa: "\e4ff";
-}
-
-.fa-heart-circle-plus {
- --fa: "\e500";
-}
-
-.fa-heart-circle-xmark {
- --fa: "\e501";
-}
-
-.fa-helicopter-symbol {
- --fa: "\e502";
-}
-
-.fa-helmet-un {
- --fa: "\e503";
-}
-
-.fa-hill-avalanche {
- --fa: "\e507";
-}
-
-.fa-hill-rockslide {
- --fa: "\e508";
-}
-
-.fa-house-circle-check {
- --fa: "\e509";
-}
-
-.fa-house-circle-exclamation {
- --fa: "\e50a";
-}
-
-.fa-house-circle-xmark {
- --fa: "\e50b";
-}
-
-.fa-house-fire {
- --fa: "\e50c";
-}
-
-.fa-house-flag {
- --fa: "\e50d";
-}
-
-.fa-house-flood-water {
- --fa: "\e50e";
-}
-
-.fa-house-flood-water-circle-arrow-right {
- --fa: "\e50f";
-}
-
-.fa-house-lock {
- --fa: "\e510";
-}
-
-.fa-house-medical-circle-check {
- --fa: "\e511";
-}
-
-.fa-house-medical-circle-exclamation {
- --fa: "\e512";
-}
-
-.fa-house-medical-circle-xmark {
- --fa: "\e513";
-}
-
-.fa-house-medical-flag {
- --fa: "\e514";
-}
-
-.fa-house-tsunami {
- --fa: "\e515";
-}
-
-.fa-jar {
- --fa: "\e516";
-}
-
-.fa-jar-wheat {
- --fa: "\e517";
-}
-
-.fa-jet-fighter-up {
- --fa: "\e518";
-}
-
-.fa-jug-detergent {
- --fa: "\e519";
-}
-
-.fa-kitchen-set {
- --fa: "\e51a";
-}
-
-.fa-land-mine-on {
- --fa: "\e51b";
-}
-
-.fa-landmark-flag {
- --fa: "\e51c";
-}
-
-.fa-laptop-file {
- --fa: "\e51d";
-}
-
-.fa-lines-leaning {
- --fa: "\e51e";
-}
-
-.fa-location-pin-lock {
- --fa: "\e51f";
-}
-
-.fa-locust {
- --fa: "\e520";
-}
-
-.fa-magnifying-glass-arrow-right {
- --fa: "\e521";
-}
-
-.fa-magnifying-glass-chart {
- --fa: "\e522";
-}
-
-.fa-mars-and-venus-burst {
- --fa: "\e523";
-}
-
-.fa-mask-ventilator {
- --fa: "\e524";
-}
-
-.fa-mattress-pillow {
- --fa: "\e525";
-}
-
-.fa-mobile-retro {
- --fa: "\e527";
-}
-
-.fa-money-bill-transfer {
- --fa: "\e528";
-}
-
-.fa-money-bill-trend-up {
- --fa: "\e529";
-}
-
-.fa-money-bill-wheat {
- --fa: "\e52a";
-}
-
-.fa-mosquito {
- --fa: "\e52b";
-}
-
-.fa-mosquito-net {
- --fa: "\e52c";
-}
-
-.fa-mound {
- --fa: "\e52d";
-}
-
-.fa-mountain-city {
- --fa: "\e52e";
-}
-
-.fa-mountain-sun {
- --fa: "\e52f";
-}
-
-.fa-oil-well {
- --fa: "\e532";
-}
-
-.fa-people-group {
- --fa: "\e533";
-}
-
-.fa-people-line {
- --fa: "\e534";
-}
-
-.fa-people-pulling {
- --fa: "\e535";
-}
-
-.fa-people-robbery {
- --fa: "\e536";
-}
-
-.fa-people-roof {
- --fa: "\e537";
-}
-
-.fa-person-arrow-down-to-line {
- --fa: "\e538";
-}
-
-.fa-person-arrow-up-from-line {
- --fa: "\e539";
-}
-
-.fa-person-breastfeeding {
- --fa: "\e53a";
-}
-
-.fa-person-burst {
- --fa: "\e53b";
-}
-
-.fa-person-cane {
- --fa: "\e53c";
-}
-
-.fa-person-chalkboard {
- --fa: "\e53d";
-}
-
-.fa-person-circle-check {
- --fa: "\e53e";
-}
-
-.fa-person-circle-exclamation {
- --fa: "\e53f";
-}
-
-.fa-person-circle-minus {
- --fa: "\e540";
-}
-
-.fa-person-circle-plus {
- --fa: "\e541";
-}
-
-.fa-person-circle-question {
- --fa: "\e542";
-}
-
-.fa-person-circle-xmark {
- --fa: "\e543";
-}
-
-.fa-person-dress-burst {
- --fa: "\e544";
-}
-
-.fa-person-drowning {
- --fa: "\e545";
-}
-
-.fa-person-falling {
- --fa: "\e546";
-}
-
-.fa-person-falling-burst {
- --fa: "\e547";
-}
-
-.fa-person-half-dress {
- --fa: "\e548";
-}
-
-.fa-person-harassing {
- --fa: "\e549";
-}
-
-.fa-person-military-pointing {
- --fa: "\e54a";
-}
-
-.fa-person-military-rifle {
- --fa: "\e54b";
-}
-
-.fa-person-military-to-person {
- --fa: "\e54c";
-}
-
-.fa-person-rays {
- --fa: "\e54d";
-}
-
-.fa-person-rifle {
- --fa: "\e54e";
-}
-
-.fa-person-shelter {
- --fa: "\e54f";
-}
-
-.fa-person-walking-arrow-loop-left {
- --fa: "\e551";
-}
-
-.fa-person-walking-arrow-right {
- --fa: "\e552";
-}
-
-.fa-person-walking-dashed-line-arrow-right {
- --fa: "\e553";
-}
-
-.fa-person-walking-luggage {
- --fa: "\e554";
-}
-
-.fa-plane-circle-check {
- --fa: "\e555";
-}
-
-.fa-plane-circle-exclamation {
- --fa: "\e556";
-}
-
-.fa-plane-circle-xmark {
- --fa: "\e557";
-}
-
-.fa-plane-lock {
- --fa: "\e558";
-}
-
-.fa-plate-wheat {
- --fa: "\e55a";
-}
-
-.fa-plug-circle-bolt {
- --fa: "\e55b";
-}
-
-.fa-plug-circle-check {
- --fa: "\e55c";
-}
-
-.fa-plug-circle-exclamation {
- --fa: "\e55d";
-}
-
-.fa-plug-circle-minus {
- --fa: "\e55e";
-}
-
-.fa-plug-circle-plus {
- --fa: "\e55f";
-}
-
-.fa-plug-circle-xmark {
- --fa: "\e560";
-}
-
-.fa-ranking-star {
- --fa: "\e561";
-}
-
-.fa-road-barrier {
- --fa: "\e562";
-}
-
-.fa-road-bridge {
- --fa: "\e563";
-}
-
-.fa-road-circle-check {
- --fa: "\e564";
-}
-
-.fa-road-circle-exclamation {
- --fa: "\e565";
-}
-
-.fa-road-circle-xmark {
- --fa: "\e566";
-}
-
-.fa-road-lock {
- --fa: "\e567";
-}
-
-.fa-road-spikes {
- --fa: "\e568";
-}
-
-.fa-rug {
- --fa: "\e569";
-}
-
-.fa-sack-xmark {
- --fa: "\e56a";
-}
-
-.fa-school-circle-check {
- --fa: "\e56b";
-}
-
-.fa-school-circle-exclamation {
- --fa: "\e56c";
-}
-
-.fa-school-circle-xmark {
- --fa: "\e56d";
-}
-
-.fa-school-flag {
- --fa: "\e56e";
-}
-
-.fa-school-lock {
- --fa: "\e56f";
-}
-
-.fa-sheet-plastic {
- --fa: "\e571";
-}
-
-.fa-shield-cat {
- --fa: "\e572";
-}
-
-.fa-shield-dog {
- --fa: "\e573";
-}
-
-.fa-shield-heart {
- --fa: "\e574";
-}
-
-.fa-square-nfi {
- --fa: "\e576";
-}
-
-.fa-square-person-confined {
- --fa: "\e577";
-}
-
-.fa-square-virus {
- --fa: "\e578";
-}
-
-.fa-staff-snake {
- --fa: "\e579";
-}
-
-.fa-rod-asclepius {
- --fa: "\e579";
-}
-
-.fa-rod-snake {
- --fa: "\e579";
-}
-
-.fa-staff-aesculapius {
- --fa: "\e579";
-}
-
-.fa-sun-plant-wilt {
- --fa: "\e57a";
-}
-
-.fa-tarp {
- --fa: "\e57b";
-}
-
-.fa-tarp-droplet {
- --fa: "\e57c";
-}
-
-.fa-tent {
- --fa: "\e57d";
-}
-
-.fa-tent-arrow-down-to-line {
- --fa: "\e57e";
-}
-
-.fa-tent-arrow-left-right {
- --fa: "\e57f";
-}
-
-.fa-tent-arrow-turn-left {
- --fa: "\e580";
-}
-
-.fa-tent-arrows-down {
- --fa: "\e581";
-}
-
-.fa-tents {
- --fa: "\e582";
-}
-
-.fa-toilet-portable {
- --fa: "\e583";
-}
-
-.fa-toilets-portable {
- --fa: "\e584";
-}
-
-.fa-tower-cell {
- --fa: "\e585";
-}
-
-.fa-tower-observation {
- --fa: "\e586";
-}
-
-.fa-tree-city {
- --fa: "\e587";
-}
-
-.fa-trowel {
- --fa: "\e589";
-}
-
-.fa-trowel-bricks {
- --fa: "\e58a";
-}
-
-.fa-truck-arrow-right {
- --fa: "\e58b";
-}
-
-.fa-truck-droplet {
- --fa: "\e58c";
-}
-
-.fa-truck-field {
- --fa: "\e58d";
-}
-
-.fa-truck-field-un {
- --fa: "\e58e";
-}
-
-.fa-truck-plane {
- --fa: "\e58f";
-}
-
-.fa-users-between-lines {
- --fa: "\e591";
-}
-
-.fa-users-line {
- --fa: "\e592";
-}
-
-.fa-users-rays {
- --fa: "\e593";
-}
-
-.fa-users-rectangle {
- --fa: "\e594";
-}
-
-.fa-users-viewfinder {
- --fa: "\e595";
-}
-
-.fa-vial-circle-check {
- --fa: "\e596";
-}
-
-.fa-vial-virus {
- --fa: "\e597";
-}
-
-.fa-wheat-awn-circle-exclamation {
- --fa: "\e598";
-}
-
-.fa-worm {
- --fa: "\e599";
-}
-
-.fa-xmarks-lines {
- --fa: "\e59a";
-}
-
-.fa-child-dress {
- --fa: "\e59c";
-}
-
-.fa-child-reaching {
- --fa: "\e59d";
-}
-
-.fa-file-circle-check {
- --fa: "\e5a0";
-}
-
-.fa-file-circle-xmark {
- --fa: "\e5a1";
-}
-
-.fa-person-through-window {
- --fa: "\e5a9";
-}
-
-.fa-plant-wilt {
- --fa: "\e5aa";
-}
-
-.fa-stapler {
- --fa: "\e5af";
-}
-
-.fa-train-tram {
- --fa: "\e5b4";
-}
-
-.fa-table-cells-column-lock {
- --fa: "\e678";
-}
-
-.fa-table-cells-row-lock {
- --fa: "\e67a";
-}
-
-.fa-web-awesome {
- --fa: "\e682";
-}
-
-.fa-thumbtack-slash {
- --fa: "\e68f";
-}
-
-.fa-thumb-tack-slash {
- --fa: "\e68f";
-}
-
-.fa-table-cells-row-unlock {
- --fa: "\e691";
-}
-
-.fa-chart-diagram {
- --fa: "\e695";
-}
-
-.fa-comment-nodes {
- --fa: "\e696";
-}
-
-.fa-file-fragment {
- --fa: "\e697";
-}
-
-.fa-file-half-dashed {
- --fa: "\e698";
-}
-
-.fa-hexagon-nodes {
- --fa: "\e699";
-}
-
-.fa-hexagon-nodes-bolt {
- --fa: "\e69a";
-}
-
-.fa-square-binary {
- --fa: "\e69b";
-}
-
-.fa-pentagon {
- --fa: "\e790";
-}
-
-.fa-non-binary {
- --fa: "\e807";
-}
-
-.fa-spiral {
- --fa: "\e80a";
-}
-
-.fa-mobile-vibrate {
- --fa: "\e816";
-}
-
-.fa-single-quote-left {
- --fa: "\e81b";
-}
-
-.fa-single-quote-right {
- --fa: "\e81c";
-}
-
-.fa-bus-side {
- --fa: "\e81d";
-}
-
-.fa-septagon {
- --fa: "\e820";
-}
-
-.fa-heptagon {
- --fa: "\e820";
-}
-
-.fa-martini-glass-empty {
- --fa: "\f000";
-}
-
-.fa-glass-martini {
- --fa: "\f000";
-}
-
-.fa-music {
- --fa: "\f001";
-}
-
-.fa-magnifying-glass {
- --fa: "\f002";
-}
-
-.fa-search {
- --fa: "\f002";
-}
-
-.fa-heart {
- --fa: "\f004";
-}
-
-.fa-star {
- --fa: "\f005";
-}
-
-.fa-user {
- --fa: "\f007";
-}
-
-.fa-user-alt {
- --fa: "\f007";
-}
-
-.fa-user-large {
- --fa: "\f007";
-}
-
-.fa-film {
- --fa: "\f008";
-}
-
-.fa-film-alt {
- --fa: "\f008";
-}
-
-.fa-film-simple {
- --fa: "\f008";
-}
-
-.fa-table-cells-large {
- --fa: "\f009";
-}
-
-.fa-th-large {
- --fa: "\f009";
-}
-
-.fa-table-cells {
- --fa: "\f00a";
-}
-
-.fa-th {
- --fa: "\f00a";
-}
-
-.fa-table-list {
- --fa: "\f00b";
-}
-
-.fa-th-list {
- --fa: "\f00b";
-}
-
-.fa-check {
- --fa: "\f00c";
-}
-
-.fa-xmark {
- --fa: "\f00d";
-}
-
-.fa-close {
- --fa: "\f00d";
-}
-
-.fa-multiply {
- --fa: "\f00d";
-}
-
-.fa-remove {
- --fa: "\f00d";
-}
-
-.fa-times {
- --fa: "\f00d";
-}
-
-.fa-magnifying-glass-plus {
- --fa: "\f00e";
-}
-
-.fa-search-plus {
- --fa: "\f00e";
-}
-
-.fa-magnifying-glass-minus {
- --fa: "\f010";
-}
-
-.fa-search-minus {
- --fa: "\f010";
-}
-
-.fa-power-off {
- --fa: "\f011";
-}
-
-.fa-signal {
- --fa: "\f012";
-}
-
-.fa-signal-5 {
- --fa: "\f012";
-}
-
-.fa-signal-perfect {
- --fa: "\f012";
-}
-
-.fa-gear {
- --fa: "\f013";
-}
-
-.fa-cog {
- --fa: "\f013";
-}
-
-.fa-house {
- --fa: "\f015";
-}
-
-.fa-home {
- --fa: "\f015";
-}
-
-.fa-home-alt {
- --fa: "\f015";
-}
-
-.fa-home-lg-alt {
- --fa: "\f015";
-}
-
-.fa-clock {
- --fa: "\f017";
-}
-
-.fa-clock-four {
- --fa: "\f017";
-}
-
-.fa-road {
- --fa: "\f018";
-}
-
-.fa-download {
- --fa: "\f019";
-}
-
-.fa-inbox {
- --fa: "\f01c";
-}
-
-.fa-arrow-rotate-right {
- --fa: "\f01e";
-}
-
-.fa-arrow-right-rotate {
- --fa: "\f01e";
-}
-
-.fa-arrow-rotate-forward {
- --fa: "\f01e";
-}
-
-.fa-redo {
- --fa: "\f01e";
-}
-
-.fa-arrows-rotate {
- --fa: "\f021";
-}
-
-.fa-refresh {
- --fa: "\f021";
-}
-
-.fa-sync {
- --fa: "\f021";
-}
-
-.fa-rectangle-list {
- --fa: "\f022";
-}
-
-.fa-list-alt {
- --fa: "\f022";
-}
-
-.fa-lock {
- --fa: "\f023";
-}
-
-.fa-flag {
- --fa: "\f024";
-}
-
-.fa-headphones {
- --fa: "\f025";
-}
-
-.fa-headphones-alt {
- --fa: "\f025";
-}
-
-.fa-headphones-simple {
- --fa: "\f025";
-}
-
-.fa-volume-off {
- --fa: "\f026";
-}
-
-.fa-volume-low {
- --fa: "\f027";
-}
-
-.fa-volume-down {
- --fa: "\f027";
-}
-
-.fa-volume-high {
- --fa: "\f028";
-}
-
-.fa-volume-up {
- --fa: "\f028";
-}
-
-.fa-qrcode {
- --fa: "\f029";
-}
-
-.fa-barcode {
- --fa: "\f02a";
-}
-
-.fa-tag {
- --fa: "\f02b";
-}
-
-.fa-tags {
- --fa: "\f02c";
-}
-
-.fa-book {
- --fa: "\f02d";
-}
-
-.fa-bookmark {
- --fa: "\f02e";
-}
-
-.fa-print {
- --fa: "\f02f";
-}
-
-.fa-camera {
- --fa: "\f030";
-}
-
-.fa-camera-alt {
- --fa: "\f030";
-}
-
-.fa-font {
- --fa: "\f031";
-}
-
-.fa-bold {
- --fa: "\f032";
-}
-
-.fa-italic {
- --fa: "\f033";
-}
-
-.fa-text-height {
- --fa: "\f034";
-}
-
-.fa-text-width {
- --fa: "\f035";
-}
-
-.fa-align-left {
- --fa: "\f036";
-}
-
-.fa-align-center {
- --fa: "\f037";
-}
-
-.fa-align-right {
- --fa: "\f038";
-}
-
-.fa-align-justify {
- --fa: "\f039";
-}
-
-.fa-list {
- --fa: "\f03a";
-}
-
-.fa-list-squares {
- --fa: "\f03a";
-}
-
-.fa-outdent {
- --fa: "\f03b";
-}
-
-.fa-dedent {
- --fa: "\f03b";
-}
-
-.fa-indent {
- --fa: "\f03c";
-}
-
-.fa-video {
- --fa: "\f03d";
-}
-
-.fa-video-camera {
- --fa: "\f03d";
-}
-
-.fa-image {
- --fa: "\f03e";
-}
-
-.fa-location-pin {
- --fa: "\f041";
-}
-
-.fa-map-marker {
- --fa: "\f041";
-}
-
-.fa-circle-half-stroke {
- --fa: "\f042";
-}
-
-.fa-adjust {
- --fa: "\f042";
-}
-
-.fa-droplet {
- --fa: "\f043";
-}
-
-.fa-tint {
- --fa: "\f043";
-}
-
-.fa-pen-to-square {
- --fa: "\f044";
-}
-
-.fa-edit {
- --fa: "\f044";
-}
-
-.fa-arrows-up-down-left-right {
- --fa: "\f047";
-}
-
-.fa-arrows {
- --fa: "\f047";
-}
-
-.fa-backward-step {
- --fa: "\f048";
-}
-
-.fa-step-backward {
- --fa: "\f048";
-}
-
-.fa-backward-fast {
- --fa: "\f049";
-}
-
-.fa-fast-backward {
- --fa: "\f049";
-}
-
-.fa-backward {
- --fa: "\f04a";
-}
-
-.fa-play {
- --fa: "\f04b";
-}
-
-.fa-pause {
- --fa: "\f04c";
-}
-
-.fa-stop {
- --fa: "\f04d";
-}
-
-.fa-forward {
- --fa: "\f04e";
-}
-
-.fa-forward-fast {
- --fa: "\f050";
-}
-
-.fa-fast-forward {
- --fa: "\f050";
-}
-
-.fa-forward-step {
- --fa: "\f051";
-}
-
-.fa-step-forward {
- --fa: "\f051";
-}
-
-.fa-eject {
- --fa: "\f052";
-}
-
-.fa-chevron-left {
- --fa: "\f053";
-}
-
-.fa-chevron-right {
- --fa: "\f054";
-}
-
-.fa-circle-plus {
- --fa: "\f055";
-}
-
-.fa-plus-circle {
- --fa: "\f055";
-}
-
-.fa-circle-minus {
- --fa: "\f056";
-}
-
-.fa-minus-circle {
- --fa: "\f056";
-}
-
-.fa-circle-xmark {
- --fa: "\f057";
-}
-
-.fa-times-circle {
- --fa: "\f057";
-}
-
-.fa-xmark-circle {
- --fa: "\f057";
-}
-
-.fa-circle-check {
- --fa: "\f058";
-}
-
-.fa-check-circle {
- --fa: "\f058";
-}
-
-.fa-circle-question {
- --fa: "\f059";
-}
-
-.fa-question-circle {
- --fa: "\f059";
-}
-
-.fa-circle-info {
- --fa: "\f05a";
-}
-
-.fa-info-circle {
- --fa: "\f05a";
-}
-
-.fa-crosshairs {
- --fa: "\f05b";
-}
-
-.fa-ban {
- --fa: "\f05e";
-}
-
-.fa-cancel {
- --fa: "\f05e";
-}
-
-.fa-arrow-left {
- --fa: "\f060";
-}
-
-.fa-arrow-right {
- --fa: "\f061";
-}
-
-.fa-arrow-up {
- --fa: "\f062";
-}
-
-.fa-arrow-down {
- --fa: "\f063";
-}
-
-.fa-share {
- --fa: "\f064";
-}
-
-.fa-mail-forward {
- --fa: "\f064";
-}
-
-.fa-expand {
- --fa: "\f065";
-}
-
-.fa-compress {
- --fa: "\f066";
-}
-
-.fa-minus {
- --fa: "\f068";
-}
-
-.fa-subtract {
- --fa: "\f068";
-}
-
-.fa-circle-exclamation {
- --fa: "\f06a";
-}
-
-.fa-exclamation-circle {
- --fa: "\f06a";
-}
-
-.fa-gift {
- --fa: "\f06b";
-}
-
-.fa-leaf {
- --fa: "\f06c";
-}
-
-.fa-fire {
- --fa: "\f06d";
-}
-
-.fa-eye {
- --fa: "\f06e";
-}
-
-.fa-eye-slash {
- --fa: "\f070";
-}
-
-.fa-triangle-exclamation {
- --fa: "\f071";
-}
-
-.fa-exclamation-triangle {
- --fa: "\f071";
-}
-
-.fa-warning {
- --fa: "\f071";
-}
-
-.fa-plane {
- --fa: "\f072";
-}
-
-.fa-calendar-days {
- --fa: "\f073";
-}
-
-.fa-calendar-alt {
- --fa: "\f073";
-}
-
-.fa-shuffle {
- --fa: "\f074";
-}
-
-.fa-random {
- --fa: "\f074";
-}
-
-.fa-comment {
- --fa: "\f075";
-}
-
-.fa-magnet {
- --fa: "\f076";
-}
-
-.fa-chevron-up {
- --fa: "\f077";
-}
-
-.fa-chevron-down {
- --fa: "\f078";
-}
-
-.fa-retweet {
- --fa: "\f079";
-}
-
-.fa-cart-shopping {
- --fa: "\f07a";
-}
-
-.fa-shopping-cart {
- --fa: "\f07a";
-}
-
-.fa-folder {
- --fa: "\f07b";
-}
-
-.fa-folder-blank {
- --fa: "\f07b";
-}
-
-.fa-folder-open {
- --fa: "\f07c";
-}
-
-.fa-arrows-up-down {
- --fa: "\f07d";
-}
-
-.fa-arrows-v {
- --fa: "\f07d";
-}
-
-.fa-arrows-left-right {
- --fa: "\f07e";
-}
-
-.fa-arrows-h {
- --fa: "\f07e";
-}
-
-.fa-chart-bar {
- --fa: "\f080";
-}
-
-.fa-bar-chart {
- --fa: "\f080";
-}
-
-.fa-camera-retro {
- --fa: "\f083";
-}
-
-.fa-key {
- --fa: "\f084";
-}
-
-.fa-gears {
- --fa: "\f085";
-}
-
-.fa-cogs {
- --fa: "\f085";
-}
-
-.fa-comments {
- --fa: "\f086";
-}
-
-.fa-star-half {
- --fa: "\f089";
-}
-
-.fa-arrow-right-from-bracket {
- --fa: "\f08b";
-}
-
-.fa-sign-out {
- --fa: "\f08b";
-}
-
-.fa-thumbtack {
- --fa: "\f08d";
-}
-
-.fa-thumb-tack {
- --fa: "\f08d";
-}
-
-.fa-arrow-up-right-from-square {
- --fa: "\f08e";
-}
-
-.fa-external-link {
- --fa: "\f08e";
-}
-
-.fa-arrow-right-to-bracket {
- --fa: "\f090";
-}
-
-.fa-sign-in {
- --fa: "\f090";
-}
-
-.fa-trophy {
- --fa: "\f091";
-}
-
-.fa-upload {
- --fa: "\f093";
-}
-
-.fa-lemon {
- --fa: "\f094";
-}
-
-.fa-phone {
- --fa: "\f095";
-}
-
-.fa-square-phone {
- --fa: "\f098";
-}
-
-.fa-phone-square {
- --fa: "\f098";
-}
-
-.fa-unlock {
- --fa: "\f09c";
-}
-
-.fa-credit-card {
- --fa: "\f09d";
-}
-
-.fa-credit-card-alt {
- --fa: "\f09d";
-}
-
-.fa-rss {
- --fa: "\f09e";
-}
-
-.fa-feed {
- --fa: "\f09e";
-}
-
-.fa-hard-drive {
- --fa: "\f0a0";
-}
-
-.fa-hdd {
- --fa: "\f0a0";
-}
-
-.fa-bullhorn {
- --fa: "\f0a1";
-}
-
-.fa-certificate {
- --fa: "\f0a3";
-}
-
-.fa-hand-point-right {
- --fa: "\f0a4";
-}
-
-.fa-hand-point-left {
- --fa: "\f0a5";
-}
-
-.fa-hand-point-up {
- --fa: "\f0a6";
-}
-
-.fa-hand-point-down {
- --fa: "\f0a7";
-}
-
-.fa-circle-arrow-left {
- --fa: "\f0a8";
-}
-
-.fa-arrow-circle-left {
- --fa: "\f0a8";
-}
-
-.fa-circle-arrow-right {
- --fa: "\f0a9";
-}
-
-.fa-arrow-circle-right {
- --fa: "\f0a9";
-}
-
-.fa-circle-arrow-up {
- --fa: "\f0aa";
-}
-
-.fa-arrow-circle-up {
- --fa: "\f0aa";
-}
-
-.fa-circle-arrow-down {
- --fa: "\f0ab";
-}
-
-.fa-arrow-circle-down {
- --fa: "\f0ab";
-}
-
-.fa-globe {
- --fa: "\f0ac";
-}
-
-.fa-wrench {
- --fa: "\f0ad";
-}
-
-.fa-list-check {
- --fa: "\f0ae";
-}
-
-.fa-tasks {
- --fa: "\f0ae";
-}
-
-.fa-filter {
- --fa: "\f0b0";
-}
-
-.fa-briefcase {
- --fa: "\f0b1";
-}
-
-.fa-up-down-left-right {
- --fa: "\f0b2";
-}
-
-.fa-arrows-alt {
- --fa: "\f0b2";
-}
-
-.fa-users {
- --fa: "\f0c0";
-}
-
-.fa-link {
- --fa: "\f0c1";
-}
-
-.fa-chain {
- --fa: "\f0c1";
-}
-
-.fa-cloud {
- --fa: "\f0c2";
-}
-
-.fa-flask {
- --fa: "\f0c3";
-}
-
-.fa-scissors {
- --fa: "\f0c4";
-}
-
-.fa-cut {
- --fa: "\f0c4";
-}
-
-.fa-copy {
- --fa: "\f0c5";
-}
-
-.fa-paperclip {
- --fa: "\f0c6";
-}
-
-.fa-floppy-disk {
- --fa: "\f0c7";
-}
-
-.fa-save {
- --fa: "\f0c7";
-}
-
-.fa-square {
- --fa: "\f0c8";
-}
-
-.fa-bars {
- --fa: "\f0c9";
-}
-
-.fa-navicon {
- --fa: "\f0c9";
-}
-
-.fa-list-ul {
- --fa: "\f0ca";
-}
-
-.fa-list-dots {
- --fa: "\f0ca";
-}
-
-.fa-list-ol {
- --fa: "\f0cb";
-}
-
-.fa-list-1-2 {
- --fa: "\f0cb";
-}
-
-.fa-list-numeric {
- --fa: "\f0cb";
-}
-
-.fa-strikethrough {
- --fa: "\f0cc";
-}
-
-.fa-underline {
- --fa: "\f0cd";
-}
-
-.fa-table {
- --fa: "\f0ce";
-}
-
-.fa-wand-magic {
- --fa: "\f0d0";
-}
-
-.fa-magic {
- --fa: "\f0d0";
-}
-
-.fa-truck {
- --fa: "\f0d1";
-}
-
-.fa-money-bill {
- --fa: "\f0d6";
-}
-
-.fa-caret-down {
- --fa: "\f0d7";
-}
-
-.fa-caret-up {
- --fa: "\f0d8";
-}
-
-.fa-caret-left {
- --fa: "\f0d9";
-}
-
-.fa-caret-right {
- --fa: "\f0da";
-}
-
-.fa-table-columns {
- --fa: "\f0db";
-}
-
-.fa-columns {
- --fa: "\f0db";
-}
-
-.fa-sort {
- --fa: "\f0dc";
-}
-
-.fa-unsorted {
- --fa: "\f0dc";
-}
-
-.fa-sort-down {
- --fa: "\f0dd";
-}
-
-.fa-sort-desc {
- --fa: "\f0dd";
-}
-
-.fa-sort-up {
- --fa: "\f0de";
-}
-
-.fa-sort-asc {
- --fa: "\f0de";
-}
-
-.fa-envelope {
- --fa: "\f0e0";
-}
-
-.fa-arrow-rotate-left {
- --fa: "\f0e2";
-}
-
-.fa-arrow-left-rotate {
- --fa: "\f0e2";
-}
-
-.fa-arrow-rotate-back {
- --fa: "\f0e2";
-}
-
-.fa-arrow-rotate-backward {
- --fa: "\f0e2";
-}
-
-.fa-undo {
- --fa: "\f0e2";
-}
-
-.fa-gavel {
- --fa: "\f0e3";
-}
-
-.fa-legal {
- --fa: "\f0e3";
-}
-
-.fa-bolt {
- --fa: "\f0e7";
-}
-
-.fa-zap {
- --fa: "\f0e7";
-}
-
-.fa-sitemap {
- --fa: "\f0e8";
-}
-
-.fa-umbrella {
- --fa: "\f0e9";
-}
-
-.fa-paste {
- --fa: "\f0ea";
-}
-
-.fa-file-clipboard {
- --fa: "\f0ea";
-}
-
-.fa-lightbulb {
- --fa: "\f0eb";
-}
-
-.fa-arrow-right-arrow-left {
- --fa: "\f0ec";
-}
-
-.fa-exchange {
- --fa: "\f0ec";
-}
-
-.fa-cloud-arrow-down {
- --fa: "\f0ed";
-}
-
-.fa-cloud-download {
- --fa: "\f0ed";
-}
-
-.fa-cloud-download-alt {
- --fa: "\f0ed";
-}
-
-.fa-cloud-arrow-up {
- --fa: "\f0ee";
-}
-
-.fa-cloud-upload {
- --fa: "\f0ee";
-}
-
-.fa-cloud-upload-alt {
- --fa: "\f0ee";
-}
-
-.fa-user-doctor {
- --fa: "\f0f0";
-}
-
-.fa-user-md {
- --fa: "\f0f0";
-}
-
-.fa-stethoscope {
- --fa: "\f0f1";
-}
-
-.fa-suitcase {
- --fa: "\f0f2";
-}
-
-.fa-bell {
- --fa: "\f0f3";
-}
-
-.fa-mug-saucer {
- --fa: "\f0f4";
-}
-
-.fa-coffee {
- --fa: "\f0f4";
-}
-
-.fa-hospital {
- --fa: "\f0f8";
-}
-
-.fa-hospital-alt {
- --fa: "\f0f8";
-}
-
-.fa-hospital-wide {
- --fa: "\f0f8";
-}
-
-.fa-truck-medical {
- --fa: "\f0f9";
-}
-
-.fa-ambulance {
- --fa: "\f0f9";
-}
-
-.fa-suitcase-medical {
- --fa: "\f0fa";
-}
-
-.fa-medkit {
- --fa: "\f0fa";
-}
-
-.fa-jet-fighter {
- --fa: "\f0fb";
-}
-
-.fa-fighter-jet {
- --fa: "\f0fb";
-}
-
-.fa-beer-mug-empty {
- --fa: "\f0fc";
-}
-
-.fa-beer {
- --fa: "\f0fc";
-}
-
-.fa-square-h {
- --fa: "\f0fd";
-}
-
-.fa-h-square {
- --fa: "\f0fd";
-}
-
-.fa-square-plus {
- --fa: "\f0fe";
-}
-
-.fa-plus-square {
- --fa: "\f0fe";
-}
-
-.fa-angles-left {
- --fa: "\f100";
-}
-
-.fa-angle-double-left {
- --fa: "\f100";
-}
-
-.fa-angles-right {
- --fa: "\f101";
-}
-
-.fa-angle-double-right {
- --fa: "\f101";
-}
-
-.fa-angles-up {
- --fa: "\f102";
-}
-
-.fa-angle-double-up {
- --fa: "\f102";
-}
-
-.fa-angles-down {
- --fa: "\f103";
-}
-
-.fa-angle-double-down {
- --fa: "\f103";
-}
-
-.fa-angle-left {
- --fa: "\f104";
-}
-
-.fa-angle-right {
- --fa: "\f105";
-}
-
-.fa-angle-up {
- --fa: "\f106";
-}
-
-.fa-angle-down {
- --fa: "\f107";
-}
-
-.fa-laptop {
- --fa: "\f109";
-}
-
-.fa-tablet-button {
- --fa: "\f10a";
-}
-
-.fa-mobile-button {
- --fa: "\f10b";
-}
-
-.fa-quote-left {
- --fa: "\f10d";
-}
-
-.fa-quote-left-alt {
- --fa: "\f10d";
-}
-
-.fa-quote-right {
- --fa: "\f10e";
-}
-
-.fa-quote-right-alt {
- --fa: "\f10e";
-}
-
-.fa-spinner {
- --fa: "\f110";
-}
-
-.fa-circle {
- --fa: "\f111";
-}
-
-.fa-face-smile {
- --fa: "\f118";
-}
-
-.fa-smile {
- --fa: "\f118";
-}
-
-.fa-face-frown {
- --fa: "\f119";
-}
-
-.fa-frown {
- --fa: "\f119";
-}
-
-.fa-face-meh {
- --fa: "\f11a";
-}
-
-.fa-meh {
- --fa: "\f11a";
-}
-
-.fa-gamepad {
- --fa: "\f11b";
-}
-
-.fa-keyboard {
- --fa: "\f11c";
-}
-
-.fa-flag-checkered {
- --fa: "\f11e";
-}
-
-.fa-terminal {
- --fa: "\f120";
-}
-
-.fa-code {
- --fa: "\f121";
-}
-
-.fa-reply-all {
- --fa: "\f122";
-}
-
-.fa-mail-reply-all {
- --fa: "\f122";
-}
-
-.fa-location-arrow {
- --fa: "\f124";
-}
-
-.fa-crop {
- --fa: "\f125";
-}
-
-.fa-code-branch {
- --fa: "\f126";
-}
-
-.fa-link-slash {
- --fa: "\f127";
-}
-
-.fa-chain-broken {
- --fa: "\f127";
-}
-
-.fa-chain-slash {
- --fa: "\f127";
-}
-
-.fa-unlink {
- --fa: "\f127";
-}
-
-.fa-info {
- --fa: "\f129";
-}
-
-.fa-superscript {
- --fa: "\f12b";
-}
-
-.fa-subscript {
- --fa: "\f12c";
-}
-
-.fa-eraser {
- --fa: "\f12d";
-}
-
-.fa-puzzle-piece {
- --fa: "\f12e";
-}
-
-.fa-microphone {
- --fa: "\f130";
-}
-
-.fa-microphone-slash {
- --fa: "\f131";
-}
-
-.fa-shield {
- --fa: "\f132";
-}
-
-.fa-shield-blank {
- --fa: "\f132";
-}
-
-.fa-calendar {
- --fa: "\f133";
-}
-
-.fa-fire-extinguisher {
- --fa: "\f134";
-}
-
-.fa-rocket {
- --fa: "\f135";
-}
-
-.fa-circle-chevron-left {
- --fa: "\f137";
-}
-
-.fa-chevron-circle-left {
- --fa: "\f137";
-}
-
-.fa-circle-chevron-right {
- --fa: "\f138";
-}
-
-.fa-chevron-circle-right {
- --fa: "\f138";
-}
-
-.fa-circle-chevron-up {
- --fa: "\f139";
-}
-
-.fa-chevron-circle-up {
- --fa: "\f139";
-}
-
-.fa-circle-chevron-down {
- --fa: "\f13a";
-}
-
-.fa-chevron-circle-down {
- --fa: "\f13a";
-}
-
-.fa-anchor {
- --fa: "\f13d";
-}
-
-.fa-unlock-keyhole {
- --fa: "\f13e";
-}
-
-.fa-unlock-alt {
- --fa: "\f13e";
-}
-
-.fa-bullseye {
- --fa: "\f140";
-}
-
-.fa-ellipsis {
- --fa: "\f141";
-}
-
-.fa-ellipsis-h {
- --fa: "\f141";
-}
-
-.fa-ellipsis-vertical {
- --fa: "\f142";
-}
-
-.fa-ellipsis-v {
- --fa: "\f142";
-}
-
-.fa-square-rss {
- --fa: "\f143";
-}
-
-.fa-rss-square {
- --fa: "\f143";
-}
-
-.fa-circle-play {
- --fa: "\f144";
-}
-
-.fa-play-circle {
- --fa: "\f144";
-}
-
-.fa-ticket {
- --fa: "\f145";
-}
-
-.fa-square-minus {
- --fa: "\f146";
-}
-
-.fa-minus-square {
- --fa: "\f146";
-}
-
-.fa-arrow-turn-up {
- --fa: "\f148";
-}
-
-.fa-level-up {
- --fa: "\f148";
-}
-
-.fa-arrow-turn-down {
- --fa: "\f149";
-}
-
-.fa-level-down {
- --fa: "\f149";
-}
-
-.fa-square-check {
- --fa: "\f14a";
-}
-
-.fa-check-square {
- --fa: "\f14a";
-}
-
-.fa-square-pen {
- --fa: "\f14b";
-}
-
-.fa-pen-square {
- --fa: "\f14b";
-}
-
-.fa-pencil-square {
- --fa: "\f14b";
-}
-
-.fa-square-arrow-up-right {
- --fa: "\f14c";
-}
-
-.fa-external-link-square {
- --fa: "\f14c";
-}
-
-.fa-share-from-square {
- --fa: "\f14d";
-}
-
-.fa-share-square {
- --fa: "\f14d";
-}
-
-.fa-compass {
- --fa: "\f14e";
-}
-
-.fa-square-caret-down {
- --fa: "\f150";
-}
-
-.fa-caret-square-down {
- --fa: "\f150";
-}
-
-.fa-square-caret-up {
- --fa: "\f151";
-}
-
-.fa-caret-square-up {
- --fa: "\f151";
-}
-
-.fa-square-caret-right {
- --fa: "\f152";
-}
-
-.fa-caret-square-right {
- --fa: "\f152";
-}
-
-.fa-euro-sign {
- --fa: "\f153";
-}
-
-.fa-eur {
- --fa: "\f153";
-}
-
-.fa-euro {
- --fa: "\f153";
-}
-
-.fa-sterling-sign {
- --fa: "\f154";
-}
-
-.fa-gbp {
- --fa: "\f154";
-}
-
-.fa-pound-sign {
- --fa: "\f154";
-}
-
-.fa-rupee-sign {
- --fa: "\f156";
-}
-
-.fa-rupee {
- --fa: "\f156";
-}
-
-.fa-yen-sign {
- --fa: "\f157";
-}
-
-.fa-cny {
- --fa: "\f157";
-}
-
-.fa-jpy {
- --fa: "\f157";
-}
-
-.fa-rmb {
- --fa: "\f157";
-}
-
-.fa-yen {
- --fa: "\f157";
-}
-
-.fa-ruble-sign {
- --fa: "\f158";
-}
-
-.fa-rouble {
- --fa: "\f158";
-}
-
-.fa-rub {
- --fa: "\f158";
-}
-
-.fa-ruble {
- --fa: "\f158";
-}
-
-.fa-won-sign {
- --fa: "\f159";
-}
-
-.fa-krw {
- --fa: "\f159";
-}
-
-.fa-won {
- --fa: "\f159";
-}
-
-.fa-file {
- --fa: "\f15b";
-}
-
-.fa-file-lines {
- --fa: "\f15c";
-}
-
-.fa-file-alt {
- --fa: "\f15c";
-}
-
-.fa-file-text {
- --fa: "\f15c";
-}
-
-.fa-arrow-down-a-z {
- --fa: "\f15d";
-}
-
-.fa-sort-alpha-asc {
- --fa: "\f15d";
-}
-
-.fa-sort-alpha-down {
- --fa: "\f15d";
-}
-
-.fa-arrow-up-a-z {
- --fa: "\f15e";
-}
-
-.fa-sort-alpha-up {
- --fa: "\f15e";
-}
-
-.fa-arrow-down-wide-short {
- --fa: "\f160";
-}
-
-.fa-sort-amount-asc {
- --fa: "\f160";
-}
-
-.fa-sort-amount-down {
- --fa: "\f160";
-}
-
-.fa-arrow-up-wide-short {
- --fa: "\f161";
-}
-
-.fa-sort-amount-up {
- --fa: "\f161";
-}
-
-.fa-arrow-down-1-9 {
- --fa: "\f162";
-}
-
-.fa-sort-numeric-asc {
- --fa: "\f162";
-}
-
-.fa-sort-numeric-down {
- --fa: "\f162";
-}
-
-.fa-arrow-up-1-9 {
- --fa: "\f163";
-}
-
-.fa-sort-numeric-up {
- --fa: "\f163";
-}
-
-.fa-thumbs-up {
- --fa: "\f164";
-}
-
-.fa-thumbs-down {
- --fa: "\f165";
-}
-
-.fa-arrow-down-long {
- --fa: "\f175";
-}
-
-.fa-long-arrow-down {
- --fa: "\f175";
-}
-
-.fa-arrow-up-long {
- --fa: "\f176";
-}
-
-.fa-long-arrow-up {
- --fa: "\f176";
-}
-
-.fa-arrow-left-long {
- --fa: "\f177";
-}
-
-.fa-long-arrow-left {
- --fa: "\f177";
-}
-
-.fa-arrow-right-long {
- --fa: "\f178";
-}
-
-.fa-long-arrow-right {
- --fa: "\f178";
-}
-
-.fa-person-dress {
- --fa: "\f182";
-}
-
-.fa-female {
- --fa: "\f182";
-}
-
-.fa-person {
- --fa: "\f183";
-}
-
-.fa-male {
- --fa: "\f183";
-}
-
-.fa-sun {
- --fa: "\f185";
-}
-
-.fa-moon {
- --fa: "\f186";
-}
-
-.fa-box-archive {
- --fa: "\f187";
-}
-
-.fa-archive {
- --fa: "\f187";
-}
-
-.fa-bug {
- --fa: "\f188";
-}
-
-.fa-square-caret-left {
- --fa: "\f191";
-}
-
-.fa-caret-square-left {
- --fa: "\f191";
-}
-
-.fa-circle-dot {
- --fa: "\f192";
-}
-
-.fa-dot-circle {
- --fa: "\f192";
-}
-
-.fa-wheelchair {
- --fa: "\f193";
-}
-
-.fa-lira-sign {
- --fa: "\f195";
-}
-
-.fa-shuttle-space {
- --fa: "\f197";
-}
-
-.fa-space-shuttle {
- --fa: "\f197";
-}
-
-.fa-square-envelope {
- --fa: "\f199";
-}
-
-.fa-envelope-square {
- --fa: "\f199";
-}
-
-.fa-building-columns {
- --fa: "\f19c";
-}
-
-.fa-bank {
- --fa: "\f19c";
-}
-
-.fa-institution {
- --fa: "\f19c";
-}
-
-.fa-museum {
- --fa: "\f19c";
-}
-
-.fa-university {
- --fa: "\f19c";
-}
-
-.fa-graduation-cap {
- --fa: "\f19d";
-}
-
-.fa-mortar-board {
- --fa: "\f19d";
-}
-
-.fa-language {
- --fa: "\f1ab";
-}
-
-.fa-fax {
- --fa: "\f1ac";
-}
-
-.fa-building {
- --fa: "\f1ad";
-}
-
-.fa-child {
- --fa: "\f1ae";
-}
-
-.fa-paw {
- --fa: "\f1b0";
-}
-
-.fa-cube {
- --fa: "\f1b2";
-}
-
-.fa-cubes {
- --fa: "\f1b3";
-}
-
-.fa-recycle {
- --fa: "\f1b8";
-}
-
-.fa-car {
- --fa: "\f1b9";
-}
-
-.fa-automobile {
- --fa: "\f1b9";
-}
-
-.fa-taxi {
- --fa: "\f1ba";
-}
-
-.fa-cab {
- --fa: "\f1ba";
-}
-
-.fa-tree {
- --fa: "\f1bb";
-}
-
-.fa-database {
- --fa: "\f1c0";
-}
-
-.fa-file-pdf {
- --fa: "\f1c1";
-}
-
-.fa-file-word {
- --fa: "\f1c2";
-}
-
-.fa-file-excel {
- --fa: "\f1c3";
-}
-
-.fa-file-powerpoint {
- --fa: "\f1c4";
-}
-
-.fa-file-image {
- --fa: "\f1c5";
-}
-
-.fa-file-zipper {
- --fa: "\f1c6";
-}
-
-.fa-file-archive {
- --fa: "\f1c6";
-}
-
-.fa-file-audio {
- --fa: "\f1c7";
-}
-
-.fa-file-video {
- --fa: "\f1c8";
-}
-
-.fa-file-code {
- --fa: "\f1c9";
-}
-
-.fa-life-ring {
- --fa: "\f1cd";
-}
-
-.fa-circle-notch {
- --fa: "\f1ce";
-}
-
-.fa-paper-plane {
- --fa: "\f1d8";
-}
-
-.fa-clock-rotate-left {
- --fa: "\f1da";
-}
-
-.fa-history {
- --fa: "\f1da";
-}
-
-.fa-heading {
- --fa: "\f1dc";
-}
-
-.fa-header {
- --fa: "\f1dc";
-}
-
-.fa-paragraph {
- --fa: "\f1dd";
-}
-
-.fa-sliders {
- --fa: "\f1de";
-}
-
-.fa-sliders-h {
- --fa: "\f1de";
-}
-
-.fa-share-nodes {
- --fa: "\f1e0";
-}
-
-.fa-share-alt {
- --fa: "\f1e0";
-}
-
-.fa-square-share-nodes {
- --fa: "\f1e1";
-}
-
-.fa-share-alt-square {
- --fa: "\f1e1";
-}
-
-.fa-bomb {
- --fa: "\f1e2";
-}
-
-.fa-futbol {
- --fa: "\f1e3";
-}
-
-.fa-futbol-ball {
- --fa: "\f1e3";
-}
-
-.fa-soccer-ball {
- --fa: "\f1e3";
-}
-
-.fa-tty {
- --fa: "\f1e4";
-}
-
-.fa-teletype {
- --fa: "\f1e4";
-}
-
-.fa-binoculars {
- --fa: "\f1e5";
-}
-
-.fa-plug {
- --fa: "\f1e6";
-}
-
-.fa-newspaper {
- --fa: "\f1ea";
-}
-
-.fa-wifi {
- --fa: "\f1eb";
-}
-
-.fa-wifi-3 {
- --fa: "\f1eb";
-}
-
-.fa-wifi-strong {
- --fa: "\f1eb";
-}
-
-.fa-calculator {
- --fa: "\f1ec";
-}
-
-.fa-bell-slash {
- --fa: "\f1f6";
-}
-
-.fa-trash {
- --fa: "\f1f8";
-}
-
-.fa-copyright {
- --fa: "\f1f9";
-}
-
-.fa-eye-dropper {
- --fa: "\f1fb";
-}
-
-.fa-eye-dropper-empty {
- --fa: "\f1fb";
-}
-
-.fa-eyedropper {
- --fa: "\f1fb";
-}
-
-.fa-paintbrush {
- --fa: "\f1fc";
-}
-
-.fa-paint-brush {
- --fa: "\f1fc";
-}
-
-.fa-cake-candles {
- --fa: "\f1fd";
-}
-
-.fa-birthday-cake {
- --fa: "\f1fd";
-}
-
-.fa-cake {
- --fa: "\f1fd";
-}
-
-.fa-chart-area {
- --fa: "\f1fe";
-}
-
-.fa-area-chart {
- --fa: "\f1fe";
-}
-
-.fa-chart-pie {
- --fa: "\f200";
-}
-
-.fa-pie-chart {
- --fa: "\f200";
-}
-
-.fa-chart-line {
- --fa: "\f201";
-}
-
-.fa-line-chart {
- --fa: "\f201";
-}
-
-.fa-toggle-off {
- --fa: "\f204";
-}
-
-.fa-toggle-on {
- --fa: "\f205";
-}
-
-.fa-bicycle {
- --fa: "\f206";
-}
-
-.fa-bus {
- --fa: "\f207";
-}
-
-.fa-closed-captioning {
- --fa: "\f20a";
-}
-
-.fa-shekel-sign {
- --fa: "\f20b";
-}
-
-.fa-ils {
- --fa: "\f20b";
-}
-
-.fa-shekel {
- --fa: "\f20b";
-}
-
-.fa-sheqel {
- --fa: "\f20b";
-}
-
-.fa-sheqel-sign {
- --fa: "\f20b";
-}
-
-.fa-cart-plus {
- --fa: "\f217";
-}
-
-.fa-cart-arrow-down {
- --fa: "\f218";
-}
-
-.fa-diamond {
- --fa: "\f219";
-}
-
-.fa-ship {
- --fa: "\f21a";
-}
-
-.fa-user-secret {
- --fa: "\f21b";
-}
-
-.fa-motorcycle {
- --fa: "\f21c";
-}
-
-.fa-street-view {
- --fa: "\f21d";
-}
-
-.fa-heart-pulse {
- --fa: "\f21e";
-}
-
-.fa-heartbeat {
- --fa: "\f21e";
-}
-
-.fa-venus {
- --fa: "\f221";
-}
-
-.fa-mars {
- --fa: "\f222";
-}
-
-.fa-mercury {
- --fa: "\f223";
-}
-
-.fa-mars-and-venus {
- --fa: "\f224";
-}
-
-.fa-transgender {
- --fa: "\f225";
-}
-
-.fa-transgender-alt {
- --fa: "\f225";
-}
-
-.fa-venus-double {
- --fa: "\f226";
-}
-
-.fa-mars-double {
- --fa: "\f227";
-}
-
-.fa-venus-mars {
- --fa: "\f228";
-}
-
-.fa-mars-stroke {
- --fa: "\f229";
-}
-
-.fa-mars-stroke-up {
- --fa: "\f22a";
-}
-
-.fa-mars-stroke-v {
- --fa: "\f22a";
-}
-
-.fa-mars-stroke-right {
- --fa: "\f22b";
-}
-
-.fa-mars-stroke-h {
- --fa: "\f22b";
-}
-
-.fa-neuter {
- --fa: "\f22c";
-}
-
-.fa-genderless {
- --fa: "\f22d";
-}
-
-.fa-server {
- --fa: "\f233";
-}
-
-.fa-user-plus {
- --fa: "\f234";
-}
-
-.fa-user-xmark {
- --fa: "\f235";
-}
-
-.fa-user-times {
- --fa: "\f235";
-}
-
-.fa-bed {
- --fa: "\f236";
-}
-
-.fa-train {
- --fa: "\f238";
-}
-
-.fa-train-subway {
- --fa: "\f239";
-}
-
-.fa-subway {
- --fa: "\f239";
-}
-
-.fa-battery-full {
- --fa: "\f240";
-}
-
-.fa-battery {
- --fa: "\f240";
-}
-
-.fa-battery-5 {
- --fa: "\f240";
-}
-
-.fa-battery-three-quarters {
- --fa: "\f241";
-}
-
-.fa-battery-4 {
- --fa: "\f241";
-}
-
-.fa-battery-half {
- --fa: "\f242";
-}
-
-.fa-battery-3 {
- --fa: "\f242";
-}
-
-.fa-battery-quarter {
- --fa: "\f243";
-}
-
-.fa-battery-2 {
- --fa: "\f243";
-}
-
-.fa-battery-empty {
- --fa: "\f244";
-}
-
-.fa-battery-0 {
- --fa: "\f244";
-}
-
-.fa-arrow-pointer {
- --fa: "\f245";
-}
-
-.fa-mouse-pointer {
- --fa: "\f245";
-}
-
-.fa-i-cursor {
- --fa: "\f246";
-}
-
-.fa-object-group {
- --fa: "\f247";
-}
-
-.fa-object-ungroup {
- --fa: "\f248";
-}
-
-.fa-note-sticky {
- --fa: "\f249";
-}
-
-.fa-sticky-note {
- --fa: "\f249";
-}
-
-.fa-clone {
- --fa: "\f24d";
-}
-
-.fa-scale-balanced {
- --fa: "\f24e";
-}
-
-.fa-balance-scale {
- --fa: "\f24e";
-}
-
-.fa-hourglass-start {
- --fa: "\f251";
-}
-
-.fa-hourglass-1 {
- --fa: "\f251";
-}
-
-.fa-hourglass-half {
- --fa: "\f252";
-}
-
-.fa-hourglass-2 {
- --fa: "\f252";
-}
-
-.fa-hourglass-end {
- --fa: "\f253";
-}
-
-.fa-hourglass-3 {
- --fa: "\f253";
-}
-
-.fa-hourglass {
- --fa: "\f254";
-}
-
-.fa-hourglass-empty {
- --fa: "\f254";
-}
-
-.fa-hand-back-fist {
- --fa: "\f255";
-}
-
-.fa-hand-rock {
- --fa: "\f255";
-}
-
-.fa-hand {
- --fa: "\f256";
-}
-
-.fa-hand-paper {
- --fa: "\f256";
-}
-
-.fa-hand-scissors {
- --fa: "\f257";
-}
-
-.fa-hand-lizard {
- --fa: "\f258";
-}
-
-.fa-hand-spock {
- --fa: "\f259";
-}
-
-.fa-hand-pointer {
- --fa: "\f25a";
-}
-
-.fa-hand-peace {
- --fa: "\f25b";
-}
-
-.fa-trademark {
- --fa: "\f25c";
-}
-
-.fa-registered {
- --fa: "\f25d";
-}
-
-.fa-tv {
- --fa: "\f26c";
-}
-
-.fa-television {
- --fa: "\f26c";
-}
-
-.fa-tv-alt {
- --fa: "\f26c";
-}
-
-.fa-calendar-plus {
- --fa: "\f271";
-}
-
-.fa-calendar-minus {
- --fa: "\f272";
-}
-
-.fa-calendar-xmark {
- --fa: "\f273";
-}
-
-.fa-calendar-times {
- --fa: "\f273";
-}
-
-.fa-calendar-check {
- --fa: "\f274";
-}
-
-.fa-industry {
- --fa: "\f275";
-}
-
-.fa-map-pin {
- --fa: "\f276";
-}
-
-.fa-signs-post {
- --fa: "\f277";
-}
-
-.fa-map-signs {
- --fa: "\f277";
-}
-
-.fa-map {
- --fa: "\f279";
-}
-
-.fa-message {
- --fa: "\f27a";
-}
-
-.fa-comment-alt {
- --fa: "\f27a";
-}
-
-.fa-circle-pause {
- --fa: "\f28b";
-}
-
-.fa-pause-circle {
- --fa: "\f28b";
-}
-
-.fa-circle-stop {
- --fa: "\f28d";
-}
-
-.fa-stop-circle {
- --fa: "\f28d";
-}
-
-.fa-bag-shopping {
- --fa: "\f290";
-}
-
-.fa-shopping-bag {
- --fa: "\f290";
-}
-
-.fa-basket-shopping {
- --fa: "\f291";
-}
-
-.fa-shopping-basket {
- --fa: "\f291";
-}
-
-.fa-universal-access {
- --fa: "\f29a";
-}
-
-.fa-person-walking-with-cane {
- --fa: "\f29d";
-}
-
-.fa-blind {
- --fa: "\f29d";
-}
-
-.fa-audio-description {
- --fa: "\f29e";
-}
-
-.fa-phone-volume {
- --fa: "\f2a0";
-}
-
-.fa-volume-control-phone {
- --fa: "\f2a0";
-}
-
-.fa-braille {
- --fa: "\f2a1";
-}
-
-.fa-ear-listen {
- --fa: "\f2a2";
-}
-
-.fa-assistive-listening-systems {
- --fa: "\f2a2";
-}
-
-.fa-hands-asl-interpreting {
- --fa: "\f2a3";
-}
-
-.fa-american-sign-language-interpreting {
- --fa: "\f2a3";
-}
-
-.fa-asl-interpreting {
- --fa: "\f2a3";
-}
-
-.fa-hands-american-sign-language-interpreting {
- --fa: "\f2a3";
-}
-
-.fa-ear-deaf {
- --fa: "\f2a4";
-}
-
-.fa-deaf {
- --fa: "\f2a4";
-}
-
-.fa-deafness {
- --fa: "\f2a4";
-}
-
-.fa-hard-of-hearing {
- --fa: "\f2a4";
-}
-
-.fa-hands {
- --fa: "\f2a7";
-}
-
-.fa-sign-language {
- --fa: "\f2a7";
-}
-
-.fa-signing {
- --fa: "\f2a7";
-}
-
-.fa-eye-low-vision {
- --fa: "\f2a8";
-}
-
-.fa-low-vision {
- --fa: "\f2a8";
-}
-
-.fa-font-awesome {
- --fa: "\f2b4";
-}
-
-.fa-font-awesome-flag {
- --fa: "\f2b4";
-}
-
-.fa-font-awesome-logo-full {
- --fa: "\f2b4";
-}
-
-.fa-handshake {
- --fa: "\f2b5";
-}
-
-.fa-handshake-alt {
- --fa: "\f2b5";
-}
-
-.fa-handshake-simple {
- --fa: "\f2b5";
-}
-
-.fa-envelope-open {
- --fa: "\f2b6";
-}
-
-.fa-address-book {
- --fa: "\f2b9";
-}
-
-.fa-contact-book {
- --fa: "\f2b9";
-}
-
-.fa-address-card {
- --fa: "\f2bb";
-}
-
-.fa-contact-card {
- --fa: "\f2bb";
-}
-
-.fa-vcard {
- --fa: "\f2bb";
-}
-
-.fa-circle-user {
- --fa: "\f2bd";
-}
-
-.fa-user-circle {
- --fa: "\f2bd";
-}
-
-.fa-id-badge {
- --fa: "\f2c1";
-}
-
-.fa-id-card {
- --fa: "\f2c2";
-}
-
-.fa-drivers-license {
- --fa: "\f2c2";
-}
-
-.fa-temperature-full {
- --fa: "\f2c7";
-}
-
-.fa-temperature-4 {
- --fa: "\f2c7";
-}
-
-.fa-thermometer-4 {
- --fa: "\f2c7";
-}
-
-.fa-thermometer-full {
- --fa: "\f2c7";
-}
-
-.fa-temperature-three-quarters {
- --fa: "\f2c8";
-}
-
-.fa-temperature-3 {
- --fa: "\f2c8";
-}
-
-.fa-thermometer-3 {
- --fa: "\f2c8";
-}
-
-.fa-thermometer-three-quarters {
- --fa: "\f2c8";
-}
-
-.fa-temperature-half {
- --fa: "\f2c9";
-}
-
-.fa-temperature-2 {
- --fa: "\f2c9";
-}
-
-.fa-thermometer-2 {
- --fa: "\f2c9";
-}
-
-.fa-thermometer-half {
- --fa: "\f2c9";
-}
-
-.fa-temperature-quarter {
- --fa: "\f2ca";
-}
-
-.fa-temperature-1 {
- --fa: "\f2ca";
-}
-
-.fa-thermometer-1 {
- --fa: "\f2ca";
-}
-
-.fa-thermometer-quarter {
- --fa: "\f2ca";
-}
-
-.fa-temperature-empty {
- --fa: "\f2cb";
-}
-
-.fa-temperature-0 {
- --fa: "\f2cb";
-}
-
-.fa-thermometer-0 {
- --fa: "\f2cb";
-}
-
-.fa-thermometer-empty {
- --fa: "\f2cb";
-}
-
-.fa-shower {
- --fa: "\f2cc";
-}
-
-.fa-bath {
- --fa: "\f2cd";
-}
-
-.fa-bathtub {
- --fa: "\f2cd";
-}
-
-.fa-podcast {
- --fa: "\f2ce";
-}
-
-.fa-window-maximize {
- --fa: "\f2d0";
-}
-
-.fa-window-minimize {
- --fa: "\f2d1";
-}
-
-.fa-window-restore {
- --fa: "\f2d2";
-}
-
-.fa-square-xmark {
- --fa: "\f2d3";
-}
-
-.fa-times-square {
- --fa: "\f2d3";
-}
-
-.fa-xmark-square {
- --fa: "\f2d3";
-}
-
-.fa-microchip {
- --fa: "\f2db";
-}
-
-.fa-snowflake {
- --fa: "\f2dc";
-}
-
-.fa-spoon {
- --fa: "\f2e5";
-}
-
-.fa-utensil-spoon {
- --fa: "\f2e5";
-}
-
-.fa-utensils {
- --fa: "\f2e7";
-}
-
-.fa-cutlery {
- --fa: "\f2e7";
-}
-
-.fa-rotate-left {
- --fa: "\f2ea";
-}
-
-.fa-rotate-back {
- --fa: "\f2ea";
-}
-
-.fa-rotate-backward {
- --fa: "\f2ea";
-}
-
-.fa-undo-alt {
- --fa: "\f2ea";
-}
-
-.fa-trash-can {
- --fa: "\f2ed";
-}
-
-.fa-trash-alt {
- --fa: "\f2ed";
-}
-
-.fa-rotate {
- --fa: "\f2f1";
-}
-
-.fa-sync-alt {
- --fa: "\f2f1";
-}
-
-.fa-stopwatch {
- --fa: "\f2f2";
-}
-
-.fa-right-from-bracket {
- --fa: "\f2f5";
-}
-
-.fa-sign-out-alt {
- --fa: "\f2f5";
-}
-
-.fa-right-to-bracket {
- --fa: "\f2f6";
-}
-
-.fa-sign-in-alt {
- --fa: "\f2f6";
-}
-
-.fa-rotate-right {
- --fa: "\f2f9";
-}
-
-.fa-redo-alt {
- --fa: "\f2f9";
-}
-
-.fa-rotate-forward {
- --fa: "\f2f9";
-}
-
-.fa-poo {
- --fa: "\f2fe";
-}
-
-.fa-images {
- --fa: "\f302";
-}
-
-.fa-pencil {
- --fa: "\f303";
-}
-
-.fa-pencil-alt {
- --fa: "\f303";
-}
-
-.fa-pen {
- --fa: "\f304";
-}
-
-.fa-pen-clip {
- --fa: "\f305";
-}
-
-.fa-pen-alt {
- --fa: "\f305";
-}
-
-.fa-octagon {
- --fa: "\f306";
-}
-
-.fa-down-long {
- --fa: "\f309";
-}
-
-.fa-long-arrow-alt-down {
- --fa: "\f309";
-}
-
-.fa-left-long {
- --fa: "\f30a";
-}
-
-.fa-long-arrow-alt-left {
- --fa: "\f30a";
-}
-
-.fa-right-long {
- --fa: "\f30b";
-}
-
-.fa-long-arrow-alt-right {
- --fa: "\f30b";
-}
-
-.fa-up-long {
- --fa: "\f30c";
-}
-
-.fa-long-arrow-alt-up {
- --fa: "\f30c";
-}
-
-.fa-hexagon {
- --fa: "\f312";
-}
-
-.fa-file-pen {
- --fa: "\f31c";
-}
-
-.fa-file-edit {
- --fa: "\f31c";
-}
-
-.fa-maximize {
- --fa: "\f31e";
-}
-
-.fa-expand-arrows-alt {
- --fa: "\f31e";
-}
-
-.fa-clipboard {
- --fa: "\f328";
-}
-
-.fa-left-right {
- --fa: "\f337";
-}
-
-.fa-arrows-alt-h {
- --fa: "\f337";
-}
-
-.fa-up-down {
- --fa: "\f338";
-}
-
-.fa-arrows-alt-v {
- --fa: "\f338";
-}
-
-.fa-alarm-clock {
- --fa: "\f34e";
-}
-
-.fa-circle-down {
- --fa: "\f358";
-}
-
-.fa-arrow-alt-circle-down {
- --fa: "\f358";
-}
-
-.fa-circle-left {
- --fa: "\f359";
-}
-
-.fa-arrow-alt-circle-left {
- --fa: "\f359";
-}
-
-.fa-circle-right {
- --fa: "\f35a";
-}
-
-.fa-arrow-alt-circle-right {
- --fa: "\f35a";
-}
-
-.fa-circle-up {
- --fa: "\f35b";
-}
-
-.fa-arrow-alt-circle-up {
- --fa: "\f35b";
-}
-
-.fa-up-right-from-square {
- --fa: "\f35d";
-}
-
-.fa-external-link-alt {
- --fa: "\f35d";
-}
-
-.fa-square-up-right {
- --fa: "\f360";
-}
-
-.fa-external-link-square-alt {
- --fa: "\f360";
-}
-
-.fa-right-left {
- --fa: "\f362";
-}
-
-.fa-exchange-alt {
- --fa: "\f362";
-}
-
-.fa-repeat {
- --fa: "\f363";
-}
-
-.fa-code-commit {
- --fa: "\f386";
-}
-
-.fa-code-merge {
- --fa: "\f387";
-}
-
-.fa-desktop {
- --fa: "\f390";
-}
-
-.fa-desktop-alt {
- --fa: "\f390";
-}
-
-.fa-gem {
- --fa: "\f3a5";
-}
-
-.fa-turn-down {
- --fa: "\f3be";
-}
-
-.fa-level-down-alt {
- --fa: "\f3be";
-}
-
-.fa-turn-up {
- --fa: "\f3bf";
-}
-
-.fa-level-up-alt {
- --fa: "\f3bf";
-}
-
-.fa-lock-open {
- --fa: "\f3c1";
-}
-
-.fa-location-dot {
- --fa: "\f3c5";
-}
-
-.fa-map-marker-alt {
- --fa: "\f3c5";
-}
-
-.fa-microphone-lines {
- --fa: "\f3c9";
-}
-
-.fa-microphone-alt {
- --fa: "\f3c9";
-}
-
-.fa-mobile-screen-button {
- --fa: "\f3cd";
-}
-
-.fa-mobile-alt {
- --fa: "\f3cd";
-}
-
-.fa-mobile {
- --fa: "\f3ce";
-}
-
-.fa-mobile-android {
- --fa: "\f3ce";
-}
-
-.fa-mobile-phone {
- --fa: "\f3ce";
-}
-
-.fa-mobile-screen {
- --fa: "\f3cf";
-}
-
-.fa-mobile-android-alt {
- --fa: "\f3cf";
-}
-
-.fa-money-bill-1 {
- --fa: "\f3d1";
-}
-
-.fa-money-bill-alt {
- --fa: "\f3d1";
-}
-
-.fa-phone-slash {
- --fa: "\f3dd";
-}
-
-.fa-image-portrait {
- --fa: "\f3e0";
-}
-
-.fa-portrait {
- --fa: "\f3e0";
-}
-
-.fa-reply {
- --fa: "\f3e5";
-}
-
-.fa-mail-reply {
- --fa: "\f3e5";
-}
-
-.fa-shield-halved {
- --fa: "\f3ed";
-}
-
-.fa-shield-alt {
- --fa: "\f3ed";
-}
-
-.fa-tablet-screen-button {
- --fa: "\f3fa";
-}
-
-.fa-tablet-alt {
- --fa: "\f3fa";
-}
-
-.fa-tablet {
- --fa: "\f3fb";
-}
-
-.fa-tablet-android {
- --fa: "\f3fb";
-}
-
-.fa-ticket-simple {
- --fa: "\f3ff";
-}
-
-.fa-ticket-alt {
- --fa: "\f3ff";
-}
-
-.fa-rectangle-xmark {
- --fa: "\f410";
-}
-
-.fa-rectangle-times {
- --fa: "\f410";
-}
-
-.fa-times-rectangle {
- --fa: "\f410";
-}
-
-.fa-window-close {
- --fa: "\f410";
-}
-
-.fa-down-left-and-up-right-to-center {
- --fa: "\f422";
-}
-
-.fa-compress-alt {
- --fa: "\f422";
-}
-
-.fa-up-right-and-down-left-from-center {
- --fa: "\f424";
-}
-
-.fa-expand-alt {
- --fa: "\f424";
-}
-
-.fa-baseball-bat-ball {
- --fa: "\f432";
-}
-
-.fa-baseball {
- --fa: "\f433";
-}
-
-.fa-baseball-ball {
- --fa: "\f433";
-}
-
-.fa-basketball {
- --fa: "\f434";
-}
-
-.fa-basketball-ball {
- --fa: "\f434";
-}
-
-.fa-bowling-ball {
- --fa: "\f436";
-}
-
-.fa-chess {
- --fa: "\f439";
-}
-
-.fa-chess-bishop {
- --fa: "\f43a";
-}
-
-.fa-chess-board {
- --fa: "\f43c";
-}
-
-.fa-chess-king {
- --fa: "\f43f";
-}
-
-.fa-chess-knight {
- --fa: "\f441";
-}
-
-.fa-chess-pawn {
- --fa: "\f443";
-}
-
-.fa-chess-queen {
- --fa: "\f445";
-}
-
-.fa-chess-rook {
- --fa: "\f447";
-}
-
-.fa-dumbbell {
- --fa: "\f44b";
-}
-
-.fa-football {
- --fa: "\f44e";
-}
-
-.fa-football-ball {
- --fa: "\f44e";
-}
-
-.fa-golf-ball-tee {
- --fa: "\f450";
-}
-
-.fa-golf-ball {
- --fa: "\f450";
-}
-
-.fa-hockey-puck {
- --fa: "\f453";
-}
-
-.fa-broom-ball {
- --fa: "\f458";
-}
-
-.fa-quidditch {
- --fa: "\f458";
-}
-
-.fa-quidditch-broom-ball {
- --fa: "\f458";
-}
-
-.fa-square-full {
- --fa: "\f45c";
-}
-
-.fa-table-tennis-paddle-ball {
- --fa: "\f45d";
-}
-
-.fa-ping-pong-paddle-ball {
- --fa: "\f45d";
-}
-
-.fa-table-tennis {
- --fa: "\f45d";
-}
-
-.fa-volleyball {
- --fa: "\f45f";
-}
-
-.fa-volleyball-ball {
- --fa: "\f45f";
-}
-
-.fa-hand-dots {
- --fa: "\f461";
-}
-
-.fa-allergies {
- --fa: "\f461";
-}
-
-.fa-bandage {
- --fa: "\f462";
-}
-
-.fa-band-aid {
- --fa: "\f462";
-}
-
-.fa-box {
- --fa: "\f466";
-}
-
-.fa-boxes-stacked {
- --fa: "\f468";
-}
-
-.fa-boxes {
- --fa: "\f468";
-}
-
-.fa-boxes-alt {
- --fa: "\f468";
-}
-
-.fa-briefcase-medical {
- --fa: "\f469";
-}
-
-.fa-fire-flame-simple {
- --fa: "\f46a";
-}
-
-.fa-burn {
- --fa: "\f46a";
-}
-
-.fa-capsules {
- --fa: "\f46b";
-}
-
-.fa-clipboard-check {
- --fa: "\f46c";
-}
-
-.fa-clipboard-list {
- --fa: "\f46d";
-}
-
-.fa-person-dots-from-line {
- --fa: "\f470";
-}
-
-.fa-diagnoses {
- --fa: "\f470";
-}
-
-.fa-dna {
- --fa: "\f471";
-}
-
-.fa-dolly {
- --fa: "\f472";
-}
-
-.fa-dolly-box {
- --fa: "\f472";
-}
-
-.fa-cart-flatbed {
- --fa: "\f474";
-}
-
-.fa-dolly-flatbed {
- --fa: "\f474";
-}
-
-.fa-file-medical {
- --fa: "\f477";
-}
-
-.fa-file-waveform {
- --fa: "\f478";
-}
-
-.fa-file-medical-alt {
- --fa: "\f478";
-}
-
-.fa-kit-medical {
- --fa: "\f479";
-}
-
-.fa-first-aid {
- --fa: "\f479";
-}
-
-.fa-circle-h {
- --fa: "\f47e";
-}
-
-.fa-hospital-symbol {
- --fa: "\f47e";
-}
-
-.fa-id-card-clip {
- --fa: "\f47f";
-}
-
-.fa-id-card-alt {
- --fa: "\f47f";
-}
-
-.fa-notes-medical {
- --fa: "\f481";
-}
-
-.fa-pallet {
- --fa: "\f482";
-}
-
-.fa-pills {
- --fa: "\f484";
-}
-
-.fa-prescription-bottle {
- --fa: "\f485";
-}
-
-.fa-prescription-bottle-medical {
- --fa: "\f486";
-}
-
-.fa-prescription-bottle-alt {
- --fa: "\f486";
-}
-
-.fa-bed-pulse {
- --fa: "\f487";
-}
-
-.fa-procedures {
- --fa: "\f487";
-}
-
-.fa-truck-fast {
- --fa: "\f48b";
-}
-
-.fa-shipping-fast {
- --fa: "\f48b";
-}
-
-.fa-smoking {
- --fa: "\f48d";
-}
-
-.fa-syringe {
- --fa: "\f48e";
-}
-
-.fa-tablets {
- --fa: "\f490";
-}
-
-.fa-thermometer {
- --fa: "\f491";
-}
-
-.fa-vial {
- --fa: "\f492";
-}
-
-.fa-vials {
- --fa: "\f493";
-}
-
-.fa-warehouse {
- --fa: "\f494";
-}
-
-.fa-weight-scale {
- --fa: "\f496";
-}
-
-.fa-weight {
- --fa: "\f496";
-}
-
-.fa-x-ray {
- --fa: "\f497";
-}
-
-.fa-box-open {
- --fa: "\f49e";
-}
-
-.fa-comment-dots {
- --fa: "\f4ad";
-}
-
-.fa-commenting {
- --fa: "\f4ad";
-}
-
-.fa-comment-slash {
- --fa: "\f4b3";
-}
-
-.fa-couch {
- --fa: "\f4b8";
-}
-
-.fa-circle-dollar-to-slot {
- --fa: "\f4b9";
-}
-
-.fa-donate {
- --fa: "\f4b9";
-}
-
-.fa-dove {
- --fa: "\f4ba";
-}
-
-.fa-hand-holding {
- --fa: "\f4bd";
-}
-
-.fa-hand-holding-heart {
- --fa: "\f4be";
-}
-
-.fa-hand-holding-dollar {
- --fa: "\f4c0";
-}
-
-.fa-hand-holding-usd {
- --fa: "\f4c0";
-}
-
-.fa-hand-holding-droplet {
- --fa: "\f4c1";
-}
-
-.fa-hand-holding-water {
- --fa: "\f4c1";
-}
-
-.fa-hands-holding {
- --fa: "\f4c2";
-}
-
-.fa-handshake-angle {
- --fa: "\f4c4";
-}
-
-.fa-hands-helping {
- --fa: "\f4c4";
-}
-
-.fa-parachute-box {
- --fa: "\f4cd";
-}
-
-.fa-people-carry-box {
- --fa: "\f4ce";
-}
-
-.fa-people-carry {
- --fa: "\f4ce";
-}
-
-.fa-piggy-bank {
- --fa: "\f4d3";
-}
-
-.fa-ribbon {
- --fa: "\f4d6";
-}
-
-.fa-route {
- --fa: "\f4d7";
-}
-
-.fa-seedling {
- --fa: "\f4d8";
-}
-
-.fa-sprout {
- --fa: "\f4d8";
-}
-
-.fa-sign-hanging {
- --fa: "\f4d9";
-}
-
-.fa-sign {
- --fa: "\f4d9";
-}
-
-.fa-face-smile-wink {
- --fa: "\f4da";
-}
-
-.fa-smile-wink {
- --fa: "\f4da";
-}
-
-.fa-tape {
- --fa: "\f4db";
-}
-
-.fa-truck-ramp-box {
- --fa: "\f4de";
-}
-
-.fa-truck-loading {
- --fa: "\f4de";
-}
-
-.fa-truck-moving {
- --fa: "\f4df";
-}
-
-.fa-video-slash {
- --fa: "\f4e2";
-}
-
-.fa-wine-glass {
- --fa: "\f4e3";
-}
-
-.fa-user-astronaut {
- --fa: "\f4fb";
-}
-
-.fa-user-check {
- --fa: "\f4fc";
-}
-
-.fa-user-clock {
- --fa: "\f4fd";
-}
-
-.fa-user-gear {
- --fa: "\f4fe";
-}
-
-.fa-user-cog {
- --fa: "\f4fe";
-}
-
-.fa-user-pen {
- --fa: "\f4ff";
-}
-
-.fa-user-edit {
- --fa: "\f4ff";
-}
-
-.fa-user-group {
- --fa: "\f500";
-}
-
-.fa-user-friends {
- --fa: "\f500";
-}
-
-.fa-user-graduate {
- --fa: "\f501";
-}
-
-.fa-user-lock {
- --fa: "\f502";
-}
-
-.fa-user-minus {
- --fa: "\f503";
-}
-
-.fa-user-ninja {
- --fa: "\f504";
-}
-
-.fa-user-shield {
- --fa: "\f505";
-}
-
-.fa-user-slash {
- --fa: "\f506";
-}
-
-.fa-user-alt-slash {
- --fa: "\f506";
-}
-
-.fa-user-large-slash {
- --fa: "\f506";
-}
-
-.fa-user-tag {
- --fa: "\f507";
-}
-
-.fa-user-tie {
- --fa: "\f508";
-}
-
-.fa-users-gear {
- --fa: "\f509";
-}
-
-.fa-users-cog {
- --fa: "\f509";
-}
-
-.fa-scale-unbalanced {
- --fa: "\f515";
-}
-
-.fa-balance-scale-left {
- --fa: "\f515";
-}
-
-.fa-scale-unbalanced-flip {
- --fa: "\f516";
-}
-
-.fa-balance-scale-right {
- --fa: "\f516";
-}
-
-.fa-blender {
- --fa: "\f517";
-}
-
-.fa-book-open {
- --fa: "\f518";
-}
-
-.fa-tower-broadcast {
- --fa: "\f519";
-}
-
-.fa-broadcast-tower {
- --fa: "\f519";
-}
-
-.fa-broom {
- --fa: "\f51a";
-}
-
-.fa-chalkboard {
- --fa: "\f51b";
-}
-
-.fa-blackboard {
- --fa: "\f51b";
-}
-
-.fa-chalkboard-user {
- --fa: "\f51c";
-}
-
-.fa-chalkboard-teacher {
- --fa: "\f51c";
-}
-
-.fa-church {
- --fa: "\f51d";
-}
-
-.fa-coins {
- --fa: "\f51e";
-}
-
-.fa-compact-disc {
- --fa: "\f51f";
-}
-
-.fa-crow {
- --fa: "\f520";
-}
-
-.fa-crown {
- --fa: "\f521";
-}
-
-.fa-dice {
- --fa: "\f522";
-}
-
-.fa-dice-five {
- --fa: "\f523";
-}
-
-.fa-dice-four {
- --fa: "\f524";
-}
-
-.fa-dice-one {
- --fa: "\f525";
-}
-
-.fa-dice-six {
- --fa: "\f526";
-}
-
-.fa-dice-three {
- --fa: "\f527";
-}
-
-.fa-dice-two {
- --fa: "\f528";
-}
-
-.fa-divide {
- --fa: "\f529";
-}
-
-.fa-door-closed {
- --fa: "\f52a";
-}
-
-.fa-door-open {
- --fa: "\f52b";
-}
-
-.fa-feather {
- --fa: "\f52d";
-}
-
-.fa-frog {
- --fa: "\f52e";
-}
-
-.fa-gas-pump {
- --fa: "\f52f";
-}
-
-.fa-glasses {
- --fa: "\f530";
-}
-
-.fa-greater-than-equal {
- --fa: "\f532";
-}
-
-.fa-helicopter {
- --fa: "\f533";
-}
-
-.fa-infinity {
- --fa: "\f534";
-}
-
-.fa-kiwi-bird {
- --fa: "\f535";
-}
-
-.fa-less-than-equal {
- --fa: "\f537";
-}
-
-.fa-memory {
- --fa: "\f538";
-}
-
-.fa-microphone-lines-slash {
- --fa: "\f539";
-}
-
-.fa-microphone-alt-slash {
- --fa: "\f539";
-}
-
-.fa-money-bill-wave {
- --fa: "\f53a";
-}
-
-.fa-money-bill-1-wave {
- --fa: "\f53b";
-}
-
-.fa-money-bill-wave-alt {
- --fa: "\f53b";
-}
-
-.fa-money-check {
- --fa: "\f53c";
-}
-
-.fa-money-check-dollar {
- --fa: "\f53d";
-}
-
-.fa-money-check-alt {
- --fa: "\f53d";
-}
-
-.fa-not-equal {
- --fa: "\f53e";
-}
-
-.fa-palette {
- --fa: "\f53f";
-}
-
-.fa-square-parking {
- --fa: "\f540";
-}
-
-.fa-parking {
- --fa: "\f540";
-}
-
-.fa-diagram-project {
- --fa: "\f542";
-}
-
-.fa-project-diagram {
- --fa: "\f542";
-}
-
-.fa-receipt {
- --fa: "\f543";
-}
-
-.fa-robot {
- --fa: "\f544";
-}
-
-.fa-ruler {
- --fa: "\f545";
-}
-
-.fa-ruler-combined {
- --fa: "\f546";
-}
-
-.fa-ruler-horizontal {
- --fa: "\f547";
-}
-
-.fa-ruler-vertical {
- --fa: "\f548";
-}
-
-.fa-school {
- --fa: "\f549";
-}
-
-.fa-screwdriver {
- --fa: "\f54a";
-}
-
-.fa-shoe-prints {
- --fa: "\f54b";
-}
-
-.fa-skull {
- --fa: "\f54c";
-}
-
-.fa-ban-smoking {
- --fa: "\f54d";
-}
-
-.fa-smoking-ban {
- --fa: "\f54d";
-}
-
-.fa-store {
- --fa: "\f54e";
-}
-
-.fa-shop {
- --fa: "\f54f";
-}
-
-.fa-store-alt {
- --fa: "\f54f";
-}
-
-.fa-bars-staggered {
- --fa: "\f550";
-}
-
-.fa-reorder {
- --fa: "\f550";
-}
-
-.fa-stream {
- --fa: "\f550";
-}
-
-.fa-stroopwafel {
- --fa: "\f551";
-}
-
-.fa-toolbox {
- --fa: "\f552";
-}
-
-.fa-shirt {
- --fa: "\f553";
-}
-
-.fa-t-shirt {
- --fa: "\f553";
-}
-
-.fa-tshirt {
- --fa: "\f553";
-}
-
-.fa-person-walking {
- --fa: "\f554";
-}
-
-.fa-walking {
- --fa: "\f554";
-}
-
-.fa-wallet {
- --fa: "\f555";
-}
-
-.fa-face-angry {
- --fa: "\f556";
-}
-
-.fa-angry {
- --fa: "\f556";
-}
-
-.fa-archway {
- --fa: "\f557";
-}
-
-.fa-book-atlas {
- --fa: "\f558";
-}
-
-.fa-atlas {
- --fa: "\f558";
-}
-
-.fa-award {
- --fa: "\f559";
-}
-
-.fa-delete-left {
- --fa: "\f55a";
-}
-
-.fa-backspace {
- --fa: "\f55a";
-}
-
-.fa-bezier-curve {
- --fa: "\f55b";
-}
-
-.fa-bong {
- --fa: "\f55c";
-}
-
-.fa-brush {
- --fa: "\f55d";
-}
-
-.fa-bus-simple {
- --fa: "\f55e";
-}
-
-.fa-bus-alt {
- --fa: "\f55e";
-}
-
-.fa-cannabis {
- --fa: "\f55f";
-}
-
-.fa-check-double {
- --fa: "\f560";
-}
-
-.fa-martini-glass-citrus {
- --fa: "\f561";
-}
-
-.fa-cocktail {
- --fa: "\f561";
-}
-
-.fa-bell-concierge {
- --fa: "\f562";
-}
-
-.fa-concierge-bell {
- --fa: "\f562";
-}
-
-.fa-cookie {
- --fa: "\f563";
-}
-
-.fa-cookie-bite {
- --fa: "\f564";
-}
-
-.fa-crop-simple {
- --fa: "\f565";
-}
-
-.fa-crop-alt {
- --fa: "\f565";
-}
-
-.fa-tachograph-digital {
- --fa: "\f566";
-}
-
-.fa-digital-tachograph {
- --fa: "\f566";
-}
-
-.fa-face-dizzy {
- --fa: "\f567";
-}
-
-.fa-dizzy {
- --fa: "\f567";
-}
-
-.fa-compass-drafting {
- --fa: "\f568";
-}
-
-.fa-drafting-compass {
- --fa: "\f568";
-}
-
-.fa-drum {
- --fa: "\f569";
-}
-
-.fa-drum-steelpan {
- --fa: "\f56a";
-}
-
-.fa-feather-pointed {
- --fa: "\f56b";
-}
-
-.fa-feather-alt {
- --fa: "\f56b";
-}
-
-.fa-file-contract {
- --fa: "\f56c";
-}
-
-.fa-file-arrow-down {
- --fa: "\f56d";
-}
-
-.fa-file-download {
- --fa: "\f56d";
-}
-
-.fa-file-export {
- --fa: "\f56e";
-}
-
-.fa-arrow-right-from-file {
- --fa: "\f56e";
-}
-
-.fa-file-import {
- --fa: "\f56f";
-}
-
-.fa-arrow-right-to-file {
- --fa: "\f56f";
-}
-
-.fa-file-invoice {
- --fa: "\f570";
-}
-
-.fa-file-invoice-dollar {
- --fa: "\f571";
-}
-
-.fa-file-prescription {
- --fa: "\f572";
-}
-
-.fa-file-signature {
- --fa: "\f573";
-}
-
-.fa-file-arrow-up {
- --fa: "\f574";
-}
-
-.fa-file-upload {
- --fa: "\f574";
-}
-
-.fa-fill {
- --fa: "\f575";
-}
-
-.fa-fill-drip {
- --fa: "\f576";
-}
-
-.fa-fingerprint {
- --fa: "\f577";
-}
-
-.fa-fish {
- --fa: "\f578";
-}
-
-.fa-face-flushed {
- --fa: "\f579";
-}
-
-.fa-flushed {
- --fa: "\f579";
-}
-
-.fa-face-frown-open {
- --fa: "\f57a";
-}
-
-.fa-frown-open {
- --fa: "\f57a";
-}
-
-.fa-martini-glass {
- --fa: "\f57b";
-}
-
-.fa-glass-martini-alt {
- --fa: "\f57b";
-}
-
-.fa-earth-africa {
- --fa: "\f57c";
-}
-
-.fa-globe-africa {
- --fa: "\f57c";
-}
-
-.fa-earth-americas {
- --fa: "\f57d";
-}
-
-.fa-earth {
- --fa: "\f57d";
-}
-
-.fa-earth-america {
- --fa: "\f57d";
-}
-
-.fa-globe-americas {
- --fa: "\f57d";
-}
-
-.fa-earth-asia {
- --fa: "\f57e";
-}
-
-.fa-globe-asia {
- --fa: "\f57e";
-}
-
-.fa-face-grimace {
- --fa: "\f57f";
-}
-
-.fa-grimace {
- --fa: "\f57f";
-}
-
-.fa-face-grin {
- --fa: "\f580";
-}
-
-.fa-grin {
- --fa: "\f580";
-}
-
-.fa-face-grin-wide {
- --fa: "\f581";
-}
-
-.fa-grin-alt {
- --fa: "\f581";
-}
-
-.fa-face-grin-beam {
- --fa: "\f582";
-}
-
-.fa-grin-beam {
- --fa: "\f582";
-}
-
-.fa-face-grin-beam-sweat {
- --fa: "\f583";
-}
-
-.fa-grin-beam-sweat {
- --fa: "\f583";
-}
-
-.fa-face-grin-hearts {
- --fa: "\f584";
-}
-
-.fa-grin-hearts {
- --fa: "\f584";
-}
-
-.fa-face-grin-squint {
- --fa: "\f585";
-}
-
-.fa-grin-squint {
- --fa: "\f585";
-}
-
-.fa-face-grin-squint-tears {
- --fa: "\f586";
-}
-
-.fa-grin-squint-tears {
- --fa: "\f586";
-}
-
-.fa-face-grin-stars {
- --fa: "\f587";
-}
-
-.fa-grin-stars {
- --fa: "\f587";
-}
-
-.fa-face-grin-tears {
- --fa: "\f588";
-}
-
-.fa-grin-tears {
- --fa: "\f588";
-}
-
-.fa-face-grin-tongue {
- --fa: "\f589";
-}
-
-.fa-grin-tongue {
- --fa: "\f589";
-}
-
-.fa-face-grin-tongue-squint {
- --fa: "\f58a";
-}
-
-.fa-grin-tongue-squint {
- --fa: "\f58a";
-}
-
-.fa-face-grin-tongue-wink {
- --fa: "\f58b";
-}
-
-.fa-grin-tongue-wink {
- --fa: "\f58b";
-}
-
-.fa-face-grin-wink {
- --fa: "\f58c";
-}
-
-.fa-grin-wink {
- --fa: "\f58c";
-}
-
-.fa-grip {
- --fa: "\f58d";
-}
-
-.fa-grid-horizontal {
- --fa: "\f58d";
-}
-
-.fa-grip-horizontal {
- --fa: "\f58d";
-}
-
-.fa-grip-vertical {
- --fa: "\f58e";
-}
-
-.fa-grid-vertical {
- --fa: "\f58e";
-}
-
-.fa-headset {
- --fa: "\f590";
-}
-
-.fa-highlighter {
- --fa: "\f591";
-}
-
-.fa-hot-tub-person {
- --fa: "\f593";
-}
-
-.fa-hot-tub {
- --fa: "\f593";
-}
-
-.fa-hotel {
- --fa: "\f594";
-}
-
-.fa-joint {
- --fa: "\f595";
-}
-
-.fa-face-kiss {
- --fa: "\f596";
-}
-
-.fa-kiss {
- --fa: "\f596";
-}
-
-.fa-face-kiss-beam {
- --fa: "\f597";
-}
-
-.fa-kiss-beam {
- --fa: "\f597";
-}
-
-.fa-face-kiss-wink-heart {
- --fa: "\f598";
-}
-
-.fa-kiss-wink-heart {
- --fa: "\f598";
-}
-
-.fa-face-laugh {
- --fa: "\f599";
-}
-
-.fa-laugh {
- --fa: "\f599";
-}
-
-.fa-face-laugh-beam {
- --fa: "\f59a";
-}
-
-.fa-laugh-beam {
- --fa: "\f59a";
-}
-
-.fa-face-laugh-squint {
- --fa: "\f59b";
-}
-
-.fa-laugh-squint {
- --fa: "\f59b";
-}
-
-.fa-face-laugh-wink {
- --fa: "\f59c";
-}
-
-.fa-laugh-wink {
- --fa: "\f59c";
-}
-
-.fa-cart-flatbed-suitcase {
- --fa: "\f59d";
-}
-
-.fa-luggage-cart {
- --fa: "\f59d";
-}
-
-.fa-map-location {
- --fa: "\f59f";
-}
-
-.fa-map-marked {
- --fa: "\f59f";
-}
-
-.fa-map-location-dot {
- --fa: "\f5a0";
-}
-
-.fa-map-marked-alt {
- --fa: "\f5a0";
-}
-
-.fa-marker {
- --fa: "\f5a1";
-}
-
-.fa-medal {
- --fa: "\f5a2";
-}
-
-.fa-face-meh-blank {
- --fa: "\f5a4";
-}
-
-.fa-meh-blank {
- --fa: "\f5a4";
-}
-
-.fa-face-rolling-eyes {
- --fa: "\f5a5";
-}
-
-.fa-meh-rolling-eyes {
- --fa: "\f5a5";
-}
-
-.fa-monument {
- --fa: "\f5a6";
-}
-
-.fa-mortar-pestle {
- --fa: "\f5a7";
-}
-
-.fa-paint-roller {
- --fa: "\f5aa";
-}
-
-.fa-passport {
- --fa: "\f5ab";
-}
-
-.fa-pen-fancy {
- --fa: "\f5ac";
-}
-
-.fa-pen-nib {
- --fa: "\f5ad";
-}
-
-.fa-pen-ruler {
- --fa: "\f5ae";
-}
-
-.fa-pencil-ruler {
- --fa: "\f5ae";
-}
-
-.fa-plane-arrival {
- --fa: "\f5af";
-}
-
-.fa-plane-departure {
- --fa: "\f5b0";
-}
-
-.fa-prescription {
- --fa: "\f5b1";
-}
-
-.fa-face-sad-cry {
- --fa: "\f5b3";
-}
-
-.fa-sad-cry {
- --fa: "\f5b3";
-}
-
-.fa-face-sad-tear {
- --fa: "\f5b4";
-}
-
-.fa-sad-tear {
- --fa: "\f5b4";
-}
-
-.fa-van-shuttle {
- --fa: "\f5b6";
-}
-
-.fa-shuttle-van {
- --fa: "\f5b6";
-}
-
-.fa-signature {
- --fa: "\f5b7";
-}
-
-.fa-face-smile-beam {
- --fa: "\f5b8";
-}
-
-.fa-smile-beam {
- --fa: "\f5b8";
-}
-
-.fa-solar-panel {
- --fa: "\f5ba";
-}
-
-.fa-spa {
- --fa: "\f5bb";
-}
-
-.fa-splotch {
- --fa: "\f5bc";
-}
-
-.fa-spray-can {
- --fa: "\f5bd";
-}
-
-.fa-stamp {
- --fa: "\f5bf";
-}
-
-.fa-star-half-stroke {
- --fa: "\f5c0";
-}
-
-.fa-star-half-alt {
- --fa: "\f5c0";
-}
-
-.fa-suitcase-rolling {
- --fa: "\f5c1";
-}
-
-.fa-face-surprise {
- --fa: "\f5c2";
-}
-
-.fa-surprise {
- --fa: "\f5c2";
-}
-
-.fa-swatchbook {
- --fa: "\f5c3";
-}
-
-.fa-person-swimming {
- --fa: "\f5c4";
-}
-
-.fa-swimmer {
- --fa: "\f5c4";
-}
-
-.fa-water-ladder {
- --fa: "\f5c5";
-}
-
-.fa-ladder-water {
- --fa: "\f5c5";
-}
-
-.fa-swimming-pool {
- --fa: "\f5c5";
-}
-
-.fa-droplet-slash {
- --fa: "\f5c7";
-}
-
-.fa-tint-slash {
- --fa: "\f5c7";
-}
-
-.fa-face-tired {
- --fa: "\f5c8";
-}
-
-.fa-tired {
- --fa: "\f5c8";
-}
-
-.fa-tooth {
- --fa: "\f5c9";
-}
-
-.fa-umbrella-beach {
- --fa: "\f5ca";
-}
-
-.fa-weight-hanging {
- --fa: "\f5cd";
-}
-
-.fa-wine-glass-empty {
- --fa: "\f5ce";
-}
-
-.fa-wine-glass-alt {
- --fa: "\f5ce";
-}
-
-.fa-spray-can-sparkles {
- --fa: "\f5d0";
-}
-
-.fa-air-freshener {
- --fa: "\f5d0";
-}
-
-.fa-apple-whole {
- --fa: "\f5d1";
-}
-
-.fa-apple-alt {
- --fa: "\f5d1";
-}
-
-.fa-atom {
- --fa: "\f5d2";
-}
-
-.fa-bone {
- --fa: "\f5d7";
-}
-
-.fa-book-open-reader {
- --fa: "\f5da";
-}
-
-.fa-book-reader {
- --fa: "\f5da";
-}
-
-.fa-brain {
- --fa: "\f5dc";
-}
-
-.fa-car-rear {
- --fa: "\f5de";
-}
-
-.fa-car-alt {
- --fa: "\f5de";
-}
-
-.fa-car-battery {
- --fa: "\f5df";
-}
-
-.fa-battery-car {
- --fa: "\f5df";
-}
-
-.fa-car-burst {
- --fa: "\f5e1";
-}
-
-.fa-car-crash {
- --fa: "\f5e1";
-}
-
-.fa-car-side {
- --fa: "\f5e4";
-}
-
-.fa-charging-station {
- --fa: "\f5e7";
-}
-
-.fa-diamond-turn-right {
- --fa: "\f5eb";
-}
-
-.fa-directions {
- --fa: "\f5eb";
-}
-
-.fa-draw-polygon {
- --fa: "\f5ee";
-}
-
-.fa-vector-polygon {
- --fa: "\f5ee";
-}
-
-.fa-laptop-code {
- --fa: "\f5fc";
-}
-
-.fa-layer-group {
- --fa: "\f5fd";
-}
-
-.fa-location-crosshairs {
- --fa: "\f601";
-}
-
-.fa-location {
- --fa: "\f601";
-}
-
-.fa-lungs {
- --fa: "\f604";
-}
-
-.fa-microscope {
- --fa: "\f610";
-}
-
-.fa-oil-can {
- --fa: "\f613";
-}
-
-.fa-poop {
- --fa: "\f619";
-}
-
-.fa-shapes {
- --fa: "\f61f";
-}
-
-.fa-triangle-circle-square {
- --fa: "\f61f";
-}
-
-.fa-star-of-life {
- --fa: "\f621";
-}
-
-.fa-gauge {
- --fa: "\f624";
-}
-
-.fa-dashboard {
- --fa: "\f624";
-}
-
-.fa-gauge-med {
- --fa: "\f624";
-}
-
-.fa-tachometer-alt-average {
- --fa: "\f624";
-}
-
-.fa-gauge-high {
- --fa: "\f625";
-}
-
-.fa-tachometer-alt {
- --fa: "\f625";
-}
-
-.fa-tachometer-alt-fast {
- --fa: "\f625";
-}
-
-.fa-gauge-simple {
- --fa: "\f629";
-}
-
-.fa-gauge-simple-med {
- --fa: "\f629";
-}
-
-.fa-tachometer-average {
- --fa: "\f629";
-}
-
-.fa-gauge-simple-high {
- --fa: "\f62a";
-}
-
-.fa-tachometer {
- --fa: "\f62a";
-}
-
-.fa-tachometer-fast {
- --fa: "\f62a";
-}
-
-.fa-teeth {
- --fa: "\f62e";
-}
-
-.fa-teeth-open {
- --fa: "\f62f";
-}
-
-.fa-masks-theater {
- --fa: "\f630";
-}
-
-.fa-theater-masks {
- --fa: "\f630";
-}
-
-.fa-traffic-light {
- --fa: "\f637";
-}
-
-.fa-truck-monster {
- --fa: "\f63b";
-}
-
-.fa-truck-pickup {
- --fa: "\f63c";
-}
-
-.fa-rectangle-ad {
- --fa: "\f641";
-}
-
-.fa-ad {
- --fa: "\f641";
-}
-
-.fa-ankh {
- --fa: "\f644";
-}
-
-.fa-book-bible {
- --fa: "\f647";
-}
-
-.fa-bible {
- --fa: "\f647";
-}
-
-.fa-business-time {
- --fa: "\f64a";
-}
-
-.fa-briefcase-clock {
- --fa: "\f64a";
-}
-
-.fa-city {
- --fa: "\f64f";
-}
-
-.fa-comment-dollar {
- --fa: "\f651";
-}
-
-.fa-comments-dollar {
- --fa: "\f653";
-}
-
-.fa-cross {
- --fa: "\f654";
-}
-
-.fa-dharmachakra {
- --fa: "\f655";
-}
-
-.fa-envelope-open-text {
- --fa: "\f658";
-}
-
-.fa-folder-minus {
- --fa: "\f65d";
-}
-
-.fa-folder-plus {
- --fa: "\f65e";
-}
-
-.fa-filter-circle-dollar {
- --fa: "\f662";
-}
-
-.fa-funnel-dollar {
- --fa: "\f662";
-}
-
-.fa-gopuram {
- --fa: "\f664";
-}
-
-.fa-hamsa {
- --fa: "\f665";
-}
-
-.fa-bahai {
- --fa: "\f666";
-}
-
-.fa-haykal {
- --fa: "\f666";
-}
-
-.fa-jedi {
- --fa: "\f669";
-}
-
-.fa-book-journal-whills {
- --fa: "\f66a";
-}
-
-.fa-journal-whills {
- --fa: "\f66a";
-}
-
-.fa-kaaba {
- --fa: "\f66b";
-}
-
-.fa-khanda {
- --fa: "\f66d";
-}
-
-.fa-landmark {
- --fa: "\f66f";
-}
-
-.fa-envelopes-bulk {
- --fa: "\f674";
-}
-
-.fa-mail-bulk {
- --fa: "\f674";
-}
-
-.fa-menorah {
- --fa: "\f676";
-}
-
-.fa-mosque {
- --fa: "\f678";
-}
-
-.fa-om {
- --fa: "\f679";
-}
-
-.fa-spaghetti-monster-flying {
- --fa: "\f67b";
-}
-
-.fa-pastafarianism {
- --fa: "\f67b";
-}
-
-.fa-peace {
- --fa: "\f67c";
-}
-
-.fa-place-of-worship {
- --fa: "\f67f";
-}
-
-.fa-square-poll-vertical {
- --fa: "\f681";
-}
-
-.fa-poll {
- --fa: "\f681";
-}
-
-.fa-square-poll-horizontal {
- --fa: "\f682";
-}
-
-.fa-poll-h {
- --fa: "\f682";
-}
-
-.fa-person-praying {
- --fa: "\f683";
-}
-
-.fa-pray {
- --fa: "\f683";
-}
-
-.fa-hands-praying {
- --fa: "\f684";
-}
-
-.fa-praying-hands {
- --fa: "\f684";
-}
-
-.fa-book-quran {
- --fa: "\f687";
-}
-
-.fa-quran {
- --fa: "\f687";
-}
-
-.fa-magnifying-glass-dollar {
- --fa: "\f688";
-}
-
-.fa-search-dollar {
- --fa: "\f688";
-}
-
-.fa-magnifying-glass-location {
- --fa: "\f689";
-}
-
-.fa-search-location {
- --fa: "\f689";
-}
-
-.fa-socks {
- --fa: "\f696";
-}
-
-.fa-square-root-variable {
- --fa: "\f698";
-}
-
-.fa-square-root-alt {
- --fa: "\f698";
-}
-
-.fa-star-and-crescent {
- --fa: "\f699";
-}
-
-.fa-star-of-david {
- --fa: "\f69a";
-}
-
-.fa-synagogue {
- --fa: "\f69b";
-}
-
-.fa-scroll-torah {
- --fa: "\f6a0";
-}
-
-.fa-torah {
- --fa: "\f6a0";
-}
-
-.fa-torii-gate {
- --fa: "\f6a1";
-}
-
-.fa-vihara {
- --fa: "\f6a7";
-}
-
-.fa-volume-xmark {
- --fa: "\f6a9";
-}
-
-.fa-volume-mute {
- --fa: "\f6a9";
-}
-
-.fa-volume-times {
- --fa: "\f6a9";
-}
-
-.fa-yin-yang {
- --fa: "\f6ad";
-}
-
-.fa-blender-phone {
- --fa: "\f6b6";
-}
-
-.fa-book-skull {
- --fa: "\f6b7";
-}
-
-.fa-book-dead {
- --fa: "\f6b7";
-}
-
-.fa-campground {
- --fa: "\f6bb";
-}
-
-.fa-cat {
- --fa: "\f6be";
-}
-
-.fa-chair {
- --fa: "\f6c0";
-}
-
-.fa-cloud-moon {
- --fa: "\f6c3";
-}
-
-.fa-cloud-sun {
- --fa: "\f6c4";
-}
-
-.fa-cow {
- --fa: "\f6c8";
-}
-
-.fa-dice-d20 {
- --fa: "\f6cf";
-}
-
-.fa-dice-d6 {
- --fa: "\f6d1";
-}
-
-.fa-dog {
- --fa: "\f6d3";
-}
-
-.fa-dragon {
- --fa: "\f6d5";
-}
-
-.fa-drumstick-bite {
- --fa: "\f6d7";
-}
-
-.fa-dungeon {
- --fa: "\f6d9";
-}
-
-.fa-file-csv {
- --fa: "\f6dd";
-}
-
-.fa-hand-fist {
- --fa: "\f6de";
-}
-
-.fa-fist-raised {
- --fa: "\f6de";
-}
-
-.fa-ghost {
- --fa: "\f6e2";
-}
-
-.fa-hammer {
- --fa: "\f6e3";
-}
-
-.fa-hanukiah {
- --fa: "\f6e6";
-}
-
-.fa-hat-wizard {
- --fa: "\f6e8";
-}
-
-.fa-person-hiking {
- --fa: "\f6ec";
-}
-
-.fa-hiking {
- --fa: "\f6ec";
-}
-
-.fa-hippo {
- --fa: "\f6ed";
-}
-
-.fa-horse {
- --fa: "\f6f0";
-}
-
-.fa-house-chimney-crack {
- --fa: "\f6f1";
-}
-
-.fa-house-damage {
- --fa: "\f6f1";
-}
-
-.fa-hryvnia-sign {
- --fa: "\f6f2";
-}
-
-.fa-hryvnia {
- --fa: "\f6f2";
-}
-
-.fa-mask {
- --fa: "\f6fa";
-}
-
-.fa-mountain {
- --fa: "\f6fc";
-}
-
-.fa-network-wired {
- --fa: "\f6ff";
-}
-
-.fa-otter {
- --fa: "\f700";
-}
-
-.fa-ring {
- --fa: "\f70b";
-}
-
-.fa-person-running {
- --fa: "\f70c";
-}
-
-.fa-running {
- --fa: "\f70c";
-}
-
-.fa-scroll {
- --fa: "\f70e";
-}
-
-.fa-skull-crossbones {
- --fa: "\f714";
-}
-
-.fa-slash {
- --fa: "\f715";
-}
-
-.fa-spider {
- --fa: "\f717";
-}
-
-.fa-toilet-paper {
- --fa: "\f71e";
-}
-
-.fa-toilet-paper-alt {
- --fa: "\f71e";
-}
-
-.fa-toilet-paper-blank {
- --fa: "\f71e";
-}
-
-.fa-tractor {
- --fa: "\f722";
-}
-
-.fa-user-injured {
- --fa: "\f728";
-}
-
-.fa-vr-cardboard {
- --fa: "\f729";
-}
-
-.fa-wand-sparkles {
- --fa: "\f72b";
-}
-
-.fa-wind {
- --fa: "\f72e";
-}
-
-.fa-wine-bottle {
- --fa: "\f72f";
-}
-
-.fa-cloud-meatball {
- --fa: "\f73b";
-}
-
-.fa-cloud-moon-rain {
- --fa: "\f73c";
-}
-
-.fa-cloud-rain {
- --fa: "\f73d";
-}
-
-.fa-cloud-showers-heavy {
- --fa: "\f740";
-}
-
-.fa-cloud-sun-rain {
- --fa: "\f743";
-}
-
-.fa-democrat {
- --fa: "\f747";
-}
-
-.fa-flag-usa {
- --fa: "\f74d";
-}
-
-.fa-hurricane {
- --fa: "\f751";
-}
-
-.fa-landmark-dome {
- --fa: "\f752";
-}
-
-.fa-landmark-alt {
- --fa: "\f752";
-}
-
-.fa-meteor {
- --fa: "\f753";
-}
-
-.fa-person-booth {
- --fa: "\f756";
-}
-
-.fa-poo-storm {
- --fa: "\f75a";
-}
-
-.fa-poo-bolt {
- --fa: "\f75a";
-}
-
-.fa-rainbow {
- --fa: "\f75b";
-}
-
-.fa-republican {
- --fa: "\f75e";
-}
-
-.fa-smog {
- --fa: "\f75f";
-}
-
-.fa-temperature-high {
- --fa: "\f769";
-}
-
-.fa-temperature-low {
- --fa: "\f76b";
-}
-
-.fa-cloud-bolt {
- --fa: "\f76c";
-}
-
-.fa-thunderstorm {
- --fa: "\f76c";
-}
-
-.fa-tornado {
- --fa: "\f76f";
-}
-
-.fa-volcano {
- --fa: "\f770";
-}
-
-.fa-check-to-slot {
- --fa: "\f772";
-}
-
-.fa-vote-yea {
- --fa: "\f772";
-}
-
-.fa-water {
- --fa: "\f773";
-}
-
-.fa-baby {
- --fa: "\f77c";
-}
-
-.fa-baby-carriage {
- --fa: "\f77d";
-}
-
-.fa-carriage-baby {
- --fa: "\f77d";
-}
-
-.fa-biohazard {
- --fa: "\f780";
-}
-
-.fa-blog {
- --fa: "\f781";
-}
-
-.fa-calendar-day {
- --fa: "\f783";
-}
-
-.fa-calendar-week {
- --fa: "\f784";
-}
-
-.fa-candy-cane {
- --fa: "\f786";
-}
-
-.fa-carrot {
- --fa: "\f787";
-}
-
-.fa-cash-register {
- --fa: "\f788";
-}
-
-.fa-minimize {
- --fa: "\f78c";
-}
-
-.fa-compress-arrows-alt {
- --fa: "\f78c";
-}
-
-.fa-dumpster {
- --fa: "\f793";
-}
-
-.fa-dumpster-fire {
- --fa: "\f794";
-}
-
-.fa-ethernet {
- --fa: "\f796";
-}
-
-.fa-gifts {
- --fa: "\f79c";
-}
-
-.fa-champagne-glasses {
- --fa: "\f79f";
-}
-
-.fa-glass-cheers {
- --fa: "\f79f";
-}
-
-.fa-whiskey-glass {
- --fa: "\f7a0";
-}
-
-.fa-glass-whiskey {
- --fa: "\f7a0";
-}
-
-.fa-earth-europe {
- --fa: "\f7a2";
-}
-
-.fa-globe-europe {
- --fa: "\f7a2";
-}
-
-.fa-grip-lines {
- --fa: "\f7a4";
-}
-
-.fa-grip-lines-vertical {
- --fa: "\f7a5";
-}
-
-.fa-guitar {
- --fa: "\f7a6";
-}
-
-.fa-heart-crack {
- --fa: "\f7a9";
-}
-
-.fa-heart-broken {
- --fa: "\f7a9";
-}
-
-.fa-holly-berry {
- --fa: "\f7aa";
-}
-
-.fa-horse-head {
- --fa: "\f7ab";
-}
-
-.fa-icicles {
- --fa: "\f7ad";
-}
-
-.fa-igloo {
- --fa: "\f7ae";
-}
-
-.fa-mitten {
- --fa: "\f7b5";
-}
-
-.fa-mug-hot {
- --fa: "\f7b6";
-}
-
-.fa-radiation {
- --fa: "\f7b9";
-}
-
-.fa-circle-radiation {
- --fa: "\f7ba";
-}
-
-.fa-radiation-alt {
- --fa: "\f7ba";
-}
-
-.fa-restroom {
- --fa: "\f7bd";
-}
-
-.fa-satellite {
- --fa: "\f7bf";
-}
-
-.fa-satellite-dish {
- --fa: "\f7c0";
-}
-
-.fa-sd-card {
- --fa: "\f7c2";
-}
-
-.fa-sim-card {
- --fa: "\f7c4";
-}
-
-.fa-person-skating {
- --fa: "\f7c5";
-}
-
-.fa-skating {
- --fa: "\f7c5";
-}
-
-.fa-person-skiing {
- --fa: "\f7c9";
-}
-
-.fa-skiing {
- --fa: "\f7c9";
-}
-
-.fa-person-skiing-nordic {
- --fa: "\f7ca";
-}
-
-.fa-skiing-nordic {
- --fa: "\f7ca";
-}
-
-.fa-sleigh {
- --fa: "\f7cc";
-}
-
-.fa-comment-sms {
- --fa: "\f7cd";
-}
-
-.fa-sms {
- --fa: "\f7cd";
-}
-
-.fa-person-snowboarding {
- --fa: "\f7ce";
-}
-
-.fa-snowboarding {
- --fa: "\f7ce";
-}
-
-.fa-snowman {
- --fa: "\f7d0";
-}
-
-.fa-snowplow {
- --fa: "\f7d2";
-}
-
-.fa-tenge-sign {
- --fa: "\f7d7";
-}
-
-.fa-tenge {
- --fa: "\f7d7";
-}
-
-.fa-toilet {
- --fa: "\f7d8";
-}
-
-.fa-screwdriver-wrench {
- --fa: "\f7d9";
-}
-
-.fa-tools {
- --fa: "\f7d9";
-}
-
-.fa-cable-car {
- --fa: "\f7da";
-}
-
-.fa-tram {
- --fa: "\f7da";
-}
-
-.fa-fire-flame-curved {
- --fa: "\f7e4";
-}
-
-.fa-fire-alt {
- --fa: "\f7e4";
-}
-
-.fa-bacon {
- --fa: "\f7e5";
-}
-
-.fa-book-medical {
- --fa: "\f7e6";
-}
-
-.fa-bread-slice {
- --fa: "\f7ec";
-}
-
-.fa-cheese {
- --fa: "\f7ef";
-}
-
-.fa-house-chimney-medical {
- --fa: "\f7f2";
-}
-
-.fa-clinic-medical {
- --fa: "\f7f2";
-}
-
-.fa-clipboard-user {
- --fa: "\f7f3";
-}
-
-.fa-comment-medical {
- --fa: "\f7f5";
-}
-
-.fa-crutch {
- --fa: "\f7f7";
-}
-
-.fa-disease {
- --fa: "\f7fa";
-}
-
-.fa-egg {
- --fa: "\f7fb";
-}
-
-.fa-folder-tree {
- --fa: "\f802";
-}
-
-.fa-burger {
- --fa: "\f805";
-}
-
-.fa-hamburger {
- --fa: "\f805";
-}
-
-.fa-hand-middle-finger {
- --fa: "\f806";
-}
-
-.fa-helmet-safety {
- --fa: "\f807";
-}
-
-.fa-hard-hat {
- --fa: "\f807";
-}
-
-.fa-hat-hard {
- --fa: "\f807";
-}
-
-.fa-hospital-user {
- --fa: "\f80d";
-}
-
-.fa-hotdog {
- --fa: "\f80f";
-}
-
-.fa-ice-cream {
- --fa: "\f810";
-}
-
-.fa-laptop-medical {
- --fa: "\f812";
-}
-
-.fa-pager {
- --fa: "\f815";
-}
-
-.fa-pepper-hot {
- --fa: "\f816";
-}
-
-.fa-pizza-slice {
- --fa: "\f818";
-}
-
-.fa-sack-dollar {
- --fa: "\f81d";
-}
-
-.fa-book-tanakh {
- --fa: "\f827";
-}
-
-.fa-tanakh {
- --fa: "\f827";
-}
-
-.fa-bars-progress {
- --fa: "\f828";
-}
-
-.fa-tasks-alt {
- --fa: "\f828";
-}
-
-.fa-trash-arrow-up {
- --fa: "\f829";
-}
-
-.fa-trash-restore {
- --fa: "\f829";
-}
-
-.fa-trash-can-arrow-up {
- --fa: "\f82a";
-}
-
-.fa-trash-restore-alt {
- --fa: "\f82a";
-}
-
-.fa-user-nurse {
- --fa: "\f82f";
-}
-
-.fa-wave-square {
- --fa: "\f83e";
-}
-
-.fa-person-biking {
- --fa: "\f84a";
-}
-
-.fa-biking {
- --fa: "\f84a";
-}
-
-.fa-border-all {
- --fa: "\f84c";
-}
-
-.fa-border-none {
- --fa: "\f850";
-}
-
-.fa-border-top-left {
- --fa: "\f853";
-}
-
-.fa-border-style {
- --fa: "\f853";
-}
-
-.fa-person-digging {
- --fa: "\f85e";
-}
-
-.fa-digging {
- --fa: "\f85e";
-}
-
-.fa-fan {
- --fa: "\f863";
-}
-
-.fa-icons {
- --fa: "\f86d";
-}
-
-.fa-heart-music-camera-bolt {
- --fa: "\f86d";
-}
-
-.fa-phone-flip {
- --fa: "\f879";
-}
-
-.fa-phone-alt {
- --fa: "\f879";
-}
-
-.fa-square-phone-flip {
- --fa: "\f87b";
-}
-
-.fa-phone-square-alt {
- --fa: "\f87b";
-}
-
-.fa-photo-film {
- --fa: "\f87c";
-}
-
-.fa-photo-video {
- --fa: "\f87c";
-}
-
-.fa-text-slash {
- --fa: "\f87d";
-}
-
-.fa-remove-format {
- --fa: "\f87d";
-}
-
-.fa-arrow-down-z-a {
- --fa: "\f881";
-}
-
-.fa-sort-alpha-desc {
- --fa: "\f881";
-}
-
-.fa-sort-alpha-down-alt {
- --fa: "\f881";
-}
-
-.fa-arrow-up-z-a {
- --fa: "\f882";
-}
-
-.fa-sort-alpha-up-alt {
- --fa: "\f882";
-}
-
-.fa-arrow-down-short-wide {
- --fa: "\f884";
-}
-
-.fa-sort-amount-desc {
- --fa: "\f884";
-}
-
-.fa-sort-amount-down-alt {
- --fa: "\f884";
-}
-
-.fa-arrow-up-short-wide {
- --fa: "\f885";
-}
-
-.fa-sort-amount-up-alt {
- --fa: "\f885";
-}
-
-.fa-arrow-down-9-1 {
- --fa: "\f886";
-}
-
-.fa-sort-numeric-desc {
- --fa: "\f886";
-}
-
-.fa-sort-numeric-down-alt {
- --fa: "\f886";
-}
-
-.fa-arrow-up-9-1 {
- --fa: "\f887";
-}
-
-.fa-sort-numeric-up-alt {
- --fa: "\f887";
-}
-
-.fa-spell-check {
- --fa: "\f891";
-}
-
-.fa-voicemail {
- --fa: "\f897";
-}
-
-.fa-hat-cowboy {
- --fa: "\f8c0";
-}
-
-.fa-hat-cowboy-side {
- --fa: "\f8c1";
-}
-
-.fa-computer-mouse {
- --fa: "\f8cc";
-}
-
-.fa-mouse {
- --fa: "\f8cc";
-}
-
-.fa-radio {
- --fa: "\f8d7";
-}
-
-.fa-record-vinyl {
- --fa: "\f8d9";
-}
-
-.fa-walkie-talkie {
- --fa: "\f8ef";
-}
-
-.fa-caravan {
- --fa: "\f8ff";
-}
-:root, :host {
- --fa-family-brands: "Font Awesome 7 Brands";
- --fa-font-brands: normal 400 1em/1 var(--fa-family-brands);
-}
-
-@font-face {
- font-family: "Font Awesome 7 Brands";
- font-style: normal;
- font-weight: 400;
- font-display: block;
- src: url("../webfonts/fa-brands-400.woff2");
-}
-.fab,
-.fa-brands,
-.fa-classic.fa-brands {
- --fa-family: var(--fa-family-brands);
- --fa-style: 400;
-}
-
-.fa-firefox-browser {
- --fa: "\e007";
-}
-
-.fa-ideal {
- --fa: "\e013";
-}
-
-.fa-microblog {
- --fa: "\e01a";
-}
-
-.fa-square-pied-piper {
- --fa: "\e01e";
-}
-
-.fa-pied-piper-square {
- --fa: "\e01e";
-}
-
-.fa-unity {
- --fa: "\e049";
-}
-
-.fa-dailymotion {
- --fa: "\e052";
-}
-
-.fa-square-instagram {
- --fa: "\e055";
-}
-
-.fa-instagram-square {
- --fa: "\e055";
-}
-
-.fa-mixer {
- --fa: "\e056";
-}
-
-.fa-shopify {
- --fa: "\e057";
-}
-
-.fa-deezer {
- --fa: "\e077";
-}
-
-.fa-edge-legacy {
- --fa: "\e078";
-}
-
-.fa-google-pay {
- --fa: "\e079";
-}
-
-.fa-rust {
- --fa: "\e07a";
-}
-
-.fa-tiktok {
- --fa: "\e07b";
-}
-
-.fa-unsplash {
- --fa: "\e07c";
-}
-
-.fa-cloudflare {
- --fa: "\e07d";
-}
-
-.fa-guilded {
- --fa: "\e07e";
-}
-
-.fa-hive {
- --fa: "\e07f";
-}
-
-.fa-42-group {
- --fa: "\e080";
-}
-
-.fa-innosoft {
- --fa: "\e080";
-}
-
-.fa-instalod {
- --fa: "\e081";
-}
-
-.fa-octopus-deploy {
- --fa: "\e082";
-}
-
-.fa-perbyte {
- --fa: "\e083";
-}
-
-.fa-uncharted {
- --fa: "\e084";
-}
-
-.fa-watchman-monitoring {
- --fa: "\e087";
-}
-
-.fa-wodu {
- --fa: "\e088";
-}
-
-.fa-wirsindhandwerk {
- --fa: "\e2d0";
-}
-
-.fa-wsh {
- --fa: "\e2d0";
-}
-
-.fa-bots {
- --fa: "\e340";
-}
-
-.fa-cmplid {
- --fa: "\e360";
-}
-
-.fa-bilibili {
- --fa: "\e3d9";
-}
-
-.fa-golang {
- --fa: "\e40f";
-}
-
-.fa-pix {
- --fa: "\e43a";
-}
-
-.fa-sitrox {
- --fa: "\e44a";
-}
-
-.fa-hashnode {
- --fa: "\e499";
-}
-
-.fa-meta {
- --fa: "\e49b";
-}
-
-.fa-padlet {
- --fa: "\e4a0";
-}
-
-.fa-nfc-directional {
- --fa: "\e530";
-}
-
-.fa-nfc-symbol {
- --fa: "\e531";
-}
-
-.fa-screenpal {
- --fa: "\e570";
-}
-
-.fa-space-awesome {
- --fa: "\e5ac";
-}
-
-.fa-square-font-awesome {
- --fa: "\e5ad";
-}
-
-.fa-square-gitlab {
- --fa: "\e5ae";
-}
-
-.fa-gitlab-square {
- --fa: "\e5ae";
-}
-
-.fa-odysee {
- --fa: "\e5c6";
-}
-
-.fa-stubber {
- --fa: "\e5c7";
-}
-
-.fa-debian {
- --fa: "\e60b";
-}
-
-.fa-shoelace {
- --fa: "\e60c";
-}
-
-.fa-threads {
- --fa: "\e618";
-}
-
-.fa-square-threads {
- --fa: "\e619";
-}
-
-.fa-square-x-twitter {
- --fa: "\e61a";
-}
-
-.fa-x-twitter {
- --fa: "\e61b";
-}
-
-.fa-opensuse {
- --fa: "\e62b";
-}
-
-.fa-letterboxd {
- --fa: "\e62d";
-}
-
-.fa-square-letterboxd {
- --fa: "\e62e";
-}
-
-.fa-mintbit {
- --fa: "\e62f";
-}
-
-.fa-google-scholar {
- --fa: "\e63b";
-}
-
-.fa-brave {
- --fa: "\e63c";
-}
-
-.fa-brave-reverse {
- --fa: "\e63d";
-}
-
-.fa-pixiv {
- --fa: "\e640";
-}
-
-.fa-upwork {
- --fa: "\e641";
-}
-
-.fa-webflow {
- --fa: "\e65c";
-}
-
-.fa-signal-messenger {
- --fa: "\e663";
-}
-
-.fa-bluesky {
- --fa: "\e671";
-}
-
-.fa-jxl {
- --fa: "\e67b";
-}
-
-.fa-square-upwork {
- --fa: "\e67c";
-}
-
-.fa-web-awesome {
- --fa: "\e682";
-}
-
-.fa-square-web-awesome {
- --fa: "\e683";
-}
-
-.fa-square-web-awesome-stroke {
- --fa: "\e684";
-}
-
-.fa-dart-lang {
- --fa: "\e693";
-}
-
-.fa-flutter {
- --fa: "\e694";
-}
-
-.fa-files-pinwheel {
- --fa: "\e69f";
-}
-
-.fa-css {
- --fa: "\e6a2";
-}
-
-.fa-square-bluesky {
- --fa: "\e6a3";
-}
-
-.fa-openai {
- --fa: "\e7cf";
-}
-
-.fa-square-linkedin {
- --fa: "\e7d0";
-}
-
-.fa-cash-app {
- --fa: "\e7d4";
-}
-
-.fa-disqus {
- --fa: "\e7d5";
-}
-
-.fa-eleventy {
- --fa: "\e7d6";
-}
-
-.fa-11ty {
- --fa: "\e7d6";
-}
-
-.fa-kakao-talk {
- --fa: "\e7d7";
-}
-
-.fa-linktree {
- --fa: "\e7d8";
-}
-
-.fa-notion {
- --fa: "\e7d9";
-}
-
-.fa-pandora {
- --fa: "\e7da";
-}
-
-.fa-pixelfed {
- --fa: "\e7db";
-}
-
-.fa-tidal {
- --fa: "\e7dc";
-}
-
-.fa-vsco {
- --fa: "\e7dd";
-}
-
-.fa-w3c {
- --fa: "\e7de";
-}
-
-.fa-lumon {
- --fa: "\e7e2";
-}
-
-.fa-lumon-drop {
- --fa: "\e7e3";
-}
-
-.fa-square-figma {
- --fa: "\e7e4";
-}
-
-.fa-tex {
- --fa: "\e7ff";
-}
-
-.fa-duolingo {
- --fa: "\e812";
-}
-
-.fa-square-twitter {
- --fa: "\f081";
-}
-
-.fa-twitter-square {
- --fa: "\f081";
-}
-
-.fa-square-facebook {
- --fa: "\f082";
-}
-
-.fa-facebook-square {
- --fa: "\f082";
-}
-
-.fa-linkedin {
- --fa: "\f08c";
-}
-
-.fa-square-github {
- --fa: "\f092";
-}
-
-.fa-github-square {
- --fa: "\f092";
-}
-
-.fa-twitter {
- --fa: "\f099";
-}
-
-.fa-facebook {
- --fa: "\f09a";
-}
-
-.fa-github {
- --fa: "\f09b";
-}
-
-.fa-pinterest {
- --fa: "\f0d2";
-}
-
-.fa-square-pinterest {
- --fa: "\f0d3";
-}
-
-.fa-pinterest-square {
- --fa: "\f0d3";
-}
-
-.fa-square-google-plus {
- --fa: "\f0d4";
-}
-
-.fa-google-plus-square {
- --fa: "\f0d4";
-}
-
-.fa-google-plus-g {
- --fa: "\f0d5";
-}
-
-.fa-linkedin-in {
- --fa: "\f0e1";
-}
-
-.fa-github-alt {
- --fa: "\f113";
-}
-
-.fa-maxcdn {
- --fa: "\f136";
-}
-
-.fa-html5 {
- --fa: "\f13b";
-}
-
-.fa-css3 {
- --fa: "\f13c";
-}
-
-.fa-btc {
- --fa: "\f15a";
-}
-
-.fa-youtube {
- --fa: "\f167";
-}
-
-.fa-xing {
- --fa: "\f168";
-}
-
-.fa-square-xing {
- --fa: "\f169";
-}
-
-.fa-xing-square {
- --fa: "\f169";
-}
-
-.fa-dropbox {
- --fa: "\f16b";
-}
-
-.fa-stack-overflow {
- --fa: "\f16c";
-}
-
-.fa-instagram {
- --fa: "\f16d";
-}
-
-.fa-flickr {
- --fa: "\f16e";
-}
-
-.fa-adn {
- --fa: "\f170";
-}
-
-.fa-bitbucket {
- --fa: "\f171";
-}
-
-.fa-tumblr {
- --fa: "\f173";
-}
-
-.fa-square-tumblr {
- --fa: "\f174";
-}
-
-.fa-tumblr-square {
- --fa: "\f174";
-}
-
-.fa-apple {
- --fa: "\f179";
-}
-
-.fa-windows {
- --fa: "\f17a";
-}
-
-.fa-android {
- --fa: "\f17b";
-}
-
-.fa-linux {
- --fa: "\f17c";
-}
-
-.fa-dribbble {
- --fa: "\f17d";
-}
-
-.fa-skype {
- --fa: "\f17e";
-}
-
-.fa-foursquare {
- --fa: "\f180";
-}
-
-.fa-trello {
- --fa: "\f181";
-}
-
-.fa-gratipay {
- --fa: "\f184";
-}
-
-.fa-vk {
- --fa: "\f189";
-}
-
-.fa-weibo {
- --fa: "\f18a";
-}
-
-.fa-renren {
- --fa: "\f18b";
-}
-
-.fa-pagelines {
- --fa: "\f18c";
-}
-
-.fa-stack-exchange {
- --fa: "\f18d";
-}
-
-.fa-square-vimeo {
- --fa: "\f194";
-}
-
-.fa-vimeo-square {
- --fa: "\f194";
-}
-
-.fa-slack {
- --fa: "\f198";
-}
-
-.fa-slack-hash {
- --fa: "\f198";
-}
-
-.fa-wordpress {
- --fa: "\f19a";
-}
-
-.fa-openid {
- --fa: "\f19b";
-}
-
-.fa-yahoo {
- --fa: "\f19e";
-}
-
-.fa-google {
- --fa: "\f1a0";
-}
-
-.fa-reddit {
- --fa: "\f1a1";
-}
-
-.fa-square-reddit {
- --fa: "\f1a2";
-}
-
-.fa-reddit-square {
- --fa: "\f1a2";
-}
-
-.fa-stumbleupon-circle {
- --fa: "\f1a3";
-}
-
-.fa-stumbleupon {
- --fa: "\f1a4";
-}
-
-.fa-delicious {
- --fa: "\f1a5";
-}
-
-.fa-digg {
- --fa: "\f1a6";
-}
-
-.fa-pied-piper-pp {
- --fa: "\f1a7";
-}
-
-.fa-pied-piper-alt {
- --fa: "\f1a8";
-}
-
-.fa-drupal {
- --fa: "\f1a9";
-}
-
-.fa-joomla {
- --fa: "\f1aa";
-}
-
-.fa-behance {
- --fa: "\f1b4";
-}
-
-.fa-square-behance {
- --fa: "\f1b5";
-}
-
-.fa-behance-square {
- --fa: "\f1b5";
-}
-
-.fa-steam {
- --fa: "\f1b6";
-}
-
-.fa-square-steam {
- --fa: "\f1b7";
-}
-
-.fa-steam-square {
- --fa: "\f1b7";
-}
-
-.fa-spotify {
- --fa: "\f1bc";
-}
-
-.fa-deviantart {
- --fa: "\f1bd";
-}
-
-.fa-soundcloud {
- --fa: "\f1be";
-}
-
-.fa-vine {
- --fa: "\f1ca";
-}
-
-.fa-codepen {
- --fa: "\f1cb";
-}
-
-.fa-jsfiddle {
- --fa: "\f1cc";
-}
-
-.fa-rebel {
- --fa: "\f1d0";
-}
-
-.fa-empire {
- --fa: "\f1d1";
-}
-
-.fa-square-git {
- --fa: "\f1d2";
-}
-
-.fa-git-square {
- --fa: "\f1d2";
-}
-
-.fa-git {
- --fa: "\f1d3";
-}
-
-.fa-hacker-news {
- --fa: "\f1d4";
-}
-
-.fa-tencent-weibo {
- --fa: "\f1d5";
-}
-
-.fa-qq {
- --fa: "\f1d6";
-}
-
-.fa-weixin {
- --fa: "\f1d7";
-}
-
-.fa-slideshare {
- --fa: "\f1e7";
-}
-
-.fa-twitch {
- --fa: "\f1e8";
-}
-
-.fa-yelp {
- --fa: "\f1e9";
-}
-
-.fa-paypal {
- --fa: "\f1ed";
-}
-
-.fa-google-wallet {
- --fa: "\f1ee";
-}
-
-.fa-cc-visa {
- --fa: "\f1f0";
-}
-
-.fa-cc-mastercard {
- --fa: "\f1f1";
-}
-
-.fa-cc-discover {
- --fa: "\f1f2";
-}
-
-.fa-cc-amex {
- --fa: "\f1f3";
-}
-
-.fa-cc-paypal {
- --fa: "\f1f4";
-}
-
-.fa-cc-stripe {
- --fa: "\f1f5";
-}
-
-.fa-lastfm {
- --fa: "\f202";
-}
-
-.fa-square-lastfm {
- --fa: "\f203";
-}
-
-.fa-lastfm-square {
- --fa: "\f203";
-}
-
-.fa-ioxhost {
- --fa: "\f208";
-}
-
-.fa-angellist {
- --fa: "\f209";
-}
-
-.fa-buysellads {
- --fa: "\f20d";
-}
-
-.fa-connectdevelop {
- --fa: "\f20e";
-}
-
-.fa-dashcube {
- --fa: "\f210";
-}
-
-.fa-forumbee {
- --fa: "\f211";
-}
-
-.fa-leanpub {
- --fa: "\f212";
-}
-
-.fa-sellsy {
- --fa: "\f213";
-}
-
-.fa-shirtsinbulk {
- --fa: "\f214";
-}
-
-.fa-simplybuilt {
- --fa: "\f215";
-}
-
-.fa-skyatlas {
- --fa: "\f216";
-}
-
-.fa-pinterest-p {
- --fa: "\f231";
-}
-
-.fa-whatsapp {
- --fa: "\f232";
-}
-
-.fa-viacoin {
- --fa: "\f237";
-}
-
-.fa-medium {
- --fa: "\f23a";
-}
-
-.fa-medium-m {
- --fa: "\f23a";
-}
-
-.fa-y-combinator {
- --fa: "\f23b";
-}
-
-.fa-optin-monster {
- --fa: "\f23c";
-}
-
-.fa-opencart {
- --fa: "\f23d";
-}
-
-.fa-expeditedssl {
- --fa: "\f23e";
-}
-
-.fa-cc-jcb {
- --fa: "\f24b";
-}
-
-.fa-cc-diners-club {
- --fa: "\f24c";
-}
-
-.fa-creative-commons {
- --fa: "\f25e";
-}
-
-.fa-gg {
- --fa: "\f260";
-}
-
-.fa-gg-circle {
- --fa: "\f261";
-}
-
-.fa-odnoklassniki {
- --fa: "\f263";
-}
-
-.fa-square-odnoklassniki {
- --fa: "\f264";
-}
-
-.fa-odnoklassniki-square {
- --fa: "\f264";
-}
-
-.fa-get-pocket {
- --fa: "\f265";
-}
-
-.fa-wikipedia-w {
- --fa: "\f266";
-}
-
-.fa-safari {
- --fa: "\f267";
-}
-
-.fa-chrome {
- --fa: "\f268";
-}
-
-.fa-firefox {
- --fa: "\f269";
-}
-
-.fa-opera {
- --fa: "\f26a";
-}
-
-.fa-internet-explorer {
- --fa: "\f26b";
-}
-
-.fa-contao {
- --fa: "\f26d";
-}
-
-.fa-500px {
- --fa: "\f26e";
-}
-
-.fa-amazon {
- --fa: "\f270";
-}
-
-.fa-houzz {
- --fa: "\f27c";
-}
-
-.fa-vimeo-v {
- --fa: "\f27d";
-}
-
-.fa-black-tie {
- --fa: "\f27e";
-}
-
-.fa-fonticons {
- --fa: "\f280";
-}
-
-.fa-reddit-alien {
- --fa: "\f281";
-}
-
-.fa-edge {
- --fa: "\f282";
-}
-
-.fa-codiepie {
- --fa: "\f284";
-}
-
-.fa-modx {
- --fa: "\f285";
-}
-
-.fa-fort-awesome {
- --fa: "\f286";
-}
-
-.fa-usb {
- --fa: "\f287";
-}
-
-.fa-product-hunt {
- --fa: "\f288";
-}
-
-.fa-mixcloud {
- --fa: "\f289";
-}
-
-.fa-scribd {
- --fa: "\f28a";
-}
-
-.fa-bluetooth {
- --fa: "\f293";
-}
-
-.fa-bluetooth-b {
- --fa: "\f294";
-}
-
-.fa-gitlab {
- --fa: "\f296";
-}
-
-.fa-wpbeginner {
- --fa: "\f297";
-}
-
-.fa-wpforms {
- --fa: "\f298";
-}
-
-.fa-envira {
- --fa: "\f299";
-}
-
-.fa-glide {
- --fa: "\f2a5";
-}
-
-.fa-glide-g {
- --fa: "\f2a6";
-}
-
-.fa-viadeo {
- --fa: "\f2a9";
-}
-
-.fa-square-viadeo {
- --fa: "\f2aa";
-}
-
-.fa-viadeo-square {
- --fa: "\f2aa";
-}
-
-.fa-snapchat {
- --fa: "\f2ab";
-}
-
-.fa-snapchat-ghost {
- --fa: "\f2ab";
-}
-
-.fa-square-snapchat {
- --fa: "\f2ad";
-}
-
-.fa-snapchat-square {
- --fa: "\f2ad";
-}
-
-.fa-pied-piper {
- --fa: "\f2ae";
-}
-
-.fa-first-order {
- --fa: "\f2b0";
-}
-
-.fa-yoast {
- --fa: "\f2b1";
-}
-
-.fa-themeisle {
- --fa: "\f2b2";
-}
-
-.fa-google-plus {
- --fa: "\f2b3";
-}
-
-.fa-font-awesome {
- --fa: "\f2b4";
-}
-
-.fa-font-awesome-flag {
- --fa: "\f2b4";
-}
-
-.fa-font-awesome-logo-full {
- --fa: "\f2b4";
-}
-
-.fa-linode {
- --fa: "\f2b8";
-}
-
-.fa-quora {
- --fa: "\f2c4";
-}
-
-.fa-free-code-camp {
- --fa: "\f2c5";
-}
-
-.fa-telegram {
- --fa: "\f2c6";
-}
-
-.fa-telegram-plane {
- --fa: "\f2c6";
-}
-
-.fa-bandcamp {
- --fa: "\f2d5";
-}
-
-.fa-grav {
- --fa: "\f2d6";
-}
-
-.fa-etsy {
- --fa: "\f2d7";
-}
-
-.fa-imdb {
- --fa: "\f2d8";
-}
-
-.fa-ravelry {
- --fa: "\f2d9";
-}
-
-.fa-sellcast {
- --fa: "\f2da";
-}
-
-.fa-superpowers {
- --fa: "\f2dd";
-}
-
-.fa-wpexplorer {
- --fa: "\f2de";
-}
-
-.fa-meetup {
- --fa: "\f2e0";
-}
-
-.fa-square-font-awesome-stroke {
- --fa: "\f35c";
-}
-
-.fa-font-awesome-alt {
- --fa: "\f35c";
-}
-
-.fa-accessible-icon {
- --fa: "\f368";
-}
-
-.fa-accusoft {
- --fa: "\f369";
-}
-
-.fa-adversal {
- --fa: "\f36a";
-}
-
-.fa-affiliatetheme {
- --fa: "\f36b";
-}
-
-.fa-algolia {
- --fa: "\f36c";
-}
-
-.fa-amilia {
- --fa: "\f36d";
-}
-
-.fa-angrycreative {
- --fa: "\f36e";
-}
-
-.fa-app-store {
- --fa: "\f36f";
-}
-
-.fa-app-store-ios {
- --fa: "\f370";
-}
-
-.fa-apper {
- --fa: "\f371";
-}
-
-.fa-asymmetrik {
- --fa: "\f372";
-}
-
-.fa-audible {
- --fa: "\f373";
-}
-
-.fa-avianex {
- --fa: "\f374";
-}
-
-.fa-aws {
- --fa: "\f375";
-}
-
-.fa-bimobject {
- --fa: "\f378";
-}
-
-.fa-bitcoin {
- --fa: "\f379";
-}
-
-.fa-bity {
- --fa: "\f37a";
-}
-
-.fa-blackberry {
- --fa: "\f37b";
-}
-
-.fa-blogger {
- --fa: "\f37c";
-}
-
-.fa-blogger-b {
- --fa: "\f37d";
-}
-
-.fa-buromobelexperte {
- --fa: "\f37f";
-}
-
-.fa-centercode {
- --fa: "\f380";
-}
-
-.fa-cloudscale {
- --fa: "\f383";
-}
-
-.fa-cloudsmith {
- --fa: "\f384";
-}
-
-.fa-cloudversify {
- --fa: "\f385";
-}
-
-.fa-cpanel {
- --fa: "\f388";
-}
-
-.fa-css3-alt {
- --fa: "\f38b";
-}
-
-.fa-cuttlefish {
- --fa: "\f38c";
-}
-
-.fa-d-and-d {
- --fa: "\f38d";
-}
-
-.fa-deploydog {
- --fa: "\f38e";
-}
-
-.fa-deskpro {
- --fa: "\f38f";
-}
-
-.fa-digital-ocean {
- --fa: "\f391";
-}
-
-.fa-discord {
- --fa: "\f392";
-}
-
-.fa-discourse {
- --fa: "\f393";
-}
-
-.fa-dochub {
- --fa: "\f394";
-}
-
-.fa-docker {
- --fa: "\f395";
-}
-
-.fa-draft2digital {
- --fa: "\f396";
-}
-
-.fa-square-dribbble {
- --fa: "\f397";
-}
-
-.fa-dribbble-square {
- --fa: "\f397";
-}
-
-.fa-dyalog {
- --fa: "\f399";
-}
-
-.fa-earlybirds {
- --fa: "\f39a";
-}
-
-.fa-erlang {
- --fa: "\f39d";
-}
-
-.fa-facebook-f {
- --fa: "\f39e";
-}
-
-.fa-facebook-messenger {
- --fa: "\f39f";
-}
-
-.fa-firstdraft {
- --fa: "\f3a1";
-}
-
-.fa-fonticons-fi {
- --fa: "\f3a2";
-}
-
-.fa-fort-awesome-alt {
- --fa: "\f3a3";
-}
-
-.fa-freebsd {
- --fa: "\f3a4";
-}
-
-.fa-gitkraken {
- --fa: "\f3a6";
-}
-
-.fa-gofore {
- --fa: "\f3a7";
-}
-
-.fa-goodreads {
- --fa: "\f3a8";
-}
-
-.fa-goodreads-g {
- --fa: "\f3a9";
-}
-
-.fa-google-drive {
- --fa: "\f3aa";
-}
-
-.fa-google-play {
- --fa: "\f3ab";
-}
-
-.fa-gripfire {
- --fa: "\f3ac";
-}
-
-.fa-grunt {
- --fa: "\f3ad";
-}
-
-.fa-gulp {
- --fa: "\f3ae";
-}
-
-.fa-square-hacker-news {
- --fa: "\f3af";
-}
-
-.fa-hacker-news-square {
- --fa: "\f3af";
-}
-
-.fa-hire-a-helper {
- --fa: "\f3b0";
-}
-
-.fa-hotjar {
- --fa: "\f3b1";
-}
-
-.fa-hubspot {
- --fa: "\f3b2";
-}
-
-.fa-itunes {
- --fa: "\f3b4";
-}
-
-.fa-itunes-note {
- --fa: "\f3b5";
-}
-
-.fa-jenkins {
- --fa: "\f3b6";
-}
-
-.fa-joget {
- --fa: "\f3b7";
-}
-
-.fa-js {
- --fa: "\f3b8";
-}
-
-.fa-square-js {
- --fa: "\f3b9";
-}
-
-.fa-js-square {
- --fa: "\f3b9";
-}
-
-.fa-keycdn {
- --fa: "\f3ba";
-}
-
-.fa-kickstarter {
- --fa: "\f3bb";
-}
-
-.fa-square-kickstarter {
- --fa: "\f3bb";
-}
-
-.fa-kickstarter-k {
- --fa: "\f3bc";
-}
-
-.fa-laravel {
- --fa: "\f3bd";
-}
-
-.fa-line {
- --fa: "\f3c0";
-}
-
-.fa-lyft {
- --fa: "\f3c3";
-}
-
-.fa-magento {
- --fa: "\f3c4";
-}
-
-.fa-medapps {
- --fa: "\f3c6";
-}
-
-.fa-medrt {
- --fa: "\f3c8";
-}
-
-.fa-microsoft {
- --fa: "\f3ca";
-}
-
-.fa-mix {
- --fa: "\f3cb";
-}
-
-.fa-mizuni {
- --fa: "\f3cc";
-}
-
-.fa-monero {
- --fa: "\f3d0";
-}
-
-.fa-napster {
- --fa: "\f3d2";
-}
-
-.fa-node-js {
- --fa: "\f3d3";
-}
-
-.fa-npm {
- --fa: "\f3d4";
-}
-
-.fa-ns8 {
- --fa: "\f3d5";
-}
-
-.fa-nutritionix {
- --fa: "\f3d6";
-}
-
-.fa-page4 {
- --fa: "\f3d7";
-}
-
-.fa-palfed {
- --fa: "\f3d8";
-}
-
-.fa-patreon {
- --fa: "\f3d9";
-}
-
-.fa-periscope {
- --fa: "\f3da";
-}
-
-.fa-phabricator {
- --fa: "\f3db";
-}
-
-.fa-phoenix-framework {
- --fa: "\f3dc";
-}
-
-.fa-playstation {
- --fa: "\f3df";
-}
-
-.fa-pushed {
- --fa: "\f3e1";
-}
-
-.fa-python {
- --fa: "\f3e2";
-}
-
-.fa-red-river {
- --fa: "\f3e3";
-}
-
-.fa-wpressr {
- --fa: "\f3e4";
-}
-
-.fa-rendact {
- --fa: "\f3e4";
-}
-
-.fa-replyd {
- --fa: "\f3e6";
-}
-
-.fa-resolving {
- --fa: "\f3e7";
-}
-
-.fa-rocketchat {
- --fa: "\f3e8";
-}
-
-.fa-rockrms {
- --fa: "\f3e9";
-}
-
-.fa-schlix {
- --fa: "\f3ea";
-}
-
-.fa-searchengin {
- --fa: "\f3eb";
-}
-
-.fa-servicestack {
- --fa: "\f3ec";
-}
-
-.fa-sistrix {
- --fa: "\f3ee";
-}
-
-.fa-speakap {
- --fa: "\f3f3";
-}
-
-.fa-staylinked {
- --fa: "\f3f5";
-}
-
-.fa-steam-symbol {
- --fa: "\f3f6";
-}
-
-.fa-sticker-mule {
- --fa: "\f3f7";
-}
-
-.fa-studiovinari {
- --fa: "\f3f8";
-}
-
-.fa-supple {
- --fa: "\f3f9";
-}
-
-.fa-uber {
- --fa: "\f402";
-}
-
-.fa-uikit {
- --fa: "\f403";
-}
-
-.fa-uniregistry {
- --fa: "\f404";
-}
-
-.fa-untappd {
- --fa: "\f405";
-}
-
-.fa-ussunnah {
- --fa: "\f407";
-}
-
-.fa-vaadin {
- --fa: "\f408";
-}
-
-.fa-viber {
- --fa: "\f409";
-}
-
-.fa-vimeo {
- --fa: "\f40a";
-}
-
-.fa-vnv {
- --fa: "\f40b";
-}
-
-.fa-square-whatsapp {
- --fa: "\f40c";
-}
-
-.fa-whatsapp-square {
- --fa: "\f40c";
-}
-
-.fa-whmcs {
- --fa: "\f40d";
-}
-
-.fa-wordpress-simple {
- --fa: "\f411";
-}
-
-.fa-xbox {
- --fa: "\f412";
-}
-
-.fa-yandex {
- --fa: "\f413";
-}
-
-.fa-yandex-international {
- --fa: "\f414";
-}
-
-.fa-apple-pay {
- --fa: "\f415";
-}
-
-.fa-cc-apple-pay {
- --fa: "\f416";
-}
-
-.fa-fly {
- --fa: "\f417";
-}
-
-.fa-node {
- --fa: "\f419";
-}
-
-.fa-osi {
- --fa: "\f41a";
-}
-
-.fa-react {
- --fa: "\f41b";
-}
-
-.fa-autoprefixer {
- --fa: "\f41c";
-}
-
-.fa-less {
- --fa: "\f41d";
-}
-
-.fa-sass {
- --fa: "\f41e";
-}
-
-.fa-vuejs {
- --fa: "\f41f";
-}
-
-.fa-angular {
- --fa: "\f420";
-}
-
-.fa-aviato {
- --fa: "\f421";
-}
-
-.fa-ember {
- --fa: "\f423";
-}
-
-.fa-gitter {
- --fa: "\f426";
-}
-
-.fa-hooli {
- --fa: "\f427";
-}
-
-.fa-strava {
- --fa: "\f428";
-}
-
-.fa-stripe {
- --fa: "\f429";
-}
-
-.fa-stripe-s {
- --fa: "\f42a";
-}
-
-.fa-typo3 {
- --fa: "\f42b";
-}
-
-.fa-amazon-pay {
- --fa: "\f42c";
-}
-
-.fa-cc-amazon-pay {
- --fa: "\f42d";
-}
-
-.fa-ethereum {
- --fa: "\f42e";
-}
-
-.fa-korvue {
- --fa: "\f42f";
-}
-
-.fa-elementor {
- --fa: "\f430";
-}
-
-.fa-square-youtube {
- --fa: "\f431";
-}
-
-.fa-youtube-square {
- --fa: "\f431";
-}
-
-.fa-flipboard {
- --fa: "\f44d";
-}
-
-.fa-hips {
- --fa: "\f452";
-}
-
-.fa-php {
- --fa: "\f457";
-}
-
-.fa-quinscape {
- --fa: "\f459";
-}
-
-.fa-readme {
- --fa: "\f4d5";
-}
-
-.fa-java {
- --fa: "\f4e4";
-}
-
-.fa-pied-piper-hat {
- --fa: "\f4e5";
-}
-
-.fa-creative-commons-by {
- --fa: "\f4e7";
-}
-
-.fa-creative-commons-nc {
- --fa: "\f4e8";
-}
-
-.fa-creative-commons-nc-eu {
- --fa: "\f4e9";
-}
-
-.fa-creative-commons-nc-jp {
- --fa: "\f4ea";
-}
-
-.fa-creative-commons-nd {
- --fa: "\f4eb";
-}
-
-.fa-creative-commons-pd {
- --fa: "\f4ec";
-}
-
-.fa-creative-commons-pd-alt {
- --fa: "\f4ed";
-}
-
-.fa-creative-commons-remix {
- --fa: "\f4ee";
-}
-
-.fa-creative-commons-sa {
- --fa: "\f4ef";
-}
-
-.fa-creative-commons-sampling {
- --fa: "\f4f0";
-}
-
-.fa-creative-commons-sampling-plus {
- --fa: "\f4f1";
-}
-
-.fa-creative-commons-share {
- --fa: "\f4f2";
-}
-
-.fa-creative-commons-zero {
- --fa: "\f4f3";
-}
-
-.fa-ebay {
- --fa: "\f4f4";
-}
-
-.fa-keybase {
- --fa: "\f4f5";
-}
-
-.fa-mastodon {
- --fa: "\f4f6";
-}
-
-.fa-r-project {
- --fa: "\f4f7";
-}
-
-.fa-researchgate {
- --fa: "\f4f8";
-}
-
-.fa-teamspeak {
- --fa: "\f4f9";
-}
-
-.fa-first-order-alt {
- --fa: "\f50a";
-}
-
-.fa-fulcrum {
- --fa: "\f50b";
-}
-
-.fa-galactic-republic {
- --fa: "\f50c";
-}
-
-.fa-galactic-senate {
- --fa: "\f50d";
-}
-
-.fa-jedi-order {
- --fa: "\f50e";
-}
-
-.fa-mandalorian {
- --fa: "\f50f";
-}
-
-.fa-old-republic {
- --fa: "\f510";
-}
-
-.fa-phoenix-squadron {
- --fa: "\f511";
-}
-
-.fa-sith {
- --fa: "\f512";
-}
-
-.fa-trade-federation {
- --fa: "\f513";
-}
-
-.fa-wolf-pack-battalion {
- --fa: "\f514";
-}
-
-.fa-hornbill {
- --fa: "\f592";
-}
-
-.fa-mailchimp {
- --fa: "\f59e";
-}
-
-.fa-megaport {
- --fa: "\f5a3";
-}
-
-.fa-nimblr {
- --fa: "\f5a8";
-}
-
-.fa-rev {
- --fa: "\f5b2";
-}
-
-.fa-shopware {
- --fa: "\f5b5";
-}
-
-.fa-squarespace {
- --fa: "\f5be";
-}
-
-.fa-themeco {
- --fa: "\f5c6";
-}
-
-.fa-weebly {
- --fa: "\f5cc";
-}
-
-.fa-wix {
- --fa: "\f5cf";
-}
-
-.fa-ello {
- --fa: "\f5f1";
-}
-
-.fa-hackerrank {
- --fa: "\f5f7";
-}
-
-.fa-kaggle {
- --fa: "\f5fa";
-}
-
-.fa-markdown {
- --fa: "\f60f";
-}
-
-.fa-neos {
- --fa: "\f612";
-}
-
-.fa-zhihu {
- --fa: "\f63f";
-}
-
-.fa-alipay {
- --fa: "\f642";
-}
-
-.fa-the-red-yeti {
- --fa: "\f69d";
-}
-
-.fa-critical-role {
- --fa: "\f6c9";
-}
-
-.fa-d-and-d-beyond {
- --fa: "\f6ca";
-}
-
-.fa-dev {
- --fa: "\f6cc";
-}
-
-.fa-fantasy-flight-games {
- --fa: "\f6dc";
-}
-
-.fa-wizards-of-the-coast {
- --fa: "\f730";
-}
-
-.fa-think-peaks {
- --fa: "\f731";
-}
-
-.fa-reacteurope {
- --fa: "\f75d";
-}
-
-.fa-artstation {
- --fa: "\f77a";
-}
-
-.fa-atlassian {
- --fa: "\f77b";
-}
-
-.fa-canadian-maple-leaf {
- --fa: "\f785";
-}
-
-.fa-centos {
- --fa: "\f789";
-}
-
-.fa-confluence {
- --fa: "\f78d";
-}
-
-.fa-dhl {
- --fa: "\f790";
-}
-
-.fa-diaspora {
- --fa: "\f791";
-}
-
-.fa-fedex {
- --fa: "\f797";
-}
-
-.fa-fedora {
- --fa: "\f798";
-}
-
-.fa-figma {
- --fa: "\f799";
-}
-
-.fa-intercom {
- --fa: "\f7af";
-}
-
-.fa-invision {
- --fa: "\f7b0";
-}
-
-.fa-jira {
- --fa: "\f7b1";
-}
-
-.fa-mendeley {
- --fa: "\f7b3";
-}
-
-.fa-raspberry-pi {
- --fa: "\f7bb";
-}
-
-.fa-redhat {
- --fa: "\f7bc";
-}
-
-.fa-sketch {
- --fa: "\f7c6";
-}
-
-.fa-sourcetree {
- --fa: "\f7d3";
-}
-
-.fa-suse {
- --fa: "\f7d6";
-}
-
-.fa-ubuntu {
- --fa: "\f7df";
-}
-
-.fa-ups {
- --fa: "\f7e0";
-}
-
-.fa-usps {
- --fa: "\f7e1";
-}
-
-.fa-yarn {
- --fa: "\f7e3";
-}
-
-.fa-airbnb {
- --fa: "\f834";
-}
-
-.fa-battle-net {
- --fa: "\f835";
-}
-
-.fa-bootstrap {
- --fa: "\f836";
-}
-
-.fa-buffer {
- --fa: "\f837";
-}
-
-.fa-chromecast {
- --fa: "\f838";
-}
-
-.fa-evernote {
- --fa: "\f839";
-}
-
-.fa-itch-io {
- --fa: "\f83a";
-}
-
-.fa-salesforce {
- --fa: "\f83b";
-}
-
-.fa-speaker-deck {
- --fa: "\f83c";
-}
-
-.fa-symfony {
- --fa: "\f83d";
-}
-
-.fa-waze {
- --fa: "\f83f";
-}
-
-.fa-yammer {
- --fa: "\f840";
-}
-
-.fa-git-alt {
- --fa: "\f841";
-}
-
-.fa-stackpath {
- --fa: "\f842";
-}
-
-.fa-cotton-bureau {
- --fa: "\f89e";
-}
-
-.fa-buy-n-large {
- --fa: "\f8a6";
-}
-
-.fa-mdb {
- --fa: "\f8ca";
-}
-
-.fa-orcid {
- --fa: "\f8d2";
-}
-
-.fa-swift {
- --fa: "\f8e1";
-}
-
-.fa-umbraco {
- --fa: "\f8e8";
-}:root, :host {
- --fa-family-classic: "Font Awesome 7 Free";
- --fa-font-regular: normal 400 1em/1 var(--fa-family-classic);
- /* deprecated: this older custom property will be removed next major release */
- --fa-style-family-classic: var(--fa-family-classic);
-}
-
-@font-face {
- font-family: "Font Awesome 7 Free";
- font-style: normal;
- font-weight: 400;
- font-display: block;
- src: url("../webfonts/fa-regular-400.woff2");
-}
-.far {
- --fa-family: var(--fa-family-classic);
- --fa-style: 400;
-}
-
-.fa-classic {
- --fa-family: var(--fa-family-classic);
-}
-
-.fa-regular {
- --fa-style: 400;
-}:root, :host {
- --fa-family-classic: "Font Awesome 7 Free";
- --fa-font-solid: normal 900 1em/1 var(--fa-family-classic);
- /* deprecated: this older custom property will be removed next major release */
- --fa-style-family-classic: var(--fa-family-classic);
-}
-
-@font-face {
- font-family: "Font Awesome 7 Free";
- font-style: normal;
- font-weight: 900;
- font-display: block;
- src: url("../webfonts/fa-solid-900.woff2");
-}
-.fas {
- --fa-family: var(--fa-family-classic);
- --fa-style: 900;
-}
-
-.fa-classic {
- --fa-family: var(--fa-family-classic);
-}
-
-.fa-solid {
- --fa-style: 900;
-}@font-face {
- font-family: "Font Awesome 5 Brands";
- font-display: block;
- font-weight: 400;
- src: url("../webfonts/fa-brands-400.woff2") format("woff2");
-}
-@font-face {
- font-family: "Font Awesome 5 Free";
- font-display: block;
- font-weight: 900;
- src: url("../webfonts/fa-solid-900.woff2") format("woff2");
-}
-@font-face {
- font-family: "Font Awesome 5 Free";
- font-display: block;
- font-weight: 400;
- src: url("../webfonts/fa-regular-400.woff2") format("woff2");
-}@font-face {
- font-family: "FontAwesome";
- font-display: block;
- src: url("../webfonts/fa-solid-900.woff2") format("woff2");
-}
-@font-face {
- font-family: "FontAwesome";
- font-display: block;
- src: url("../webfonts/fa-brands-400.woff2") format("woff2");
-}
-@font-face {
- font-family: "FontAwesome";
- font-display: block;
- src: url("../webfonts/fa-regular-400.woff2") format("woff2");
- unicode-range: U+F003, U+F006, U+F014, U+F016-F017, U+F01A-F01B, U+F01D, U+F022, U+F03E, U+F044, U+F046, U+F05C-F05D, U+F06E, U+F070, U+F087-F088, U+F08A, U+F094, U+F096-F097, U+F09D, U+F0A0, U+F0A2, U+F0A4-F0A7, U+F0C5, U+F0C7, U+F0E5-F0E6, U+F0EB, U+F0F6-F0F8, U+F10C, U+F114-F115, U+F118-F11A, U+F11C-F11D, U+F133, U+F147, U+F14E, U+F150-F152, U+F185-F186, U+F18E, U+F190-F192, U+F196, U+F1C1-F1C9, U+F1D9, U+F1DB, U+F1E3, U+F1EA, U+F1F7, U+F1F9, U+F20A, U+F247-F248, U+F24A, U+F24D, U+F255-F25B, U+F25D, U+F271-F274, U+F278, U+F27B, U+F28C, U+F28E, U+F29C, U+F2B5, U+F2B7, U+F2BA, U+F2BC, U+F2BE, U+F2C0-F2C1, U+F2C3, U+F2D0, U+F2D2, U+F2D4, U+F2DC;
-}
-@font-face {
- font-family: "FontAwesome";
- font-display: block;
- src: url("../webfonts/fa-v4compatibility.woff2") format("woff2");
- unicode-range: U+F041, U+F047, U+F065-F066, U+F07D-F07E, U+F080, U+F08B, U+F08E, U+F090, U+F09A, U+F0AC, U+F0AE, U+F0B2, U+F0D0, U+F0D6, U+F0E4, U+F0EC, U+F10A-F10B, U+F123, U+F13E, U+F148-F149, U+F14C, U+F156, U+F15E, U+F160-F161, U+F163, U+F175-F178, U+F195, U+F1F8, U+F219, U+F27A;
-}
\ No newline at end of file
diff --git a/src/media/vendor/fa7free/css/brands.css b/src/media/vendor/fa7free/css/brands.css
deleted file mode 100644
index 389e32b..0000000
--- a/src/media/vendor/fa7free/css/brands.css
+++ /dev/null
@@ -1,2219 +0,0 @@
-/*!
- * Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
- * Copyright 2025 Fonticons, Inc.
- */
-:root, :host {
- --fa-family-brands: "Font Awesome 7 Brands";
- --fa-font-brands: normal 400 1em/1 var(--fa-family-brands);
-}
-
-@font-face {
- font-family: "Font Awesome 7 Brands";
- font-style: normal;
- font-weight: 400;
- font-display: block;
- src: url("../webfonts/fa-brands-400.woff2");
-}
-.fab,
-.fa-brands,
-.fa-classic.fa-brands {
- --fa-family: var(--fa-family-brands);
- --fa-style: 400;
-}
-
-.fa-firefox-browser {
- --fa: "\e007";
-}
-
-.fa-ideal {
- --fa: "\e013";
-}
-
-.fa-microblog {
- --fa: "\e01a";
-}
-
-.fa-square-pied-piper {
- --fa: "\e01e";
-}
-
-.fa-pied-piper-square {
- --fa: "\e01e";
-}
-
-.fa-unity {
- --fa: "\e049";
-}
-
-.fa-dailymotion {
- --fa: "\e052";
-}
-
-.fa-square-instagram {
- --fa: "\e055";
-}
-
-.fa-instagram-square {
- --fa: "\e055";
-}
-
-.fa-mixer {
- --fa: "\e056";
-}
-
-.fa-shopify {
- --fa: "\e057";
-}
-
-.fa-deezer {
- --fa: "\e077";
-}
-
-.fa-edge-legacy {
- --fa: "\e078";
-}
-
-.fa-google-pay {
- --fa: "\e079";
-}
-
-.fa-rust {
- --fa: "\e07a";
-}
-
-.fa-tiktok {
- --fa: "\e07b";
-}
-
-.fa-unsplash {
- --fa: "\e07c";
-}
-
-.fa-cloudflare {
- --fa: "\e07d";
-}
-
-.fa-guilded {
- --fa: "\e07e";
-}
-
-.fa-hive {
- --fa: "\e07f";
-}
-
-.fa-42-group {
- --fa: "\e080";
-}
-
-.fa-innosoft {
- --fa: "\e080";
-}
-
-.fa-instalod {
- --fa: "\e081";
-}
-
-.fa-octopus-deploy {
- --fa: "\e082";
-}
-
-.fa-perbyte {
- --fa: "\e083";
-}
-
-.fa-uncharted {
- --fa: "\e084";
-}
-
-.fa-watchman-monitoring {
- --fa: "\e087";
-}
-
-.fa-wodu {
- --fa: "\e088";
-}
-
-.fa-wirsindhandwerk {
- --fa: "\e2d0";
-}
-
-.fa-wsh {
- --fa: "\e2d0";
-}
-
-.fa-bots {
- --fa: "\e340";
-}
-
-.fa-cmplid {
- --fa: "\e360";
-}
-
-.fa-bilibili {
- --fa: "\e3d9";
-}
-
-.fa-golang {
- --fa: "\e40f";
-}
-
-.fa-pix {
- --fa: "\e43a";
-}
-
-.fa-sitrox {
- --fa: "\e44a";
-}
-
-.fa-hashnode {
- --fa: "\e499";
-}
-
-.fa-meta {
- --fa: "\e49b";
-}
-
-.fa-padlet {
- --fa: "\e4a0";
-}
-
-.fa-nfc-directional {
- --fa: "\e530";
-}
-
-.fa-nfc-symbol {
- --fa: "\e531";
-}
-
-.fa-screenpal {
- --fa: "\e570";
-}
-
-.fa-space-awesome {
- --fa: "\e5ac";
-}
-
-.fa-square-font-awesome {
- --fa: "\e5ad";
-}
-
-.fa-square-gitlab {
- --fa: "\e5ae";
-}
-
-.fa-gitlab-square {
- --fa: "\e5ae";
-}
-
-.fa-odysee {
- --fa: "\e5c6";
-}
-
-.fa-stubber {
- --fa: "\e5c7";
-}
-
-.fa-debian {
- --fa: "\e60b";
-}
-
-.fa-shoelace {
- --fa: "\e60c";
-}
-
-.fa-threads {
- --fa: "\e618";
-}
-
-.fa-square-threads {
- --fa: "\e619";
-}
-
-.fa-square-x-twitter {
- --fa: "\e61a";
-}
-
-.fa-x-twitter {
- --fa: "\e61b";
-}
-
-.fa-opensuse {
- --fa: "\e62b";
-}
-
-.fa-letterboxd {
- --fa: "\e62d";
-}
-
-.fa-square-letterboxd {
- --fa: "\e62e";
-}
-
-.fa-mintbit {
- --fa: "\e62f";
-}
-
-.fa-google-scholar {
- --fa: "\e63b";
-}
-
-.fa-brave {
- --fa: "\e63c";
-}
-
-.fa-brave-reverse {
- --fa: "\e63d";
-}
-
-.fa-pixiv {
- --fa: "\e640";
-}
-
-.fa-upwork {
- --fa: "\e641";
-}
-
-.fa-webflow {
- --fa: "\e65c";
-}
-
-.fa-signal-messenger {
- --fa: "\e663";
-}
-
-.fa-bluesky {
- --fa: "\e671";
-}
-
-.fa-jxl {
- --fa: "\e67b";
-}
-
-.fa-square-upwork {
- --fa: "\e67c";
-}
-
-.fa-web-awesome {
- --fa: "\e682";
-}
-
-.fa-square-web-awesome {
- --fa: "\e683";
-}
-
-.fa-square-web-awesome-stroke {
- --fa: "\e684";
-}
-
-.fa-dart-lang {
- --fa: "\e693";
-}
-
-.fa-flutter {
- --fa: "\e694";
-}
-
-.fa-files-pinwheel {
- --fa: "\e69f";
-}
-
-.fa-css {
- --fa: "\e6a2";
-}
-
-.fa-square-bluesky {
- --fa: "\e6a3";
-}
-
-.fa-openai {
- --fa: "\e7cf";
-}
-
-.fa-square-linkedin {
- --fa: "\e7d0";
-}
-
-.fa-cash-app {
- --fa: "\e7d4";
-}
-
-.fa-disqus {
- --fa: "\e7d5";
-}
-
-.fa-eleventy {
- --fa: "\e7d6";
-}
-
-.fa-11ty {
- --fa: "\e7d6";
-}
-
-.fa-kakao-talk {
- --fa: "\e7d7";
-}
-
-.fa-linktree {
- --fa: "\e7d8";
-}
-
-.fa-notion {
- --fa: "\e7d9";
-}
-
-.fa-pandora {
- --fa: "\e7da";
-}
-
-.fa-pixelfed {
- --fa: "\e7db";
-}
-
-.fa-tidal {
- --fa: "\e7dc";
-}
-
-.fa-vsco {
- --fa: "\e7dd";
-}
-
-.fa-w3c {
- --fa: "\e7de";
-}
-
-.fa-lumon {
- --fa: "\e7e2";
-}
-
-.fa-lumon-drop {
- --fa: "\e7e3";
-}
-
-.fa-square-figma {
- --fa: "\e7e4";
-}
-
-.fa-tex {
- --fa: "\e7ff";
-}
-
-.fa-duolingo {
- --fa: "\e812";
-}
-
-.fa-square-twitter {
- --fa: "\f081";
-}
-
-.fa-twitter-square {
- --fa: "\f081";
-}
-
-.fa-square-facebook {
- --fa: "\f082";
-}
-
-.fa-facebook-square {
- --fa: "\f082";
-}
-
-.fa-linkedin {
- --fa: "\f08c";
-}
-
-.fa-square-github {
- --fa: "\f092";
-}
-
-.fa-github-square {
- --fa: "\f092";
-}
-
-.fa-twitter {
- --fa: "\f099";
-}
-
-.fa-facebook {
- --fa: "\f09a";
-}
-
-.fa-github {
- --fa: "\f09b";
-}
-
-.fa-pinterest {
- --fa: "\f0d2";
-}
-
-.fa-square-pinterest {
- --fa: "\f0d3";
-}
-
-.fa-pinterest-square {
- --fa: "\f0d3";
-}
-
-.fa-square-google-plus {
- --fa: "\f0d4";
-}
-
-.fa-google-plus-square {
- --fa: "\f0d4";
-}
-
-.fa-google-plus-g {
- --fa: "\f0d5";
-}
-
-.fa-linkedin-in {
- --fa: "\f0e1";
-}
-
-.fa-github-alt {
- --fa: "\f113";
-}
-
-.fa-maxcdn {
- --fa: "\f136";
-}
-
-.fa-html5 {
- --fa: "\f13b";
-}
-
-.fa-css3 {
- --fa: "\f13c";
-}
-
-.fa-btc {
- --fa: "\f15a";
-}
-
-.fa-youtube {
- --fa: "\f167";
-}
-
-.fa-xing {
- --fa: "\f168";
-}
-
-.fa-square-xing {
- --fa: "\f169";
-}
-
-.fa-xing-square {
- --fa: "\f169";
-}
-
-.fa-dropbox {
- --fa: "\f16b";
-}
-
-.fa-stack-overflow {
- --fa: "\f16c";
-}
-
-.fa-instagram {
- --fa: "\f16d";
-}
-
-.fa-flickr {
- --fa: "\f16e";
-}
-
-.fa-adn {
- --fa: "\f170";
-}
-
-.fa-bitbucket {
- --fa: "\f171";
-}
-
-.fa-tumblr {
- --fa: "\f173";
-}
-
-.fa-square-tumblr {
- --fa: "\f174";
-}
-
-.fa-tumblr-square {
- --fa: "\f174";
-}
-
-.fa-apple {
- --fa: "\f179";
-}
-
-.fa-windows {
- --fa: "\f17a";
-}
-
-.fa-android {
- --fa: "\f17b";
-}
-
-.fa-linux {
- --fa: "\f17c";
-}
-
-.fa-dribbble {
- --fa: "\f17d";
-}
-
-.fa-skype {
- --fa: "\f17e";
-}
-
-.fa-foursquare {
- --fa: "\f180";
-}
-
-.fa-trello {
- --fa: "\f181";
-}
-
-.fa-gratipay {
- --fa: "\f184";
-}
-
-.fa-vk {
- --fa: "\f189";
-}
-
-.fa-weibo {
- --fa: "\f18a";
-}
-
-.fa-renren {
- --fa: "\f18b";
-}
-
-.fa-pagelines {
- --fa: "\f18c";
-}
-
-.fa-stack-exchange {
- --fa: "\f18d";
-}
-
-.fa-square-vimeo {
- --fa: "\f194";
-}
-
-.fa-vimeo-square {
- --fa: "\f194";
-}
-
-.fa-slack {
- --fa: "\f198";
-}
-
-.fa-slack-hash {
- --fa: "\f198";
-}
-
-.fa-wordpress {
- --fa: "\f19a";
-}
-
-.fa-openid {
- --fa: "\f19b";
-}
-
-.fa-yahoo {
- --fa: "\f19e";
-}
-
-.fa-google {
- --fa: "\f1a0";
-}
-
-.fa-reddit {
- --fa: "\f1a1";
-}
-
-.fa-square-reddit {
- --fa: "\f1a2";
-}
-
-.fa-reddit-square {
- --fa: "\f1a2";
-}
-
-.fa-stumbleupon-circle {
- --fa: "\f1a3";
-}
-
-.fa-stumbleupon {
- --fa: "\f1a4";
-}
-
-.fa-delicious {
- --fa: "\f1a5";
-}
-
-.fa-digg {
- --fa: "\f1a6";
-}
-
-.fa-pied-piper-pp {
- --fa: "\f1a7";
-}
-
-.fa-pied-piper-alt {
- --fa: "\f1a8";
-}
-
-.fa-drupal {
- --fa: "\f1a9";
-}
-
-.fa-joomla {
- --fa: "\f1aa";
-}
-
-.fa-behance {
- --fa: "\f1b4";
-}
-
-.fa-square-behance {
- --fa: "\f1b5";
-}
-
-.fa-behance-square {
- --fa: "\f1b5";
-}
-
-.fa-steam {
- --fa: "\f1b6";
-}
-
-.fa-square-steam {
- --fa: "\f1b7";
-}
-
-.fa-steam-square {
- --fa: "\f1b7";
-}
-
-.fa-spotify {
- --fa: "\f1bc";
-}
-
-.fa-deviantart {
- --fa: "\f1bd";
-}
-
-.fa-soundcloud {
- --fa: "\f1be";
-}
-
-.fa-vine {
- --fa: "\f1ca";
-}
-
-.fa-codepen {
- --fa: "\f1cb";
-}
-
-.fa-jsfiddle {
- --fa: "\f1cc";
-}
-
-.fa-rebel {
- --fa: "\f1d0";
-}
-
-.fa-empire {
- --fa: "\f1d1";
-}
-
-.fa-square-git {
- --fa: "\f1d2";
-}
-
-.fa-git-square {
- --fa: "\f1d2";
-}
-
-.fa-git {
- --fa: "\f1d3";
-}
-
-.fa-hacker-news {
- --fa: "\f1d4";
-}
-
-.fa-tencent-weibo {
- --fa: "\f1d5";
-}
-
-.fa-qq {
- --fa: "\f1d6";
-}
-
-.fa-weixin {
- --fa: "\f1d7";
-}
-
-.fa-slideshare {
- --fa: "\f1e7";
-}
-
-.fa-twitch {
- --fa: "\f1e8";
-}
-
-.fa-yelp {
- --fa: "\f1e9";
-}
-
-.fa-paypal {
- --fa: "\f1ed";
-}
-
-.fa-google-wallet {
- --fa: "\f1ee";
-}
-
-.fa-cc-visa {
- --fa: "\f1f0";
-}
-
-.fa-cc-mastercard {
- --fa: "\f1f1";
-}
-
-.fa-cc-discover {
- --fa: "\f1f2";
-}
-
-.fa-cc-amex {
- --fa: "\f1f3";
-}
-
-.fa-cc-paypal {
- --fa: "\f1f4";
-}
-
-.fa-cc-stripe {
- --fa: "\f1f5";
-}
-
-.fa-lastfm {
- --fa: "\f202";
-}
-
-.fa-square-lastfm {
- --fa: "\f203";
-}
-
-.fa-lastfm-square {
- --fa: "\f203";
-}
-
-.fa-ioxhost {
- --fa: "\f208";
-}
-
-.fa-angellist {
- --fa: "\f209";
-}
-
-.fa-buysellads {
- --fa: "\f20d";
-}
-
-.fa-connectdevelop {
- --fa: "\f20e";
-}
-
-.fa-dashcube {
- --fa: "\f210";
-}
-
-.fa-forumbee {
- --fa: "\f211";
-}
-
-.fa-leanpub {
- --fa: "\f212";
-}
-
-.fa-sellsy {
- --fa: "\f213";
-}
-
-.fa-shirtsinbulk {
- --fa: "\f214";
-}
-
-.fa-simplybuilt {
- --fa: "\f215";
-}
-
-.fa-skyatlas {
- --fa: "\f216";
-}
-
-.fa-pinterest-p {
- --fa: "\f231";
-}
-
-.fa-whatsapp {
- --fa: "\f232";
-}
-
-.fa-viacoin {
- --fa: "\f237";
-}
-
-.fa-medium {
- --fa: "\f23a";
-}
-
-.fa-medium-m {
- --fa: "\f23a";
-}
-
-.fa-y-combinator {
- --fa: "\f23b";
-}
-
-.fa-optin-monster {
- --fa: "\f23c";
-}
-
-.fa-opencart {
- --fa: "\f23d";
-}
-
-.fa-expeditedssl {
- --fa: "\f23e";
-}
-
-.fa-cc-jcb {
- --fa: "\f24b";
-}
-
-.fa-cc-diners-club {
- --fa: "\f24c";
-}
-
-.fa-creative-commons {
- --fa: "\f25e";
-}
-
-.fa-gg {
- --fa: "\f260";
-}
-
-.fa-gg-circle {
- --fa: "\f261";
-}
-
-.fa-odnoklassniki {
- --fa: "\f263";
-}
-
-.fa-square-odnoklassniki {
- --fa: "\f264";
-}
-
-.fa-odnoklassniki-square {
- --fa: "\f264";
-}
-
-.fa-get-pocket {
- --fa: "\f265";
-}
-
-.fa-wikipedia-w {
- --fa: "\f266";
-}
-
-.fa-safari {
- --fa: "\f267";
-}
-
-.fa-chrome {
- --fa: "\f268";
-}
-
-.fa-firefox {
- --fa: "\f269";
-}
-
-.fa-opera {
- --fa: "\f26a";
-}
-
-.fa-internet-explorer {
- --fa: "\f26b";
-}
-
-.fa-contao {
- --fa: "\f26d";
-}
-
-.fa-500px {
- --fa: "\f26e";
-}
-
-.fa-amazon {
- --fa: "\f270";
-}
-
-.fa-houzz {
- --fa: "\f27c";
-}
-
-.fa-vimeo-v {
- --fa: "\f27d";
-}
-
-.fa-black-tie {
- --fa: "\f27e";
-}
-
-.fa-fonticons {
- --fa: "\f280";
-}
-
-.fa-reddit-alien {
- --fa: "\f281";
-}
-
-.fa-edge {
- --fa: "\f282";
-}
-
-.fa-codiepie {
- --fa: "\f284";
-}
-
-.fa-modx {
- --fa: "\f285";
-}
-
-.fa-fort-awesome {
- --fa: "\f286";
-}
-
-.fa-usb {
- --fa: "\f287";
-}
-
-.fa-product-hunt {
- --fa: "\f288";
-}
-
-.fa-mixcloud {
- --fa: "\f289";
-}
-
-.fa-scribd {
- --fa: "\f28a";
-}
-
-.fa-bluetooth {
- --fa: "\f293";
-}
-
-.fa-bluetooth-b {
- --fa: "\f294";
-}
-
-.fa-gitlab {
- --fa: "\f296";
-}
-
-.fa-wpbeginner {
- --fa: "\f297";
-}
-
-.fa-wpforms {
- --fa: "\f298";
-}
-
-.fa-envira {
- --fa: "\f299";
-}
-
-.fa-glide {
- --fa: "\f2a5";
-}
-
-.fa-glide-g {
- --fa: "\f2a6";
-}
-
-.fa-viadeo {
- --fa: "\f2a9";
-}
-
-.fa-square-viadeo {
- --fa: "\f2aa";
-}
-
-.fa-viadeo-square {
- --fa: "\f2aa";
-}
-
-.fa-snapchat {
- --fa: "\f2ab";
-}
-
-.fa-snapchat-ghost {
- --fa: "\f2ab";
-}
-
-.fa-square-snapchat {
- --fa: "\f2ad";
-}
-
-.fa-snapchat-square {
- --fa: "\f2ad";
-}
-
-.fa-pied-piper {
- --fa: "\f2ae";
-}
-
-.fa-first-order {
- --fa: "\f2b0";
-}
-
-.fa-yoast {
- --fa: "\f2b1";
-}
-
-.fa-themeisle {
- --fa: "\f2b2";
-}
-
-.fa-google-plus {
- --fa: "\f2b3";
-}
-
-.fa-font-awesome {
- --fa: "\f2b4";
-}
-
-.fa-font-awesome-flag {
- --fa: "\f2b4";
-}
-
-.fa-font-awesome-logo-full {
- --fa: "\f2b4";
-}
-
-.fa-linode {
- --fa: "\f2b8";
-}
-
-.fa-quora {
- --fa: "\f2c4";
-}
-
-.fa-free-code-camp {
- --fa: "\f2c5";
-}
-
-.fa-telegram {
- --fa: "\f2c6";
-}
-
-.fa-telegram-plane {
- --fa: "\f2c6";
-}
-
-.fa-bandcamp {
- --fa: "\f2d5";
-}
-
-.fa-grav {
- --fa: "\f2d6";
-}
-
-.fa-etsy {
- --fa: "\f2d7";
-}
-
-.fa-imdb {
- --fa: "\f2d8";
-}
-
-.fa-ravelry {
- --fa: "\f2d9";
-}
-
-.fa-sellcast {
- --fa: "\f2da";
-}
-
-.fa-superpowers {
- --fa: "\f2dd";
-}
-
-.fa-wpexplorer {
- --fa: "\f2de";
-}
-
-.fa-meetup {
- --fa: "\f2e0";
-}
-
-.fa-square-font-awesome-stroke {
- --fa: "\f35c";
-}
-
-.fa-font-awesome-alt {
- --fa: "\f35c";
-}
-
-.fa-accessible-icon {
- --fa: "\f368";
-}
-
-.fa-accusoft {
- --fa: "\f369";
-}
-
-.fa-adversal {
- --fa: "\f36a";
-}
-
-.fa-affiliatetheme {
- --fa: "\f36b";
-}
-
-.fa-algolia {
- --fa: "\f36c";
-}
-
-.fa-amilia {
- --fa: "\f36d";
-}
-
-.fa-angrycreative {
- --fa: "\f36e";
-}
-
-.fa-app-store {
- --fa: "\f36f";
-}
-
-.fa-app-store-ios {
- --fa: "\f370";
-}
-
-.fa-apper {
- --fa: "\f371";
-}
-
-.fa-asymmetrik {
- --fa: "\f372";
-}
-
-.fa-audible {
- --fa: "\f373";
-}
-
-.fa-avianex {
- --fa: "\f374";
-}
-
-.fa-aws {
- --fa: "\f375";
-}
-
-.fa-bimobject {
- --fa: "\f378";
-}
-
-.fa-bitcoin {
- --fa: "\f379";
-}
-
-.fa-bity {
- --fa: "\f37a";
-}
-
-.fa-blackberry {
- --fa: "\f37b";
-}
-
-.fa-blogger {
- --fa: "\f37c";
-}
-
-.fa-blogger-b {
- --fa: "\f37d";
-}
-
-.fa-buromobelexperte {
- --fa: "\f37f";
-}
-
-.fa-centercode {
- --fa: "\f380";
-}
-
-.fa-cloudscale {
- --fa: "\f383";
-}
-
-.fa-cloudsmith {
- --fa: "\f384";
-}
-
-.fa-cloudversify {
- --fa: "\f385";
-}
-
-.fa-cpanel {
- --fa: "\f388";
-}
-
-.fa-css3-alt {
- --fa: "\f38b";
-}
-
-.fa-cuttlefish {
- --fa: "\f38c";
-}
-
-.fa-d-and-d {
- --fa: "\f38d";
-}
-
-.fa-deploydog {
- --fa: "\f38e";
-}
-
-.fa-deskpro {
- --fa: "\f38f";
-}
-
-.fa-digital-ocean {
- --fa: "\f391";
-}
-
-.fa-discord {
- --fa: "\f392";
-}
-
-.fa-discourse {
- --fa: "\f393";
-}
-
-.fa-dochub {
- --fa: "\f394";
-}
-
-.fa-docker {
- --fa: "\f395";
-}
-
-.fa-draft2digital {
- --fa: "\f396";
-}
-
-.fa-square-dribbble {
- --fa: "\f397";
-}
-
-.fa-dribbble-square {
- --fa: "\f397";
-}
-
-.fa-dyalog {
- --fa: "\f399";
-}
-
-.fa-earlybirds {
- --fa: "\f39a";
-}
-
-.fa-erlang {
- --fa: "\f39d";
-}
-
-.fa-facebook-f {
- --fa: "\f39e";
-}
-
-.fa-facebook-messenger {
- --fa: "\f39f";
-}
-
-.fa-firstdraft {
- --fa: "\f3a1";
-}
-
-.fa-fonticons-fi {
- --fa: "\f3a2";
-}
-
-.fa-fort-awesome-alt {
- --fa: "\f3a3";
-}
-
-.fa-freebsd {
- --fa: "\f3a4";
-}
-
-.fa-gitkraken {
- --fa: "\f3a6";
-}
-
-.fa-gofore {
- --fa: "\f3a7";
-}
-
-.fa-goodreads {
- --fa: "\f3a8";
-}
-
-.fa-goodreads-g {
- --fa: "\f3a9";
-}
-
-.fa-google-drive {
- --fa: "\f3aa";
-}
-
-.fa-google-play {
- --fa: "\f3ab";
-}
-
-.fa-gripfire {
- --fa: "\f3ac";
-}
-
-.fa-grunt {
- --fa: "\f3ad";
-}
-
-.fa-gulp {
- --fa: "\f3ae";
-}
-
-.fa-square-hacker-news {
- --fa: "\f3af";
-}
-
-.fa-hacker-news-square {
- --fa: "\f3af";
-}
-
-.fa-hire-a-helper {
- --fa: "\f3b0";
-}
-
-.fa-hotjar {
- --fa: "\f3b1";
-}
-
-.fa-hubspot {
- --fa: "\f3b2";
-}
-
-.fa-itunes {
- --fa: "\f3b4";
-}
-
-.fa-itunes-note {
- --fa: "\f3b5";
-}
-
-.fa-jenkins {
- --fa: "\f3b6";
-}
-
-.fa-joget {
- --fa: "\f3b7";
-}
-
-.fa-js {
- --fa: "\f3b8";
-}
-
-.fa-square-js {
- --fa: "\f3b9";
-}
-
-.fa-js-square {
- --fa: "\f3b9";
-}
-
-.fa-keycdn {
- --fa: "\f3ba";
-}
-
-.fa-kickstarter {
- --fa: "\f3bb";
-}
-
-.fa-square-kickstarter {
- --fa: "\f3bb";
-}
-
-.fa-kickstarter-k {
- --fa: "\f3bc";
-}
-
-.fa-laravel {
- --fa: "\f3bd";
-}
-
-.fa-line {
- --fa: "\f3c0";
-}
-
-.fa-lyft {
- --fa: "\f3c3";
-}
-
-.fa-magento {
- --fa: "\f3c4";
-}
-
-.fa-medapps {
- --fa: "\f3c6";
-}
-
-.fa-medrt {
- --fa: "\f3c8";
-}
-
-.fa-microsoft {
- --fa: "\f3ca";
-}
-
-.fa-mix {
- --fa: "\f3cb";
-}
-
-.fa-mizuni {
- --fa: "\f3cc";
-}
-
-.fa-monero {
- --fa: "\f3d0";
-}
-
-.fa-napster {
- --fa: "\f3d2";
-}
-
-.fa-node-js {
- --fa: "\f3d3";
-}
-
-.fa-npm {
- --fa: "\f3d4";
-}
-
-.fa-ns8 {
- --fa: "\f3d5";
-}
-
-.fa-nutritionix {
- --fa: "\f3d6";
-}
-
-.fa-page4 {
- --fa: "\f3d7";
-}
-
-.fa-palfed {
- --fa: "\f3d8";
-}
-
-.fa-patreon {
- --fa: "\f3d9";
-}
-
-.fa-periscope {
- --fa: "\f3da";
-}
-
-.fa-phabricator {
- --fa: "\f3db";
-}
-
-.fa-phoenix-framework {
- --fa: "\f3dc";
-}
-
-.fa-playstation {
- --fa: "\f3df";
-}
-
-.fa-pushed {
- --fa: "\f3e1";
-}
-
-.fa-python {
- --fa: "\f3e2";
-}
-
-.fa-red-river {
- --fa: "\f3e3";
-}
-
-.fa-wpressr {
- --fa: "\f3e4";
-}
-
-.fa-rendact {
- --fa: "\f3e4";
-}
-
-.fa-replyd {
- --fa: "\f3e6";
-}
-
-.fa-resolving {
- --fa: "\f3e7";
-}
-
-.fa-rocketchat {
- --fa: "\f3e8";
-}
-
-.fa-rockrms {
- --fa: "\f3e9";
-}
-
-.fa-schlix {
- --fa: "\f3ea";
-}
-
-.fa-searchengin {
- --fa: "\f3eb";
-}
-
-.fa-servicestack {
- --fa: "\f3ec";
-}
-
-.fa-sistrix {
- --fa: "\f3ee";
-}
-
-.fa-speakap {
- --fa: "\f3f3";
-}
-
-.fa-staylinked {
- --fa: "\f3f5";
-}
-
-.fa-steam-symbol {
- --fa: "\f3f6";
-}
-
-.fa-sticker-mule {
- --fa: "\f3f7";
-}
-
-.fa-studiovinari {
- --fa: "\f3f8";
-}
-
-.fa-supple {
- --fa: "\f3f9";
-}
-
-.fa-uber {
- --fa: "\f402";
-}
-
-.fa-uikit {
- --fa: "\f403";
-}
-
-.fa-uniregistry {
- --fa: "\f404";
-}
-
-.fa-untappd {
- --fa: "\f405";
-}
-
-.fa-ussunnah {
- --fa: "\f407";
-}
-
-.fa-vaadin {
- --fa: "\f408";
-}
-
-.fa-viber {
- --fa: "\f409";
-}
-
-.fa-vimeo {
- --fa: "\f40a";
-}
-
-.fa-vnv {
- --fa: "\f40b";
-}
-
-.fa-square-whatsapp {
- --fa: "\f40c";
-}
-
-.fa-whatsapp-square {
- --fa: "\f40c";
-}
-
-.fa-whmcs {
- --fa: "\f40d";
-}
-
-.fa-wordpress-simple {
- --fa: "\f411";
-}
-
-.fa-xbox {
- --fa: "\f412";
-}
-
-.fa-yandex {
- --fa: "\f413";
-}
-
-.fa-yandex-international {
- --fa: "\f414";
-}
-
-.fa-apple-pay {
- --fa: "\f415";
-}
-
-.fa-cc-apple-pay {
- --fa: "\f416";
-}
-
-.fa-fly {
- --fa: "\f417";
-}
-
-.fa-node {
- --fa: "\f419";
-}
-
-.fa-osi {
- --fa: "\f41a";
-}
-
-.fa-react {
- --fa: "\f41b";
-}
-
-.fa-autoprefixer {
- --fa: "\f41c";
-}
-
-.fa-less {
- --fa: "\f41d";
-}
-
-.fa-sass {
- --fa: "\f41e";
-}
-
-.fa-vuejs {
- --fa: "\f41f";
-}
-
-.fa-angular {
- --fa: "\f420";
-}
-
-.fa-aviato {
- --fa: "\f421";
-}
-
-.fa-ember {
- --fa: "\f423";
-}
-
-.fa-gitter {
- --fa: "\f426";
-}
-
-.fa-hooli {
- --fa: "\f427";
-}
-
-.fa-strava {
- --fa: "\f428";
-}
-
-.fa-stripe {
- --fa: "\f429";
-}
-
-.fa-stripe-s {
- --fa: "\f42a";
-}
-
-.fa-typo3 {
- --fa: "\f42b";
-}
-
-.fa-amazon-pay {
- --fa: "\f42c";
-}
-
-.fa-cc-amazon-pay {
- --fa: "\f42d";
-}
-
-.fa-ethereum {
- --fa: "\f42e";
-}
-
-.fa-korvue {
- --fa: "\f42f";
-}
-
-.fa-elementor {
- --fa: "\f430";
-}
-
-.fa-square-youtube {
- --fa: "\f431";
-}
-
-.fa-youtube-square {
- --fa: "\f431";
-}
-
-.fa-flipboard {
- --fa: "\f44d";
-}
-
-.fa-hips {
- --fa: "\f452";
-}
-
-.fa-php {
- --fa: "\f457";
-}
-
-.fa-quinscape {
- --fa: "\f459";
-}
-
-.fa-readme {
- --fa: "\f4d5";
-}
-
-.fa-java {
- --fa: "\f4e4";
-}
-
-.fa-pied-piper-hat {
- --fa: "\f4e5";
-}
-
-.fa-creative-commons-by {
- --fa: "\f4e7";
-}
-
-.fa-creative-commons-nc {
- --fa: "\f4e8";
-}
-
-.fa-creative-commons-nc-eu {
- --fa: "\f4e9";
-}
-
-.fa-creative-commons-nc-jp {
- --fa: "\f4ea";
-}
-
-.fa-creative-commons-nd {
- --fa: "\f4eb";
-}
-
-.fa-creative-commons-pd {
- --fa: "\f4ec";
-}
-
-.fa-creative-commons-pd-alt {
- --fa: "\f4ed";
-}
-
-.fa-creative-commons-remix {
- --fa: "\f4ee";
-}
-
-.fa-creative-commons-sa {
- --fa: "\f4ef";
-}
-
-.fa-creative-commons-sampling {
- --fa: "\f4f0";
-}
-
-.fa-creative-commons-sampling-plus {
- --fa: "\f4f1";
-}
-
-.fa-creative-commons-share {
- --fa: "\f4f2";
-}
-
-.fa-creative-commons-zero {
- --fa: "\f4f3";
-}
-
-.fa-ebay {
- --fa: "\f4f4";
-}
-
-.fa-keybase {
- --fa: "\f4f5";
-}
-
-.fa-mastodon {
- --fa: "\f4f6";
-}
-
-.fa-r-project {
- --fa: "\f4f7";
-}
-
-.fa-researchgate {
- --fa: "\f4f8";
-}
-
-.fa-teamspeak {
- --fa: "\f4f9";
-}
-
-.fa-first-order-alt {
- --fa: "\f50a";
-}
-
-.fa-fulcrum {
- --fa: "\f50b";
-}
-
-.fa-galactic-republic {
- --fa: "\f50c";
-}
-
-.fa-galactic-senate {
- --fa: "\f50d";
-}
-
-.fa-jedi-order {
- --fa: "\f50e";
-}
-
-.fa-mandalorian {
- --fa: "\f50f";
-}
-
-.fa-old-republic {
- --fa: "\f510";
-}
-
-.fa-phoenix-squadron {
- --fa: "\f511";
-}
-
-.fa-sith {
- --fa: "\f512";
-}
-
-.fa-trade-federation {
- --fa: "\f513";
-}
-
-.fa-wolf-pack-battalion {
- --fa: "\f514";
-}
-
-.fa-hornbill {
- --fa: "\f592";
-}
-
-.fa-mailchimp {
- --fa: "\f59e";
-}
-
-.fa-megaport {
- --fa: "\f5a3";
-}
-
-.fa-nimblr {
- --fa: "\f5a8";
-}
-
-.fa-rev {
- --fa: "\f5b2";
-}
-
-.fa-shopware {
- --fa: "\f5b5";
-}
-
-.fa-squarespace {
- --fa: "\f5be";
-}
-
-.fa-themeco {
- --fa: "\f5c6";
-}
-
-.fa-weebly {
- --fa: "\f5cc";
-}
-
-.fa-wix {
- --fa: "\f5cf";
-}
-
-.fa-ello {
- --fa: "\f5f1";
-}
-
-.fa-hackerrank {
- --fa: "\f5f7";
-}
-
-.fa-kaggle {
- --fa: "\f5fa";
-}
-
-.fa-markdown {
- --fa: "\f60f";
-}
-
-.fa-neos {
- --fa: "\f612";
-}
-
-.fa-zhihu {
- --fa: "\f63f";
-}
-
-.fa-alipay {
- --fa: "\f642";
-}
-
-.fa-the-red-yeti {
- --fa: "\f69d";
-}
-
-.fa-critical-role {
- --fa: "\f6c9";
-}
-
-.fa-d-and-d-beyond {
- --fa: "\f6ca";
-}
-
-.fa-dev {
- --fa: "\f6cc";
-}
-
-.fa-fantasy-flight-games {
- --fa: "\f6dc";
-}
-
-.fa-wizards-of-the-coast {
- --fa: "\f730";
-}
-
-.fa-think-peaks {
- --fa: "\f731";
-}
-
-.fa-reacteurope {
- --fa: "\f75d";
-}
-
-.fa-artstation {
- --fa: "\f77a";
-}
-
-.fa-atlassian {
- --fa: "\f77b";
-}
-
-.fa-canadian-maple-leaf {
- --fa: "\f785";
-}
-
-.fa-centos {
- --fa: "\f789";
-}
-
-.fa-confluence {
- --fa: "\f78d";
-}
-
-.fa-dhl {
- --fa: "\f790";
-}
-
-.fa-diaspora {
- --fa: "\f791";
-}
-
-.fa-fedex {
- --fa: "\f797";
-}
-
-.fa-fedora {
- --fa: "\f798";
-}
-
-.fa-figma {
- --fa: "\f799";
-}
-
-.fa-intercom {
- --fa: "\f7af";
-}
-
-.fa-invision {
- --fa: "\f7b0";
-}
-
-.fa-jira {
- --fa: "\f7b1";
-}
-
-.fa-mendeley {
- --fa: "\f7b3";
-}
-
-.fa-raspberry-pi {
- --fa: "\f7bb";
-}
-
-.fa-redhat {
- --fa: "\f7bc";
-}
-
-.fa-sketch {
- --fa: "\f7c6";
-}
-
-.fa-sourcetree {
- --fa: "\f7d3";
-}
-
-.fa-suse {
- --fa: "\f7d6";
-}
-
-.fa-ubuntu {
- --fa: "\f7df";
-}
-
-.fa-ups {
- --fa: "\f7e0";
-}
-
-.fa-usps {
- --fa: "\f7e1";
-}
-
-.fa-yarn {
- --fa: "\f7e3";
-}
-
-.fa-airbnb {
- --fa: "\f834";
-}
-
-.fa-battle-net {
- --fa: "\f835";
-}
-
-.fa-bootstrap {
- --fa: "\f836";
-}
-
-.fa-buffer {
- --fa: "\f837";
-}
-
-.fa-chromecast {
- --fa: "\f838";
-}
-
-.fa-evernote {
- --fa: "\f839";
-}
-
-.fa-itch-io {
- --fa: "\f83a";
-}
-
-.fa-salesforce {
- --fa: "\f83b";
-}
-
-.fa-speaker-deck {
- --fa: "\f83c";
-}
-
-.fa-symfony {
- --fa: "\f83d";
-}
-
-.fa-waze {
- --fa: "\f83f";
-}
-
-.fa-yammer {
- --fa: "\f840";
-}
-
-.fa-git-alt {
- --fa: "\f841";
-}
-
-.fa-stackpath {
- --fa: "\f842";
-}
-
-.fa-cotton-bureau {
- --fa: "\f89e";
-}
-
-.fa-buy-n-large {
- --fa: "\f8a6";
-}
-
-.fa-mdb {
- --fa: "\f8ca";
-}
-
-.fa-orcid {
- --fa: "\f8d2";
-}
-
-.fa-swift {
- --fa: "\f8e1";
-}
-
-.fa-umbraco {
- --fa: "\f8e8";
-}
\ No newline at end of file
diff --git a/src/media/vendor/fa7free/css/fontawesome.css b/src/media/vendor/fa7free/css/fontawesome.css
deleted file mode 100644
index 9e736b5..0000000
--- a/src/media/vendor/fa7free/css/fontawesome.css
+++ /dev/null
@@ -1,8361 +0,0 @@
-/*!
- * Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
- * Copyright 2025 Fonticons, Inc.
- */
-.fa-solid,
-.fa-regular,
-.fa-brands,
-.fa-classic,
-.fas,
-.far,
-.fab,
-.fa {
- --_fa-family: var(--fa-family, var(--fa-style-family, "Font Awesome 7 Free"));
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- display: var(--fa-display, inline-block);
- font-family: var(--_fa-family);
- font-feature-settings: normal;
- font-style: normal;
- font-synthesis: none;
- font-variant: normal;
- font-weight: var(--fa-style, 900);
- line-height: 1;
- text-align: center;
- text-rendering: auto;
- width: var(--fa-width, 1.25em);
-}
-
-:is(.fas,
-.far,
-.fab,
-.fa-solid,
-.fa-regular,
-.fa-brands,
-.fa-classic,
-.fa)::before {
- content: var(--fa)/"";
-}
-
-@supports not (content: ""/"") {
- :is(.fas,
- .far,
- .fab,
- .fa-solid,
- .fa-regular,
- .fa-brands,
- .fa-classic,
- .fa)::before {
- content: var(--fa);
- }
-}
-.fa-1x {
- font-size: 1em;
-}
-
-.fa-2x {
- font-size: 2em;
-}
-
-.fa-3x {
- font-size: 3em;
-}
-
-.fa-4x {
- font-size: 4em;
-}
-
-.fa-5x {
- font-size: 5em;
-}
-
-.fa-6x {
- font-size: 6em;
-}
-
-.fa-7x {
- font-size: 7em;
-}
-
-.fa-8x {
- font-size: 8em;
-}
-
-.fa-9x {
- font-size: 9em;
-}
-
-.fa-10x {
- font-size: 10em;
-}
-
-.fa-2xs {
- font-size: calc(10 / 16 * 1em); /* converts a 10px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 10 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 10 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-xs {
- font-size: calc(12 / 16 * 1em); /* converts a 12px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 12 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 12 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-sm {
- font-size: calc(14 / 16 * 1em); /* converts a 14px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 14 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 14 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-lg {
- font-size: calc(20 / 16 * 1em); /* converts a 20px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 20 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 20 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-xl {
- font-size: calc(24 / 16 * 1em); /* converts a 24px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 24 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 24 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-2xl {
- font-size: calc(32 / 16 * 1em); /* converts a 32px size into an em-based value that's relative to the scale's 16px base */
- line-height: calc(1 / 32 * 1em); /* sets the line-height of the icon back to that of it's parent */
- vertical-align: calc((6 / 32 - 0.375) * 1em); /* vertically centers the icon taking into account the surrounding text's descender */
-}
-
-.fa-width-auto {
- --fa-width: auto;
-}
-
-.fa-fw,
-.fa-width-fixed {
- --fa-width: 1.25em;
-}
-
-.fa-ul {
- list-style-type: none;
- margin-inline-start: var(--fa-li-margin, 2.5em);
- padding-inline-start: 0;
-}
-.fa-ul > li {
- position: relative;
-}
-
-.fa-li {
- inset-inline-start: calc(-1 * var(--fa-li-width, 2em));
- position: absolute;
- text-align: center;
- width: var(--fa-li-width, 2em);
- line-height: inherit;
-}
-
-/* Heads Up: Bordered Icons will not be supported in the future!
- - This feature will be deprecated in the next major release of Font Awesome (v8)!
- - You may continue to use it in this version *v7), but it will not be supported in Font Awesome v8.
-*/
-/* Notes:
-* --@{v.$css-prefix}-border-width = 1/16 by default (to render as ~1px based on a 16px default font-size)
-* --@{v.$css-prefix}-border-padding =
- ** 3/16 for vertical padding (to give ~2px of vertical whitespace around an icon considering it's vertical alignment)
- ** 4/16 for horizontal padding (to give ~4px of horizontal whitespace around an icon)
-*/
-.fa-border {
- border-color: var(--fa-border-color, #eee);
- border-radius: var(--fa-border-radius, 0.1em);
- border-style: var(--fa-border-style, solid);
- border-width: var(--fa-border-width, 0.0625em);
- box-sizing: var(--fa-border-box-sizing, content-box);
- padding: var(--fa-border-padding, 0.1875em 0.25em);
-}
-
-.fa-pull-left,
-.fa-pull-start {
- float: inline-start;
- margin-inline-end: var(--fa-pull-margin, 0.3em);
-}
-
-.fa-pull-right,
-.fa-pull-end {
- float: inline-end;
- margin-inline-start: var(--fa-pull-margin, 0.3em);
-}
-
-.fa-beat {
- animation-name: fa-beat;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, ease-in-out);
-}
-
-.fa-bounce {
- animation-name: fa-bounce;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1));
-}
-
-.fa-fade {
- animation-name: fa-fade;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
-}
-
-.fa-beat-fade {
- animation-name: fa-beat-fade;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
-}
-
-.fa-flip {
- animation-name: fa-flip;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, ease-in-out);
-}
-
-.fa-shake {
- animation-name: fa-shake;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, linear);
-}
-
-.fa-spin {
- animation-name: fa-spin;
- animation-delay: var(--fa-animation-delay, 0s);
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 2s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, linear);
-}
-
-.fa-spin-reverse {
- --fa-animation-direction: reverse;
-}
-
-.fa-pulse,
-.fa-spin-pulse {
- animation-name: fa-spin;
- animation-direction: var(--fa-animation-direction, normal);
- animation-duration: var(--fa-animation-duration, 1s);
- animation-iteration-count: var(--fa-animation-iteration-count, infinite);
- animation-timing-function: var(--fa-animation-timing, steps(8));
-}
-
-@media (prefers-reduced-motion: reduce) {
- .fa-beat,
- .fa-bounce,
- .fa-fade,
- .fa-beat-fade,
- .fa-flip,
- .fa-pulse,
- .fa-shake,
- .fa-spin,
- .fa-spin-pulse {
- animation: none !important;
- transition: none !important;
- }
-}
-@keyframes fa-beat {
- 0%, 90% {
- transform: scale(1);
- }
- 45% {
- transform: scale(var(--fa-beat-scale, 1.25));
- }
-}
-@keyframes fa-bounce {
- 0% {
- transform: scale(1, 1) translateY(0);
- }
- 10% {
- transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
- }
- 30% {
- transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
- }
- 50% {
- transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
- }
- 57% {
- transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
- }
- 64% {
- transform: scale(1, 1) translateY(0);
- }
- 100% {
- transform: scale(1, 1) translateY(0);
- }
-}
-@keyframes fa-fade {
- 50% {
- opacity: var(--fa-fade-opacity, 0.4);
- }
-}
-@keyframes fa-beat-fade {
- 0%, 100% {
- opacity: var(--fa-beat-fade-opacity, 0.4);
- transform: scale(1);
- }
- 50% {
- opacity: 1;
- transform: scale(var(--fa-beat-fade-scale, 1.125));
- }
-}
-@keyframes fa-flip {
- 50% {
- transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
- }
-}
-@keyframes fa-shake {
- 0% {
- transform: rotate(-15deg);
- }
- 4% {
- transform: rotate(15deg);
- }
- 8%, 24% {
- transform: rotate(-18deg);
- }
- 12%, 28% {
- transform: rotate(18deg);
- }
- 16% {
- transform: rotate(-22deg);
- }
- 20% {
- transform: rotate(22deg);
- }
- 32% {
- transform: rotate(-12deg);
- }
- 36% {
- transform: rotate(12deg);
- }
- 40%, 100% {
- transform: rotate(0deg);
- }
-}
-@keyframes fa-spin {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
-}
-.fa-rotate-90 {
- transform: rotate(90deg);
-}
-
-.fa-rotate-180 {
- transform: rotate(180deg);
-}
-
-.fa-rotate-270 {
- transform: rotate(270deg);
-}
-
-.fa-flip-horizontal {
- transform: scale(-1, 1);
-}
-
-.fa-flip-vertical {
- transform: scale(1, -1);
-}
-
-.fa-flip-both,
-.fa-flip-horizontal.fa-flip-vertical {
- transform: scale(-1, -1);
-}
-
-.fa-rotate-by {
- transform: rotate(var(--fa-rotate-angle, 0));
-}
-
-.fa-stack {
- display: inline-block;
- height: 2em;
- line-height: 2em;
- position: relative;
- vertical-align: middle;
- width: 2.5em;
-}
-
-.fa-stack-1x,
-.fa-stack-2x {
- --fa-width: 100%;
- inset: 0;
- position: absolute;
- text-align: center;
- width: var(--fa-width);
- z-index: var(--fa-stack-z-index, auto);
-}
-
-.fa-stack-1x {
- line-height: inherit;
-}
-
-.fa-stack-2x {
- font-size: 2em;
-}
-
-.fa-inverse {
- color: var(--fa-inverse, #fff);
-}
-
-/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen
- readers do not read off random characters that represent icons */
-
-.fa-0 {
- --fa: "\30 ";
-}
-
-.fa-1 {
- --fa: "\31 ";
-}
-
-.fa-2 {
- --fa: "\32 ";
-}
-
-.fa-3 {
- --fa: "\33 ";
-}
-
-.fa-4 {
- --fa: "\34 ";
-}
-
-.fa-5 {
- --fa: "\35 ";
-}
-
-.fa-6 {
- --fa: "\36 ";
-}
-
-.fa-7 {
- --fa: "\37 ";
-}
-
-.fa-8 {
- --fa: "\38 ";
-}
-
-.fa-9 {
- --fa: "\39 ";
-}
-
-.fa-exclamation {
- --fa: "\!";
-}
-
-.fa-hashtag {
- --fa: "\#";
-}
-
-.fa-dollar-sign {
- --fa: "\$";
-}
-
-.fa-dollar {
- --fa: "\$";
-}
-
-.fa-usd {
- --fa: "\$";
-}
-
-.fa-percent {
- --fa: "\%";
-}
-
-.fa-percentage {
- --fa: "\%";
-}
-
-.fa-asterisk {
- --fa: "\*";
-}
-
-.fa-plus {
- --fa: "\+";
-}
-
-.fa-add {
- --fa: "\+";
-}
-
-.fa-less-than {
- --fa: "\<";
-}
-
-.fa-equals {
- --fa: "\=";
-}
-
-.fa-greater-than {
- --fa: "\>";
-}
-
-.fa-question {
- --fa: "\?";
-}
-
-.fa-at {
- --fa: "\@";
-}
-
-.fa-a {
- --fa: "A";
-}
-
-.fa-b {
- --fa: "B";
-}
-
-.fa-c {
- --fa: "C";
-}
-
-.fa-d {
- --fa: "D";
-}
-
-.fa-e {
- --fa: "E";
-}
-
-.fa-f {
- --fa: "F";
-}
-
-.fa-g {
- --fa: "G";
-}
-
-.fa-h {
- --fa: "H";
-}
-
-.fa-i {
- --fa: "I";
-}
-
-.fa-j {
- --fa: "J";
-}
-
-.fa-k {
- --fa: "K";
-}
-
-.fa-l {
- --fa: "L";
-}
-
-.fa-m {
- --fa: "M";
-}
-
-.fa-n {
- --fa: "N";
-}
-
-.fa-o {
- --fa: "O";
-}
-
-.fa-p {
- --fa: "P";
-}
-
-.fa-q {
- --fa: "Q";
-}
-
-.fa-r {
- --fa: "R";
-}
-
-.fa-s {
- --fa: "S";
-}
-
-.fa-t {
- --fa: "T";
-}
-
-.fa-u {
- --fa: "U";
-}
-
-.fa-v {
- --fa: "V";
-}
-
-.fa-w {
- --fa: "W";
-}
-
-.fa-x {
- --fa: "X";
-}
-
-.fa-y {
- --fa: "Y";
-}
-
-.fa-z {
- --fa: "Z";
-}
-
-.fa-faucet {
- --fa: "\e005";
-}
-
-.fa-faucet-drip {
- --fa: "\e006";
-}
-
-.fa-house-chimney-window {
- --fa: "\e00d";
-}
-
-.fa-house-signal {
- --fa: "\e012";
-}
-
-.fa-temperature-arrow-down {
- --fa: "\e03f";
-}
-
-.fa-temperature-down {
- --fa: "\e03f";
-}
-
-.fa-temperature-arrow-up {
- --fa: "\e040";
-}
-
-.fa-temperature-up {
- --fa: "\e040";
-}
-
-.fa-trailer {
- --fa: "\e041";
-}
-
-.fa-bacteria {
- --fa: "\e059";
-}
-
-.fa-bacterium {
- --fa: "\e05a";
-}
-
-.fa-box-tissue {
- --fa: "\e05b";
-}
-
-.fa-hand-holding-medical {
- --fa: "\e05c";
-}
-
-.fa-hand-sparkles {
- --fa: "\e05d";
-}
-
-.fa-hands-bubbles {
- --fa: "\e05e";
-}
-
-.fa-hands-wash {
- --fa: "\e05e";
-}
-
-.fa-handshake-slash {
- --fa: "\e060";
-}
-
-.fa-handshake-alt-slash {
- --fa: "\e060";
-}
-
-.fa-handshake-simple-slash {
- --fa: "\e060";
-}
-
-.fa-head-side-cough {
- --fa: "\e061";
-}
-
-.fa-head-side-cough-slash {
- --fa: "\e062";
-}
-
-.fa-head-side-mask {
- --fa: "\e063";
-}
-
-.fa-head-side-virus {
- --fa: "\e064";
-}
-
-.fa-house-chimney-user {
- --fa: "\e065";
-}
-
-.fa-house-laptop {
- --fa: "\e066";
-}
-
-.fa-laptop-house {
- --fa: "\e066";
-}
-
-.fa-lungs-virus {
- --fa: "\e067";
-}
-
-.fa-people-arrows {
- --fa: "\e068";
-}
-
-.fa-people-arrows-left-right {
- --fa: "\e068";
-}
-
-.fa-plane-slash {
- --fa: "\e069";
-}
-
-.fa-pump-medical {
- --fa: "\e06a";
-}
-
-.fa-pump-soap {
- --fa: "\e06b";
-}
-
-.fa-shield-virus {
- --fa: "\e06c";
-}
-
-.fa-sink {
- --fa: "\e06d";
-}
-
-.fa-soap {
- --fa: "\e06e";
-}
-
-.fa-stopwatch-20 {
- --fa: "\e06f";
-}
-
-.fa-shop-slash {
- --fa: "\e070";
-}
-
-.fa-store-alt-slash {
- --fa: "\e070";
-}
-
-.fa-store-slash {
- --fa: "\e071";
-}
-
-.fa-toilet-paper-slash {
- --fa: "\e072";
-}
-
-.fa-users-slash {
- --fa: "\e073";
-}
-
-.fa-virus {
- --fa: "\e074";
-}
-
-.fa-virus-slash {
- --fa: "\e075";
-}
-
-.fa-viruses {
- --fa: "\e076";
-}
-
-.fa-vest {
- --fa: "\e085";
-}
-
-.fa-vest-patches {
- --fa: "\e086";
-}
-
-.fa-arrow-trend-down {
- --fa: "\e097";
-}
-
-.fa-arrow-trend-up {
- --fa: "\e098";
-}
-
-.fa-arrow-up-from-bracket {
- --fa: "\e09a";
-}
-
-.fa-austral-sign {
- --fa: "\e0a9";
-}
-
-.fa-baht-sign {
- --fa: "\e0ac";
-}
-
-.fa-bitcoin-sign {
- --fa: "\e0b4";
-}
-
-.fa-bolt-lightning {
- --fa: "\e0b7";
-}
-
-.fa-book-bookmark {
- --fa: "\e0bb";
-}
-
-.fa-camera-rotate {
- --fa: "\e0d8";
-}
-
-.fa-cedi-sign {
- --fa: "\e0df";
-}
-
-.fa-chart-column {
- --fa: "\e0e3";
-}
-
-.fa-chart-gantt {
- --fa: "\e0e4";
-}
-
-.fa-clapperboard {
- --fa: "\e131";
-}
-
-.fa-clover {
- --fa: "\e139";
-}
-
-.fa-code-compare {
- --fa: "\e13a";
-}
-
-.fa-code-fork {
- --fa: "\e13b";
-}
-
-.fa-code-pull-request {
- --fa: "\e13c";
-}
-
-.fa-colon-sign {
- --fa: "\e140";
-}
-
-.fa-cruzeiro-sign {
- --fa: "\e152";
-}
-
-.fa-display {
- --fa: "\e163";
-}
-
-.fa-dong-sign {
- --fa: "\e169";
-}
-
-.fa-elevator {
- --fa: "\e16d";
-}
-
-.fa-filter-circle-xmark {
- --fa: "\e17b";
-}
-
-.fa-florin-sign {
- --fa: "\e184";
-}
-
-.fa-folder-closed {
- --fa: "\e185";
-}
-
-.fa-franc-sign {
- --fa: "\e18f";
-}
-
-.fa-guarani-sign {
- --fa: "\e19a";
-}
-
-.fa-gun {
- --fa: "\e19b";
-}
-
-.fa-hands-clapping {
- --fa: "\e1a8";
-}
-
-.fa-house-user {
- --fa: "\e1b0";
-}
-
-.fa-home-user {
- --fa: "\e1b0";
-}
-
-.fa-indian-rupee-sign {
- --fa: "\e1bc";
-}
-
-.fa-indian-rupee {
- --fa: "\e1bc";
-}
-
-.fa-inr {
- --fa: "\e1bc";
-}
-
-.fa-kip-sign {
- --fa: "\e1c4";
-}
-
-.fa-lari-sign {
- --fa: "\e1c8";
-}
-
-.fa-litecoin-sign {
- --fa: "\e1d3";
-}
-
-.fa-manat-sign {
- --fa: "\e1d5";
-}
-
-.fa-mask-face {
- --fa: "\e1d7";
-}
-
-.fa-mill-sign {
- --fa: "\e1ed";
-}
-
-.fa-money-bills {
- --fa: "\e1f3";
-}
-
-.fa-naira-sign {
- --fa: "\e1f6";
-}
-
-.fa-notdef {
- --fa: "\e1fe";
-}
-
-.fa-panorama {
- --fa: "\e209";
-}
-
-.fa-peseta-sign {
- --fa: "\e221";
-}
-
-.fa-peso-sign {
- --fa: "\e222";
-}
-
-.fa-plane-up {
- --fa: "\e22d";
-}
-
-.fa-rupiah-sign {
- --fa: "\e23d";
-}
-
-.fa-stairs {
- --fa: "\e289";
-}
-
-.fa-timeline {
- --fa: "\e29c";
-}
-
-.fa-truck-front {
- --fa: "\e2b7";
-}
-
-.fa-turkish-lira-sign {
- --fa: "\e2bb";
-}
-
-.fa-try {
- --fa: "\e2bb";
-}
-
-.fa-turkish-lira {
- --fa: "\e2bb";
-}
-
-.fa-vault {
- --fa: "\e2c5";
-}
-
-.fa-wand-magic-sparkles {
- --fa: "\e2ca";
-}
-
-.fa-magic-wand-sparkles {
- --fa: "\e2ca";
-}
-
-.fa-wheat-awn {
- --fa: "\e2cd";
-}
-
-.fa-wheat-alt {
- --fa: "\e2cd";
-}
-
-.fa-wheelchair-move {
- --fa: "\e2ce";
-}
-
-.fa-wheelchair-alt {
- --fa: "\e2ce";
-}
-
-.fa-bangladeshi-taka-sign {
- --fa: "\e2e6";
-}
-
-.fa-bowl-rice {
- --fa: "\e2eb";
-}
-
-.fa-person-pregnant {
- --fa: "\e31e";
-}
-
-.fa-house-chimney {
- --fa: "\e3af";
-}
-
-.fa-home-lg {
- --fa: "\e3af";
-}
-
-.fa-house-crack {
- --fa: "\e3b1";
-}
-
-.fa-house-medical {
- --fa: "\e3b2";
-}
-
-.fa-cent-sign {
- --fa: "\e3f5";
-}
-
-.fa-plus-minus {
- --fa: "\e43c";
-}
-
-.fa-sailboat {
- --fa: "\e445";
-}
-
-.fa-section {
- --fa: "\e447";
-}
-
-.fa-shrimp {
- --fa: "\e448";
-}
-
-.fa-brazilian-real-sign {
- --fa: "\e46c";
-}
-
-.fa-chart-simple {
- --fa: "\e473";
-}
-
-.fa-diagram-next {
- --fa: "\e476";
-}
-
-.fa-diagram-predecessor {
- --fa: "\e477";
-}
-
-.fa-diagram-successor {
- --fa: "\e47a";
-}
-
-.fa-earth-oceania {
- --fa: "\e47b";
-}
-
-.fa-globe-oceania {
- --fa: "\e47b";
-}
-
-.fa-bug-slash {
- --fa: "\e490";
-}
-
-.fa-file-circle-plus {
- --fa: "\e494";
-}
-
-.fa-shop-lock {
- --fa: "\e4a5";
-}
-
-.fa-virus-covid {
- --fa: "\e4a8";
-}
-
-.fa-virus-covid-slash {
- --fa: "\e4a9";
-}
-
-.fa-anchor-circle-check {
- --fa: "\e4aa";
-}
-
-.fa-anchor-circle-exclamation {
- --fa: "\e4ab";
-}
-
-.fa-anchor-circle-xmark {
- --fa: "\e4ac";
-}
-
-.fa-anchor-lock {
- --fa: "\e4ad";
-}
-
-.fa-arrow-down-up-across-line {
- --fa: "\e4af";
-}
-
-.fa-arrow-down-up-lock {
- --fa: "\e4b0";
-}
-
-.fa-arrow-right-to-city {
- --fa: "\e4b3";
-}
-
-.fa-arrow-up-from-ground-water {
- --fa: "\e4b5";
-}
-
-.fa-arrow-up-from-water-pump {
- --fa: "\e4b6";
-}
-
-.fa-arrow-up-right-dots {
- --fa: "\e4b7";
-}
-
-.fa-arrows-down-to-line {
- --fa: "\e4b8";
-}
-
-.fa-arrows-down-to-people {
- --fa: "\e4b9";
-}
-
-.fa-arrows-left-right-to-line {
- --fa: "\e4ba";
-}
-
-.fa-arrows-spin {
- --fa: "\e4bb";
-}
-
-.fa-arrows-split-up-and-left {
- --fa: "\e4bc";
-}
-
-.fa-arrows-to-circle {
- --fa: "\e4bd";
-}
-
-.fa-arrows-to-dot {
- --fa: "\e4be";
-}
-
-.fa-arrows-to-eye {
- --fa: "\e4bf";
-}
-
-.fa-arrows-turn-right {
- --fa: "\e4c0";
-}
-
-.fa-arrows-turn-to-dots {
- --fa: "\e4c1";
-}
-
-.fa-arrows-up-to-line {
- --fa: "\e4c2";
-}
-
-.fa-bore-hole {
- --fa: "\e4c3";
-}
-
-.fa-bottle-droplet {
- --fa: "\e4c4";
-}
-
-.fa-bottle-water {
- --fa: "\e4c5";
-}
-
-.fa-bowl-food {
- --fa: "\e4c6";
-}
-
-.fa-boxes-packing {
- --fa: "\e4c7";
-}
-
-.fa-bridge {
- --fa: "\e4c8";
-}
-
-.fa-bridge-circle-check {
- --fa: "\e4c9";
-}
-
-.fa-bridge-circle-exclamation {
- --fa: "\e4ca";
-}
-
-.fa-bridge-circle-xmark {
- --fa: "\e4cb";
-}
-
-.fa-bridge-lock {
- --fa: "\e4cc";
-}
-
-.fa-bridge-water {
- --fa: "\e4ce";
-}
-
-.fa-bucket {
- --fa: "\e4cf";
-}
-
-.fa-bugs {
- --fa: "\e4d0";
-}
-
-.fa-building-circle-arrow-right {
- --fa: "\e4d1";
-}
-
-.fa-building-circle-check {
- --fa: "\e4d2";
-}
-
-.fa-building-circle-exclamation {
- --fa: "\e4d3";
-}
-
-.fa-building-circle-xmark {
- --fa: "\e4d4";
-}
-
-.fa-building-flag {
- --fa: "\e4d5";
-}
-
-.fa-building-lock {
- --fa: "\e4d6";
-}
-
-.fa-building-ngo {
- --fa: "\e4d7";
-}
-
-.fa-building-shield {
- --fa: "\e4d8";
-}
-
-.fa-building-un {
- --fa: "\e4d9";
-}
-
-.fa-building-user {
- --fa: "\e4da";
-}
-
-.fa-building-wheat {
- --fa: "\e4db";
-}
-
-.fa-burst {
- --fa: "\e4dc";
-}
-
-.fa-car-on {
- --fa: "\e4dd";
-}
-
-.fa-car-tunnel {
- --fa: "\e4de";
-}
-
-.fa-child-combatant {
- --fa: "\e4e0";
-}
-
-.fa-child-rifle {
- --fa: "\e4e0";
-}
-
-.fa-children {
- --fa: "\e4e1";
-}
-
-.fa-circle-nodes {
- --fa: "\e4e2";
-}
-
-.fa-clipboard-question {
- --fa: "\e4e3";
-}
-
-.fa-cloud-showers-water {
- --fa: "\e4e4";
-}
-
-.fa-computer {
- --fa: "\e4e5";
-}
-
-.fa-cubes-stacked {
- --fa: "\e4e6";
-}
-
-.fa-envelope-circle-check {
- --fa: "\e4e8";
-}
-
-.fa-explosion {
- --fa: "\e4e9";
-}
-
-.fa-ferry {
- --fa: "\e4ea";
-}
-
-.fa-file-circle-exclamation {
- --fa: "\e4eb";
-}
-
-.fa-file-circle-minus {
- --fa: "\e4ed";
-}
-
-.fa-file-circle-question {
- --fa: "\e4ef";
-}
-
-.fa-file-shield {
- --fa: "\e4f0";
-}
-
-.fa-fire-burner {
- --fa: "\e4f1";
-}
-
-.fa-fish-fins {
- --fa: "\e4f2";
-}
-
-.fa-flask-vial {
- --fa: "\e4f3";
-}
-
-.fa-glass-water {
- --fa: "\e4f4";
-}
-
-.fa-glass-water-droplet {
- --fa: "\e4f5";
-}
-
-.fa-group-arrows-rotate {
- --fa: "\e4f6";
-}
-
-.fa-hand-holding-hand {
- --fa: "\e4f7";
-}
-
-.fa-handcuffs {
- --fa: "\e4f8";
-}
-
-.fa-hands-bound {
- --fa: "\e4f9";
-}
-
-.fa-hands-holding-child {
- --fa: "\e4fa";
-}
-
-.fa-hands-holding-circle {
- --fa: "\e4fb";
-}
-
-.fa-heart-circle-bolt {
- --fa: "\e4fc";
-}
-
-.fa-heart-circle-check {
- --fa: "\e4fd";
-}
-
-.fa-heart-circle-exclamation {
- --fa: "\e4fe";
-}
-
-.fa-heart-circle-minus {
- --fa: "\e4ff";
-}
-
-.fa-heart-circle-plus {
- --fa: "\e500";
-}
-
-.fa-heart-circle-xmark {
- --fa: "\e501";
-}
-
-.fa-helicopter-symbol {
- --fa: "\e502";
-}
-
-.fa-helmet-un {
- --fa: "\e503";
-}
-
-.fa-hill-avalanche {
- --fa: "\e507";
-}
-
-.fa-hill-rockslide {
- --fa: "\e508";
-}
-
-.fa-house-circle-check {
- --fa: "\e509";
-}
-
-.fa-house-circle-exclamation {
- --fa: "\e50a";
-}
-
-.fa-house-circle-xmark {
- --fa: "\e50b";
-}
-
-.fa-house-fire {
- --fa: "\e50c";
-}
-
-.fa-house-flag {
- --fa: "\e50d";
-}
-
-.fa-house-flood-water {
- --fa: "\e50e";
-}
-
-.fa-house-flood-water-circle-arrow-right {
- --fa: "\e50f";
-}
-
-.fa-house-lock {
- --fa: "\e510";
-}
-
-.fa-house-medical-circle-check {
- --fa: "\e511";
-}
-
-.fa-house-medical-circle-exclamation {
- --fa: "\e512";
-}
-
-.fa-house-medical-circle-xmark {
- --fa: "\e513";
-}
-
-.fa-house-medical-flag {
- --fa: "\e514";
-}
-
-.fa-house-tsunami {
- --fa: "\e515";
-}
-
-.fa-jar {
- --fa: "\e516";
-}
-
-.fa-jar-wheat {
- --fa: "\e517";
-}
-
-.fa-jet-fighter-up {
- --fa: "\e518";
-}
-
-.fa-jug-detergent {
- --fa: "\e519";
-}
-
-.fa-kitchen-set {
- --fa: "\e51a";
-}
-
-.fa-land-mine-on {
- --fa: "\e51b";
-}
-
-.fa-landmark-flag {
- --fa: "\e51c";
-}
-
-.fa-laptop-file {
- --fa: "\e51d";
-}
-
-.fa-lines-leaning {
- --fa: "\e51e";
-}
-
-.fa-location-pin-lock {
- --fa: "\e51f";
-}
-
-.fa-locust {
- --fa: "\e520";
-}
-
-.fa-magnifying-glass-arrow-right {
- --fa: "\e521";
-}
-
-.fa-magnifying-glass-chart {
- --fa: "\e522";
-}
-
-.fa-mars-and-venus-burst {
- --fa: "\e523";
-}
-
-.fa-mask-ventilator {
- --fa: "\e524";
-}
-
-.fa-mattress-pillow {
- --fa: "\e525";
-}
-
-.fa-mobile-retro {
- --fa: "\e527";
-}
-
-.fa-money-bill-transfer {
- --fa: "\e528";
-}
-
-.fa-money-bill-trend-up {
- --fa: "\e529";
-}
-
-.fa-money-bill-wheat {
- --fa: "\e52a";
-}
-
-.fa-mosquito {
- --fa: "\e52b";
-}
-
-.fa-mosquito-net {
- --fa: "\e52c";
-}
-
-.fa-mound {
- --fa: "\e52d";
-}
-
-.fa-mountain-city {
- --fa: "\e52e";
-}
-
-.fa-mountain-sun {
- --fa: "\e52f";
-}
-
-.fa-oil-well {
- --fa: "\e532";
-}
-
-.fa-people-group {
- --fa: "\e533";
-}
-
-.fa-people-line {
- --fa: "\e534";
-}
-
-.fa-people-pulling {
- --fa: "\e535";
-}
-
-.fa-people-robbery {
- --fa: "\e536";
-}
-
-.fa-people-roof {
- --fa: "\e537";
-}
-
-.fa-person-arrow-down-to-line {
- --fa: "\e538";
-}
-
-.fa-person-arrow-up-from-line {
- --fa: "\e539";
-}
-
-.fa-person-breastfeeding {
- --fa: "\e53a";
-}
-
-.fa-person-burst {
- --fa: "\e53b";
-}
-
-.fa-person-cane {
- --fa: "\e53c";
-}
-
-.fa-person-chalkboard {
- --fa: "\e53d";
-}
-
-.fa-person-circle-check {
- --fa: "\e53e";
-}
-
-.fa-person-circle-exclamation {
- --fa: "\e53f";
-}
-
-.fa-person-circle-minus {
- --fa: "\e540";
-}
-
-.fa-person-circle-plus {
- --fa: "\e541";
-}
-
-.fa-person-circle-question {
- --fa: "\e542";
-}
-
-.fa-person-circle-xmark {
- --fa: "\e543";
-}
-
-.fa-person-dress-burst {
- --fa: "\e544";
-}
-
-.fa-person-drowning {
- --fa: "\e545";
-}
-
-.fa-person-falling {
- --fa: "\e546";
-}
-
-.fa-person-falling-burst {
- --fa: "\e547";
-}
-
-.fa-person-half-dress {
- --fa: "\e548";
-}
-
-.fa-person-harassing {
- --fa: "\e549";
-}
-
-.fa-person-military-pointing {
- --fa: "\e54a";
-}
-
-.fa-person-military-rifle {
- --fa: "\e54b";
-}
-
-.fa-person-military-to-person {
- --fa: "\e54c";
-}
-
-.fa-person-rays {
- --fa: "\e54d";
-}
-
-.fa-person-rifle {
- --fa: "\e54e";
-}
-
-.fa-person-shelter {
- --fa: "\e54f";
-}
-
-.fa-person-walking-arrow-loop-left {
- --fa: "\e551";
-}
-
-.fa-person-walking-arrow-right {
- --fa: "\e552";
-}
-
-.fa-person-walking-dashed-line-arrow-right {
- --fa: "\e553";
-}
-
-.fa-person-walking-luggage {
- --fa: "\e554";
-}
-
-.fa-plane-circle-check {
- --fa: "\e555";
-}
-
-.fa-plane-circle-exclamation {
- --fa: "\e556";
-}
-
-.fa-plane-circle-xmark {
- --fa: "\e557";
-}
-
-.fa-plane-lock {
- --fa: "\e558";
-}
-
-.fa-plate-wheat {
- --fa: "\e55a";
-}
-
-.fa-plug-circle-bolt {
- --fa: "\e55b";
-}
-
-.fa-plug-circle-check {
- --fa: "\e55c";
-}
-
-.fa-plug-circle-exclamation {
- --fa: "\e55d";
-}
-
-.fa-plug-circle-minus {
- --fa: "\e55e";
-}
-
-.fa-plug-circle-plus {
- --fa: "\e55f";
-}
-
-.fa-plug-circle-xmark {
- --fa: "\e560";
-}
-
-.fa-ranking-star {
- --fa: "\e561";
-}
-
-.fa-road-barrier {
- --fa: "\e562";
-}
-
-.fa-road-bridge {
- --fa: "\e563";
-}
-
-.fa-road-circle-check {
- --fa: "\e564";
-}
-
-.fa-road-circle-exclamation {
- --fa: "\e565";
-}
-
-.fa-road-circle-xmark {
- --fa: "\e566";
-}
-
-.fa-road-lock {
- --fa: "\e567";
-}
-
-.fa-road-spikes {
- --fa: "\e568";
-}
-
-.fa-rug {
- --fa: "\e569";
-}
-
-.fa-sack-xmark {
- --fa: "\e56a";
-}
-
-.fa-school-circle-check {
- --fa: "\e56b";
-}
-
-.fa-school-circle-exclamation {
- --fa: "\e56c";
-}
-
-.fa-school-circle-xmark {
- --fa: "\e56d";
-}
-
-.fa-school-flag {
- --fa: "\e56e";
-}
-
-.fa-school-lock {
- --fa: "\e56f";
-}
-
-.fa-sheet-plastic {
- --fa: "\e571";
-}
-
-.fa-shield-cat {
- --fa: "\e572";
-}
-
-.fa-shield-dog {
- --fa: "\e573";
-}
-
-.fa-shield-heart {
- --fa: "\e574";
-}
-
-.fa-square-nfi {
- --fa: "\e576";
-}
-
-.fa-square-person-confined {
- --fa: "\e577";
-}
-
-.fa-square-virus {
- --fa: "\e578";
-}
-
-.fa-staff-snake {
- --fa: "\e579";
-}
-
-.fa-rod-asclepius {
- --fa: "\e579";
-}
-
-.fa-rod-snake {
- --fa: "\e579";
-}
-
-.fa-staff-aesculapius {
- --fa: "\e579";
-}
-
-.fa-sun-plant-wilt {
- --fa: "\e57a";
-}
-
-.fa-tarp {
- --fa: "\e57b";
-}
-
-.fa-tarp-droplet {
- --fa: "\e57c";
-}
-
-.fa-tent {
- --fa: "\e57d";
-}
-
-.fa-tent-arrow-down-to-line {
- --fa: "\e57e";
-}
-
-.fa-tent-arrow-left-right {
- --fa: "\e57f";
-}
-
-.fa-tent-arrow-turn-left {
- --fa: "\e580";
-}
-
-.fa-tent-arrows-down {
- --fa: "\e581";
-}
-
-.fa-tents {
- --fa: "\e582";
-}
-
-.fa-toilet-portable {
- --fa: "\e583";
-}
-
-.fa-toilets-portable {
- --fa: "\e584";
-}
-
-.fa-tower-cell {
- --fa: "\e585";
-}
-
-.fa-tower-observation {
- --fa: "\e586";
-}
-
-.fa-tree-city {
- --fa: "\e587";
-}
-
-.fa-trowel {
- --fa: "\e589";
-}
-
-.fa-trowel-bricks {
- --fa: "\e58a";
-}
-
-.fa-truck-arrow-right {
- --fa: "\e58b";
-}
-
-.fa-truck-droplet {
- --fa: "\e58c";
-}
-
-.fa-truck-field {
- --fa: "\e58d";
-}
-
-.fa-truck-field-un {
- --fa: "\e58e";
-}
-
-.fa-truck-plane {
- --fa: "\e58f";
-}
-
-.fa-users-between-lines {
- --fa: "\e591";
-}
-
-.fa-users-line {
- --fa: "\e592";
-}
-
-.fa-users-rays {
- --fa: "\e593";
-}
-
-.fa-users-rectangle {
- --fa: "\e594";
-}
-
-.fa-users-viewfinder {
- --fa: "\e595";
-}
-
-.fa-vial-circle-check {
- --fa: "\e596";
-}
-
-.fa-vial-virus {
- --fa: "\e597";
-}
-
-.fa-wheat-awn-circle-exclamation {
- --fa: "\e598";
-}
-
-.fa-worm {
- --fa: "\e599";
-}
-
-.fa-xmarks-lines {
- --fa: "\e59a";
-}
-
-.fa-child-dress {
- --fa: "\e59c";
-}
-
-.fa-child-reaching {
- --fa: "\e59d";
-}
-
-.fa-file-circle-check {
- --fa: "\e5a0";
-}
-
-.fa-file-circle-xmark {
- --fa: "\e5a1";
-}
-
-.fa-person-through-window {
- --fa: "\e5a9";
-}
-
-.fa-plant-wilt {
- --fa: "\e5aa";
-}
-
-.fa-stapler {
- --fa: "\e5af";
-}
-
-.fa-train-tram {
- --fa: "\e5b4";
-}
-
-.fa-table-cells-column-lock {
- --fa: "\e678";
-}
-
-.fa-table-cells-row-lock {
- --fa: "\e67a";
-}
-
-.fa-web-awesome {
- --fa: "\e682";
-}
-
-.fa-thumbtack-slash {
- --fa: "\e68f";
-}
-
-.fa-thumb-tack-slash {
- --fa: "\e68f";
-}
-
-.fa-table-cells-row-unlock {
- --fa: "\e691";
-}
-
-.fa-chart-diagram {
- --fa: "\e695";
-}
-
-.fa-comment-nodes {
- --fa: "\e696";
-}
-
-.fa-file-fragment {
- --fa: "\e697";
-}
-
-.fa-file-half-dashed {
- --fa: "\e698";
-}
-
-.fa-hexagon-nodes {
- --fa: "\e699";
-}
-
-.fa-hexagon-nodes-bolt {
- --fa: "\e69a";
-}
-
-.fa-square-binary {
- --fa: "\e69b";
-}
-
-.fa-pentagon {
- --fa: "\e790";
-}
-
-.fa-non-binary {
- --fa: "\e807";
-}
-
-.fa-spiral {
- --fa: "\e80a";
-}
-
-.fa-mobile-vibrate {
- --fa: "\e816";
-}
-
-.fa-single-quote-left {
- --fa: "\e81b";
-}
-
-.fa-single-quote-right {
- --fa: "\e81c";
-}
-
-.fa-bus-side {
- --fa: "\e81d";
-}
-
-.fa-septagon {
- --fa: "\e820";
-}
-
-.fa-heptagon {
- --fa: "\e820";
-}
-
-.fa-martini-glass-empty {
- --fa: "\f000";
-}
-
-.fa-glass-martini {
- --fa: "\f000";
-}
-
-.fa-music {
- --fa: "\f001";
-}
-
-.fa-magnifying-glass {
- --fa: "\f002";
-}
-
-.fa-search {
- --fa: "\f002";
-}
-
-.fa-heart {
- --fa: "\f004";
-}
-
-.fa-star {
- --fa: "\f005";
-}
-
-.fa-user {
- --fa: "\f007";
-}
-
-.fa-user-alt {
- --fa: "\f007";
-}
-
-.fa-user-large {
- --fa: "\f007";
-}
-
-.fa-film {
- --fa: "\f008";
-}
-
-.fa-film-alt {
- --fa: "\f008";
-}
-
-.fa-film-simple {
- --fa: "\f008";
-}
-
-.fa-table-cells-large {
- --fa: "\f009";
-}
-
-.fa-th-large {
- --fa: "\f009";
-}
-
-.fa-table-cells {
- --fa: "\f00a";
-}
-
-.fa-th {
- --fa: "\f00a";
-}
-
-.fa-table-list {
- --fa: "\f00b";
-}
-
-.fa-th-list {
- --fa: "\f00b";
-}
-
-.fa-check {
- --fa: "\f00c";
-}
-
-.fa-xmark {
- --fa: "\f00d";
-}
-
-.fa-close {
- --fa: "\f00d";
-}
-
-.fa-multiply {
- --fa: "\f00d";
-}
-
-.fa-remove {
- --fa: "\f00d";
-}
-
-.fa-times {
- --fa: "\f00d";
-}
-
-.fa-magnifying-glass-plus {
- --fa: "\f00e";
-}
-
-.fa-search-plus {
- --fa: "\f00e";
-}
-
-.fa-magnifying-glass-minus {
- --fa: "\f010";
-}
-
-.fa-search-minus {
- --fa: "\f010";
-}
-
-.fa-power-off {
- --fa: "\f011";
-}
-
-.fa-signal {
- --fa: "\f012";
-}
-
-.fa-signal-5 {
- --fa: "\f012";
-}
-
-.fa-signal-perfect {
- --fa: "\f012";
-}
-
-.fa-gear {
- --fa: "\f013";
-}
-
-.fa-cog {
- --fa: "\f013";
-}
-
-.fa-house {
- --fa: "\f015";
-}
-
-.fa-home {
- --fa: "\f015";
-}
-
-.fa-home-alt {
- --fa: "\f015";
-}
-
-.fa-home-lg-alt {
- --fa: "\f015";
-}
-
-.fa-clock {
- --fa: "\f017";
-}
-
-.fa-clock-four {
- --fa: "\f017";
-}
-
-.fa-road {
- --fa: "\f018";
-}
-
-.fa-download {
- --fa: "\f019";
-}
-
-.fa-inbox {
- --fa: "\f01c";
-}
-
-.fa-arrow-rotate-right {
- --fa: "\f01e";
-}
-
-.fa-arrow-right-rotate {
- --fa: "\f01e";
-}
-
-.fa-arrow-rotate-forward {
- --fa: "\f01e";
-}
-
-.fa-redo {
- --fa: "\f01e";
-}
-
-.fa-arrows-rotate {
- --fa: "\f021";
-}
-
-.fa-refresh {
- --fa: "\f021";
-}
-
-.fa-sync {
- --fa: "\f021";
-}
-
-.fa-rectangle-list {
- --fa: "\f022";
-}
-
-.fa-list-alt {
- --fa: "\f022";
-}
-
-.fa-lock {
- --fa: "\f023";
-}
-
-.fa-flag {
- --fa: "\f024";
-}
-
-.fa-headphones {
- --fa: "\f025";
-}
-
-.fa-headphones-alt {
- --fa: "\f025";
-}
-
-.fa-headphones-simple {
- --fa: "\f025";
-}
-
-.fa-volume-off {
- --fa: "\f026";
-}
-
-.fa-volume-low {
- --fa: "\f027";
-}
-
-.fa-volume-down {
- --fa: "\f027";
-}
-
-.fa-volume-high {
- --fa: "\f028";
-}
-
-.fa-volume-up {
- --fa: "\f028";
-}
-
-.fa-qrcode {
- --fa: "\f029";
-}
-
-.fa-barcode {
- --fa: "\f02a";
-}
-
-.fa-tag {
- --fa: "\f02b";
-}
-
-.fa-tags {
- --fa: "\f02c";
-}
-
-.fa-book {
- --fa: "\f02d";
-}
-
-.fa-bookmark {
- --fa: "\f02e";
-}
-
-.fa-print {
- --fa: "\f02f";
-}
-
-.fa-camera {
- --fa: "\f030";
-}
-
-.fa-camera-alt {
- --fa: "\f030";
-}
-
-.fa-font {
- --fa: "\f031";
-}
-
-.fa-bold {
- --fa: "\f032";
-}
-
-.fa-italic {
- --fa: "\f033";
-}
-
-.fa-text-height {
- --fa: "\f034";
-}
-
-.fa-text-width {
- --fa: "\f035";
-}
-
-.fa-align-left {
- --fa: "\f036";
-}
-
-.fa-align-center {
- --fa: "\f037";
-}
-
-.fa-align-right {
- --fa: "\f038";
-}
-
-.fa-align-justify {
- --fa: "\f039";
-}
-
-.fa-list {
- --fa: "\f03a";
-}
-
-.fa-list-squares {
- --fa: "\f03a";
-}
-
-.fa-outdent {
- --fa: "\f03b";
-}
-
-.fa-dedent {
- --fa: "\f03b";
-}
-
-.fa-indent {
- --fa: "\f03c";
-}
-
-.fa-video {
- --fa: "\f03d";
-}
-
-.fa-video-camera {
- --fa: "\f03d";
-}
-
-.fa-image {
- --fa: "\f03e";
-}
-
-.fa-location-pin {
- --fa: "\f041";
-}
-
-.fa-map-marker {
- --fa: "\f041";
-}
-
-.fa-circle-half-stroke {
- --fa: "\f042";
-}
-
-.fa-adjust {
- --fa: "\f042";
-}
-
-.fa-droplet {
- --fa: "\f043";
-}
-
-.fa-tint {
- --fa: "\f043";
-}
-
-.fa-pen-to-square {
- --fa: "\f044";
-}
-
-.fa-edit {
- --fa: "\f044";
-}
-
-.fa-arrows-up-down-left-right {
- --fa: "\f047";
-}
-
-.fa-arrows {
- --fa: "\f047";
-}
-
-.fa-backward-step {
- --fa: "\f048";
-}
-
-.fa-step-backward {
- --fa: "\f048";
-}
-
-.fa-backward-fast {
- --fa: "\f049";
-}
-
-.fa-fast-backward {
- --fa: "\f049";
-}
-
-.fa-backward {
- --fa: "\f04a";
-}
-
-.fa-play {
- --fa: "\f04b";
-}
-
-.fa-pause {
- --fa: "\f04c";
-}
-
-.fa-stop {
- --fa: "\f04d";
-}
-
-.fa-forward {
- --fa: "\f04e";
-}
-
-.fa-forward-fast {
- --fa: "\f050";
-}
-
-.fa-fast-forward {
- --fa: "\f050";
-}
-
-.fa-forward-step {
- --fa: "\f051";
-}
-
-.fa-step-forward {
- --fa: "\f051";
-}
-
-.fa-eject {
- --fa: "\f052";
-}
-
-.fa-chevron-left {
- --fa: "\f053";
-}
-
-.fa-chevron-right {
- --fa: "\f054";
-}
-
-.fa-circle-plus {
- --fa: "\f055";
-}
-
-.fa-plus-circle {
- --fa: "\f055";
-}
-
-.fa-circle-minus {
- --fa: "\f056";
-}
-
-.fa-minus-circle {
- --fa: "\f056";
-}
-
-.fa-circle-xmark {
- --fa: "\f057";
-}
-
-.fa-times-circle {
- --fa: "\f057";
-}
-
-.fa-xmark-circle {
- --fa: "\f057";
-}
-
-.fa-circle-check {
- --fa: "\f058";
-}
-
-.fa-check-circle {
- --fa: "\f058";
-}
-
-.fa-circle-question {
- --fa: "\f059";
-}
-
-.fa-question-circle {
- --fa: "\f059";
-}
-
-.fa-circle-info {
- --fa: "\f05a";
-}
-
-.fa-info-circle {
- --fa: "\f05a";
-}
-
-.fa-crosshairs {
- --fa: "\f05b";
-}
-
-.fa-ban {
- --fa: "\f05e";
-}
-
-.fa-cancel {
- --fa: "\f05e";
-}
-
-.fa-arrow-left {
- --fa: "\f060";
-}
-
-.fa-arrow-right {
- --fa: "\f061";
-}
-
-.fa-arrow-up {
- --fa: "\f062";
-}
-
-.fa-arrow-down {
- --fa: "\f063";
-}
-
-.fa-share {
- --fa: "\f064";
-}
-
-.fa-mail-forward {
- --fa: "\f064";
-}
-
-.fa-expand {
- --fa: "\f065";
-}
-
-.fa-compress {
- --fa: "\f066";
-}
-
-.fa-minus {
- --fa: "\f068";
-}
-
-.fa-subtract {
- --fa: "\f068";
-}
-
-.fa-circle-exclamation {
- --fa: "\f06a";
-}
-
-.fa-exclamation-circle {
- --fa: "\f06a";
-}
-
-.fa-gift {
- --fa: "\f06b";
-}
-
-.fa-leaf {
- --fa: "\f06c";
-}
-
-.fa-fire {
- --fa: "\f06d";
-}
-
-.fa-eye {
- --fa: "\f06e";
-}
-
-.fa-eye-slash {
- --fa: "\f070";
-}
-
-.fa-triangle-exclamation {
- --fa: "\f071";
-}
-
-.fa-exclamation-triangle {
- --fa: "\f071";
-}
-
-.fa-warning {
- --fa: "\f071";
-}
-
-.fa-plane {
- --fa: "\f072";
-}
-
-.fa-calendar-days {
- --fa: "\f073";
-}
-
-.fa-calendar-alt {
- --fa: "\f073";
-}
-
-.fa-shuffle {
- --fa: "\f074";
-}
-
-.fa-random {
- --fa: "\f074";
-}
-
-.fa-comment {
- --fa: "\f075";
-}
-
-.fa-magnet {
- --fa: "\f076";
-}
-
-.fa-chevron-up {
- --fa: "\f077";
-}
-
-.fa-chevron-down {
- --fa: "\f078";
-}
-
-.fa-retweet {
- --fa: "\f079";
-}
-
-.fa-cart-shopping {
- --fa: "\f07a";
-}
-
-.fa-shopping-cart {
- --fa: "\f07a";
-}
-
-.fa-folder {
- --fa: "\f07b";
-}
-
-.fa-folder-blank {
- --fa: "\f07b";
-}
-
-.fa-folder-open {
- --fa: "\f07c";
-}
-
-.fa-arrows-up-down {
- --fa: "\f07d";
-}
-
-.fa-arrows-v {
- --fa: "\f07d";
-}
-
-.fa-arrows-left-right {
- --fa: "\f07e";
-}
-
-.fa-arrows-h {
- --fa: "\f07e";
-}
-
-.fa-chart-bar {
- --fa: "\f080";
-}
-
-.fa-bar-chart {
- --fa: "\f080";
-}
-
-.fa-camera-retro {
- --fa: "\f083";
-}
-
-.fa-key {
- --fa: "\f084";
-}
-
-.fa-gears {
- --fa: "\f085";
-}
-
-.fa-cogs {
- --fa: "\f085";
-}
-
-.fa-comments {
- --fa: "\f086";
-}
-
-.fa-star-half {
- --fa: "\f089";
-}
-
-.fa-arrow-right-from-bracket {
- --fa: "\f08b";
-}
-
-.fa-sign-out {
- --fa: "\f08b";
-}
-
-.fa-thumbtack {
- --fa: "\f08d";
-}
-
-.fa-thumb-tack {
- --fa: "\f08d";
-}
-
-.fa-arrow-up-right-from-square {
- --fa: "\f08e";
-}
-
-.fa-external-link {
- --fa: "\f08e";
-}
-
-.fa-arrow-right-to-bracket {
- --fa: "\f090";
-}
-
-.fa-sign-in {
- --fa: "\f090";
-}
-
-.fa-trophy {
- --fa: "\f091";
-}
-
-.fa-upload {
- --fa: "\f093";
-}
-
-.fa-lemon {
- --fa: "\f094";
-}
-
-.fa-phone {
- --fa: "\f095";
-}
-
-.fa-square-phone {
- --fa: "\f098";
-}
-
-.fa-phone-square {
- --fa: "\f098";
-}
-
-.fa-unlock {
- --fa: "\f09c";
-}
-
-.fa-credit-card {
- --fa: "\f09d";
-}
-
-.fa-credit-card-alt {
- --fa: "\f09d";
-}
-
-.fa-rss {
- --fa: "\f09e";
-}
-
-.fa-feed {
- --fa: "\f09e";
-}
-
-.fa-hard-drive {
- --fa: "\f0a0";
-}
-
-.fa-hdd {
- --fa: "\f0a0";
-}
-
-.fa-bullhorn {
- --fa: "\f0a1";
-}
-
-.fa-certificate {
- --fa: "\f0a3";
-}
-
-.fa-hand-point-right {
- --fa: "\f0a4";
-}
-
-.fa-hand-point-left {
- --fa: "\f0a5";
-}
-
-.fa-hand-point-up {
- --fa: "\f0a6";
-}
-
-.fa-hand-point-down {
- --fa: "\f0a7";
-}
-
-.fa-circle-arrow-left {
- --fa: "\f0a8";
-}
-
-.fa-arrow-circle-left {
- --fa: "\f0a8";
-}
-
-.fa-circle-arrow-right {
- --fa: "\f0a9";
-}
-
-.fa-arrow-circle-right {
- --fa: "\f0a9";
-}
-
-.fa-circle-arrow-up {
- --fa: "\f0aa";
-}
-
-.fa-arrow-circle-up {
- --fa: "\f0aa";
-}
-
-.fa-circle-arrow-down {
- --fa: "\f0ab";
-}
-
-.fa-arrow-circle-down {
- --fa: "\f0ab";
-}
-
-.fa-globe {
- --fa: "\f0ac";
-}
-
-.fa-wrench {
- --fa: "\f0ad";
-}
-
-.fa-list-check {
- --fa: "\f0ae";
-}
-
-.fa-tasks {
- --fa: "\f0ae";
-}
-
-.fa-filter {
- --fa: "\f0b0";
-}
-
-.fa-briefcase {
- --fa: "\f0b1";
-}
-
-.fa-up-down-left-right {
- --fa: "\f0b2";
-}
-
-.fa-arrows-alt {
- --fa: "\f0b2";
-}
-
-.fa-users {
- --fa: "\f0c0";
-}
-
-.fa-link {
- --fa: "\f0c1";
-}
-
-.fa-chain {
- --fa: "\f0c1";
-}
-
-.fa-cloud {
- --fa: "\f0c2";
-}
-
-.fa-flask {
- --fa: "\f0c3";
-}
-
-.fa-scissors {
- --fa: "\f0c4";
-}
-
-.fa-cut {
- --fa: "\f0c4";
-}
-
-.fa-copy {
- --fa: "\f0c5";
-}
-
-.fa-paperclip {
- --fa: "\f0c6";
-}
-
-.fa-floppy-disk {
- --fa: "\f0c7";
-}
-
-.fa-save {
- --fa: "\f0c7";
-}
-
-.fa-square {
- --fa: "\f0c8";
-}
-
-.fa-bars {
- --fa: "\f0c9";
-}
-
-.fa-navicon {
- --fa: "\f0c9";
-}
-
-.fa-list-ul {
- --fa: "\f0ca";
-}
-
-.fa-list-dots {
- --fa: "\f0ca";
-}
-
-.fa-list-ol {
- --fa: "\f0cb";
-}
-
-.fa-list-1-2 {
- --fa: "\f0cb";
-}
-
-.fa-list-numeric {
- --fa: "\f0cb";
-}
-
-.fa-strikethrough {
- --fa: "\f0cc";
-}
-
-.fa-underline {
- --fa: "\f0cd";
-}
-
-.fa-table {
- --fa: "\f0ce";
-}
-
-.fa-wand-magic {
- --fa: "\f0d0";
-}
-
-.fa-magic {
- --fa: "\f0d0";
-}
-
-.fa-truck {
- --fa: "\f0d1";
-}
-
-.fa-money-bill {
- --fa: "\f0d6";
-}
-
-.fa-caret-down {
- --fa: "\f0d7";
-}
-
-.fa-caret-up {
- --fa: "\f0d8";
-}
-
-.fa-caret-left {
- --fa: "\f0d9";
-}
-
-.fa-caret-right {
- --fa: "\f0da";
-}
-
-.fa-table-columns {
- --fa: "\f0db";
-}
-
-.fa-columns {
- --fa: "\f0db";
-}
-
-.fa-sort {
- --fa: "\f0dc";
-}
-
-.fa-unsorted {
- --fa: "\f0dc";
-}
-
-.fa-sort-down {
- --fa: "\f0dd";
-}
-
-.fa-sort-desc {
- --fa: "\f0dd";
-}
-
-.fa-sort-up {
- --fa: "\f0de";
-}
-
-.fa-sort-asc {
- --fa: "\f0de";
-}
-
-.fa-envelope {
- --fa: "\f0e0";
-}
-
-.fa-arrow-rotate-left {
- --fa: "\f0e2";
-}
-
-.fa-arrow-left-rotate {
- --fa: "\f0e2";
-}
-
-.fa-arrow-rotate-back {
- --fa: "\f0e2";
-}
-
-.fa-arrow-rotate-backward {
- --fa: "\f0e2";
-}
-
-.fa-undo {
- --fa: "\f0e2";
-}
-
-.fa-gavel {
- --fa: "\f0e3";
-}
-
-.fa-legal {
- --fa: "\f0e3";
-}
-
-.fa-bolt {
- --fa: "\f0e7";
-}
-
-.fa-zap {
- --fa: "\f0e7";
-}
-
-.fa-sitemap {
- --fa: "\f0e8";
-}
-
-.fa-umbrella {
- --fa: "\f0e9";
-}
-
-.fa-paste {
- --fa: "\f0ea";
-}
-
-.fa-file-clipboard {
- --fa: "\f0ea";
-}
-
-.fa-lightbulb {
- --fa: "\f0eb";
-}
-
-.fa-arrow-right-arrow-left {
- --fa: "\f0ec";
-}
-
-.fa-exchange {
- --fa: "\f0ec";
-}
-
-.fa-cloud-arrow-down {
- --fa: "\f0ed";
-}
-
-.fa-cloud-download {
- --fa: "\f0ed";
-}
-
-.fa-cloud-download-alt {
- --fa: "\f0ed";
-}
-
-.fa-cloud-arrow-up {
- --fa: "\f0ee";
-}
-
-.fa-cloud-upload {
- --fa: "\f0ee";
-}
-
-.fa-cloud-upload-alt {
- --fa: "\f0ee";
-}
-
-.fa-user-doctor {
- --fa: "\f0f0";
-}
-
-.fa-user-md {
- --fa: "\f0f0";
-}
-
-.fa-stethoscope {
- --fa: "\f0f1";
-}
-
-.fa-suitcase {
- --fa: "\f0f2";
-}
-
-.fa-bell {
- --fa: "\f0f3";
-}
-
-.fa-mug-saucer {
- --fa: "\f0f4";
-}
-
-.fa-coffee {
- --fa: "\f0f4";
-}
-
-.fa-hospital {
- --fa: "\f0f8";
-}
-
-.fa-hospital-alt {
- --fa: "\f0f8";
-}
-
-.fa-hospital-wide {
- --fa: "\f0f8";
-}
-
-.fa-truck-medical {
- --fa: "\f0f9";
-}
-
-.fa-ambulance {
- --fa: "\f0f9";
-}
-
-.fa-suitcase-medical {
- --fa: "\f0fa";
-}
-
-.fa-medkit {
- --fa: "\f0fa";
-}
-
-.fa-jet-fighter {
- --fa: "\f0fb";
-}
-
-.fa-fighter-jet {
- --fa: "\f0fb";
-}
-
-.fa-beer-mug-empty {
- --fa: "\f0fc";
-}
-
-.fa-beer {
- --fa: "\f0fc";
-}
-
-.fa-square-h {
- --fa: "\f0fd";
-}
-
-.fa-h-square {
- --fa: "\f0fd";
-}
-
-.fa-square-plus {
- --fa: "\f0fe";
-}
-
-.fa-plus-square {
- --fa: "\f0fe";
-}
-
-.fa-angles-left {
- --fa: "\f100";
-}
-
-.fa-angle-double-left {
- --fa: "\f100";
-}
-
-.fa-angles-right {
- --fa: "\f101";
-}
-
-.fa-angle-double-right {
- --fa: "\f101";
-}
-
-.fa-angles-up {
- --fa: "\f102";
-}
-
-.fa-angle-double-up {
- --fa: "\f102";
-}
-
-.fa-angles-down {
- --fa: "\f103";
-}
-
-.fa-angle-double-down {
- --fa: "\f103";
-}
-
-.fa-angle-left {
- --fa: "\f104";
-}
-
-.fa-angle-right {
- --fa: "\f105";
-}
-
-.fa-angle-up {
- --fa: "\f106";
-}
-
-.fa-angle-down {
- --fa: "\f107";
-}
-
-.fa-laptop {
- --fa: "\f109";
-}
-
-.fa-tablet-button {
- --fa: "\f10a";
-}
-
-.fa-mobile-button {
- --fa: "\f10b";
-}
-
-.fa-quote-left {
- --fa: "\f10d";
-}
-
-.fa-quote-left-alt {
- --fa: "\f10d";
-}
-
-.fa-quote-right {
- --fa: "\f10e";
-}
-
-.fa-quote-right-alt {
- --fa: "\f10e";
-}
-
-.fa-spinner {
- --fa: "\f110";
-}
-
-.fa-circle {
- --fa: "\f111";
-}
-
-.fa-face-smile {
- --fa: "\f118";
-}
-
-.fa-smile {
- --fa: "\f118";
-}
-
-.fa-face-frown {
- --fa: "\f119";
-}
-
-.fa-frown {
- --fa: "\f119";
-}
-
-.fa-face-meh {
- --fa: "\f11a";
-}
-
-.fa-meh {
- --fa: "\f11a";
-}
-
-.fa-gamepad {
- --fa: "\f11b";
-}
-
-.fa-keyboard {
- --fa: "\f11c";
-}
-
-.fa-flag-checkered {
- --fa: "\f11e";
-}
-
-.fa-terminal {
- --fa: "\f120";
-}
-
-.fa-code {
- --fa: "\f121";
-}
-
-.fa-reply-all {
- --fa: "\f122";
-}
-
-.fa-mail-reply-all {
- --fa: "\f122";
-}
-
-.fa-location-arrow {
- --fa: "\f124";
-}
-
-.fa-crop {
- --fa: "\f125";
-}
-
-.fa-code-branch {
- --fa: "\f126";
-}
-
-.fa-link-slash {
- --fa: "\f127";
-}
-
-.fa-chain-broken {
- --fa: "\f127";
-}
-
-.fa-chain-slash {
- --fa: "\f127";
-}
-
-.fa-unlink {
- --fa: "\f127";
-}
-
-.fa-info {
- --fa: "\f129";
-}
-
-.fa-superscript {
- --fa: "\f12b";
-}
-
-.fa-subscript {
- --fa: "\f12c";
-}
-
-.fa-eraser {
- --fa: "\f12d";
-}
-
-.fa-puzzle-piece {
- --fa: "\f12e";
-}
-
-.fa-microphone {
- --fa: "\f130";
-}
-
-.fa-microphone-slash {
- --fa: "\f131";
-}
-
-.fa-shield {
- --fa: "\f132";
-}
-
-.fa-shield-blank {
- --fa: "\f132";
-}
-
-.fa-calendar {
- --fa: "\f133";
-}
-
-.fa-fire-extinguisher {
- --fa: "\f134";
-}
-
-.fa-rocket {
- --fa: "\f135";
-}
-
-.fa-circle-chevron-left {
- --fa: "\f137";
-}
-
-.fa-chevron-circle-left {
- --fa: "\f137";
-}
-
-.fa-circle-chevron-right {
- --fa: "\f138";
-}
-
-.fa-chevron-circle-right {
- --fa: "\f138";
-}
-
-.fa-circle-chevron-up {
- --fa: "\f139";
-}
-
-.fa-chevron-circle-up {
- --fa: "\f139";
-}
-
-.fa-circle-chevron-down {
- --fa: "\f13a";
-}
-
-.fa-chevron-circle-down {
- --fa: "\f13a";
-}
-
-.fa-anchor {
- --fa: "\f13d";
-}
-
-.fa-unlock-keyhole {
- --fa: "\f13e";
-}
-
-.fa-unlock-alt {
- --fa: "\f13e";
-}
-
-.fa-bullseye {
- --fa: "\f140";
-}
-
-.fa-ellipsis {
- --fa: "\f141";
-}
-
-.fa-ellipsis-h {
- --fa: "\f141";
-}
-
-.fa-ellipsis-vertical {
- --fa: "\f142";
-}
-
-.fa-ellipsis-v {
- --fa: "\f142";
-}
-
-.fa-square-rss {
- --fa: "\f143";
-}
-
-.fa-rss-square {
- --fa: "\f143";
-}
-
-.fa-circle-play {
- --fa: "\f144";
-}
-
-.fa-play-circle {
- --fa: "\f144";
-}
-
-.fa-ticket {
- --fa: "\f145";
-}
-
-.fa-square-minus {
- --fa: "\f146";
-}
-
-.fa-minus-square {
- --fa: "\f146";
-}
-
-.fa-arrow-turn-up {
- --fa: "\f148";
-}
-
-.fa-level-up {
- --fa: "\f148";
-}
-
-.fa-arrow-turn-down {
- --fa: "\f149";
-}
-
-.fa-level-down {
- --fa: "\f149";
-}
-
-.fa-square-check {
- --fa: "\f14a";
-}
-
-.fa-check-square {
- --fa: "\f14a";
-}
-
-.fa-square-pen {
- --fa: "\f14b";
-}
-
-.fa-pen-square {
- --fa: "\f14b";
-}
-
-.fa-pencil-square {
- --fa: "\f14b";
-}
-
-.fa-square-arrow-up-right {
- --fa: "\f14c";
-}
-
-.fa-external-link-square {
- --fa: "\f14c";
-}
-
-.fa-share-from-square {
- --fa: "\f14d";
-}
-
-.fa-share-square {
- --fa: "\f14d";
-}
-
-.fa-compass {
- --fa: "\f14e";
-}
-
-.fa-square-caret-down {
- --fa: "\f150";
-}
-
-.fa-caret-square-down {
- --fa: "\f150";
-}
-
-.fa-square-caret-up {
- --fa: "\f151";
-}
-
-.fa-caret-square-up {
- --fa: "\f151";
-}
-
-.fa-square-caret-right {
- --fa: "\f152";
-}
-
-.fa-caret-square-right {
- --fa: "\f152";
-}
-
-.fa-euro-sign {
- --fa: "\f153";
-}
-
-.fa-eur {
- --fa: "\f153";
-}
-
-.fa-euro {
- --fa: "\f153";
-}
-
-.fa-sterling-sign {
- --fa: "\f154";
-}
-
-.fa-gbp {
- --fa: "\f154";
-}
-
-.fa-pound-sign {
- --fa: "\f154";
-}
-
-.fa-rupee-sign {
- --fa: "\f156";
-}
-
-.fa-rupee {
- --fa: "\f156";
-}
-
-.fa-yen-sign {
- --fa: "\f157";
-}
-
-.fa-cny {
- --fa: "\f157";
-}
-
-.fa-jpy {
- --fa: "\f157";
-}
-
-.fa-rmb {
- --fa: "\f157";
-}
-
-.fa-yen {
- --fa: "\f157";
-}
-
-.fa-ruble-sign {
- --fa: "\f158";
-}
-
-.fa-rouble {
- --fa: "\f158";
-}
-
-.fa-rub {
- --fa: "\f158";
-}
-
-.fa-ruble {
- --fa: "\f158";
-}
-
-.fa-won-sign {
- --fa: "\f159";
-}
-
-.fa-krw {
- --fa: "\f159";
-}
-
-.fa-won {
- --fa: "\f159";
-}
-
-.fa-file {
- --fa: "\f15b";
-}
-
-.fa-file-lines {
- --fa: "\f15c";
-}
-
-.fa-file-alt {
- --fa: "\f15c";
-}
-
-.fa-file-text {
- --fa: "\f15c";
-}
-
-.fa-arrow-down-a-z {
- --fa: "\f15d";
-}
-
-.fa-sort-alpha-asc {
- --fa: "\f15d";
-}
-
-.fa-sort-alpha-down {
- --fa: "\f15d";
-}
-
-.fa-arrow-up-a-z {
- --fa: "\f15e";
-}
-
-.fa-sort-alpha-up {
- --fa: "\f15e";
-}
-
-.fa-arrow-down-wide-short {
- --fa: "\f160";
-}
-
-.fa-sort-amount-asc {
- --fa: "\f160";
-}
-
-.fa-sort-amount-down {
- --fa: "\f160";
-}
-
-.fa-arrow-up-wide-short {
- --fa: "\f161";
-}
-
-.fa-sort-amount-up {
- --fa: "\f161";
-}
-
-.fa-arrow-down-1-9 {
- --fa: "\f162";
-}
-
-.fa-sort-numeric-asc {
- --fa: "\f162";
-}
-
-.fa-sort-numeric-down {
- --fa: "\f162";
-}
-
-.fa-arrow-up-1-9 {
- --fa: "\f163";
-}
-
-.fa-sort-numeric-up {
- --fa: "\f163";
-}
-
-.fa-thumbs-up {
- --fa: "\f164";
-}
-
-.fa-thumbs-down {
- --fa: "\f165";
-}
-
-.fa-arrow-down-long {
- --fa: "\f175";
-}
-
-.fa-long-arrow-down {
- --fa: "\f175";
-}
-
-.fa-arrow-up-long {
- --fa: "\f176";
-}
-
-.fa-long-arrow-up {
- --fa: "\f176";
-}
-
-.fa-arrow-left-long {
- --fa: "\f177";
-}
-
-.fa-long-arrow-left {
- --fa: "\f177";
-}
-
-.fa-arrow-right-long {
- --fa: "\f178";
-}
-
-.fa-long-arrow-right {
- --fa: "\f178";
-}
-
-.fa-person-dress {
- --fa: "\f182";
-}
-
-.fa-female {
- --fa: "\f182";
-}
-
-.fa-person {
- --fa: "\f183";
-}
-
-.fa-male {
- --fa: "\f183";
-}
-
-.fa-sun {
- --fa: "\f185";
-}
-
-.fa-moon {
- --fa: "\f186";
-}
-
-.fa-box-archive {
- --fa: "\f187";
-}
-
-.fa-archive {
- --fa: "\f187";
-}
-
-.fa-bug {
- --fa: "\f188";
-}
-
-.fa-square-caret-left {
- --fa: "\f191";
-}
-
-.fa-caret-square-left {
- --fa: "\f191";
-}
-
-.fa-circle-dot {
- --fa: "\f192";
-}
-
-.fa-dot-circle {
- --fa: "\f192";
-}
-
-.fa-wheelchair {
- --fa: "\f193";
-}
-
-.fa-lira-sign {
- --fa: "\f195";
-}
-
-.fa-shuttle-space {
- --fa: "\f197";
-}
-
-.fa-space-shuttle {
- --fa: "\f197";
-}
-
-.fa-square-envelope {
- --fa: "\f199";
-}
-
-.fa-envelope-square {
- --fa: "\f199";
-}
-
-.fa-building-columns {
- --fa: "\f19c";
-}
-
-.fa-bank {
- --fa: "\f19c";
-}
-
-.fa-institution {
- --fa: "\f19c";
-}
-
-.fa-museum {
- --fa: "\f19c";
-}
-
-.fa-university {
- --fa: "\f19c";
-}
-
-.fa-graduation-cap {
- --fa: "\f19d";
-}
-
-.fa-mortar-board {
- --fa: "\f19d";
-}
-
-.fa-language {
- --fa: "\f1ab";
-}
-
-.fa-fax {
- --fa: "\f1ac";
-}
-
-.fa-building {
- --fa: "\f1ad";
-}
-
-.fa-child {
- --fa: "\f1ae";
-}
-
-.fa-paw {
- --fa: "\f1b0";
-}
-
-.fa-cube {
- --fa: "\f1b2";
-}
-
-.fa-cubes {
- --fa: "\f1b3";
-}
-
-.fa-recycle {
- --fa: "\f1b8";
-}
-
-.fa-car {
- --fa: "\f1b9";
-}
-
-.fa-automobile {
- --fa: "\f1b9";
-}
-
-.fa-taxi {
- --fa: "\f1ba";
-}
-
-.fa-cab {
- --fa: "\f1ba";
-}
-
-.fa-tree {
- --fa: "\f1bb";
-}
-
-.fa-database {
- --fa: "\f1c0";
-}
-
-.fa-file-pdf {
- --fa: "\f1c1";
-}
-
-.fa-file-word {
- --fa: "\f1c2";
-}
-
-.fa-file-excel {
- --fa: "\f1c3";
-}
-
-.fa-file-powerpoint {
- --fa: "\f1c4";
-}
-
-.fa-file-image {
- --fa: "\f1c5";
-}
-
-.fa-file-zipper {
- --fa: "\f1c6";
-}
-
-.fa-file-archive {
- --fa: "\f1c6";
-}
-
-.fa-file-audio {
- --fa: "\f1c7";
-}
-
-.fa-file-video {
- --fa: "\f1c8";
-}
-
-.fa-file-code {
- --fa: "\f1c9";
-}
-
-.fa-life-ring {
- --fa: "\f1cd";
-}
-
-.fa-circle-notch {
- --fa: "\f1ce";
-}
-
-.fa-paper-plane {
- --fa: "\f1d8";
-}
-
-.fa-clock-rotate-left {
- --fa: "\f1da";
-}
-
-.fa-history {
- --fa: "\f1da";
-}
-
-.fa-heading {
- --fa: "\f1dc";
-}
-
-.fa-header {
- --fa: "\f1dc";
-}
-
-.fa-paragraph {
- --fa: "\f1dd";
-}
-
-.fa-sliders {
- --fa: "\f1de";
-}
-
-.fa-sliders-h {
- --fa: "\f1de";
-}
-
-.fa-share-nodes {
- --fa: "\f1e0";
-}
-
-.fa-share-alt {
- --fa: "\f1e0";
-}
-
-.fa-square-share-nodes {
- --fa: "\f1e1";
-}
-
-.fa-share-alt-square {
- --fa: "\f1e1";
-}
-
-.fa-bomb {
- --fa: "\f1e2";
-}
-
-.fa-futbol {
- --fa: "\f1e3";
-}
-
-.fa-futbol-ball {
- --fa: "\f1e3";
-}
-
-.fa-soccer-ball {
- --fa: "\f1e3";
-}
-
-.fa-tty {
- --fa: "\f1e4";
-}
-
-.fa-teletype {
- --fa: "\f1e4";
-}
-
-.fa-binoculars {
- --fa: "\f1e5";
-}
-
-.fa-plug {
- --fa: "\f1e6";
-}
-
-.fa-newspaper {
- --fa: "\f1ea";
-}
-
-.fa-wifi {
- --fa: "\f1eb";
-}
-
-.fa-wifi-3 {
- --fa: "\f1eb";
-}
-
-.fa-wifi-strong {
- --fa: "\f1eb";
-}
-
-.fa-calculator {
- --fa: "\f1ec";
-}
-
-.fa-bell-slash {
- --fa: "\f1f6";
-}
-
-.fa-trash {
- --fa: "\f1f8";
-}
-
-.fa-copyright {
- --fa: "\f1f9";
-}
-
-.fa-eye-dropper {
- --fa: "\f1fb";
-}
-
-.fa-eye-dropper-empty {
- --fa: "\f1fb";
-}
-
-.fa-eyedropper {
- --fa: "\f1fb";
-}
-
-.fa-paintbrush {
- --fa: "\f1fc";
-}
-
-.fa-paint-brush {
- --fa: "\f1fc";
-}
-
-.fa-cake-candles {
- --fa: "\f1fd";
-}
-
-.fa-birthday-cake {
- --fa: "\f1fd";
-}
-
-.fa-cake {
- --fa: "\f1fd";
-}
-
-.fa-chart-area {
- --fa: "\f1fe";
-}
-
-.fa-area-chart {
- --fa: "\f1fe";
-}
-
-.fa-chart-pie {
- --fa: "\f200";
-}
-
-.fa-pie-chart {
- --fa: "\f200";
-}
-
-.fa-chart-line {
- --fa: "\f201";
-}
-
-.fa-line-chart {
- --fa: "\f201";
-}
-
-.fa-toggle-off {
- --fa: "\f204";
-}
-
-.fa-toggle-on {
- --fa: "\f205";
-}
-
-.fa-bicycle {
- --fa: "\f206";
-}
-
-.fa-bus {
- --fa: "\f207";
-}
-
-.fa-closed-captioning {
- --fa: "\f20a";
-}
-
-.fa-shekel-sign {
- --fa: "\f20b";
-}
-
-.fa-ils {
- --fa: "\f20b";
-}
-
-.fa-shekel {
- --fa: "\f20b";
-}
-
-.fa-sheqel {
- --fa: "\f20b";
-}
-
-.fa-sheqel-sign {
- --fa: "\f20b";
-}
-
-.fa-cart-plus {
- --fa: "\f217";
-}
-
-.fa-cart-arrow-down {
- --fa: "\f218";
-}
-
-.fa-diamond {
- --fa: "\f219";
-}
-
-.fa-ship {
- --fa: "\f21a";
-}
-
-.fa-user-secret {
- --fa: "\f21b";
-}
-
-.fa-motorcycle {
- --fa: "\f21c";
-}
-
-.fa-street-view {
- --fa: "\f21d";
-}
-
-.fa-heart-pulse {
- --fa: "\f21e";
-}
-
-.fa-heartbeat {
- --fa: "\f21e";
-}
-
-.fa-venus {
- --fa: "\f221";
-}
-
-.fa-mars {
- --fa: "\f222";
-}
-
-.fa-mercury {
- --fa: "\f223";
-}
-
-.fa-mars-and-venus {
- --fa: "\f224";
-}
-
-.fa-transgender {
- --fa: "\f225";
-}
-
-.fa-transgender-alt {
- --fa: "\f225";
-}
-
-.fa-venus-double {
- --fa: "\f226";
-}
-
-.fa-mars-double {
- --fa: "\f227";
-}
-
-.fa-venus-mars {
- --fa: "\f228";
-}
-
-.fa-mars-stroke {
- --fa: "\f229";
-}
-
-.fa-mars-stroke-up {
- --fa: "\f22a";
-}
-
-.fa-mars-stroke-v {
- --fa: "\f22a";
-}
-
-.fa-mars-stroke-right {
- --fa: "\f22b";
-}
-
-.fa-mars-stroke-h {
- --fa: "\f22b";
-}
-
-.fa-neuter {
- --fa: "\f22c";
-}
-
-.fa-genderless {
- --fa: "\f22d";
-}
-
-.fa-server {
- --fa: "\f233";
-}
-
-.fa-user-plus {
- --fa: "\f234";
-}
-
-.fa-user-xmark {
- --fa: "\f235";
-}
-
-.fa-user-times {
- --fa: "\f235";
-}
-
-.fa-bed {
- --fa: "\f236";
-}
-
-.fa-train {
- --fa: "\f238";
-}
-
-.fa-train-subway {
- --fa: "\f239";
-}
-
-.fa-subway {
- --fa: "\f239";
-}
-
-.fa-battery-full {
- --fa: "\f240";
-}
-
-.fa-battery {
- --fa: "\f240";
-}
-
-.fa-battery-5 {
- --fa: "\f240";
-}
-
-.fa-battery-three-quarters {
- --fa: "\f241";
-}
-
-.fa-battery-4 {
- --fa: "\f241";
-}
-
-.fa-battery-half {
- --fa: "\f242";
-}
-
-.fa-battery-3 {
- --fa: "\f242";
-}
-
-.fa-battery-quarter {
- --fa: "\f243";
-}
-
-.fa-battery-2 {
- --fa: "\f243";
-}
-
-.fa-battery-empty {
- --fa: "\f244";
-}
-
-.fa-battery-0 {
- --fa: "\f244";
-}
-
-.fa-arrow-pointer {
- --fa: "\f245";
-}
-
-.fa-mouse-pointer {
- --fa: "\f245";
-}
-
-.fa-i-cursor {
- --fa: "\f246";
-}
-
-.fa-object-group {
- --fa: "\f247";
-}
-
-.fa-object-ungroup {
- --fa: "\f248";
-}
-
-.fa-note-sticky {
- --fa: "\f249";
-}
-
-.fa-sticky-note {
- --fa: "\f249";
-}
-
-.fa-clone {
- --fa: "\f24d";
-}
-
-.fa-scale-balanced {
- --fa: "\f24e";
-}
-
-.fa-balance-scale {
- --fa: "\f24e";
-}
-
-.fa-hourglass-start {
- --fa: "\f251";
-}
-
-.fa-hourglass-1 {
- --fa: "\f251";
-}
-
-.fa-hourglass-half {
- --fa: "\f252";
-}
-
-.fa-hourglass-2 {
- --fa: "\f252";
-}
-
-.fa-hourglass-end {
- --fa: "\f253";
-}
-
-.fa-hourglass-3 {
- --fa: "\f253";
-}
-
-.fa-hourglass {
- --fa: "\f254";
-}
-
-.fa-hourglass-empty {
- --fa: "\f254";
-}
-
-.fa-hand-back-fist {
- --fa: "\f255";
-}
-
-.fa-hand-rock {
- --fa: "\f255";
-}
-
-.fa-hand {
- --fa: "\f256";
-}
-
-.fa-hand-paper {
- --fa: "\f256";
-}
-
-.fa-hand-scissors {
- --fa: "\f257";
-}
-
-.fa-hand-lizard {
- --fa: "\f258";
-}
-
-.fa-hand-spock {
- --fa: "\f259";
-}
-
-.fa-hand-pointer {
- --fa: "\f25a";
-}
-
-.fa-hand-peace {
- --fa: "\f25b";
-}
-
-.fa-trademark {
- --fa: "\f25c";
-}
-
-.fa-registered {
- --fa: "\f25d";
-}
-
-.fa-tv {
- --fa: "\f26c";
-}
-
-.fa-television {
- --fa: "\f26c";
-}
-
-.fa-tv-alt {
- --fa: "\f26c";
-}
-
-.fa-calendar-plus {
- --fa: "\f271";
-}
-
-.fa-calendar-minus {
- --fa: "\f272";
-}
-
-.fa-calendar-xmark {
- --fa: "\f273";
-}
-
-.fa-calendar-times {
- --fa: "\f273";
-}
-
-.fa-calendar-check {
- --fa: "\f274";
-}
-
-.fa-industry {
- --fa: "\f275";
-}
-
-.fa-map-pin {
- --fa: "\f276";
-}
-
-.fa-signs-post {
- --fa: "\f277";
-}
-
-.fa-map-signs {
- --fa: "\f277";
-}
-
-.fa-map {
- --fa: "\f279";
-}
-
-.fa-message {
- --fa: "\f27a";
-}
-
-.fa-comment-alt {
- --fa: "\f27a";
-}
-
-.fa-circle-pause {
- --fa: "\f28b";
-}
-
-.fa-pause-circle {
- --fa: "\f28b";
-}
-
-.fa-circle-stop {
- --fa: "\f28d";
-}
-
-.fa-stop-circle {
- --fa: "\f28d";
-}
-
-.fa-bag-shopping {
- --fa: "\f290";
-}
-
-.fa-shopping-bag {
- --fa: "\f290";
-}
-
-.fa-basket-shopping {
- --fa: "\f291";
-}
-
-.fa-shopping-basket {
- --fa: "\f291";
-}
-
-.fa-universal-access {
- --fa: "\f29a";
-}
-
-.fa-person-walking-with-cane {
- --fa: "\f29d";
-}
-
-.fa-blind {
- --fa: "\f29d";
-}
-
-.fa-audio-description {
- --fa: "\f29e";
-}
-
-.fa-phone-volume {
- --fa: "\f2a0";
-}
-
-.fa-volume-control-phone {
- --fa: "\f2a0";
-}
-
-.fa-braille {
- --fa: "\f2a1";
-}
-
-.fa-ear-listen {
- --fa: "\f2a2";
-}
-
-.fa-assistive-listening-systems {
- --fa: "\f2a2";
-}
-
-.fa-hands-asl-interpreting {
- --fa: "\f2a3";
-}
-
-.fa-american-sign-language-interpreting {
- --fa: "\f2a3";
-}
-
-.fa-asl-interpreting {
- --fa: "\f2a3";
-}
-
-.fa-hands-american-sign-language-interpreting {
- --fa: "\f2a3";
-}
-
-.fa-ear-deaf {
- --fa: "\f2a4";
-}
-
-.fa-deaf {
- --fa: "\f2a4";
-}
-
-.fa-deafness {
- --fa: "\f2a4";
-}
-
-.fa-hard-of-hearing {
- --fa: "\f2a4";
-}
-
-.fa-hands {
- --fa: "\f2a7";
-}
-
-.fa-sign-language {
- --fa: "\f2a7";
-}
-
-.fa-signing {
- --fa: "\f2a7";
-}
-
-.fa-eye-low-vision {
- --fa: "\f2a8";
-}
-
-.fa-low-vision {
- --fa: "\f2a8";
-}
-
-.fa-font-awesome {
- --fa: "\f2b4";
-}
-
-.fa-font-awesome-flag {
- --fa: "\f2b4";
-}
-
-.fa-font-awesome-logo-full {
- --fa: "\f2b4";
-}
-
-.fa-handshake {
- --fa: "\f2b5";
-}
-
-.fa-handshake-alt {
- --fa: "\f2b5";
-}
-
-.fa-handshake-simple {
- --fa: "\f2b5";
-}
-
-.fa-envelope-open {
- --fa: "\f2b6";
-}
-
-.fa-address-book {
- --fa: "\f2b9";
-}
-
-.fa-contact-book {
- --fa: "\f2b9";
-}
-
-.fa-address-card {
- --fa: "\f2bb";
-}
-
-.fa-contact-card {
- --fa: "\f2bb";
-}
-
-.fa-vcard {
- --fa: "\f2bb";
-}
-
-.fa-circle-user {
- --fa: "\f2bd";
-}
-
-.fa-user-circle {
- --fa: "\f2bd";
-}
-
-.fa-id-badge {
- --fa: "\f2c1";
-}
-
-.fa-id-card {
- --fa: "\f2c2";
-}
-
-.fa-drivers-license {
- --fa: "\f2c2";
-}
-
-.fa-temperature-full {
- --fa: "\f2c7";
-}
-
-.fa-temperature-4 {
- --fa: "\f2c7";
-}
-
-.fa-thermometer-4 {
- --fa: "\f2c7";
-}
-
-.fa-thermometer-full {
- --fa: "\f2c7";
-}
-
-.fa-temperature-three-quarters {
- --fa: "\f2c8";
-}
-
-.fa-temperature-3 {
- --fa: "\f2c8";
-}
-
-.fa-thermometer-3 {
- --fa: "\f2c8";
-}
-
-.fa-thermometer-three-quarters {
- --fa: "\f2c8";
-}
-
-.fa-temperature-half {
- --fa: "\f2c9";
-}
-
-.fa-temperature-2 {
- --fa: "\f2c9";
-}
-
-.fa-thermometer-2 {
- --fa: "\f2c9";
-}
-
-.fa-thermometer-half {
- --fa: "\f2c9";
-}
-
-.fa-temperature-quarter {
- --fa: "\f2ca";
-}
-
-.fa-temperature-1 {
- --fa: "\f2ca";
-}
-
-.fa-thermometer-1 {
- --fa: "\f2ca";
-}
-
-.fa-thermometer-quarter {
- --fa: "\f2ca";
-}
-
-.fa-temperature-empty {
- --fa: "\f2cb";
-}
-
-.fa-temperature-0 {
- --fa: "\f2cb";
-}
-
-.fa-thermometer-0 {
- --fa: "\f2cb";
-}
-
-.fa-thermometer-empty {
- --fa: "\f2cb";
-}
-
-.fa-shower {
- --fa: "\f2cc";
-}
-
-.fa-bath {
- --fa: "\f2cd";
-}
-
-.fa-bathtub {
- --fa: "\f2cd";
-}
-
-.fa-podcast {
- --fa: "\f2ce";
-}
-
-.fa-window-maximize {
- --fa: "\f2d0";
-}
-
-.fa-window-minimize {
- --fa: "\f2d1";
-}
-
-.fa-window-restore {
- --fa: "\f2d2";
-}
-
-.fa-square-xmark {
- --fa: "\f2d3";
-}
-
-.fa-times-square {
- --fa: "\f2d3";
-}
-
-.fa-xmark-square {
- --fa: "\f2d3";
-}
-
-.fa-microchip {
- --fa: "\f2db";
-}
-
-.fa-snowflake {
- --fa: "\f2dc";
-}
-
-.fa-spoon {
- --fa: "\f2e5";
-}
-
-.fa-utensil-spoon {
- --fa: "\f2e5";
-}
-
-.fa-utensils {
- --fa: "\f2e7";
-}
-
-.fa-cutlery {
- --fa: "\f2e7";
-}
-
-.fa-rotate-left {
- --fa: "\f2ea";
-}
-
-.fa-rotate-back {
- --fa: "\f2ea";
-}
-
-.fa-rotate-backward {
- --fa: "\f2ea";
-}
-
-.fa-undo-alt {
- --fa: "\f2ea";
-}
-
-.fa-trash-can {
- --fa: "\f2ed";
-}
-
-.fa-trash-alt {
- --fa: "\f2ed";
-}
-
-.fa-rotate {
- --fa: "\f2f1";
-}
-
-.fa-sync-alt {
- --fa: "\f2f1";
-}
-
-.fa-stopwatch {
- --fa: "\f2f2";
-}
-
-.fa-right-from-bracket {
- --fa: "\f2f5";
-}
-
-.fa-sign-out-alt {
- --fa: "\f2f5";
-}
-
-.fa-right-to-bracket {
- --fa: "\f2f6";
-}
-
-.fa-sign-in-alt {
- --fa: "\f2f6";
-}
-
-.fa-rotate-right {
- --fa: "\f2f9";
-}
-
-.fa-redo-alt {
- --fa: "\f2f9";
-}
-
-.fa-rotate-forward {
- --fa: "\f2f9";
-}
-
-.fa-poo {
- --fa: "\f2fe";
-}
-
-.fa-images {
- --fa: "\f302";
-}
-
-.fa-pencil {
- --fa: "\f303";
-}
-
-.fa-pencil-alt {
- --fa: "\f303";
-}
-
-.fa-pen {
- --fa: "\f304";
-}
-
-.fa-pen-clip {
- --fa: "\f305";
-}
-
-.fa-pen-alt {
- --fa: "\f305";
-}
-
-.fa-octagon {
- --fa: "\f306";
-}
-
-.fa-down-long {
- --fa: "\f309";
-}
-
-.fa-long-arrow-alt-down {
- --fa: "\f309";
-}
-
-.fa-left-long {
- --fa: "\f30a";
-}
-
-.fa-long-arrow-alt-left {
- --fa: "\f30a";
-}
-
-.fa-right-long {
- --fa: "\f30b";
-}
-
-.fa-long-arrow-alt-right {
- --fa: "\f30b";
-}
-
-.fa-up-long {
- --fa: "\f30c";
-}
-
-.fa-long-arrow-alt-up {
- --fa: "\f30c";
-}
-
-.fa-hexagon {
- --fa: "\f312";
-}
-
-.fa-file-pen {
- --fa: "\f31c";
-}
-
-.fa-file-edit {
- --fa: "\f31c";
-}
-
-.fa-maximize {
- --fa: "\f31e";
-}
-
-.fa-expand-arrows-alt {
- --fa: "\f31e";
-}
-
-.fa-clipboard {
- --fa: "\f328";
-}
-
-.fa-left-right {
- --fa: "\f337";
-}
-
-.fa-arrows-alt-h {
- --fa: "\f337";
-}
-
-.fa-up-down {
- --fa: "\f338";
-}
-
-.fa-arrows-alt-v {
- --fa: "\f338";
-}
-
-.fa-alarm-clock {
- --fa: "\f34e";
-}
-
-.fa-circle-down {
- --fa: "\f358";
-}
-
-.fa-arrow-alt-circle-down {
- --fa: "\f358";
-}
-
-.fa-circle-left {
- --fa: "\f359";
-}
-
-.fa-arrow-alt-circle-left {
- --fa: "\f359";
-}
-
-.fa-circle-right {
- --fa: "\f35a";
-}
-
-.fa-arrow-alt-circle-right {
- --fa: "\f35a";
-}
-
-.fa-circle-up {
- --fa: "\f35b";
-}
-
-.fa-arrow-alt-circle-up {
- --fa: "\f35b";
-}
-
-.fa-up-right-from-square {
- --fa: "\f35d";
-}
-
-.fa-external-link-alt {
- --fa: "\f35d";
-}
-
-.fa-square-up-right {
- --fa: "\f360";
-}
-
-.fa-external-link-square-alt {
- --fa: "\f360";
-}
-
-.fa-right-left {
- --fa: "\f362";
-}
-
-.fa-exchange-alt {
- --fa: "\f362";
-}
-
-.fa-repeat {
- --fa: "\f363";
-}
-
-.fa-code-commit {
- --fa: "\f386";
-}
-
-.fa-code-merge {
- --fa: "\f387";
-}
-
-.fa-desktop {
- --fa: "\f390";
-}
-
-.fa-desktop-alt {
- --fa: "\f390";
-}
-
-.fa-gem {
- --fa: "\f3a5";
-}
-
-.fa-turn-down {
- --fa: "\f3be";
-}
-
-.fa-level-down-alt {
- --fa: "\f3be";
-}
-
-.fa-turn-up {
- --fa: "\f3bf";
-}
-
-.fa-level-up-alt {
- --fa: "\f3bf";
-}
-
-.fa-lock-open {
- --fa: "\f3c1";
-}
-
-.fa-location-dot {
- --fa: "\f3c5";
-}
-
-.fa-map-marker-alt {
- --fa: "\f3c5";
-}
-
-.fa-microphone-lines {
- --fa: "\f3c9";
-}
-
-.fa-microphone-alt {
- --fa: "\f3c9";
-}
-
-.fa-mobile-screen-button {
- --fa: "\f3cd";
-}
-
-.fa-mobile-alt {
- --fa: "\f3cd";
-}
-
-.fa-mobile {
- --fa: "\f3ce";
-}
-
-.fa-mobile-android {
- --fa: "\f3ce";
-}
-
-.fa-mobile-phone {
- --fa: "\f3ce";
-}
-
-.fa-mobile-screen {
- --fa: "\f3cf";
-}
-
-.fa-mobile-android-alt {
- --fa: "\f3cf";
-}
-
-.fa-money-bill-1 {
- --fa: "\f3d1";
-}
-
-.fa-money-bill-alt {
- --fa: "\f3d1";
-}
-
-.fa-phone-slash {
- --fa: "\f3dd";
-}
-
-.fa-image-portrait {
- --fa: "\f3e0";
-}
-
-.fa-portrait {
- --fa: "\f3e0";
-}
-
-.fa-reply {
- --fa: "\f3e5";
-}
-
-.fa-mail-reply {
- --fa: "\f3e5";
-}
-
-.fa-shield-halved {
- --fa: "\f3ed";
-}
-
-.fa-shield-alt {
- --fa: "\f3ed";
-}
-
-.fa-tablet-screen-button {
- --fa: "\f3fa";
-}
-
-.fa-tablet-alt {
- --fa: "\f3fa";
-}
-
-.fa-tablet {
- --fa: "\f3fb";
-}
-
-.fa-tablet-android {
- --fa: "\f3fb";
-}
-
-.fa-ticket-simple {
- --fa: "\f3ff";
-}
-
-.fa-ticket-alt {
- --fa: "\f3ff";
-}
-
-.fa-rectangle-xmark {
- --fa: "\f410";
-}
-
-.fa-rectangle-times {
- --fa: "\f410";
-}
-
-.fa-times-rectangle {
- --fa: "\f410";
-}
-
-.fa-window-close {
- --fa: "\f410";
-}
-
-.fa-down-left-and-up-right-to-center {
- --fa: "\f422";
-}
-
-.fa-compress-alt {
- --fa: "\f422";
-}
-
-.fa-up-right-and-down-left-from-center {
- --fa: "\f424";
-}
-
-.fa-expand-alt {
- --fa: "\f424";
-}
-
-.fa-baseball-bat-ball {
- --fa: "\f432";
-}
-
-.fa-baseball {
- --fa: "\f433";
-}
-
-.fa-baseball-ball {
- --fa: "\f433";
-}
-
-.fa-basketball {
- --fa: "\f434";
-}
-
-.fa-basketball-ball {
- --fa: "\f434";
-}
-
-.fa-bowling-ball {
- --fa: "\f436";
-}
-
-.fa-chess {
- --fa: "\f439";
-}
-
-.fa-chess-bishop {
- --fa: "\f43a";
-}
-
-.fa-chess-board {
- --fa: "\f43c";
-}
-
-.fa-chess-king {
- --fa: "\f43f";
-}
-
-.fa-chess-knight {
- --fa: "\f441";
-}
-
-.fa-chess-pawn {
- --fa: "\f443";
-}
-
-.fa-chess-queen {
- --fa: "\f445";
-}
-
-.fa-chess-rook {
- --fa: "\f447";
-}
-
-.fa-dumbbell {
- --fa: "\f44b";
-}
-
-.fa-football {
- --fa: "\f44e";
-}
-
-.fa-football-ball {
- --fa: "\f44e";
-}
-
-.fa-golf-ball-tee {
- --fa: "\f450";
-}
-
-.fa-golf-ball {
- --fa: "\f450";
-}
-
-.fa-hockey-puck {
- --fa: "\f453";
-}
-
-.fa-broom-ball {
- --fa: "\f458";
-}
-
-.fa-quidditch {
- --fa: "\f458";
-}
-
-.fa-quidditch-broom-ball {
- --fa: "\f458";
-}
-
-.fa-square-full {
- --fa: "\f45c";
-}
-
-.fa-table-tennis-paddle-ball {
- --fa: "\f45d";
-}
-
-.fa-ping-pong-paddle-ball {
- --fa: "\f45d";
-}
-
-.fa-table-tennis {
- --fa: "\f45d";
-}
-
-.fa-volleyball {
- --fa: "\f45f";
-}
-
-.fa-volleyball-ball {
- --fa: "\f45f";
-}
-
-.fa-hand-dots {
- --fa: "\f461";
-}
-
-.fa-allergies {
- --fa: "\f461";
-}
-
-.fa-bandage {
- --fa: "\f462";
-}
-
-.fa-band-aid {
- --fa: "\f462";
-}
-
-.fa-box {
- --fa: "\f466";
-}
-
-.fa-boxes-stacked {
- --fa: "\f468";
-}
-
-.fa-boxes {
- --fa: "\f468";
-}
-
-.fa-boxes-alt {
- --fa: "\f468";
-}
-
-.fa-briefcase-medical {
- --fa: "\f469";
-}
-
-.fa-fire-flame-simple {
- --fa: "\f46a";
-}
-
-.fa-burn {
- --fa: "\f46a";
-}
-
-.fa-capsules {
- --fa: "\f46b";
-}
-
-.fa-clipboard-check {
- --fa: "\f46c";
-}
-
-.fa-clipboard-list {
- --fa: "\f46d";
-}
-
-.fa-person-dots-from-line {
- --fa: "\f470";
-}
-
-.fa-diagnoses {
- --fa: "\f470";
-}
-
-.fa-dna {
- --fa: "\f471";
-}
-
-.fa-dolly {
- --fa: "\f472";
-}
-
-.fa-dolly-box {
- --fa: "\f472";
-}
-
-.fa-cart-flatbed {
- --fa: "\f474";
-}
-
-.fa-dolly-flatbed {
- --fa: "\f474";
-}
-
-.fa-file-medical {
- --fa: "\f477";
-}
-
-.fa-file-waveform {
- --fa: "\f478";
-}
-
-.fa-file-medical-alt {
- --fa: "\f478";
-}
-
-.fa-kit-medical {
- --fa: "\f479";
-}
-
-.fa-first-aid {
- --fa: "\f479";
-}
-
-.fa-circle-h {
- --fa: "\f47e";
-}
-
-.fa-hospital-symbol {
- --fa: "\f47e";
-}
-
-.fa-id-card-clip {
- --fa: "\f47f";
-}
-
-.fa-id-card-alt {
- --fa: "\f47f";
-}
-
-.fa-notes-medical {
- --fa: "\f481";
-}
-
-.fa-pallet {
- --fa: "\f482";
-}
-
-.fa-pills {
- --fa: "\f484";
-}
-
-.fa-prescription-bottle {
- --fa: "\f485";
-}
-
-.fa-prescription-bottle-medical {
- --fa: "\f486";
-}
-
-.fa-prescription-bottle-alt {
- --fa: "\f486";
-}
-
-.fa-bed-pulse {
- --fa: "\f487";
-}
-
-.fa-procedures {
- --fa: "\f487";
-}
-
-.fa-truck-fast {
- --fa: "\f48b";
-}
-
-.fa-shipping-fast {
- --fa: "\f48b";
-}
-
-.fa-smoking {
- --fa: "\f48d";
-}
-
-.fa-syringe {
- --fa: "\f48e";
-}
-
-.fa-tablets {
- --fa: "\f490";
-}
-
-.fa-thermometer {
- --fa: "\f491";
-}
-
-.fa-vial {
- --fa: "\f492";
-}
-
-.fa-vials {
- --fa: "\f493";
-}
-
-.fa-warehouse {
- --fa: "\f494";
-}
-
-.fa-weight-scale {
- --fa: "\f496";
-}
-
-.fa-weight {
- --fa: "\f496";
-}
-
-.fa-x-ray {
- --fa: "\f497";
-}
-
-.fa-box-open {
- --fa: "\f49e";
-}
-
-.fa-comment-dots {
- --fa: "\f4ad";
-}
-
-.fa-commenting {
- --fa: "\f4ad";
-}
-
-.fa-comment-slash {
- --fa: "\f4b3";
-}
-
-.fa-couch {
- --fa: "\f4b8";
-}
-
-.fa-circle-dollar-to-slot {
- --fa: "\f4b9";
-}
-
-.fa-donate {
- --fa: "\f4b9";
-}
-
-.fa-dove {
- --fa: "\f4ba";
-}
-
-.fa-hand-holding {
- --fa: "\f4bd";
-}
-
-.fa-hand-holding-heart {
- --fa: "\f4be";
-}
-
-.fa-hand-holding-dollar {
- --fa: "\f4c0";
-}
-
-.fa-hand-holding-usd {
- --fa: "\f4c0";
-}
-
-.fa-hand-holding-droplet {
- --fa: "\f4c1";
-}
-
-.fa-hand-holding-water {
- --fa: "\f4c1";
-}
-
-.fa-hands-holding {
- --fa: "\f4c2";
-}
-
-.fa-handshake-angle {
- --fa: "\f4c4";
-}
-
-.fa-hands-helping {
- --fa: "\f4c4";
-}
-
-.fa-parachute-box {
- --fa: "\f4cd";
-}
-
-.fa-people-carry-box {
- --fa: "\f4ce";
-}
-
-.fa-people-carry {
- --fa: "\f4ce";
-}
-
-.fa-piggy-bank {
- --fa: "\f4d3";
-}
-
-.fa-ribbon {
- --fa: "\f4d6";
-}
-
-.fa-route {
- --fa: "\f4d7";
-}
-
-.fa-seedling {
- --fa: "\f4d8";
-}
-
-.fa-sprout {
- --fa: "\f4d8";
-}
-
-.fa-sign-hanging {
- --fa: "\f4d9";
-}
-
-.fa-sign {
- --fa: "\f4d9";
-}
-
-.fa-face-smile-wink {
- --fa: "\f4da";
-}
-
-.fa-smile-wink {
- --fa: "\f4da";
-}
-
-.fa-tape {
- --fa: "\f4db";
-}
-
-.fa-truck-ramp-box {
- --fa: "\f4de";
-}
-
-.fa-truck-loading {
- --fa: "\f4de";
-}
-
-.fa-truck-moving {
- --fa: "\f4df";
-}
-
-.fa-video-slash {
- --fa: "\f4e2";
-}
-
-.fa-wine-glass {
- --fa: "\f4e3";
-}
-
-.fa-user-astronaut {
- --fa: "\f4fb";
-}
-
-.fa-user-check {
- --fa: "\f4fc";
-}
-
-.fa-user-clock {
- --fa: "\f4fd";
-}
-
-.fa-user-gear {
- --fa: "\f4fe";
-}
-
-.fa-user-cog {
- --fa: "\f4fe";
-}
-
-.fa-user-pen {
- --fa: "\f4ff";
-}
-
-.fa-user-edit {
- --fa: "\f4ff";
-}
-
-.fa-user-group {
- --fa: "\f500";
-}
-
-.fa-user-friends {
- --fa: "\f500";
-}
-
-.fa-user-graduate {
- --fa: "\f501";
-}
-
-.fa-user-lock {
- --fa: "\f502";
-}
-
-.fa-user-minus {
- --fa: "\f503";
-}
-
-.fa-user-ninja {
- --fa: "\f504";
-}
-
-.fa-user-shield {
- --fa: "\f505";
-}
-
-.fa-user-slash {
- --fa: "\f506";
-}
-
-.fa-user-alt-slash {
- --fa: "\f506";
-}
-
-.fa-user-large-slash {
- --fa: "\f506";
-}
-
-.fa-user-tag {
- --fa: "\f507";
-}
-
-.fa-user-tie {
- --fa: "\f508";
-}
-
-.fa-users-gear {
- --fa: "\f509";
-}
-
-.fa-users-cog {
- --fa: "\f509";
-}
-
-.fa-scale-unbalanced {
- --fa: "\f515";
-}
-
-.fa-balance-scale-left {
- --fa: "\f515";
-}
-
-.fa-scale-unbalanced-flip {
- --fa: "\f516";
-}
-
-.fa-balance-scale-right {
- --fa: "\f516";
-}
-
-.fa-blender {
- --fa: "\f517";
-}
-
-.fa-book-open {
- --fa: "\f518";
-}
-
-.fa-tower-broadcast {
- --fa: "\f519";
-}
-
-.fa-broadcast-tower {
- --fa: "\f519";
-}
-
-.fa-broom {
- --fa: "\f51a";
-}
-
-.fa-chalkboard {
- --fa: "\f51b";
-}
-
-.fa-blackboard {
- --fa: "\f51b";
-}
-
-.fa-chalkboard-user {
- --fa: "\f51c";
-}
-
-.fa-chalkboard-teacher {
- --fa: "\f51c";
-}
-
-.fa-church {
- --fa: "\f51d";
-}
-
-.fa-coins {
- --fa: "\f51e";
-}
-
-.fa-compact-disc {
- --fa: "\f51f";
-}
-
-.fa-crow {
- --fa: "\f520";
-}
-
-.fa-crown {
- --fa: "\f521";
-}
-
-.fa-dice {
- --fa: "\f522";
-}
-
-.fa-dice-five {
- --fa: "\f523";
-}
-
-.fa-dice-four {
- --fa: "\f524";
-}
-
-.fa-dice-one {
- --fa: "\f525";
-}
-
-.fa-dice-six {
- --fa: "\f526";
-}
-
-.fa-dice-three {
- --fa: "\f527";
-}
-
-.fa-dice-two {
- --fa: "\f528";
-}
-
-.fa-divide {
- --fa: "\f529";
-}
-
-.fa-door-closed {
- --fa: "\f52a";
-}
-
-.fa-door-open {
- --fa: "\f52b";
-}
-
-.fa-feather {
- --fa: "\f52d";
-}
-
-.fa-frog {
- --fa: "\f52e";
-}
-
-.fa-gas-pump {
- --fa: "\f52f";
-}
-
-.fa-glasses {
- --fa: "\f530";
-}
-
-.fa-greater-than-equal {
- --fa: "\f532";
-}
-
-.fa-helicopter {
- --fa: "\f533";
-}
-
-.fa-infinity {
- --fa: "\f534";
-}
-
-.fa-kiwi-bird {
- --fa: "\f535";
-}
-
-.fa-less-than-equal {
- --fa: "\f537";
-}
-
-.fa-memory {
- --fa: "\f538";
-}
-
-.fa-microphone-lines-slash {
- --fa: "\f539";
-}
-
-.fa-microphone-alt-slash {
- --fa: "\f539";
-}
-
-.fa-money-bill-wave {
- --fa: "\f53a";
-}
-
-.fa-money-bill-1-wave {
- --fa: "\f53b";
-}
-
-.fa-money-bill-wave-alt {
- --fa: "\f53b";
-}
-
-.fa-money-check {
- --fa: "\f53c";
-}
-
-.fa-money-check-dollar {
- --fa: "\f53d";
-}
-
-.fa-money-check-alt {
- --fa: "\f53d";
-}
-
-.fa-not-equal {
- --fa: "\f53e";
-}
-
-.fa-palette {
- --fa: "\f53f";
-}
-
-.fa-square-parking {
- --fa: "\f540";
-}
-
-.fa-parking {
- --fa: "\f540";
-}
-
-.fa-diagram-project {
- --fa: "\f542";
-}
-
-.fa-project-diagram {
- --fa: "\f542";
-}
-
-.fa-receipt {
- --fa: "\f543";
-}
-
-.fa-robot {
- --fa: "\f544";
-}
-
-.fa-ruler {
- --fa: "\f545";
-}
-
-.fa-ruler-combined {
- --fa: "\f546";
-}
-
-.fa-ruler-horizontal {
- --fa: "\f547";
-}
-
-.fa-ruler-vertical {
- --fa: "\f548";
-}
-
-.fa-school {
- --fa: "\f549";
-}
-
-.fa-screwdriver {
- --fa: "\f54a";
-}
-
-.fa-shoe-prints {
- --fa: "\f54b";
-}
-
-.fa-skull {
- --fa: "\f54c";
-}
-
-.fa-ban-smoking {
- --fa: "\f54d";
-}
-
-.fa-smoking-ban {
- --fa: "\f54d";
-}
-
-.fa-store {
- --fa: "\f54e";
-}
-
-.fa-shop {
- --fa: "\f54f";
-}
-
-.fa-store-alt {
- --fa: "\f54f";
-}
-
-.fa-bars-staggered {
- --fa: "\f550";
-}
-
-.fa-reorder {
- --fa: "\f550";
-}
-
-.fa-stream {
- --fa: "\f550";
-}
-
-.fa-stroopwafel {
- --fa: "\f551";
-}
-
-.fa-toolbox {
- --fa: "\f552";
-}
-
-.fa-shirt {
- --fa: "\f553";
-}
-
-.fa-t-shirt {
- --fa: "\f553";
-}
-
-.fa-tshirt {
- --fa: "\f553";
-}
-
-.fa-person-walking {
- --fa: "\f554";
-}
-
-.fa-walking {
- --fa: "\f554";
-}
-
-.fa-wallet {
- --fa: "\f555";
-}
-
-.fa-face-angry {
- --fa: "\f556";
-}
-
-.fa-angry {
- --fa: "\f556";
-}
-
-.fa-archway {
- --fa: "\f557";
-}
-
-.fa-book-atlas {
- --fa: "\f558";
-}
-
-.fa-atlas {
- --fa: "\f558";
-}
-
-.fa-award {
- --fa: "\f559";
-}
-
-.fa-delete-left {
- --fa: "\f55a";
-}
-
-.fa-backspace {
- --fa: "\f55a";
-}
-
-.fa-bezier-curve {
- --fa: "\f55b";
-}
-
-.fa-bong {
- --fa: "\f55c";
-}
-
-.fa-brush {
- --fa: "\f55d";
-}
-
-.fa-bus-simple {
- --fa: "\f55e";
-}
-
-.fa-bus-alt {
- --fa: "\f55e";
-}
-
-.fa-cannabis {
- --fa: "\f55f";
-}
-
-.fa-check-double {
- --fa: "\f560";
-}
-
-.fa-martini-glass-citrus {
- --fa: "\f561";
-}
-
-.fa-cocktail {
- --fa: "\f561";
-}
-
-.fa-bell-concierge {
- --fa: "\f562";
-}
-
-.fa-concierge-bell {
- --fa: "\f562";
-}
-
-.fa-cookie {
- --fa: "\f563";
-}
-
-.fa-cookie-bite {
- --fa: "\f564";
-}
-
-.fa-crop-simple {
- --fa: "\f565";
-}
-
-.fa-crop-alt {
- --fa: "\f565";
-}
-
-.fa-tachograph-digital {
- --fa: "\f566";
-}
-
-.fa-digital-tachograph {
- --fa: "\f566";
-}
-
-.fa-face-dizzy {
- --fa: "\f567";
-}
-
-.fa-dizzy {
- --fa: "\f567";
-}
-
-.fa-compass-drafting {
- --fa: "\f568";
-}
-
-.fa-drafting-compass {
- --fa: "\f568";
-}
-
-.fa-drum {
- --fa: "\f569";
-}
-
-.fa-drum-steelpan {
- --fa: "\f56a";
-}
-
-.fa-feather-pointed {
- --fa: "\f56b";
-}
-
-.fa-feather-alt {
- --fa: "\f56b";
-}
-
-.fa-file-contract {
- --fa: "\f56c";
-}
-
-.fa-file-arrow-down {
- --fa: "\f56d";
-}
-
-.fa-file-download {
- --fa: "\f56d";
-}
-
-.fa-file-export {
- --fa: "\f56e";
-}
-
-.fa-arrow-right-from-file {
- --fa: "\f56e";
-}
-
-.fa-file-import {
- --fa: "\f56f";
-}
-
-.fa-arrow-right-to-file {
- --fa: "\f56f";
-}
-
-.fa-file-invoice {
- --fa: "\f570";
-}
-
-.fa-file-invoice-dollar {
- --fa: "\f571";
-}
-
-.fa-file-prescription {
- --fa: "\f572";
-}
-
-.fa-file-signature {
- --fa: "\f573";
-}
-
-.fa-file-arrow-up {
- --fa: "\f574";
-}
-
-.fa-file-upload {
- --fa: "\f574";
-}
-
-.fa-fill {
- --fa: "\f575";
-}
-
-.fa-fill-drip {
- --fa: "\f576";
-}
-
-.fa-fingerprint {
- --fa: "\f577";
-}
-
-.fa-fish {
- --fa: "\f578";
-}
-
-.fa-face-flushed {
- --fa: "\f579";
-}
-
-.fa-flushed {
- --fa: "\f579";
-}
-
-.fa-face-frown-open {
- --fa: "\f57a";
-}
-
-.fa-frown-open {
- --fa: "\f57a";
-}
-
-.fa-martini-glass {
- --fa: "\f57b";
-}
-
-.fa-glass-martini-alt {
- --fa: "\f57b";
-}
-
-.fa-earth-africa {
- --fa: "\f57c";
-}
-
-.fa-globe-africa {
- --fa: "\f57c";
-}
-
-.fa-earth-americas {
- --fa: "\f57d";
-}
-
-.fa-earth {
- --fa: "\f57d";
-}
-
-.fa-earth-america {
- --fa: "\f57d";
-}
-
-.fa-globe-americas {
- --fa: "\f57d";
-}
-
-.fa-earth-asia {
- --fa: "\f57e";
-}
-
-.fa-globe-asia {
- --fa: "\f57e";
-}
-
-.fa-face-grimace {
- --fa: "\f57f";
-}
-
-.fa-grimace {
- --fa: "\f57f";
-}
-
-.fa-face-grin {
- --fa: "\f580";
-}
-
-.fa-grin {
- --fa: "\f580";
-}
-
-.fa-face-grin-wide {
- --fa: "\f581";
-}
-
-.fa-grin-alt {
- --fa: "\f581";
-}
-
-.fa-face-grin-beam {
- --fa: "\f582";
-}
-
-.fa-grin-beam {
- --fa: "\f582";
-}
-
-.fa-face-grin-beam-sweat {
- --fa: "\f583";
-}
-
-.fa-grin-beam-sweat {
- --fa: "\f583";
-}
-
-.fa-face-grin-hearts {
- --fa: "\f584";
-}
-
-.fa-grin-hearts {
- --fa: "\f584";
-}
-
-.fa-face-grin-squint {
- --fa: "\f585";
-}
-
-.fa-grin-squint {
- --fa: "\f585";
-}
-
-.fa-face-grin-squint-tears {
- --fa: "\f586";
-}
-
-.fa-grin-squint-tears {
- --fa: "\f586";
-}
-
-.fa-face-grin-stars {
- --fa: "\f587";
-}
-
-.fa-grin-stars {
- --fa: "\f587";
-}
-
-.fa-face-grin-tears {
- --fa: "\f588";
-}
-
-.fa-grin-tears {
- --fa: "\f588";
-}
-
-.fa-face-grin-tongue {
- --fa: "\f589";
-}
-
-.fa-grin-tongue {
- --fa: "\f589";
-}
-
-.fa-face-grin-tongue-squint {
- --fa: "\f58a";
-}
-
-.fa-grin-tongue-squint {
- --fa: "\f58a";
-}
-
-.fa-face-grin-tongue-wink {
- --fa: "\f58b";
-}
-
-.fa-grin-tongue-wink {
- --fa: "\f58b";
-}
-
-.fa-face-grin-wink {
- --fa: "\f58c";
-}
-
-.fa-grin-wink {
- --fa: "\f58c";
-}
-
-.fa-grip {
- --fa: "\f58d";
-}
-
-.fa-grid-horizontal {
- --fa: "\f58d";
-}
-
-.fa-grip-horizontal {
- --fa: "\f58d";
-}
-
-.fa-grip-vertical {
- --fa: "\f58e";
-}
-
-.fa-grid-vertical {
- --fa: "\f58e";
-}
-
-.fa-headset {
- --fa: "\f590";
-}
-
-.fa-highlighter {
- --fa: "\f591";
-}
-
-.fa-hot-tub-person {
- --fa: "\f593";
-}
-
-.fa-hot-tub {
- --fa: "\f593";
-}
-
-.fa-hotel {
- --fa: "\f594";
-}
-
-.fa-joint {
- --fa: "\f595";
-}
-
-.fa-face-kiss {
- --fa: "\f596";
-}
-
-.fa-kiss {
- --fa: "\f596";
-}
-
-.fa-face-kiss-beam {
- --fa: "\f597";
-}
-
-.fa-kiss-beam {
- --fa: "\f597";
-}
-
-.fa-face-kiss-wink-heart {
- --fa: "\f598";
-}
-
-.fa-kiss-wink-heart {
- --fa: "\f598";
-}
-
-.fa-face-laugh {
- --fa: "\f599";
-}
-
-.fa-laugh {
- --fa: "\f599";
-}
-
-.fa-face-laugh-beam {
- --fa: "\f59a";
-}
-
-.fa-laugh-beam {
- --fa: "\f59a";
-}
-
-.fa-face-laugh-squint {
- --fa: "\f59b";
-}
-
-.fa-laugh-squint {
- --fa: "\f59b";
-}
-
-.fa-face-laugh-wink {
- --fa: "\f59c";
-}
-
-.fa-laugh-wink {
- --fa: "\f59c";
-}
-
-.fa-cart-flatbed-suitcase {
- --fa: "\f59d";
-}
-
-.fa-luggage-cart {
- --fa: "\f59d";
-}
-
-.fa-map-location {
- --fa: "\f59f";
-}
-
-.fa-map-marked {
- --fa: "\f59f";
-}
-
-.fa-map-location-dot {
- --fa: "\f5a0";
-}
-
-.fa-map-marked-alt {
- --fa: "\f5a0";
-}
-
-.fa-marker {
- --fa: "\f5a1";
-}
-
-.fa-medal {
- --fa: "\f5a2";
-}
-
-.fa-face-meh-blank {
- --fa: "\f5a4";
-}
-
-.fa-meh-blank {
- --fa: "\f5a4";
-}
-
-.fa-face-rolling-eyes {
- --fa: "\f5a5";
-}
-
-.fa-meh-rolling-eyes {
- --fa: "\f5a5";
-}
-
-.fa-monument {
- --fa: "\f5a6";
-}
-
-.fa-mortar-pestle {
- --fa: "\f5a7";
-}
-
-.fa-paint-roller {
- --fa: "\f5aa";
-}
-
-.fa-passport {
- --fa: "\f5ab";
-}
-
-.fa-pen-fancy {
- --fa: "\f5ac";
-}
-
-.fa-pen-nib {
- --fa: "\f5ad";
-}
-
-.fa-pen-ruler {
- --fa: "\f5ae";
-}
-
-.fa-pencil-ruler {
- --fa: "\f5ae";
-}
-
-.fa-plane-arrival {
- --fa: "\f5af";
-}
-
-.fa-plane-departure {
- --fa: "\f5b0";
-}
-
-.fa-prescription {
- --fa: "\f5b1";
-}
-
-.fa-face-sad-cry {
- --fa: "\f5b3";
-}
-
-.fa-sad-cry {
- --fa: "\f5b3";
-}
-
-.fa-face-sad-tear {
- --fa: "\f5b4";
-}
-
-.fa-sad-tear {
- --fa: "\f5b4";
-}
-
-.fa-van-shuttle {
- --fa: "\f5b6";
-}
-
-.fa-shuttle-van {
- --fa: "\f5b6";
-}
-
-.fa-signature {
- --fa: "\f5b7";
-}
-
-.fa-face-smile-beam {
- --fa: "\f5b8";
-}
-
-.fa-smile-beam {
- --fa: "\f5b8";
-}
-
-.fa-solar-panel {
- --fa: "\f5ba";
-}
-
-.fa-spa {
- --fa: "\f5bb";
-}
-
-.fa-splotch {
- --fa: "\f5bc";
-}
-
-.fa-spray-can {
- --fa: "\f5bd";
-}
-
-.fa-stamp {
- --fa: "\f5bf";
-}
-
-.fa-star-half-stroke {
- --fa: "\f5c0";
-}
-
-.fa-star-half-alt {
- --fa: "\f5c0";
-}
-
-.fa-suitcase-rolling {
- --fa: "\f5c1";
-}
-
-.fa-face-surprise {
- --fa: "\f5c2";
-}
-
-.fa-surprise {
- --fa: "\f5c2";
-}
-
-.fa-swatchbook {
- --fa: "\f5c3";
-}
-
-.fa-person-swimming {
- --fa: "\f5c4";
-}
-
-.fa-swimmer {
- --fa: "\f5c4";
-}
-
-.fa-water-ladder {
- --fa: "\f5c5";
-}
-
-.fa-ladder-water {
- --fa: "\f5c5";
-}
-
-.fa-swimming-pool {
- --fa: "\f5c5";
-}
-
-.fa-droplet-slash {
- --fa: "\f5c7";
-}
-
-.fa-tint-slash {
- --fa: "\f5c7";
-}
-
-.fa-face-tired {
- --fa: "\f5c8";
-}
-
-.fa-tired {
- --fa: "\f5c8";
-}
-
-.fa-tooth {
- --fa: "\f5c9";
-}
-
-.fa-umbrella-beach {
- --fa: "\f5ca";
-}
-
-.fa-weight-hanging {
- --fa: "\f5cd";
-}
-
-.fa-wine-glass-empty {
- --fa: "\f5ce";
-}
-
-.fa-wine-glass-alt {
- --fa: "\f5ce";
-}
-
-.fa-spray-can-sparkles {
- --fa: "\f5d0";
-}
-
-.fa-air-freshener {
- --fa: "\f5d0";
-}
-
-.fa-apple-whole {
- --fa: "\f5d1";
-}
-
-.fa-apple-alt {
- --fa: "\f5d1";
-}
-
-.fa-atom {
- --fa: "\f5d2";
-}
-
-.fa-bone {
- --fa: "\f5d7";
-}
-
-.fa-book-open-reader {
- --fa: "\f5da";
-}
-
-.fa-book-reader {
- --fa: "\f5da";
-}
-
-.fa-brain {
- --fa: "\f5dc";
-}
-
-.fa-car-rear {
- --fa: "\f5de";
-}
-
-.fa-car-alt {
- --fa: "\f5de";
-}
-
-.fa-car-battery {
- --fa: "\f5df";
-}
-
-.fa-battery-car {
- --fa: "\f5df";
-}
-
-.fa-car-burst {
- --fa: "\f5e1";
-}
-
-.fa-car-crash {
- --fa: "\f5e1";
-}
-
-.fa-car-side {
- --fa: "\f5e4";
-}
-
-.fa-charging-station {
- --fa: "\f5e7";
-}
-
-.fa-diamond-turn-right {
- --fa: "\f5eb";
-}
-
-.fa-directions {
- --fa: "\f5eb";
-}
-
-.fa-draw-polygon {
- --fa: "\f5ee";
-}
-
-.fa-vector-polygon {
- --fa: "\f5ee";
-}
-
-.fa-laptop-code {
- --fa: "\f5fc";
-}
-
-.fa-layer-group {
- --fa: "\f5fd";
-}
-
-.fa-location-crosshairs {
- --fa: "\f601";
-}
-
-.fa-location {
- --fa: "\f601";
-}
-
-.fa-lungs {
- --fa: "\f604";
-}
-
-.fa-microscope {
- --fa: "\f610";
-}
-
-.fa-oil-can {
- --fa: "\f613";
-}
-
-.fa-poop {
- --fa: "\f619";
-}
-
-.fa-shapes {
- --fa: "\f61f";
-}
-
-.fa-triangle-circle-square {
- --fa: "\f61f";
-}
-
-.fa-star-of-life {
- --fa: "\f621";
-}
-
-.fa-gauge {
- --fa: "\f624";
-}
-
-.fa-dashboard {
- --fa: "\f624";
-}
-
-.fa-gauge-med {
- --fa: "\f624";
-}
-
-.fa-tachometer-alt-average {
- --fa: "\f624";
-}
-
-.fa-gauge-high {
- --fa: "\f625";
-}
-
-.fa-tachometer-alt {
- --fa: "\f625";
-}
-
-.fa-tachometer-alt-fast {
- --fa: "\f625";
-}
-
-.fa-gauge-simple {
- --fa: "\f629";
-}
-
-.fa-gauge-simple-med {
- --fa: "\f629";
-}
-
-.fa-tachometer-average {
- --fa: "\f629";
-}
-
-.fa-gauge-simple-high {
- --fa: "\f62a";
-}
-
-.fa-tachometer {
- --fa: "\f62a";
-}
-
-.fa-tachometer-fast {
- --fa: "\f62a";
-}
-
-.fa-teeth {
- --fa: "\f62e";
-}
-
-.fa-teeth-open {
- --fa: "\f62f";
-}
-
-.fa-masks-theater {
- --fa: "\f630";
-}
-
-.fa-theater-masks {
- --fa: "\f630";
-}
-
-.fa-traffic-light {
- --fa: "\f637";
-}
-
-.fa-truck-monster {
- --fa: "\f63b";
-}
-
-.fa-truck-pickup {
- --fa: "\f63c";
-}
-
-.fa-rectangle-ad {
- --fa: "\f641";
-}
-
-.fa-ad {
- --fa: "\f641";
-}
-
-.fa-ankh {
- --fa: "\f644";
-}
-
-.fa-book-bible {
- --fa: "\f647";
-}
-
-.fa-bible {
- --fa: "\f647";
-}
-
-.fa-business-time {
- --fa: "\f64a";
-}
-
-.fa-briefcase-clock {
- --fa: "\f64a";
-}
-
-.fa-city {
- --fa: "\f64f";
-}
-
-.fa-comment-dollar {
- --fa: "\f651";
-}
-
-.fa-comments-dollar {
- --fa: "\f653";
-}
-
-.fa-cross {
- --fa: "\f654";
-}
-
-.fa-dharmachakra {
- --fa: "\f655";
-}
-
-.fa-envelope-open-text {
- --fa: "\f658";
-}
-
-.fa-folder-minus {
- --fa: "\f65d";
-}
-
-.fa-folder-plus {
- --fa: "\f65e";
-}
-
-.fa-filter-circle-dollar {
- --fa: "\f662";
-}
-
-.fa-funnel-dollar {
- --fa: "\f662";
-}
-
-.fa-gopuram {
- --fa: "\f664";
-}
-
-.fa-hamsa {
- --fa: "\f665";
-}
-
-.fa-bahai {
- --fa: "\f666";
-}
-
-.fa-haykal {
- --fa: "\f666";
-}
-
-.fa-jedi {
- --fa: "\f669";
-}
-
-.fa-book-journal-whills {
- --fa: "\f66a";
-}
-
-.fa-journal-whills {
- --fa: "\f66a";
-}
-
-.fa-kaaba {
- --fa: "\f66b";
-}
-
-.fa-khanda {
- --fa: "\f66d";
-}
-
-.fa-landmark {
- --fa: "\f66f";
-}
-
-.fa-envelopes-bulk {
- --fa: "\f674";
-}
-
-.fa-mail-bulk {
- --fa: "\f674";
-}
-
-.fa-menorah {
- --fa: "\f676";
-}
-
-.fa-mosque {
- --fa: "\f678";
-}
-
-.fa-om {
- --fa: "\f679";
-}
-
-.fa-spaghetti-monster-flying {
- --fa: "\f67b";
-}
-
-.fa-pastafarianism {
- --fa: "\f67b";
-}
-
-.fa-peace {
- --fa: "\f67c";
-}
-
-.fa-place-of-worship {
- --fa: "\f67f";
-}
-
-.fa-square-poll-vertical {
- --fa: "\f681";
-}
-
-.fa-poll {
- --fa: "\f681";
-}
-
-.fa-square-poll-horizontal {
- --fa: "\f682";
-}
-
-.fa-poll-h {
- --fa: "\f682";
-}
-
-.fa-person-praying {
- --fa: "\f683";
-}
-
-.fa-pray {
- --fa: "\f683";
-}
-
-.fa-hands-praying {
- --fa: "\f684";
-}
-
-.fa-praying-hands {
- --fa: "\f684";
-}
-
-.fa-book-quran {
- --fa: "\f687";
-}
-
-.fa-quran {
- --fa: "\f687";
-}
-
-.fa-magnifying-glass-dollar {
- --fa: "\f688";
-}
-
-.fa-search-dollar {
- --fa: "\f688";
-}
-
-.fa-magnifying-glass-location {
- --fa: "\f689";
-}
-
-.fa-search-location {
- --fa: "\f689";
-}
-
-.fa-socks {
- --fa: "\f696";
-}
-
-.fa-square-root-variable {
- --fa: "\f698";
-}
-
-.fa-square-root-alt {
- --fa: "\f698";
-}
-
-.fa-star-and-crescent {
- --fa: "\f699";
-}
-
-.fa-star-of-david {
- --fa: "\f69a";
-}
-
-.fa-synagogue {
- --fa: "\f69b";
-}
-
-.fa-scroll-torah {
- --fa: "\f6a0";
-}
-
-.fa-torah {
- --fa: "\f6a0";
-}
-
-.fa-torii-gate {
- --fa: "\f6a1";
-}
-
-.fa-vihara {
- --fa: "\f6a7";
-}
-
-.fa-volume-xmark {
- --fa: "\f6a9";
-}
-
-.fa-volume-mute {
- --fa: "\f6a9";
-}
-
-.fa-volume-times {
- --fa: "\f6a9";
-}
-
-.fa-yin-yang {
- --fa: "\f6ad";
-}
-
-.fa-blender-phone {
- --fa: "\f6b6";
-}
-
-.fa-book-skull {
- --fa: "\f6b7";
-}
-
-.fa-book-dead {
- --fa: "\f6b7";
-}
-
-.fa-campground {
- --fa: "\f6bb";
-}
-
-.fa-cat {
- --fa: "\f6be";
-}
-
-.fa-chair {
- --fa: "\f6c0";
-}
-
-.fa-cloud-moon {
- --fa: "\f6c3";
-}
-
-.fa-cloud-sun {
- --fa: "\f6c4";
-}
-
-.fa-cow {
- --fa: "\f6c8";
-}
-
-.fa-dice-d20 {
- --fa: "\f6cf";
-}
-
-.fa-dice-d6 {
- --fa: "\f6d1";
-}
-
-.fa-dog {
- --fa: "\f6d3";
-}
-
-.fa-dragon {
- --fa: "\f6d5";
-}
-
-.fa-drumstick-bite {
- --fa: "\f6d7";
-}
-
-.fa-dungeon {
- --fa: "\f6d9";
-}
-
-.fa-file-csv {
- --fa: "\f6dd";
-}
-
-.fa-hand-fist {
- --fa: "\f6de";
-}
-
-.fa-fist-raised {
- --fa: "\f6de";
-}
-
-.fa-ghost {
- --fa: "\f6e2";
-}
-
-.fa-hammer {
- --fa: "\f6e3";
-}
-
-.fa-hanukiah {
- --fa: "\f6e6";
-}
-
-.fa-hat-wizard {
- --fa: "\f6e8";
-}
-
-.fa-person-hiking {
- --fa: "\f6ec";
-}
-
-.fa-hiking {
- --fa: "\f6ec";
-}
-
-.fa-hippo {
- --fa: "\f6ed";
-}
-
-.fa-horse {
- --fa: "\f6f0";
-}
-
-.fa-house-chimney-crack {
- --fa: "\f6f1";
-}
-
-.fa-house-damage {
- --fa: "\f6f1";
-}
-
-.fa-hryvnia-sign {
- --fa: "\f6f2";
-}
-
-.fa-hryvnia {
- --fa: "\f6f2";
-}
-
-.fa-mask {
- --fa: "\f6fa";
-}
-
-.fa-mountain {
- --fa: "\f6fc";
-}
-
-.fa-network-wired {
- --fa: "\f6ff";
-}
-
-.fa-otter {
- --fa: "\f700";
-}
-
-.fa-ring {
- --fa: "\f70b";
-}
-
-.fa-person-running {
- --fa: "\f70c";
-}
-
-.fa-running {
- --fa: "\f70c";
-}
-
-.fa-scroll {
- --fa: "\f70e";
-}
-
-.fa-skull-crossbones {
- --fa: "\f714";
-}
-
-.fa-slash {
- --fa: "\f715";
-}
-
-.fa-spider {
- --fa: "\f717";
-}
-
-.fa-toilet-paper {
- --fa: "\f71e";
-}
-
-.fa-toilet-paper-alt {
- --fa: "\f71e";
-}
-
-.fa-toilet-paper-blank {
- --fa: "\f71e";
-}
-
-.fa-tractor {
- --fa: "\f722";
-}
-
-.fa-user-injured {
- --fa: "\f728";
-}
-
-.fa-vr-cardboard {
- --fa: "\f729";
-}
-
-.fa-wand-sparkles {
- --fa: "\f72b";
-}
-
-.fa-wind {
- --fa: "\f72e";
-}
-
-.fa-wine-bottle {
- --fa: "\f72f";
-}
-
-.fa-cloud-meatball {
- --fa: "\f73b";
-}
-
-.fa-cloud-moon-rain {
- --fa: "\f73c";
-}
-
-.fa-cloud-rain {
- --fa: "\f73d";
-}
-
-.fa-cloud-showers-heavy {
- --fa: "\f740";
-}
-
-.fa-cloud-sun-rain {
- --fa: "\f743";
-}
-
-.fa-democrat {
- --fa: "\f747";
-}
-
-.fa-flag-usa {
- --fa: "\f74d";
-}
-
-.fa-hurricane {
- --fa: "\f751";
-}
-
-.fa-landmark-dome {
- --fa: "\f752";
-}
-
-.fa-landmark-alt {
- --fa: "\f752";
-}
-
-.fa-meteor {
- --fa: "\f753";
-}
-
-.fa-person-booth {
- --fa: "\f756";
-}
-
-.fa-poo-storm {
- --fa: "\f75a";
-}
-
-.fa-poo-bolt {
- --fa: "\f75a";
-}
-
-.fa-rainbow {
- --fa: "\f75b";
-}
-
-.fa-republican {
- --fa: "\f75e";
-}
-
-.fa-smog {
- --fa: "\f75f";
-}
-
-.fa-temperature-high {
- --fa: "\f769";
-}
-
-.fa-temperature-low {
- --fa: "\f76b";
-}
-
-.fa-cloud-bolt {
- --fa: "\f76c";
-}
-
-.fa-thunderstorm {
- --fa: "\f76c";
-}
-
-.fa-tornado {
- --fa: "\f76f";
-}
-
-.fa-volcano {
- --fa: "\f770";
-}
-
-.fa-check-to-slot {
- --fa: "\f772";
-}
-
-.fa-vote-yea {
- --fa: "\f772";
-}
-
-.fa-water {
- --fa: "\f773";
-}
-
-.fa-baby {
- --fa: "\f77c";
-}
-
-.fa-baby-carriage {
- --fa: "\f77d";
-}
-
-.fa-carriage-baby {
- --fa: "\f77d";
-}
-
-.fa-biohazard {
- --fa: "\f780";
-}
-
-.fa-blog {
- --fa: "\f781";
-}
-
-.fa-calendar-day {
- --fa: "\f783";
-}
-
-.fa-calendar-week {
- --fa: "\f784";
-}
-
-.fa-candy-cane {
- --fa: "\f786";
-}
-
-.fa-carrot {
- --fa: "\f787";
-}
-
-.fa-cash-register {
- --fa: "\f788";
-}
-
-.fa-minimize {
- --fa: "\f78c";
-}
-
-.fa-compress-arrows-alt {
- --fa: "\f78c";
-}
-
-.fa-dumpster {
- --fa: "\f793";
-}
-
-.fa-dumpster-fire {
- --fa: "\f794";
-}
-
-.fa-ethernet {
- --fa: "\f796";
-}
-
-.fa-gifts {
- --fa: "\f79c";
-}
-
-.fa-champagne-glasses {
- --fa: "\f79f";
-}
-
-.fa-glass-cheers {
- --fa: "\f79f";
-}
-
-.fa-whiskey-glass {
- --fa: "\f7a0";
-}
-
-.fa-glass-whiskey {
- --fa: "\f7a0";
-}
-
-.fa-earth-europe {
- --fa: "\f7a2";
-}
-
-.fa-globe-europe {
- --fa: "\f7a2";
-}
-
-.fa-grip-lines {
- --fa: "\f7a4";
-}
-
-.fa-grip-lines-vertical {
- --fa: "\f7a5";
-}
-
-.fa-guitar {
- --fa: "\f7a6";
-}
-
-.fa-heart-crack {
- --fa: "\f7a9";
-}
-
-.fa-heart-broken {
- --fa: "\f7a9";
-}
-
-.fa-holly-berry {
- --fa: "\f7aa";
-}
-
-.fa-horse-head {
- --fa: "\f7ab";
-}
-
-.fa-icicles {
- --fa: "\f7ad";
-}
-
-.fa-igloo {
- --fa: "\f7ae";
-}
-
-.fa-mitten {
- --fa: "\f7b5";
-}
-
-.fa-mug-hot {
- --fa: "\f7b6";
-}
-
-.fa-radiation {
- --fa: "\f7b9";
-}
-
-.fa-circle-radiation {
- --fa: "\f7ba";
-}
-
-.fa-radiation-alt {
- --fa: "\f7ba";
-}
-
-.fa-restroom {
- --fa: "\f7bd";
-}
-
-.fa-satellite {
- --fa: "\f7bf";
-}
-
-.fa-satellite-dish {
- --fa: "\f7c0";
-}
-
-.fa-sd-card {
- --fa: "\f7c2";
-}
-
-.fa-sim-card {
- --fa: "\f7c4";
-}
-
-.fa-person-skating {
- --fa: "\f7c5";
-}
-
-.fa-skating {
- --fa: "\f7c5";
-}
-
-.fa-person-skiing {
- --fa: "\f7c9";
-}
-
-.fa-skiing {
- --fa: "\f7c9";
-}
-
-.fa-person-skiing-nordic {
- --fa: "\f7ca";
-}
-
-.fa-skiing-nordic {
- --fa: "\f7ca";
-}
-
-.fa-sleigh {
- --fa: "\f7cc";
-}
-
-.fa-comment-sms {
- --fa: "\f7cd";
-}
-
-.fa-sms {
- --fa: "\f7cd";
-}
-
-.fa-person-snowboarding {
- --fa: "\f7ce";
-}
-
-.fa-snowboarding {
- --fa: "\f7ce";
-}
-
-.fa-snowman {
- --fa: "\f7d0";
-}
-
-.fa-snowplow {
- --fa: "\f7d2";
-}
-
-.fa-tenge-sign {
- --fa: "\f7d7";
-}
-
-.fa-tenge {
- --fa: "\f7d7";
-}
-
-.fa-toilet {
- --fa: "\f7d8";
-}
-
-.fa-screwdriver-wrench {
- --fa: "\f7d9";
-}
-
-.fa-tools {
- --fa: "\f7d9";
-}
-
-.fa-cable-car {
- --fa: "\f7da";
-}
-
-.fa-tram {
- --fa: "\f7da";
-}
-
-.fa-fire-flame-curved {
- --fa: "\f7e4";
-}
-
-.fa-fire-alt {
- --fa: "\f7e4";
-}
-
-.fa-bacon {
- --fa: "\f7e5";
-}
-
-.fa-book-medical {
- --fa: "\f7e6";
-}
-
-.fa-bread-slice {
- --fa: "\f7ec";
-}
-
-.fa-cheese {
- --fa: "\f7ef";
-}
-
-.fa-house-chimney-medical {
- --fa: "\f7f2";
-}
-
-.fa-clinic-medical {
- --fa: "\f7f2";
-}
-
-.fa-clipboard-user {
- --fa: "\f7f3";
-}
-
-.fa-comment-medical {
- --fa: "\f7f5";
-}
-
-.fa-crutch {
- --fa: "\f7f7";
-}
-
-.fa-disease {
- --fa: "\f7fa";
-}
-
-.fa-egg {
- --fa: "\f7fb";
-}
-
-.fa-folder-tree {
- --fa: "\f802";
-}
-
-.fa-burger {
- --fa: "\f805";
-}
-
-.fa-hamburger {
- --fa: "\f805";
-}
-
-.fa-hand-middle-finger {
- --fa: "\f806";
-}
-
-.fa-helmet-safety {
- --fa: "\f807";
-}
-
-.fa-hard-hat {
- --fa: "\f807";
-}
-
-.fa-hat-hard {
- --fa: "\f807";
-}
-
-.fa-hospital-user {
- --fa: "\f80d";
-}
-
-.fa-hotdog {
- --fa: "\f80f";
-}
-
-.fa-ice-cream {
- --fa: "\f810";
-}
-
-.fa-laptop-medical {
- --fa: "\f812";
-}
-
-.fa-pager {
- --fa: "\f815";
-}
-
-.fa-pepper-hot {
- --fa: "\f816";
-}
-
-.fa-pizza-slice {
- --fa: "\f818";
-}
-
-.fa-sack-dollar {
- --fa: "\f81d";
-}
-
-.fa-book-tanakh {
- --fa: "\f827";
-}
-
-.fa-tanakh {
- --fa: "\f827";
-}
-
-.fa-bars-progress {
- --fa: "\f828";
-}
-
-.fa-tasks-alt {
- --fa: "\f828";
-}
-
-.fa-trash-arrow-up {
- --fa: "\f829";
-}
-
-.fa-trash-restore {
- --fa: "\f829";
-}
-
-.fa-trash-can-arrow-up {
- --fa: "\f82a";
-}
-
-.fa-trash-restore-alt {
- --fa: "\f82a";
-}
-
-.fa-user-nurse {
- --fa: "\f82f";
-}
-
-.fa-wave-square {
- --fa: "\f83e";
-}
-
-.fa-person-biking {
- --fa: "\f84a";
-}
-
-.fa-biking {
- --fa: "\f84a";
-}
-
-.fa-border-all {
- --fa: "\f84c";
-}
-
-.fa-border-none {
- --fa: "\f850";
-}
-
-.fa-border-top-left {
- --fa: "\f853";
-}
-
-.fa-border-style {
- --fa: "\f853";
-}
-
-.fa-person-digging {
- --fa: "\f85e";
-}
-
-.fa-digging {
- --fa: "\f85e";
-}
-
-.fa-fan {
- --fa: "\f863";
-}
-
-.fa-icons {
- --fa: "\f86d";
-}
-
-.fa-heart-music-camera-bolt {
- --fa: "\f86d";
-}
-
-.fa-phone-flip {
- --fa: "\f879";
-}
-
-.fa-phone-alt {
- --fa: "\f879";
-}
-
-.fa-square-phone-flip {
- --fa: "\f87b";
-}
-
-.fa-phone-square-alt {
- --fa: "\f87b";
-}
-
-.fa-photo-film {
- --fa: "\f87c";
-}
-
-.fa-photo-video {
- --fa: "\f87c";
-}
-
-.fa-text-slash {
- --fa: "\f87d";
-}
-
-.fa-remove-format {
- --fa: "\f87d";
-}
-
-.fa-arrow-down-z-a {
- --fa: "\f881";
-}
-
-.fa-sort-alpha-desc {
- --fa: "\f881";
-}
-
-.fa-sort-alpha-down-alt {
- --fa: "\f881";
-}
-
-.fa-arrow-up-z-a {
- --fa: "\f882";
-}
-
-.fa-sort-alpha-up-alt {
- --fa: "\f882";
-}
-
-.fa-arrow-down-short-wide {
- --fa: "\f884";
-}
-
-.fa-sort-amount-desc {
- --fa: "\f884";
-}
-
-.fa-sort-amount-down-alt {
- --fa: "\f884";
-}
-
-.fa-arrow-up-short-wide {
- --fa: "\f885";
-}
-
-.fa-sort-amount-up-alt {
- --fa: "\f885";
-}
-
-.fa-arrow-down-9-1 {
- --fa: "\f886";
-}
-
-.fa-sort-numeric-desc {
- --fa: "\f886";
-}
-
-.fa-sort-numeric-down-alt {
- --fa: "\f886";
-}
-
-.fa-arrow-up-9-1 {
- --fa: "\f887";
-}
-
-.fa-sort-numeric-up-alt {
- --fa: "\f887";
-}
-
-.fa-spell-check {
- --fa: "\f891";
-}
-
-.fa-voicemail {
- --fa: "\f897";
-}
-
-.fa-hat-cowboy {
- --fa: "\f8c0";
-}
-
-.fa-hat-cowboy-side {
- --fa: "\f8c1";
-}
-
-.fa-computer-mouse {
- --fa: "\f8cc";
-}
-
-.fa-mouse {
- --fa: "\f8cc";
-}
-
-.fa-radio {
- --fa: "\f8d7";
-}
-
-.fa-record-vinyl {
- --fa: "\f8d9";
-}
-
-.fa-walkie-talkie {
- --fa: "\f8ef";
-}
-
-.fa-caravan {
- --fa: "\f8ff";
-}
diff --git a/src/media/vendor/fa7free/css/regular.css b/src/media/vendor/fa7free/css/regular.css
deleted file mode 100644
index 0715c62..0000000
--- a/src/media/vendor/fa7free/css/regular.css
+++ /dev/null
@@ -1,31 +0,0 @@
-/*!
- * Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
- * Copyright 2025 Fonticons, Inc.
- */
-:root, :host {
- --fa-family-classic: "Font Awesome 7 Free";
- --fa-font-regular: normal 400 1em/1 var(--fa-family-classic);
- /* deprecated: this older custom property will be removed next major release */
- --fa-style-family-classic: var(--fa-family-classic);
-}
-
-@font-face {
- font-family: "Font Awesome 7 Free";
- font-style: normal;
- font-weight: 400;
- font-display: block;
- src: url("../webfonts/fa-regular-400.woff2");
-}
-.far {
- --fa-family: var(--fa-family-classic);
- --fa-style: 400;
-}
-
-.fa-classic {
- --fa-family: var(--fa-family-classic);
-}
-
-.fa-regular {
- --fa-style: 400;
-}
\ No newline at end of file
diff --git a/src/media/vendor/fa7free/css/solid.css b/src/media/vendor/fa7free/css/solid.css
deleted file mode 100644
index a70ad2e..0000000
--- a/src/media/vendor/fa7free/css/solid.css
+++ /dev/null
@@ -1,31 +0,0 @@
-/*!
- * Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com
- * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
- * Copyright 2025 Fonticons, Inc.
- */
-:root, :host {
- --fa-family-classic: "Font Awesome 7 Free";
- --fa-font-solid: normal 900 1em/1 var(--fa-family-classic);
- /* deprecated: this older custom property will be removed next major release */
- --fa-style-family-classic: var(--fa-family-classic);
-}
-
-@font-face {
- font-family: "Font Awesome 7 Free";
- font-style: normal;
- font-weight: 900;
- font-display: block;
- src: url("../webfonts/fa-solid-900.woff2");
-}
-.fas {
- --fa-family: var(--fa-family-classic);
- --fa-style: 900;
-}
-
-.fa-classic {
- --fa-family: var(--fa-family-classic);
-}
-
-.fa-solid {
- --fa-style: 900;
-}
\ No newline at end of file
--
2.52.0
From a95d35114c36f19fb48ecc569f913e49fba26403 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:50:49 +0000
Subject: [PATCH 014/114] chore(version): auto-bump patch 02.01.03 [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 7a0f6f1..f49a761 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 02.01.02
+ VERSION: 02.01.03
BRIEF: Documentation for MokoOnyx template
-->
--
2.52.0
From f62c22b1f0bcc403298327becc54e6f12d25b2e3 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:50:50 +0000
Subject: [PATCH 015/114] chore: update updates.xml (development: 02.01.03-dev)
[skip ci]
---
updates.xml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/updates.xml b/updates.xml
index 46c3e03..7f9b0a3 100644
--- a/updates.xml
+++ b/updates.xml
@@ -11,13 +11,13 @@
mokoonyx
template
site
- 02.01.02
+ 02.01.03
2026-05-09
https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.02-dev.zip
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.03-dev.zip
- 4baa2a53831cd29690fac0a1d2c461e41da1f8f720aa25b63222db2d6bd48b9a
+ 5880dae0d3157a1c439da336ec4a710589c8478783d1e2c958b759c3375a5ebf
development
Moko Consulting
https://mokoconsulting.tech
--
2.52.0
From 3d3f8f14eff9a9ab945879d6cca171791cbdbc45 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Sat, 9 May 2026 16:51:44 -0500
Subject: [PATCH 016/114] chore: vendor FA assets point to .min.css only
(source files removed)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Font Awesome is a vendor library — no unminified source needed.
Asset registry now points directly to .min.css files.
Index.php no longer conditionally switches FA files by dev mode.
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/index.php | 2 +-
src/joomla.asset.json | 32 +-------------------------------
2 files changed, 2 insertions(+), 32 deletions(-)
diff --git a/src/index.php b/src/index.php
index d717743..e3ac65f 100644
--- a/src/index.php
+++ b/src/index.php
@@ -222,7 +222,7 @@ if ($faKitCode !== '') {
} else {
// Load local FA7 Free — all.css via WebAsset
// Resolve the actual filesystem path: media dir (Joomla install) or template dir (SFTP deploy)
- $faCssFile = $params_developmentmode ? 'vendor/fa7free/css/all.css' : 'vendor/fa7free/css/all.min.css';
+ $faCssFile = 'vendor/fa7free/css/all.min.css'; // vendor ships minified only
$faCandidates = [
$templatePath . '/' . $faCssFile, // media/templates/site/mokoonyx/...
'templates/site/' . $this->template . '/media/' . $faCssFile, // templates/site/mokoonyx/media/...
diff --git a/src/joomla.asset.json b/src/joomla.asset.json
index 2bd1946..ff1d793 100644
--- a/src/joomla.asset.json
+++ b/src/joomla.asset.json
@@ -103,59 +103,29 @@
{
"name": "vendor.fa7free.all",
"type": "style",
- "uri": "media/templates/site/mokoonyx/vendor/fa7free/css/all.css"
- },
- {
- "name": "vendor.fa7free.all.min",
- "type": "style",
- "uri": "media/templates/site/mokoonyx/vendor/fa7free/css/all.min.css",
- "attributes": {"media": "all"}
+ "uri": "media/templates/site/mokoonyx/vendor/fa7free/css/all.min.css"
},
{
"name": "vendor.fa7free.brands",
"type": "style",
- "uri": "media/templates/site/mokoonyx/vendor/fa7free/css/brands.css",
- "attributes": {"media": "all"}
- },
- {
- "name": "vendor.fa7free.brands.min",
- "type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/brands.min.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.fontawesome",
"type": "style",
- "uri": "media/templates/site/mokoonyx/vendor/fa7free/css/fontawesome.css",
- "attributes": {"media": "all"}
- },
- {
- "name": "vendor.fa7free.fontawesome.min",
- "type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/fontawesome.min.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.regular",
"type": "style",
- "uri": "media/templates/site/mokoonyx/vendor/fa7free/css/regular.css",
- "attributes": {"media": "all"}
- },
- {
- "name": "vendor.fa7free.regular.min",
- "type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/regular.min.css",
"attributes": {"media": "all"}
},
{
"name": "vendor.fa7free.solid",
"type": "style",
- "uri": "media/templates/site/mokoonyx/vendor/fa7free/css/solid.css",
- "attributes": {"media": "all"}
- },
- {
- "name": "vendor.fa7free.solid.min",
- "type": "style",
"uri": "media/templates/site/mokoonyx/vendor/fa7free/css/solid.min.css",
"attributes": {"media": "all"}
},
--
2.52.0
From 51efb3f24315268ba176d4b6364a3b43f26d47f2 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:53:08 +0000
Subject: [PATCH 017/114] chore(version): auto-bump patch 02.01.04 [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index f49a761..2452e1a 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 02.01.03
+ VERSION: 02.01.04
BRIEF: Documentation for MokoOnyx template
-->
--
2.52.0
From b4cc43181c52b479c83ca1dd1f0652864564502a Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:53:09 +0000
Subject: [PATCH 018/114] chore: update updates.xml (development: 02.01.04-dev)
[skip ci]
---
updates.xml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/updates.xml b/updates.xml
index 7f9b0a3..ff35b34 100644
--- a/updates.xml
+++ b/updates.xml
@@ -11,13 +11,13 @@
mokoonyx
template
site
- 02.01.03
+ 02.01.04
2026-05-09
https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.03-dev.zip
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.04-dev.zip
- 5880dae0d3157a1c439da336ec4a710589c8478783d1e2c958b759c3375a5ebf
+ 4651a05337a8667473f39fba3f0a0c57fd6f615c76779bbc54699ba450b4e0a2
development
Moko Consulting
https://mokoconsulting.tech
--
2.52.0
From ec9895902489a65e46b0b7e5b51c99a096f4e406 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Sat, 9 May 2026 16:54:50 -0500
Subject: [PATCH 019/114] feat: clean media folder on install/update
- Deletes stale .min.css/.min.js in project dirs (MokoMinifyHelper
regenerates them automatically on next page load)
- Removes unminified vendor FA CSS (vendors ship .min only)
- Removes deprecated files from previous versions
- Runs during postflight on both install and update
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/script.php | 83 +++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 82 insertions(+), 1 deletion(-)
diff --git a/src/script.php b/src/script.php
index e73bab4..d78ceb6 100644
--- a/src/script.php
+++ b/src/script.php
@@ -88,11 +88,11 @@ class Tpl_MokoonyxInstallerScript implements InstallerScriptInterface
public function postflight(string $type, InstallerAdapter $parent): bool
{
- // On install or update: migrate from MokoCassiopeia if it exists
if ($type === 'install' || $type === 'update') {
$this->migrateFromCassiopeia();
$this->replaceCassiopeiaReferences();
$this->clearFaviconStamp();
+ $this->cleanMediaFolder();
$this->lockExtension();
}
@@ -402,6 +402,87 @@ class Tpl_MokoonyxInstallerScript implements InstallerScriptInterface
}
}
+ /**
+ * Clean the template media folder on install/update.
+ *
+ * - Removes stale .min files (regenerated automatically by MokoMinifyHelper)
+ * - Removes deprecated/renamed files from previous versions
+ * - Removes unminified vendor files (vendors ship .min only)
+ */
+ private function cleanMediaFolder(): void
+ {
+ $mediaRoot = JPATH_ROOT . '/media/templates/site/' . self::NEW_NAME;
+ if (!is_dir($mediaRoot)) {
+ return;
+ }
+
+ $removed = 0;
+
+ // 1. Delete all .min.css and .min.js in project dirs (MokoMinifyHelper rebuilds them)
+ // Skip vendor/ — those are pre-minified originals
+ $projectDirs = ['css', 'js'];
+ foreach ($projectDirs as $dir) {
+ $path = $mediaRoot . '/' . $dir;
+ if (!is_dir($path)) continue;
+ $this->deleteMinFilesRecursive($path, $removed);
+ }
+
+ // 2. Remove unminified vendor files (vendors ship .min only)
+ $vendorUnminified = [
+ 'vendor/fa7free/css/all.css',
+ 'vendor/fa7free/css/brands.css',
+ 'vendor/fa7free/css/fontawesome.css',
+ 'vendor/fa7free/css/regular.css',
+ 'vendor/fa7free/css/solid.css',
+ ];
+ foreach ($vendorUnminified as $relPath) {
+ $file = $mediaRoot . '/' . $relPath;
+ if (is_file($file)) {
+ @unlink($file);
+ $removed++;
+ }
+ }
+
+ // 3. Remove deprecated files from previous versions
+ $deprecated = [
+ 'css/custom.css', // Renamed to css/user.css
+ 'js/custom.js', // Renamed to js/user.js
+ 'css/template-rtl.css', // No longer used
+ ];
+ foreach ($deprecated as $relPath) {
+ $file = $mediaRoot . '/' . $relPath;
+ if (is_file($file)) {
+ @unlink($file);
+ $removed++;
+ }
+ }
+
+ if ($removed > 0) {
+ $this->logMessage("Cleaned media folder: removed {$removed} stale/deprecated file(s).");
+ }
+ }
+
+ /**
+ * Recursively delete *.min.css and *.min.js in a directory.
+ */
+ private function deleteMinFilesRecursive(string $dir, int &$count): void
+ {
+ $entries = scandir($dir);
+ if (!$entries) return;
+
+ foreach ($entries as $entry) {
+ if ($entry === '.' || $entry === '..') continue;
+ $full = $dir . '/' . $entry;
+
+ if (is_dir($full)) {
+ $this->deleteMinFilesRecursive($full, $count);
+ } elseif (preg_match('/\.min\.(css|js)$/', $entry)) {
+ @unlink($full);
+ $count++;
+ }
+ }
+ }
+
private function logMessage(string $message, string $priority = 'info'): void
{
$priorities = [
--
2.52.0
From 09aafeedce4cd31666f46145e641b300f41a1e43 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:55:17 +0000
Subject: [PATCH 020/114] chore(version): auto-bump patch 02.01.05 [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 2452e1a..b5df590 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 02.01.04
+ VERSION: 02.01.05
BRIEF: Documentation for MokoOnyx template
-->
--
2.52.0
From 42d8ba05854bc0283df45567e0bddb2a23b3e8bb Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:55:17 +0000
Subject: [PATCH 021/114] chore: update updates.xml (development: 02.01.05-dev)
[skip ci]
---
updates.xml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/updates.xml b/updates.xml
index ff35b34..36ea885 100644
--- a/updates.xml
+++ b/updates.xml
@@ -11,13 +11,13 @@
mokoonyx
template
site
- 02.01.04
+ 02.01.05
2026-05-09
https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.04-dev.zip
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.05-dev.zip
- 4651a05337a8667473f39fba3f0a0c57fd6f615c76779bbc54699ba450b4e0a2
+ 574a3f6a94e62f7195cfedb87b72314c6a66efe4220f28e27bea75ac2806e1bc
development
Moko Consulting
https://mokoconsulting.tech
--
2.52.0
From 5ca85f220e217278d2a5fab46dbc5903260e69f9 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:58:35 +0000
Subject: [PATCH 022/114] =?UTF-8?q?chore(version):=20bump=2002.01.05=20?=
=?UTF-8?q?=E2=86=92=2002.01.06=20[skip=20ci]?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 2 +-
src/templateDetails.xml | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index b5df590..5e3924c 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 02.01.05
+ VERSION: 02.01.06
BRIEF: Documentation for MokoOnyx template
-->
diff --git a/src/templateDetails.xml b/src/templateDetails.xml
index 72c16a0..9bb9c02 100644
--- a/src/templateDetails.xml
+++ b/src/templateDetails.xml
@@ -39,9 +39,9 @@
MokoOnyx
- 02.01.00
+ 02.01.06
script.php
- 2026-05-05
+ 2026-05-09
Jonathan Miller || Moko Consulting
hello@mokoconsulting.tech
(C)GNU General Public License Version 3 - 2026 Moko Consulting
--
2.52.0
From b136d7f63c8a0bbb416b8e21cd61da425d08a296 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Sat, 9 May 2026 21:58:37 +0000
Subject: [PATCH 023/114] chore: update development channel 02.01.06 [skip ci]
---
updates.xml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/updates.xml b/updates.xml
index 36ea885..4b013c2 100644
--- a/updates.xml
+++ b/updates.xml
@@ -11,13 +11,13 @@
mokoonyx
template
site
- 02.01.05
+ 02.01.06
2026-05-09
https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.05-dev.zip
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.06-dev.zip
- 574a3f6a94e62f7195cfedb87b72314c6a66efe4220f28e27bea75ac2806e1bc
+ e5bf49a2727098e9761bfe0da3ea0b73c681ad0b5362bd478af476153244ec7d
development
Moko Consulting
https://mokoconsulting.tech
--
2.52.0
From fe3a5efda4b194b774ed9045b2356217718b9e69 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Sat, 9 May 2026 17:28:55 -0500
Subject: [PATCH 024/114] fix: sync updates.xml to all branches after release
Pre-release and auto-release workflows now push updates.xml to both
main and dev branches after any release. Joomla reads updates.xml from
main, so dev-only updates were invisible to the updater.
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.gitea/workflows/auto-release.yml | 32 +++++++++++++++++--------------
.gitea/workflows/pre-release.yml | 24 ++++++++++++++++++++++-
2 files changed, 41 insertions(+), 15 deletions(-)
diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml
index f3ce70a..eff573a 100644
--- a/.gitea/workflows/auto-release.yml
+++ b/.gitea/workflows/auto-release.yml
@@ -663,20 +663,24 @@ jobs:
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
- if [ -n "$FILE_SHA" ]; then
- CONTENT=$(base64 -w0 updates.xml)
- curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
- -H "Content-Type: application/json" \
- "${API}/contents/updates.xml" \
- -d "$(jq -n \
- --arg content "$CONTENT" \
- --arg sha "$FILE_SHA" \
- --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
- --arg branch "main" \
- '{content: $content, sha: $sha, message: $msg, branch: $branch}'
- )" > /dev/null 2>&1 \
- && echo "updates.xml synced to main via API" \
- || echo "WARNING: failed to sync updates.xml to main"
+ CONTENT=$(base64 -w0 updates.xml)
+ for BRANCH in main dev; do
+ BRANCH_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/contents/updates.xml?ref=${BRANCH}" | jq -r '.sha // empty' 2>/dev/null)
+ [ -z "$BRANCH_SHA" ] && echo "SKIP: no updates.xml on ${BRANCH}" && continue
+ curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/contents/updates.xml" \
+ -d "$(jq -n \
+ --arg content "$CONTENT" \
+ --arg sha "$BRANCH_SHA" \
+ --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
+ --arg branch "$BRANCH" \
+ '{content: $content, sha: $sha, message: $msg, branch: $branch}'
+ )" > /dev/null 2>&1 \
+ && echo "updates.xml synced to ${BRANCH}" \
+ || echo "WARNING: failed to sync updates.xml to ${BRANCH}"
+ done
else
echo "WARNING: could not get updates.xml SHA from main"
fi
diff --git a/.gitea/workflows/pre-release.yml b/.gitea/workflows/pre-release.yml
index 4969383..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]"
@@ -287,6 +287,28 @@ jobs:
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
+ - name: "Sync updates.xml to all branches"
+ run: |
+ CURRENT_BRANCH="${{ github.ref_name }}"
+ 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
+
+ echo "Syncing updates.xml → ${BRANCH}"
+ git fetch origin "${BRANCH}" 2>/dev/null || continue
+ git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
+ git checkout "${CURRENT_BRANCH}" -- updates.xml
+ if ! git diff --quiet updates.xml 2>/dev/null; then
+ git add updates.xml
+ git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
+ git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
+ fi
+ git checkout "${CURRENT_BRANCH}" 2>/dev/null
+ done
+
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
--
2.52.0
From 24c5ed687bc714a3075cdbcfabf3cbf1868bbe52 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Sun, 10 May 2026 19:57:25 +0000
Subject: [PATCH 025/114] chore: remove renovate.json [skip ci]
---
renovate.json | 26 --------------------------
1 file changed, 26 deletions(-)
delete mode 100644 renovate.json
diff --git a/renovate.json b/renovate.json
deleted file mode 100644
index 15c6a10..0000000
--- a/renovate.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": [
- "config:recommended",
- "schedule:weekly",
- ":disableDependencyDashboard"
- ],
- "labels": ["dependencies"],
- "automerge": false,
- "platformAutomerge": false,
- "rangeStrategy": "bump",
- "packageRules": [
- {
- "matchUpdateTypes": ["patch"],
- "automerge": true
- },
- {
- "matchManagers": ["composer"],
- "enabled": true
- },
- {
- "matchManagers": ["npm"],
- "enabled": true
- }
- ]
-}
--
2.52.0
From 06177dc3e1c2f7fa1f5647fc372d15ad67c422d6 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Mon, 11 May 2026 11:17:41 -0500
Subject: [PATCH 026/114] feat: replace custom head params with
user.js/user.css, add component.css print view
Remove custom_head_start/custom_head_end template params and associated
code from index.php, error.php, and templateDetails.xml in favor of the
existing user.js and user.css asset loading (via Web Asset Manager).
Add dedicated component.css for the print/component view that replaces
template.css with print-optimized styles using theme variables. Component
view now loads custom light palette when configured and sends a
content_group=print_view identifier to Google Analytics.
Add user.css and user.js to .gitignore (client-repo only files).
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.gitignore | 4 +
src/component.php | 25 +++-
src/error.php | 4 -
src/index.php | 4 -
src/joomla.asset.json | 6 +
src/language/en-GB/tpl_mokoonyx.ini | 6 -
src/language/en-US/tpl_mokoonyx.ini | 6 -
src/media/css/component.css | 225 ++++++++++++++++++++++++++++
src/templateDetails.xml | 6 -
9 files changed, 256 insertions(+), 30 deletions(-)
create mode 100644 src/media/css/component.css
diff --git a/.gitignore b/.gitignore
index 4756361..fde323a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -213,6 +213,10 @@ hypothesis/
src/media/css/theme/*.custom.css
src/media/css/theme/*.custom.min.css
templates/*.custom.css
+
+# User override files (site-specific, not version controlled)
+src/media/css/user.css
+src/media/js/user.js
update.xml
.moko-standards
profile.ps1
diff --git a/src/component.php b/src/component.php
index fb329e4..84fbcd8 100644
--- a/src/component.php
+++ b/src/component.php
@@ -47,15 +47,26 @@ $pageclass = $menu !== null ? $menu->getParams()->get('pageclass_sfx', '') : '';
// Template/Media path
$templatePath = 'media/templates/site/mokoonyx';
-// Core template CSS
-$wa->useStyle('template.base'); // css/template.css
+// Component / print-view CSS (replaces template.css for this view)
+$wa->useStyle('template.component'); // css/component.css
-// Component always uses light theme only (no theme switching)
+// Light theme only (no theme switching in component view)
$wa->useStyle('template.light.standard'); // css/theme/light.standard.css
+// Load custom light palette if selected in template configuration and file exists
+$params_LightColorName = (string) $this->params->get('colorLightName', 'standard');
+if ($params_LightColorName === 'custom' && file_exists(JPATH_ROOT . '/media/templates/site/mokoonyx/css/theme/light.custom.css'))
+{
+ $wa->useStyle('template.light.custom');
+}
+
// Load Osaka font for site title
$wa->useStyle('template.font.osaka');
+// Load user assets last (after all other styles and scripts)
+$wa->useStyle('template.user'); // css/user.css
+$wa->useScript('user.js'); // js/user.js
+
// Brand: logo from params OR siteTitle
// -------------------------------------
$brandHtml = '';
@@ -129,7 +140,7 @@ if ($logoFile !== '') {
});
(function(id){
if (/^G-/.test(id)) {
- gtag('config', id, { 'anonymize_ip': true });
+ gtag('config', id, { 'anonymize_ip': true, 'content_group': 'print_view' });
} else if (/^UA-/.test(id)) {
gtag('config', id, { 'anonymize_ip': true });
console.warn('Using a UA- ID. Universal Analytics is sunset; consider migrating to GA4.');
@@ -137,6 +148,12 @@ if ($logoFile !== '') {
console.warn('Unrecognized Google Analytics ID format:', id);
}
})('');
+ gtag('event', 'page_view', {
+ 'page_title': document.title,
+ 'content_group': 'print_view',
+ 'custom_map': {'dimension1': 'template_view'},
+ 'template_view': 'component'
+ });
diff --git a/src/error.php b/src/error.php
index 34dcc3a..9a797c0 100644
--- a/src/error.php
+++ b/src/error.php
@@ -27,8 +27,6 @@ $params_googletagmanager = $params->get('googletagmanager', false);
$params_googletagmanagerid = $params->get('googletagmanagerid', '');
$params_googleanalytics = $params->get('googleanalytics', false);
$params_googleanalyticsid = $params->get('googleanalyticsid', '');
-$params_custom_head_start = $params->get('custom_head_start', '');
-$params_custom_head_end = $params->get('custom_head_end', '');
$params_developmentmode = $params->get('developmentmode', false);
// ------------------ Params ------------------
@@ -154,7 +152,6 @@ $wa->useScript('user.js'); // js/user.js
-
-
language; ?>" dir="direction; ?>">
-
@@ -321,7 +318,6 @@ $wa->useScript('user.js'); // js/user.js
-
+
+ This file is part of a Moko Consulting project.
+
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+/* ==========================================================================
+ Component / Print-view stylesheet
+ Used by component.php (tmpl=component) for modals, popups, and print.
+ Consumes theme variables from light.standard.css (always light in this view).
+ ========================================================================== */
+
+/* ---- Screen: minimal chrome for modal / popup views ---- */
+
+body {
+ margin: 0;
+ padding: 1.5rem;
+ font-family: var(--body-font-family);
+ font-size: var(--body-font-size, 1rem);
+ font-weight: var(--body-font-weight, 400);
+ line-height: var(--body-line-height, 1.5);
+ color: var(--body-color, #22262a);
+ background: var(--body-bg, #fff);
+}
+
+.navbar-brand {
+ display: flex;
+ align-items: center;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: var(--border-width, 1px) var(--border-style, solid) var(--border-color, #dfe3e7);
+}
+
+.navbar-brand .brand-logo {
+ text-decoration: none;
+ color: var(--color-primary, #112855);
+}
+
+.navbar-brand .logo {
+ max-height: 48px;
+ width: auto;
+}
+
+.navbar-brand .site-title {
+ font-size: var(--navbar-brand-font-size, 1.25rem);
+ font-weight: 600;
+ color: var(--color-primary, #112855);
+}
+
+a {
+ color: var(--link-color, #224faa);
+ text-decoration: var(--link-decoration, underline);
+}
+
+a:hover {
+ color: var(--link-hover-color, #424077);
+}
+
+h1, h2, h3, h4, h5, h6 {
+ color: var(--heading-color, inherit);
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+}
+
+img {
+ max-width: 100%;
+ height: auto;
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+ color: var(--table-color, var(--body-color));
+ background-color: var(--table-bg, transparent);
+}
+
+th, td {
+ padding: 0.5rem;
+ border: var(--border-width, 1px) var(--border-style, solid) var(--table-border-color, var(--border-color, #dfe3e7));
+ text-align: left;
+ vertical-align: top;
+}
+
+th {
+ font-weight: 600;
+ color: var(--emphasis-color, #000);
+}
+
+.container-footer {
+ margin-top: 2rem;
+ padding-top: 0.75rem;
+ border-top: var(--border-width, 1px) var(--border-style, solid) var(--border-color, #dfe3e7);
+ font-size: 0.85rem;
+ color: var(--muted-color, #6d757e);
+}
+
+pre, code {
+ font-family: var(--font-monospace);
+ color: var(--code-color-ink, #e93f8e);
+}
+
+pre {
+ background: var(--tertiary-bg, #f9fafb);
+ padding: 1rem;
+ border-radius: var(--border-radius, .25rem);
+ overflow-x: auto;
+}
+
+blockquote {
+ padding: 0.5rem 1rem;
+ margin: 0 0 1rem;
+ border-left: 4px solid var(--accent-color-primary, #3f8ff0);
+ color: var(--secondary-color, #22262abf);
+}
+
+hr {
+ border: 0;
+ border-top: var(--border-width, 1px) solid var(--hr-color, #dfe3e7);
+ margin: 1rem 0;
+}
+
+/* ---- Print ---- */
+
+@media print {
+ @page {
+ margin: 1.5cm;
+ size: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ color: #000 !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ text-shadow: none !important;
+ }
+
+ body {
+ padding: 0;
+ font-size: 11pt;
+ line-height: 1.4;
+ }
+
+ .navbar-brand {
+ border-bottom: 1px solid #000;
+ margin-bottom: 0.75cm;
+ padding-bottom: 0.25cm;
+ }
+
+ .navbar-brand .logo {
+ max-height: 36px;
+ }
+
+ .container-footer,
+ .alert,
+ .btn,
+ iframe,
+ video,
+ audio,
+ [data-module],
+ #debug,
+ .joomla-script-options {
+ display: none !important;
+ }
+
+ a[href] {
+ text-decoration: underline !important;
+ }
+
+ a[href]::after {
+ content: " (" attr(href) ")";
+ font-size: 0.8em;
+ font-weight: normal;
+ }
+
+ a[href^="#"]::after,
+ a[href^="javascript:"]::after,
+ a[href^="mailto:"]::after {
+ content: "";
+ }
+
+ h1, h2, h3, h4 {
+ page-break-after: avoid;
+ orphans: 3;
+ widows: 3;
+ }
+
+ img, table, figure, pre, blockquote {
+ page-break-inside: avoid;
+ }
+
+ thead {
+ display: table-header-group;
+ }
+
+ tr {
+ page-break-inside: avoid;
+ }
+
+ p, h2, h3 {
+ orphans: 3;
+ widows: 3;
+ }
+
+ table {
+ border-color: #000 !important;
+ }
+
+ th, td {
+ border-color: #000 !important;
+ }
+
+ pre {
+ border: 1px solid #999;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+
+ blockquote {
+ border-left-color: #000 !important;
+ }
+}
diff --git a/src/templateDetails.xml b/src/templateDetails.xml
index 9bb9c02..d4629ba 100644
--- a/src/templateDetails.xml
+++ b/src/templateDetails.xml
@@ -148,12 +148,6 @@
-
-
-
-
-
-
--
2.52.0
From 97e0b92d323bbab1a746c291540db02cd83ad608 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Mon, 11 May 2026 16:32:29 +0000
Subject: [PATCH 027/114] chore(version): auto-bump patch 02.01.07 [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 5e3924c..bc3bbbc 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 02.01.06
+ VERSION: 02.01.07
BRIEF: Documentation for MokoOnyx template
-->
--
2.52.0
From f575224860969cdd77bbea212da651a3454ed3c3 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Mon, 11 May 2026 16:32:30 +0000
Subject: [PATCH 028/114] chore: update updates.xml (development: 02.01.07-dev)
[skip ci]
---
updates.xml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/updates.xml b/updates.xml
index 4b013c2..919de2e 100644
--- a/updates.xml
+++ b/updates.xml
@@ -11,13 +11,13 @@
mokoonyx
template
site
- 02.01.06
- 2026-05-09
+ 02.01.07
+ 2026-05-11
https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.06-dev.zip
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.07-dev.zip
- e5bf49a2727098e9761bfe0da3ea0b73c681ad0b5362bd478af476153244ec7d
+ be654cedebcdd4d33c96927d2702318f3a1ac719efc01fd2fe727840d9cb17be
development
Moko Consulting
https://mokoconsulting.tech
--
2.52.0
From 85b0649ff6f4a32e2a18084249c4a89333586455 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Mon, 11 May 2026 11:43:20 -0500
Subject: [PATCH 029/114] chore: remove migration tab and update template
description
Remove the migration fieldset (MokoCassiopeia migration notes) from
template params and associated language strings. Update the template
description to remove migration callout and "formerly MokoCassiopeia"
reference.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/language/en-GB/tpl_mokoonyx.ini | 7 -------
src/language/en-US/tpl_mokoonyx.ini | 7 -------
src/templateDetails.xml | 8 ++------
3 files changed, 2 insertions(+), 20 deletions(-)
diff --git a/src/language/en-GB/tpl_mokoonyx.ini b/src/language/en-GB/tpl_mokoonyx.ini
index 76312cf..16dd458 100644
--- a/src/language/en-GB/tpl_mokoonyx.ini
+++ b/src/language/en-GB/tpl_mokoonyx.ini
@@ -263,13 +263,6 @@ TPL_MOKOONYX_THEME_PREVIEW_FIELDSET_LABEL="Theme Preview"
TPL_MOKOONYX_THEME_PREVIEW_INTRO="Live preview of all CSS variables, hero variants, block colours, and Bootstrap components rendered with your active theme. Use the Toggle Light / Dark button inside the preview to switch modes. This page is also available as a standalone file at templates/mokoonyx/templates/theme-test.html.
"
TPL_MOKOONYX_THEME_PREVIEW_FRAME=""
-; ===== Migration =====
-TPL_MOKOONYX_MIGRATION_FIELDSET_LABEL="Migration"
-TPL_MOKOONYX_MIGRATION_NOTE_LABEL="MokoCassiopeia Migration"
-TPL_MOKOONYX_MIGRATION_NOTE_DESC="MokoOnyx automatically imports settings from MokoCassiopeia on first page load. If you need to re-run the migration, delete the file templates/mokoonyx/.migrated and visit any frontend page. Check administrator/logs/mokoonyx_migrate.log.php to confirm."
-TPL_MOKOONYX_MIGRATION_RUN_LABEL="Re-run Migration"
-TPL_MOKOONYX_MIGRATION_RUN_DESC="To re-run the migration: Delete templates/mokoonyx/.migrated via FTP or file manager, then visit any page on your site. The migration will run again automatically.To uninstall MokoCassiopeia: Go to Extensions → Manage , find MokoCassiopeia, and click Uninstall."
-
; ===== Misc =====
MOD_BREADCRUMBS_HERE="YOU ARE HERE:"
diff --git a/src/language/en-US/tpl_mokoonyx.ini b/src/language/en-US/tpl_mokoonyx.ini
index 615ad54..cbc4777 100644
--- a/src/language/en-US/tpl_mokoonyx.ini
+++ b/src/language/en-US/tpl_mokoonyx.ini
@@ -263,13 +263,6 @@ TPL_MOKOONYX_THEME_PREVIEW_FIELDSET_LABEL="Theme Preview"
TPL_MOKOONYX_THEME_PREVIEW_INTRO="Live preview of all CSS variables, hero variants, block colors, and Bootstrap components rendered with your active theme. Use the Toggle Light / Dark button inside the preview to switch modes. This page is also available as a standalone file at templates/mokoonyx/templates/theme-test.html.
"
TPL_MOKOONYX_THEME_PREVIEW_FRAME=""
-; ===== Migration =====
-TPL_MOKOONYX_MIGRATION_FIELDSET_LABEL="Migration"
-TPL_MOKOONYX_MIGRATION_NOTE_LABEL="MokoCassiopeia Migration"
-TPL_MOKOONYX_MIGRATION_NOTE_DESC="MokoOnyx automatically imports settings from MokoCassiopeia on first page load. If you need to re-run the migration, delete the file templates/mokoonyx/.migrated and visit any frontend page. Check administrator/logs/mokoonyx_migrate.log.php to confirm."
-TPL_MOKOONYX_MIGRATION_RUN_LABEL="Re-run Migration"
-TPL_MOKOONYX_MIGRATION_RUN_DESC="To re-run the migration: Delete templates/mokoonyx/.migrated via FTP or file manager, then visit any page on your site. The migration will run again automatically.To uninstall MokoCassiopeia: Go to Extensions → Manage , find MokoCassiopeia, and click Uninstall."
-
; ===== Misc =====
MOD_BREADCRUMBS_HERE="YOU ARE HERE:"
diff --git a/src/templateDetails.xml b/src/templateDetails.xml
index d4629ba..47cdd2a 100644
--- a/src/templateDetails.xml
+++ b/src/templateDetails.xml
@@ -45,7 +45,7 @@
Jonathan Miller || Moko Consulting
hello@mokoconsulting.tech
(C)GNU General Public License Version 3 - 2026 Moko Consulting
- Migrating from MokoCassiopeia? MokoOnyx automatically imports your MokoCassiopeia settings on first use. To trigger the migration:
Install MokoOnyx via System → Install → Extensions Go to System → Site Templates and set MokoOnyx as your default template Visit any page on your site — the migration runs automatically on first page load Check administrator/logs/mokoonyx_migrate.log.php to confirm migration completed Uninstall MokoCassiopeia from Extensions → Manage What gets migrated: template style params, custom colour palettes (light.custom.css, dark.custom.css), user.css, user.js, and update server URL. To re-run the migration, delete templates/mokoonyx/.migrated and reload any page.
MokoOnyx MokoOnyx (formerly MokoCassiopeia) is a modern, lightweight enhancement layer built on Joomla's Cassiopeia template. It adds Font Awesome 7, Bootstrap 5 helpers, automatic Table of Contents, advanced Dark Mode theming, and optional Google Tag Manager / GA4 integration — all with minimal core overrides for maximum upgrade compatibility.
Custom Colour Themes Copy templates/mokoonyx/templates/light.custom.css to media/templates/site/mokoonyx/css/theme/light.custom.css (or dark equivalent), customise the CSS variables, then select "Custom" in the Theme tab.
Custom CSS & JavaScript media/templates/site/mokoonyx/css/user.css — custom CSS overrides media/templates/site/mokoonyx/js/user.js — custom JavaScript These files survive template updates.
]]>
+
MokoOnyx MokoOnyx is a modern, lightweight enhancement layer built on Joomla's Cassiopeia template. It adds Font Awesome 7, Bootstrap 5 helpers, automatic Table of Contents, advanced Dark Mode theming, and optional Google Tag Manager / GA4 integration — all with minimal core overrides for maximum upgrade compatibility.
Custom Colour Themes Copy templates/mokoonyx/templates/light.custom.css to media/templates/site/mokoonyx/css/theme/light.custom.css (or dark equivalent), customise the CSS variables, then select "Custom" in the Theme tab.
Custom CSS & JavaScript media/templates/site/mokoonyx/css/user.css — custom CSS overrides media/templates/site/mokoonyx/js/user.js — custom JavaScript These files survive template updates.
]]>
1
component.php
@@ -107,11 +107,7 @@
-
-
-
-
-
+
JNO
--
2.52.0
From a1bbbe95f4e661a99706366212758908a8066629 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Mon, 11 May 2026 11:44:12 -0500
Subject: [PATCH 030/114] docs: update CHANGELOG with unreleased changes
Co-Authored-By: Claude Opus 4.6 (1M context)
---
CHANGELOG.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2de5655..c70a8bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Smart Visitor Detection** — Pushes anonymised visitor properties (login status, user group, page type) to the dataLayer for Google Analytics / Tag Manager. Sets GA4 `user_properties` for persistent session-scoped dimensions. No PII is sent. Default enabled when GTM or GA4 is active.
- **Auto-cascade workflow** — Forward-merges `main` → `dev` after every push; auto-creates a PR on conflict
+- **Component/print-view stylesheet** — Dedicated `component.css` replaces `template.css` in the component view with print-optimised styles using theme variables
+- **Print-view GA4 tracking** — Component view sends `content_group=print_view` to Google Analytics for tracking print/modal usage
+- **Custom light theme in component view** — Component view now loads `light.custom.css` when configured
+
+### Changed
+- **Custom head params replaced with user files** — Removed `custom_head_start` / `custom_head_end` template params in favour of `user.css` and `user.js` (loaded via Web Asset Manager)
+- **User override files added to .gitignore** — `user.css` and `user.js` are client-repo only; not committed to the template repo
+
+### Removed
+- **Migration tab** — Removed MokoCassiopeia migration fieldset and associated language strings from template params
+- **Migration description** — Removed migration callout and "formerly MokoCassiopeia" reference from template description
+- **Custom head fields** — Removed `custom_head_start` / `custom_head_end` fields and `Custom Code` fieldset from template configuration
## [03.10.00] - 2026-04-18 — Bridge Release (MokoOnyx → MokoOnyx)
--
2.52.0
From 5312bb1f6bde0e619f5a558c22ba8da93a07c0fe Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Mon, 11 May 2026 11:53:52 -0500
Subject: [PATCH 031/114] feat: add changelog auto-bump to auto-release, fix
CHANGELOG history
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add changelog promotion step to auto-release workflow: renames
## [Unreleased] to ## [XX.YY.ZZ] --- YYYY-MM-DD and inserts a fresh
Unreleased section on stable release (ported from MokoGalleryCalendar).
Fix CHANGELOG.md: remove orphaned duplicate [Unreleased] section,
fix nonsensical "MokoOnyx → MokoOnyx" bridge release description,
correct migration script description.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.gitea/workflows/auto-release.yml | 9 +++++++++
CHANGELOG.md | 14 +++++---------
2 files changed, 14 insertions(+), 9 deletions(-)
diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml
index eff573a..4bff870 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/CHANGELOG.md b/CHANGELOG.md
index c70a8bd..bbee9d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Component/print-view stylesheet** — Dedicated `component.css` replaces `template.css` in the component view with print-optimised styles using theme variables
- **Print-view GA4 tracking** — Component view sends `content_group=print_view` to Google Analytics for tracking print/modal usage
- **Custom light theme in component view** — Component view now loads `light.custom.css` when configured
+- **Changelog auto-bump in auto-release** — `## [Unreleased]` is automatically promoted to the release version on stable release, with a fresh `## [Unreleased]` section inserted above
### Changed
- **Custom head params replaced with user files** — Removed `custom_head_start` / `custom_head_end` template params in favour of `user.css` and `user.js` (loaded via Web Asset Manager)
@@ -37,10 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Migration description** — Removed migration callout and "formerly MokoCassiopeia" reference from template description
- **Custom head fields** — Removed `custom_head_start` / `custom_head_end` fields and `Custom Code` fieldset from template configuration
-## [03.10.00] - 2026-04-18 — Bridge Release (MokoOnyx → MokoOnyx)
+## [03.10.00] - 2026-04-18
### Important
-- **Template Rename** — MokoOnyx is being renamed to **MokoOnyx**. This bridge release automatically migrates your template settings, menu assignments, and files to the new name. MokoOnyx can be safely uninstalled after this update.
+- **Template Consolidation** — This release finalised the MokoOnyx identity, adding automatic migration from legacy MokoCassiopeia installations. Settings, menu assignments, and files are imported on first page load.
### Added
- **Offline page redesign** — Full-viewport background from Joomla offline_image or header background, glass card overlay, centered logo with glow, login accordion, copyright footer
@@ -50,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Favicon multi-format support** — Now handles PNG, JPEG, GIF, WebP, BMP (not just PNG)
- **Theme variables** — `--theme-fab-bg`, `--theme-fab-color`, `--theme-fab-btn-bg`, `--theme-fab-border`, `--offline-card-bg`
- **Footer CSS variables** — Added to CSS Variables reference tab
-- **Bridge migration script** — `helper/bridge.php` handles automatic MokoOnyx → MokoOnyx migration
+- **Bridge migration script** — `helper/bridge.php` handles automatic MokoCassiopeia → MokoOnyx migration
- **Dedicated release runner** — Release workflows run on isolated `release` label runner
- **Runner fleet** — 3 CI + 1 release runner (12 concurrent jobs)
@@ -79,10 +80,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
-## [Unreleased] - 2026-04-02
+## [03.09.03] - 2026-04-02
### Added
-
- **Favicon configuration** — New "Favicon" tab in template config; upload a PNG and all favicon sizes are auto-generated via PHP GD (ICO, Apple Touch Icon 180px, Android Chrome 192/512px, site.webmanifest)
- **Module overrides** — 11 new `default.php` layout overrides for Joomla core modules: `mod_custom`, `mod_articles_latest`, `mod_articles_popular`, `mod_articles_news`, `mod_articles_category`, `mod_breadcrumbs`, `mod_footer`, `mod_login`, `mod_finder`, `mod_tags_popular`, `mod_tags_similar`, `mod_related_items`
- **Module title support** — All module overrides respect `$module->showtitle`, `header_tag`, `header_class`, and `moduleclass_sfx` parameters
@@ -91,10 +91,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Hero mobile breakpoint** — Photo background hidden on mobile (≤767.98px), hero card becomes full-bleed (100dvh, no border-radius)
- **CSS fallback values** — 1365 `var()` calls in template.css now include inline fallback values
- **Card border-radius** — `.card` now has `.25rem` fallback on `var(--card-border-radius)`
-- **Usage section in README** — Added missing "Usage" section required by MokoStandards
### Changed
-
- **Button backgrounds** — `--btn-bg: transparent` changed to `var(--body-bg)` in dark and light themes
- **Offcanvas close button** — `.offcanvas-header .btn-close` now gets `background-color` from `--offcanvas-bg`
- **Custom template sync** — Both `dark.custom.css` and `light.custom.css` now contain all variables from their standard counterparts (was missing 223 variables)
@@ -102,13 +100,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Legacy CSS cleanup** — Removed vendor prefixes (`-webkit-box`, `-ms-flexbox`) from `.overlay` rules, replaced with modern flexbox
### Removed
-
- **FILE INFORMATION headers** — Stripped DEFGROUP/INGROUP/PATH/VERSION/BRIEF metadata from all PHP, CSS, JS, INI, and HTML files (kept in XML and README per policy)
- **Mobile overrides** — Deleted 26 `mobile.php` layout files and their empty parent directories
- **Joomla-specific gitignore entries** — Removed ~700 lines of Joomla CMS core paths from `.gitignore` (not applicable to a template repository)
### Fixed
-
- **CI: composer install** — Workflow `standards-compliance.yml` now conditionally runs `composer install` only when `composer.json` exists
- **CI: YAML syntax** — Fixed invalid YAML in `auto-update-sha.yml` caused by multiline commit message in run block
--
2.52.0
From 64cecd0be4607c0fdf388542e533cfb9acc67639 Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Mon, 11 May 2026 16:54:59 +0000
Subject: [PATCH 032/114] chore(version): auto-bump patch 02.01.08 [skip ci]
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index bc3bbbc..450da38 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
INGROUP: MokoOnyx.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx
FILE: ./README.md
- VERSION: 02.01.07
+ VERSION: 02.01.08
BRIEF: Documentation for MokoOnyx template
-->
--
2.52.0
From d77a449e36d3c8668e6f7356555a141bc2b8ed1b Mon Sep 17 00:00:00 2001
From: "gitea-actions[bot]"
Date: Mon, 11 May 2026 16:54:59 +0000
Subject: [PATCH 033/114] chore: update updates.xml (development: 02.01.08-dev)
[skip ci]
---
updates.xml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/updates.xml b/updates.xml
index 919de2e..b0b35ab 100644
--- a/updates.xml
+++ b/updates.xml
@@ -11,13 +11,13 @@
mokoonyx
template
site
- 02.01.07
+ 02.01.08
2026-05-11
https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/tag/development
- https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.07-dev.zip
+ https://git.mokoconsulting.tech/MokoConsulting/MokoOnyx/releases/download/development/mokoonyx-02.01.08-dev.zip
- be654cedebcdd4d33c96927d2702318f3a1ac719efc01fd2fe727840d9cb17be
+ 698e87f9cc67884461287f56dc2611c794caf3a1ab6787503045988a2b829a49
development
Moko Consulting
https://mokoconsulting.tech
--
2.52.0
From 796a9b5eede1d7ca0b27627fa6a6f7b4b7b6eb23 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Mon, 11 May 2026 21:20:22 +0000
Subject: [PATCH 034/114] chore: sync auto-release.yml from Template-Joomla
[skip ci]
---
.gitea/workflows/auto-release.yml | 239 +++++++++++++++++++++++-------
1 file changed, 186 insertions(+), 53 deletions(-)
diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml
index 4bff870..279bc5e 100644
--- a/.gitea/workflows/auto-release.yml
+++ b/.gitea/workflows/auto-release.yml
@@ -166,7 +166,7 @@ jobs:
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
- git commit -m "chore(version): bump ${CURRENT} → ${VERSION} (minor) [skip ci]"
+ git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
git push origin HEAD:main 2>&1
}
@@ -320,6 +320,7 @@ jobs:
# -- STEP 5: Write updates.xml (Joomla update server) ---------------------
- name: "Step 5: Write updates.xml"
+ id: updates
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
@@ -343,20 +344,44 @@ jobs:
TARGET_PLATFORM=$(sed -n 's/.*\( \).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
+ # If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini
+ if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then
+ INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
+ [ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
+ [ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME"
+ fi
+
# Fallbacks
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
# Derive element if not in manifest:
- # 1. Try XML filename (e.g. mokowaas.xml → mokowaas)
- # 2. Fall back to repo name (lowercased)
+ # 1. plugin="xxx" attribute (plugins)
+ # 2. module="xxx" attribute (modules)
+ # 3. XML filename (components, packages)
+ # 4. Repo name fallback (templates, anything else)
if [ -z "$EXT_ELEMENT" ]; then
- EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ fi
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ fi
+ if [ -z "$EXT_ELEMENT" ]; then
+ FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
# If filename is generic (templateDetails, manifest), use repo name
- case "$EXT_ELEMENT" in
- templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
+ case "$FNAME" in
+ templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
+ *) EXT_ELEMENT="$FNAME" ;;
esac
fi
+ # Final fallback
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+
+ # Save for Steps 7, 8, 8b
+ echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
+ echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT"
+ echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT"
+ echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT"
# Build client tag: plugins and frontend modules need site
CLIENT_TAG=""
@@ -383,7 +408,18 @@ jobs:
PHP_TAG="${PHP_MINIMUM} "
fi
- DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${EXT_ELEMENT}-${VERSION}.zip"
+ # Build TYPE_PREFIX for download URL
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+
+ DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable"
# -- Build update entry for a given stability tag
@@ -478,21 +514,32 @@ jobs:
MAJOR="${{ steps.version.outputs.major }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
- # Auto-detect extension element for release naming
- MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
- EXT_ELEMENT=""
- if [ -n "$MANIFEST" ]; then
- EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
- [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
- case "$EXT_ELEMENT" in templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;; esac
- else
+ # Reuse metadata from Step 5 (single source of truth)
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ EXT_NAME="${{ steps.updates.outputs.ext_name }}"
+ EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
+ EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
+
+ # Fallbacks if Step 5 was skipped
+ if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
fi
+ [ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
- RELEASE_NAME="${EXT_ELEMENT} ${VERSION} (stable)"
+ # Build release name: "Pretty Name VERSION (type_element-VERSION)"
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
# Delete existing release if present (overwrite, not append)
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
@@ -519,22 +566,6 @@ jobs:
}))")"
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
- # -- STEP 7.5: Minify CSS/JS assets (build-time only, not committed) ------
- - name: "Step 7.5: Minify assets"
- if: >-
- steps.version.outputs.skip != 'true'
- run: |
- npm install --no-save terser clean-css 2>/dev/null || true
- MINIFY=""
- for p in "../moko-platform/build/minify.js" "scripts/minify.js"; do
- [ -f "$p" ] && MINIFY="$p" && break
- done
- if [ -n "$MINIFY" ]; then
- node "$MINIFY" src
- else
- echo "No minify script found — skipping"
- fi
-
# -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
- name: "Step 8: Build Joomla package and update checksum"
if: >-
@@ -558,9 +589,28 @@ jobs:
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true)
[ -z "$MANIFEST" ] && exit 0
- EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
- ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
- TAR_NAME="${EXT_ELEMENT}-${VERSION}.tar.gz"
+ # Reuse element from Step 5, with same fallback chain
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+ # ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
+ EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
# -- Build install packages from src/ ----------------------------
SOURCE_DIR="src"
@@ -672,24 +722,20 @@ jobs:
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
"${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
- CONTENT=$(base64 -w0 updates.xml)
- for BRANCH in main dev; do
- BRANCH_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
- "${API}/contents/updates.xml?ref=${BRANCH}" | jq -r '.sha // empty' 2>/dev/null)
- [ -z "$BRANCH_SHA" ] && echo "SKIP: no updates.xml on ${BRANCH}" && continue
- curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
- -H "Content-Type: application/json" \
- "${API}/contents/updates.xml" \
- -d "$(jq -n \
- --arg content "$CONTENT" \
- --arg sha "$BRANCH_SHA" \
- --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
- --arg branch "$BRANCH" \
- '{content: $content, sha: $sha, message: $msg, branch: $branch}'
- )" > /dev/null 2>&1 \
- && echo "updates.xml synced to ${BRANCH}" \
- || echo "WARNING: failed to sync updates.xml to ${BRANCH}"
- done
+ if [ -n "$FILE_SHA" ]; then
+ CONTENT=$(base64 -w0 updates.xml)
+ curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/contents/updates.xml" \
+ -d "$(jq -n \
+ --arg content "$CONTENT" \
+ --arg sha "$FILE_SHA" \
+ --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
+ --arg branch "main" \
+ '{content: $content, sha: $sha, message: $msg, branch: $branch}'
+ )" > /dev/null 2>&1 \
+ && echo "updates.xml synced to main via API" \
+ || echo "WARNING: failed to sync updates.xml to main"
else
echo "WARNING: could not get updates.xml SHA from main"
fi
@@ -704,6 +750,73 @@ jobs:
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
+ # -- STEP 8b: Update release description with changelog + SHA ----------------
+ - name: "Step 8b: Update release body with changelog and SHA"
+ if: steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
+ EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
+
+ # Build TYPE_PREFIX to match Step 8's ZIP naming
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
+
+ # Get SHA from the built files
+ SHA256_ZIP=""
+ [ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
+ SHA256_TAR=""
+ [ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
+
+ # Extract latest changelog entry (strip the ## header to avoid duplicate)
+ CHANGELOG=""
+ if [ -f "CHANGELOG.md" ]; then
+ CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
+ [ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
+ fi
+
+ # Build release body (single header, no duplicate from changelog)
+ BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
+ if [ -n "$CHANGELOG" ]; then
+ BODY="${BODY}${CHANGELOG}\n\n"
+ fi
+ BODY="${BODY}---\n\n### Checksums\n\n"
+ BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
+ [ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
+ [ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
+
+ # Get release ID and update body
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ python3 -c "
+ import json, urllib.request
+ body = '''$(printf '%b' "$BODY")'''
+ data = json.dumps({'body': body}).encode()
+ req = urllib.request.Request(
+ '${API_BASE}/releases/${RELEASE_ID}',
+ data=data,
+ headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
+ method='PATCH'
+ )
+ urllib.request.urlopen(req)
+ " 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
+ fi
+
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
@@ -793,6 +906,26 @@ jobs:
done
echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY
+ # -- STEP 11: Reset dev branch from main ------------------------------------
+ - name: "Step 11: Delete and recreate dev branch from main"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+
+ # Delete dev branch
+ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
+
+ # Recreate dev from main (now includes version bump + changelog promotion)
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/branches" \
+ -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
+
+ echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
+
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
--
2.52.0
From 4b02fd4b0a253c17cf114a1b371b1048ee6a8e68 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Mon, 11 May 2026 21:26:42 +0000
Subject: [PATCH 035/114] chore: sync pr-branch-check.yml from Template-Joomla
[skip ci]
---
.gitea/workflows/pr-branch-check.yml | 90 ++++++++++++++++++++++++++++
1 file changed, 90 insertions(+)
create mode 100644 .gitea/workflows/pr-branch-check.yml
diff --git a/.gitea/workflows/pr-branch-check.yml b/.gitea/workflows/pr-branch-check.yml
new file mode 100644
index 0000000..b8d9742
--- /dev/null
+++ b/.gitea/workflows/pr-branch-check.yml
@@ -0,0 +1,90 @@
+# Copyright (C) 2026 Moko Consulting
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Enforces branch merge policy:
+# feature/* → dev only
+# fix/* → dev only
+# hotfix/* → dev or main (emergency)
+# dev → main only
+# alpha/* → dev only
+# beta/* → dev only
+# rc/* → main only
+
+name: Branch Policy Check
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited]
+
+jobs:
+ check-target:
+ name: Verify merge target
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check branch policy
+ run: |
+ HEAD="${{ github.head_ref }}"
+ BASE="${{ github.base_ref }}"
+
+ echo "PR: ${HEAD} → ${BASE}"
+
+ ALLOWED=true
+ REASON=""
+
+ case "$HEAD" in
+ feature/*|feat/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Feature branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ fix/*|bugfix/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Fix branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ hotfix/*)
+ if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
+ fi
+ ;;
+ alpha/*|beta/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Pre-release branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ rc/*)
+ if [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Release candidate branches must target 'main', not '${BASE}'"
+ fi
+ ;;
+ dev)
+ if [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Dev branch can only merge into 'main', not '${BASE}'"
+ fi
+ ;;
+ esac
+
+ if [ "$ALLOWED" = false ]; then
+ echo "::error::${REASON}"
+ echo ""
+ echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "${REASON}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
+ echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+
+ echo "Branch policy: OK (${HEAD} → ${BASE})"
+ echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
--
2.52.0
From f0fe2cdc096533e739f7020f0a47fd8c2fb59e32 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:01 +0000
Subject: [PATCH 036/114] chore: move .gitea/.mokostandards to
.mokogitea/.mokostandards [skip ci]
---
.mokogitea/.mokostandards | 54 +++++++++++++++++++++++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 .mokogitea/.mokostandards
diff --git a/.mokogitea/.mokostandards b/.mokogitea/.mokostandards
new file mode 100644
index 0000000..272b5a4
--- /dev/null
+++ b/.mokogitea/.mokostandards
@@ -0,0 +1,54 @@
+
+
+
+
+ MokoOnyx
+ MokoConsulting
+ MokoOnyx - Joomla site template (successor to MokoCassiopeia)
+ GNU General Public License v3
+
+
+ joomla
+ 04.07.00
+ https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
+ 2026-05-02T23:06:05+00:00
+
+
+ CSS
+ php:>=8.1
+ joomla-extension
+ src/templateDetails.xml
+
+
+
+
+
+
+
+
+
--
2.52.0
From 51d2fb5225419156a054a68a992706724909ea8b Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:02 +0000
Subject: [PATCH 037/114] chore: remove .gitea/.mokostandards (moved to
.mokogitea/) [skip ci]
---
.gitea/.mokostandards | 54 -------------------------------------------
1 file changed, 54 deletions(-)
delete mode 100644 .gitea/.mokostandards
diff --git a/.gitea/.mokostandards b/.gitea/.mokostandards
deleted file mode 100644
index 272b5a4..0000000
--- a/.gitea/.mokostandards
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
- MokoOnyx
- MokoConsulting
- MokoOnyx - Joomla site template (successor to MokoCassiopeia)
- GNU General Public License v3
-
-
- joomla
- 04.07.00
- https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
- 2026-05-02T23:06:05+00:00
-
-
- CSS
- php:>=8.1
- joomla-extension
- src/templateDetails.xml
-
-
-
-
-
-
-
-
-
--
2.52.0
From c0726a296966f3ac9aa3b6402a7710c0750766e0 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:02 +0000
Subject: [PATCH 038/114] chore: move .gitea/workflows/auto-release.yml to
.mokogitea/auto-release.yml [skip ci]
---
.mokogitea/auto-release.yml | 949 ++++++++++++++++++++++++++++++++++++
1 file changed, 949 insertions(+)
create mode 100644 .mokogitea/auto-release.yml
diff --git a/.mokogitea/auto-release.yml b/.mokogitea/auto-release.yml
new file mode 100644
index 0000000..279bc5e
--- /dev/null
+++ b/.mokogitea/auto-release.yml
@@ -0,0 +1,949 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Release
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/joomla/auto-release.yml.template
+# VERSION: 04.06.00
+# BRIEF: Joomla build & release — ZIP package, updates.xml, SHA-256 checksum
+#
+# +========================================================================+
+# | BUILD & RELEASE PIPELINE (JOOMLA) |
+# +========================================================================+
+# | |
+# | Triggers on push to main (skips bot commits + [skip ci]): |
+# | |
+# | Every push: |
+# | 1. Read version from README.md |
+# | 3. Set platform version (Joomla ) |
+# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files |
+# | 5. Write updates.xml (Joomla update server XML) |
+# | 6. Create git tag vXX.YY.ZZ |
+# | 7a. Patch: update existing Gitea Release for this minor |
+# | 8. Build ZIP, upload asset, write SHA-256 to updates.xml |
+# | |
+# | Every version change: archives main -> version/XX.YY branch |
+# | All patches release (including 00). Patch 00/01 = full pipeline. |
+# | First release only (patch == 01): |
+# | 7b. Create new Gitea Release |
+# | |
+# | GitHub mirror: stable/rc releases only (continue-on-error) |
+# | |
+# +========================================================================+
+
+name: Build & Release
+
+on:
+ pull_request:
+ types: [closed]
+ branches:
+ - main
+ paths:
+ - 'src/**'
+ - 'htdocs/**'
+ 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:
+ release:
+ name: Build & Release Pipeline
+ runs-on: release
+ if: >-
+ github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ token: ${{ secrets.GA_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup MokoStandards tools
+ env:
+ MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
+ MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
+ run: |
+ # Ensure PHP + Composer are available
+ if ! command -v composer &> /dev/null; then
+ sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
+ fi
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
+ /tmp/mokostandards-api
+ cd /tmp/mokostandards-api
+ composer install --no-dev --no-interaction --quiet
+
+ # -- STEP 1: Read version -----------------------------------------------
+ - name: "Step 1: Read version from README.md"
+ id: version
+ run: |
+ VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null)
+ if [ -z "$VERSION" ]; then
+ echo "No VERSION in README.md — skipping release"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ # Derive major.minor for branch naming (patches update existing branch)
+ MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
+ PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
+
+ MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
+ MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
+
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
+ echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
+ echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
+ echo "release_tag=stable" >> "$GITHUB_OUTPUT"
+ echo "stability=stable" >> "$GITHUB_OUTPUT"
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ if [ "$PATCH" = "00" ] || [ "$PATCH" = "01" ]; then
+ echo "is_minor=true" >> "$GITHUB_OUTPUT"
+ echo "Version: $VERSION (first release for this minor — full pipeline)"
+ else
+ echo "is_minor=false" >> "$GITHUB_OUTPUT"
+ echo "Version: $VERSION (patch — platform version + badges only)"
+ fi
+
+ # -- STEP 1b: Bump minor version (stable = minor bump, reset patch) ------
+ - name: "Step 1b: Bump minor version for stable release"
+ if: steps.version.outputs.skip != 'true'
+ id: bump
+ run: |
+ CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
+ [ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
+
+ MAJOR=$((10#$(echo "$CURRENT" | cut -d. -f1)))
+ MINOR=$((10#$(echo "$CURRENT" | cut -d. -f2)))
+
+ # Minor bump, reset patch. Rollover if minor > 99
+ MINOR=$((MINOR + 1))
+ if [ $MINOR -gt 99 ]; then
+ MINOR=0
+ MAJOR=$((MAJOR + 1))
+ fi
+
+ VERSION=$(printf "%02d.%02d.00" $MAJOR $MINOR)
+ TODAY=$(date +%Y-%m-%d)
+
+ echo "Stable bump: ${CURRENT} → ${VERSION} (minor)"
+
+ # Update README.md
+ sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
+
+ # Update manifest
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
+ if [ -n "$MANIFEST" ]; then
+ MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
+ [ -n "$MANIFEST_VER" ] && sed -i "s|${MANIFEST_VER} |${VERSION} |" "$MANIFEST"
+ 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]"
+ git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git diff --cached --quiet || {
+ git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
+ git push origin HEAD:main 2>&1
+ }
+
+ # Override version output for rest of pipeline
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "major=$(printf "%02d" $MAJOR)" >> "$GITHUB_OUTPUT"
+
+ - name: Check if already released
+ if: steps.version.outputs.skip != 'true'
+ id: check
+ run: |
+ TAG="${{ steps.version.outputs.release_tag }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+
+ TAG_EXISTS=false
+ BRANCH_EXISTS=false
+
+ git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
+ git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
+
+ echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
+ echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
+
+ # Tag and branch may persist across patch releases — never skip
+ echo "already_released=false" >> "$GITHUB_OUTPUT"
+
+ # -- SANITY CHECKS -------------------------------------------------------
+ - name: "Sanity: Pre-release validation"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ ERRORS=0
+
+ echo "## Pre-Release Sanity Checks (Joomla)" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # -- Version drift check (must pass before release) --------
+ README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
+ if [ "$README_VER" != "$VERSION" ]; then
+ echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Check CHANGELOG version matches
+ CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
+ if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
+ echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi
+
+ # Check composer.json version if present
+ if [ -f "composer.json" ]; then
+ COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
+ if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
+ echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi
+ fi
+
+ # Common checks
+ if [ ! -f "LICENSE" ]; then
+ echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
+ echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- Joomla: manifest version drift --------
+ MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1)
+ if [ -n "$MANIFEST" ]; then
+ XML_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
+ echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ fi
+
+ # -- Joomla: XML manifest existence --------
+ if [ -z "$MANIFEST" ]; then
+ echo "- No Joomla XML manifest found" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
+
+ # -- Joomla: extension type check --------
+ TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
+ echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ echo "" >> $GITHUB_STEP_SUMMARY
+ if [ "$ERRORS" -gt 0 ]; then
+ echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 2: Create or update version/XX.YY archive branch ---------------
+ # Always runs — every version change on main archives to version/XX.YY
+ - name: "Step 2: Version archive branch"
+ if: steps.check.outputs.already_released != 'true'
+ run: |
+ BRANCH="${{ steps.version.outputs.branch }}"
+ IS_MINOR="${{ steps.version.outputs.is_minor }}"
+ PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
+
+ # Check if branch exists
+ if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
+ git push origin HEAD:"$BRANCH" --force
+ echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
+ else
+ git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
+ git push origin "$BRANCH" --force
+ echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 3: Set platform version ----------------------------------------
+ - name: "Step 3: Set platform version"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ php /tmp/mokostandards-api/cli/version_set_platform.php \
+ --path . --version "$VERSION" --branch main
+
+ # -- STEP 4: Update version badges ----------------------------------------
+ - name: "Step 4: Update version badges"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do
+ if grep -q '\[VERSION:' "$f" 2>/dev/null; then
+ sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f"
+ fi
+ done
+
+ # -- STEP 5: Write updates.xml (Joomla update server) ---------------------
+ - name: "Step 5: Write updates.xml"
+ id: updates
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ REPO="${{ github.repository }}"
+
+ # -- Parse extension metadata from XML manifest ----------------
+ MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1)
+ if [ -z "$MANIFEST" ]; then
+ echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY
+ exit 0
+ fi
+
+ # Extract fields using sed (portable — no grep -P)
+ EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
+ EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\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)
+
+ # If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini
+ if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then
+ INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
+ [ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
+ [ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME"
+ fi
+
+ # Fallbacks
+ [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
+ [ -z "$EXT_TYPE" ] && EXT_TYPE="component"
+
+ # Derive element if not in manifest:
+ # 1. plugin="xxx" attribute (plugins)
+ # 2. module="xxx" attribute (modules)
+ # 3. XML filename (components, packages)
+ # 4. Repo name fallback (templates, anything else)
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ fi
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ fi
+ if [ -z "$EXT_ELEMENT" ]; then
+ FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ # If filename is generic (templateDetails, manifest), use repo name
+ case "$FNAME" in
+ templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
+ *) EXT_ELEMENT="$FNAME" ;;
+ esac
+ fi
+ # Final fallback
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+
+ # Save for Steps 7, 8, 8b
+ echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
+ echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT"
+ echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT"
+ echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT"
+
+ # Build client tag: plugins and frontend modules need site
+ CLIENT_TAG=""
+ if [ -n "$EXT_CLIENT" ]; then
+ CLIENT_TAG="${EXT_CLIENT} "
+ elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
+ CLIENT_TAG="site "
+ fi
+
+ # Build folder tag for plugins (required for Joomla to match the update)
+ FOLDER_TAG=""
+ if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
+ FOLDER_TAG="${EXT_FOLDER} "
+ fi
+
+ # Build targetplatform (fallback to Joomla 5 if not in manifest)
+ if [ -z "$TARGET_PLATFORM" ]; then
+ TARGET_PLATFORM=$(printf '' "/")
+ fi
+
+ # Build php_minimum tag
+ PHP_TAG=""
+ if [ -n "$PHP_MINIMUM" ]; then
+ PHP_TAG="${PHP_MINIMUM} "
+ fi
+
+ # Build TYPE_PREFIX for download URL
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+
+ DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable"
+
+ # -- Build update entry for a given stability tag
+ build_entry() {
+ local TAG_NAME="$1"
+ printf '%s\n' ' '
+ printf '%s\n' " ${EXT_NAME} "
+ printf '%s\n' " ${EXT_NAME} update "
+ printf '%s\n' " ${EXT_ELEMENT} "
+ printf '%s\n' " ${EXT_TYPE} "
+ printf '%s\n' " ${VERSION} "
+ [ -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' " ${TARGET_PLATFORM}"
+ [ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}"
+ printf '%s\n' ' Moko Consulting '
+ printf '%s\n' ' https://mokoconsulting.tech '
+ printf '%s\n' ' '
+ }
+
+ # -- Write updates.xml with cascading channels
+ # Stable release updates ALL channels (development, alpha, beta, rc, stable)
+ {
+ printf '%s\n' ""
+ printf '%s\n' ""
+ printf '%s\n' ""
+ printf '%s\n' ''
+ build_entry "development"
+ build_entry "alpha"
+ build_entry "beta"
+ build_entry "rc"
+ build_entry "stable"
+ printf '%s\n' ' '
+ } > updates.xml
+
+ echo "updates.xml: ${VERSION} (all channels updated to stable)" >> $GITHUB_STEP_SUMMARY
+
+ # -- Commit all changes ---------------------------------------------------
+ - name: Commit release changes
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ if git diff --quiet && git diff --cached --quiet; then
+ echo "No changes to commit"
+ exit 0
+ fi
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "gitea-actions[bot]"
+ # Set push URL with token for branch-protected repos
+ git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git commit -m "chore(release): build ${VERSION} [skip ci]" \
+ --author="gitea-actions[bot] "
+ git push -u origin HEAD
+
+ # -- STEP 6: Create tag ---------------------------------------------------
+ - name: "Step 6: Create git tag"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.tag_exists != 'true' &&
+ steps.version.outputs.is_minor == 'true'
+ run: |
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ # Only create the major release tag if it doesn't exist yet
+ if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
+ git tag "$RELEASE_TAG"
+ git push origin "$RELEASE_TAG"
+ echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
+ fi
+ echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 7: Create or update Gitea Release --------------------------------
+ - name: "Step 7: Gitea Release"
+ if: >-
+ steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+ MAJOR="${{ steps.version.outputs.major }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ # Reuse metadata from Step 5 (single source of truth)
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ EXT_NAME="${{ steps.updates.outputs.ext_name }}"
+ EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
+ EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
+
+ # Fallbacks if Step 5 was skipped
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+ [ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
+
+ NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
+ [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
+
+ # Build release name: "Pretty Name VERSION (type_element-VERSION)"
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
+
+ # Delete existing release if present (overwrite, not append)
+ EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
+ EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$EXISTING_ID" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
+ echo "Deleted previous stable release (id: ${EXISTING_ID})"
+ fi
+
+ # Create fresh release
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/releases" \
+ -d "$(python3 -c "import json; print(json.dumps({
+ 'tag_name': '${RELEASE_TAG}',
+ 'name': '${RELEASE_NAME}',
+ 'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
+ 'target_commitish': '${BRANCH}'
+ }))")"
+ echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
+ - name: "Step 8: Build Joomla package and update checksum"
+ if: >-
+ steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ REPO="${{ github.repository }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ # All ZIPs upload to the major release tag (vXX)
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
+ 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
+ echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
+ exit 0
+ fi
+
+ # Find extension element name from manifest
+ MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true)
+ [ -z "$MANIFEST" ] && exit 0
+
+ # Reuse element from Step 5, with same fallback chain
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+ # ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
+ EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
+
+ # -- Build install packages from src/ ----------------------------
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; }
+
+ EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
+
+ # ZIP package
+ cd "$SOURCE_DIR"
+ zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES
+ cd ..
+
+ # tar.gz package
+ tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
+ --exclude='.ftpignore' --exclude='sftp-config*' \
+ --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
+
+ ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
+ TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
+
+ # -- Calculate SHA-256 for both ----------------------------------
+ SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
+ SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
+
+ # -- Delete existing assets with same name before uploading ------
+ ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
+ for ASSET_NAME in "$ZIP_NAME" "$TAR_NAME"; do
+ ASSET_ID=$(echo "$ASSETS" | python3 -c "
+ import sys,json
+ assets = json.load(sys.stdin)
+ for a in assets:
+ if a['name'] == '${ASSET_NAME}':
+ print(a['id']); break
+ " 2>/dev/null || true)
+ if [ -n "$ASSET_ID" ]; then
+ curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
+ fi
+ done
+
+ # -- Upload both to release tag ----------------------------------
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"/tmp/${ZIP_NAME}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null 2>&1 || true
+
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"/tmp/${TAR_NAME}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
+
+ # -- Update updates.xml with both download formats ---------------
+ if [ -f "updates.xml" ]; then
+ ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
+ TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
+
+ # Use Python to update only the stable entry's downloads + sha256
+ export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
+ python3 << 'PYEOF'
+ import re, os
+
+ with open("updates.xml") as f:
+ content = f.read()
+
+ zip_url = os.environ["PY_ZIP_URL"]
+ tar_url = os.environ["PY_TAR_URL"]
+ sha = os.environ["PY_SHA"]
+
+ # Find the stable update block and replace its downloads + sha256
+ def replace_stable(m):
+ block = m.group(0)
+ # Replace downloads block
+ new_downloads = (
+ " \n"
+ f" {zip_url} \n"
+ " "
+ )
+ block = re.sub(r' .*? ', new_downloads, block, flags=re.DOTALL)
+ # Add or replace sha256
+ if '' in block:
+ block = re.sub(r' .*? ', f' {sha} ', block)
+ else:
+ block = block.replace('', f'\n {sha} ')
+ return block
+
+ content = re.sub(
+ r' .*?stable .*? ',
+ replace_stable,
+ content,
+ flags=re.DOTALL
+ )
+
+ with open("updates.xml", "w") as f:
+ f.write(content)
+ PYEOF
+
+ CURRENT_BRANCH="${{ github.ref_name }}"
+ git add updates.xml
+ git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
+ --author="gitea-actions[bot] " || true
+ git push || true
+
+ # Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
+ GA_TOKEN="${{ secrets.GA_TOKEN }}"
+ API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
+
+ FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
+
+ if [ -n "$FILE_SHA" ]; then
+ CONTENT=$(base64 -w0 updates.xml)
+ curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/contents/updates.xml" \
+ -d "$(jq -n \
+ --arg content "$CONTENT" \
+ --arg sha "$FILE_SHA" \
+ --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
+ --arg branch "main" \
+ '{content: $content, sha: $sha, message: $msg, branch: $branch}'
+ )" > /dev/null 2>&1 \
+ && echo "updates.xml synced to main via API" \
+ || echo "WARNING: failed to sync updates.xml to main"
+ else
+ echo "WARNING: could not get updates.xml SHA from main"
+ fi
+ fi
+
+ echo "### Joomla Packages" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
+ echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
+ echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
+ echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 8b: Update release description with changelog + SHA ----------------
+ - name: "Step 8b: Update release body with changelog and SHA"
+ if: steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
+ EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
+
+ # Build TYPE_PREFIX to match Step 8's ZIP naming
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
+
+ # Get SHA from the built files
+ SHA256_ZIP=""
+ [ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
+ SHA256_TAR=""
+ [ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
+
+ # Extract latest changelog entry (strip the ## header to avoid duplicate)
+ CHANGELOG=""
+ if [ -f "CHANGELOG.md" ]; then
+ CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
+ [ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
+ fi
+
+ # Build release body (single header, no duplicate from changelog)
+ BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
+ if [ -n "$CHANGELOG" ]; then
+ BODY="${BODY}${CHANGELOG}\n\n"
+ fi
+ BODY="${BODY}---\n\n### Checksums\n\n"
+ BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
+ [ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
+ [ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
+
+ # Get release ID and update body
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ python3 -c "
+ import json, urllib.request
+ body = '''$(printf '%b' "$BODY")'''
+ data = json.dumps({'body': body}).encode()
+ req = urllib.request.Request(
+ '${API_BASE}/releases/${RELEASE_ID}',
+ data=data,
+ headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
+ method='PATCH'
+ )
+ urllib.request.urlopen(req)
+ " 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
+ - name: "Step 9: Mirror release to GitHub"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.version.outputs.stability == 'stable' &&
+ secrets.GH_TOKEN != ''
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ MAJOR="${{ steps.version.outputs.major }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+ GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
+
+ NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
+ [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
+ echo "$NOTES" > /tmp/release_notes.md
+
+ EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
+
+ if [ -z "$EXISTING" ]; then
+ gh release create "$RELEASE_TAG" \
+ --repo "$GH_REPO" \
+ --title "v${MAJOR} (latest: ${VERSION})" \
+ --notes-file /tmp/release_notes.md \
+ --target "$BRANCH" || true
+ else
+ gh release edit "$RELEASE_TAG" \
+ --repo "$GH_REPO" \
+ --title "v${MAJOR} (latest: ${VERSION})" || true
+ fi
+
+ # Upload assets to GitHub mirror
+ for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
+ if [ -f "$PKG" ]; then
+ _RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
+ [ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
+ fi
+ done
+ echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 10: Sync main branch to GitHub mirror ----------------------------
+ - name: "Step 10: Push main to GitHub mirror"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ secrets.GH_TOKEN != ''
+ continue-on-error: true
+ run: |
+ GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
+ GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
+ GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
+ git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
+ git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
+ git fetch origin main --depth=1
+ git push github origin/main:refs/heads/main --force 2>/dev/null \
+ && echo "main branch pushed to GitHub mirror" \
+ || echo "WARNING: GitHub mirror push failed"
+
+ # -- Clean up lesser pre-releases (cascade) ---------------------------------
+ # stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev
+ - name: "Delete lesser pre-release channels"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+
+ # Stable deletes all pre-release channels
+ TAGS_TO_DELETE="development alpha beta release-candidate"
+
+ DELETED=0
+ for TAG in $TAGS_TO_DELETE; do
+ RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/tags/${TAG}" 2>/dev/null || true
+ echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
+ DELETED=$((DELETED + 1))
+ fi
+ done
+ echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 11: Reset dev branch from main ------------------------------------
+ - name: "Step 11: Delete and recreate dev branch from main"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+
+ # Delete dev branch
+ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
+
+ # Recreate dev from main (now includes version bump + changelog promotion)
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/branches" \
+ -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
+
+ echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
+
+ # -- Summary --------------------------------------------------------------
+ - name: Pipeline Summary
+ if: always()
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
+ echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
+ echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
+ elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
+ echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "## Build & Release Complete (Joomla)" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
+ echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
+ fi
--
2.52.0
From edaafce92373953b0db8c8bc84e2cac900bffeff Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:02 +0000
Subject: [PATCH 039/114] chore: remove .gitea/workflows/auto-release.yml
(moved to .mokogitea/) [skip ci]
---
.gitea/workflows/auto-release.yml | 949 ------------------------------
1 file changed, 949 deletions(-)
delete mode 100644 .gitea/workflows/auto-release.yml
diff --git a/.gitea/workflows/auto-release.yml b/.gitea/workflows/auto-release.yml
deleted file mode 100644
index 279bc5e..0000000
--- a/.gitea/workflows/auto-release.yml
+++ /dev/null
@@ -1,949 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
-# INGROUP: MokoStandards.Release
-# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
-# PATH: /templates/workflows/joomla/auto-release.yml.template
-# VERSION: 04.06.00
-# BRIEF: Joomla build & release — ZIP package, updates.xml, SHA-256 checksum
-#
-# +========================================================================+
-# | BUILD & RELEASE PIPELINE (JOOMLA) |
-# +========================================================================+
-# | |
-# | Triggers on push to main (skips bot commits + [skip ci]): |
-# | |
-# | Every push: |
-# | 1. Read version from README.md |
-# | 3. Set platform version (Joomla ) |
-# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files |
-# | 5. Write updates.xml (Joomla update server XML) |
-# | 6. Create git tag vXX.YY.ZZ |
-# | 7a. Patch: update existing Gitea Release for this minor |
-# | 8. Build ZIP, upload asset, write SHA-256 to updates.xml |
-# | |
-# | Every version change: archives main -> version/XX.YY branch |
-# | All patches release (including 00). Patch 00/01 = full pipeline. |
-# | First release only (patch == 01): |
-# | 7b. Create new Gitea Release |
-# | |
-# | GitHub mirror: stable/rc releases only (continue-on-error) |
-# | |
-# +========================================================================+
-
-name: Build & Release
-
-on:
- pull_request:
- types: [closed]
- branches:
- - main
- paths:
- - 'src/**'
- - 'htdocs/**'
- 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:
- release:
- name: Build & Release Pipeline
- runs-on: release
- if: >-
- github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- with:
- token: ${{ secrets.GA_TOKEN }}
- fetch-depth: 0
-
- - name: Setup MokoStandards tools
- env:
- MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
- MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
- COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
- run: |
- # Ensure PHP + Composer are available
- if ! command -v composer &> /dev/null; then
- sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
- fi
- git clone --depth 1 --branch main --quiet \
- "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
- /tmp/mokostandards-api
- cd /tmp/mokostandards-api
- composer install --no-dev --no-interaction --quiet
-
- # -- STEP 1: Read version -----------------------------------------------
- - name: "Step 1: Read version from README.md"
- id: version
- run: |
- VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null)
- if [ -z "$VERSION" ]; then
- echo "No VERSION in README.md — skipping release"
- echo "skip=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- # Derive major.minor for branch naming (patches update existing branch)
- MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
- PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
-
- MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
- MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
-
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
- echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
- echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
- echo "release_tag=stable" >> "$GITHUB_OUTPUT"
- echo "stability=stable" >> "$GITHUB_OUTPUT"
- echo "skip=false" >> "$GITHUB_OUTPUT"
- if [ "$PATCH" = "00" ] || [ "$PATCH" = "01" ]; then
- echo "is_minor=true" >> "$GITHUB_OUTPUT"
- echo "Version: $VERSION (first release for this minor — full pipeline)"
- else
- echo "is_minor=false" >> "$GITHUB_OUTPUT"
- echo "Version: $VERSION (patch — platform version + badges only)"
- fi
-
- # -- STEP 1b: Bump minor version (stable = minor bump, reset patch) ------
- - name: "Step 1b: Bump minor version for stable release"
- if: steps.version.outputs.skip != 'true'
- id: bump
- run: |
- CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
- [ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
-
- MAJOR=$((10#$(echo "$CURRENT" | cut -d. -f1)))
- MINOR=$((10#$(echo "$CURRENT" | cut -d. -f2)))
-
- # Minor bump, reset patch. Rollover if minor > 99
- MINOR=$((MINOR + 1))
- if [ $MINOR -gt 99 ]; then
- MINOR=0
- MAJOR=$((MAJOR + 1))
- fi
-
- VERSION=$(printf "%02d.%02d.00" $MAJOR $MINOR)
- TODAY=$(date +%Y-%m-%d)
-
- echo "Stable bump: ${CURRENT} → ${VERSION} (minor)"
-
- # Update README.md
- sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
-
- # Update manifest
- MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
- if [ -n "$MANIFEST" ]; then
- MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
- [ -n "$MANIFEST_VER" ] && sed -i "s|${MANIFEST_VER} |${VERSION} |" "$MANIFEST"
- 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]"
- git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- git add -A
- git diff --cached --quiet || {
- git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
- git push origin HEAD:main 2>&1
- }
-
- # Override version output for rest of pipeline
- echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- echo "major=$(printf "%02d" $MAJOR)" >> "$GITHUB_OUTPUT"
-
- - name: Check if already released
- if: steps.version.outputs.skip != 'true'
- id: check
- run: |
- TAG="${{ steps.version.outputs.release_tag }}"
- BRANCH="${{ steps.version.outputs.branch }}"
-
- TAG_EXISTS=false
- BRANCH_EXISTS=false
-
- git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
- git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
-
- echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
- echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
-
- # Tag and branch may persist across patch releases — never skip
- echo "already_released=false" >> "$GITHUB_OUTPUT"
-
- # -- SANITY CHECKS -------------------------------------------------------
- - name: "Sanity: Pre-release validation"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- ERRORS=0
-
- echo "## Pre-Release Sanity Checks (Joomla)" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
-
- # -- Version drift check (must pass before release) --------
- README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
- if [ "$README_VER" != "$VERSION" ]; then
- echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS+1))
- else
- echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
- fi
-
- # Check CHANGELOG version matches
- CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
- if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
- echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS+1))
- fi
-
- # Check composer.json version if present
- if [ -f "composer.json" ]; then
- COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
- if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
- echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS+1))
- fi
- fi
-
- # Common checks
- if [ ! -f "LICENSE" ]; then
- echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS+1))
- else
- echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
- fi
-
- if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
- echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
- else
- echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
- fi
-
- # -- Joomla: manifest version drift --------
- MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1)
- if [ -n "$MANIFEST" ]; then
- XML_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
- if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
- echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS+1))
- else
- echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
- fi
- fi
-
- # -- Joomla: XML manifest existence --------
- if [ -z "$MANIFEST" ]; then
- echo "- No Joomla XML manifest found" >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS+1))
- else
- echo "- Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
-
- # -- Joomla: extension type check --------
- TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
- echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
- fi
-
- echo "" >> $GITHUB_STEP_SUMMARY
- if [ "$ERRORS" -gt 0 ]; then
- echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
- else
- echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
- fi
-
- # -- STEP 2: Create or update version/XX.YY archive branch ---------------
- # Always runs — every version change on main archives to version/XX.YY
- - name: "Step 2: Version archive branch"
- if: steps.check.outputs.already_released != 'true'
- run: |
- BRANCH="${{ steps.version.outputs.branch }}"
- IS_MINOR="${{ steps.version.outputs.is_minor }}"
- PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
-
- # Check if branch exists
- if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
- git push origin HEAD:"$BRANCH" --force
- echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
- else
- git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
- git push origin "$BRANCH" --force
- echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
- fi
-
- # -- STEP 3: Set platform version ----------------------------------------
- - name: "Step 3: Set platform version"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- php /tmp/mokostandards-api/cli/version_set_platform.php \
- --path . --version "$VERSION" --branch main
-
- # -- STEP 4: Update version badges ----------------------------------------
- - name: "Step 4: Update version badges"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do
- if grep -q '\[VERSION:' "$f" 2>/dev/null; then
- sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f"
- fi
- done
-
- # -- STEP 5: Write updates.xml (Joomla update server) ---------------------
- - name: "Step 5: Write updates.xml"
- id: updates
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- REPO="${{ github.repository }}"
-
- # -- Parse extension metadata from XML manifest ----------------
- MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1)
- if [ -z "$MANIFEST" ]; then
- echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY
- exit 0
- fi
-
- # Extract fields using sed (portable — no grep -P)
- EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
- EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
- EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\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)
-
- # If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini
- if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then
- INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
- [ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
- [ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME"
- fi
-
- # Fallbacks
- [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
- [ -z "$EXT_TYPE" ] && EXT_TYPE="component"
-
- # Derive element if not in manifest:
- # 1. plugin="xxx" attribute (plugins)
- # 2. module="xxx" attribute (modules)
- # 3. XML filename (components, packages)
- # 4. Repo name fallback (templates, anything else)
- if [ -z "$EXT_ELEMENT" ]; then
- EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
- fi
- if [ -z "$EXT_ELEMENT" ]; then
- EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
- fi
- if [ -z "$EXT_ELEMENT" ]; then
- FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
- # If filename is generic (templateDetails, manifest), use repo name
- case "$FNAME" in
- templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
- *) EXT_ELEMENT="$FNAME" ;;
- esac
- fi
- # Final fallback
- [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
-
- # Save for Steps 7, 8, 8b
- echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
- echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT"
- echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT"
- echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT"
-
- # Build client tag: plugins and frontend modules need site
- CLIENT_TAG=""
- if [ -n "$EXT_CLIENT" ]; then
- CLIENT_TAG="${EXT_CLIENT} "
- elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
- CLIENT_TAG="site "
- fi
-
- # Build folder tag for plugins (required for Joomla to match the update)
- FOLDER_TAG=""
- if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
- FOLDER_TAG="${EXT_FOLDER} "
- fi
-
- # Build targetplatform (fallback to Joomla 5 if not in manifest)
- if [ -z "$TARGET_PLATFORM" ]; then
- TARGET_PLATFORM=$(printf '' "/")
- fi
-
- # Build php_minimum tag
- PHP_TAG=""
- if [ -n "$PHP_MINIMUM" ]; then
- PHP_TAG="${PHP_MINIMUM} "
- fi
-
- # Build TYPE_PREFIX for download URL
- TYPE_PREFIX=""
- case "${EXT_TYPE}" in
- plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
- module) TYPE_PREFIX="mod_" ;;
- component) TYPE_PREFIX="com_" ;;
- template) TYPE_PREFIX="tpl_" ;;
- library) TYPE_PREFIX="lib_" ;;
- package) TYPE_PREFIX="pkg_" ;;
- esac
-
- DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
- INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable"
-
- # -- Build update entry for a given stability tag
- build_entry() {
- local TAG_NAME="$1"
- printf '%s\n' ' '
- printf '%s\n' " ${EXT_NAME} "
- printf '%s\n' " ${EXT_NAME} update "
- printf '%s\n' " ${EXT_ELEMENT} "
- printf '%s\n' " ${EXT_TYPE} "
- printf '%s\n' " ${VERSION} "
- [ -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' " ${TARGET_PLATFORM}"
- [ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}"
- printf '%s\n' ' Moko Consulting '
- printf '%s\n' ' https://mokoconsulting.tech '
- printf '%s\n' ' '
- }
-
- # -- Write updates.xml with cascading channels
- # Stable release updates ALL channels (development, alpha, beta, rc, stable)
- {
- printf '%s\n' ""
- printf '%s\n' ""
- printf '%s\n' ""
- printf '%s\n' ''
- build_entry "development"
- build_entry "alpha"
- build_entry "beta"
- build_entry "rc"
- build_entry "stable"
- printf '%s\n' ' '
- } > updates.xml
-
- echo "updates.xml: ${VERSION} (all channels updated to stable)" >> $GITHUB_STEP_SUMMARY
-
- # -- Commit all changes ---------------------------------------------------
- - name: Commit release changes
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.already_released != 'true'
- run: |
- if git diff --quiet && git diff --cached --quiet; then
- echo "No changes to commit"
- exit 0
- fi
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
- git config --local user.name "gitea-actions[bot]"
- # Set push URL with token for branch-protected repos
- git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- git add -A
- git commit -m "chore(release): build ${VERSION} [skip ci]" \
- --author="gitea-actions[bot] "
- git push -u origin HEAD
-
- # -- STEP 6: Create tag ---------------------------------------------------
- - name: "Step 6: Create git tag"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.check.outputs.tag_exists != 'true' &&
- steps.version.outputs.is_minor == 'true'
- run: |
- RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
- # Only create the major release tag if it doesn't exist yet
- if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
- git tag "$RELEASE_TAG"
- git push origin "$RELEASE_TAG"
- echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
- else
- echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
- fi
- echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
-
- # -- STEP 7: Create or update Gitea Release --------------------------------
- - name: "Step 7: Gitea Release"
- if: >-
- steps.version.outputs.skip != 'true'
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
- BRANCH="${{ steps.version.outputs.branch }}"
- MAJOR="${{ steps.version.outputs.major }}"
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
-
- # Reuse metadata from Step 5 (single source of truth)
- EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
- EXT_NAME="${{ steps.updates.outputs.ext_name }}"
- EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
- EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
-
- # Fallbacks if Step 5 was skipped
- if [ -z "$EXT_ELEMENT" ]; then
- EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
- fi
- [ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
-
- NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
- [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
-
- # Build release name: "Pretty Name VERSION (type_element-VERSION)"
- TYPE_PREFIX=""
- case "${EXT_TYPE}" in
- plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
- module) TYPE_PREFIX="mod_" ;;
- component) TYPE_PREFIX="com_" ;;
- template) TYPE_PREFIX="tpl_" ;;
- library) TYPE_PREFIX="lib_" ;;
- package) TYPE_PREFIX="pkg_" ;;
- esac
- RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
-
- # Delete existing release if present (overwrite, not append)
- EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
- EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
-
- if [ -n "$EXISTING_ID" ]; then
- curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
- curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
- echo "Deleted previous stable release (id: ${EXISTING_ID})"
- fi
-
- # Create fresh release
- curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- -H "Content-Type: application/json" \
- "${API_BASE}/releases" \
- -d "$(python3 -c "import json; print(json.dumps({
- 'tag_name': '${RELEASE_TAG}',
- 'name': '${RELEASE_NAME}',
- 'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
- 'target_commitish': '${BRANCH}'
- }))")"
- echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
-
- # -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
- - name: "Step 8: Build Joomla package and update checksum"
- if: >-
- steps.version.outputs.skip != 'true'
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
- REPO="${{ github.repository }}"
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
-
- # All ZIPs upload to the major release tag (vXX)
- RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
- 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
- echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
- exit 0
- fi
-
- # Find extension element name from manifest
- MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true)
- [ -z "$MANIFEST" ] && exit 0
-
- # Reuse element from Step 5, with same fallback chain
- EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
- if [ -z "$EXT_ELEMENT" ]; then
- EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
- [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
- [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
- [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
- fi
- # ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
- EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
- EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
- TYPE_PREFIX=""
- case "${EXT_TYPE}" in
- plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
- module) TYPE_PREFIX="mod_" ;;
- component) TYPE_PREFIX="com_" ;;
- template) TYPE_PREFIX="tpl_" ;;
- library) TYPE_PREFIX="lib_" ;;
- package) TYPE_PREFIX="pkg_" ;;
- esac
- ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
- TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
-
- # -- Build install packages from src/ ----------------------------
- SOURCE_DIR="src"
- [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
- [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; }
-
- EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
-
- # ZIP package
- cd "$SOURCE_DIR"
- zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES
- cd ..
-
- # tar.gz package
- tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
- --exclude='.ftpignore' --exclude='sftp-config*' \
- --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
-
- ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
- TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
-
- # -- Calculate SHA-256 for both ----------------------------------
- SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
- SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
-
- # -- Delete existing assets with same name before uploading ------
- ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
- for ASSET_NAME in "$ZIP_NAME" "$TAR_NAME"; do
- ASSET_ID=$(echo "$ASSETS" | python3 -c "
- import sys,json
- assets = json.load(sys.stdin)
- for a in assets:
- if a['name'] == '${ASSET_NAME}':
- print(a['id']); break
- " 2>/dev/null || true)
- if [ -n "$ASSET_ID" ]; then
- curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
- fi
- done
-
- # -- Upload both to release tag ----------------------------------
- curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- -H "Content-Type: application/octet-stream" \
- --data-binary @"/tmp/${ZIP_NAME}" \
- "${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null 2>&1 || true
-
- curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- -H "Content-Type: application/octet-stream" \
- --data-binary @"/tmp/${TAR_NAME}" \
- "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
-
- # -- Update updates.xml with both download formats ---------------
- if [ -f "updates.xml" ]; then
- ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
- TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
-
- # Use Python to update only the stable entry's downloads + sha256
- export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
- python3 << 'PYEOF'
- import re, os
-
- with open("updates.xml") as f:
- content = f.read()
-
- zip_url = os.environ["PY_ZIP_URL"]
- tar_url = os.environ["PY_TAR_URL"]
- sha = os.environ["PY_SHA"]
-
- # Find the stable update block and replace its downloads + sha256
- def replace_stable(m):
- block = m.group(0)
- # Replace downloads block
- new_downloads = (
- " \n"
- f" {zip_url} \n"
- " "
- )
- block = re.sub(r' .*? ', new_downloads, block, flags=re.DOTALL)
- # Add or replace sha256
- if '' in block:
- block = re.sub(r' .*? ', f' {sha} ', block)
- else:
- block = block.replace('', f'\n {sha} ')
- return block
-
- content = re.sub(
- r' .*?stable .*? ',
- replace_stable,
- content,
- flags=re.DOTALL
- )
-
- with open("updates.xml", "w") as f:
- f.write(content)
- PYEOF
-
- CURRENT_BRANCH="${{ github.ref_name }}"
- git add updates.xml
- git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
- --author="gitea-actions[bot] " || true
- git push || true
-
- # Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
- GA_TOKEN="${{ secrets.GA_TOKEN }}"
- API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
-
- FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
- "${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
-
- if [ -n "$FILE_SHA" ]; then
- CONTENT=$(base64 -w0 updates.xml)
- curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
- -H "Content-Type: application/json" \
- "${API}/contents/updates.xml" \
- -d "$(jq -n \
- --arg content "$CONTENT" \
- --arg sha "$FILE_SHA" \
- --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
- --arg branch "main" \
- '{content: $content, sha: $sha, message: $msg, branch: $branch}'
- )" > /dev/null 2>&1 \
- && echo "updates.xml synced to main via API" \
- || echo "WARNING: failed to sync updates.xml to main"
- else
- echo "WARNING: could not get updates.xml SHA from main"
- fi
- fi
-
- echo "### Joomla Packages" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
- echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
- echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
- echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
-
- # -- STEP 8b: Update release description with changelog + SHA ----------------
- - name: "Step 8b: Update release body with changelog and SHA"
- if: steps.version.outputs.skip != 'true'
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
- EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
- EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
- EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
-
- # Build TYPE_PREFIX to match Step 8's ZIP naming
- TYPE_PREFIX=""
- case "${EXT_TYPE}" in
- plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
- module) TYPE_PREFIX="mod_" ;;
- component) TYPE_PREFIX="com_" ;;
- template) TYPE_PREFIX="tpl_" ;;
- library) TYPE_PREFIX="lib_" ;;
- package) TYPE_PREFIX="pkg_" ;;
- esac
- ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
- TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
-
- # Get SHA from the built files
- SHA256_ZIP=""
- [ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
- SHA256_TAR=""
- [ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
-
- # Extract latest changelog entry (strip the ## header to avoid duplicate)
- CHANGELOG=""
- if [ -f "CHANGELOG.md" ]; then
- CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
- [ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
- fi
-
- # Build release body (single header, no duplicate from changelog)
- BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
- if [ -n "$CHANGELOG" ]; then
- BODY="${BODY}${CHANGELOG}\n\n"
- fi
- BODY="${BODY}---\n\n### Checksums\n\n"
- BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
- [ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
- [ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
-
- # Get release ID and update body
- RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
- python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
-
- if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
- python3 -c "
- import json, urllib.request
- body = '''$(printf '%b' "$BODY")'''
- data = json.dumps({'body': body}).encode()
- req = urllib.request.Request(
- '${API_BASE}/releases/${RELEASE_ID}',
- data=data,
- headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
- method='PATCH'
- )
- urllib.request.urlopen(req)
- " 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
- fi
-
- # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- - name: "Step 9: Mirror release to GitHub"
- if: >-
- steps.version.outputs.skip != 'true' &&
- steps.version.outputs.stability == 'stable' &&
- secrets.GH_TOKEN != ''
- continue-on-error: true
- env:
- GH_TOKEN: ${{ secrets.GH_TOKEN }}
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
- MAJOR="${{ steps.version.outputs.major }}"
- BRANCH="${{ steps.version.outputs.branch }}"
- GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
-
- NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
- [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
- echo "$NOTES" > /tmp/release_notes.md
-
- EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
-
- if [ -z "$EXISTING" ]; then
- gh release create "$RELEASE_TAG" \
- --repo "$GH_REPO" \
- --title "v${MAJOR} (latest: ${VERSION})" \
- --notes-file /tmp/release_notes.md \
- --target "$BRANCH" || true
- else
- gh release edit "$RELEASE_TAG" \
- --repo "$GH_REPO" \
- --title "v${MAJOR} (latest: ${VERSION})" || true
- fi
-
- # Upload assets to GitHub mirror
- for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
- if [ -f "$PKG" ]; then
- _RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
- [ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
- fi
- done
- echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
-
- # -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- - name: "Step 10: Push main to GitHub mirror"
- if: >-
- steps.version.outputs.skip != 'true' &&
- secrets.GH_TOKEN != ''
- continue-on-error: true
- run: |
- GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
- GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
- GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
- git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
- git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
- git fetch origin main --depth=1
- git push github origin/main:refs/heads/main --force 2>/dev/null \
- && echo "main branch pushed to GitHub mirror" \
- || echo "WARNING: GitHub mirror push failed"
-
- # -- Clean up lesser pre-releases (cascade) ---------------------------------
- # stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev
- - name: "Delete lesser pre-release channels"
- if: steps.version.outputs.skip != 'true'
- continue-on-error: true
- run: |
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
- TOKEN="${{ secrets.GA_TOKEN }}"
-
- # Stable deletes all pre-release channels
- TAGS_TO_DELETE="development alpha beta release-candidate"
-
- DELETED=0
- for TAG in $TAGS_TO_DELETE; do
- RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
- "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
- python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
-
- if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
- curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
- "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
- curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
- "${API_BASE}/tags/${TAG}" 2>/dev/null || true
- echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
- DELETED=$((DELETED + 1))
- fi
- done
- echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY
-
- # -- STEP 11: Reset dev branch from main ------------------------------------
- - name: "Step 11: Delete and recreate dev branch from main"
- if: steps.version.outputs.skip != 'true'
- continue-on-error: true
- run: |
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
- TOKEN="${{ secrets.GA_TOKEN }}"
-
- # Delete dev branch
- curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
- "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
-
- # Recreate dev from main (now includes version bump + changelog promotion)
- curl -sf -X POST -H "Authorization: token ${TOKEN}" \
- -H "Content-Type: application/json" \
- "${API_BASE}/branches" \
- -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
-
- echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
-
- # -- Summary --------------------------------------------------------------
- - name: Pipeline Summary
- if: always()
- run: |
- VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
- if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
- echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
- echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
- elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
- echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
- else
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "## Build & Release Complete (Joomla)" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
- echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
- echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
- fi
--
2.52.0
From 10a38db28f2299b8fd1ddfcf5b73ab83ab6b44c4 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:03 +0000
Subject: [PATCH 040/114] chore: move .gitea/workflows/cascade-dev.yml to
.mokogitea/cascade-dev.yml [skip ci]
---
.mokogitea/cascade-dev.yml | 213 +++++++++++++++++++++++++++++++++++++
1 file changed, 213 insertions(+)
create mode 100644 .mokogitea/cascade-dev.yml
diff --git a/.mokogitea/cascade-dev.yml b/.mokogitea/cascade-dev.yml
new file mode 100644
index 0000000..d4780b1
--- /dev/null
+++ b/.mokogitea/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
--
2.52.0
From 8e64ba47d54d32bc64350fb8c2edc69f50edc46c Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:03 +0000
Subject: [PATCH 041/114] chore: remove .gitea/workflows/cascade-dev.yml (moved
to .mokogitea/) [skip ci]
---
.gitea/workflows/cascade-dev.yml | 213 -------------------------------
1 file changed, 213 deletions(-)
delete mode 100644 .gitea/workflows/cascade-dev.yml
diff --git a/.gitea/workflows/cascade-dev.yml b/.gitea/workflows/cascade-dev.yml
deleted file mode 100644
index d4780b1..0000000
--- a/.gitea/workflows/cascade-dev.yml
+++ /dev/null
@@ -1,213 +0,0 @@
-# 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
--
2.52.0
From e81e0fab184d936d41c6410dbd6c4ecfddc669d9 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:04 +0000
Subject: [PATCH 042/114] chore: move .gitea/workflows/ci-joomla.yml to
.mokogitea/ci-joomla.yml [skip ci]
---
.mokogitea/ci-joomla.yml | 450 +++++++++++++++++++++++++++++++++++++++
1 file changed, 450 insertions(+)
create mode 100644 .mokogitea/ci-joomla.yml
diff --git a/.mokogitea/ci-joomla.yml b/.mokogitea/ci-joomla.yml
new file mode 100644
index 0000000..28cee48
--- /dev/null
+++ b/.mokogitea/ci-joomla.yml
@@ -0,0 +1,450 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# This file is part of a Moko Consulting project.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow.Template
+# INGROUP: MokoStandards.CI
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/joomla/ci-joomla.yml.template
+# VERSION: 04.06.00
+# BRIEF: CI workflow for Joomla extensions — lint, validate, test
+
+name: Joomla Extension CI
+
+on:
+ pull_request:
+ branches:
+ - main
+ - 'dev/**'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ lint-and-validate:
+ name: Lint & Validate
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Setup PHP
+ run: |
+ php -v && composer --version
+
+ - name: Clone MokoStandards
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
+ run: |
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
+ /tmp/mokostandards-api
+
+ - 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
+ else
+ echo "No composer.json found — skipping dependency install"
+ fi
+
+ - name: PHP syntax check
+ run: |
+ ERRORS=0
+ for DIR in src/ htdocs/; do
+ if [ -d "$DIR" ]; then
+ FOUND=1
+ while IFS= read -r -d '' FILE; do
+ OUTPUT=$(php -l "$FILE" 2>&1)
+ if echo "$OUTPUT" | grep -q "Parse error"; then
+ echo "::error file=${FILE}::${OUTPUT}"
+ ERRORS=$((ERRORS + 1))
+ fi
+ done < <(find "$DIR" -name "*.php" -print0)
+ fi
+ done
+ echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY
+ if [ "${ERRORS}" -gt 0 ]; then
+ echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: XML manifest validation
+ run: |
+ echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY
+ ERRORS=0
+
+ # Find the extension manifest (XML with /dev/null; then
+ MANIFEST="$XML_FILE"
+ break
+ fi
+ done
+
+ if [ -z "$MANIFEST" ]; then
+ echo "No Joomla extension manifest found (XML file with \`> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
+
+ # Validate well-formed XML
+ php -r "
+ \$xml = @simplexml_load_file('$MANIFEST');
+ if (\$xml === false) {
+ echo 'INVALID';
+ exit(1);
+ }
+ echo 'VALID';
+ " > /tmp/xml_result 2>&1
+ XML_RESULT=$(cat /tmp/xml_result)
+ if [ "$XML_RESULT" != "VALID" ]; then
+ echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Check required tags: name, version, author, namespace (Joomla 5+)
+ for TAG in name version author namespace; do
+ if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
+ echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ done
+ fi
+
+ if [ "${ERRORS}" -gt 0 ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Check language files referenced in manifest
+ run: |
+ echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
+ ERRORS=0
+
+ MANIFEST=""
+ for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
+ if grep -q "/dev/null; then
+ MANIFEST="$XML_FILE"
+ break
+ fi
+ done
+
+ if [ -n "$MANIFEST" ]; then
+ # Extract language file references from manifest
+ LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
+ if [ -z "$LANG_FILES" ]; then
+ echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY
+ else
+ while IFS= read -r LANG_FILE; do
+ LANG_FILE=$(echo "$LANG_FILE" | xargs)
+ if [ -z "$LANG_FILE" ]; then
+ continue
+ fi
+ # Check in common locations
+ FOUND=0
+ for BASE in "." "src" "htdocs"; do
+ if [ -f "${BASE}/${LANG_FILE}" ]; then
+ FOUND=1
+ break
+ fi
+ done
+ if [ "$FOUND" -eq 0 ]; then
+ echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ done <<< "$LANG_FILES"
+ fi
+ else
+ echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ if [ "${ERRORS}" -gt 0 ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Check index.html files in directories
+ run: |
+ echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
+ MISSING=0
+ CHECKED=0
+
+ for DIR in src/ htdocs/; do
+ if [ -d "$DIR" ]; then
+ while IFS= read -r -d '' SUBDIR; do
+ CHECKED=$((CHECKED + 1))
+ if [ ! -f "${SUBDIR}/index.html" ]; then
+ echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY
+ MISSING=$((MISSING + 1))
+ fi
+ done < <(find "$DIR" -type d -print0)
+ fi
+ done
+
+ if [ "${CHECKED}" -eq 0 ]; then
+ echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
+ elif [ "${MISSING}" -gt 0 ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ release-readiness:
+ name: Release Readiness Check
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request' && github.base_ref == 'main'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Validate release readiness
+ run: |
+ echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ ERRORS=0
+
+ # Extract version from README.md
+ README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
+ if [ -z "$README_VERSION" ]; then
+ echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Find the extension manifest
+ MANIFEST=""
+ for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
+ if grep -q "/dev/null; then
+ MANIFEST="$XML_FILE"
+ break
+ fi
+ done
+
+ if [ -z "$MANIFEST" ]; then
+ echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
+
+ # Check matches README VERSION
+ MANIFEST_VERSION=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1)
+ if [ -z "$MANIFEST_VERSION" ]; then
+ echo "No \`\` tag in manifest." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then
+ echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Check extension type, element, client attributes
+ EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1)
+ if [ -z "$EXT_TYPE" ]; then
+ echo "Missing \`type\` attribute on \`\` tag." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Element check (component/module/plugin name)
+ HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0")
+ if [ "$HAS_ELEMENT" -eq 0 ]; then
+ echo "Missing \`\` or \`\` in manifest." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ fi
+
+ # Client attribute for site/admin modules and plugins
+ if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then
+ HAS_CLIENT=$(grep -cP ']*\bclient=' "$MANIFEST" 2>/dev/null || echo "0")
+ if [ "$HAS_CLIENT" -eq 0 ]; then
+ echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ fi
+ fi
+ fi
+
+ # Check updates.xml exists
+ if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then
+ echo "Update XML present." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ fi
+
+ # Check CHANGELOG.md exists
+ if [ -f "CHANGELOG.md" ]; then
+ echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ fi
+
+ echo "" >> $GITHUB_STEP_SUMMARY
+ if [ $ERRORS -gt 0 ]; then
+ echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ test:
+ name: Tests (PHP ${{ matrix.php }})
+ runs-on: ubuntu-latest
+ needs: lint-and-validate
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.2', '8.3']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Setup PHP ${{ matrix.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
+ else
+ echo "No composer.json found — skipping dependency install"
+ fi
+
+ - name: Run tests
+ run: |
+ echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
+ if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
+ vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log
+ EXIT=${PIPESTATUS[0]}
+ if [ $EXIT -eq 0 ]; then
+ echo "All tests passed." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ fi
+ exit $EXIT
+ 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
--
2.52.0
From 81bdb43fbe2618ae8e9657539b5a6fcd93c5183c Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:04 +0000
Subject: [PATCH 043/114] chore: remove .gitea/workflows/ci-joomla.yml (moved
to .mokogitea/) [skip ci]
---
.gitea/workflows/ci-joomla.yml | 450 ---------------------------------
1 file changed, 450 deletions(-)
delete mode 100644 .gitea/workflows/ci-joomla.yml
diff --git a/.gitea/workflows/ci-joomla.yml b/.gitea/workflows/ci-joomla.yml
deleted file mode 100644
index 28cee48..0000000
--- a/.gitea/workflows/ci-joomla.yml
+++ /dev/null
@@ -1,450 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# This file is part of a Moko Consulting project.
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow.Template
-# INGROUP: MokoStandards.CI
-# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
-# PATH: /templates/workflows/joomla/ci-joomla.yml.template
-# VERSION: 04.06.00
-# BRIEF: CI workflow for Joomla extensions — lint, validate, test
-
-name: Joomla Extension CI
-
-on:
- pull_request:
- branches:
- - main
- - 'dev/**'
- workflow_dispatch:
-
-permissions:
- contents: read
- pull-requests: write
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-jobs:
- lint-and-validate:
- name: Lint & Validate
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
-
- - name: Setup PHP
- run: |
- php -v && composer --version
-
- - name: Clone MokoStandards
- env:
- GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
- MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
- MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
- run: |
- git clone --depth 1 --branch main --quiet \
- "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
- /tmp/mokostandards-api
-
- - 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
- else
- echo "No composer.json found — skipping dependency install"
- fi
-
- - name: PHP syntax check
- run: |
- ERRORS=0
- for DIR in src/ htdocs/; do
- if [ -d "$DIR" ]; then
- FOUND=1
- while IFS= read -r -d '' FILE; do
- OUTPUT=$(php -l "$FILE" 2>&1)
- if echo "$OUTPUT" | grep -q "Parse error"; then
- echo "::error file=${FILE}::${OUTPUT}"
- ERRORS=$((ERRORS + 1))
- fi
- done < <(find "$DIR" -name "*.php" -print0)
- fi
- done
- echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY
- if [ "${ERRORS}" -gt 0 ]; then
- echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY
- exit 1
- else
- echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
- fi
-
- - name: XML manifest validation
- run: |
- echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY
- ERRORS=0
-
- # Find the extension manifest (XML with /dev/null; then
- MANIFEST="$XML_FILE"
- break
- fi
- done
-
- if [ -z "$MANIFEST" ]; then
- echo "No Joomla extension manifest found (XML file with \`> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- else
- echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
-
- # Validate well-formed XML
- php -r "
- \$xml = @simplexml_load_file('$MANIFEST');
- if (\$xml === false) {
- echo 'INVALID';
- exit(1);
- }
- echo 'VALID';
- " > /tmp/xml_result 2>&1
- XML_RESULT=$(cat /tmp/xml_result)
- if [ "$XML_RESULT" != "VALID" ]; then
- echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- else
- echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
- fi
-
- # Check required tags: name, version, author, namespace (Joomla 5+)
- for TAG in name version author namespace; do
- if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
- echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- else
- echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
- fi
- done
- fi
-
- if [ "${ERRORS}" -gt 0 ]; then
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY
- exit 1
- else
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
- fi
-
- - name: Check language files referenced in manifest
- run: |
- echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
- ERRORS=0
-
- MANIFEST=""
- for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
- if grep -q "/dev/null; then
- MANIFEST="$XML_FILE"
- break
- fi
- done
-
- if [ -n "$MANIFEST" ]; then
- # Extract language file references from manifest
- LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
- if [ -z "$LANG_FILES" ]; then
- echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY
- else
- while IFS= read -r LANG_FILE; do
- LANG_FILE=$(echo "$LANG_FILE" | xargs)
- if [ -z "$LANG_FILE" ]; then
- continue
- fi
- # Check in common locations
- FOUND=0
- for BASE in "." "src" "htdocs"; do
- if [ -f "${BASE}/${LANG_FILE}" ]; then
- FOUND=1
- break
- fi
- done
- if [ "$FOUND" -eq 0 ]; then
- echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- else
- echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
- fi
- done <<< "$LANG_FILES"
- fi
- else
- echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY
- fi
-
- if [ "${ERRORS}" -gt 0 ]; then
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY
- exit 1
- else
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
- fi
-
- - name: Check index.html files in directories
- run: |
- echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
- MISSING=0
- CHECKED=0
-
- for DIR in src/ htdocs/; do
- if [ -d "$DIR" ]; then
- while IFS= read -r -d '' SUBDIR; do
- CHECKED=$((CHECKED + 1))
- if [ ! -f "${SUBDIR}/index.html" ]; then
- echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY
- MISSING=$((MISSING + 1))
- fi
- done < <(find "$DIR" -type d -print0)
- fi
- done
-
- if [ "${CHECKED}" -eq 0 ]; then
- echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
- elif [ "${MISSING}" -gt 0 ]; then
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
- exit 1
- else
- echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
- fi
-
- release-readiness:
- name: Release Readiness Check
- runs-on: ubuntu-latest
- if: github.event_name == 'pull_request' && github.base_ref == 'main'
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
-
- - name: Validate release readiness
- run: |
- echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- ERRORS=0
-
- # Extract version from README.md
- README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
- if [ -z "$README_VERSION" ]; then
- echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- else
- echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
- fi
-
- # Find the extension manifest
- MANIFEST=""
- for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
- if grep -q "/dev/null; then
- MANIFEST="$XML_FILE"
- break
- fi
- done
-
- if [ -z "$MANIFEST" ]; then
- echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- else
- echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
-
- # Check matches README VERSION
- MANIFEST_VERSION=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1)
- if [ -z "$MANIFEST_VERSION" ]; then
- echo "No \`\` tag in manifest." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then
- echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- else
- echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY
- fi
-
- # Check extension type, element, client attributes
- EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1)
- if [ -z "$EXT_TYPE" ]; then
- echo "Missing \`type\` attribute on \`\` tag." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- else
- echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY
- fi
-
- # Element check (component/module/plugin name)
- HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0")
- if [ "$HAS_ELEMENT" -eq 0 ]; then
- echo "Missing \`\` or \`\` in manifest." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- fi
-
- # Client attribute for site/admin modules and plugins
- if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then
- HAS_CLIENT=$(grep -cP ']*\bclient=' "$MANIFEST" 2>/dev/null || echo "0")
- if [ "$HAS_CLIENT" -eq 0 ]; then
- echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- fi
- fi
- fi
-
- # Check updates.xml exists
- if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then
- echo "Update XML present." >> $GITHUB_STEP_SUMMARY
- else
- echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- fi
-
- # Check CHANGELOG.md exists
- if [ -f "CHANGELOG.md" ]; then
- echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY
- else
- echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY
- ERRORS=$((ERRORS + 1))
- fi
-
- echo "" >> $GITHUB_STEP_SUMMARY
- if [ $ERRORS -gt 0 ]; then
- echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY
- exit 1
- else
- echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY
- fi
-
- test:
- name: Tests (PHP ${{ matrix.php }})
- runs-on: ubuntu-latest
- needs: lint-and-validate
-
- strategy:
- fail-fast: false
- matrix:
- php: ['8.2', '8.3']
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
-
- - name: Setup PHP ${{ matrix.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
- else
- echo "No composer.json found — skipping dependency install"
- fi
-
- - name: Run tests
- run: |
- echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
- if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
- vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log
- EXIT=${PIPESTATUS[0]}
- if [ $EXIT -eq 0 ]; then
- echo "All tests passed." >> $GITHUB_STEP_SUMMARY
- else
- echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- fi
- exit $EXIT
- 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
--
2.52.0
From 46c50edc916a63ece81d0020574ff3275e83fd60 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:04 +0000
Subject: [PATCH 044/114] chore: move .gitea/workflows/cleanup.yml to
.mokogitea/cleanup.yml [skip ci]
---
.mokogitea/cleanup.yml | 87 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100644 .mokogitea/cleanup.yml
diff --git a/.mokogitea/cleanup.yml b/.mokogitea/cleanup.yml
new file mode 100644
index 0000000..78aa0c3
--- /dev/null
+++ b/.mokogitea/cleanup.yml
@@ -0,0 +1,87 @@
+# 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/MokoStandards
+# PATH: /.gitea/workflows/cleanup.yml
+# VERSION: 01.00.00
+# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
+
+name: Repository Cleanup
+
+on:
+ schedule:
+ - cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+env:
+ GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+
+jobs:
+ cleanup:
+ name: Clean Merged Branches
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GA_TOKEN }}
+
+ - name: Delete merged branches
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ echo "=== Merged Branch Cleanup ==="
+ API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
+
+ # List branches via API
+ BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/branches?limit=50" | jq -r '.[].name')
+
+ DELETED=0
+ for BRANCH in $BRANCHES; do
+ # Skip protected branches
+ case "$BRANCH" in
+ main|master|develop|release/*|hotfix/*) continue ;;
+ esac
+
+ # Check if branch is merged into main
+ if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
+ echo " Deleting merged branch: ${BRANCH}"
+ curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/branches/${BRANCH}" 2>/dev/null || true
+ DELETED=$((DELETED + 1))
+ fi
+ done
+
+ echo "Deleted ${DELETED} merged branch(es)"
+
+ - name: Clean old workflow runs
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ echo "=== Workflow Run Cleanup ==="
+ API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
+ CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
+
+ # Get old completed runs
+ RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/actions/runs?status=completed&limit=50" | \
+ jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
+
+ DELETED=0
+ for RUN_ID in $RUNS; do
+ curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
+ DELETED=$((DELETED + 1))
+ done
+
+ echo "Deleted ${DELETED} old workflow run(s)"
--
2.52.0
From 1e9cc2185e727eb60d753c96ef22e48e9bcfe3db Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:05 +0000
Subject: [PATCH 045/114] chore: remove .gitea/workflows/cleanup.yml (moved to
.mokogitea/) [skip ci]
---
.gitea/workflows/cleanup.yml | 87 ------------------------------------
1 file changed, 87 deletions(-)
delete mode 100644 .gitea/workflows/cleanup.yml
diff --git a/.gitea/workflows/cleanup.yml b/.gitea/workflows/cleanup.yml
deleted file mode 100644
index 78aa0c3..0000000
--- a/.gitea/workflows/cleanup.yml
+++ /dev/null
@@ -1,87 +0,0 @@
-# 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/MokoStandards
-# PATH: /.gitea/workflows/cleanup.yml
-# VERSION: 01.00.00
-# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
-
-name: Repository Cleanup
-
-on:
- schedule:
- - cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC
- workflow_dispatch:
-
-permissions:
- contents: write
-
-env:
- GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
-
-jobs:
- cleanup:
- name: Clean Merged Branches
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- token: ${{ secrets.GA_TOKEN }}
-
- - name: Delete merged branches
- env:
- GA_TOKEN: ${{ secrets.GA_TOKEN }}
- run: |
- echo "=== Merged Branch Cleanup ==="
- API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
-
- # List branches via API
- BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
- "${API}/branches?limit=50" | jq -r '.[].name')
-
- DELETED=0
- for BRANCH in $BRANCHES; do
- # Skip protected branches
- case "$BRANCH" in
- main|master|develop|release/*|hotfix/*) continue ;;
- esac
-
- # Check if branch is merged into main
- if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
- echo " Deleting merged branch: ${BRANCH}"
- curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
- "${API}/branches/${BRANCH}" 2>/dev/null || true
- DELETED=$((DELETED + 1))
- fi
- done
-
- echo "Deleted ${DELETED} merged branch(es)"
-
- - name: Clean old workflow runs
- env:
- GA_TOKEN: ${{ secrets.GA_TOKEN }}
- run: |
- echo "=== Workflow Run Cleanup ==="
- API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
- CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
-
- # Get old completed runs
- RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
- "${API}/actions/runs?status=completed&limit=50" | \
- jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
-
- DELETED=0
- for RUN_ID in $RUNS; do
- curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
- "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
- DELETED=$((DELETED + 1))
- done
-
- echo "Deleted ${DELETED} old workflow run(s)"
--
2.52.0
From bcdcd9534eb16b97fbb8eef8381f8ee28fa6fed4 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:05 +0000
Subject: [PATCH 046/114] chore: move .gitea/workflows/deploy-manual.yml to
.mokogitea/deploy-manual.yml [skip ci]
---
.mokogitea/deploy-manual.yml | 126 +++++++++++++++++++++++++++++++++++
1 file changed, 126 insertions(+)
create mode 100644 .mokogitea/deploy-manual.yml
diff --git a/.mokogitea/deploy-manual.yml b/.mokogitea/deploy-manual.yml
new file mode 100644
index 0000000..a81cfa5
--- /dev/null
+++ b/.mokogitea/deploy-manual.yml
@@ -0,0 +1,126 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Deploy
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
+# PATH: /templates/workflows/joomla/deploy-manual.yml.template
+# VERSION: 04.07.00
+# BRIEF: Manual SFTP deploy to dev server for Joomla repos
+
+name: Deploy to Dev (Manual)
+
+on:
+ workflow_dispatch:
+ inputs:
+ clear_remote:
+ description: 'Delete all remote files before uploading'
+ required: false
+ default: 'false'
+ type: boolean
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+permissions:
+ contents: read
+
+jobs:
+ deploy:
+ name: SFTP Deploy to Dev
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Setup PHP
+ run: |
+ php -v && composer --version
+
+ - name: Setup MokoStandards tools
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
+ run: |
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
+ /tmp/mokostandards-api 2>/dev/null || true
+ if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
+ cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
+ fi
+
+ - name: Check FTP configuration
+ id: check
+ env:
+ HOST: ${{ vars.DEV_FTP_HOST }}
+ PATH_VAR: ${{ vars.DEV_FTP_PATH }}
+ PORT: ${{ vars.DEV_FTP_PORT }}
+ run: |
+ if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
+ echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ echo "host=$HOST" >> "$GITHUB_OUTPUT"
+
+ REMOTE="${PATH_VAR%/}"
+ echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
+
+ [ -z "$PORT" ] && PORT="22"
+ echo "port=$PORT" >> "$GITHUB_OUTPUT"
+
+ - name: Deploy via SFTP
+ if: steps.check.outputs.skip != 'true'
+ env:
+ SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
+ SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
+ SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
+ run: |
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
+
+ printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
+ "${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
+ > /tmp/sftp-config.json
+
+ if [ -n "$SFTP_KEY" ]; then
+ echo "$SFTP_KEY" > /tmp/deploy_key
+ chmod 600 /tmp/deploy_key
+ printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
+ else
+ printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
+ fi
+
+ DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
+ [ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
+
+ PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
+ if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
+ php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
+ else
+ php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
+ fi
+
+ rm -f /tmp/deploy_key /tmp/sftp-config.json
+
+ - name: Summary
+ if: always()
+ run: |
+ if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
+ echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
+ echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
+ fi
--
2.52.0
From 9ffb4d44550019154aee68394cfad71852f322c5 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:06 +0000
Subject: [PATCH 047/114] chore: remove .gitea/workflows/deploy-manual.yml
(moved to .mokogitea/) [skip ci]
---
.gitea/workflows/deploy-manual.yml | 126 -----------------------------
1 file changed, 126 deletions(-)
delete mode 100644 .gitea/workflows/deploy-manual.yml
diff --git a/.gitea/workflows/deploy-manual.yml b/.gitea/workflows/deploy-manual.yml
deleted file mode 100644
index a81cfa5..0000000
--- a/.gitea/workflows/deploy-manual.yml
+++ /dev/null
@@ -1,126 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
-# INGROUP: MokoStandards.Deploy
-# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
-# PATH: /templates/workflows/joomla/deploy-manual.yml.template
-# VERSION: 04.07.00
-# BRIEF: Manual SFTP deploy to dev server for Joomla repos
-
-name: Deploy to Dev (Manual)
-
-on:
- workflow_dispatch:
- inputs:
- clear_remote:
- description: 'Delete all remote files before uploading'
- required: false
- default: 'false'
- type: boolean
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-permissions:
- contents: read
-
-jobs:
- deploy:
- name: SFTP Deploy to Dev
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
-
- - name: Setup PHP
- run: |
- php -v && composer --version
-
- - name: Setup MokoStandards tools
- env:
- GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
- MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
- MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
- COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
- run: |
- git clone --depth 1 --branch main --quiet \
- "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
- /tmp/mokostandards-api 2>/dev/null || true
- if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
- cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- fi
-
- - name: Check FTP configuration
- id: check
- env:
- HOST: ${{ vars.DEV_FTP_HOST }}
- PATH_VAR: ${{ vars.DEV_FTP_PATH }}
- PORT: ${{ vars.DEV_FTP_PORT }}
- run: |
- if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
- echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
- echo "skip=true" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- echo "skip=false" >> "$GITHUB_OUTPUT"
- echo "host=$HOST" >> "$GITHUB_OUTPUT"
-
- REMOTE="${PATH_VAR%/}"
- echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
-
- [ -z "$PORT" ] && PORT="22"
- echo "port=$PORT" >> "$GITHUB_OUTPUT"
-
- - name: Deploy via SFTP
- if: steps.check.outputs.skip != 'true'
- env:
- SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
- SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
- SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
- run: |
- SOURCE_DIR="src"
- [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
- [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
-
- printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
- "${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
- > /tmp/sftp-config.json
-
- if [ -n "$SFTP_KEY" ]; then
- echo "$SFTP_KEY" > /tmp/deploy_key
- chmod 600 /tmp/deploy_key
- printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
- else
- printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
- fi
-
- DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
- [ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
-
- PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
- if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
- php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
- else
- php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
- fi
-
- rm -f /tmp/deploy_key /tmp/sftp-config.json
-
- - name: Summary
- if: always()
- run: |
- if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
- echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
- else
- echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
- echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
- echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
- fi
--
2.52.0
From 7e0b7165f4ff2ac4b76ee2714223b38ba5853acb Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:06 +0000
Subject: [PATCH 048/114] chore: move .gitea/workflows/dispatch-css-sync.yml to
.mokogitea/dispatch-css-sync.yml [skip ci]
---
.mokogitea/dispatch-css-sync.yml | 111 +++++++++++++++++++++++++++++++
1 file changed, 111 insertions(+)
create mode 100644 .mokogitea/dispatch-css-sync.yml
diff --git a/.mokogitea/dispatch-css-sync.yml b/.mokogitea/dispatch-css-sync.yml
new file mode 100644
index 0000000..fe233e7
--- /dev/null
+++ b/.mokogitea/dispatch-css-sync.yml
@@ -0,0 +1,111 @@
+# When MokoOnyx CSS changes hit main:
+# 1. Sync base CSS to Template-Client-WaaS (the single source for clients)
+# 2. If new CSS variables were added, create issues on individual client repos
+name: Sync CSS to Client Template
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'src/media/templates/site/mokoonyx/css/**'
+ - 'media/templates/site/mokoonyx/css/**'
+
+permissions:
+ contents: read
+
+jobs:
+ sync:
+ name: Sync to Template and Notify Clients
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout MokoOnyx
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+
+ - name: Sync CSS to Template-Client-WaaS
+ env:
+ GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ API="${{ github.server_url }}/api/v1"
+ AUTH="Authorization: token ${GITEA_TOKEN}"
+ TEMPLATE="MokoConsulting/Template-Client-WaaS"
+
+ CSS_DIR="src/media/templates/site/mokoonyx/css"
+ [ ! -d "$CSS_DIR" ] && CSS_DIR="media/templates/site/mokoonyx/css"
+
+ # Sync base CSS files only (user.css and *.custom.css are client-owned)
+ find "$CSS_DIR" -name "*.css" -not -name "user.css" -not -name "*.custom.css" | while read -r file; do
+ rel_path="src/media/templates/site/mokoonyx/css/${file#${CSS_DIR}/}"
+ content_b64=$(base64 -w0 "$file")
+ sha=$(curl -sf -H "$AUTH" "${API}/repos/${TEMPLATE}/contents/${rel_path}" | jq -r '.sha // empty')
+
+ if [ -n "$sha" ]; then
+ curl -sf -X PUT -H "$AUTH" -H "Content-Type: application/json" \
+ "${API}/repos/${TEMPLATE}/contents/${rel_path}" \
+ -d "{\"content\": \"${content_b64}\", \"sha\": \"${sha}\", \"message\": \"chore: sync CSS from MokoOnyx\"}" \
+ -o /dev/null && echo "Updated: ${rel_path}"
+ else
+ curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
+ "${API}/repos/${TEMPLATE}/contents/${rel_path}" \
+ -d "{\"content\": \"${content_b64}\", \"message\": \"chore: sync CSS from MokoOnyx\"}" \
+ -o /dev/null && echo "Created: ${rel_path}"
+ fi
+ done
+
+ - name: Extract all CSS variables from MokoOnyx base
+ id: vars
+ env:
+ GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ API="${{ github.server_url }}/api/v1"
+ AUTH="Authorization: token ${GITEA_TOKEN}"
+
+ CSS_DIR="src/media/templates/site/mokoonyx/css"
+ [ ! -d "$CSS_DIR" ] && CSS_DIR="media/templates/site/mokoonyx/css"
+
+ # Get ALL variables defined in MokoOnyx base CSS (excluding custom files)
+ ALL_VARS=$(find "$CSS_DIR" -name "*.css" -not -name "*.custom.css" -not -name "user.css" -exec grep -ohE '\-\-[a-z][a-z0-9-]+' {} \; | sort -u)
+ echo "$ALL_VARS" > /tmp/all_vars.txt
+ echo "Total base variables: $(wc -l < /tmp/all_vars.txt)"
+
+ # Check each client repo for missing variables
+ CLIENTS=(
+ "ClarksvilleFurs/client-waas-clarksvillefurs"
+ "KiddieLand/client-waas-kiddieland"
+ "VexCreations/client-waas-vexcreations"
+ )
+
+ for repo in "${CLIENTS[@]}"; do
+ echo "=== Checking ${repo} ==="
+ MISSING=""
+
+ for theme in "dark" "light"; do
+ FILE_PATH="src/media/templates/site/mokoonyx/css/theme/${theme}.custom.css"
+ CLIENT_CSS=$(curl -sf -H "$AUTH" "${API}/repos/${repo}/contents/${FILE_PATH}" | jq -r '.content // empty' | base64 -d 2>/dev/null || echo "")
+
+ if [ -z "$CLIENT_CSS" ]; then
+ MISSING="$MISSING\nAll variables missing from ${theme}.custom.css (file not found)"
+ continue
+ fi
+
+ # Find variables in base that are NOT in client custom file
+ while read -r var; do
+ [ -z "$var" ] && continue
+ if ! echo "$CLIENT_CSS" | grep -qF "$var"; then
+ MISSING="$MISSING\n- \`${var}\` missing from ${theme}.custom.css"
+ fi
+ done < /tmp/all_vars.txt
+ done
+
+ if [ -n "$MISSING" ]; then
+ BODY="Your theme custom files are missing CSS variables defined in MokoOnyx base.\n\n## Missing Variables\n${MISSING}\n\n## Action\n\nAdd these variables to your \`dark.custom.css\` and/or \`light.custom.css\` with appropriate values for your theme.\n\nBase CSS reference: ${{ github.server_url }}/MokoConsulting/MokoOnyx/src/branch/main/src/media/templates/site/mokoonyx/css"
+
+ curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
+ "${API}/repos/${repo}/issues" \
+ -d "$(jq -n --arg t "chore: CSS variables out of sync with MokoOnyx" --arg b "$BODY" '{title:$t,body:$b}')" \
+ -o /dev/null && echo "Issue created: ${repo}"
+ else
+ echo " All variables present"
+ fi
+ done
--
2.52.0
From 00ddab25db30ebfbcd2c658e26bd32947e33eb3b Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:06 +0000
Subject: [PATCH 049/114] chore: remove .gitea/workflows/dispatch-css-sync.yml
(moved to .mokogitea/) [skip ci]
---
.gitea/workflows/dispatch-css-sync.yml | 111 -------------------------
1 file changed, 111 deletions(-)
delete mode 100644 .gitea/workflows/dispatch-css-sync.yml
diff --git a/.gitea/workflows/dispatch-css-sync.yml b/.gitea/workflows/dispatch-css-sync.yml
deleted file mode 100644
index fe233e7..0000000
--- a/.gitea/workflows/dispatch-css-sync.yml
+++ /dev/null
@@ -1,111 +0,0 @@
-# When MokoOnyx CSS changes hit main:
-# 1. Sync base CSS to Template-Client-WaaS (the single source for clients)
-# 2. If new CSS variables were added, create issues on individual client repos
-name: Sync CSS to Client Template
-
-on:
- push:
- branches: [main]
- paths:
- - 'src/media/templates/site/mokoonyx/css/**'
- - 'media/templates/site/mokoonyx/css/**'
-
-permissions:
- contents: read
-
-jobs:
- sync:
- name: Sync to Template and Notify Clients
- runs-on: ubuntu-latest
- steps:
- - name: Checkout MokoOnyx
- uses: actions/checkout@v4
- with:
- fetch-depth: 2
-
- - name: Sync CSS to Template-Client-WaaS
- env:
- GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
- run: |
- API="${{ github.server_url }}/api/v1"
- AUTH="Authorization: token ${GITEA_TOKEN}"
- TEMPLATE="MokoConsulting/Template-Client-WaaS"
-
- CSS_DIR="src/media/templates/site/mokoonyx/css"
- [ ! -d "$CSS_DIR" ] && CSS_DIR="media/templates/site/mokoonyx/css"
-
- # Sync base CSS files only (user.css and *.custom.css are client-owned)
- find "$CSS_DIR" -name "*.css" -not -name "user.css" -not -name "*.custom.css" | while read -r file; do
- rel_path="src/media/templates/site/mokoonyx/css/${file#${CSS_DIR}/}"
- content_b64=$(base64 -w0 "$file")
- sha=$(curl -sf -H "$AUTH" "${API}/repos/${TEMPLATE}/contents/${rel_path}" | jq -r '.sha // empty')
-
- if [ -n "$sha" ]; then
- curl -sf -X PUT -H "$AUTH" -H "Content-Type: application/json" \
- "${API}/repos/${TEMPLATE}/contents/${rel_path}" \
- -d "{\"content\": \"${content_b64}\", \"sha\": \"${sha}\", \"message\": \"chore: sync CSS from MokoOnyx\"}" \
- -o /dev/null && echo "Updated: ${rel_path}"
- else
- curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
- "${API}/repos/${TEMPLATE}/contents/${rel_path}" \
- -d "{\"content\": \"${content_b64}\", \"message\": \"chore: sync CSS from MokoOnyx\"}" \
- -o /dev/null && echo "Created: ${rel_path}"
- fi
- done
-
- - name: Extract all CSS variables from MokoOnyx base
- id: vars
- env:
- GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
- run: |
- API="${{ github.server_url }}/api/v1"
- AUTH="Authorization: token ${GITEA_TOKEN}"
-
- CSS_DIR="src/media/templates/site/mokoonyx/css"
- [ ! -d "$CSS_DIR" ] && CSS_DIR="media/templates/site/mokoonyx/css"
-
- # Get ALL variables defined in MokoOnyx base CSS (excluding custom files)
- ALL_VARS=$(find "$CSS_DIR" -name "*.css" -not -name "*.custom.css" -not -name "user.css" -exec grep -ohE '\-\-[a-z][a-z0-9-]+' {} \; | sort -u)
- echo "$ALL_VARS" > /tmp/all_vars.txt
- echo "Total base variables: $(wc -l < /tmp/all_vars.txt)"
-
- # Check each client repo for missing variables
- CLIENTS=(
- "ClarksvilleFurs/client-waas-clarksvillefurs"
- "KiddieLand/client-waas-kiddieland"
- "VexCreations/client-waas-vexcreations"
- )
-
- for repo in "${CLIENTS[@]}"; do
- echo "=== Checking ${repo} ==="
- MISSING=""
-
- for theme in "dark" "light"; do
- FILE_PATH="src/media/templates/site/mokoonyx/css/theme/${theme}.custom.css"
- CLIENT_CSS=$(curl -sf -H "$AUTH" "${API}/repos/${repo}/contents/${FILE_PATH}" | jq -r '.content // empty' | base64 -d 2>/dev/null || echo "")
-
- if [ -z "$CLIENT_CSS" ]; then
- MISSING="$MISSING\nAll variables missing from ${theme}.custom.css (file not found)"
- continue
- fi
-
- # Find variables in base that are NOT in client custom file
- while read -r var; do
- [ -z "$var" ] && continue
- if ! echo "$CLIENT_CSS" | grep -qF "$var"; then
- MISSING="$MISSING\n- \`${var}\` missing from ${theme}.custom.css"
- fi
- done < /tmp/all_vars.txt
- done
-
- if [ -n "$MISSING" ]; then
- BODY="Your theme custom files are missing CSS variables defined in MokoOnyx base.\n\n## Missing Variables\n${MISSING}\n\n## Action\n\nAdd these variables to your \`dark.custom.css\` and/or \`light.custom.css\` with appropriate values for your theme.\n\nBase CSS reference: ${{ github.server_url }}/MokoConsulting/MokoOnyx/src/branch/main/src/media/templates/site/mokoonyx/css"
-
- curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
- "${API}/repos/${repo}/issues" \
- -d "$(jq -n --arg t "chore: CSS variables out of sync with MokoOnyx" --arg b "$BODY" '{title:$t,body:$b}')" \
- -o /dev/null && echo "Issue created: ${repo}"
- else
- echo " All variables present"
- fi
- done
--
2.52.0
From 79fbed2ad56c435fd2934d0a90a6bf8ee265b0f5 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:07 +0000
Subject: [PATCH 050/114] chore: move .gitea/workflows/gitleaks.yml to
.mokogitea/gitleaks.yml [skip ci]
---
.mokogitea/gitleaks.yml | 96 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 96 insertions(+)
create mode 100644 .mokogitea/gitleaks.yml
diff --git a/.mokogitea/gitleaks.yml b/.mokogitea/gitleaks.yml
new file mode 100644
index 0000000..b29f881
--- /dev/null
+++ b/.mokogitea/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
--
2.52.0
From 08264367124e86075602147d7146322c33670169 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:07 +0000
Subject: [PATCH 051/114] chore: remove .gitea/workflows/gitleaks.yml (moved to
.mokogitea/) [skip ci]
---
.gitea/workflows/gitleaks.yml | 96 -----------------------------------
1 file changed, 96 deletions(-)
delete mode 100644 .gitea/workflows/gitleaks.yml
diff --git a/.gitea/workflows/gitleaks.yml b/.gitea/workflows/gitleaks.yml
deleted file mode 100644
index b29f881..0000000
--- a/.gitea/workflows/gitleaks.yml
+++ /dev/null
@@ -1,96 +0,0 @@
-# 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
--
2.52.0
From 848510e3626cfcb31cd5a4c810dd6a7063e68820 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:08 +0000
Subject: [PATCH 052/114] chore: move .gitea/workflows/notify.yml to
.mokogitea/notify.yml [skip ci]
---
.mokogitea/notify.yml | 71 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 71 insertions(+)
create mode 100644 .mokogitea/notify.yml
diff --git a/.mokogitea/notify.yml b/.mokogitea/notify.yml
new file mode 100644
index 0000000..8cc8382
--- /dev/null
+++ b/.mokogitea/notify.yml
@@ -0,0 +1,71 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Notifications
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
+# PATH: /.gitea/workflows/notify.yml
+# VERSION: 01.00.00
+# BRIEF: Push notifications via ntfy on release success or workflow failure
+
+name: Notifications
+
+on:
+ workflow_run:
+ workflows:
+ - "Joomla Build & Release"
+ - "Joomla Extension CI"
+ - "Deploy"
+ - "Cascade Main → Dev"
+ types:
+ - completed
+
+permissions:
+ contents: read
+
+env:
+ NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
+ NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
+
+jobs:
+ notify:
+ name: Send Notification
+ runs-on: ubuntu-latest
+ if: >-
+ github.event.workflow_run.conclusion == 'success' ||
+ github.event.workflow_run.conclusion == 'failure'
+
+ steps:
+ - name: Notify on success (releases only)
+ if: >-
+ github.event.workflow_run.conclusion == 'success' &&
+ contains(github.event.workflow_run.name, 'Release')
+ run: |
+ REPO="${{ github.event.repository.name }}"
+ WORKFLOW="${{ github.event.workflow_run.name }}"
+ URL="${{ github.event.workflow_run.html_url }}"
+
+ curl -sS \
+ -H "Title: ${REPO} released" \
+ -H "Tags: white_check_mark,package" \
+ -H "Priority: default" \
+ -H "Click: ${URL}" \
+ -d "${WORKFLOW} completed successfully." \
+ "${NTFY_URL}/${NTFY_TOPIC}"
+
+ - name: Notify on failure
+ if: github.event.workflow_run.conclusion == 'failure'
+ run: |
+ REPO="${{ github.event.repository.name }}"
+ WORKFLOW="${{ github.event.workflow_run.name }}"
+ URL="${{ github.event.workflow_run.html_url }}"
+
+ curl -sS \
+ -H "Title: ${REPO} workflow failed" \
+ -H "Tags: x,warning" \
+ -H "Priority: high" \
+ -H "Click: ${URL}" \
+ -d "${WORKFLOW} failed. Check the run for details." \
+ "${NTFY_URL}/${NTFY_TOPIC}"
--
2.52.0
From 59fc56308ded868e06b0832294789d35f663e4b3 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:08 +0000
Subject: [PATCH 053/114] chore: remove .gitea/workflows/notify.yml (moved to
.mokogitea/) [skip ci]
---
.gitea/workflows/notify.yml | 71 -------------------------------------
1 file changed, 71 deletions(-)
delete mode 100644 .gitea/workflows/notify.yml
diff --git a/.gitea/workflows/notify.yml b/.gitea/workflows/notify.yml
deleted file mode 100644
index 8cc8382..0000000
--- a/.gitea/workflows/notify.yml
+++ /dev/null
@@ -1,71 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
-# INGROUP: MokoStandards.Notifications
-# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
-# PATH: /.gitea/workflows/notify.yml
-# VERSION: 01.00.00
-# BRIEF: Push notifications via ntfy on release success or workflow failure
-
-name: Notifications
-
-on:
- workflow_run:
- workflows:
- - "Joomla Build & Release"
- - "Joomla Extension CI"
- - "Deploy"
- - "Cascade Main → Dev"
- types:
- - completed
-
-permissions:
- contents: read
-
-env:
- NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
- NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
-
-jobs:
- notify:
- name: Send Notification
- runs-on: ubuntu-latest
- if: >-
- github.event.workflow_run.conclusion == 'success' ||
- github.event.workflow_run.conclusion == 'failure'
-
- steps:
- - name: Notify on success (releases only)
- if: >-
- github.event.workflow_run.conclusion == 'success' &&
- contains(github.event.workflow_run.name, 'Release')
- run: |
- REPO="${{ github.event.repository.name }}"
- WORKFLOW="${{ github.event.workflow_run.name }}"
- URL="${{ github.event.workflow_run.html_url }}"
-
- curl -sS \
- -H "Title: ${REPO} released" \
- -H "Tags: white_check_mark,package" \
- -H "Priority: default" \
- -H "Click: ${URL}" \
- -d "${WORKFLOW} completed successfully." \
- "${NTFY_URL}/${NTFY_TOPIC}"
-
- - name: Notify on failure
- if: github.event.workflow_run.conclusion == 'failure'
- run: |
- REPO="${{ github.event.repository.name }}"
- WORKFLOW="${{ github.event.workflow_run.name }}"
- URL="${{ github.event.workflow_run.html_url }}"
-
- curl -sS \
- -H "Title: ${REPO} workflow failed" \
- -H "Tags: x,warning" \
- -H "Priority: high" \
- -H "Click: ${URL}" \
- -d "${WORKFLOW} failed. Check the run for details." \
- "${NTFY_URL}/${NTFY_TOPIC}"
--
2.52.0
From 275c438e08a01a9e202b385fa0b36f6d7cc2edf5 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:08 +0000
Subject: [PATCH 054/114] chore: move .gitea/workflows/pr-branch-check.yml to
.mokogitea/pr-branch-check.yml [skip ci]
---
.mokogitea/pr-branch-check.yml | 90 ++++++++++++++++++++++++++++++++++
1 file changed, 90 insertions(+)
create mode 100644 .mokogitea/pr-branch-check.yml
diff --git a/.mokogitea/pr-branch-check.yml b/.mokogitea/pr-branch-check.yml
new file mode 100644
index 0000000..b8d9742
--- /dev/null
+++ b/.mokogitea/pr-branch-check.yml
@@ -0,0 +1,90 @@
+# Copyright (C) 2026 Moko Consulting
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Enforces branch merge policy:
+# feature/* → dev only
+# fix/* → dev only
+# hotfix/* → dev or main (emergency)
+# dev → main only
+# alpha/* → dev only
+# beta/* → dev only
+# rc/* → main only
+
+name: Branch Policy Check
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited]
+
+jobs:
+ check-target:
+ name: Verify merge target
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check branch policy
+ run: |
+ HEAD="${{ github.head_ref }}"
+ BASE="${{ github.base_ref }}"
+
+ echo "PR: ${HEAD} → ${BASE}"
+
+ ALLOWED=true
+ REASON=""
+
+ case "$HEAD" in
+ feature/*|feat/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Feature branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ fix/*|bugfix/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Fix branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ hotfix/*)
+ if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
+ fi
+ ;;
+ alpha/*|beta/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Pre-release branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ rc/*)
+ if [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Release candidate branches must target 'main', not '${BASE}'"
+ fi
+ ;;
+ dev)
+ if [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Dev branch can only merge into 'main', not '${BASE}'"
+ fi
+ ;;
+ esac
+
+ if [ "$ALLOWED" = false ]; then
+ echo "::error::${REASON}"
+ echo ""
+ echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "${REASON}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
+ echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+
+ echo "Branch policy: OK (${HEAD} → ${BASE})"
+ echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
--
2.52.0
From 559cb2db3b8d1d905454a937b32623604280aff6 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:09 +0000
Subject: [PATCH 055/114] chore: remove .gitea/workflows/pr-branch-check.yml
(moved to .mokogitea/) [skip ci]
---
.gitea/workflows/pr-branch-check.yml | 90 ----------------------------
1 file changed, 90 deletions(-)
delete mode 100644 .gitea/workflows/pr-branch-check.yml
diff --git a/.gitea/workflows/pr-branch-check.yml b/.gitea/workflows/pr-branch-check.yml
deleted file mode 100644
index b8d9742..0000000
--- a/.gitea/workflows/pr-branch-check.yml
+++ /dev/null
@@ -1,90 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# Enforces branch merge policy:
-# feature/* → dev only
-# fix/* → dev only
-# hotfix/* → dev or main (emergency)
-# dev → main only
-# alpha/* → dev only
-# beta/* → dev only
-# rc/* → main only
-
-name: Branch Policy Check
-
-on:
- pull_request:
- types: [opened, synchronize, reopened, edited]
-
-jobs:
- check-target:
- name: Verify merge target
- runs-on: ubuntu-latest
- steps:
- - name: Check branch policy
- run: |
- HEAD="${{ github.head_ref }}"
- BASE="${{ github.base_ref }}"
-
- echo "PR: ${HEAD} → ${BASE}"
-
- ALLOWED=true
- REASON=""
-
- case "$HEAD" in
- feature/*|feat/*)
- if [ "$BASE" != "dev" ]; then
- ALLOWED=false
- REASON="Feature branches must target 'dev', not '${BASE}'"
- fi
- ;;
- fix/*|bugfix/*)
- if [ "$BASE" != "dev" ]; then
- ALLOWED=false
- REASON="Fix branches must target 'dev', not '${BASE}'"
- fi
- ;;
- hotfix/*)
- if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
- ALLOWED=false
- REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
- fi
- ;;
- alpha/*|beta/*)
- if [ "$BASE" != "dev" ]; then
- ALLOWED=false
- REASON="Pre-release branches must target 'dev', not '${BASE}'"
- fi
- ;;
- rc/*)
- if [ "$BASE" != "main" ]; then
- ALLOWED=false
- REASON="Release candidate branches must target 'main', not '${BASE}'"
- fi
- ;;
- dev)
- if [ "$BASE" != "main" ]; then
- ALLOWED=false
- REASON="Dev branch can only merge into 'main', not '${BASE}'"
- fi
- ;;
- esac
-
- if [ "$ALLOWED" = false ]; then
- echo "::error::${REASON}"
- echo ""
- echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "${REASON}" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
- echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
- exit 1
- fi
-
- echo "Branch policy: OK (${HEAD} → ${BASE})"
- echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
--
2.52.0
From d746f492fcfcc2b3b0cc29eee7f65ae9be203d83 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:09 +0000
Subject: [PATCH 056/114] chore: move .gitea/workflows/pr-check.yml to
.mokogitea/pr-check.yml [skip ci]
---
.mokogitea/pr-check.yml | 106 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
create mode 100644 .mokogitea/pr-check.yml
diff --git a/.mokogitea/pr-check.yml b/.mokogitea/pr-check.yml
new file mode 100644
index 0000000..0220500
--- /dev/null
+++ b/.mokogitea/pr-check.yml
@@ -0,0 +1,106 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.CI
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
+# PATH: /.gitea/workflows/pr-check.yml
+# VERSION: 01.00.00
+# BRIEF: PR gate — validates code quality and manifest before merge to main
+
+name: PR Check
+
+on:
+ pull_request:
+ branches:
+ - main
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ validate:
+ name: Validate PR
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ run: |
+ if ! command -v php &> /dev/null; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
+ fi
+
+ - name: PHP syntax check
+ run: |
+ echo "=== PHP Lint ==="
+ ERRORS=0
+ while IFS= read -r -d '' file; do
+ if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
+ ERRORS=$((ERRORS + 1))
+ fi
+ done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
+ echo "Checked files, errors: ${ERRORS}"
+ [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
+
+ - name: Validate Joomla manifest
+ run: |
+ echo "=== Manifest Validation ==="
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
+ if [ -z "$MANIFEST" ]; then
+ echo "::warning::No Joomla manifest found"
+ exit 0
+ fi
+ echo "Manifest: ${MANIFEST}"
+
+ # Check well-formed XML
+ if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}"; then
+ echo "::error::Manifest XML is malformed"
+ exit 1
+ fi
+
+ # Check required elements
+ for ELEMENT in name version description; do
+ if ! grep -q "<${ELEMENT}>" "$MANIFEST"; then
+ echo "::error::Missing <${ELEMENT}> in manifest"
+ exit 1
+ fi
+ done
+ echo "Manifest valid"
+
+ - name: Check updates.xml format
+ run: |
+ if [ ! -f "updates.xml" ]; then
+ echo "No updates.xml — skipping"
+ exit 0
+ fi
+ echo "=== updates.xml Validation ==="
+ if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}"; then
+ echo "::error::updates.xml is malformed"
+ exit 1
+ fi
+ echo "updates.xml valid"
+
+ - name: Verify package builds
+ run: |
+ echo "=== Package Build Test ==="
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ if [ ! -d "$SOURCE_DIR" ]; then
+ echo "::warning::No src/ or htdocs/ directory"
+ exit 0
+ fi
+ # Dry-run: ensure zip would succeed
+ FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
+ echo "Source contains ${FILE_COUNT} files — package will build"
+ [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
--
2.52.0
From 3eff7b73fdf2dfed9e6243cfda3f23b6da427b4e Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:10 +0000
Subject: [PATCH 057/114] chore: remove .gitea/workflows/pr-check.yml (moved to
.mokogitea/) [skip ci]
---
.gitea/workflows/pr-check.yml | 106 ----------------------------------
1 file changed, 106 deletions(-)
delete mode 100644 .gitea/workflows/pr-check.yml
diff --git a/.gitea/workflows/pr-check.yml b/.gitea/workflows/pr-check.yml
deleted file mode 100644
index 0220500..0000000
--- a/.gitea/workflows/pr-check.yml
+++ /dev/null
@@ -1,106 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
-# INGROUP: MokoStandards.CI
-# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
-# PATH: /.gitea/workflows/pr-check.yml
-# VERSION: 01.00.00
-# BRIEF: PR gate — validates code quality and manifest before merge to main
-
-name: PR Check
-
-on:
- pull_request:
- branches:
- - main
- types: [opened, synchronize, reopened]
-
-permissions:
- contents: read
- pull-requests: write
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-jobs:
- validate:
- name: Validate PR
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup PHP
- run: |
- if ! command -v php &> /dev/null; then
- sudo apt-get update -qq
- sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
- fi
-
- - name: PHP syntax check
- run: |
- echo "=== PHP Lint ==="
- ERRORS=0
- while IFS= read -r -d '' file; do
- if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
- ERRORS=$((ERRORS + 1))
- fi
- done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
- echo "Checked files, errors: ${ERRORS}"
- [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
-
- - name: Validate Joomla manifest
- run: |
- echo "=== Manifest Validation ==="
- MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
- if [ -z "$MANIFEST" ]; then
- echo "::warning::No Joomla manifest found"
- exit 0
- fi
- echo "Manifest: ${MANIFEST}"
-
- # Check well-formed XML
- if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}"; then
- echo "::error::Manifest XML is malformed"
- exit 1
- fi
-
- # Check required elements
- for ELEMENT in name version description; do
- if ! grep -q "<${ELEMENT}>" "$MANIFEST"; then
- echo "::error::Missing <${ELEMENT}> in manifest"
- exit 1
- fi
- done
- echo "Manifest valid"
-
- - name: Check updates.xml format
- run: |
- if [ ! -f "updates.xml" ]; then
- echo "No updates.xml — skipping"
- exit 0
- fi
- echo "=== updates.xml Validation ==="
- if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}"; then
- echo "::error::updates.xml is malformed"
- exit 1
- fi
- echo "updates.xml valid"
-
- - name: Verify package builds
- run: |
- echo "=== Package Build Test ==="
- SOURCE_DIR="src"
- [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
- if [ ! -d "$SOURCE_DIR" ]; then
- echo "::warning::No src/ or htdocs/ directory"
- exit 0
- fi
- # Dry-run: ensure zip would succeed
- FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
- echo "Source contains ${FILE_COUNT} files — package will build"
- [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
--
2.52.0
From 3eff02bf20dc7a43dcb244ecc7f07634cec4fccb Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:10 +0000
Subject: [PATCH 058/114] chore: move .gitea/workflows/pre-release.yml to
.mokogitea/pre-release.yml [skip ci]
---
.mokogitea/pre-release.yml | 341 +++++++++++++++++++++++++++++++++++++
1 file changed, 341 insertions(+)
create mode 100644 .mokogitea/pre-release.yml
diff --git a/.mokogitea/pre-release.yml b/.mokogitea/pre-release.yml
new file mode 100644
index 0000000..30c9bcf
--- /dev/null
+++ b/.mokogitea/pre-release.yml
@@ -0,0 +1,341 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Release
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
+# PATH: /.gitea/workflows/pre-release.yml
+# VERSION: 01.00.00
+# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
+
+name: Pre-Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ stability:
+ description: 'Pre-release channel'
+ required: true
+ type: choice
+ options:
+ - development
+ - alpha
+ - beta
+ - release-candidate
+
+permissions:
+ contents: write
+
+env:
+ 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 }}
+
+jobs:
+ build:
+ name: "Build Pre-Release (${{ inputs.stability }})"
+ runs-on: release
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GA_TOKEN }}
+
+ - name: Setup PHP
+ run: |
+ if ! command -v php &> /dev/null; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
+ fi
+
+ - name: Resolve metadata
+ id: meta
+ run: |
+ STABILITY="${{ inputs.stability }}"
+
+ case "$STABILITY" in
+ development) SUFFIX="-dev"; TAG="development" ;;
+ alpha) SUFFIX="-alpha"; TAG="alpha" ;;
+ beta) SUFFIX="-beta"; TAG="beta" ;;
+ release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
+ esac
+
+ # Read and bump patch version (with rollover)
+ CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
+ [ -z "$CURRENT" ] && CURRENT="00.00.00"
+
+ MAJOR=$(echo "$CURRENT" | cut -d. -f1)
+ MINOR=$(echo "$CURRENT" | cut -d. -f2)
+ PATCH=$(echo "$CURRENT" | cut -d. -f3)
+
+ # Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
+ NEW_PATCH=$((10#$PATCH + 1))
+ NEW_MINOR=$((10#$MINOR))
+ NEW_MAJOR=$((10#$MAJOR))
+
+ if [ $NEW_PATCH -gt 99 ]; then
+ NEW_PATCH=0
+ NEW_MINOR=$((NEW_MINOR + 1))
+ fi
+ if [ $NEW_MINOR -gt 99 ]; then
+ NEW_MINOR=0
+ NEW_MAJOR=$((NEW_MAJOR + 1))
+ fi
+
+ VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
+ TODAY=$(date +%Y-%m-%d)
+
+ echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
+
+ # Update README.md
+ sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
+
+ # Update manifest
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
+ if [ -n "$MANIFEST" ]; then
+ MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
+ sed -i "s|${MANIFEST_VER} |${VERSION} |" "$MANIFEST"
+ sed -i "s|[^<]* |${TODAY} |" "$MANIFEST"
+ fi
+
+ # Commit version bump
+ git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "gitea-actions[bot]"
+ git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git diff --cached --quiet || {
+ git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
+ git push origin HEAD 2>&1
+ }
+
+ # Auto-detect element from manifest
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
+ EXT_ELEMENT=""
+ if [ -n "$MANIFEST" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ case "$EXT_ELEMENT" in
+ templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
+ esac
+ fi
+ else
+ EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+
+ ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
+
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
+ echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
+ echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
+ echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
+
+ echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
+
+ - name: Build package
+ run: |
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ if [ ! -d "$SOURCE_DIR" ]; then
+ echo "::error::No src/ or htdocs/ directory"
+ exit 1
+ fi
+
+ mkdir -p build/package
+ rsync -a \
+ --exclude='sftp-config*' \
+ --exclude='.ftpignore' \
+ --exclude='*.ppk' \
+ --exclude='*.pem' \
+ --exclude='*.key' \
+ --exclude='.env*' \
+ --exclude='*.local' \
+ --exclude='.build-trigger' \
+ "${SOURCE_DIR}/" build/package/
+
+ - name: Create ZIP
+ id: zip
+ run: |
+ ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
+ cd build/package
+ zip -r "../${ZIP_NAME}" .
+ cd ..
+
+ SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
+ echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
+ echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
+
+ - name: Create or replace Gitea release
+ id: release
+ run: |
+ TAG="${{ steps.meta.outputs.tag }}"
+ VERSION="${{ steps.meta.outputs.version }}"
+ STABILITY="${{ steps.meta.outputs.stability }}"
+ SHA256="${{ steps.zip.outputs.sha256 }}"
+ ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
+ EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+ API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ BRANCH=$(git branch --show-current)
+
+ BODY="## ${VERSION} ($(date +%Y-%m-%d))
+ **Channel:** ${STABILITY}
+ **SHA-256:** \`${SHA256}\`"
+
+ # Delete existing release
+ EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
+ "${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
+ if [ -n "$EXISTING_ID" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API}/releases/${EXISTING_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API}/tags/${TAG}" 2>/dev/null || true
+ fi
+
+ # Create release
+ RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/releases" \
+ -d "$(jq -n \
+ --arg tag "$TAG" \
+ --arg target "$BRANCH" \
+ --arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
+ --arg body "$BODY" \
+ '{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
+ )" | jq -r '.id')
+
+ echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
+
+ # Upload ZIP
+ curl -sS -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/octet-stream" \
+ "${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
+ --data-binary "@build/${ZIP_NAME}"
+
+ echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
+
+ - name: Update updates.xml
+ run: |
+ STABILITY="${{ steps.meta.outputs.stability }}"
+ VERSION="${{ steps.meta.outputs.version }}"
+ SHA256="${{ steps.zip.outputs.sha256 }}"
+ ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
+ TAG="${{ steps.meta.outputs.tag }}"
+ DATE=$(date +%Y-%m-%d)
+
+ if [ ! -f "updates.xml" ]; then
+ echo "No updates.xml — skipping"
+ exit 0
+ fi
+
+ export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
+ PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
+ PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
+ python3 << 'PYEOF'
+ import re, os
+
+ stability = os.environ["PY_STABILITY"]
+ version = os.environ["PY_VERSION"]
+ sha256 = os.environ["PY_SHA256"]
+ zip_name = os.environ["PY_ZIP_NAME"]
+ tag = os.environ["PY_TAG"]
+ date = os.environ["PY_DATE"]
+ gitea_org = os.environ["PY_GITEA_ORG"]
+ gitea_repo = os.environ["PY_GITEA_REPO"]
+ download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
+
+ with open("updates.xml", "r") as f:
+ content = f.read()
+
+ # Map stability to XML tag name
+ tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
+ xml_tag = tag_map.get(stability, stability)
+
+ pattern = r"((?:(?! ).)*?" + re.escape(xml_tag) + r" .*?)"
+ match = re.search(pattern, content, re.DOTALL)
+ if match:
+ block = match.group(1)
+ updated = re.sub(r"[^<]* ", f"{version} ", block)
+ updated = re.sub(r"[^<]* ", f"{date} ", updated)
+ if "" in updated:
+ updated = re.sub(r"[^<]* ", f"{sha256} ", updated)
+ else:
+ updated = updated.replace("", f"\n {sha256} ")
+ updated = re.sub(r"(]*>)[^<]*( )", rf"\g<1>{download_url}\g<2>", updated)
+ content = content.replace(block, updated)
+ print(f"Updated {xml_tag} channel: version={version}")
+ else:
+ print(f"WARNING: No {xml_tag} block in updates.xml")
+
+ with open("updates.xml", "w") as f:
+ f.write(content)
+ PYEOF
+
+ # 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]"
+ git add updates.xml
+ git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
+ git push origin HEAD 2>&1 || echo "WARNING: push failed"
+ fi
+
+ - name: "Sync updates.xml to all branches"
+ run: |
+ CURRENT_BRANCH="${{ github.ref_name }}"
+ 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
+
+ echo "Syncing updates.xml → ${BRANCH}"
+ git fetch origin "${BRANCH}" 2>/dev/null || continue
+ git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
+ git checkout "${CURRENT_BRANCH}" -- updates.xml
+ if ! git diff --quiet updates.xml 2>/dev/null; then
+ git add updates.xml
+ git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
+ git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
+ fi
+ git checkout "${CURRENT_BRANCH}" 2>/dev/null
+ done
+
+ - name: "Delete lesser pre-release channels (cascade)"
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+ STABILITY="${{ steps.meta.outputs.stability }}"
+
+ # Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
+ case "$STABILITY" in
+ release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
+ beta) TAGS_TO_DELETE="alpha development" ;;
+ alpha) TAGS_TO_DELETE="development" ;;
+ *) TAGS_TO_DELETE="" ;;
+ esac
+
+ [ -z "$TAGS_TO_DELETE" ] && exit 0
+
+ for TAG in $TAGS_TO_DELETE; do
+ RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/tags/${TAG}" 2>/dev/null || true
+ echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
+ fi
+ done
--
2.52.0
From 101401a05da51c9b2c7f517c53310548330efd72 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:10 +0000
Subject: [PATCH 059/114] chore: remove .gitea/workflows/pre-release.yml (moved
to .mokogitea/) [skip ci]
---
.gitea/workflows/pre-release.yml | 341 -------------------------------
1 file changed, 341 deletions(-)
delete mode 100644 .gitea/workflows/pre-release.yml
diff --git a/.gitea/workflows/pre-release.yml b/.gitea/workflows/pre-release.yml
deleted file mode 100644
index 30c9bcf..0000000
--- a/.gitea/workflows/pre-release.yml
+++ /dev/null
@@ -1,341 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
-# INGROUP: MokoStandards.Release
-# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
-# PATH: /.gitea/workflows/pre-release.yml
-# VERSION: 01.00.00
-# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
-
-name: Pre-Release
-
-on:
- workflow_dispatch:
- inputs:
- stability:
- description: 'Pre-release channel'
- required: true
- type: choice
- options:
- - development
- - alpha
- - beta
- - release-candidate
-
-permissions:
- contents: write
-
-env:
- 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 }}
-
-jobs:
- build:
- name: "Build Pre-Release (${{ inputs.stability }})"
- runs-on: release
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- token: ${{ secrets.GA_TOKEN }}
-
- - name: Setup PHP
- run: |
- if ! command -v php &> /dev/null; then
- sudo apt-get update -qq
- sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
- fi
-
- - name: Resolve metadata
- id: meta
- run: |
- STABILITY="${{ inputs.stability }}"
-
- case "$STABILITY" in
- development) SUFFIX="-dev"; TAG="development" ;;
- alpha) SUFFIX="-alpha"; TAG="alpha" ;;
- beta) SUFFIX="-beta"; TAG="beta" ;;
- release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
- esac
-
- # Read and bump patch version (with rollover)
- CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
- [ -z "$CURRENT" ] && CURRENT="00.00.00"
-
- MAJOR=$(echo "$CURRENT" | cut -d. -f1)
- MINOR=$(echo "$CURRENT" | cut -d. -f2)
- PATCH=$(echo "$CURRENT" | cut -d. -f3)
-
- # Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
- NEW_PATCH=$((10#$PATCH + 1))
- NEW_MINOR=$((10#$MINOR))
- NEW_MAJOR=$((10#$MAJOR))
-
- if [ $NEW_PATCH -gt 99 ]; then
- NEW_PATCH=0
- NEW_MINOR=$((NEW_MINOR + 1))
- fi
- if [ $NEW_MINOR -gt 99 ]; then
- NEW_MINOR=0
- NEW_MAJOR=$((NEW_MAJOR + 1))
- fi
-
- VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
- TODAY=$(date +%Y-%m-%d)
-
- echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
-
- # Update README.md
- sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
-
- # Update manifest
- MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
- if [ -n "$MANIFEST" ]; then
- MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
- sed -i "s|${MANIFEST_VER} |${VERSION} |" "$MANIFEST"
- sed -i "s|[^<]* |${TODAY} |" "$MANIFEST"
- fi
-
- # Commit version bump
- git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
- git config --local user.name "gitea-actions[bot]"
- git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- git add -A
- git diff --cached --quiet || {
- git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
- git push origin HEAD 2>&1
- }
-
- # Auto-detect element from manifest
- MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
- EXT_ELEMENT=""
- if [ -n "$MANIFEST" ]; then
- EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
- if [ -z "$EXT_ELEMENT" ]; then
- EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
- case "$EXT_ELEMENT" in
- templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
- esac
- fi
- else
- EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
- fi
-
- ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
-
- echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
- echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
- echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
- echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
- echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
-
- echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
-
- - name: Build package
- run: |
- SOURCE_DIR="src"
- [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
- if [ ! -d "$SOURCE_DIR" ]; then
- echo "::error::No src/ or htdocs/ directory"
- exit 1
- fi
-
- mkdir -p build/package
- rsync -a \
- --exclude='sftp-config*' \
- --exclude='.ftpignore' \
- --exclude='*.ppk' \
- --exclude='*.pem' \
- --exclude='*.key' \
- --exclude='.env*' \
- --exclude='*.local' \
- --exclude='.build-trigger' \
- "${SOURCE_DIR}/" build/package/
-
- - name: Create ZIP
- id: zip
- run: |
- ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
- cd build/package
- zip -r "../${ZIP_NAME}" .
- cd ..
-
- SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
- echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
- echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
-
- - name: Create or replace Gitea release
- id: release
- run: |
- TAG="${{ steps.meta.outputs.tag }}"
- VERSION="${{ steps.meta.outputs.version }}"
- STABILITY="${{ steps.meta.outputs.stability }}"
- SHA256="${{ steps.zip.outputs.sha256 }}"
- ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
- EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
- TOKEN="${{ secrets.GA_TOKEN }}"
- API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
- BRANCH=$(git branch --show-current)
-
- BODY="## ${VERSION} ($(date +%Y-%m-%d))
- **Channel:** ${STABILITY}
- **SHA-256:** \`${SHA256}\`"
-
- # Delete existing release
- EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
- "${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
- if [ -n "$EXISTING_ID" ]; then
- curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
- "${API}/releases/${EXISTING_ID}" 2>/dev/null || true
- curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
- "${API}/tags/${TAG}" 2>/dev/null || true
- fi
-
- # Create release
- RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
- -H "Content-Type: application/json" \
- "${API}/releases" \
- -d "$(jq -n \
- --arg tag "$TAG" \
- --arg target "$BRANCH" \
- --arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
- --arg body "$BODY" \
- '{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
- )" | jq -r '.id')
-
- echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
-
- # Upload ZIP
- curl -sS -X POST -H "Authorization: token ${TOKEN}" \
- -H "Content-Type: application/octet-stream" \
- "${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
- --data-binary "@build/${ZIP_NAME}"
-
- echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
-
- - name: Update updates.xml
- run: |
- STABILITY="${{ steps.meta.outputs.stability }}"
- VERSION="${{ steps.meta.outputs.version }}"
- SHA256="${{ steps.zip.outputs.sha256 }}"
- ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
- TAG="${{ steps.meta.outputs.tag }}"
- DATE=$(date +%Y-%m-%d)
-
- if [ ! -f "updates.xml" ]; then
- echo "No updates.xml — skipping"
- exit 0
- fi
-
- export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
- PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
- PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
- python3 << 'PYEOF'
- import re, os
-
- stability = os.environ["PY_STABILITY"]
- version = os.environ["PY_VERSION"]
- sha256 = os.environ["PY_SHA256"]
- zip_name = os.environ["PY_ZIP_NAME"]
- tag = os.environ["PY_TAG"]
- date = os.environ["PY_DATE"]
- gitea_org = os.environ["PY_GITEA_ORG"]
- gitea_repo = os.environ["PY_GITEA_REPO"]
- download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
-
- with open("updates.xml", "r") as f:
- content = f.read()
-
- # Map stability to XML tag name
- tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
- xml_tag = tag_map.get(stability, stability)
-
- pattern = r"((?:(?! ).)*?" + re.escape(xml_tag) + r" .*?)"
- match = re.search(pattern, content, re.DOTALL)
- if match:
- block = match.group(1)
- updated = re.sub(r"[^<]* ", f"{version} ", block)
- updated = re.sub(r"[^<]* ", f"{date} ", updated)
- if "" in updated:
- updated = re.sub(r"[^<]* ", f"{sha256} ", updated)
- else:
- updated = updated.replace("", f"\n {sha256} ")
- updated = re.sub(r"(]*>)[^<]*( )", rf"\g<1>{download_url}\g<2>", updated)
- content = content.replace(block, updated)
- print(f"Updated {xml_tag} channel: version={version}")
- else:
- print(f"WARNING: No {xml_tag} block in updates.xml")
-
- with open("updates.xml", "w") as f:
- f.write(content)
- PYEOF
-
- # 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]"
- git add updates.xml
- git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
- git push origin HEAD 2>&1 || echo "WARNING: push failed"
- fi
-
- - name: "Sync updates.xml to all branches"
- run: |
- CURRENT_BRANCH="${{ github.ref_name }}"
- 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
-
- echo "Syncing updates.xml → ${BRANCH}"
- git fetch origin "${BRANCH}" 2>/dev/null || continue
- git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
- git checkout "${CURRENT_BRANCH}" -- updates.xml
- if ! git diff --quiet updates.xml 2>/dev/null; then
- git add updates.xml
- git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
- git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
- fi
- git checkout "${CURRENT_BRANCH}" 2>/dev/null
- done
-
- - name: "Delete lesser pre-release channels (cascade)"
- continue-on-error: true
- run: |
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
- TOKEN="${{ secrets.GA_TOKEN }}"
- STABILITY="${{ steps.meta.outputs.stability }}"
-
- # Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
- case "$STABILITY" in
- release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
- beta) TAGS_TO_DELETE="alpha development" ;;
- alpha) TAGS_TO_DELETE="development" ;;
- *) TAGS_TO_DELETE="" ;;
- esac
-
- [ -z "$TAGS_TO_DELETE" ] && exit 0
-
- for TAG in $TAGS_TO_DELETE; do
- RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
- "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
- python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
-
- if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
- curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
- "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
- curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
- "${API_BASE}/tags/${TAG}" 2>/dev/null || true
- echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
- fi
- done
--
2.52.0
From 2de9a19a194c6a4e783c0e3185c11e78db503728 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:11 +0000
Subject: [PATCH 060/114] chore: move .gitea/workflows/repo-health.yml to
.mokogitea/repo-health.yml [skip ci]
---
.mokogitea/repo-health.yml | 766 +++++++++++++++++++++++++++++++++++++
1 file changed, 766 insertions(+)
create mode 100644 .mokogitea/repo-health.yml
diff --git a/.mokogitea/repo-health.yml b/.mokogitea/repo-health.yml
new file mode 100644
index 0000000..57b11ef
--- /dev/null
+++ b/.mokogitea/repo-health.yml
@@ -0,0 +1,766 @@
+# ============================================================================
+# Copyright (C) 2025 Moko Consulting
+#
+# This file is part of a Moko Consulting project.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Validation
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/joomla/repo_health.yml.template
+# VERSION: 04.06.00
+# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
+# ============================================================================
+
+name: Repo Health
+
+concurrency:
+ group: repo-health-${{ github.repository }}-${{ github.ref }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: bash
+
+on:
+ workflow_dispatch:
+ inputs:
+ profile:
+ description: 'Validation profile: all, release, scripts, or repo'
+ required: true
+ default: all
+ type: choice
+ options:
+ - all
+ - release
+ - scripts
+ - repo
+ pull_request:
+ push:
+
+permissions:
+ contents: read
+
+env:
+ # Release policy - Repository Variables Only
+ RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
+ RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
+
+ # Scripts governance policy
+ SCRIPTS_REQUIRED_DIRS:
+ SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
+
+ # Repo health policy
+ REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
+ REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
+ REPO_DISALLOWED_DIRS:
+ REPO_DISALLOWED_FILES: TODO.md,todo.md
+
+ # Extended checks toggles
+ EXTENDED_CHECKS: "true"
+
+ # File / directory variables
+ DOCS_INDEX: docs/docs-index.md
+ SCRIPT_DIR: scripts
+ WORKFLOWS_DIR: .gitea/workflows
+ SHELLCHECK_PATTERN: '*.sh'
+ SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ access_check:
+ name: Access control
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+
+ outputs:
+ allowed: ${{ steps.perm.outputs.allowed }}
+ permission: ${{ steps.perm.outputs.permission }}
+
+ steps:
+ - name: Check actor permission (admin only)
+ id: perm
+ env:
+ TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ REPO: ${{ github.repository }}
+ ACTOR: ${{ github.actor }}
+ run: |
+ set -euo pipefail
+ ALLOWED=false
+ PERMISSION=unknown
+ METHOD=""
+
+ # Hardcoded authorized users — always allowed
+ case "$ACTOR" in
+ jmiller|gitea-actions[bot])
+ ALLOWED=true
+ PERMISSION=admin
+ METHOD="hardcoded allowlist"
+ ;;
+ *)
+ # Detect platform and check permissions via API
+ API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
+ RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
+ PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
+ if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
+ ALLOWED=true
+ fi
+ METHOD="collaborator API"
+ ;;
+ esac
+
+ echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
+ echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
+
+ {
+ echo "## Access Authorization"
+ echo ""
+ echo "| Field | Value |"
+ echo "|-------|-------|"
+ echo "| **Actor** | \`${ACTOR}\` |"
+ echo "| **Repository** | \`${REPO}\` |"
+ echo "| **Permission** | \`${PERMISSION}\` |"
+ echo "| **Method** | ${METHOD} |"
+ echo "| **Authorized** | ${ALLOWED} |"
+ echo ""
+ if [ "$ALLOWED" = "true" ]; then
+ echo "${ACTOR} authorized (${METHOD})"
+ else
+ echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
+ fi
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ - name: Deny execution when not permitted
+ if: ${{ steps.perm.outputs.allowed != 'true' }}
+ run: |
+ set -euo pipefail
+ printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+
+ release_config:
+ name: Release configuration
+ needs: access_check
+ if: ${{ needs.access_check.outputs.allowed == 'true' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+
+ - name: Guardrails release vars
+ env:
+ PROFILE_RAW: ${{ github.event.inputs.profile }}
+ RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
+ DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
+ run: |
+ set -euo pipefail
+
+ profile="${PROFILE_RAW:-all}"
+ case "${profile}" in
+ all|release|scripts|repo) ;;
+ *)
+ printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ ;;
+ esac
+
+ if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
+ {
+ printf '%s\n' '### Release configuration (Repository Variables)'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' 'Status: SKIPPED'
+ printf '%s\n' 'Reason: profile excludes release validation'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 0
+ fi
+
+ IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
+ IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
+
+ missing=()
+ missing_optional=()
+
+ for k in "${required[@]}"; do
+ v="${!k:-}"
+ [ -z "${v}" ] && missing+=("${k}")
+ done
+
+ for k in "${optional[@]}"; do
+ v="${!k:-}"
+ [ -z "${v}" ] && missing_optional+=("${k}")
+ done
+
+ {
+ printf '%s\n' '### Release configuration (Repository Variables)'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' '| Variable | Status |'
+ printf '%s\n' '|---|---|'
+ printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
+ printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ if [ "${#missing_optional[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Missing optional repository variables'
+ for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ if [ "${#missing[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Missing required repository variables'
+ for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ {
+ printf '%s\n' '### Repository variables validation result'
+ printf '%s\n' 'Status: OK'
+ printf '%s\n' 'All required repository variables present.'
+ printf '%s\n' ''
+ printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ scripts_governance:
+ name: Scripts governance
+ needs: access_check
+ if: ${{ needs.access_check.outputs.allowed == 'true' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+
+ - name: Scripts folder checks
+ env:
+ PROFILE_RAW: ${{ github.event.inputs.profile }}
+ run: |
+ set -euo pipefail
+
+ profile="${PROFILE_RAW:-all}"
+ case "${profile}" in
+ all|release|scripts|repo) ;;
+ *)
+ printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ ;;
+ esac
+
+ if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
+ {
+ printf '%s\n' '### Scripts governance'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' 'Status: SKIPPED'
+ printf '%s\n' 'Reason: profile excludes scripts governance'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 0
+ fi
+
+ if [ ! -d "${SCRIPT_DIR}" ]; then
+ {
+ printf '%s\n' '### Scripts governance'
+ printf '%s\n' 'Status: OK (advisory)'
+ printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 0
+ fi
+
+ IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
+ IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
+
+ missing_dirs=()
+ unapproved_dirs=()
+
+ for d in "${required_dirs[@]}"; do
+ req="${d%/}"
+ [ ! -d "${req}" ] && missing_dirs+=("${req}/")
+ done
+
+ while IFS= read -r d; do
+ allowed=false
+ for a in "${allowed_dirs[@]}"; do
+ a_norm="${a%/}"
+ [ "${d%/}" = "${a_norm}" ] && allowed=true
+ done
+ [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
+ done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
+
+ {
+ printf '%s\n' '### Scripts governance'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' '| Area | Status | Notes |'
+ printf '%s\n' '|---|---|---|'
+
+ if [ "${#missing_dirs[@]}" -gt 0 ]; then
+ printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
+ else
+ printf '%s\n' '| Required directories | OK | All required subfolders present |'
+ fi
+
+ if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
+ printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
+ else
+ printf '%s\n' '| Directory policy | OK | No unapproved directories |'
+ fi
+
+ printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
+ printf '\n'
+
+ if [ "${#missing_dirs[@]}" -gt 0 ]; then
+ printf '%s\n' 'Missing required script directories:'
+ for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ else
+ printf '%s\n' 'Missing required script directories: none.'
+ printf '\n'
+ fi
+
+ if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
+ printf '%s\n' 'Unapproved script directories detected:'
+ for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ else
+ printf '%s\n' 'Unapproved script directories detected: none.'
+ printf '\n'
+ fi
+
+ printf '%s\n' 'Scripts governance completed in advisory mode.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ repo_health:
+ name: Repository health
+ needs: access_check
+ if: ${{ needs.access_check.outputs.allowed == 'true' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+
+ - name: Repository health checks
+ env:
+ PROFILE_RAW: ${{ github.event.inputs.profile }}
+ run: |
+ set -euo pipefail
+
+ profile="${PROFILE_RAW:-all}"
+ case "${profile}" in
+ all|release|scripts|repo) ;;
+ *)
+ printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ ;;
+ esac
+
+ if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
+ {
+ printf '%s\n' '### Repository health'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' 'Status: SKIPPED'
+ printf '%s\n' 'Reason: profile excludes repository health'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 0
+ fi
+
+ # Source directory: src/ or htdocs/ (either is valid)
+ if [ -d "src" ]; then
+ SOURCE_DIR="src"
+ elif [ -d "htdocs" ]; then
+ SOURCE_DIR="htdocs"
+ else
+ missing_required+=("src/ or htdocs/ (source directory required)")
+ fi
+
+ IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
+ IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
+ IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
+ IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
+
+ missing_required=()
+ missing_optional=()
+
+ for item in "${required_artifacts[@]}"; do
+ if printf '%s' "${item}" | grep -q '/$'; then
+ d="${item%/}"
+ [ ! -d "${d}" ] && missing_required+=("${item}")
+ else
+ [ ! -f "${item}" ] && missing_required+=("${item}")
+ fi
+ done
+
+ for f in "${optional_files[@]}"; do
+ if printf '%s' "${f}" | grep -q '/$'; then
+ d="${f%/}"
+ [ ! -d "${d}" ] && missing_optional+=("${f}")
+ else
+ [ ! -f "${f}" ] && missing_optional+=("${f}")
+ fi
+ done
+
+ for d in "${disallowed_dirs[@]}"; do
+ d_norm="${d%/}"
+ [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
+ done
+
+ for f in "${disallowed_files[@]}"; do
+ [ -f "${f}" ] && missing_required+=("${f} (disallowed)")
+ done
+
+ git fetch origin --prune
+
+ dev_paths=()
+ dev_branches=()
+
+ while IFS= read -r b; do
+ name="${b#origin/}"
+ if [ "${name}" = 'dev' ]; then
+ dev_branches+=("${name}")
+ else
+ dev_paths+=("${name}")
+ fi
+ done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
+
+ if [ "${#dev_paths[@]}" -eq 0 ]; then
+ missing_required+=("dev/* branch (e.g. dev/01.00.00)")
+ fi
+
+ if [ "${#dev_branches[@]}" -gt 0 ]; then
+ missing_required+=("invalid branch dev (must be dev/)")
+ fi
+
+ content_warnings=()
+
+ if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
+ content_warnings+=("CHANGELOG.md missing '# Changelog' header")
+ fi
+
+ if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
+ content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
+ fi
+
+ if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
+ content_warnings+=("LICENSE does not look like a GPL text")
+ fi
+
+ if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
+ content_warnings+=("README.md missing expected brand keyword")
+ fi
+
+ export PROFILE_RAW="${profile}"
+ export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
+ export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
+ export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
+
+ report_json="$(python3 - <<'PY'
+ import json
+ import os
+
+ profile = os.environ.get('PROFILE_RAW') or 'all'
+
+ missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
+ missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
+ content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
+
+ out = {
+ 'profile': profile,
+ 'missing_required': [x for x in missing_required if x],
+ 'missing_optional': [x for x in missing_optional if x],
+ 'content_warnings': [x for x in content_warnings if x],
+ }
+
+ print(json.dumps(out, indent=2))
+ PY
+ )"
+
+ {
+ printf '%s\n' '### Repository health'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' '| Metric | Value |'
+ printf '%s\n' '|---|---|'
+ printf '%s\n' "| Missing required | ${#missing_required[@]} |"
+ printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
+ printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
+ printf '\n'
+
+ printf '%s\n' '### Guardrails report (JSON)'
+ printf '%s\n' '```json'
+ printf '%s\n' "${report_json}"
+ printf '%s\n' '```'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ if [ "${#missing_required[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Missing required repo artifacts'
+ for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ if [ "${#missing_optional[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Missing optional repo artifacts'
+ for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ if [ "${#content_warnings[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Repo content warnings'
+ for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ # -- Joomla-specific checks --
+ joomla_findings=()
+
+ MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/dev/null | head -1 || true)"
+ if [ -z "${MANIFEST}" ]; then
+ joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)")
+ else
+ if ! grep -qP '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: tag missing")
+ fi
+ if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: type attribute missing or invalid")
+ fi
+ if ! grep -qP '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: tag missing")
+ fi
+ if ! grep -qP '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: tag missing")
+ fi
+ if ! grep -qP ' missing (required for Joomla 5+)")
+ fi
+ fi
+
+ INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
+ if [ "${INI_COUNT}" -eq 0 ]; then
+ joomla_findings+=("No .ini language files found")
+ fi
+
+ if [ ! -f 'updates.xml' ]; then
+ joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
+ fi
+
+ INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
+ for dir in "${INDEX_DIRS[@]}"; do
+ if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
+ joomla_findings+=("${dir}/index.html missing (directory listing protection)")
+ fi
+ done
+
+ if [ "${#joomla_findings[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Joomla extension checks'
+ printf '%s\n' '| Check | Status |'
+ printf '%s\n' '|---|---|'
+ for f in "${joomla_findings[@]}"; do
+ printf '%s\n' "| ${f} | Warning |"
+ done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ else
+ {
+ printf '%s\n' '### Joomla extension checks'
+ printf '%s\n' 'All Joomla-specific checks passed.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ extended_enabled="${EXTENDED_CHECKS:-true}"
+ extended_findings=()
+
+ if [ "${extended_enabled}" = 'true' ]; then
+ if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
+ :
+ else
+ extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
+ fi
+
+ if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
+ bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
+ if [ -n "${bad_refs}" ]; then
+ extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
+ {
+ printf '%s\n' '### Workflow pinning advisory'
+ printf '%s\n' 'Found uses: entries pinned to main/master:'
+ printf '%s\n' '```'
+ printf '%s\n' "${bad_refs}"
+ printf '%s\n' '```'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+ fi
+
+ if [ -f "${DOCS_INDEX}" ]; then
+ missing_links="$(python3 - <<'PY'
+ import os
+ import re
+
+ idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
+ base = os.getcwd()
+
+ bad = []
+ pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
+
+ with open(idx, 'r', encoding='utf-8') as f:
+ for line in f:
+ for m in pat.findall(line):
+ link = m.strip()
+ if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
+ continue
+ if link.startswith('/'):
+ rel = link.lstrip('/')
+ else:
+ rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
+ rel = rel.split('#', 1)[0]
+ rel = rel.split('?', 1)[0]
+ if not rel:
+ continue
+ p = os.path.join(base, rel)
+ if not os.path.exists(p):
+ bad.append(rel)
+
+ print('\n'.join(sorted(set(bad))))
+ PY
+ )"
+ if [ -n "${missing_links}" ]; then
+ extended_findings+=("docs/docs-index.md contains broken relative links")
+ {
+ printf '%s\n' '### Docs index link integrity'
+ printf '%s\n' 'Broken relative links:'
+ while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+ fi
+
+ if [ -d "${SCRIPT_DIR}" ]; then
+ if ! command -v shellcheck >/dev/null 2>&1; then
+ sudo apt-get update -qq
+ sudo apt-get install -y shellcheck >/dev/null
+ fi
+
+ sc_out=''
+ while IFS= read -r shf; do
+ [ -z "${shf}" ] && continue
+ out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
+ if [ -n "${out_one}" ]; then
+ sc_out="${sc_out}${out_one}\n"
+ fi
+ done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
+
+ if [ -n "${sc_out}" ]; then
+ extended_findings+=("ShellCheck warnings detected (advisory)")
+ sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
+ {
+ printf '%s\n' '### ShellCheck (advisory)'
+ printf '%s\n' '```'
+ printf '%s\n' "${sc_head}"
+ printf '%s\n' '```'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+ fi
+
+ spdx_missing=()
+ IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
+ spdx_args=()
+ for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
+
+ while IFS= read -r f; do
+ [ -z "${f}" ] && continue
+ if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
+ spdx_missing+=("${f}")
+ fi
+ done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
+
+ if [ "${#spdx_missing[@]}" -gt 0 ]; then
+ extended_findings+=("SPDX header missing in some tracked files (advisory)")
+ {
+ printf '%s\n' '### SPDX header advisory'
+ printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
+ for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ stale_cutoff_days=180
+ stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
+ if [ -n "${stale_branches}" ]; then
+ extended_findings+=("Stale remote branches detected (advisory)")
+ {
+ printf '%s\n' '### Git hygiene advisory'
+ printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
+ while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+ fi
+
+ {
+ printf '%s\n' '### Guardrails coverage matrix'
+ printf '%s\n' '| Domain | Status | Notes |'
+ printf '%s\n' '|---|---|---|'
+ printf '%s\n' '| Access control | OK | Admin-only execution gate |'
+ printf '%s\n' '| Release variables | OK | Repository variables validation |'
+ printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
+ printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
+ printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
+ if [ "${extended_enabled}" = 'true' ]; then
+ if [ "${#extended_findings[@]}" -gt 0 ]; then
+ printf '%s\n' '| Extended checks | Warning | See extended findings below |'
+ else
+ printf '%s\n' '| Extended checks | OK | No findings |'
+ fi
+ else
+ printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
+ fi
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Extended findings (advisory)'
+ for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
--
2.52.0
From 1a5c68720c1b41c1fbef271022e9e3437865aa5d Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:11 +0000
Subject: [PATCH 061/114] chore: remove .gitea/workflows/repo-health.yml (moved
to .mokogitea/) [skip ci]
---
.gitea/workflows/repo-health.yml | 766 -------------------------------
1 file changed, 766 deletions(-)
delete mode 100644 .gitea/workflows/repo-health.yml
diff --git a/.gitea/workflows/repo-health.yml b/.gitea/workflows/repo-health.yml
deleted file mode 100644
index 57b11ef..0000000
--- a/.gitea/workflows/repo-health.yml
+++ /dev/null
@@ -1,766 +0,0 @@
-# ============================================================================
-# Copyright (C) 2025 Moko Consulting
-#
-# This file is part of a Moko Consulting project.
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
-# INGROUP: MokoStandards.Validation
-# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
-# PATH: /templates/workflows/joomla/repo_health.yml.template
-# VERSION: 04.06.00
-# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
-# ============================================================================
-
-name: Repo Health
-
-concurrency:
- group: repo-health-${{ github.repository }}-${{ github.ref }}
- cancel-in-progress: true
-
-defaults:
- run:
- shell: bash
-
-on:
- workflow_dispatch:
- inputs:
- profile:
- description: 'Validation profile: all, release, scripts, or repo'
- required: true
- default: all
- type: choice
- options:
- - all
- - release
- - scripts
- - repo
- pull_request:
- push:
-
-permissions:
- contents: read
-
-env:
- # Release policy - Repository Variables Only
- RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
- RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
-
- # Scripts governance policy
- SCRIPTS_REQUIRED_DIRS:
- SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
-
- # Repo health policy
- REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
- REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
- REPO_DISALLOWED_DIRS:
- REPO_DISALLOWED_FILES: TODO.md,todo.md
-
- # Extended checks toggles
- EXTENDED_CHECKS: "true"
-
- # File / directory variables
- DOCS_INDEX: docs/docs-index.md
- SCRIPT_DIR: scripts
- WORKFLOWS_DIR: .gitea/workflows
- SHELLCHECK_PATTERN: '*.sh'
- SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-jobs:
- access_check:
- name: Access control
- runs-on: ubuntu-latest
- timeout-minutes: 10
- permissions:
- contents: read
-
- outputs:
- allowed: ${{ steps.perm.outputs.allowed }}
- permission: ${{ steps.perm.outputs.permission }}
-
- steps:
- - name: Check actor permission (admin only)
- id: perm
- env:
- TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
- REPO: ${{ github.repository }}
- ACTOR: ${{ github.actor }}
- run: |
- set -euo pipefail
- ALLOWED=false
- PERMISSION=unknown
- METHOD=""
-
- # Hardcoded authorized users — always allowed
- case "$ACTOR" in
- jmiller|gitea-actions[bot])
- ALLOWED=true
- PERMISSION=admin
- METHOD="hardcoded allowlist"
- ;;
- *)
- # Detect platform and check permissions via API
- API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
- RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
- "${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
- PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
- if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
- ALLOWED=true
- fi
- METHOD="collaborator API"
- ;;
- esac
-
- echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
- echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
-
- {
- echo "## Access Authorization"
- echo ""
- echo "| Field | Value |"
- echo "|-------|-------|"
- echo "| **Actor** | \`${ACTOR}\` |"
- echo "| **Repository** | \`${REPO}\` |"
- echo "| **Permission** | \`${PERMISSION}\` |"
- echo "| **Method** | ${METHOD} |"
- echo "| **Authorized** | ${ALLOWED} |"
- echo ""
- if [ "$ALLOWED" = "true" ]; then
- echo "${ACTOR} authorized (${METHOD})"
- else
- echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
- fi
- } >> "${GITHUB_STEP_SUMMARY}"
-
- - name: Deny execution when not permitted
- if: ${{ steps.perm.outputs.allowed != 'true' }}
- run: |
- set -euo pipefail
- printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
- exit 1
-
- release_config:
- name: Release configuration
- needs: access_check
- if: ${{ needs.access_check.outputs.allowed == 'true' }}
- runs-on: ubuntu-latest
- timeout-minutes: 20
- permissions:
- contents: read
-
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- with:
- fetch-depth: 0
-
- - name: Guardrails release vars
- env:
- PROFILE_RAW: ${{ github.event.inputs.profile }}
- RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
- DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
- run: |
- set -euo pipefail
-
- profile="${PROFILE_RAW:-all}"
- case "${profile}" in
- all|release|scripts|repo) ;;
- *)
- printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
- exit 1
- ;;
- esac
-
- if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
- {
- printf '%s\n' '### Release configuration (Repository Variables)'
- printf '%s\n' "Profile: ${profile}"
- printf '%s\n' 'Status: SKIPPED'
- printf '%s\n' 'Reason: profile excludes release validation'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- exit 0
- fi
-
- IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
- IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
-
- missing=()
- missing_optional=()
-
- for k in "${required[@]}"; do
- v="${!k:-}"
- [ -z "${v}" ] && missing+=("${k}")
- done
-
- for k in "${optional[@]}"; do
- v="${!k:-}"
- [ -z "${v}" ] && missing_optional+=("${k}")
- done
-
- {
- printf '%s\n' '### Release configuration (Repository Variables)'
- printf '%s\n' "Profile: ${profile}"
- printf '%s\n' '| Variable | Status |'
- printf '%s\n' '|---|---|'
- printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
- printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
-
- if [ "${#missing_optional[@]}" -gt 0 ]; then
- {
- printf '%s\n' '### Missing optional repository variables'
- for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
-
- if [ "${#missing[@]}" -gt 0 ]; then
- {
- printf '%s\n' '### Missing required repository variables'
- for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
- printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
- } >> "${GITHUB_STEP_SUMMARY}"
- exit 1
- fi
-
- {
- printf '%s\n' '### Repository variables validation result'
- printf '%s\n' 'Status: OK'
- printf '%s\n' 'All required repository variables present.'
- printf '%s\n' ''
- printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
-
- scripts_governance:
- name: Scripts governance
- needs: access_check
- if: ${{ needs.access_check.outputs.allowed == 'true' }}
- runs-on: ubuntu-latest
- timeout-minutes: 15
- permissions:
- contents: read
-
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- with:
- fetch-depth: 0
-
- - name: Scripts folder checks
- env:
- PROFILE_RAW: ${{ github.event.inputs.profile }}
- run: |
- set -euo pipefail
-
- profile="${PROFILE_RAW:-all}"
- case "${profile}" in
- all|release|scripts|repo) ;;
- *)
- printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
- exit 1
- ;;
- esac
-
- if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
- {
- printf '%s\n' '### Scripts governance'
- printf '%s\n' "Profile: ${profile}"
- printf '%s\n' 'Status: SKIPPED'
- printf '%s\n' 'Reason: profile excludes scripts governance'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- exit 0
- fi
-
- if [ ! -d "${SCRIPT_DIR}" ]; then
- {
- printf '%s\n' '### Scripts governance'
- printf '%s\n' 'Status: OK (advisory)'
- printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- exit 0
- fi
-
- IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
- IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
-
- missing_dirs=()
- unapproved_dirs=()
-
- for d in "${required_dirs[@]}"; do
- req="${d%/}"
- [ ! -d "${req}" ] && missing_dirs+=("${req}/")
- done
-
- while IFS= read -r d; do
- allowed=false
- for a in "${allowed_dirs[@]}"; do
- a_norm="${a%/}"
- [ "${d%/}" = "${a_norm}" ] && allowed=true
- done
- [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
- done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
-
- {
- printf '%s\n' '### Scripts governance'
- printf '%s\n' "Profile: ${profile}"
- printf '%s\n' '| Area | Status | Notes |'
- printf '%s\n' '|---|---|---|'
-
- if [ "${#missing_dirs[@]}" -gt 0 ]; then
- printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
- else
- printf '%s\n' '| Required directories | OK | All required subfolders present |'
- fi
-
- if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
- printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
- else
- printf '%s\n' '| Directory policy | OK | No unapproved directories |'
- fi
-
- printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
- printf '\n'
-
- if [ "${#missing_dirs[@]}" -gt 0 ]; then
- printf '%s\n' 'Missing required script directories:'
- for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
- printf '\n'
- else
- printf '%s\n' 'Missing required script directories: none.'
- printf '\n'
- fi
-
- if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
- printf '%s\n' 'Unapproved script directories detected:'
- for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
- printf '\n'
- else
- printf '%s\n' 'Unapproved script directories detected: none.'
- printf '\n'
- fi
-
- printf '%s\n' 'Scripts governance completed in advisory mode.'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
-
- repo_health:
- name: Repository health
- needs: access_check
- if: ${{ needs.access_check.outputs.allowed == 'true' }}
- runs-on: ubuntu-latest
- timeout-minutes: 20
- permissions:
- contents: read
-
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- with:
- fetch-depth: 0
-
- - name: Repository health checks
- env:
- PROFILE_RAW: ${{ github.event.inputs.profile }}
- run: |
- set -euo pipefail
-
- profile="${PROFILE_RAW:-all}"
- case "${profile}" in
- all|release|scripts|repo) ;;
- *)
- printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
- exit 1
- ;;
- esac
-
- if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
- {
- printf '%s\n' '### Repository health'
- printf '%s\n' "Profile: ${profile}"
- printf '%s\n' 'Status: SKIPPED'
- printf '%s\n' 'Reason: profile excludes repository health'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- exit 0
- fi
-
- # Source directory: src/ or htdocs/ (either is valid)
- if [ -d "src" ]; then
- SOURCE_DIR="src"
- elif [ -d "htdocs" ]; then
- SOURCE_DIR="htdocs"
- else
- missing_required+=("src/ or htdocs/ (source directory required)")
- fi
-
- IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
- IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
- IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
- IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
-
- missing_required=()
- missing_optional=()
-
- for item in "${required_artifacts[@]}"; do
- if printf '%s' "${item}" | grep -q '/$'; then
- d="${item%/}"
- [ ! -d "${d}" ] && missing_required+=("${item}")
- else
- [ ! -f "${item}" ] && missing_required+=("${item}")
- fi
- done
-
- for f in "${optional_files[@]}"; do
- if printf '%s' "${f}" | grep -q '/$'; then
- d="${f%/}"
- [ ! -d "${d}" ] && missing_optional+=("${f}")
- else
- [ ! -f "${f}" ] && missing_optional+=("${f}")
- fi
- done
-
- for d in "${disallowed_dirs[@]}"; do
- d_norm="${d%/}"
- [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
- done
-
- for f in "${disallowed_files[@]}"; do
- [ -f "${f}" ] && missing_required+=("${f} (disallowed)")
- done
-
- git fetch origin --prune
-
- dev_paths=()
- dev_branches=()
-
- while IFS= read -r b; do
- name="${b#origin/}"
- if [ "${name}" = 'dev' ]; then
- dev_branches+=("${name}")
- else
- dev_paths+=("${name}")
- fi
- done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
-
- if [ "${#dev_paths[@]}" -eq 0 ]; then
- missing_required+=("dev/* branch (e.g. dev/01.00.00)")
- fi
-
- if [ "${#dev_branches[@]}" -gt 0 ]; then
- missing_required+=("invalid branch dev (must be dev/)")
- fi
-
- content_warnings=()
-
- if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
- content_warnings+=("CHANGELOG.md missing '# Changelog' header")
- fi
-
- if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
- content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
- fi
-
- if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
- content_warnings+=("LICENSE does not look like a GPL text")
- fi
-
- if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
- content_warnings+=("README.md missing expected brand keyword")
- fi
-
- export PROFILE_RAW="${profile}"
- export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
- export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
- export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
-
- report_json="$(python3 - <<'PY'
- import json
- import os
-
- profile = os.environ.get('PROFILE_RAW') or 'all'
-
- missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
- missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
- content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
-
- out = {
- 'profile': profile,
- 'missing_required': [x for x in missing_required if x],
- 'missing_optional': [x for x in missing_optional if x],
- 'content_warnings': [x for x in content_warnings if x],
- }
-
- print(json.dumps(out, indent=2))
- PY
- )"
-
- {
- printf '%s\n' '### Repository health'
- printf '%s\n' "Profile: ${profile}"
- printf '%s\n' '| Metric | Value |'
- printf '%s\n' '|---|---|'
- printf '%s\n' "| Missing required | ${#missing_required[@]} |"
- printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
- printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
- printf '\n'
-
- printf '%s\n' '### Guardrails report (JSON)'
- printf '%s\n' '```json'
- printf '%s\n' "${report_json}"
- printf '%s\n' '```'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
-
- if [ "${#missing_required[@]}" -gt 0 ]; then
- {
- printf '%s\n' '### Missing required repo artifacts'
- for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
- printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- exit 1
- fi
-
- if [ "${#missing_optional[@]}" -gt 0 ]; then
- {
- printf '%s\n' '### Missing optional repo artifacts'
- for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
-
- if [ "${#content_warnings[@]}" -gt 0 ]; then
- {
- printf '%s\n' '### Repo content warnings'
- for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
-
- # -- Joomla-specific checks --
- joomla_findings=()
-
- MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/dev/null | head -1 || true)"
- if [ -z "${MANIFEST}" ]; then
- joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)")
- else
- if ! grep -qP '' "${MANIFEST}"; then
- joomla_findings+=("XML manifest: tag missing")
- fi
- if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
- joomla_findings+=("XML manifest: type attribute missing or invalid")
- fi
- if ! grep -qP '' "${MANIFEST}"; then
- joomla_findings+=("XML manifest: tag missing")
- fi
- if ! grep -qP '' "${MANIFEST}"; then
- joomla_findings+=("XML manifest: tag missing")
- fi
- if ! grep -qP ' missing (required for Joomla 5+)")
- fi
- fi
-
- INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
- if [ "${INI_COUNT}" -eq 0 ]; then
- joomla_findings+=("No .ini language files found")
- fi
-
- if [ ! -f 'updates.xml' ]; then
- joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
- fi
-
- INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
- for dir in "${INDEX_DIRS[@]}"; do
- if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
- joomla_findings+=("${dir}/index.html missing (directory listing protection)")
- fi
- done
-
- if [ "${#joomla_findings[@]}" -gt 0 ]; then
- {
- printf '%s\n' '### Joomla extension checks'
- printf '%s\n' '| Check | Status |'
- printf '%s\n' '|---|---|'
- for f in "${joomla_findings[@]}"; do
- printf '%s\n' "| ${f} | Warning |"
- done
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- else
- {
- printf '%s\n' '### Joomla extension checks'
- printf '%s\n' 'All Joomla-specific checks passed.'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
-
- extended_enabled="${EXTENDED_CHECKS:-true}"
- extended_findings=()
-
- if [ "${extended_enabled}" = 'true' ]; then
- if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
- :
- else
- extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
- fi
-
- if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
- bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
- if [ -n "${bad_refs}" ]; then
- extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
- {
- printf '%s\n' '### Workflow pinning advisory'
- printf '%s\n' 'Found uses: entries pinned to main/master:'
- printf '%s\n' '```'
- printf '%s\n' "${bad_refs}"
- printf '%s\n' '```'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
- fi
-
- if [ -f "${DOCS_INDEX}" ]; then
- missing_links="$(python3 - <<'PY'
- import os
- import re
-
- idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
- base = os.getcwd()
-
- bad = []
- pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
-
- with open(idx, 'r', encoding='utf-8') as f:
- for line in f:
- for m in pat.findall(line):
- link = m.strip()
- if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
- continue
- if link.startswith('/'):
- rel = link.lstrip('/')
- else:
- rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
- rel = rel.split('#', 1)[0]
- rel = rel.split('?', 1)[0]
- if not rel:
- continue
- p = os.path.join(base, rel)
- if not os.path.exists(p):
- bad.append(rel)
-
- print('\n'.join(sorted(set(bad))))
- PY
- )"
- if [ -n "${missing_links}" ]; then
- extended_findings+=("docs/docs-index.md contains broken relative links")
- {
- printf '%s\n' '### Docs index link integrity'
- printf '%s\n' 'Broken relative links:'
- while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
- fi
-
- if [ -d "${SCRIPT_DIR}" ]; then
- if ! command -v shellcheck >/dev/null 2>&1; then
- sudo apt-get update -qq
- sudo apt-get install -y shellcheck >/dev/null
- fi
-
- sc_out=''
- while IFS= read -r shf; do
- [ -z "${shf}" ] && continue
- out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
- if [ -n "${out_one}" ]; then
- sc_out="${sc_out}${out_one}\n"
- fi
- done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
-
- if [ -n "${sc_out}" ]; then
- extended_findings+=("ShellCheck warnings detected (advisory)")
- sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
- {
- printf '%s\n' '### ShellCheck (advisory)'
- printf '%s\n' '```'
- printf '%s\n' "${sc_head}"
- printf '%s\n' '```'
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
- fi
-
- spdx_missing=()
- IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
- spdx_args=()
- for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
-
- while IFS= read -r f; do
- [ -z "${f}" ] && continue
- if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
- spdx_missing+=("${f}")
- fi
- done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
-
- if [ "${#spdx_missing[@]}" -gt 0 ]; then
- extended_findings+=("SPDX header missing in some tracked files (advisory)")
- {
- printf '%s\n' '### SPDX header advisory'
- printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
- for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
-
- stale_cutoff_days=180
- stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
- if [ -n "${stale_branches}" ]; then
- extended_findings+=("Stale remote branches detected (advisory)")
- {
- printf '%s\n' '### Git hygiene advisory'
- printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
- while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
- fi
-
- {
- printf '%s\n' '### Guardrails coverage matrix'
- printf '%s\n' '| Domain | Status | Notes |'
- printf '%s\n' '|---|---|---|'
- printf '%s\n' '| Access control | OK | Admin-only execution gate |'
- printf '%s\n' '| Release variables | OK | Repository variables validation |'
- printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
- printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
- printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
- if [ "${extended_enabled}" = 'true' ]; then
- if [ "${#extended_findings[@]}" -gt 0 ]; then
- printf '%s\n' '| Extended checks | Warning | See extended findings below |'
- else
- printf '%s\n' '| Extended checks | OK | No findings |'
- fi
- else
- printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
- fi
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
-
- if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
- {
- printf '%s\n' '### Extended findings (advisory)'
- for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
- printf '\n'
- } >> "${GITHUB_STEP_SUMMARY}"
- fi
-
- printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
--
2.52.0
From 95c35fc43ccc3e13605e82ba72616f0793dc46d6 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:12 +0000
Subject: [PATCH 062/114] chore: move .gitea/workflows/security-audit.yml to
.mokogitea/security-audit.yml [skip ci]
---
.mokogitea/security-audit.yml | 82 +++++++++++++++++++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 .mokogitea/security-audit.yml
diff --git a/.mokogitea/security-audit.yml b/.mokogitea/security-audit.yml
new file mode 100644
index 0000000..ff6de4c
--- /dev/null
+++ b/.mokogitea/security-audit.yml
@@ -0,0 +1,82 @@
+# 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/MokoStandards
+# PATH: /.gitea/workflows/security-audit.yml
+# VERSION: 01.00.00
+# BRIEF: Dependency vulnerability scanning for composer and npm packages
+
+name: Security Audit
+
+on:
+ schedule:
+ - cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'composer.json'
+ - 'composer.lock'
+ - 'package.json'
+ - 'package-lock.json'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
+ NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
+
+jobs:
+ audit:
+ name: Dependency Audit
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Composer audit
+ if: hashFiles('composer.lock') != ''
+ run: |
+ echo "=== Composer Security Audit ==="
+ if ! command -v composer &> /dev/null; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
+ fi
+ composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
+ RESULT=$?
+ if [ $RESULT -ne 0 ]; then
+ echo "::warning::Composer vulnerabilities found"
+ echo "composer_vulnerable=true" >> "$GITHUB_ENV"
+ else
+ echo "No known vulnerabilities in composer dependencies"
+ fi
+
+ - name: NPM audit
+ if: hashFiles('package-lock.json') != ''
+ run: |
+ echo "=== NPM Security Audit ==="
+ npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
+ if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
+ echo "No known vulnerabilities in npm dependencies"
+ else
+ echo "::warning::NPM vulnerabilities found"
+ echo "npm_vulnerable=true" >> "$GITHUB_ENV"
+ fi
+
+ - name: Notify on vulnerabilities
+ if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
+ run: |
+ REPO="${{ github.event.repository.name }}"
+ curl -sS \
+ -H "Title: ${REPO} has vulnerable dependencies" \
+ -H "Tags: lock,warning" \
+ -H "Priority: high" \
+ -d "Security audit found vulnerabilities. Review dependency updates." \
+ "${NTFY_URL}/${NTFY_TOPIC}" || true
--
2.52.0
From cabfc5c3f2bf11c65c63d49feec7bac7112299e0 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:12 +0000
Subject: [PATCH 063/114] chore: remove .gitea/workflows/security-audit.yml
(moved to .mokogitea/) [skip ci]
---
.gitea/workflows/security-audit.yml | 82 -----------------------------
1 file changed, 82 deletions(-)
delete mode 100644 .gitea/workflows/security-audit.yml
diff --git a/.gitea/workflows/security-audit.yml b/.gitea/workflows/security-audit.yml
deleted file mode 100644
index ff6de4c..0000000
--- a/.gitea/workflows/security-audit.yml
+++ /dev/null
@@ -1,82 +0,0 @@
-# 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/MokoStandards
-# PATH: /.gitea/workflows/security-audit.yml
-# VERSION: 01.00.00
-# BRIEF: Dependency vulnerability scanning for composer and npm packages
-
-name: Security Audit
-
-on:
- schedule:
- - cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
- pull_request:
- branches:
- - main
- paths:
- - 'composer.json'
- - 'composer.lock'
- - 'package.json'
- - 'package-lock.json'
- workflow_dispatch:
-
-permissions:
- contents: read
-
-env:
- NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
- NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
-
-jobs:
- audit:
- name: Dependency Audit
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Composer audit
- if: hashFiles('composer.lock') != ''
- run: |
- echo "=== Composer Security Audit ==="
- if ! command -v composer &> /dev/null; then
- sudo apt-get update -qq
- sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
- fi
- composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
- RESULT=$?
- if [ $RESULT -ne 0 ]; then
- echo "::warning::Composer vulnerabilities found"
- echo "composer_vulnerable=true" >> "$GITHUB_ENV"
- else
- echo "No known vulnerabilities in composer dependencies"
- fi
-
- - name: NPM audit
- if: hashFiles('package-lock.json') != ''
- run: |
- echo "=== NPM Security Audit ==="
- npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
- if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
- echo "No known vulnerabilities in npm dependencies"
- else
- echo "::warning::NPM vulnerabilities found"
- echo "npm_vulnerable=true" >> "$GITHUB_ENV"
- fi
-
- - name: Notify on vulnerabilities
- if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
- run: |
- REPO="${{ github.event.repository.name }}"
- curl -sS \
- -H "Title: ${REPO} has vulnerable dependencies" \
- -H "Tags: lock,warning" \
- -H "Priority: high" \
- -d "Security audit found vulnerabilities. Review dependency updates." \
- "${NTFY_URL}/${NTFY_TOPIC}" || true
--
2.52.0
From 0a3d08d538e270fca1cccd9a5d0b90c8dc9a09c9 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:12 +0000
Subject: [PATCH 064/114] chore: move .gitea/workflows/update-server.yml to
.mokogitea/update-server.yml [skip ci]
---
.mokogitea/update-server.yml | 464 +++++++++++++++++++++++++++++++++++
1 file changed, 464 insertions(+)
create mode 100644 .mokogitea/update-server.yml
diff --git a/.mokogitea/update-server.yml b/.mokogitea/update-server.yml
new file mode 100644
index 0000000..e6a1924
--- /dev/null
+++ b/.mokogitea/update-server.yml
@@ -0,0 +1,464 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Joomla
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/joomla/update-server.yml.template
+# VERSION: 04.06.00
+# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
+#
+# Writes updates.xml with multiple entries:
+# - stable on push to main (from auto-release)
+# - rc on push to rc/**
+# - development on push to dev or dev/**
+#
+# Joomla filters by user's "Minimum Stability" setting.
+
+name: Update Joomla Update Server XML Feed
+
+on:
+ push:
+ branches:
+ - 'dev'
+ - 'dev/**'
+ - 'alpha/**'
+ - 'beta/**'
+ - 'rc/**'
+ paths:
+ - 'src/**'
+ - 'htdocs/**'
+ pull_request:
+ types: [closed]
+ branches:
+ - 'dev'
+ - 'dev/**'
+ - 'alpha/**'
+ - 'beta/**'
+ - 'rc/**'
+ paths:
+ - 'src/**'
+ - 'htdocs/**'
+ workflow_dispatch:
+ inputs:
+ stability:
+ description: 'Stability tag'
+ required: true
+ default: 'development'
+ type: choice
+ options:
+ - development
+ - alpha
+ - beta
+ - rc
+ - stable
+
+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:
+ update-xml:
+ name: Update updates.xml
+ runs-on: release
+ if: >-
+ github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ token: ${{ secrets.GA_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup MokoStandards tools
+ env:
+ MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
+ MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
+ COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
+ run: |
+ if ! command -v composer &> /dev/null; then
+ sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
+ fi
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
+ /tmp/mokostandards-api 2>/dev/null || true
+ if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
+ cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
+ fi
+
+ - name: Generate updates.xml entry
+ id: update
+ run: |
+ BRANCH="${{ github.ref_name }}"
+ REPO="${{ github.repository }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
+
+ # Auto-bump patch on all branches (dev, alpha, beta, rc)
+ git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "gitea-actions[bot]"
+ BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
+ if [ -n "$BUMPED" ]; then
+ VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
+ git add -A
+ git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
+ --author="gitea-actions[bot] " 2>/dev/null || true
+ git push 2>/dev/null || true
+ fi
+
+ # Determine stability from branch or input
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ STABILITY="${{ inputs.stability }}"
+ elif [[ "$BRANCH" == rc/* ]]; then
+ STABILITY="rc"
+ elif [[ "$BRANCH" == beta/* ]]; then
+ STABILITY="beta"
+ elif [[ "$BRANCH" == alpha/* ]]; then
+ STABILITY="alpha"
+ elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
+ STABILITY="development"
+ else
+ STABILITY="stable"
+ fi
+
+ echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
+
+ # Parse manifest (portable — no grep -P)
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '/dev/null | head -1)
+ if [ -z "$MANIFEST" ]; then
+ echo "No Joomla manifest found — skipping"
+ exit 0
+ fi
+
+ # Extract fields using sed (works on all runners)
+ EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
+ EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\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)
+ EXT_VERSION=$(sed -n 's/.*\([^<]*\)<\/version>.*/\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)
+
+ # Fallbacks
+ [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
+ [ -z "$EXT_TYPE" ] && EXT_TYPE="component"
+
+ # Derive element if not in manifest: try XML filename, then repo name
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ case "$EXT_ELEMENT" in
+ templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
+ esac
+ fi
+
+ # Use manifest version if README version is empty
+ [ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
+
+ [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/")
+
+ CLIENT_TAG=""
+ [ -n "$EXT_CLIENT" ] && CLIENT_TAG="${EXT_CLIENT} "
+ [ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="site "
+
+ FOLDER_TAG=""
+ [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="${EXT_FOLDER} "
+
+ PHP_TAG=""
+ [ -n "$PHP_MINIMUM" ] && PHP_TAG="${PHP_MINIMUM} "
+
+ # Version suffix for non-stable
+ DISPLAY_VERSION="$VERSION"
+ case "$STABILITY" in
+ development) DISPLAY_VERSION="${VERSION}-dev" ;;
+ alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
+ beta) DISPLAY_VERSION="${VERSION}-beta" ;;
+ rc) DISPLAY_VERSION="${VERSION}-rc" ;;
+ esac
+
+ MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
+
+ # Each stability level has its own release tag
+ case "$STABILITY" in
+ development) RELEASE_TAG="development" ;;
+ alpha) RELEASE_TAG="alpha" ;;
+ beta) RELEASE_TAG="beta" ;;
+ rc) RELEASE_TAG="release-candidate" ;;
+ *) RELEASE_TAG="v${MAJOR}" ;;
+ esac
+
+ PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
+ DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
+ INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
+
+ # -- Build install packages (ZIP + tar.gz) --------------------
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ if [ -d "$SOURCE_DIR" ]; then
+ EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
+ TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
+
+ cd "$SOURCE_DIR"
+ zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
+ cd ..
+ tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
+ --exclude='.ftpignore' --exclude='sftp-config*' \
+ --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
+
+ SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
+
+ # Ensure release exists on Gitea
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
+ 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
+ # Create release
+ RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/releases" \
+ -d "$(python3 -c "import json; print(json.dumps({
+ 'tag_name': '${RELEASE_TAG}',
+ 'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
+ 'body': '${STABILITY} release',
+ 'prerelease': True,
+ 'target_commitish': 'main'
+ }))")" 2>/dev/null || true)
+ RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+ fi
+
+ if [ -n "$RELEASE_ID" ]; then
+ # Delete existing assets with same name before uploading
+ ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
+ for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
+ ASSET_ID=$(echo "$ASSETS" | python3 -c "
+ import sys,json
+ assets = json.load(sys.stdin)
+ for a in assets:
+ if a['name'] == '${ASSET_FILE}':
+ print(a['id']); break
+ " 2>/dev/null || true)
+ if [ -n "$ASSET_ID" ]; then
+ curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
+ fi
+ done
+
+ # Upload both formats
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"/tmp/${PACKAGE_NAME}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
+
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"/tmp/${TAR_NAME}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
+ fi
+
+ echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
+ else
+ SHA256=""
+ fi
+
+ # -- Build the new entry (canonical format matching release.yml) --
+ NEW_ENTRY=""
+ NEW_ENTRY="${NEW_ENTRY} \n"
+ NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME} \n"
+ NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME} ${STABILITY} build. \n"
+ NEW_ENTRY="${NEW_ENTRY} ${EXT_ELEMENT} \n"
+ NEW_ENTRY="${NEW_ENTRY} ${EXT_TYPE} \n"
+ [ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
+ [ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
+ NEW_ENTRY="${NEW_ENTRY} ${VERSION} \n"
+ NEW_ENTRY="${NEW_ENTRY} $(date +%Y-%m-%d) \n"
+ NEW_ENTRY="${NEW_ENTRY} https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG} \n"
+ NEW_ENTRY="${NEW_ENTRY} \n"
+ NEW_ENTRY="${NEW_ENTRY} ${DOWNLOAD_URL} \n"
+ NEW_ENTRY="${NEW_ENTRY} \n"
+ [ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} ${SHA256} \n"
+ NEW_ENTRY="${NEW_ENTRY} ${STABILITY} \n"
+ NEW_ENTRY="${NEW_ENTRY} Moko Consulting \n"
+ NEW_ENTRY="${NEW_ENTRY} https://mokoconsulting.tech \n"
+ NEW_ENTRY="${NEW_ENTRY} \n"
+ [ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} ${PHP_MINIMUM} \n"
+ NEW_ENTRY="${NEW_ENTRY} "
+
+ # -- Write new entry to temp file --------------------------------
+ printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
+
+ # -- Merge into updates.xml ----------------------------------------
+ # Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
+ CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
+ TARGETS=""
+ for entry in $CASCADE_MAP; do
+ key="${entry%%:*}"
+ vals="${entry#*:}"
+ if [ "$key" = "${STABILITY}" ]; then
+ TARGETS="$vals"
+ break
+ fi
+ done
+ [ -z "$TARGETS" ] && TARGETS="${STABILITY}"
+
+ echo "Cascade: ${STABILITY} → ${TARGETS}"
+
+ # Create updates.xml if missing
+ if [ ! -f "updates.xml" ]; then
+ printf '%s\n' "" > updates.xml
+ printf '%s\n' "" >> updates.xml
+ printf '%s\n' "" >> updates.xml
+ printf '%s\n' " " >> updates.xml
+ fi
+
+ # Update existing blocks or create missing ones
+ export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
+ python3 << 'PYEOF'
+ import re, os
+
+ targets = os.environ["PY_TARGETS"].split(",")
+ version = os.environ["PY_VERSION"]
+ date = os.environ["PY_DATE"]
+
+ with open("updates.xml") as f:
+ content = f.read()
+ with open("/tmp/new_entry.xml") as f:
+ new_entry_template = f.read()
+
+ for tag in targets:
+ tag = tag.strip()
+ # Build entry with this tag's name
+ new_entry = re.sub(r"[^<]* ", f"{tag} ", new_entry_template)
+
+ # Try to find existing block (handles both single-line and multi-line )
+ block_pattern = r"((?:(?! ).)*?" + re.escape(tag) + r" .*? )"
+ match = re.search(block_pattern, content, re.DOTALL)
+
+ if match:
+ # Update in place — replace entire block
+ content = content.replace(match.group(1), new_entry.strip())
+ print(f" UPDATED: {tag} → {version}")
+ else:
+ # Create — insert before
+ content = content.replace("", "\n" + new_entry.strip() + "\n\n")
+ print(f" CREATED: {tag} → {version}")
+
+ # Clean up excessive blank lines
+ content = re.sub(r"\n{3,}", "\n\n", content)
+
+ with open("updates.xml", "w") as f:
+ f.write(content)
+ PYEOF
+
+ # Commit
+ git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "gitea-actions[bot]"
+ git add updates.xml
+ git diff --cached --quiet || {
+ git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
+ --author="gitea-actions[bot] "
+ git push
+ }
+
+ # -- Sync updates.xml to main (for non-main branches) ----------------------
+ - name: Sync updates.xml to main
+ if: github.ref_name != 'main'
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ GA_TOKEN="${{ secrets.GA_TOKEN }}"
+
+ FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
+ "${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
+
+ if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
+ CONTENT=$(base64 -w0 updates.xml)
+ curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
+ -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 from ${STABILITY} [skip ci]',
+ 'branch': 'main'
+ }))")" > /dev/null 2>&1 \
+ && echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
+ || echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: SFTP deploy to dev server
+ if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
+ env:
+ DEV_HOST: ${{ vars.DEV_FTP_HOST }}
+ DEV_PATH: ${{ vars.DEV_FTP_PATH }}
+ DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
+ DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
+ DEV_PORT: ${{ vars.DEV_FTP_PORT }}
+ DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
+ DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
+ run: |
+ # -- Permission check: admin or maintain role required --------
+ ACTOR="${{ github.actor }}"
+ REPO="${{ github.repository }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
+ case "$PERMISSION" in
+ admin|maintain|write) ;;
+ *)
+ echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
+ exit 0
+ ;;
+ esac
+
+ [ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
+
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ [ ! -d "$SOURCE_DIR" ] && exit 0
+
+ PORT="${DEV_PORT:-22}"
+ REMOTE="${DEV_PATH%/}"
+ [ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
+
+ printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
+ "$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
+ if [ -n "$DEV_KEY" ]; then
+ echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
+ printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
+ else
+ printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
+ fi
+
+ PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
+ if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
+ php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
+ elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
+ php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
+ fi
+ rm -f /tmp/deploy_key /tmp/sftp-config.json
+ echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
+
+ - name: Summary
+ if: always()
+ run: |
+ echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
+ echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
--
2.52.0
From 875067a5c13e4c4aac7389970995c7d81465d033 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 05:11:13 +0000
Subject: [PATCH 065/114] chore: remove .gitea/workflows/update-server.yml
(moved to .mokogitea/) [skip ci]
---
.gitea/workflows/update-server.yml | 464 -----------------------------
1 file changed, 464 deletions(-)
delete mode 100644 .gitea/workflows/update-server.yml
diff --git a/.gitea/workflows/update-server.yml b/.gitea/workflows/update-server.yml
deleted file mode 100644
index e6a1924..0000000
--- a/.gitea/workflows/update-server.yml
+++ /dev/null
@@ -1,464 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-#
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# FILE INFORMATION
-# DEFGROUP: Gitea.Workflow
-# INGROUP: MokoStandards.Joomla
-# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
-# PATH: /templates/workflows/joomla/update-server.yml.template
-# VERSION: 04.06.00
-# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
-#
-# Writes updates.xml with multiple entries:
-# - stable on push to main (from auto-release)
-# - rc on push to rc/**
-# - development on push to dev or dev/**
-#
-# Joomla filters by user's "Minimum Stability" setting.
-
-name: Update Joomla Update Server XML Feed
-
-on:
- push:
- branches:
- - 'dev'
- - 'dev/**'
- - 'alpha/**'
- - 'beta/**'
- - 'rc/**'
- paths:
- - 'src/**'
- - 'htdocs/**'
- pull_request:
- types: [closed]
- branches:
- - 'dev'
- - 'dev/**'
- - 'alpha/**'
- - 'beta/**'
- - 'rc/**'
- paths:
- - 'src/**'
- - 'htdocs/**'
- workflow_dispatch:
- inputs:
- stability:
- description: 'Stability tag'
- required: true
- default: 'development'
- type: choice
- options:
- - development
- - alpha
- - beta
- - rc
- - stable
-
-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:
- update-xml:
- name: Update updates.xml
- runs-on: release
- if: >-
- github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- with:
- token: ${{ secrets.GA_TOKEN }}
- fetch-depth: 0
-
- - name: Setup MokoStandards tools
- env:
- MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
- MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
- COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
- run: |
- if ! command -v composer &> /dev/null; then
- sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
- fi
- git clone --depth 1 --branch main --quiet \
- "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
- /tmp/mokostandards-api 2>/dev/null || true
- if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
- cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- fi
-
- - name: Generate updates.xml entry
- id: update
- run: |
- BRANCH="${{ github.ref_name }}"
- REPO="${{ github.repository }}"
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
- VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
-
- # Auto-bump patch on all branches (dev, alpha, beta, rc)
- git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
- git config --local user.name "gitea-actions[bot]"
- BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
- if [ -n "$BUMPED" ]; then
- VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
- git add -A
- git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
- --author="gitea-actions[bot] " 2>/dev/null || true
- git push 2>/dev/null || true
- fi
-
- # Determine stability from branch or input
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- STABILITY="${{ inputs.stability }}"
- elif [[ "$BRANCH" == rc/* ]]; then
- STABILITY="rc"
- elif [[ "$BRANCH" == beta/* ]]; then
- STABILITY="beta"
- elif [[ "$BRANCH" == alpha/* ]]; then
- STABILITY="alpha"
- elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
- STABILITY="development"
- else
- STABILITY="stable"
- fi
-
- echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
-
- # Parse manifest (portable — no grep -P)
- MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '/dev/null | head -1)
- if [ -z "$MANIFEST" ]; then
- echo "No Joomla manifest found — skipping"
- exit 0
- fi
-
- # Extract fields using sed (works on all runners)
- EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
- EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
- EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\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)
- EXT_VERSION=$(sed -n 's/.*\([^<]*\)<\/version>.*/\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)
-
- # Fallbacks
- [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
- [ -z "$EXT_TYPE" ] && EXT_TYPE="component"
-
- # Derive element if not in manifest: try XML filename, then repo name
- if [ -z "$EXT_ELEMENT" ]; then
- EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
- case "$EXT_ELEMENT" in
- templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
- esac
- fi
-
- # Use manifest version if README version is empty
- [ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
-
- [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/")
-
- CLIENT_TAG=""
- [ -n "$EXT_CLIENT" ] && CLIENT_TAG="${EXT_CLIENT} "
- [ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="site "
-
- FOLDER_TAG=""
- [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="${EXT_FOLDER} "
-
- PHP_TAG=""
- [ -n "$PHP_MINIMUM" ] && PHP_TAG="${PHP_MINIMUM} "
-
- # Version suffix for non-stable
- DISPLAY_VERSION="$VERSION"
- case "$STABILITY" in
- development) DISPLAY_VERSION="${VERSION}-dev" ;;
- alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
- beta) DISPLAY_VERSION="${VERSION}-beta" ;;
- rc) DISPLAY_VERSION="${VERSION}-rc" ;;
- esac
-
- MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
-
- # Each stability level has its own release tag
- case "$STABILITY" in
- development) RELEASE_TAG="development" ;;
- alpha) RELEASE_TAG="alpha" ;;
- beta) RELEASE_TAG="beta" ;;
- rc) RELEASE_TAG="release-candidate" ;;
- *) RELEASE_TAG="v${MAJOR}" ;;
- esac
-
- PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
- DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
- INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
-
- # -- Build install packages (ZIP + tar.gz) --------------------
- SOURCE_DIR="src"
- [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
- if [ -d "$SOURCE_DIR" ]; then
- EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
- TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
-
- cd "$SOURCE_DIR"
- zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
- cd ..
- tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
- --exclude='.ftpignore' --exclude='sftp-config*' \
- --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
-
- SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
-
- # Ensure release exists on Gitea
- RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
- 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
- # Create release
- RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- -H "Content-Type: application/json" \
- "${API_BASE}/releases" \
- -d "$(python3 -c "import json; print(json.dumps({
- 'tag_name': '${RELEASE_TAG}',
- 'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
- 'body': '${STABILITY} release',
- 'prerelease': True,
- 'target_commitish': 'main'
- }))")" 2>/dev/null || true)
- RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
- fi
-
- if [ -n "$RELEASE_ID" ]; then
- # Delete existing assets with same name before uploading
- ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
- for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
- ASSET_ID=$(echo "$ASSETS" | python3 -c "
- import sys,json
- assets = json.load(sys.stdin)
- for a in assets:
- if a['name'] == '${ASSET_FILE}':
- print(a['id']); break
- " 2>/dev/null || true)
- if [ -n "$ASSET_ID" ]; then
- curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
- fi
- done
-
- # Upload both formats
- curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- -H "Content-Type: application/octet-stream" \
- --data-binary @"/tmp/${PACKAGE_NAME}" \
- "${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
-
- curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- -H "Content-Type: application/octet-stream" \
- --data-binary @"/tmp/${TAR_NAME}" \
- "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
- fi
-
- echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
- else
- SHA256=""
- fi
-
- # -- Build the new entry (canonical format matching release.yml) --
- NEW_ENTRY=""
- NEW_ENTRY="${NEW_ENTRY} \n"
- NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME} \n"
- NEW_ENTRY="${NEW_ENTRY} ${EXT_NAME} ${STABILITY} build. \n"
- NEW_ENTRY="${NEW_ENTRY} ${EXT_ELEMENT} \n"
- NEW_ENTRY="${NEW_ENTRY} ${EXT_TYPE} \n"
- [ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
- [ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
- NEW_ENTRY="${NEW_ENTRY} ${VERSION} \n"
- NEW_ENTRY="${NEW_ENTRY} $(date +%Y-%m-%d) \n"
- NEW_ENTRY="${NEW_ENTRY} https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG} \n"
- NEW_ENTRY="${NEW_ENTRY} \n"
- NEW_ENTRY="${NEW_ENTRY} ${DOWNLOAD_URL} \n"
- NEW_ENTRY="${NEW_ENTRY} \n"
- [ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} ${SHA256} \n"
- NEW_ENTRY="${NEW_ENTRY} ${STABILITY} \n"
- NEW_ENTRY="${NEW_ENTRY} Moko Consulting \n"
- NEW_ENTRY="${NEW_ENTRY} https://mokoconsulting.tech \n"
- NEW_ENTRY="${NEW_ENTRY} \n"
- [ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} ${PHP_MINIMUM} \n"
- NEW_ENTRY="${NEW_ENTRY} "
-
- # -- Write new entry to temp file --------------------------------
- printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
-
- # -- Merge into updates.xml ----------------------------------------
- # Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
- CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
- TARGETS=""
- for entry in $CASCADE_MAP; do
- key="${entry%%:*}"
- vals="${entry#*:}"
- if [ "$key" = "${STABILITY}" ]; then
- TARGETS="$vals"
- break
- fi
- done
- [ -z "$TARGETS" ] && TARGETS="${STABILITY}"
-
- echo "Cascade: ${STABILITY} → ${TARGETS}"
-
- # Create updates.xml if missing
- if [ ! -f "updates.xml" ]; then
- printf '%s\n' "" > updates.xml
- printf '%s\n' "" >> updates.xml
- printf '%s\n' "" >> updates.xml
- printf '%s\n' " " >> updates.xml
- fi
-
- # Update existing blocks or create missing ones
- export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
- python3 << 'PYEOF'
- import re, os
-
- targets = os.environ["PY_TARGETS"].split(",")
- version = os.environ["PY_VERSION"]
- date = os.environ["PY_DATE"]
-
- with open("updates.xml") as f:
- content = f.read()
- with open("/tmp/new_entry.xml") as f:
- new_entry_template = f.read()
-
- for tag in targets:
- tag = tag.strip()
- # Build entry with this tag's name
- new_entry = re.sub(r"[^<]* ", f"{tag} ", new_entry_template)
-
- # Try to find existing block (handles both single-line and multi-line )
- block_pattern = r"((?:(?! ).)*?" + re.escape(tag) + r" .*? )"
- match = re.search(block_pattern, content, re.DOTALL)
-
- if match:
- # Update in place — replace entire block
- content = content.replace(match.group(1), new_entry.strip())
- print(f" UPDATED: {tag} → {version}")
- else:
- # Create — insert before
- content = content.replace("", "\n" + new_entry.strip() + "\n\n")
- print(f" CREATED: {tag} → {version}")
-
- # Clean up excessive blank lines
- content = re.sub(r"\n{3,}", "\n\n", content)
-
- with open("updates.xml", "w") as f:
- f.write(content)
- PYEOF
-
- # Commit
- git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
- git config --local user.name "gitea-actions[bot]"
- git add updates.xml
- git diff --cached --quiet || {
- git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
- --author="gitea-actions[bot] "
- git push
- }
-
- # -- Sync updates.xml to main (for non-main branches) ----------------------
- - name: Sync updates.xml to main
- if: github.ref_name != 'main'
- run: |
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
- GA_TOKEN="${{ secrets.GA_TOKEN }}"
-
- FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
- "${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
-
- if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
- CONTENT=$(base64 -w0 updates.xml)
- curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
- -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 from ${STABILITY} [skip ci]',
- 'branch': 'main'
- }))")" > /dev/null 2>&1 \
- && echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
- || echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
- else
- echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
- fi
-
- - name: SFTP deploy to dev server
- if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
- env:
- DEV_HOST: ${{ vars.DEV_FTP_HOST }}
- DEV_PATH: ${{ vars.DEV_FTP_PATH }}
- DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
- DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
- DEV_PORT: ${{ vars.DEV_FTP_PORT }}
- DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
- DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
- run: |
- # -- Permission check: admin or maintain role required --------
- ACTOR="${{ github.actor }}"
- REPO="${{ github.repository }}"
- API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
-
- PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
- "${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
- python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
- case "$PERMISSION" in
- admin|maintain|write) ;;
- *)
- echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
- exit 0
- ;;
- esac
-
- [ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
-
- SOURCE_DIR="src"
- [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
- [ ! -d "$SOURCE_DIR" ] && exit 0
-
- PORT="${DEV_PORT:-22}"
- REMOTE="${DEV_PATH%/}"
- [ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
-
- printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
- "$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
- if [ -n "$DEV_KEY" ]; then
- echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
- printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
- else
- printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
- fi
-
- PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
- if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
- php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
- elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
- php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
- fi
- rm -f /tmp/deploy_key /tmp/sftp-config.json
- echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
-
- - name: Summary
- if: always()
- run: |
- echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
- echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
- echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
- echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
--
2.52.0
From 89c5a63aa4396a6a97708b3bba226463ae956e3e Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:50 +0000
Subject: [PATCH 066/114] chore: sync .mokogitea/workflows/auto-release.yml
from template [skip ci]
---
.mokogitea/workflows/auto-release.yml | 1007 +++++++++++++++++++++++++
1 file changed, 1007 insertions(+)
create mode 100644 .mokogitea/workflows/auto-release.yml
diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml
new file mode 100644
index 0000000..1fe7aa6
--- /dev/null
+++ b/.mokogitea/workflows/auto-release.yml
@@ -0,0 +1,1007 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Release
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/universal/auto-release.yml.template
+# VERSION: 05.00.00
+# BRIEF: Universal build & release � detects platform from .moko-platform
+#
+# +========================================================================+
+# | UNIVERSAL BUILD & RELEASE PIPELINE |
+# +========================================================================+
+# | |
+# | Reads .moko-platform (joomla|dolibarr|generic) to branch logic. |
+# | |
+# | Platform-specific: |
+# | joomla: XML manifest, updates.xml, type-prefixed packages |
+# | dolibarr: mod*.class.php, update.txt, dev version reset |
+# | generic: README-only, no update stream |
+# | |
+# +========================================================================+
+
+name: "Universal: Build & Release"
+
+on:
+ pull_request:
+ types: [closed]
+ branches:
+ - main
+ paths:
+ - 'src/**'
+ - 'htdocs/**'
+ 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:
+ release:
+ name: Build & Release Pipeline
+ runs-on: release
+ if: >-
+ github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ token: ${{ secrets.GA_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup MokoStandards tools
+ env:
+ MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
+ MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
+ run: |
+ # Ensure PHP + Composer are available
+ if ! command -v composer &> /dev/null; then
+ sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
+ fi
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
+ /tmp/mokostandards-api
+ cd /tmp/mokostandards-api
+ composer install --no-dev --no-interaction --quiet
+
+
+ # -- PLATFORM DETECTION ---------------------------------------------------
+ - name: Detect platform
+ id: platform
+ run: |
+ # Read platform from XML manifest ( tag) or plain text fallback
+ PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1)
+ [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]')
+ [ -z "$PLATFORM" ] && PLATFORM="generic"
+ echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
+ echo "Platform detected: ${PLATFORM}"
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
+ MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
+ echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
+ echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
+
+ # -- STEP 1: Read version -----------------------------------------------
+ - name: "Step 1: Read version from README.md"
+ id: version
+ run: |
+ VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null)
+ if [ -z "$VERSION" ]; then
+ echo "No VERSION in README.md — skipping release"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ # Derive major.minor for branch naming (patches update existing branch)
+ MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
+ PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
+
+ MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
+ MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
+
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
+ echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
+ echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
+ echo "release_tag=stable" >> "$GITHUB_OUTPUT"
+ echo "stability=stable" >> "$GITHUB_OUTPUT"
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ if [ "$PATCH" = "00" ] || [ "$PATCH" = "01" ]; then
+ echo "is_minor=true" >> "$GITHUB_OUTPUT"
+ echo "Version: $VERSION (first release for this minor — full pipeline)"
+ else
+ echo "is_minor=false" >> "$GITHUB_OUTPUT"
+ echo "Version: $VERSION (patch — platform version + badges only)"
+ fi
+
+ # -- STEP 1b: Bump minor version (stable = minor bump, reset patch) ------
+ - name: "Step 1b: Bump minor version for stable release"
+ if: steps.version.outputs.skip != 'true'
+ id: bump
+ run: |
+ CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
+ [ -z "$CURRENT" ] && { echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
+
+ MAJOR=$((10#$(echo "$CURRENT" | cut -d. -f1)))
+ MINOR=$((10#$(echo "$CURRENT" | cut -d. -f2)))
+
+ # Minor bump, reset patch. Rollover if minor > 99
+ MINOR=$((MINOR + 1))
+ if [ $MINOR -gt 99 ]; then
+ MINOR=0
+ MAJOR=$((MAJOR + 1))
+ fi
+
+ VERSION=$(printf "%02d.%02d.00" $MAJOR $MINOR)
+ TODAY=$(date +%Y-%m-%d)
+
+ echo "Stable bump: ${CURRENT} → ${VERSION} (minor)"
+
+ # Update README.md
+ sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
+
+ # Update platform-specific manifest
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ MANIFEST="${{ steps.platform.outputs.manifest }}"
+ MOD_FILE="${{ steps.platform.outputs.mod_file }}"
+ case "$PLATFORM" in
+ joomla)
+ if [ -n "$MANIFEST" ]; then
+ MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
+ [ -n "$MANIFEST_VER" ] && sed -i "s|${MANIFEST_VER} |${VERSION} |" "$MANIFEST"
+ sed -i "s|[^<]* |${TODAY} |" "$MANIFEST"
+ fi
+ ;;
+ dolibarr)
+ if [ -n "$MOD_FILE" ]; then
+ sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
+ fi
+ echo "${VERSION}" > update.txt
+ ;;
+ *) ;;
+ esac
+
+ # 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]"
+ git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git diff --cached --quiet || {
+ git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
+ git push origin HEAD:main 2>&1
+ }
+
+ # Override version output for rest of pipeline
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "major=$(printf "%02d" $MAJOR)" >> "$GITHUB_OUTPUT"
+
+ - name: Check if already released
+ if: steps.version.outputs.skip != 'true'
+ id: check
+ run: |
+ TAG="${{ steps.version.outputs.release_tag }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+
+ TAG_EXISTS=false
+ BRANCH_EXISTS=false
+
+ git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
+ git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
+
+ echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
+ echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
+
+ # Tag and branch may persist across patch releases — never skip
+ echo "already_released=false" >> "$GITHUB_OUTPUT"
+
+ # -- SANITY CHECKS -------------------------------------------------------
+ - name: "Sanity: Pre-release validation"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ ERRORS=0
+
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ MANIFEST="${{ steps.platform.outputs.manifest }}"
+ MOD_FILE="${{ steps.platform.outputs.mod_file }}"
+ echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # -- Version drift check (must pass before release) --------
+ README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
+ if [ "$README_VER" != "$VERSION" ]; then
+ echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Check CHANGELOG version matches
+ CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
+ if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
+ echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi
+
+ # Check composer.json version if present
+ if [ -f "composer.json" ]; then
+ COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
+ if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
+ echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi
+ fi
+
+ # Common checks
+ if [ ! -f "LICENSE" ]; then
+ echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
+ echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- Platform-specific checks --------
+ case "$PLATFORM" in
+ joomla)
+ if [ -n "$MANIFEST" ]; then
+ XML_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
+ echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
+ echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY
+ fi ;;
+ dolibarr)
+ if [ -n "$MOD_FILE" ]; then
+ MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1)
+ if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then
+ echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ else
+ echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ else
+ echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi
+ if [ ! -f "update.txt" ]; then
+ echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS+1))
+ fi ;;
+ *) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;;
+ esac
+
+ echo "" >> $GITHUB_STEP_SUMMARY
+ if [ "$ERRORS" -gt 0 ]; then
+ echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 2: Create or update version/XX.YY archive branch ---------------
+ # Always runs — every version change on main archives to version/XX.YY
+ - name: "Step 2: Version archive branch"
+ if: steps.check.outputs.already_released != 'true'
+ run: |
+ BRANCH="${{ steps.version.outputs.branch }}"
+ IS_MINOR="${{ steps.version.outputs.is_minor }}"
+ PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
+
+ # Check if branch exists
+ if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
+ git push origin HEAD:"$BRANCH" --force
+ echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
+ else
+ git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
+ git push origin "$BRANCH" --force
+ echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 3: Set platform version ----------------------------------------
+ - name: "Step 3: Set platform version"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ php /tmp/mokostandards-api/cli/version_set_platform.php \
+ --path . --version "$VERSION" --branch main
+
+ # -- STEP 4: Update version badges ----------------------------------------
+ - name: "Step 4: Update version badges"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do
+ if grep -q '\[VERSION:' "$f" 2>/dev/null; then
+ sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f"
+ fi
+ done
+
+ # -- STEP 5: Write updates.xml (Joomla update server) ---------------------
+ - name: "Step 5: Write update stream"
+ id: updates
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ REPO="${{ github.repository }}"
+
+ # -- Parse extension metadata from XML manifest ----------------
+ MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1)
+ if [ -z "$MANIFEST" ]; then
+ echo "Warning: No Joomla XML manifest found — skipping updates.xml" >> $GITHUB_STEP_SUMMARY
+ exit 0
+ fi
+
+ # Extract fields using sed (portable — no grep -P)
+ EXT_NAME=$(sed -n 's/.*\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
+ EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\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)
+
+ # If EXT_NAME is a language key (e.g. PLG_SYSTEM_MOKOJGDPC), resolve from .ini
+ if echo "$EXT_NAME" | grep -qE '^[A-Z_]+$'; then
+ INI_NAME=$(find . -name "*.sys.ini" -path "*/en-GB/*" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
+ [ -z "$INI_NAME" ] && INI_NAME=$(find . -name "*.sys.ini" -exec grep -h "^${EXT_NAME}=" {} \; 2>/dev/null | head -1 | cut -d'"' -f2)
+ [ -n "$INI_NAME" ] && EXT_NAME="$INI_NAME"
+ fi
+
+ # Fallbacks
+ [ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
+ [ -z "$EXT_TYPE" ] && EXT_TYPE="component"
+
+ # Derive element if not in manifest:
+ # 1. plugin="xxx" attribute (plugins)
+ # 2. module="xxx" attribute (modules)
+ # 3. XML filename (components, packages)
+ # 4. Repo name fallback (templates, anything else)
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ fi
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*module="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ fi
+ if [ -z "$EXT_ELEMENT" ]; then
+ FNAME=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ # If filename is generic (templateDetails, manifest), use repo name
+ case "$FNAME" in
+ templatedetails|manifest) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
+ *) EXT_ELEMENT="$FNAME" ;;
+ esac
+ fi
+ # Final fallback
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+
+ # Save for Steps 7, 8, 8b
+ echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
+ echo "ext_name=${EXT_NAME}" >> "$GITHUB_OUTPUT"
+ echo "ext_type=${EXT_TYPE}" >> "$GITHUB_OUTPUT"
+ echo "ext_folder=${EXT_FOLDER}" >> "$GITHUB_OUTPUT"
+
+ # Build client tag: plugins and frontend modules need site
+ CLIENT_TAG=""
+ if [ -n "$EXT_CLIENT" ]; then
+ CLIENT_TAG="${EXT_CLIENT} "
+ elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
+ CLIENT_TAG="site "
+ fi
+
+ # Build folder tag for plugins (required for Joomla to match the update)
+ FOLDER_TAG=""
+ if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
+ FOLDER_TAG="${EXT_FOLDER} "
+ fi
+
+ # Build targetplatform (fallback to Joomla 5 if not in manifest)
+ if [ -z "$TARGET_PLATFORM" ]; then
+ TARGET_PLATFORM=$(printf '' "/")
+ fi
+
+ # Build php_minimum tag
+ PHP_TAG=""
+ if [ -n "$PHP_MINIMUM" ]; then
+ PHP_TAG="${PHP_MINIMUM} "
+ fi
+
+ # Build TYPE_PREFIX for download URL
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+
+ DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/stable"
+
+ # -- Build update entry for a given stability tag
+ build_entry() {
+ local TAG_NAME="$1"
+ printf '%s\n' ' '
+ printf '%s\n' " ${EXT_NAME} "
+ printf '%s\n' " ${EXT_NAME} update "
+ printf '%s\n' " ${EXT_ELEMENT} "
+ printf '%s\n' " ${EXT_TYPE} "
+ printf '%s\n' " ${VERSION} "
+ [ -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' " ${TARGET_PLATFORM}"
+ [ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}"
+ printf '%s\n' ' Moko Consulting '
+ printf '%s\n' ' https://mokoconsulting.tech '
+ printf '%s\n' ' '
+ }
+
+ # -- Write updates.xml with cascading channels
+ # Stable release updates ALL channels (development, alpha, beta, rc, stable)
+ {
+ printf '%s\n' ""
+ printf '%s\n' ""
+ printf '%s\n' ""
+ printf '%s\n' ''
+ build_entry "development"
+ build_entry "alpha"
+ build_entry "beta"
+ build_entry "rc"
+ build_entry "stable"
+ printf '%s\n' ' '
+ } > updates.xml
+
+ echo "updates.xml: ${VERSION} (all channels updated to stable)" >> $GITHUB_STEP_SUMMARY
+
+ # -- Commit all changes ---------------------------------------------------
+ - name: Commit release changes
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.already_released != 'true'
+ run: |
+ if git diff --quiet && git diff --cached --quiet; then
+ echo "No changes to commit"
+ exit 0
+ fi
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "gitea-actions[bot]"
+ # Set push URL with token for branch-protected repos
+ git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git commit -m "chore(release): build ${VERSION} [skip ci]" \
+ --author="gitea-actions[bot] "
+ git push -u origin HEAD
+
+ # -- STEP 6: Create tag ---------------------------------------------------
+ - name: "Step 6: Create git tag"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.check.outputs.tag_exists != 'true' &&
+ steps.version.outputs.is_minor == 'true'
+ run: |
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ # Only create the major release tag if it doesn't exist yet
+ if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
+ git tag "$RELEASE_TAG"
+ git push origin "$RELEASE_TAG"
+ echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
+ fi
+ echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 7: Create or update Gitea Release --------------------------------
+ - name: "Step 7: Gitea Release"
+ if: >-
+ steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+ MAJOR="${{ steps.version.outputs.major }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ # Reuse metadata from Step 5 (single source of truth)
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ EXT_NAME="${{ steps.updates.outputs.ext_name }}"
+ EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
+ EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
+
+ # Fallbacks if Step 5 was skipped
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+ [ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
+
+ NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
+ [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
+
+ # Build release name: "Pretty Name VERSION (type_element-VERSION)"
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
+
+ # Delete existing release if present (overwrite, not append)
+ EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
+ EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$EXISTING_ID" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
+ echo "Deleted previous stable release (id: ${EXISTING_ID})"
+ fi
+
+ # Create fresh release
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/releases" \
+ -d "$(python3 -c "import json; print(json.dumps({
+ 'tag_name': '${RELEASE_TAG}',
+ 'name': '${RELEASE_NAME}',
+ 'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
+ 'target_commitish': '${BRANCH}'
+ }))")"
+ echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
+ - name: "Step 8: Build package and update checksum"
+ if: >-
+ steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ REPO="${{ github.repository }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+
+ # All ZIPs upload to the major release tag (vXX)
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
+ 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
+ echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
+ exit 0
+ fi
+
+ # Find extension element name from manifest
+ MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1 || true)
+ [ -z "$MANIFEST" ] && exit 0
+
+ # Reuse element from Step 5, with same fallback chain
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+ # ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
+ EXT_TYPE=$(sed -n 's/.*]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ EXT_FOLDER=$(sed -n 's/.*]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
+
+ # -- Build install packages from src/ ----------------------------
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ — skipping package"; exit 0; }
+
+ EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
+
+ # ZIP package
+ cd "$SOURCE_DIR"
+ zip -r "/tmp/${ZIP_NAME}" . -x $EXCLUDES
+ cd ..
+
+ # tar.gz package
+ tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
+ --exclude='.ftpignore' --exclude='sftp-config*' \
+ --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
+
+ ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
+ TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
+
+ # -- Calculate SHA-256 for both ----------------------------------
+ SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
+ SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
+
+ # -- Delete existing assets with same name before uploading ------
+ ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
+ for ASSET_NAME in "$ZIP_NAME" "$TAR_NAME"; do
+ ASSET_ID=$(echo "$ASSETS" | python3 -c "
+ import sys,json
+ assets = json.load(sys.stdin)
+ for a in assets:
+ if a['name'] == '${ASSET_NAME}':
+ print(a['id']); break
+ " 2>/dev/null || true)
+ if [ -n "$ASSET_ID" ]; then
+ curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
+ fi
+ done
+
+ # -- Upload both to release tag ----------------------------------
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"/tmp/${ZIP_NAME}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null 2>&1 || true
+
+ curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"/tmp/${TAR_NAME}" \
+ "${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
+
+ # -- Update updates.xml with both download formats ---------------
+ if [ -f "updates.xml" ]; then
+ ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
+ TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
+
+ # Use Python to update only the stable entry's downloads + sha256
+ export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
+ python3 << 'PYEOF'
+ import re, os
+
+ with open("updates.xml") as f:
+ content = f.read()
+
+ zip_url = os.environ["PY_ZIP_URL"]
+ tar_url = os.environ["PY_TAR_URL"]
+ sha = os.environ["PY_SHA"]
+
+ # Find the stable update block and replace its downloads + sha256
+ def replace_stable(m):
+ block = m.group(0)
+ # Replace downloads block
+ new_downloads = (
+ " \n"
+ f" {zip_url} \n"
+ " "
+ )
+ block = re.sub(r' .*? ', new_downloads, block, flags=re.DOTALL)
+ # Add or replace sha256
+ if '' in block:
+ block = re.sub(r' .*? ', f' {sha} ', block)
+ else:
+ block = block.replace('', f'\n {sha} ')
+ return block
+
+ content = re.sub(
+ r' .*?stable .*? ',
+ replace_stable,
+ content,
+ flags=re.DOTALL
+ )
+
+ with open("updates.xml", "w") as f:
+ f.write(content)
+ PYEOF
+
+ CURRENT_BRANCH="${{ github.ref_name }}"
+ git add updates.xml
+ git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
+ --author="gitea-actions[bot] " || true
+ git push || true
+
+ # Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
+ GA_TOKEN="${{ secrets.GA_TOKEN }}"
+ API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
+
+ FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
+
+ if [ -n "$FILE_SHA" ]; then
+ CONTENT=$(base64 -w0 updates.xml)
+ curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/contents/updates.xml" \
+ -d "$(jq -n \
+ --arg content "$CONTENT" \
+ --arg sha "$FILE_SHA" \
+ --arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
+ --arg branch "main" \
+ '{content: $content, sha: $sha, message: $msg, branch: $branch}'
+ )" > /dev/null 2>&1 \
+ && echo "updates.xml synced to main via API" \
+ || echo "WARNING: failed to sync updates.xml to main"
+ else
+ echo "WARNING: could not get updates.xml SHA from main"
+ fi
+ fi
+
+ echo "### Packages" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
+ echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
+ echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
+ echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 8b: Update release description with changelog + SHA ----------------
+ - name: "Step 8b: Update release body with changelog and SHA"
+ if: steps.version.outputs.skip != 'true'
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
+ EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
+ EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
+
+ # Build TYPE_PREFIX to match Step 8's ZIP naming
+ TYPE_PREFIX=""
+ case "${EXT_TYPE}" in
+ plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
+ module) TYPE_PREFIX="mod_" ;;
+ component) TYPE_PREFIX="com_" ;;
+ template) TYPE_PREFIX="tpl_" ;;
+ library) TYPE_PREFIX="lib_" ;;
+ package) TYPE_PREFIX="pkg_" ;;
+ esac
+ ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
+ TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
+
+ # Get SHA from the built files
+ SHA256_ZIP=""
+ [ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
+ SHA256_TAR=""
+ [ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
+
+ # Extract latest changelog entry (strip the ## header to avoid duplicate)
+ CHANGELOG=""
+ if [ -f "CHANGELOG.md" ]; then
+ CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
+ [ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
+ fi
+
+ # Build release body (single header, no duplicate from changelog)
+ BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
+ if [ -n "$CHANGELOG" ]; then
+ BODY="${BODY}${CHANGELOG}\n\n"
+ fi
+ BODY="${BODY}---\n\n### Checksums\n\n"
+ BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
+ [ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
+ [ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
+
+ # Get release ID and update body
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
+ "${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ python3 -c "
+ import json, urllib.request
+ body = '''$(printf '%b' "$BODY")'''
+ data = json.dumps({'body': body}).encode()
+ req = urllib.request.Request(
+ '${API_BASE}/releases/${RELEASE_ID}',
+ data=data,
+ headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
+ method='PATCH'
+ )
+ urllib.request.urlopen(req)
+ " 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
+ - name: "Step 9: Mirror release to GitHub"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.version.outputs.stability == 'stable' &&
+ secrets.GH_TOKEN != ''
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
+ MAJOR="${{ steps.version.outputs.major }}"
+ BRANCH="${{ steps.version.outputs.branch }}"
+ GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
+
+ NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
+ [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
+ echo "$NOTES" > /tmp/release_notes.md
+
+ EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
+
+ if [ -z "$EXISTING" ]; then
+ gh release create "$RELEASE_TAG" \
+ --repo "$GH_REPO" \
+ --title "v${MAJOR} (latest: ${VERSION})" \
+ --notes-file /tmp/release_notes.md \
+ --target "$BRANCH" || true
+ else
+ gh release edit "$RELEASE_TAG" \
+ --repo "$GH_REPO" \
+ --title "v${MAJOR} (latest: ${VERSION})" || true
+ fi
+
+ # Upload assets to GitHub mirror
+ for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
+ if [ -f "$PKG" ]; then
+ _RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
+ [ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
+ fi
+ done
+ echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 10: Sync main branch to GitHub mirror ----------------------------
+ - name: "Step 10: Push main to GitHub mirror"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ secrets.GH_TOKEN != ''
+ continue-on-error: true
+ run: |
+ GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
+ GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
+ GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
+ git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
+ git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
+ git fetch origin main --depth=1
+ git push github origin/main:refs/heads/main --force 2>/dev/null \
+ && echo "main branch pushed to GitHub mirror" \
+ || echo "WARNING: GitHub mirror push failed"
+
+ # -- Clean up lesser pre-releases (cascade) ---------------------------------
+ # stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev
+ - name: "Delete lesser pre-release channels"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+
+ # Stable deletes all pre-release channels
+ TAGS_TO_DELETE="development alpha beta release-candidate"
+
+ DELETED=0
+ for TAG in $TAGS_TO_DELETE; do
+ RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/tags/${TAG}" 2>/dev/null || true
+ echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
+ DELETED=$((DELETED + 1))
+ fi
+ done
+ echo "Cleaned up ${DELETED} pre-release channel(s)" >> $GITHUB_STEP_SUMMARY
+
+ # -- STEP 11: Reset dev branch from main ------------------------------------
+ - name: "Step 11: Delete and recreate dev branch from main"
+ if: steps.version.outputs.skip != 'true'
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+
+ # Delete dev branch
+ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
+
+ # Recreate dev from main (now includes version bump + changelog promotion)
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API_BASE}/branches" \
+ -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
+
+ echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
+
+
+ # -- Dolibarr post-release: Reset dev version -----------------------------
+ - name: "Dolibarr: Reset dev version"
+ if: >-
+ steps.version.outputs.skip != 'true' &&
+ steps.platform.outputs.platform == 'dolibarr' &&
+ steps.platform.outputs.mod_file != ''
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+ MOD_FILE="${{ steps.platform.outputs.mod_file }}"
+ ENCODED_PATH=$(echo "$MOD_FILE" | sed 's|^\./||' | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")
+ FILE_RESP=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/contents/${ENCODED_PATH}?ref=dev" 2>/dev/null || true)
+ FILE_SHA=$(echo "$FILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
+ FILE_CONTENT=$(echo "$FILE_RESP" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin).get('content','')).decode())" 2>/dev/null || true)
+ if [ -n "$FILE_SHA" ] && [ -n "$FILE_CONTENT" ]; then
+ UPDATED=$(echo "$FILE_CONTENT" | sed "s/\$this->version = '[^']*'/\$this->version = 'development'/")
+ ENCODED=$(echo "$UPDATED" | base64 -w0)
+ curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/contents/${ENCODED_PATH}" \
+ -d "$(jq -n --arg content \"$ENCODED\" --arg sha \"$FILE_SHA\" --arg msg \"chore(version): reset dev version [skip ci]\" --arg branch \"dev\" '{content:$content,sha:$sha,message:$msg,branch:$branch}')" > /dev/null 2>&1 || true
+ fi
+
+ # -- Summary --------------------------------------------------------------
+ - name: Pipeline Summary
+ if: always()
+ run: |
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
+ echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
+ echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
+ elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
+ echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
+ echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
+ fi
--
2.52.0
From 8267089e590f09fb8941ac9e9adb5e23c3ccb24e Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:51 +0000
Subject: [PATCH 067/114] chore: sync .mokogitea/workflows/cascade-dev.yml from
template [skip ci]
---
.mokogitea/workflows/cascade-dev.yml | 213 +++++++++++++++++++++++++++
1 file changed, 213 insertions(+)
create mode 100644 .mokogitea/workflows/cascade-dev.yml
diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml
new file mode 100644
index 0000000..4dbb135
--- /dev/null
+++ b/.mokogitea/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: "Universal: 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
--
2.52.0
From 7c917434ed6bbd90257ae84bc0d8b74263470227 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:51 +0000
Subject: [PATCH 068/114] chore: sync .mokogitea/workflows/ci-joomla.yml from
template [skip ci]
---
.mokogitea/workflows/ci-joomla.yml | 450 +++++++++++++++++++++++++++++
1 file changed, 450 insertions(+)
create mode 100644 .mokogitea/workflows/ci-joomla.yml
diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml
new file mode 100644
index 0000000..5c66f14
--- /dev/null
+++ b/.mokogitea/workflows/ci-joomla.yml
@@ -0,0 +1,450 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# This file is part of a Moko Consulting project.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow.Template
+# INGROUP: MokoStandards.CI
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/joomla/ci-joomla.yml.template
+# VERSION: 04.06.00
+# BRIEF: CI workflow for Joomla extensions — lint, validate, test
+
+name: "Joomla: Extension CI"
+
+on:
+ pull_request:
+ branches:
+ - main
+ - 'dev/**'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ lint-and-validate:
+ name: Lint & Validate
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Setup PHP
+ run: |
+ php -v && composer --version
+
+ - name: Clone MokoStandards
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
+ run: |
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
+ /tmp/mokostandards-api
+
+ - 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
+ else
+ echo "No composer.json found — skipping dependency install"
+ fi
+
+ - name: PHP syntax check
+ run: |
+ ERRORS=0
+ for DIR in src/ htdocs/; do
+ if [ -d "$DIR" ]; then
+ FOUND=1
+ while IFS= read -r -d '' FILE; do
+ OUTPUT=$(php -l "$FILE" 2>&1)
+ if echo "$OUTPUT" | grep -q "Parse error"; then
+ echo "::error file=${FILE}::${OUTPUT}"
+ ERRORS=$((ERRORS + 1))
+ fi
+ done < <(find "$DIR" -name "*.php" -print0)
+ fi
+ done
+ echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY
+ if [ "${ERRORS}" -gt 0 ]; then
+ echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: XML manifest validation
+ run: |
+ echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY
+ ERRORS=0
+
+ # Find the extension manifest (XML with /dev/null; then
+ MANIFEST="$XML_FILE"
+ break
+ fi
+ done
+
+ if [ -z "$MANIFEST" ]; then
+ echo "No Joomla extension manifest found (XML file with \`> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
+
+ # Validate well-formed XML
+ php -r "
+ \$xml = @simplexml_load_file('$MANIFEST');
+ if (\$xml === false) {
+ echo 'INVALID';
+ exit(1);
+ }
+ echo 'VALID';
+ " > /tmp/xml_result 2>&1
+ XML_RESULT=$(cat /tmp/xml_result)
+ if [ "$XML_RESULT" != "VALID" ]; then
+ echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Check required tags: name, version, author, namespace (Joomla 5+)
+ for TAG in name version author namespace; do
+ if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then
+ echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ done
+ fi
+
+ if [ "${ERRORS}" -gt 0 ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Check language files referenced in manifest
+ run: |
+ echo "### Language File Check" >> $GITHUB_STEP_SUMMARY
+ ERRORS=0
+
+ MANIFEST=""
+ for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
+ if grep -q "/dev/null; then
+ MANIFEST="$XML_FILE"
+ break
+ fi
+ done
+
+ if [ -n "$MANIFEST" ]; then
+ # Extract language file references from manifest
+ LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
+ if [ -z "$LANG_FILES" ]; then
+ echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY
+ else
+ while IFS= read -r LANG_FILE; do
+ LANG_FILE=$(echo "$LANG_FILE" | xargs)
+ if [ -z "$LANG_FILE" ]; then
+ continue
+ fi
+ # Check in common locations
+ FOUND=0
+ for BASE in "." "src" "htdocs"; do
+ if [ -f "${BASE}/${LANG_FILE}" ]; then
+ FOUND=1
+ break
+ fi
+ done
+ if [ "$FOUND" -eq 0 ]; then
+ echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+ done <<< "$LANG_FILES"
+ fi
+ else
+ echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ if [ "${ERRORS}" -gt 0 ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Check index.html files in directories
+ run: |
+ echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY
+ MISSING=0
+ CHECKED=0
+
+ for DIR in src/ htdocs/; do
+ if [ -d "$DIR" ]; then
+ while IFS= read -r -d '' SUBDIR; do
+ CHECKED=$((CHECKED + 1))
+ if [ ! -f "${SUBDIR}/index.html" ]; then
+ echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY
+ MISSING=$((MISSING + 1))
+ fi
+ done < <(find "$DIR" -type d -print0)
+ fi
+ done
+
+ if [ "${CHECKED}" -eq 0 ]; then
+ echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY
+ elif [ "${MISSING}" -gt 0 ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ release-readiness:
+ name: Release Readiness Check
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request' && github.base_ref == 'main'
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Validate release readiness
+ run: |
+ echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ ERRORS=0
+
+ # Extract version from README.md
+ README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
+ if [ -z "$README_VERSION" ]; then
+ echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Find the extension manifest
+ MANIFEST=""
+ for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
+ if grep -q "/dev/null; then
+ MANIFEST="$XML_FILE"
+ break
+ fi
+ done
+
+ if [ -z "$MANIFEST" ]; then
+ echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
+
+ # Check matches README VERSION
+ MANIFEST_VERSION=$(grep -oP '\K[^<]+' "$MANIFEST" | head -1)
+ if [ -z "$MANIFEST_VERSION" ]; then
+ echo "No \`\` tag in manifest." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then
+ echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Check extension type, element, client attributes
+ EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1)
+ if [ -z "$EXT_TYPE" ]; then
+ echo "Missing \`type\` attribute on \`\` tag." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ else
+ echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Element check (component/module/plugin name)
+ HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0")
+ if [ "$HAS_ELEMENT" -eq 0 ]; then
+ echo "Missing \`\` or \`\` in manifest." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ fi
+
+ # Client attribute for site/admin modules and plugins
+ if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then
+ HAS_CLIENT=$(grep -cP ']*\bclient=' "$MANIFEST" 2>/dev/null || echo "0")
+ if [ "$HAS_CLIENT" -eq 0 ]; then
+ echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ fi
+ fi
+ fi
+
+ # Check updates.xml exists
+ if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then
+ echo "Update XML present." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ fi
+
+ # Check CHANGELOG.md exists
+ if [ -f "CHANGELOG.md" ]; then
+ echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY
+ ERRORS=$((ERRORS + 1))
+ fi
+
+ echo "" >> $GITHUB_STEP_SUMMARY
+ if [ $ERRORS -gt 0 ]; then
+ echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ else
+ echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ test:
+ name: Tests (PHP ${{ matrix.php }})
+ runs-on: ubuntu-latest
+ needs: lint-and-validate
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.2', '8.3']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Setup PHP ${{ matrix.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
+ else
+ echo "No composer.json found — skipping dependency install"
+ fi
+
+ - name: Run tests
+ run: |
+ echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY
+ if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then
+ vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log
+ EXIT=${PIPESTATUS[0]}
+ if [ $EXIT -eq 0 ]; then
+ echo "All tests passed." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ fi
+ exit $EXIT
+ 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
--
2.52.0
From 13355ddf96cf0d16ee7606d72b8e266f7c6a05b0 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:51 +0000
Subject: [PATCH 069/114] chore: sync .mokogitea/workflows/cleanup.yml from
template [skip ci]
---
.mokogitea/workflows/cleanup.yml | 87 ++++++++++++++++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100644 .mokogitea/workflows/cleanup.yml
diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml
new file mode 100644
index 0000000..3a81856
--- /dev/null
+++ b/.mokogitea/workflows/cleanup.yml
@@ -0,0 +1,87 @@
+# 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/MokoStandards
+# PATH: /.gitea/workflows/cleanup.yml
+# VERSION: 01.00.00
+# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
+
+name: "Universal: Repository Cleanup"
+
+on:
+ schedule:
+ - cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+env:
+ GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
+
+jobs:
+ cleanup:
+ name: Clean Merged Branches
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GA_TOKEN }}
+
+ - name: Delete merged branches
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ echo "=== Merged Branch Cleanup ==="
+ API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
+
+ # List branches via API
+ BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/branches?limit=50" | jq -r '.[].name')
+
+ DELETED=0
+ for BRANCH in $BRANCHES; do
+ # Skip protected branches
+ case "$BRANCH" in
+ main|master|develop|release/*|hotfix/*) continue ;;
+ esac
+
+ # Check if branch is merged into main
+ if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
+ echo " Deleting merged branch: ${BRANCH}"
+ curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/branches/${BRANCH}" 2>/dev/null || true
+ DELETED=$((DELETED + 1))
+ fi
+ done
+
+ echo "Deleted ${DELETED} merged branch(es)"
+
+ - name: Clean old workflow runs
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN }}
+ run: |
+ echo "=== Workflow Run Cleanup ==="
+ API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
+ CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
+
+ # Get old completed runs
+ RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/actions/runs?status=completed&limit=50" | \
+ jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
+
+ DELETED=0
+ for RUN_ID in $RUNS; do
+ curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
+ "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
+ DELETED=$((DELETED + 1))
+ done
+
+ echo "Deleted ${DELETED} old workflow run(s)"
--
2.52.0
From 7fe85c7f5b8b8a5f0821cb5ed4fa360b48c23a43 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:52 +0000
Subject: [PATCH 070/114] chore: sync .mokogitea/workflows/deploy-manual.yml
from template [skip ci]
---
.mokogitea/workflows/deploy-manual.yml | 126 +++++++++++++++++++++++++
1 file changed, 126 insertions(+)
create mode 100644 .mokogitea/workflows/deploy-manual.yml
diff --git a/.mokogitea/workflows/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml
new file mode 100644
index 0000000..bb133ed
--- /dev/null
+++ b/.mokogitea/workflows/deploy-manual.yml
@@ -0,0 +1,126 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Deploy
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
+# PATH: /templates/workflows/joomla/deploy-manual.yml.template
+# VERSION: 04.07.00
+# BRIEF: Manual SFTP deploy to dev server for Joomla repos
+
+name: "Universal: Deploy to Dev (Manual)"
+
+on:
+ workflow_dispatch:
+ inputs:
+ clear_remote:
+ description: 'Delete all remote files before uploading'
+ required: false
+ default: 'false'
+ type: boolean
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+permissions:
+ contents: read
+
+jobs:
+ deploy:
+ name: SFTP Deploy to Dev
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+
+ - name: Setup PHP
+ run: |
+ php -v && composer --version
+
+ - name: Setup MokoStandards tools
+ env:
+ GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
+ run: |
+ git clone --depth 1 --branch main --quiet \
+ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
+ /tmp/mokostandards-api 2>/dev/null || true
+ if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
+ cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
+ fi
+
+ - name: Check FTP configuration
+ id: check
+ env:
+ HOST: ${{ vars.DEV_FTP_HOST }}
+ PATH_VAR: ${{ vars.DEV_FTP_PATH }}
+ PORT: ${{ vars.DEV_FTP_PORT }}
+ run: |
+ if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
+ echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ echo "host=$HOST" >> "$GITHUB_OUTPUT"
+
+ REMOTE="${PATH_VAR%/}"
+ echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
+
+ [ -z "$PORT" ] && PORT="22"
+ echo "port=$PORT" >> "$GITHUB_OUTPUT"
+
+ - name: Deploy via SFTP
+ if: steps.check.outputs.skip != 'true'
+ env:
+ SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
+ SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
+ SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
+ run: |
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ [ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
+
+ printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
+ "${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
+ > /tmp/sftp-config.json
+
+ if [ -n "$SFTP_KEY" ]; then
+ echo "$SFTP_KEY" > /tmp/deploy_key
+ chmod 600 /tmp/deploy_key
+ printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
+ else
+ printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
+ fi
+
+ DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
+ [ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
+
+ PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
+ if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
+ php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
+ else
+ php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
+ fi
+
+ rm -f /tmp/deploy_key /tmp/sftp-config.json
+
+ - name: Summary
+ if: always()
+ run: |
+ if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
+ echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
+ echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
+ echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
+ fi
--
2.52.0
From f6170df30a52a97cf30ea3391b2d5406414715db Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:52 +0000
Subject: [PATCH 071/114] chore: sync .mokogitea/workflows/gitleaks.yml from
template [skip ci]
---
.mokogitea/workflows/gitleaks.yml | 96 +++++++++++++++++++++++++++++++
1 file changed, 96 insertions(+)
create mode 100644 .mokogitea/workflows/gitleaks.yml
diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml
new file mode 100644
index 0000000..0c07612
--- /dev/null
+++ b/.mokogitea/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: "Universal: 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
--
2.52.0
From dea2db6c4fd579da2211f37d7ec4b20201468209 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:52 +0000
Subject: [PATCH 072/114] chore: sync .mokogitea/workflows/notify.yml from
template [skip ci]
---
.mokogitea/workflows/notify.yml | 71 +++++++++++++++++++++++++++++++++
1 file changed, 71 insertions(+)
create mode 100644 .mokogitea/workflows/notify.yml
diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml
new file mode 100644
index 0000000..463a900
--- /dev/null
+++ b/.mokogitea/workflows/notify.yml
@@ -0,0 +1,71 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Notifications
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
+# PATH: /.gitea/workflows/notify.yml
+# VERSION: 01.00.00
+# BRIEF: Push notifications via ntfy on release success or workflow failure
+
+name: "Universal: Notifications"
+
+on:
+ workflow_run:
+ workflows:
+ - "Joomla Build & Release"
+ - "Joomla Extension CI"
+ - "Deploy"
+ - "Cascade Main → Dev"
+ types:
+ - completed
+
+permissions:
+ contents: read
+
+env:
+ NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
+ NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
+
+jobs:
+ notify:
+ name: Send Notification
+ runs-on: ubuntu-latest
+ if: >-
+ github.event.workflow_run.conclusion == 'success' ||
+ github.event.workflow_run.conclusion == 'failure'
+
+ steps:
+ - name: Notify on success (releases only)
+ if: >-
+ github.event.workflow_run.conclusion == 'success' &&
+ contains(github.event.workflow_run.name, 'Release')
+ run: |
+ REPO="${{ github.event.repository.name }}"
+ WORKFLOW="${{ github.event.workflow_run.name }}"
+ URL="${{ github.event.workflow_run.html_url }}"
+
+ curl -sS \
+ -H "Title: ${REPO} released" \
+ -H "Tags: white_check_mark,package" \
+ -H "Priority: default" \
+ -H "Click: ${URL}" \
+ -d "${WORKFLOW} completed successfully." \
+ "${NTFY_URL}/${NTFY_TOPIC}"
+
+ - name: Notify on failure
+ if: github.event.workflow_run.conclusion == 'failure'
+ run: |
+ REPO="${{ github.event.repository.name }}"
+ WORKFLOW="${{ github.event.workflow_run.name }}"
+ URL="${{ github.event.workflow_run.html_url }}"
+
+ curl -sS \
+ -H "Title: ${REPO} workflow failed" \
+ -H "Tags: x,warning" \
+ -H "Priority: high" \
+ -H "Click: ${URL}" \
+ -d "${WORKFLOW} failed. Check the run for details." \
+ "${NTFY_URL}/${NTFY_TOPIC}"
--
2.52.0
From 4512d62cb8c15dd184406d5de630e3eff26a711d Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:53 +0000
Subject: [PATCH 073/114] chore: sync .mokogitea/workflows/pr-check.yml from
template [skip ci]
---
.mokogitea/workflows/pr-check.yml | 196 ++++++++++++++++++++++++++++++
1 file changed, 196 insertions(+)
create mode 100644 .mokogitea/workflows/pr-check.yml
diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml
new file mode 100644
index 0000000..bd06c90
--- /dev/null
+++ b/.mokogitea/workflows/pr-check.yml
@@ -0,0 +1,196 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.CI
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/universal/pr-check.yml.template
+# VERSION: 05.00.00
+# BRIEF: PR gate — branch policy + code validation before merge
+
+name: "Universal: PR Check"
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ # ── Branch Policy ──────────────────────────────────────────────────────
+ branch-policy:
+ name: Branch Policy
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check branch merge target
+ run: |
+ HEAD="${{ github.head_ref }}"
+ BASE="${{ github.base_ref }}"
+
+ echo "PR: ${HEAD} → ${BASE}"
+
+ ALLOWED=true
+ REASON=""
+
+ case "$HEAD" in
+ feature/*|feat/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Feature branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ fix/*|bugfix/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Fix branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ hotfix/*)
+ if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
+ fi
+ ;;
+ alpha/*|beta/*)
+ if [ "$BASE" != "dev" ]; then
+ ALLOWED=false
+ REASON="Pre-release branches must target 'dev', not '${BASE}'"
+ fi
+ ;;
+ rc/*)
+ if [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Release candidate branches must target 'main', not '${BASE}'"
+ fi
+ ;;
+ dev)
+ if [ "$BASE" != "main" ]; then
+ ALLOWED=false
+ REASON="Dev branch can only merge into 'main', not '${BASE}'"
+ fi
+ ;;
+ esac
+
+ if [ "$ALLOWED" = false ]; then
+ echo "::error::${REASON}"
+ echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "${REASON}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
+ echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
+ echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
+ exit 1
+ fi
+
+ echo "Branch policy: OK (${HEAD} → ${BASE})"
+ echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
+
+ # ── Code Validation ────────────────────────────────────────────────────
+ validate:
+ name: Validate PR
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Detect platform
+ id: platform
+ run: |
+ # Read platform from XML manifest ( tag) or plain text fallback
+ PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1)
+ [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]')
+ [ -z "$PLATFORM" ] && PLATFORM="generic"
+ echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
+
+ - name: Setup PHP
+ if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
+ run: |
+ if ! command -v php &> /dev/null; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
+ fi
+
+ - name: PHP syntax check
+ if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
+ run: |
+ ERRORS=0
+ while IFS= read -r -d '' file; do
+ if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
+ ERRORS=$((ERRORS + 1))
+ fi
+ done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
+ echo "PHP lint: ${ERRORS} error(s)"
+ [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
+
+ - name: Validate platform manifest
+ run: |
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ case "$PLATFORM" in
+ joomla)
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
+ if [ -z "$MANIFEST" ]; then
+ echo "::warning::No Joomla manifest found (WaaS site)"
+ exit 0
+ fi
+ echo "Manifest: ${MANIFEST}"
+ if command -v php &> /dev/null; then
+ php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
+ fi
+ for ELEMENT in name version description; do
+ grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
+ done
+ echo "Joomla manifest valid"
+ ;;
+ dolibarr)
+ MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
+ if [ -z "$MOD_FILE" ]; then
+ echo "::error::No mod*.class.php found"
+ exit 1
+ fi
+ echo "Dolibarr module: ${MOD_FILE}"
+ ;;
+ *)
+ echo "Generic platform — no manifest validation"
+ ;;
+ esac
+
+ - name: Check update stream format
+ run: |
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ case "$PLATFORM" in
+ joomla)
+ if [ -f "updates.xml" ]; then
+ if command -v php &> /dev/null; then
+ php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
+ fi
+ echo "updates.xml valid"
+ fi
+ ;;
+ dolibarr)
+ [ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
+ ;;
+ esac
+
+ - name: Verify package source
+ run: |
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ if [ ! -d "$SOURCE_DIR" ]; then
+ echo "::warning::No src/ or htdocs/ directory"
+ exit 0
+ fi
+ FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
+ echo "Source: ${FILE_COUNT} files"
+ [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
--
2.52.0
From 7d90fd95de26548dbd0eab7453fa9f775a807fb0 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:53 +0000
Subject: [PATCH 074/114] chore: sync .mokogitea/workflows/pre-release.yml from
template [skip ci]
---
.mokogitea/workflows/pre-release.yml | 384 +++++++++++++++++++++++++++
1 file changed, 384 insertions(+)
create mode 100644 .mokogitea/workflows/pre-release.yml
diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml
new file mode 100644
index 0000000..6e05d96
--- /dev/null
+++ b/.mokogitea/workflows/pre-release.yml
@@ -0,0 +1,384 @@
+# Copyright (C) 2026 Moko Consulting
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Release
+# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
+# PATH: /templates/workflows/universal/pre-release.yml.template
+# VERSION: 05.00.00
+# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
+
+name: "Universal: Pre-Release"
+
+on:
+ workflow_dispatch:
+ inputs:
+ stability:
+ description: 'Pre-release channel'
+ required: true
+ type: choice
+ options:
+ - development
+ - alpha
+ - beta
+ - release-candidate
+
+permissions:
+ contents: write
+
+env:
+ 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 }}
+
+jobs:
+ build:
+ name: "Build Pre-Release (${{ inputs.stability }})"
+ runs-on: release
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GA_TOKEN }}
+
+ - name: Setup PHP
+ run: |
+ if ! command -v php &> /dev/null; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
+ fi
+
+ - name: Detect platform
+ id: platform
+ run: |
+ # Read platform from XML manifest ( tag) or plain text fallback
+ PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/.moko-platform 2>/dev/null | head -1)
+ [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]')
+ [ -z "$PLATFORM" ] && PLATFORM="generic"
+ echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
+ MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1)
+ MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
+ echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
+ echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
+
+ - name: Resolve metadata
+ id: meta
+ run: |
+ STABILITY="${{ inputs.stability }}"
+
+ case "$STABILITY" in
+ development) SUFFIX="-dev"; TAG="development" ;;
+ alpha) SUFFIX="-alpha"; TAG="alpha" ;;
+ beta) SUFFIX="-beta"; TAG="beta" ;;
+ release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
+ esac
+
+ # Read and bump patch version (with rollover)
+ CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
+ [ -z "$CURRENT" ] && CURRENT="00.00.00"
+
+ MAJOR=$(echo "$CURRENT" | cut -d. -f1)
+ MINOR=$(echo "$CURRENT" | cut -d. -f2)
+ PATCH=$(echo "$CURRENT" | cut -d. -f3)
+
+ # Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
+ NEW_PATCH=$((10#$PATCH + 1))
+ NEW_MINOR=$((10#$MINOR))
+ NEW_MAJOR=$((10#$MAJOR))
+
+ if [ $NEW_PATCH -gt 99 ]; then
+ NEW_PATCH=0
+ NEW_MINOR=$((NEW_MINOR + 1))
+ fi
+ if [ $NEW_MINOR -gt 99 ]; then
+ NEW_MINOR=0
+ NEW_MAJOR=$((NEW_MAJOR + 1))
+ fi
+
+ VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
+ TODAY=$(date +%Y-%m-%d)
+
+ echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
+
+ # Update README.md
+ sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
+
+ # Update platform-specific manifest
+ PLATFORM="${{ steps.platform.outputs.platform }}"
+ MANIFEST="${{ steps.platform.outputs.manifest }}"
+ MOD_FILE="${{ steps.platform.outputs.mod_file }}"
+ case "$PLATFORM" in
+ joomla)
+ if [ -n "$MANIFEST" ]; then
+ MANIFEST_VER=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
+ sed -i "s|${MANIFEST_VER} |${VERSION} |" "$MANIFEST"
+ sed -i "s|[^<]* |${TODAY} |" "$MANIFEST"
+ fi
+ ;;
+ dolibarr)
+ if [ -n "$MOD_FILE" ]; then
+ sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
+ fi
+ ;;
+ *) ;;
+ esac
+
+ # Commit version bump
+ git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
+ git config --local user.name "gitea-actions[bot]"
+ git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+ git add -A
+ git diff --cached --quiet || {
+ git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
+ git push origin HEAD 2>&1
+ }
+
+ # Auto-detect element (platform-aware)
+ case "$PLATFORM" in
+ joomla)
+ MANIFEST="${{ steps.platform.outputs.manifest }}"
+ EXT_ELEMENT=""
+ if [ -n "$MANIFEST" ]; then
+ EXT_ELEMENT=$(sed -n 's/.*\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
+ if [ -z "$EXT_ELEMENT" ]; then
+ EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
+ case "$EXT_ELEMENT" in
+ templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
+ esac
+ fi
+ else
+ EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+ ;;
+ dolibarr)
+ MOD_FILE="${{ steps.platform.outputs.mod_file }}"
+ if [ -n "$MOD_FILE" ]; then
+ MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
+ EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
+ else
+ EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ fi
+ ;;
+ *)
+ EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
+ ;;
+ esac
+
+ ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
+
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
+ echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
+ echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
+ echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
+
+ echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
+
+ - name: Build package
+ run: |
+ SOURCE_DIR="src"
+ [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+ if [ ! -d "$SOURCE_DIR" ]; then
+ echo "::error::No src/ or htdocs/ directory"
+ exit 1
+ fi
+
+ mkdir -p build/package
+ rsync -a \
+ --exclude='sftp-config*' \
+ --exclude='.ftpignore' \
+ --exclude='*.ppk' \
+ --exclude='*.pem' \
+ --exclude='*.key' \
+ --exclude='.env*' \
+ --exclude='*.local' \
+ --exclude='.build-trigger' \
+ "${SOURCE_DIR}/" build/package/
+
+ - name: Create ZIP
+ id: zip
+ run: |
+ ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
+ cd build/package
+ zip -r "../${ZIP_NAME}" .
+ cd ..
+
+ SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
+ echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
+ echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
+
+ - name: Create or replace Gitea release
+ id: release
+ run: |
+ TAG="${{ steps.meta.outputs.tag }}"
+ VERSION="${{ steps.meta.outputs.version }}"
+ STABILITY="${{ steps.meta.outputs.stability }}"
+ SHA256="${{ steps.zip.outputs.sha256 }}"
+ ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
+ EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+ API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ BRANCH=$(git branch --show-current)
+
+ BODY="## ${VERSION} ($(date +%Y-%m-%d))
+ **Channel:** ${STABILITY}
+ **SHA-256:** \`${SHA256}\`"
+
+ # Delete existing release
+ EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
+ "${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
+ if [ -n "$EXISTING_ID" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API}/releases/${EXISTING_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API}/tags/${TAG}" 2>/dev/null || true
+ fi
+
+ # Create release
+ RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${API}/releases" \
+ -d "$(jq -n \
+ --arg tag "$TAG" \
+ --arg target "$BRANCH" \
+ --arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
+ --arg body "$BODY" \
+ '{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
+ )" | jq -r '.id')
+
+ echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
+
+ # Upload ZIP
+ curl -sS -X POST -H "Authorization: token ${TOKEN}" \
+ -H "Content-Type: application/octet-stream" \
+ "${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
+ --data-binary "@build/${ZIP_NAME}"
+
+ echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
+
+ - name: Update updates.xml
+ if: steps.platform.outputs.platform == 'joomla'
+ run: |
+ STABILITY="${{ steps.meta.outputs.stability }}"
+ VERSION="${{ steps.meta.outputs.version }}"
+ SHA256="${{ steps.zip.outputs.sha256 }}"
+ ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
+ TAG="${{ steps.meta.outputs.tag }}"
+ DATE=$(date +%Y-%m-%d)
+
+ if [ ! -f "updates.xml" ]; then
+ echo "No updates.xml — skipping"
+ exit 0
+ fi
+
+ export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
+ PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
+ PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
+ python3 << 'PYEOF'
+ import re, os
+
+ stability = os.environ["PY_STABILITY"]
+ version = os.environ["PY_VERSION"]
+ sha256 = os.environ["PY_SHA256"]
+ zip_name = os.environ["PY_ZIP_NAME"]
+ tag = os.environ["PY_TAG"]
+ date = os.environ["PY_DATE"]
+ gitea_org = os.environ["PY_GITEA_ORG"]
+ gitea_repo = os.environ["PY_GITEA_REPO"]
+ download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
+
+ with open("updates.xml", "r") as f:
+ content = f.read()
+
+ # Map stability to XML tag name
+ tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
+ xml_tag = tag_map.get(stability, stability)
+
+ pattern = r"((?:(?! ).)*?" + re.escape(xml_tag) + r" .*?)"
+ match = re.search(pattern, content, re.DOTALL)
+ if match:
+ block = match.group(1)
+ updated = re.sub(r"[^<]* ", f"{version} ", block)
+ updated = re.sub(r"[^<]* ", f"{date} ", updated)
+ if "" in updated:
+ updated = re.sub(r"[^<]* ", f"{sha256} ", updated)
+ else:
+ updated = updated.replace("", f"\n {sha256} ")
+ updated = re.sub(r"(]*>)[^<]*( )", rf"\g<1>{download_url}\g<2>", updated)
+ content = content.replace(block, updated)
+ print(f"Updated {xml_tag} channel: version={version}")
+ else:
+ print(f"WARNING: No {xml_tag} block in updates.xml")
+
+ with open("updates.xml", "w") as f:
+ f.write(content)
+ PYEOF
+
+ # 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]"
+ git add updates.xml
+ git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
+ git push origin HEAD 2>&1 || echo "WARNING: push failed"
+ fi
+
+ - name: "Sync updates.xml to all branches"
+ if: steps.platform.outputs.platform == 'joomla'
+ run: |
+ CURRENT_BRANCH="${{ github.ref_name }}"
+ 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
+
+ echo "Syncing updates.xml → ${BRANCH}"
+ git fetch origin "${BRANCH}" 2>/dev/null || continue
+ git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
+ git checkout "${CURRENT_BRANCH}" -- updates.xml
+ if ! git diff --quiet updates.xml 2>/dev/null; then
+ git add updates.xml
+ git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
+ git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
+ fi
+ git checkout "${CURRENT_BRANCH}" 2>/dev/null
+ done
+
+ - name: "Delete lesser pre-release channels (cascade)"
+ continue-on-error: true
+ run: |
+ API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+ TOKEN="${{ secrets.GA_TOKEN }}"
+ STABILITY="${{ steps.meta.outputs.stability }}"
+
+ # Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
+ case "$STABILITY" in
+ release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
+ beta) TAGS_TO_DELETE="alpha development" ;;
+ alpha) TAGS_TO_DELETE="development" ;;
+ *) TAGS_TO_DELETE="" ;;
+ esac
+
+ [ -z "$TAGS_TO_DELETE" ] && exit 0
+
+ for TAG in $TAGS_TO_DELETE; do
+ RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
+ python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+
+ if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
+ curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/tags/${TAG}" 2>/dev/null || true
+ echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
+ fi
+ done
--
2.52.0
From 413e36859f9d747ada9c300c2585d8777ebc3d84 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:53 +0000
Subject: [PATCH 075/114] chore: sync .mokogitea/workflows/repo-health.yml from
template [skip ci]
---
.mokogitea/workflows/repo-health.yml | 766 +++++++++++++++++++++++++++
1 file changed, 766 insertions(+)
create mode 100644 .mokogitea/workflows/repo-health.yml
diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml
new file mode 100644
index 0000000..e5e1c73
--- /dev/null
+++ b/.mokogitea/workflows/repo-health.yml
@@ -0,0 +1,766 @@
+# ============================================================================
+# Copyright (C) 2025 Moko Consulting
+#
+# This file is part of a Moko Consulting project.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# FILE INFORMATION
+# DEFGROUP: Gitea.Workflow
+# INGROUP: MokoStandards.Validation
+# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
+# PATH: /templates/workflows/joomla/repo_health.yml.template
+# VERSION: 04.06.00
+# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
+# ============================================================================
+
+name: "Joomla: Repo Health"
+
+concurrency:
+ group: repo-health-${{ github.repository }}-${{ github.ref }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: bash
+
+on:
+ workflow_dispatch:
+ inputs:
+ profile:
+ description: 'Validation profile: all, release, scripts, or repo'
+ required: true
+ default: all
+ type: choice
+ options:
+ - all
+ - release
+ - scripts
+ - repo
+ pull_request:
+ push:
+
+permissions:
+ contents: read
+
+env:
+ # Release policy - Repository Variables Only
+ RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
+ RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
+
+ # Scripts governance policy
+ SCRIPTS_REQUIRED_DIRS:
+ SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
+
+ # Repo health policy
+ REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.gitea/workflows/
+ REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
+ REPO_DISALLOWED_DIRS:
+ REPO_DISALLOWED_FILES: TODO.md,todo.md
+
+ # Extended checks toggles
+ EXTENDED_CHECKS: "true"
+
+ # File / directory variables
+ DOCS_INDEX: docs/docs-index.md
+ SCRIPT_DIR: scripts
+ WORKFLOWS_DIR: .gitea/workflows
+ SHELLCHECK_PATTERN: '*.sh'
+ SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+jobs:
+ access_check:
+ name: Access control
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+
+ outputs:
+ allowed: ${{ steps.perm.outputs.allowed }}
+ permission: ${{ steps.perm.outputs.permission }}
+
+ steps:
+ - name: Check actor permission (admin only)
+ id: perm
+ env:
+ TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
+ REPO: ${{ github.repository }}
+ ACTOR: ${{ github.actor }}
+ run: |
+ set -euo pipefail
+ ALLOWED=false
+ PERMISSION=unknown
+ METHOD=""
+
+ # Hardcoded authorized users — always allowed
+ case "$ACTOR" in
+ jmiller|gitea-actions[bot])
+ ALLOWED=true
+ PERMISSION=admin
+ METHOD="hardcoded allowlist"
+ ;;
+ *)
+ # Detect platform and check permissions via API
+ API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}"
+ RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \
+ "${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}')
+ PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown")
+ if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then
+ ALLOWED=true
+ fi
+ METHOD="collaborator API"
+ ;;
+ esac
+
+ echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT"
+ echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
+
+ {
+ echo "## Access Authorization"
+ echo ""
+ echo "| Field | Value |"
+ echo "|-------|-------|"
+ echo "| **Actor** | \`${ACTOR}\` |"
+ echo "| **Repository** | \`${REPO}\` |"
+ echo "| **Permission** | \`${PERMISSION}\` |"
+ echo "| **Method** | ${METHOD} |"
+ echo "| **Authorized** | ${ALLOWED} |"
+ echo ""
+ if [ "$ALLOWED" = "true" ]; then
+ echo "${ACTOR} authorized (${METHOD})"
+ else
+ echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
+ fi
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ - name: Deny execution when not permitted
+ if: ${{ steps.perm.outputs.allowed != 'true' }}
+ run: |
+ set -euo pipefail
+ printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+
+ release_config:
+ name: Release configuration
+ needs: access_check
+ if: ${{ needs.access_check.outputs.allowed == 'true' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+
+ - name: Guardrails release vars
+ env:
+ PROFILE_RAW: ${{ github.event.inputs.profile }}
+ RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
+ DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
+ run: |
+ set -euo pipefail
+
+ profile="${PROFILE_RAW:-all}"
+ case "${profile}" in
+ all|release|scripts|repo) ;;
+ *)
+ printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ ;;
+ esac
+
+ if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
+ {
+ printf '%s\n' '### Release configuration (Repository Variables)'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' 'Status: SKIPPED'
+ printf '%s\n' 'Reason: profile excludes release validation'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 0
+ fi
+
+ IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
+ IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
+
+ missing=()
+ missing_optional=()
+
+ for k in "${required[@]}"; do
+ v="${!k:-}"
+ [ -z "${v}" ] && missing+=("${k}")
+ done
+
+ for k in "${optional[@]}"; do
+ v="${!k:-}"
+ [ -z "${v}" ] && missing_optional+=("${k}")
+ done
+
+ {
+ printf '%s\n' '### Release configuration (Repository Variables)'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' '| Variable | Status |'
+ printf '%s\n' '|---|---|'
+ printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
+ printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ if [ "${#missing_optional[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Missing optional repository variables'
+ for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ if [ "${#missing[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Missing required repository variables'
+ for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ {
+ printf '%s\n' '### Repository variables validation result'
+ printf '%s\n' 'Status: OK'
+ printf '%s\n' 'All required repository variables present.'
+ printf '%s\n' ''
+ printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ scripts_governance:
+ name: Scripts governance
+ needs: access_check
+ if: ${{ needs.access_check.outputs.allowed == 'true' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+
+ - name: Scripts folder checks
+ env:
+ PROFILE_RAW: ${{ github.event.inputs.profile }}
+ run: |
+ set -euo pipefail
+
+ profile="${PROFILE_RAW:-all}"
+ case "${profile}" in
+ all|release|scripts|repo) ;;
+ *)
+ printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ ;;
+ esac
+
+ if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
+ {
+ printf '%s\n' '### Scripts governance'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' 'Status: SKIPPED'
+ printf '%s\n' 'Reason: profile excludes scripts governance'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 0
+ fi
+
+ if [ ! -d "${SCRIPT_DIR}" ]; then
+ {
+ printf '%s\n' '### Scripts governance'
+ printf '%s\n' 'Status: OK (advisory)'
+ printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 0
+ fi
+
+ IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
+ IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
+
+ missing_dirs=()
+ unapproved_dirs=()
+
+ for d in "${required_dirs[@]}"; do
+ req="${d%/}"
+ [ ! -d "${req}" ] && missing_dirs+=("${req}/")
+ done
+
+ while IFS= read -r d; do
+ allowed=false
+ for a in "${allowed_dirs[@]}"; do
+ a_norm="${a%/}"
+ [ "${d%/}" = "${a_norm}" ] && allowed=true
+ done
+ [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
+ done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
+
+ {
+ printf '%s\n' '### Scripts governance'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' '| Area | Status | Notes |'
+ printf '%s\n' '|---|---|---|'
+
+ if [ "${#missing_dirs[@]}" -gt 0 ]; then
+ printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
+ else
+ printf '%s\n' '| Required directories | OK | All required subfolders present |'
+ fi
+
+ if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
+ printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
+ else
+ printf '%s\n' '| Directory policy | OK | No unapproved directories |'
+ fi
+
+ printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
+ printf '\n'
+
+ if [ "${#missing_dirs[@]}" -gt 0 ]; then
+ printf '%s\n' 'Missing required script directories:'
+ for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ else
+ printf '%s\n' 'Missing required script directories: none.'
+ printf '\n'
+ fi
+
+ if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
+ printf '%s\n' 'Unapproved script directories detected:'
+ for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ else
+ printf '%s\n' 'Unapproved script directories detected: none.'
+ printf '\n'
+ fi
+
+ printf '%s\n' 'Scripts governance completed in advisory mode.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ repo_health:
+ name: Repository health
+ needs: access_check
+ if: ${{ needs.access_check.outputs.allowed == 'true' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
+ with:
+ fetch-depth: 0
+
+ - name: Repository health checks
+ env:
+ PROFILE_RAW: ${{ github.event.inputs.profile }}
+ run: |
+ set -euo pipefail
+
+ profile="${PROFILE_RAW:-all}"
+ case "${profile}" in
+ all|release|scripts|repo) ;;
+ *)
+ printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ ;;
+ esac
+
+ if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
+ {
+ printf '%s\n' '### Repository health'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' 'Status: SKIPPED'
+ printf '%s\n' 'Reason: profile excludes repository health'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 0
+ fi
+
+ # Source directory: src/ or htdocs/ (either is valid)
+ if [ -d "src" ]; then
+ SOURCE_DIR="src"
+ elif [ -d "htdocs" ]; then
+ SOURCE_DIR="htdocs"
+ else
+ missing_required+=("src/ or htdocs/ (source directory required)")
+ fi
+
+ IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
+ IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
+ IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
+ IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
+
+ missing_required=()
+ missing_optional=()
+
+ for item in "${required_artifacts[@]}"; do
+ if printf '%s' "${item}" | grep -q '/$'; then
+ d="${item%/}"
+ [ ! -d "${d}" ] && missing_required+=("${item}")
+ else
+ [ ! -f "${item}" ] && missing_required+=("${item}")
+ fi
+ done
+
+ for f in "${optional_files[@]}"; do
+ if printf '%s' "${f}" | grep -q '/$'; then
+ d="${f%/}"
+ [ ! -d "${d}" ] && missing_optional+=("${f}")
+ else
+ [ ! -f "${f}" ] && missing_optional+=("${f}")
+ fi
+ done
+
+ for d in "${disallowed_dirs[@]}"; do
+ d_norm="${d%/}"
+ [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
+ done
+
+ for f in "${disallowed_files[@]}"; do
+ [ -f "${f}" ] && missing_required+=("${f} (disallowed)")
+ done
+
+ git fetch origin --prune
+
+ dev_paths=()
+ dev_branches=()
+
+ while IFS= read -r b; do
+ name="${b#origin/}"
+ if [ "${name}" = 'dev' ]; then
+ dev_branches+=("${name}")
+ else
+ dev_paths+=("${name}")
+ fi
+ done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
+
+ if [ "${#dev_paths[@]}" -eq 0 ]; then
+ missing_required+=("dev/* branch (e.g. dev/01.00.00)")
+ fi
+
+ if [ "${#dev_branches[@]}" -gt 0 ]; then
+ missing_required+=("invalid branch dev (must be dev/)")
+ fi
+
+ content_warnings=()
+
+ if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
+ content_warnings+=("CHANGELOG.md missing '# Changelog' header")
+ fi
+
+ if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
+ content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
+ fi
+
+ if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
+ content_warnings+=("LICENSE does not look like a GPL text")
+ fi
+
+ if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
+ content_warnings+=("README.md missing expected brand keyword")
+ fi
+
+ export PROFILE_RAW="${profile}"
+ export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
+ export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
+ export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
+
+ report_json="$(python3 - <<'PY'
+ import json
+ import os
+
+ profile = os.environ.get('PROFILE_RAW') or 'all'
+
+ missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
+ missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
+ content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
+
+ out = {
+ 'profile': profile,
+ 'missing_required': [x for x in missing_required if x],
+ 'missing_optional': [x for x in missing_optional if x],
+ 'content_warnings': [x for x in content_warnings if x],
+ }
+
+ print(json.dumps(out, indent=2))
+ PY
+ )"
+
+ {
+ printf '%s\n' '### Repository health'
+ printf '%s\n' "Profile: ${profile}"
+ printf '%s\n' '| Metric | Value |'
+ printf '%s\n' '|---|---|'
+ printf '%s\n' "| Missing required | ${#missing_required[@]} |"
+ printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
+ printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
+ printf '\n'
+
+ printf '%s\n' '### Guardrails report (JSON)'
+ printf '%s\n' '```json'
+ printf '%s\n' "${report_json}"
+ printf '%s\n' '```'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ if [ "${#missing_required[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Missing required repo artifacts'
+ for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ if [ "${#missing_optional[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Missing optional repo artifacts'
+ for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ if [ "${#content_warnings[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Repo content warnings'
+ for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ # -- Joomla-specific checks --
+ joomla_findings=()
+
+ MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/dev/null | head -1 || true)"
+ if [ -z "${MANIFEST}" ]; then
+ joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)")
+ else
+ if ! grep -qP '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: tag missing")
+ fi
+ if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: type attribute missing or invalid")
+ fi
+ if ! grep -qP '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: tag missing")
+ fi
+ if ! grep -qP '' "${MANIFEST}"; then
+ joomla_findings+=("XML manifest: tag missing")
+ fi
+ if ! grep -qP ' missing (required for Joomla 5+)")
+ fi
+ fi
+
+ INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
+ if [ "${INI_COUNT}" -eq 0 ]; then
+ joomla_findings+=("No .ini language files found")
+ fi
+
+ if [ ! -f 'updates.xml' ]; then
+ joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
+ fi
+
+ INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
+ for dir in "${INDEX_DIRS[@]}"; do
+ if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
+ joomla_findings+=("${dir}/index.html missing (directory listing protection)")
+ fi
+ done
+
+ if [ "${#joomla_findings[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Joomla extension checks'
+ printf '%s\n' '| Check | Status |'
+ printf '%s\n' '|---|---|'
+ for f in "${joomla_findings[@]}"; do
+ printf '%s\n' "| ${f} | Warning |"
+ done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ else
+ {
+ printf '%s\n' '### Joomla extension checks'
+ printf '%s\n' 'All Joomla-specific checks passed.'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ extended_enabled="${EXTENDED_CHECKS:-true}"
+ extended_findings=()
+
+ if [ "${extended_enabled}" = 'true' ]; then
+ if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
+ :
+ else
+ extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
+ fi
+
+ if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
+ bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
+ if [ -n "${bad_refs}" ]; then
+ extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
+ {
+ printf '%s\n' '### Workflow pinning advisory'
+ printf '%s\n' 'Found uses: entries pinned to main/master:'
+ printf '%s\n' '```'
+ printf '%s\n' "${bad_refs}"
+ printf '%s\n' '```'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+ fi
+
+ if [ -f "${DOCS_INDEX}" ]; then
+ missing_links="$(python3 - <<'PY'
+ import os
+ import re
+
+ idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
+ base = os.getcwd()
+
+ bad = []
+ pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
+
+ with open(idx, 'r', encoding='utf-8') as f:
+ for line in f:
+ for m in pat.findall(line):
+ link = m.strip()
+ if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
+ continue
+ if link.startswith('/'):
+ rel = link.lstrip('/')
+ else:
+ rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
+ rel = rel.split('#', 1)[0]
+ rel = rel.split('?', 1)[0]
+ if not rel:
+ continue
+ p = os.path.join(base, rel)
+ if not os.path.exists(p):
+ bad.append(rel)
+
+ print('\n'.join(sorted(set(bad))))
+ PY
+ )"
+ if [ -n "${missing_links}" ]; then
+ extended_findings+=("docs/docs-index.md contains broken relative links")
+ {
+ printf '%s\n' '### Docs index link integrity'
+ printf '%s\n' 'Broken relative links:'
+ while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+ fi
+
+ if [ -d "${SCRIPT_DIR}" ]; then
+ if ! command -v shellcheck >/dev/null 2>&1; then
+ sudo apt-get update -qq
+ sudo apt-get install -y shellcheck >/dev/null
+ fi
+
+ sc_out=''
+ while IFS= read -r shf; do
+ [ -z "${shf}" ] && continue
+ out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
+ if [ -n "${out_one}" ]; then
+ sc_out="${sc_out}${out_one}\n"
+ fi
+ done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
+
+ if [ -n "${sc_out}" ]; then
+ extended_findings+=("ShellCheck warnings detected (advisory)")
+ sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
+ {
+ printf '%s\n' '### ShellCheck (advisory)'
+ printf '%s\n' '```'
+ printf '%s\n' "${sc_head}"
+ printf '%s\n' '```'
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+ fi
+
+ spdx_missing=()
+ IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
+ spdx_args=()
+ for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
+
+ while IFS= read -r f; do
+ [ -z "${f}" ] && continue
+ if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
+ spdx_missing+=("${f}")
+ fi
+ done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
+
+ if [ "${#spdx_missing[@]}" -gt 0 ]; then
+ extended_findings+=("SPDX header missing in some tracked files (advisory)")
+ {
+ printf '%s\n' '### SPDX header advisory'
+ printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
+ for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ stale_cutoff_days=180
+ stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)"
+ if [ -n "${stale_branches}" ]; then
+ extended_findings+=("Stale remote branches detected (advisory)")
+ {
+ printf '%s\n' '### Git hygiene advisory'
+ printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
+ while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+ fi
+
+ {
+ printf '%s\n' '### Guardrails coverage matrix'
+ printf '%s\n' '| Domain | Status | Notes |'
+ printf '%s\n' '|---|---|---|'
+ printf '%s\n' '| Access control | OK | Admin-only execution gate |'
+ printf '%s\n' '| Release variables | OK | Repository variables validation |'
+ printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
+ printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
+ printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
+ if [ "${extended_enabled}" = 'true' ]; then
+ if [ "${#extended_findings[@]}" -gt 0 ]; then
+ printf '%s\n' '| Extended checks | Warning | See extended findings below |'
+ else
+ printf '%s\n' '| Extended checks | OK | No findings |'
+ fi
+ else
+ printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
+ fi
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
+ {
+ printf '%s\n' '### Extended findings (advisory)'
+ for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
+ printf '\n'
+ } >> "${GITHUB_STEP_SUMMARY}"
+ fi
+
+ printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
--
2.52.0
From 6b7a7a0c14d859a6459d79484cf93a89321c9cf9 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:54 +0000
Subject: [PATCH 076/114] chore: sync .mokogitea/workflows/security-audit.yml
from template [skip ci]
---
.mokogitea/workflows/security-audit.yml | 82 +++++++++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 .mokogitea/workflows/security-audit.yml
diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml
new file mode 100644
index 0000000..789325a
--- /dev/null
+++ b/.mokogitea/workflows/security-audit.yml
@@ -0,0 +1,82 @@
+# 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/MokoStandards
+# PATH: /.gitea/workflows/security-audit.yml
+# VERSION: 01.00.00
+# BRIEF: Dependency vulnerability scanning for composer and npm packages
+
+name: "Universal: Security Audit"
+
+on:
+ schedule:
+ - cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
+ pull_request:
+ branches:
+ - main
+ paths:
+ - 'composer.json'
+ - 'composer.lock'
+ - 'package.json'
+ - 'package-lock.json'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
+ NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
+
+jobs:
+ audit:
+ name: Dependency Audit
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Composer audit
+ if: hashFiles('composer.lock') != ''
+ run: |
+ echo "=== Composer Security Audit ==="
+ if ! command -v composer &> /dev/null; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
+ fi
+ composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
+ RESULT=$?
+ if [ $RESULT -ne 0 ]; then
+ echo "::warning::Composer vulnerabilities found"
+ echo "composer_vulnerable=true" >> "$GITHUB_ENV"
+ else
+ echo "No known vulnerabilities in composer dependencies"
+ fi
+
+ - name: NPM audit
+ if: hashFiles('package-lock.json') != ''
+ run: |
+ echo "=== NPM Security Audit ==="
+ npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
+ if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
+ echo "No known vulnerabilities in npm dependencies"
+ else
+ echo "::warning::NPM vulnerabilities found"
+ echo "npm_vulnerable=true" >> "$GITHUB_ENV"
+ fi
+
+ - name: Notify on vulnerabilities
+ if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
+ run: |
+ REPO="${{ github.event.repository.name }}"
+ curl -sS \
+ -H "Title: ${REPO} has vulnerable dependencies" \
+ -H "Tags: lock,warning" \
+ -H "Priority: high" \
+ -d "Security audit found vulnerabilities. Review dependency updates." \
+ "${NTFY_URL}/${NTFY_TOPIC}" || true
--
2.52.0
From 87c1558549283f22e1827dad7a7dbe79f847ed38 Mon Sep 17 00:00:00 2001
From: Jonathan Miller
Date: Tue, 12 May 2026 18:56:54 +0000
Subject: [PATCH 077/114] chore: sync .mokogitea/workflows/update-server.yml
from template [skip ci]
---
.mokogitea/workflows/update-server.yml | 464 +++++++++++++++++++++++++
1 file changed, 464 insertions(+)
create mode 100644 .mokogitea/workflows/update-server.yml
diff --git a/.mokogitea/workflows/update-server.yml b/.mokogitea/workflows/update-server.yml
new file mode 100644
index 0000000..6e617f6
--- /dev/null
+++ b/.mokogitea/workflows/update-server.yml
@@ -0,0 +1,464 @@
+# Copyright (C) 2026 Moko Consulting