refactor: update sync engine to use new canonical workflow sources

- Replace old .template files with actual workflow YMLs from template repos
- Update RepositorySynchronizer to use new 10/11-workflow standard
- Remove legacy shared workflows (enterprise-firewall, auto-assign, etc.)
- Joomla workflows sourced from MokoStandards-Template-Joomla
- Dolibarr workflows sourced from MokoStandards-Template-Dolibarr

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-02 18:12:35 -05:00
parent 0119834cef
commit a4fbcc0f87
42 changed files with 3335 additions and 5687 deletions
+25 -39
View File
@@ -1047,51 +1047,37 @@ HCL;
$root = rtrim($repoRoot, '/');
$wfDir = $this->adapter->getWorkflowDir();
// Common workflows for ALL platforms
$shared = [
['templates/workflows/shared/enterprise-firewall-setup.yml.template', "{$wfDir}/enterprise-firewall-setup.yml"],
['templates/workflows/shared/sync-version-on-merge.yml.template', "{$wfDir}/sync-version-on-merge.yml"],
['templates/workflows/shared/repository-cleanup.yml.template', "{$wfDir}/repository-cleanup.yml"],
['templates/workflows/shared/auto-dev-issue.yml.template', "{$wfDir}/auto-dev-issue.yml"],
['templates/workflows/shared/branch-freeze.yml.template', "{$wfDir}/branch-freeze.yml"],
['templates/workflows/shared/auto-assign.yml.template', "{$wfDir}/auto-assign.yml"],
['templates/workflows/shared/changelog-validation.yml.template', "{$wfDir}/changelog-validation.yml"],
['templates/workflows/shared/deploy-rs.yml.template', "{$wfDir}/deploy-rs.yml"],
['templates/workflows/shared/export-mysql.yml.template', "{$wfDir}/export-mysql.yml"],
['templates/workflows/shared/pull-from-dev.yml.template', "{$wfDir}/pull-from-dev.yml"],
['.github/workflows/standards-compliance.yml', "{$wfDir}/standards-compliance.yml"],
['templates/workflows/shared/cleanup.yml', "{$wfDir}/cleanup.yml"],
['templates/workflows/shared/notify.yml', "{$wfDir}/notify.yml"],
['templates/workflows/shared/pr-check.yml', "{$wfDir}/pr-check.yml"],
['templates/workflows/shared/pre-release.yml', "{$wfDir}/pre-release.yml"],
['templates/workflows/shared/security-audit.yml', "{$wfDir}/security-audit.yml"],
];
// CodeQL is GitHub-only; on Gitea, Trivy replaces it
if ($this->adapter->getPlatformName() === 'github') {
$shared[] = ['.github/workflows/codeql-analysis.yml', "{$wfDir}/codeql-analysis.yml"];
}
// Platform-specific workflows
if ($platform === 'crm-module') {
$shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"];
$shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"];
$shared[] = ['templates/workflows/dolibarr/auto-release.yml.template', "{$wfDir}/auto-release.yml"];
$shared[] = ['templates/workflows/dolibarr/ci-dolibarr.yml.template', "{$wfDir}/ci-dolibarr.yml"];
$shared[] = ['templates/workflows/dolibarr/publish-to-mokodolimods.yml.template', "{$wfDir}/publish-to-mokodolimods.yml"];
$shared[] = ['templates/workflows/dolibarr/repo_health.yml.template', "{$wfDir}/repo_health.yml"];
} elseif ($platform === 'crm-platform') {
$shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"];
$shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"];
$shared[] = ['templates/workflows/dolibarr/auto-release.yml.template', "{$wfDir}/auto-release.yml"];
$shared[] = ['templates/workflows/dolibarr/ci-dolibarr.yml.template', "{$wfDir}/ci-dolibarr.yml"];
if ($platform === 'crm-module' || $platform === 'crm-platform') {
// Dolibarr: 11 workflows
$shared[] = ['templates/workflows/dolibarr/auto-release.yml', "{$wfDir}/auto-release.yml"];
$shared[] = ['templates/workflows/dolibarr/ci-dolibarr.yml', "{$wfDir}/ci-dolibarr.yml"];
$shared[] = ['templates/workflows/dolibarr/deploy-manual.yml', "{$wfDir}/deploy-manual.yml"];
$shared[] = ['templates/workflows/dolibarr/repo-health.yml', "{$wfDir}/repo-health.yml"];
$shared[] = ['templates/workflows/dolibarr/update-server.yml', "{$wfDir}/update-server.yml"];
$shared[] = ['templates/workflows/dolibarr/publish-to-mokodolimods.yml', "{$wfDir}/publish-to-mokodolimods.yml"];
} elseif ($platform === 'waas-component' || $platform === 'joomla-template') {
$shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"];
$shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"];
$shared[] = ['templates/workflows/joomla/auto-release.yml.template', "{$wfDir}/auto-release.yml"];
$shared[] = ['templates/workflows/joomla/update-server.yml.template', "{$wfDir}/update-server.yml"];
$shared[] = ['templates/workflows/joomla/ci-joomla.yml.template', "{$wfDir}/ci-joomla.yml"];
$shared[] = ['templates/workflows/joomla/repo_health.yml.template', "{$wfDir}/repo_health.yml"];
$shared[] = ['templates/workflows/joomla/deploy-manual.yml.template', "{$wfDir}/deploy-manual.yml"];
$shared[] = ['templates/workflows/joomla/deploy.yml.template', "{$wfDir}/deploy.yml"];
// Joomla: 10 workflows
$shared[] = ['templates/workflows/joomla/auto-release.yml', "{$wfDir}/auto-release.yml"];
$shared[] = ['templates/workflows/joomla/ci-joomla.yml', "{$wfDir}/ci-joomla.yml"];
$shared[] = ['templates/workflows/joomla/deploy-manual.yml', "{$wfDir}/deploy-manual.yml"];
$shared[] = ['templates/workflows/joomla/repo-health.yml', "{$wfDir}/repo-health.yml"];
$shared[] = ['templates/workflows/joomla/update-server.yml', "{$wfDir}/update-server.yml"];
} else {
$shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"];
$shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"];
$shared[] = ['templates/workflows/shared/auto-release.yml.template', "{$wfDir}/auto-release.yml"];
// Generic: 9 workflows (shared only, no platform CI)
$shared[] = ['templates/workflows/joomla/auto-release.yml', "{$wfDir}/auto-release.yml"];
$shared[] = ['templates/workflows/joomla/deploy-manual.yml', "{$wfDir}/deploy-manual.yml"];
$shared[] = ['templates/workflows/joomla/repo-health.yml', "{$wfDir}/repo-health.yml"];
$shared[] = ['templates/workflows/joomla/update-server.yml', "{$wfDir}/update-server.yml"];
}
// CODEOWNERS — GitHub only; Gitea doesn't enforce it
@@ -1,372 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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/dolibarr/auto-release.yml.template
# VERSION: 04.06.00
# BRIEF: Dolibarr build & release — module validation, update.txt
#
# +========================================================================+
# | BUILD & RELEASE PIPELINE (DOLIBARR) |
# +========================================================================+
# | |
# | Triggers on push to main (skips bot commits + [skip ci]): |
# | |
# | Every push: |
# | 1. Read version from README.md |
# | 3. Set platform version (Dolibarr $this->version) |
# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files |
# | 5. Write update.txt (version string) |
# | 6. Create git tag vXX.YY.ZZ |
# | 7a. Patch: update existing GitHub Release for this minor |
# | |
# | Every version change: archives main -> version/XX.YY branch |
# | Patch 00 = development (no release). First release = patch 01. |
# | First release only (patch == 01): |
# | 7b. Create new GitHub Release |
# | |
# +========================================================================+
name: Build & Release
on:
pull_request:
types: [closed]
branches:
- main
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
jobs:
release:
name: Build & Release Pipeline
runs-on: ubuntu-latest
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 || secrets.GA_TOKEN || github.token }}
fetch-depth: 0
- 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 {{standards_branch}} --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=v${MAJOR}" >> "$GITHUB_OUTPUT"
if [ "$PATCH" = "00" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "is_minor=false" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (patch 00 = development — skipping release)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
if [ "$PATCH" = "01" ]; then
echo "is_minor=true" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (first release — full pipeline)"
else
echo "is_minor=false" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (patch — platform version + badges only)"
fi
fi
- 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"
if [ "$TAG_EXISTS" = "true" ] && [ "$BRANCH_EXISTS" = "true" ]; then
echo "already_released=true" >> "$GITHUB_OUTPUT"
else
echo "already_released=false" >> "$GITHUB_OUTPUT"
fi
# -- SANITY CHECKS -------------------------------------------------------
- name: "Sanity: Pre-release validation"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
ERRORS=0
echo "## Pre-Release Sanity Checks (Dolibarr)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# -- Version drift check (must pass before release) --------
README_VER=$(grep -oP 'VERSION:\s*\K[\d.]+' 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=$(grep -oP 'VERSION:\s*\K[\d.]+' 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=$(grep -oP '"version"\s*:\s*"\K[^"]+' 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
# -- Dolibarr: module descriptor check --------
MOD_FILE=$(find src htdocs -path "*/core/modules/mod*.class.php" -print -quit 2>/dev/null)
if [ -z "$MOD_FILE" ]; then
echo "- No module descriptor (src/core/modules/mod*.class.php)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
else
echo "- Module descriptor: \`${MOD_FILE}\`" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr: $this->numero check --------
NUMERO=$(grep -oP '\$this->numero\s*=\s*\K\d+' "$MOD_FILE" 2>/dev/null || echo "0")
if [ "$NUMERO" = "0" ] || [ -z "$NUMERO" ]; then
echo "- Module number (\$this->numero) is 0 or not set" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
else
echo "- Module number: ${NUMERO}" >> $GITHUB_STEP_SUMMARY
fi
# -- Dolibarr: url_last_version check --------
if grep -q 'url_last_version' "$MOD_FILE" 2>/dev/null; then
echo "- url_last_version is set" >> $GITHUB_STEP_SUMMARY
else
echo "- Warning: url_last_version not set — update checks won't work" >> $GITHUB_STEP_SUMMARY
fi
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.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.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.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 update.txt --------------------------------------------
- name: "Step 5: Write update.txt"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
printf '%s' "$VERSION" > update.txt
echo "update.txt: ${VERSION}" >> $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.version.outputs.version }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add -A
git commit -m "chore(release): build ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
# -- 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 GitHub Release ------------------------------
- name: "Step 7: GitHub Release"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.tag_exists != 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
BRANCH="${{ steps.version.outputs.branch }}"
MAJOR="${{ steps.version.outputs.major }}"
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
[ -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" \
--title "v${MAJOR} (latest: ${VERSION})" \
--notes-file /tmp/release_notes.md \
--target "$BRANCH"
echo "Release created: ${RELEASE_TAG} (${VERSION})" >> $GITHUB_STEP_SUMMARY
else
CURRENT_NOTES=$(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 ".body // empty" || true)
{
echo "$CURRENT_NOTES"
echo ""
echo "---"
echo "### ${VERSION}"
echo ""
cat /tmp/release_notes.md
} > /tmp/updated_notes.md
gh release edit "$RELEASE_TAG" \
--title "v${MAJOR} (latest: ${VERSION})" \
--notes-file /tmp/updated_notes.md
echo "Release updated: ${RELEASE_TAG} -> ${VERSION}" >> $GITHUB_STEP_SUMMARY
fi
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
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 (Dolibarr)" >> $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](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
@@ -5,21 +5,28 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow.Template
# DEFGROUP: GitHub.Workflow.Template
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /templates/workflows/dolibarr/ci-dolibarr.yml.template
# VERSION: 04.06.00
# BRIEF: CI workflow for Dolibarr modules — lint, validate, test
# NOTE: Deployed to .gitea/workflows/ci-dolibarr.yml in governed Dolibarr module repos.
# NOTE: Deployed to .github/workflows/ci-dolibarr.yml in governed Dolibarr module repos.
name: Dolibarr Module CI
on:
push:
branches:
- main
- dev/**
- rc/**
- version/**
pull_request:
branches:
- main
- 'dev/**'
- dev/**
- rc/**
workflow_dispatch:
permissions:
@@ -39,22 +46,24 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0
with:
php-version: '8.2'
extensions: mbstring, xml, zip, gd, curl, json
tools: composer:v2
coverage: none
- 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' }}
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
git clone --depth 1 --branch {{standards_branch}} --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api
git clone --depth 1 --branch version/04 --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
/tmp/mokostandards
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
@@ -94,7 +103,7 @@ jobs:
EXIT=${PIPESTATUS[0]}
else
echo "validate-module not in vendor/bin — running from MokoStandards"
php /tmp/mokostandards-api/bin/validate-module --path . 2>&1 | tee /tmp/validate.log
php /tmp/mokostandards/bin/validate-module --path . 2>&1 | tee /tmp/validate.log
EXIT=${PIPESTATUS[0]}
fi
echo "### Module Validation" >> $GITHUB_STEP_SUMMARY
@@ -113,15 +122,15 @@ jobs:
if [ -f "phpcs.xml" ]; then
if [ -x "vendor/bin/phpcs" ]; then
vendor/bin/phpcs --standard=phpcs.xml src/ htdocs/ || true
elif [ -x "/tmp/mokostandards-api/vendor/bin/phpcs" ]; then
/tmp/mokostandards-api/vendor/bin/phpcs --standard=phpcs.xml src/ htdocs/ || true
elif [ -x "/tmp/mokostandards/vendor/bin/phpcs" ]; then
/tmp/mokostandards/vendor/bin/phpcs --standard=phpcs.xml src/ htdocs/ || true
fi
else
STANDARD=""
if [ -f "vendor/mokoconsulting-tech/enterprise/phpcs.xml" ]; then
STANDARD="vendor/mokoconsulting-tech/enterprise/phpcs.xml"
elif [ -f "/tmp/mokostandards-api/phpcs.xml" ]; then
STANDARD="/tmp/mokostandards-api/phpcs.xml"
elif [ -f "/tmp/mokostandards/phpcs.xml" ]; then
STANDARD="/tmp/mokostandards/phpcs.xml"
fi
if [ -n "$STANDARD" ]; then
DIRS=""
@@ -131,8 +140,8 @@ jobs:
if [ -n "$DIRS" ]; then
if [ -x "vendor/bin/phpcs" ]; then
vendor/bin/phpcs --standard="$STANDARD" $DIRS || true
elif [ -x "/tmp/mokostandards-api/vendor/bin/phpcs" ]; then
/tmp/mokostandards-api/vendor/bin/phpcs --standard="$STANDARD" $DIRS || true
elif [ -x "/tmp/mokostandards/vendor/bin/phpcs" ]; then
/tmp/mokostandards/vendor/bin/phpcs --standard="$STANDARD" $DIRS || true
fi
fi
fi
@@ -151,21 +160,21 @@ jobs:
if [ -f "phpstan.neon" ]; then
if [ -x "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse -c phpstan.neon $DIRS || true
elif [ -x "/tmp/mokostandards-api/vendor/bin/phpstan" ]; then
/tmp/mokostandards-api/vendor/bin/phpstan analyse -c phpstan.neon $DIRS || true
elif [ -x "/tmp/mokostandards/vendor/bin/phpstan" ]; then
/tmp/mokostandards/vendor/bin/phpstan analyse -c phpstan.neon $DIRS || true
fi
else
CONFIG=""
if [ -f "vendor/mokoconsulting-tech/enterprise/phpstan.neon" ]; then
CONFIG="vendor/mokoconsulting-tech/enterprise/phpstan.neon"
elif [ -f "/tmp/mokostandards-api/phpstan.neon" ]; then
CONFIG="/tmp/mokostandards-api/phpstan.neon"
elif [ -f "/tmp/mokostandards/phpstan.neon" ]; then
CONFIG="/tmp/mokostandards/phpstan.neon"
fi
if [ -n "$CONFIG" ]; then
if [ -x "vendor/bin/phpstan" ]; then
vendor/bin/phpstan analyse -c "$CONFIG" $DIRS || true
elif [ -x "/tmp/mokostandards-api/vendor/bin/phpstan" ]; then
/tmp/mokostandards-api/vendor/bin/phpstan analyse -c "$CONFIG" $DIRS || true
elif [ -x "/tmp/mokostandards/vendor/bin/phpstan" ]; then
/tmp/mokostandards/vendor/bin/phpstan analyse -c "$CONFIG" $DIRS || true
fi
fi
fi
@@ -263,12 +272,16 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP ${{ matrix.php }}
run: |
php -v && composer --version
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0
with:
php-version: ${{ matrix.php }}
extensions: mbstring, xml, zip, gd, curl, json
tools: composer:v2
coverage: none
- name: Install dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
if [ -f "composer.json" ]; then
composer install \
+87
View File
@@ -0,0 +1,87 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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)"
@@ -9,8 +9,6 @@
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
# NOTE: Joomla repos use updates.xml for distribution. This is for manual
# dev server testing only -- triggered via workflow_dispatch.
name: Deploy to Dev (Manual)
+70
View File
@@ -0,0 +1,70 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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"
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}"
+106
View File
@@ -0,0 +1,106 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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 '<extension' {} \; 2>/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; }
@@ -0,0 +1,274 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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
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)
NEW_PATCH=$(printf "%02d" $((10#$PATCH + 1)))
VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
TODAY=$(date +%Y-%m-%d)
echo "Bumping: ${CURRENT} → ${VERSION}"
# 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 '<extension' {} \; 2>/dev/null | head -1)
if [ -n "$MANIFEST" ]; then
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$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 '<extension' {} \; 2>/dev/null | head -1)
EXT_ELEMENT=""
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/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"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
match = re.search(pattern, content, re.DOTALL)
if match:
block = match.group(1)
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
if "<sha256>" in updated:
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
else:
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", 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 <tag>{xml_tag}</tag> block in updates.xml")
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit and push
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
@@ -3,9 +3,9 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow.Dolibarr
# DEFGROUP: GitHub.Workflow.Dolibarr
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /templates/workflows/dolibarr/publish-to-mokodolimods.yml.template
# VERSION: 04.06.00
# BRIEF: On release, copies src/ into htdocs/custom/$DEV_FTP_SUFFIX in mokodolimods and opens a PR
@@ -149,7 +149,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: mokoconsulting-tech/mokodolimods
token: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
token: ${{ secrets.GH_TOKEN }}
path: mokodolimods
- name: Create release branch
@@ -207,7 +207,7 @@ jobs:
- name: Create pull request on mokodolimods
if: steps.commit.outputs.changed == 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
MODULE="${{ steps.branch.outputs.module }}"
TAG="${{ steps.branch.outputs.tag }}"
@@ -9,10 +9,9 @@
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /.gitea/workflows/repo_health.yml
# 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.
# NOTE: Field is user-managed.
# ============================================================================
name: Repo Health
@@ -50,12 +49,10 @@ env:
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy
# Note: directories listed without a trailing slash.
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
# Files are listed as-is; directories must end with a trailing slash.
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:
@@ -64,7 +61,7 @@ env:
# Extended checks toggles
EXTENDED_CHECKS: "true"
# File / directory variables (moved to top-level env)
# File / directory variables
DOCS_INDEX: docs/docs-index.md
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .github/workflows
@@ -121,7 +118,7 @@ jobs:
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
{
echo "## 🔐 Access Authorization"
echo "## Access Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
@@ -132,9 +129,9 @@ jobs:
echo "| **Authorized** | ${ALLOWED} |"
echo ""
if [ "$ALLOWED" = "true" ]; then
echo "${ACTOR} authorized (${METHOD})"
echo "${ACTOR} authorized (${METHOD})"
else
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
fi
} >> "${GITHUB_STEP_SUMMARY}"
@@ -421,7 +418,6 @@ jobs:
fi
done
# Optional entries: handle files and directories (trailing slash indicates dir)
for f in "${optional_files[@]}"; do
if printf '%s' "${f}" | grep -q '/$'; then
d="${f%/}"
@@ -445,8 +441,6 @@ jobs:
dev_paths=()
dev_branches=()
# Look for remote branches matching origin/dev*.
# A plain origin/dev is considered invalid; we require dev/<something> branches.
while IFS= read -r b; do
name="${b#origin/}"
if [ "${name}" = 'dev' ]; then
@@ -456,12 +450,10 @@ jobs:
fi
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
# If there are no dev/* branches, fail the guardrail.
if [ "${#dev_paths[@]}" -eq 0 ]; then
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
fi
# If a plain dev branch exists (origin/dev), flag it as invalid.
if [ "${#dev_branches[@]}" -gt 0 ]; then
missing_required+=("invalid branch dev (must be dev/<version>)")
fi
@@ -553,48 +545,39 @@ jobs:
} >> "${GITHUB_STEP_SUMMARY}"
fi
# ── Joomla-specific checks ───────────────────────────────────────
# -- Joomla-specific checks --
joomla_findings=()
# XML manifest: find any XML file containing <extension
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
if [ -z "${MANIFEST}" ]; then
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
else
# Check <version> tag exists
if ! grep -qP '<version>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <version> tag missing")
fi
# Check extension type attribute
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
joomla_findings+=("XML manifest: type attribute missing or invalid")
fi
# Check <name> tag
if ! grep -qP '<name>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <name> tag missing")
fi
# Check <author> tag
if ! grep -qP '<author>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <author> tag missing")
fi
# Check <namespace> for Joomla 5+
if ! grep -qP '<namespace' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
fi
fi
# Language files: check for at least one .ini file
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
# updates.xml must exist in root (Joomla update server)
if [ ! -f 'updates.xml' ]; then
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
fi
# index.html files for directory listing protection
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
for dir in "${INDEX_DIRS[@]}"; do
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
@@ -624,14 +607,12 @@ jobs:
extended_findings=()
if [ "${extended_enabled}" = 'true' ]; then
# CODEOWNERS presence
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
:
else
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
fi
# Workflow pinning advisory: flag uses @main/@master
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
@@ -647,7 +628,6 @@ jobs:
fi
fi
# Docs index link integrity (docs/docs-index.md)
if [ -f "${DOCS_INDEX}" ]; then
missing_links="$(python3 - <<'PY'
import os
@@ -691,7 +671,6 @@ jobs:
fi
fi
# ShellCheck advisory
if [ -d "${SCRIPT_DIR}" ]; then
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
@@ -720,7 +699,6 @@ jobs:
fi
fi
# SPDX header advisory for common source types
spdx_missing=()
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
spdx_args=()
@@ -743,9 +721,8 @@ jobs:
} >> "${GITHUB_STEP_SUMMARY}"
fi
# Git hygiene advisory: branches older than 180 days (remote)
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 [...]
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)")
{
@@ -0,0 +1,82 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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
@@ -82,7 +82,7 @@ jobs:
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
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
@@ -131,10 +131,10 @@ jobs:
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
# Parse manifest (portable — no grep -P)
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
# Parse manifest (portable no grep -P)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "No Joomla manifest found — skipping"
echo "No Joomla manifest found skipping"
exit 0
fi
@@ -270,36 +270,34 @@ jobs:
SHA256=""
fi
# -- Build the new entry -----------------------------------------
# -- Build the new entry (canonical format matching release.yml) --
NEW_ENTRY=""
NEW_ENTRY="${NEW_ENTRY} <update>\n"
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} (${STABILITY})</description>\n"
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
NEW_ENTRY="${NEW_ENTRY} <version>${DISPLAY_VERSION}</version>\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} <tags>\n"
NEW_ENTRY="${NEW_ENTRY} <tag>${STABILITY}</tag>\n"
NEW_ENTRY="${NEW_ENTRY} </tags>\n"
NEW_ENTRY="${NEW_ENTRY} <infourl title=\"${EXT_NAME}\">${INFO_URL}</infourl>\n"
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
NEW_ENTRY="${NEW_ENTRY} <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>\n"
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
NEW_ENTRY="${NEW_ENTRY} ${TARGET_PLATFORM}\n"
[ -n "$PHP_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${PHP_TAG}\n"
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
NEW_ENTRY="${NEW_ENTRY} </update>"
# -- Write new entry to temp file --------------------------------
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
# -- Merge into updates.xml (only update this stability channel) -
# Cascading update: each stability level updates itself and all lower levels
# stable → all | rc → rc,beta,alpha,dev | beta → beta,alpha,dev | alpha → alpha,dev | dev → dev
# -- 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
@@ -312,62 +310,54 @@ jobs:
done
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
echo "Cascade: ${STABILITY} → ${TARGETS}"
# Create updates.xml if missing
if [ ! -f "updates.xml" ]; then
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting <hello@mokoconsulting.tech>" >> updates.xml
printf '%s\n' " SPDX-License-Identifier: GPL-3.0-or-later" >> updates.xml
printf '%s\n' " VERSION: ${VERSION}" >> updates.xml
printf '%s\n' " -->" >> updates.xml
printf '%s\n' "" >> updates.xml
printf '%s\n' '<updates>' >> updates.xml
cat /tmp/new_entry.xml >> updates.xml
printf '\n%s\n' '</updates>' >> updates.xml
else
# Replace each cascading channel with the new entry (different tag)
export PY_TARGETS="$TARGETS"
python3 << PYEOF
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
printf '%s\n' "<updates>" >> updates.xml
printf '%s\n' "</updates>" >> 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(",")
stability = "${STABILITY}"
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
# Build entry with this tag's name
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
# Remove existing entry for this tag
pattern = r" <update>.*?<tag>" + re.escape(tag) + r"</tag>.*?</update>\n?"
content = re.sub(pattern, "", content, flags=re.DOTALL)
# Insert before </updates>
content = content.replace("</updates>", new_entry + "\n</updates>")
# Try to find existing block (handles both single-line and multi-line <tags>)
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
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>{tag}</tag> → {version}")
else:
# Create — insert before </updates>
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
print(f" CREATED: <tag>{tag}</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
if [ $? -ne 0 ]; then
# Fallback: rebuild keeping other stability entries
{
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>"
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting <hello@mokoconsulting.tech>"
printf '%s\n' " SPDX-License-Identifier: GPL-3.0-or-later"
printf '%s\n' " VERSION: ${VERSION}"
printf '%s\n' " -->"
printf '%s\n' ""
printf '%s\n' '<updates>'
for TAG in stable rc development; do
[ "$TAG" = "${STABILITY}" ] && continue
if grep -q "<tag>${TAG}</tag>" updates.xml 2>/dev/null; then
sed -n "/<update>/,/<\/update>/{ /<tag>${TAG}<\/tag>/p; }" updates.xml
fi
done
cat /tmp/new_entry.xml
printf '\n%s\n' '</updates>'
} > /tmp/updates_new.xml
mv /tmp/updates_new.xml updates.xml
fi
fi
# Commit
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
@@ -386,41 +376,26 @@ jobs:
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}" \n "${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
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}" \n -H "Content-Type: application/json" \n "${API_BASE}/contents/updates.xml" \n -d "$(python3 -c "import json; print(json.dumps({
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 \n && echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \n || echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
}))")" > /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
# -- Mirror to GitHub (stable and rc only) --------------------------------
- name: Mirror release to GitHub
if: >-
(steps.update.outputs.stability == 'stable' || steps.update.outputs.stability == 'rc') &&
secrets.GH_TOKEN != ''
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
STABILITY="${{ steps.update.outputs.stability }}"
echo "GitHub mirror sync for ${STABILITY} — ${GH_REPO}" >> $GITHUB_STEP_SUMMARY
# Mirror packages if they exist
for PKG in /tmp/*.zip /tmp/*.tar.gz; 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
- name: SFTP deploy to dev server
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
env:
@@ -443,12 +418,12 @@ jobs:
case "$PERMISSION" in
admin|maintain|write) ;;
*)
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or 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; }
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured skipping SFTP"; exit 0; }
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
+709
View File
@@ -0,0 +1,709 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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 <version>) |
# | 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
- 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.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 '<extension' {} \; 2>/dev/null | head -1)
if [ -n "$MANIFEST" ]; then
XML_VER=$(sed -n 's/.*<version>\([^<]*\)<\/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/.*<extension[^>]*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.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.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.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"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
REPO="${{ github.repository }}"
# -- Parse extension metadata from XML manifest ----------------
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/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>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/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:
# 1. Try XML filename (e.g. mokowaas.xml → mokowaas)
# 2. Fall back to repo name (lowercased)
if [ -z "$EXT_ELEMENT" ]; then
EXT_ELEMENT=$(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 ' -') ;;
esac
fi
# Build client tag: plugins and frontend modules need <client>site</client>
CLIENT_TAG=""
if [ -n "$EXT_CLIENT" ]; then
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
CLIENT_TAG="<client>site</client>"
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="<folder>${EXT_FOLDER}</folder>"
fi
# Build targetplatform (fallback to Joomla 5 if not in manifest)
if [ -z "$TARGET_PLATFORM" ]; then
TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
fi
# Build php_minimum tag
PHP_TAG=""
if [ -n "$PHP_MINIMUM" ]; then
PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
fi
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/stable/${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' ' <update>'
printf '%s\n' " <name>${EXT_NAME}</name>"
printf '%s\n' " <description>${EXT_NAME} update</description>"
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
printf '%s\n' " <type>${EXT_TYPE}</type>"
printf '%s\n' " <version>${VERSION}</version>"
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
printf '%s\n' " <tags><tag>${TAG_NAME}</tag></tags>"
printf '%s\n' " <infourl title=\"${EXT_NAME}\">${INFO_URL}</infourl>"
printf '%s\n' ' <downloads>'
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
printf '%s\n' ' </downloads>'
printf '%s\n' " ${TARGET_PLATFORM}"
[ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}"
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
printf '%s\n' ' </update>'
}
# -- Write updates.xml with cascading channels
# Stable release updates ALL channels (development, alpha, beta, rc, stable)
{
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>"
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting <hello@mokoconsulting.tech>"
printf '%s\n' " SPDX-License-Identifier: GPL-3.0-or-later"
printf '%s\n' " VERSION: ${VERSION}"
printf '%s\n' " -->"
printf '%s\n' ""
printf '%s\n' '<updates>'
build_entry "development"
build_entry "alpha"
build_entry "beta"
build_entry "rc"
build_entry "stable"
printf '%s\n' '</updates>'
} > 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.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] <gitea-actions[bot]@mokoconsulting.tech>"
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.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}"
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
# Check if the major release already exists
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 [ -z "$EXISTING_ID" ]; then
# First release for this major
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': 'v${MAJOR} (latest: ${VERSION})',
'body': '''${NOTES}''',
'target_commitish': '${BRANCH}'
}))")"
echo "Release created: ${RELEASE_TAG} (${VERSION})" >> $GITHUB_STEP_SUMMARY
else
# Append version notes to existing major release
CURRENT_BODY=$(echo "$EXISTING" | python3 -c "import sys,json; print(json.load(sys.stdin).get('body',''))" 2>/dev/null || true)
UPDATED_BODY="${CURRENT_BODY}
---
### ${VERSION}
${NOTES}"
curl -sf -X PATCH -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
-H "Content-Type: application/json" \
"${API_BASE}/releases/${EXISTING_ID}" \
-d "$(python3 -c "import json,sys; print(json.dumps({
'name': 'v${MAJOR} (latest: ${VERSION})',
'body': sys.stdin.read()
}))" <<< "$UPDATED_BODY")"
echo "Release updated: ${RELEASE_TAG} -> ${VERSION}" >> $GITHUB_STEP_SUMMARY
fi
# -- 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.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 '<extension' {} \; 2>/dev/null | head -1 || true)
[ -z "$MANIFEST" ] && exit 0
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/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"
# -- 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 = (
" <downloads>\n"
f" <downloadurl type=\"full\" format=\"zip\">{zip_url}</downloadurl>\n"
" </downloads>"
)
block = re.sub(r' <downloads>.*?</downloads>', new_downloads, block, flags=re.DOTALL)
# Add or replace sha256
if '<sha256>' in block:
block = re.sub(r' <sha256>.*?</sha256>', f' <sha256>{sha}</sha256>', block)
else:
block = block.replace('</downloads>', f'</downloads>\n <sha256>{sha}</sha256>')
return block
content = re.sub(
r' <update>.*?<tag>stable</tag>.*?</update>',
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] <gitea-actions[bot]@mokoconsulting.tech>" || 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 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.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"
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
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
@@ -11,7 +11,6 @@
# PATH: /templates/workflows/joomla/ci-joomla.yml.template
# VERSION: 04.06.00
# BRIEF: CI workflow for Joomla extensions — lint, validate, test
# NOTE: Deployed to .gitea/workflows/ci-joomla.yml in governed Joomla extension repos.
name: Joomla Extension CI
+87
View File
@@ -0,0 +1,87 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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)"
@@ -0,0 +1,126 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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
@@ -1,205 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: GitHub.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy.yml.template
# VERSION: 01.01.00
# BRIEF: Auto-deploy src/ to dev/live servers via SSH rsync on push
#
# Generic workflow for all MokoWaaS client repos.
#
# Required repo variables:
# DEPLOY_SSH_HOST -- SSH hostname for dev server (e.g. dev.mokoconsulting.tech)
# DEPLOY_SSH_USER -- SSH username (e.g. clarksvillefurs)
# DEPLOY_SSH_PORT -- SSH port (default: 22)
# DEV_DEPLOY_PATH -- Absolute remote path for dev (e.g. /home/user/dev.example.com/public_html)
# LIVE_DEPLOY_PATH -- Absolute remote path for live (e.g. /home/user/example.com/public_html)
#
# Optional repo variables:
# LIVE_SSH_HOST -- SSH hostname for live server (falls back to DEPLOY_SSH_HOST)
#
# Required org/repo secret:
# DEPLOY_SSH_KEY -- Private SSH key for authentication
#
# See: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards/src/branch/main/docs/deployment/ssh-deploy.md
name: Deploy
on:
push:
branches: [dev, main]
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
target:
description: 'Deploy target'
required: true
default: 'dev'
type: choice
options:
- dev
- live
dry_run:
description: 'Dry run (show what would be deployed without deploying)'
required: false
default: false
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: Deploy to ${{ github.event_name == 'workflow_dispatch' && inputs.target || (github.ref_name == 'main' && 'Live' || 'Dev') }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Determine deploy target
id: target
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TARGET="${{ inputs.target }}"
elif [ "${{ github.ref_name }}" = "main" ]; then
TARGET="live"
else
TARGET="dev"
fi
echo "env=${TARGET}" >> "$GITHUB_OUTPUT"
if [ "$TARGET" = "live" ]; then
echo "remote_path=${{ vars.LIVE_DEPLOY_PATH }}" >> "$GITHUB_OUTPUT"
echo "ssh_host=${{ vars.LIVE_SSH_HOST || vars.DEPLOY_SSH_HOST }}" >> "$GITHUB_OUTPUT"
else
echo "remote_path=${{ vars.DEV_DEPLOY_PATH }}" >> "$GITHUB_OUTPUT"
echo "ssh_host=${{ vars.DEPLOY_SSH_HOST }}" >> "$GITHUB_OUTPUT"
fi
# Resolve source directory
if [ -d "src" ]; then
echo "source_dir=src" >> "$GITHUB_OUTPUT"
elif [ -d "htdocs" ]; then
echo "source_dir=htdocs" >> "$GITHUB_OUTPUT"
else
echo "source_dir=" >> "$GITHUB_OUTPUT"
fi
- name: Validate configuration
run: |
ERRORS=0
if [ -z "${{ steps.target.outputs.ssh_host }}" ]; then
echo "::error::SSH host not configured (DEPLOY_SSH_HOST or LIVE_SSH_HOST)"
ERRORS=$((ERRORS + 1))
fi
if [ -z "${{ vars.DEPLOY_SSH_USER }}" ]; then
echo "::error::DEPLOY_SSH_USER variable not configured"
ERRORS=$((ERRORS + 1))
fi
if [ -z "${{ steps.target.outputs.remote_path }}" ]; then
echo "::error::${{ steps.target.outputs.env == 'live' && 'LIVE' || 'DEV' }}_DEPLOY_PATH variable not configured"
ERRORS=$((ERRORS + 1))
fi
if [ -z "${{ steps.target.outputs.source_dir }}" ]; then
echo "::error::No src/ or htdocs/ directory found -- nothing to deploy"
ERRORS=$((ERRORS + 1))
fi
if [ "$ERRORS" -gt 0 ]; then
echo "### Deploy Failed -- Missing Configuration" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Set the required variables in **Settings > Actions > Variables**." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
PORT="${{ vars.DEPLOY_SSH_PORT }}"
[ -z "$PORT" ] && PORT="22"
ssh-keyscan -p "$PORT" "${{ steps.target.outputs.ssh_host }}" >> ~/.ssh/known_hosts 2>/dev/null
cat > ~/.ssh/config <<SSHEOF
Host deploy-target
HostName ${{ steps.target.outputs.ssh_host }}
User ${{ vars.DEPLOY_SSH_USER }}
Port ${PORT}
IdentityFile ~/.ssh/deploy_key
StrictHostKeyChecking accept-new
BatchMode yes
ConnectTimeout 30
SSHEOF
chmod 600 ~/.ssh/config
- name: Test SSH connection
run: ssh deploy-target "echo 'SSH connection OK'"
- name: Deploy via rsync
run: |
SOURCE="${{ steps.target.outputs.source_dir }}"
REMOTE="${{ steps.target.outputs.remote_path }}"
DRY_RUN="${{ inputs.dry_run }}"
RSYNC_ARGS=(
-rlptDvz
--checksum
--exclude='sftp-config*.json'
--exclude='*.ffs_db'
--exclude='*.sync-*'
--exclude='.DS_Store'
--exclude='Thumbs.db'
--exclude='.git/'
--exclude='.env*'
--exclude='*.ppk'
--exclude='*.pem'
--exclude='*.key'
)
[ "$DRY_RUN" = "true" ] && RSYNC_ARGS+=(--dry-run)
rsync "${RSYNC_ARGS[@]}" \
-e "ssh -F ~/.ssh/config" \
"${SOURCE}/" \
"deploy-target:${REMOTE}/"
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key ~/.ssh/config
- name: Summary
if: always()
run: |
TARGET="${{ steps.target.outputs.env }}"
DRY_RUN="${{ inputs.dry_run }}"
echo "## Deploy Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Target | \`${TARGET}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Host | \`${{ steps.target.outputs.ssh_host }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Remote | \`${{ steps.target.outputs.remote_path }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Source | \`${{ steps.target.outputs.source_dir }}/\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Trigger | \`${{ github.event_name }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Branch | \`${{ github.ref_name }}\` |" >> "$GITHUB_STEP_SUMMARY"
if [ "$DRY_RUN" = "true" ]; then
echo "| Mode | **Dry Run** (no files changed) |" >> "$GITHUB_STEP_SUMMARY"
fi
+70
View File
@@ -0,0 +1,70 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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"
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}"
+106
View File
@@ -0,0 +1,106 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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 '<extension' {} \; 2>/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; }
+274
View File
@@ -0,0 +1,274 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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
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)
NEW_PATCH=$(printf "%02d" $((10#$PATCH + 1)))
VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
TODAY=$(date +%Y-%m-%d)
echo "Bumping: ${CURRENT} → ${VERSION}"
# 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 '<extension' {} \; 2>/dev/null | head -1)
if [ -n "$MANIFEST" ]; then
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$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 '<extension' {} \; 2>/dev/null | head -1)
EXT_ELEMENT=""
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/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"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
match = re.search(pattern, content, re.DOTALL)
if match:
block = match.group(1)
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
if "<sha256>" in updated:
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
else:
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", 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 <tag>{xml_tag}</tag> block in updates.xml")
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit and push
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
@@ -9,10 +9,9 @@
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /.gitea/workflows/repo_health.yml
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 04.06.00
# BRIEF: Dolibarr module health checks — validates release config, module descriptor, repo artifacts, and scripts governance.
# NOTE: Field is user-managed.
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
# ============================================================================
name: Repo Health
@@ -50,21 +49,19 @@ env:
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
# Scripts governance policy
# Note: directories listed without a trailing slash.
SCRIPTS_REQUIRED_DIRS:
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
# Repo health policy
# Files are listed as-is; directories must end with a trailing slash.
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,docs/,update.txt
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
REPO_DISALLOWED_DIRS:
REPO_DISALLOWED_FILES: TODO.md,todo.md,update.json
REPO_DISALLOWED_FILES: TODO.md,todo.md
# Extended checks toggles
EXTENDED_CHECKS: "true"
# File / directory variables (moved to top-level env)
# File / directory variables
DOCS_INDEX: docs/docs-index.md
SCRIPT_DIR: scripts
WORKFLOWS_DIR: .github/workflows
@@ -121,7 +118,7 @@ jobs:
echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT"
{
echo "## 🔐 Access Authorization"
echo "## Access Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
@@ -132,9 +129,9 @@ jobs:
echo "| **Authorized** | ${ALLOWED} |"
echo ""
if [ "$ALLOWED" = "true" ]; then
echo "${ACTOR} authorized (${METHOD})"
echo "${ACTOR} authorized (${METHOD})"
else
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
echo "${ACTOR} is NOT authorized. Requires admin or maintain role."
fi
} >> "${GITHUB_STEP_SUMMARY}"
@@ -421,7 +418,6 @@ jobs:
fi
done
# Optional entries: handle files and directories (trailing slash indicates dir)
for f in "${optional_files[@]}"; do
if printf '%s' "${f}" | grep -q '/$'; then
d="${f%/}"
@@ -445,8 +441,6 @@ jobs:
dev_paths=()
dev_branches=()
# Look for remote branches matching origin/dev*.
# A plain origin/dev is considered invalid; we require dev/<something> branches.
while IFS= read -r b; do
name="${b#origin/}"
if [ "${name}" = 'dev' ]; then
@@ -456,12 +450,10 @@ jobs:
fi
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
# If there are no dev/* branches, fail the guardrail.
if [ "${#dev_paths[@]}" -eq 0 ]; then
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
fi
# If a plain dev branch exists (origin/dev), flag it as invalid.
if [ "${#dev_branches[@]}" -gt 0 ]; then
missing_required+=("invalid branch dev (must be dev/<version>)")
fi
@@ -553,57 +545,60 @@ jobs:
} >> "${GITHUB_STEP_SUMMARY}"
fi
# ── Dolibarr-specific checks ──────────────────────────────────────
dolibarr_findings=()
# -- Joomla-specific checks --
joomla_findings=()
# Module descriptor: src/core/modules/mod*.class.php
MOD_FILE="$(find src htdocs -path '*/core/modules/mod*.class.php' -print -quit 2>/dev/null || true)"
if [ -z "${MOD_FILE}" ]; then
dolibarr_findings+=("Module descriptor not found (src/core/modules/mod*.class.php)")
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
if [ -z "${MANIFEST}" ]; then
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
else
# Check $this->numero is set and non-zero
if ! grep -qP '\$this->numero\s*=\s*[1-9]' "${MOD_FILE}"; then
dolibarr_findings+=("Module descriptor: \$this->numero not set or is zero")
if ! grep -qP '<version>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <version> tag missing")
fi
# Check $this->version is not hardcoded (should be set by workflow)
if grep -qP "\\\$this->version\s*=\s*'[0-9]" "${MOD_FILE}"; then
dolibarr_findings+=("Module descriptor: \$this->version appears hardcoded (should be set by deploy/release workflow)")
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
joomla_findings+=("XML manifest: type attribute missing or invalid")
fi
# Check url_last_version points to update.txt
if grep -qP 'url_last_version.*update\.json' "${MOD_FILE}"; then
dolibarr_findings+=("Module descriptor: url_last_version points to update.json (must be update.txt)")
if ! grep -qP '<name>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <name> tag missing")
fi
# Check url_last_version contains /main/ for main branch
CURRENT_BRANCH="${GITHUB_REF_NAME:-main}"
if [ "${CURRENT_BRANCH}" = "main" ] && ! grep -qP 'url_last_version.*\/main\/' "${MOD_FILE}"; then
dolibarr_findings+=("Module descriptor: url_last_version does not reference /main/ branch")
if ! grep -qP '<author>' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <author> tag missing")
fi
if ! grep -qP '<namespace' "${MANIFEST}"; then
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 5+)")
fi
fi
# Source README should exist (Dolibarr module store requirement)
if [ -n "${SOURCE_DIR:-}" ] && [ ! -f "${SOURCE_DIR}/README.md" ]; then
dolibarr_findings+=("${SOURCE_DIR}/README.md missing (required for Dolibarr module store)")
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
# update.txt should exist in root (created by auto-release)
if [ ! -f 'update.txt' ]; then
dolibarr_findings+=("update.txt missing in root (created by auto-release workflow)")
if [ ! -f 'updates.xml' ]; then
joomla_findings+=("updates.xml missing in root (required for Joomla update server)")
fi
if [ "${#dolibarr_findings[@]}" -gt 0 ]; then
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' '### Dolibarr module checks'
printf '%s\n' '### Joomla extension checks'
printf '%s\n' '| Check | Status |'
printf '%s\n' '|---|---|'
for f in "${dolibarr_findings[@]}"; do
for f in "${joomla_findings[@]}"; do
printf '%s\n' "| ${f} | Warning |"
done
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
else
{
printf '%s\n' '### Dolibarr module checks'
printf '%s\n' 'All Dolibarr-specific checks passed.'
printf '%s\n' '### Joomla extension checks'
printf '%s\n' 'All Joomla-specific checks passed.'
printf '\n'
} >> "${GITHUB_STEP_SUMMARY}"
fi
@@ -612,14 +607,12 @@ jobs:
extended_findings=()
if [ "${extended_enabled}" = 'true' ]; then
# CODEOWNERS presence
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
:
else
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
fi
# Workflow pinning advisory: flag uses @main/@master
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
@@ -635,7 +628,6 @@ jobs:
fi
fi
# Docs index link integrity (docs/docs-index.md)
if [ -f "${DOCS_INDEX}" ]; then
missing_links="$(python3 - <<'PY'
import os
@@ -679,7 +671,6 @@ jobs:
fi
fi
# ShellCheck advisory
if [ -d "${SCRIPT_DIR}" ]; then
if ! command -v shellcheck >/dev/null 2>&1; then
sudo apt-get update -qq
@@ -708,7 +699,6 @@ jobs:
fi
fi
# SPDX header advisory for common source types
spdx_missing=()
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
spdx_args=()
@@ -731,9 +721,8 @@ jobs:
} >> "${GITHUB_STEP_SUMMARY}"
fi
# Git hygiene advisory: branches older than 180 days (remote)
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 [...]
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)")
{
@@ -0,0 +1,82 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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
@@ -0,0 +1,464 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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 <update> entries:
# - <tag>stable</tag> on push to main (from auto-release)
# - <tag>rc</tag> on push to rc/**
# - <tag>development</tag> 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] <gitea-actions[bot]@mokoconsulting.tech>" 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 '<extension' {} \; 2>/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>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/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 '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
CLIENT_TAG=""
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
FOLDER_TAG=""
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
PHP_TAG=""
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</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} <update>\n"
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</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>${VERSION}</version>\n"
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
NEW_ENTRY="${NEW_ENTRY} </update>"
# -- 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' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
printf '%s\n' "<updates>" >> updates.xml
printf '%s\n' "</updates>" >> 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"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
# Try to find existing block (handles both single-line and multi-line <tags>)
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
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>{tag}</tag> → {version}")
else:
# Create — insert before </updates>
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
print(f" CREATED: <tag>{tag}</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] <gitea-actions[bot]@mokoconsulting.tech>"
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
@@ -1,76 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Workflows.Shared
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /.gitea/workflows/auto-assign.yml
# VERSION: 04.06.00
# BRIEF: Auto-assign jmiller to unassigned issues and PRs every 15 minutes
name: Auto-Assign Issues & PRs
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
schedule:
- cron: '0 */12 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
auto-assign:
name: Assign unassigned issues and PRs
runs-on: ubuntu-latest
steps:
- name: Assign unassigned issues
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
ASSIGNEE="jmiller"
echo "## 🏷️ Auto-Assign Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
ASSIGNED_ISSUES=0
ASSIGNED_PRS=0
# Assign unassigned open issues
ISSUES=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/issues?state=open&per_page=100&assignee=none" 2>/dev/null | jq -r '.[].number' || true)
for NUM in $ISSUES; do
# Skip PRs (the issues endpoint returns PRs too)
IS_PR=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/issues/$NUM" 2>/dev/null | jq -r '.pull_request // empty' || true)
if [ -z "$IS_PR" ]; then
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/issues/$NUM/assignees" -X POST -f "assignees[]=$ASSIGNEE" --silent 2>/dev/null && {
ASSIGNED_ISSUES=$((ASSIGNED_ISSUES + 1))
echo " Assigned issue #$NUM"
} || true
fi
done
# Assign unassigned open PRs
PRS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/pulls?state=open&per_page=100" 2>/dev/null | jq -r '.[] | select(.assignees | length == 0) | .number' || true)
for NUM in $PRS; do
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/issues/$NUM/assignees" -X POST -f "assignees[]=$ASSIGNEE" --silent 2>/dev/null && {
ASSIGNED_PRS=$((ASSIGNED_PRS + 1))
echo " Assigned PR #$NUM"
} || true
done
echo "| Type | Assigned |" >> $GITHUB_STEP_SUMMARY
echo "|------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| Issues | $ASSIGNED_ISSUES |" >> $GITHUB_STEP_SUMMARY
echo "| Pull Requests | $ASSIGNED_PRS |" >> $GITHUB_STEP_SUMMARY
if [ "$ASSIGNED_ISSUES" -eq 0 ] && [ "$ASSIGNED_PRS" -eq 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ All issues and PRs already have assignees" >> $GITHUB_STEP_SUMMARY
fi
@@ -1,207 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Automation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/shared/auto-dev-issue.yml.template
# VERSION: 04.06.00
# BRIEF: Auto-create tracking issue with sub-issues for dev/rc branch workflow
# NOTE: Synced via bulk-repo-sync to .gitea/workflows/auto-dev-issue.yml in all governed repos.
name: Dev/RC Branch Issue
on:
# Auto-create on RC branch creation
create:
# Manual trigger for dev branches
workflow_dispatch:
inputs:
branch:
description: 'Branch name (e.g., dev/my-feature or dev/04.06)'
required: true
type: string
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
issues: write
jobs:
create-issue:
name: Create version tracking issue
runs-on: ubuntu-latest
if: >-
(github.event_name == 'workflow_dispatch') ||
(github.event.ref_type == 'branch' &&
(startsWith(github.event.ref, 'rc/') ||
startsWith(github.event.ref, 'alpha/') ||
startsWith(github.event.ref, 'beta/')))
steps:
- name: Create tracking issue and sub-issues
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
# For manual dispatch, use input; for auto, use event ref
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BRANCH="${{ inputs.branch }}"
else
BRANCH="${{ github.event.ref }}"
fi
REPO="${{ github.repository }}"
ACTOR="${{ github.actor }}"
NOW=$(date -u '+%Y-%m-%d %H:%M UTC')
# Determine branch type and version
if [[ "$BRANCH" == rc/* ]]; then
VERSION="${BRANCH#rc/}"
BRANCH_TYPE="Release Candidate"
LABEL_TYPE="type: release"
TITLE_PREFIX="rc"
elif [[ "$BRANCH" == beta/* ]]; then
VERSION="${BRANCH#beta/}"
BRANCH_TYPE="Beta"
LABEL_TYPE="type: release"
TITLE_PREFIX="beta"
elif [[ "$BRANCH" == alpha/* ]]; then
VERSION="${BRANCH#alpha/}"
BRANCH_TYPE="Alpha"
LABEL_TYPE="type: release"
TITLE_PREFIX="alpha"
else
VERSION="${BRANCH#dev/}"
BRANCH_TYPE="Development"
LABEL_TYPE="type: feature"
TITLE_PREFIX="feat"
fi
TITLE="${TITLE_PREFIX}(${VERSION}): ${BRANCH_TYPE} tracking for ${BRANCH}"
# Check for existing issue with same title prefix
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues?state=open&per_page=10" 2>/dev/null \
--jq ".[] | select(.title | startswith(\"${TITLE_PREFIX}(${VERSION})\")) | .number" 2>/dev/null | head -1)
if [ -n "$EXISTING" ]; then
echo "️ Issue #${EXISTING} already exists for ${VERSION}" >> $GITHUB_STEP_SUMMARY
exit 0
fi
# ── Define sub-issues for the workflow ─────────────────────────
if [[ "$BRANCH" == rc/* ]]; then
SUB_ISSUES=(
"RC Testing|Verify all features work on rc branch|type: test,release-candidate"
"Regression Testing|Run full regression suite before merge|type: test,release-candidate"
"Version Bump|Bump version in README.md and all headers|type: version,release-candidate"
"Changelog Update|Update CHANGELOG.md with release notes|documentation,release-candidate"
"Merge to Version Branch|Create PR to version/XX|type: release,needs-review"
)
elif [[ "$BRANCH" == alpha/* ]] || [[ "$BRANCH" == beta/* ]]; then
SUB_ISSUES=(
"Testing|Verify features on ${BRANCH_TYPE} branch|type: test,status: in-progress"
"Bug Fixes|Fix issues found during ${BRANCH_TYPE} testing|type: bug,status: pending"
"Promote to Next Stage|Create PR to promote to next release stage|type: release,needs-review"
)
else
SUB_ISSUES=(
"Development|Implement feature/fix on dev branch|type: feature,status: in-progress"
"Unit Testing|Write and pass unit tests|type: test,status: pending"
"Code Review|Request and complete code review|needs-review,status: pending"
"Version Bump|Bump version in README.md and all headers|type: version,status: pending"
"Changelog Update|Update CHANGELOG.md with release notes|documentation,status: pending"
"Create RC Branch|Promote dev to rc branch for final testing|type: release,status: pending"
"Merge to Main|Create PR from rc/dev to main|type: release,needs-review,status: pending"
)
fi
# ── Create sub-issues first ───────────────────────────────────────
SUB_LIST=""
SUB_NUMBERS=""
for SUB in "${SUB_ISSUES[@]}"; do
IFS='|' read -r SUB_TITLE SUB_DESC SUB_LABELS <<< "$SUB"
SUB_FULL_TITLE="${TITLE_PREFIX}(${VERSION}): ${SUB_TITLE}"
SUB_BODY=$(printf '### %s\n\n%s\n\n| Field | Value |\n|-------|-------|\n| **Parent Branch** | `%s` |\n| **Version** | `%s` |\n\n---\n*Sub-issue of the %s tracking issue for `%s`.*' \
"$SUB_TITLE" "$SUB_DESC" "$BRANCH" "$VERSION" "$BRANCH_TYPE" "$BRANCH")
SUB_URL=$(gh issue create \
--repo "$REPO" \
--title "$SUB_FULL_TITLE" \
--body "$SUB_BODY" \
--label "${SUB_LABELS}" \
--assignee "jmiller" 2>&1)
SUB_NUM=$(echo "$SUB_URL" | grep -oE '[0-9]+$')
if [ -n "$SUB_NUM" ]; then
SUB_LIST="${SUB_LIST}\n- [ ] ${SUB_TITLE} (#${SUB_NUM})"
SUB_NUMBERS="${SUB_NUMBERS} #${SUB_NUM}"
fi
sleep 0.3
done
# ── Create parent tracking issue ──────────────────────────────────
PARENT_BODY=$(printf '## %s Branch Created\n\n| Field | Value |\n|-------|-------|\n| **Branch** | `%s` |\n| **Version** | `%s` |\n| **Type** | %s |\n| **Created by** | @%s |\n| **Created at** | %s |\n| **Repository** | `%s` |\n\n## Workflow Sub-Issues\n\n%b\n\n---\n*Auto-created by [auto-dev-issue.yml](.gitea/workflows/auto-dev-issue.yml) on branch creation.*' \
"$BRANCH_TYPE" "$BRANCH" "$VERSION" "$BRANCH_TYPE" "$ACTOR" "$NOW" "$REPO" "$SUB_LIST")
PARENT_URL=$(gh issue create \
--repo "$REPO" \
--title "$TITLE" \
--body "$PARENT_BODY" \
--label "${LABEL_TYPE},version" \
--assignee "jmiller" 2>&1)
PARENT_NUM=$(echo "$PARENT_URL" | grep -oE '[0-9]+$')
# ── Link sub-issues back to parent ────────────────────────────────
if [ -n "$PARENT_NUM" ]; then
for SUB in "${SUB_ISSUES[@]}"; do
IFS='|' read -r SUB_TITLE _ _ <<< "$SUB"
SUB_FULL_TITLE="${TITLE_PREFIX}(${VERSION}): ${SUB_TITLE}"
SUB_NUM=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues?state=open&per_page=20" 2>/dev/null \
--jq ".[] | select(.title == \"${SUB_FULL_TITLE}\") | .number" 2>/dev/null | head -1)
if [ -n "$SUB_NUM" ]; then
curl -sf -X PATCH -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/issues/${SUB_NUM}" \
-f body="$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues/${SUB_NUM}" | jq -r '.body' 2>/dev/null)
> **Parent Issue:** #${PARENT_NUM}" --silent 2>/dev/null || true
fi
sleep 0.2
done
fi
# ── Create or update prerelease for alpha/beta/rc ────────────────
if [[ "$BRANCH" == rc/* ]] || [[ "$BRANCH" == alpha/* ]] || [[ "$BRANCH" == beta/* ]]; then
case "$BRANCH_TYPE" in
Alpha) RELEASE_TAG="alpha" ;;
Beta) RELEASE_TAG="beta" ;;
"Release Candidate") RELEASE_TAG="release-candidate" ;;
esac
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" \
--title "${RELEASE_TAG} (${VERSION})" \
--notes "## ${BRANCH_TYPE} ${VERSION}\n\nBranch: \`${BRANCH}\`\nTracking issue: ${PARENT_URL}" \
--prerelease \
--target main 2>/dev/null || true
echo "${BRANCH_TYPE} release created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
else
gh release edit "$RELEASE_TAG" \
--title "${RELEASE_TAG} (${VERSION})" --prerelease 2>/dev/null || true
echo "${BRANCH_TYPE} release updated: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
fi
fi
# ── Summary ───────────────────────────────────────────────────────
echo "## Dev Workflow Issues Created" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Item | Issue |" >> $GITHUB_STEP_SUMMARY
echo "|------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| **Parent** | ${PARENT_URL} |" >> $GITHUB_STEP_SUMMARY
echo "| **Sub-issues** |${SUB_NUMBERS} |" >> $GITHUB_STEP_SUMMARY
@@ -1,339 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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/shared/auto-release.yml.template
# VERSION: 04.06.00
# BRIEF: Generic build & release pipeline — version branch, platform version, badges, tag, release
#
# +========================================================================+
# | BUILD & RELEASE PIPELINE |
# +========================================================================+
# | |
# | Triggers on push to main (skips bot commits + [skip ci]): |
# | |
# | Every push: |
# | 1. Read version from README.md |
# | 3. Set platform version |
# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files |
# | 6. Create git tag vXX.YY.ZZ |
# | 7a. Patch: update existing GitHub Release for this minor |
# | |
# | Every version change: archives main -> version/XX.YY branch |
# | Patch 00 = development (no release). First release = patch 01. |
# | First release only (patch == 01): |
# | 7b. Create new GitHub Release |
# | |
# +========================================================================+
name: Build & Release
on:
pull_request:
types: [closed]
branches:
- main
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
jobs:
release:
name: Build & Release Pipeline
runs-on: ubuntu-latest
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 || secrets.GA_TOKEN || github.token }}
fetch-depth: 0
- 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 {{standards_branch}} --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=v${MAJOR}" >> "$GITHUB_OUTPUT"
if [ "$PATCH" = "00" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "is_minor=false" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (patch 00 = development — skipping release)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
if [ "$PATCH" = "01" ]; then
echo "is_minor=true" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (first release — full pipeline)"
else
echo "is_minor=false" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (patch — platform version + badges only)"
fi
fi
- 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"
if [ "$TAG_EXISTS" = "true" ] && [ "$BRANCH_EXISTS" = "true" ]; then
echo "already_released=true" >> "$GITHUB_OUTPUT"
else
echo "already_released=false" >> "$GITHUB_OUTPUT"
fi
# -- SANITY CHECKS -------------------------------------------------------
- name: "Sanity: Pre-release validation"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
ERRORS=0
echo "## Pre-Release Sanity Checks" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# -- Version drift check (must pass before release) --------
README_VER=$(grep -oP 'VERSION:\s*\K[\d.]+' 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=$(grep -oP 'VERSION:\s*\K[\d.]+' 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=$(grep -oP '"version"\s*:\s*"\K[^"]+' 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
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.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.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.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
# -- 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.version.outputs.version }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add -A
git commit -m "chore(release): build ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
# -- 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 GitHub Release ------------------------------
- name: "Step 7: GitHub Release"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.tag_exists != 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
BRANCH="${{ steps.version.outputs.branch }}"
MAJOR="${{ steps.version.outputs.major }}"
NOTES=$(php /tmp/mokostandards-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
echo "$NOTES" > /tmp/release_notes.md
# Check if the major release already exists
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
# First release for this major: create GitHub Release
gh release create "$RELEASE_TAG" \
--title "v${MAJOR} (latest: ${VERSION})" \
--notes-file /tmp/release_notes.md \
--target "$BRANCH"
echo "Release created: ${RELEASE_TAG} (${VERSION})" >> $GITHUB_STEP_SUMMARY
else
# Update existing major release with new version info
CURRENT_NOTES=$(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 ".body // empty" || true)
{
echo "$CURRENT_NOTES"
echo ""
echo "---"
echo "### ${VERSION}"
echo ""
cat /tmp/release_notes.md
} > /tmp/updated_notes.md
gh release edit "$RELEASE_TAG" \
--title "v${MAJOR} (latest: ${VERSION})" \
--notes-file /tmp/updated_notes.md
echo "Release updated: ${RELEASE_TAG} -> ${VERSION}" >> $GITHUB_STEP_SUMMARY
fi
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
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" >> $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](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
@@ -1,115 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Automation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/shared/branch-freeze.yml.template
# VERSION: 04.06.00
# BRIEF: Freeze or unfreeze any branch via ruleset — manual workflow_dispatch
name: Branch Freeze
on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to freeze/unfreeze (e.g., version/04, dev/feature)'
required: true
type: string
action:
description: 'Action to perform'
required: true
type: choice
options:
- freeze
- unfreeze
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
manage-freeze:
name: "${{ inputs.action }} branch: ${{ inputs.branch }}"
runs-on: ubuntu-latest
steps:
- name: Check permissions
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
ALLOWED_USERS="jmiller gitea-actions[bot]"
REPO="${{ github.repository }}"
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/collaborators/${ACTOR}/permission" 2>/dev/null \
2>/dev/null | jq -r '.permission' || echo "read")
if [ "$PERMISSION" != "admin" ]; then
echo "Denied: only admins can freeze/unfreeze branches (${ACTOR} has ${PERMISSION})"
exit 1
fi
- name: "${{ inputs.action }} branch"
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
BRANCH="${{ inputs.branch }}"
ACTION="${{ inputs.action }}"
REPO="${{ github.repository }}"
RULESET_NAME="FROZEN: ${BRANCH}"
echo "## Branch Freeze" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$ACTION" = "freeze" ]; then
# Check if ruleset already exists
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/rulesets" 2>/dev/null \
--jq ".[] | select(.name == \"${RULESET_NAME}\") | .id" 2>/dev/null || true)
if [ -n "$EXISTING" ]; then
echo "Branch \`${BRANCH}\` is already frozen (ruleset #${EXISTING})" >> $GITHUB_STEP_SUMMARY
exit 0
fi
# Create freeze ruleset — blocks all updates except admin bypass
printf '{"name":"%s","target":"branch","enforcement":"active",' "${RULESET_NAME}" > /tmp/ruleset.json
printf '"bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"always"}],' >> /tmp/ruleset.json
printf '"conditions":{"ref_name":{"include":["refs/heads/%s"],"exclude":[]}},' "${BRANCH}" >> /tmp/ruleset.json
printf '"rules":[{"type":"update"},{"type":"deletion"},{"type":"non_fast_forward"}]}' >> /tmp/ruleset.json
RESULT=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/rulesets" 2>/dev/null -X POST -d @/tmp/ruleset.json 2>&1 | jq -r '.id') || true
if echo "$RESULT" | grep -qE '^[0-9]+$'; then
echo "Frozen \`${BRANCH}\` — ruleset #${RESULT}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${BRANCH}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Ruleset | #${RESULT} |" >> $GITHUB_STEP_SUMMARY
echo "| Rules | No updates, no deletion, no force push |" >> $GITHUB_STEP_SUMMARY
echo "| Bypass | Repository admins only |" >> $GITHUB_STEP_SUMMARY
else
echo "Failed to freeze: ${RESULT}" >> $GITHUB_STEP_SUMMARY
exit 1
fi
elif [ "$ACTION" = "unfreeze" ]; then
# Find and delete the freeze ruleset
RULESET_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/rulesets" 2>/dev/null \
--jq ".[] | select(.name == \"${RULESET_NAME}\") | .id" 2>/dev/null || true)
if [ -z "$RULESET_ID" ]; then
echo "Branch \`${BRANCH}\` is not frozen (no ruleset found)" >> $GITHUB_STEP_SUMMARY
exit 0
fi
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/rulesets/${RULESET_ID}" 2>/dev/null -X DELETE --silent 2>/dev/null
echo "Unfrozen \`${BRANCH}\` — ruleset #${RULESET_ID} deleted" >> $GITHUB_STEP_SUMMARY
fi
rm -f /tmp/ruleset.json
@@ -1,99 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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/shared/changelog-validation.yml.template
# VERSION: 04.06.00
# BRIEF: Validates CHANGELOG.md format and version consistency
# NOTE: Deployed to .gitea/workflows/changelog-validation.yml in governed repos.
name: Changelog Validation
on:
pull_request:
branches:
- main
- 'dev/**'
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
validate-changelog:
name: Validate CHANGELOG.md
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check CHANGELOG.md exists
run: |
echo "### Changelog Validation" >> $GITHUB_STEP_SUMMARY
if [ ! -f "CHANGELOG.md" ]; then
echo "CHANGELOG.md not found in repository root." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "CHANGELOG.md exists." >> $GITHUB_STEP_SUMMARY
- name: Check VERSION header matches README.md
run: |
# Extract version from README.md FILE INFORMATION block
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
exit 1
fi
# Check that CHANGELOG.md has a matching version header
CHANGELOG_VERSION=$(grep -oP '^\#\#\s*\[\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' CHANGELOG.md | head -1)
if [ -z "$CHANGELOG_VERSION" ]; then
echo "No version header found in CHANGELOG.md (expected \`## [XX.YY.ZZ] - YYYY-MM-DD\`)." >> $GITHUB_STEP_SUMMARY
exit 1
fi
if [ "$CHANGELOG_VERSION" != "$README_VERSION" ]; then
echo "CHANGELOG latest version \`${CHANGELOG_VERSION}\` does not match README VERSION \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "CHANGELOG version \`${CHANGELOG_VERSION}\` matches README VERSION." >> $GITHUB_STEP_SUMMARY
- name: Validate conventional changelog format
run: |
ERRORS=0
# Check that version entries follow ## [XX.YY.ZZ] - YYYY-MM-DD format
while IFS= read -r LINE; do
if ! echo "$LINE" | grep -qP '^\#\#\s*\[[0-9]{2}\.[0-9]{2}\.[0-9]{2}\]\s*-\s*[0-9]{4}-[0-9]{2}-[0-9]{2}'; then
echo "Malformed version header: \`${LINE}\`" >> $GITHUB_STEP_SUMMARY
echo " Expected format: \`## [XX.YY.ZZ] - YYYY-MM-DD\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done < <(grep -P '^\#\#\s*\[' CHANGELOG.md)
ENTRY_COUNT=$(grep -cP '^\#\#\s*\[' CHANGELOG.md || echo "0")
if [ "$ENTRY_COUNT" -eq 0 ]; then
echo "No version entries found in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "Found ${ENTRY_COUNT} version entr(ies) in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} format issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Changelog format validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
+87
View File
@@ -0,0 +1,87 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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)"
@@ -1,730 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/shared/deploy-demo.yml.template
# VERSION: 04.06.00
# BRIEF: SFTP deployment workflow for demo server — synced to all governed repos
# NOTE: Synced via bulk-repo-sync to .gitea/workflows/deploy-demo.yml in all governed repos.
# Port is resolved in order: DEMO_FTP_PORT variable → :port suffix in DEMO_FTP_HOST → 22.
name: Deploy to Demo Server (SFTP)
# Deploys the contents of the src/ directory to the demo server via SFTP.
# Triggers on push/merge to main — deploys the production-ready build to the demo server.
#
# Required org-level variables: DEMO_FTP_HOST, DEMO_FTP_PATH, DEMO_FTP_USERNAME
# Optional org-level variable: DEMO_FTP_PORT (auto-detected from host or defaults to 22)
# Optional org/repo variable: DEMO_FTP_SUFFIX — when set, appended to DEMO_FTP_PATH to form the
# full remote destination: DEMO_FTP_PATH/DEMO_FTP_SUFFIX
# Ignore rules: Place a .ftpignore file in the src/ directory. Each non-empty,
# non-comment line is a glob pattern tested against the relative path
# of each file (e.g. "subdir/file.txt"). The .gitignore is NOT used.
# Required org-level secret: DEMO_FTP_KEY (preferred) or DEMO_FTP_PASSWORD
#
# Access control: only users with admin or maintain role on the repository may deploy.
on:
pull_request:
types: [closed]
branches:
- main
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all files inside the remote destination folder before uploading'
required: false
default: false
type: boolean
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
check-permission:
name: Verify Deployment Permission
runs-on: ubuntu-latest
steps:
- name: Check actor permission
env:
# Prefer the org-scoped GH_TOKEN secret (needed for the org membership
# fallback). Falls back to the built-in github.token so the collaborator
# endpoint still works even if GH_TOKEN is not configured.
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
ALLOWED_USERS="jmiller gitea-actions[bot]"
REPO="${{ github.repository }}"
ORG="${{ github.repository_owner }}"
METHOD=""
AUTHORIZED="false"
# Hardcoded authorized users — always allowed to deploy
AUTHORIZED_USERS="jmiller gitea-actions[bot]"
for user in $AUTHORIZED_USERS; do
if [ "$ACTOR" = "$user" ]; then
AUTHORIZED="true"
METHOD="hardcoded allowlist"
PERMISSION="admin"
break
fi
done
# For other actors, check repo/org permissions via API
if [ "$AUTHORIZED" != "true" ]; then
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/collaborators/${ACTOR}/permission" 2>/dev/null \
2>/dev/null | jq -r '.permission')
METHOD="repo collaborator API"
if [ -z "$PERMISSION" ]; then
ORG_ROLE=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/orgs/${ORG}/memberships/${ACTOR}" \
2>/dev/null | jq -r '.role')
METHOD="org membership API"
if [ "$ORG_ROLE" = "owner" ]; then
PERMISSION="admin"
else
PERMISSION="none"
fi
fi
case "$PERMISSION" in
admin|maintain) AUTHORIZED="true" ;;
esac
fi
# Write detailed summary
{
echo "## 🔐 Deploy Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${AUTHORIZED} |"
echo "| **Trigger** | \`${{ github.event_name }}\` |"
echo "| **Branch** | \`${{ github.ref_name }}\` |"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$AUTHORIZED" = "true" ]; then
echo "✅ ${ACTOR} authorized to deploy (${METHOD})" >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ ${ACTOR} is NOT authorized to deploy." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Deployment requires one of:" >> "$GITHUB_STEP_SUMMARY"
echo "- Being in the hardcoded allowlist" >> "$GITHUB_STEP_SUMMARY"
echo "- Having \`admin\` or \`maintain\` role on the repository" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
deploy:
name: SFTP Deploy → Demo
runs-on: ubuntu-latest
needs: [check-permission]
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Resolve source directory
id: source
run: |
# Resolve source directory: src/ preferred, htdocs/ as fallback
if [ -d "src" ]; then
SRC="src"
elif [ -d "htdocs" ]; then
SRC="htdocs"
else
echo "⚠️ No src/ or htdocs/ directory found — skipping deployment"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
COUNT=$(find "$SRC" -type f | wc -l)
echo "✅ Source: ${SRC}/ (${COUNT} file(s))"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
- name: Preview files to deploy
if: steps.source.outputs.skip == 'false'
env:
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Convert a ftpignore-style glob line to an ERE pattern ──────────────
ftpignore_to_regex() {
local line="$1"
local anchored=false
# Strip inline comments and whitespace
line=$(printf '%s' "$line" | sed 's/[[:space:]]*#.*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$line" ] && return
# Skip negation patterns (not supported)
[[ "$line" == !* ]] && return
# Trailing slash = directory marker; strip it
line="${line%/}"
# Leading slash = anchored to root; strip it
if [[ "$line" == /* ]]; then
anchored=true
line="${line#/}"
fi
# Escape ERE special chars, then restore glob semantics
local regex
regex=$(printf '%s' "$line" \
| sed 's/[.+^${}()|[\\]/\\&/g' \
| sed 's/\\\*\\\*/\x01/g' \
| sed 's/\\\*/[^\/]*/g' \
| sed 's/\x01/.*/g' \
| sed 's/\\\?/[^\/]/g')
if $anchored; then
printf '^%s(/|$)' "$regex"
else
printf '(^|/)%s(/|$)' "$regex"
fi
}
# ── Read .ftpignore (ftpignore-style globs) ─────────────────────────
IGNORE_PATTERNS=()
IGNORE_SOURCES=()
if [ -f "${SOURCE_DIR}/.ftpignore" ]; then
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
regex=$(ftpignore_to_regex "$line")
[ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
done < "${SOURCE_DIR}/.ftpignore"
fi
# ── Walk src/ and classify every file ────────────────────────────────
WILL_UPLOAD=()
IGNORED_FILES=()
while IFS= read -r -d '' file; do
rel="${file#${SOURCE_DIR}/}"
SKIP=false
for i in "${!IGNORE_PATTERNS[@]}"; do
if echo "$rel" | grep -qE "${IGNORE_PATTERNS[$i]}" 2>/dev/null; then
IGNORED_FILES+=("$rel | .ftpignore \`${IGNORE_SOURCES[$i]}\`")
SKIP=true; break
fi
done
$SKIP && continue
WILL_UPLOAD+=("$rel")
done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
UPLOAD_COUNT="${#WILL_UPLOAD[@]}"
IGNORE_COUNT="${#IGNORED_FILES[@]}"
echo "️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored"
# ── Write deployment preview to step summary ──────────────────────────
{
echo "## 📋 Deployment Preview"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Source | \`${SOURCE_DIR}/\` |"
echo "| Files to upload | **${UPLOAD_COUNT}** |"
echo "| Files ignored | **${IGNORE_COUNT}** |"
echo ""
if [ "${UPLOAD_COUNT}" -gt 0 ]; then
echo "### 📂 Files that will be uploaded"
echo '```'
printf '%s\n' "${WILL_UPLOAD[@]}"
echo '```'
echo ""
fi
if [ "${IGNORE_COUNT}" -gt 0 ]; then
echo "### ⏭️ Files excluded"
echo "| File | Reason |"
echo "|---|---|"
for entry in "${IGNORED_FILES[@]}"; do
f="${entry% | *}"; r="${entry##* | }"
echo "| \`${f}\` | ${r} |"
done
echo ""
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Resolve SFTP host and port
if: steps.source.outputs.skip == 'false'
id: conn
env:
HOST_RAW: ${{ vars.DEMO_FTP_HOST }}
PORT_VAR: ${{ vars.DEMO_FTP_PORT }}
run: |
HOST="$HOST_RAW"
PORT="$PORT_VAR"
if [ -z "$HOST" ]; then
echo "⏭️ DEMO_FTP_HOST not configured — skipping demo deployment."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Priority 1 — explicit DEMO_FTP_PORT variable
if [ -n "$PORT" ]; then
echo "️ Using explicit DEMO_FTP_PORT=${PORT}"
# Priority 2 — port embedded in DEMO_FTP_HOST (host:port)
elif [[ "$HOST" == *:* ]]; then
PORT="${HOST##*:}"
HOST="${HOST%:*}"
echo "️ Extracted port ${PORT} from DEMO_FTP_HOST"
# Priority 3 — SFTP default
else
PORT="22"
echo "️ No port specified — defaulting to SFTP port 22"
fi
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "port=${PORT}" >> "$GITHUB_OUTPUT"
echo "SFTP target: ${HOST}:${PORT}"
- name: Build remote path
if: steps.source.outputs.skip == 'false' && steps.conn.outputs.skip != 'true'
id: remote
env:
DEMO_FTP_PATH: ${{ vars.DEMO_FTP_PATH }}
DEMO_FTP_SUFFIX: ${{ vars.DEMO_FTP_SUFFIX }}
run: |
BASE="$DEMO_FTP_PATH"
if [ -z "$BASE" ]; then
echo "⏭️ DEMO_FTP_PATH not configured — skipping demo deployment."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# DEMO_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
# Without it we cannot safely determine the deployment target.
if [ -z "$DEMO_FTP_SUFFIX" ]; then
echo "⏭️ DEMO_FTP_SUFFIX variable is not set — skipping deployment."
echo " Set DEMO_FTP_SUFFIX as a repo or org variable to enable deploy-demo."
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "path=" >> "$GITHUB_OUTPUT"
exit 0
fi
REMOTE="${BASE%/}/${DEMO_FTP_SUFFIX#/}"
# ── Platform-specific path safety guards ──────────────────────────────
PLATFORM=""
MOKO_FILE=".gitea/.mokostandards"
[ ! -f "$MOKO_FILE" ] && MOKO_FILE=".github/.mokostandards"
[ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"
if [ -f "$MOKO_FILE" ]; then
# XML format: extract <platform>value</platform>
if grep -q '<mokostandards' "$MOKO_FILE" 2>/dev/null; then
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' "$MOKO_FILE" | head -1)
else
# Legacy YAML-like format: platform: value
PLATFORM=$(grep -E '^platform:' "$MOKO_FILE" | sed 's/.*:[[:space:]]*//' | tr -d '"')
fi
fi
if [ "$PLATFORM" = "crm-module" ]; then
# Dolibarr modules must deploy under htdocs/custom/ — guard against
# accidentally overwriting server root or unrelated directories.
if [[ "$REMOTE" != *custom* ]]; then
echo "❌ Safety check failed: Dolibarr (crm-module) remote path must contain 'custom'."
echo " Current path: ${REMOTE}"
echo " Set DEMO_FTP_SUFFIX to the module's htdocs/custom/ subdirectory."
exit 1
fi
fi
if [ "$PLATFORM" = "waas-component" ]; then
# Joomla extensions may only deploy to the server's tmp/ directory.
if [[ "$REMOTE" != *tmp* ]]; then
echo "❌ Safety check failed: Joomla (waas-component) remote path must contain 'tmp'."
echo " Current path: ${REMOTE}"
echo " Set DEMO_FTP_SUFFIX to a path under the server tmp/ directory."
exit 1
fi
fi
echo "️ Remote path: ${REMOTE}"
echo "path=${REMOTE}" >> "$GITHUB_OUTPUT"
- name: Detect SFTP authentication method
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
id: auth
env:
HAS_KEY: ${{ secrets.DEMO_FTP_KEY }}
HAS_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
run: |
if [ -n "$HAS_KEY" ] && [ -n "$HAS_PASSWORD" ]; then
# Both set: key auth with password as passphrase; falls back to password-only if key fails
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=true" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Primary: SSH key + passphrase (DEMO_FTP_KEY / DEMO_FTP_PASSWORD)"
echo "️ Fallback: password-only auth if key authentication fails"
elif [ -n "$HAS_KEY" ]; then
# Key only: no passphrase, no password fallback
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=false" >> "$GITHUB_OUTPUT"
echo "️ Using SSH key authentication (DEMO_FTP_KEY, no passphrase, no fallback)"
elif [ -n "$HAS_PASSWORD" ]; then
# Password only: direct SFTP password auth
echo "method=password" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Using password authentication (DEMO_FTP_PASSWORD)"
else
echo "❌ No SFTP credentials configured."
echo " Set DEMO_FTP_KEY (preferred) or DEMO_FTP_PASSWORD as an org-level secret."
exit 1
fi
- name: Setup PHP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
run: |
php -v && composer --version
- name: Setup MokoStandards deploy tools
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
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 {{standards_branch}} --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
- name: Clear remote destination folder (manual only)
if: >-
steps.source.outputs.skip == 'false' &&
steps.remote.outputs.skip != 'true' &&
inputs.clear_remote == true
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.DEMO_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.DEMO_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
HAS_PASSWORD: ${{ steps.auth.outputs.has_password }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
run: |
cat > /tmp/moko_clear.php << 'PHPEOF'
<?php
declare(strict_types=1);
require '/tmp/mokostandards-api/vendor/autoload.php';
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
$host = (string) getenv('SFTP_HOST');
$port = (int) getenv('SFTP_PORT');
$username = (string) getenv('SFTP_USER');
$authMethod = (string) getenv('AUTH_METHOD');
$usePassphrase = getenv('USE_PASSPHRASE') === 'true';
$hasPassword = getenv('HAS_PASSWORD') === 'true';
$remotePath = rtrim((string) getenv('REMOTE_PATH'), '/');
echo "⚠️ Clearing remote folder: {$remotePath}\n";
$sftp = new SFTP($host, $port);
// ── Authentication ──────────────────────────────────────────────
if ($authMethod === 'key') {
$keyData = (string) getenv('SFTP_KEY');
$passphrase = $usePassphrase ? (string) getenv('SFTP_PASSWORD') : false;
$password = $hasPassword ? (string) getenv('SFTP_PASSWORD') : '';
$key = PublicKeyLoader::load($keyData, $passphrase);
if (!$sftp->login($username, $key)) {
if ($password !== '') {
echo "⚠️ Key auth failed — falling back to password\n";
if (!$sftp->login($username, $password)) {
fwrite(STDERR, "❌ Both key and password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication (key fallback)\n";
} else {
fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
exit(1);
}
} else {
echo "✅ Connected via SSH key authentication\n";
}
} else {
if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
fwrite(STDERR, "❌ Password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication\n";
}
// ── Recursive delete ────────────────────────────────────────────
function rmrf(SFTP $sftp, string $path): void
{
$entries = $sftp->nlist($path);
if ($entries === false) {
return; // path does not exist — nothing to clear
}
foreach ($entries as $name) {
if ($name === '.' || $name === '..') {
continue;
}
$entry = "{$path}/{$name}";
if ($sftp->is_dir($entry)) {
rmrf($sftp, $entry);
$sftp->rmdir($entry);
echo " 🗑️ Removed dir: {$entry}\n";
} else {
$sftp->delete($entry);
echo " 🗑️ Removed file: {$entry}\n";
}
}
}
// ── Create remote directory tree ────────────────────────────────
function sftpMakedirs(SFTP $sftp, string $path): void
{
$parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
$current = str_starts_with($path, '/') ? '' : '';
foreach ($parts as $part) {
$current .= '/' . $part;
$sftp->mkdir($current); // silently returns false if already exists
}
}
rmrf($sftp, $remotePath);
sftpMakedirs($sftp, $remotePath);
echo "✅ Remote folder ready: {$remotePath}\n";
PHPEOF
php /tmp/moko_clear.php
- name: Deploy via SFTP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.DEMO_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.DEMO_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Write SSH key to temp file (key auth only) ────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
printf '%s' "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
fi
# ── Generate sftp-config.json safely via jq ───────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg key "/tmp/deploy_key" \
'{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
> /tmp/sftp-config.json
else
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg pass "$SFTP_PASSWORD" \
'{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
> /tmp/sftp-config.json
fi
# ── Write update files (demo = stable) ─────────────────────────────
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "unknown")
REPO="${{ github.repository }}"
if [ "$PLATFORM" = "crm-module" ]; then
printf '%s' "$VERSION" > update.txt
fi
if [ "$PLATFORM" = "waas-component" ]; then
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
if [ -n "$MANIFEST" ]; then
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/' "$MANIFEST" 2>/dev/null | head -1 || true)
[ -n "$TARGET_PLATFORM" ] && TARGET_PLATFORM="${TARGET_PLATFORM}>"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
CLIENT_TAG=""
if [ -n "$EXT_CLIENT" ]; then CLIENT_TAG="<client>${EXT_CLIENT}</client>"; elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then CLIENT_TAG="<client>site</client>"; fi
FOLDER_TAG=""
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"; fi
DOWNLOAD_URL="https://git.mokoconsulting.tech/${{ github.repository }}/releases/download/v${VERSION}/${EXT_ELEMENT}-${VERSION}.zip"
{
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
printf '%s\n' '<updates>'
printf '%s\n' ' <update>'
printf '%s\n' " <name>${EXT_NAME}</name>"
printf '%s\n' " <description>${EXT_NAME} update</description>"
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
printf '%s\n' " <type>${EXT_TYPE}</type>"
printf '%s\n' " <version>${VERSION}</version>"
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
printf '%s\n' ' <tags>'
printf '%s\n' ' <tag>stable</tag>'
printf '%s\n' ' </tags>'
printf '%s\n' " <infourl title=\"${EXT_NAME}\">https://github.com/${REPO}</infourl>"
printf '%s\n' ' <downloads>'
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
printf '%s\n' ' </downloads>'
printf '%s\n' " ${TARGET_PLATFORM}"
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
printf '%s\n' ' </update>'
printf '%s\n' '</updates>'
} > updates.xml
fi
fi
# ── Run deploy-sftp.php from MokoStandards ────────────────────────────
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
if [ "$USE_PASSPHRASE" = "true" ]; then
DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
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 "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
# Remove temp files that should never be left behind
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Create or update failure issue
if: failure() && steps.remote.outputs.skip != 'true' && steps.conn.outputs.skip != 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
RUN_URL="${{ github.server_url }}/${REPO}/actions/runs/${{ github.run_id }}"
ACTOR="${{ github.actor }}"
ALLOWED_USERS="jmiller gitea-actions[bot]"
BRANCH="${{ github.ref_name }}"
EVENT="${{ github.event_name }}"
NOW=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
LABEL="deploy-failure"
TITLE="fix: Demo deployment failed — ${REPO}"
BODY="## Demo Deployment Failed
A deployment to the demo server failed and requires attention.
| Field | Value |
|-------|-------|
| **Repository** | \`${REPO}\` |
| **Branch** | \`${BRANCH}\` |
| **Trigger** | ${EVENT} |
| **Actor** | @${ACTOR} |
| **Failed at** | ${NOW} |
| **Run** | [View workflow run](${RUN_URL}) |
### Next steps
1. Review the [workflow run log](${RUN_URL}) for the specific error.
2. Fix the underlying issue (credentials, SFTP connectivity, permissions).
3. Re-trigger the deployment via **Actions → Deploy to Demo Server → Run workflow**.
---
*Auto-created by deploy-demo.yml — close this issue once the deployment is resolved.*"
# Ensure the label exists (idempotent — no-op if already present)
gh label create "$LABEL" \
--repo "$REPO" \
--color "CC0000" \
--description "Automated deploy failure tracking" \
--force 2>/dev/null || true
# Look for an existing open deploy-failure issue
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues?labels=${LABEL}&state=all&per_page=1&sort=created&direction=desc" 2>/dev/null \
2>/dev/null | jq -r '.[0].number')
if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues/${EXISTING}" 2>/dev/null \
-X PATCH \
-f title="$TITLE" \
-f body="$BODY" \
-f state="open" \
--silent
echo "📋 Failure issue #${EXISTING} updated/reopened: ${REPO}" >> "$GITHUB_STEP_SUMMARY"
else
gh issue create \
--repo "$REPO" \
--title "$TITLE" \
--body "$BODY" \
--label "$LABEL" \
--assignee "jmiller" \
| tee -a "$GITHUB_STEP_SUMMARY"
fi
- name: Deployment summary
if: always()
run: |
if [ "${{ steps.source.outputs.skip }}" == "true" ]; then
echo "### ⏭️ Deployment Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No \`src/\` directory found in this repository." >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ job.status }}" == "success" ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ Demo Deployment Successful" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Host | \`${{ steps.conn.outputs.host }}:${{ steps.conn.outputs.port }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Remote path | \`${{ steps.remote.outputs.path }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Source | \`src/\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Trigger | ${{ github.event_name }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Auth | ${{ steps.auth.outputs.method }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Clear remote | ${{ inputs.clear_remote || 'false' }} |" >> "$GITHUB_STEP_SUMMARY"
else
echo "### ❌ Demo Deployment Failed" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Check the job log above for error details." >> "$GITHUB_STEP_SUMMARY"
fi
@@ -1,694 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/shared/deploy-dev.yml.template
# VERSION: 04.06.00
# BRIEF: SFTP deployment workflow for development server — synced to all governed repos
# NOTE: Synced via bulk-repo-sync to .gitea/workflows/deploy-dev.yml in all governed repos.
# Port is resolved in order: DEV_FTP_PORT variable → :port suffix in DEV_FTP_HOST → 22.
name: Deploy to Dev Server (SFTP)
# Deploys the contents of the src/ directory to the development server via SFTP.
# Triggers on every pull_request to development branches (so the dev server always
# reflects the latest PR state) and on push/merge to main branches.
#
# Required org-level variables: DEV_FTP_HOST, DEV_FTP_PATH, DEV_FTP_USERNAME
# Optional org-level variable: DEV_FTP_PORT (auto-detected from host or defaults to 22)
# Optional org/repo variable: DEV_FTP_SUFFIX — when set, appended to DEV_FTP_PATH to form the
# full remote destination: DEV_FTP_PATH/DEV_FTP_SUFFIX
# Ignore rules: Place a .ftpignore file in the src/ directory. Each non-empty,
# non-comment line is a glob pattern tested against the relative path
# of each file (e.g. "subdir/file.txt"). The .gitignore is NOT used.
# Required org-level secret: DEV_FTP_KEY (preferred) or DEV_FTP_PASSWORD
#
# Access control: only users with admin or maintain role on the repository may deploy.
on:
pull_request:
types: [closed]
branches:
- 'dev/**'
- 'rc/**'
- develop
- development
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all files inside the remote destination folder before uploading'
required: false
default: false
type: boolean
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
check-permission:
name: Verify Deployment Permission
runs-on: ubuntu-latest
steps:
- name: Check actor permission
env:
# Prefer the org-scoped GH_TOKEN secret (needed for the org membership
# fallback). Falls back to the built-in github.token so the collaborator
# endpoint still works even if GH_TOKEN is not configured.
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
ALLOWED_USERS="jmiller gitea-actions[bot]"
REPO="${{ github.repository }}"
ORG="${{ github.repository_owner }}"
METHOD=""
AUTHORIZED="false"
# Hardcoded authorized users — always allowed to deploy
AUTHORIZED_USERS="jmiller gitea-actions[bot]"
for user in $AUTHORIZED_USERS; do
if [ "$ACTOR" = "$user" ]; then
AUTHORIZED="true"
METHOD="hardcoded allowlist"
PERMISSION="admin"
break
fi
done
# For other actors, check repo/org permissions via API
if [ "$AUTHORIZED" != "true" ]; then
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/collaborators/${ACTOR}/permission" 2>/dev/null \
2>/dev/null | jq -r '.permission')
METHOD="repo collaborator API"
if [ -z "$PERMISSION" ]; then
ORG_ROLE=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/orgs/${ORG}/memberships/${ACTOR}" \
2>/dev/null | jq -r '.role')
METHOD="org membership API"
if [ "$ORG_ROLE" = "owner" ]; then
PERMISSION="admin"
else
PERMISSION="none"
fi
fi
case "$PERMISSION" in
admin|maintain) AUTHORIZED="true" ;;
esac
fi
# Write detailed summary
{
echo "## 🔐 Deploy Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${AUTHORIZED} |"
echo "| **Trigger** | \`${{ github.event_name }}\` |"
echo "| **Branch** | \`${{ github.ref_name }}\` |"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$AUTHORIZED" = "true" ]; then
echo "✅ ${ACTOR} authorized to deploy (${METHOD})" >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ ${ACTOR} is NOT authorized to deploy." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Deployment requires one of:" >> "$GITHUB_STEP_SUMMARY"
echo "- Being in the hardcoded allowlist" >> "$GITHUB_STEP_SUMMARY"
echo "- Having \`admin\` or \`maintain\` role on the repository" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
deploy:
name: SFTP Deploy → Dev
runs-on: ubuntu-latest
needs: [check-permission]
if: >-
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Resolve source directory
id: source
run: |
# Resolve source directory: src/ preferred, htdocs/ as fallback
if [ -d "src" ]; then
SRC="src"
elif [ -d "htdocs" ]; then
SRC="htdocs"
else
echo "⚠️ No src/ or htdocs/ directory found — skipping deployment"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
COUNT=$(find "$SRC" -type f | wc -l)
echo "✅ Source: ${SRC}/ (${COUNT} file(s))"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
- name: Preview files to deploy
if: steps.source.outputs.skip == 'false'
env:
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Convert a ftpignore-style glob line to an ERE pattern ──────────────
ftpignore_to_regex() {
local line="$1"
local anchored=false
# Strip inline comments and whitespace
line=$(printf '%s' "$line" | sed 's/[[:space:]]*#.*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$line" ] && return
# Skip negation patterns (not supported)
[[ "$line" == !* ]] && return
# Trailing slash = directory marker; strip it
line="${line%/}"
# Leading slash = anchored to root; strip it
if [[ "$line" == /* ]]; then
anchored=true
line="${line#/}"
fi
# Escape ERE special chars, then restore glob semantics
local regex
regex=$(printf '%s' "$line" \
| sed 's/[.+^${}()|[\\]/\\&/g' \
| sed 's/\\\*\\\*/\x01/g' \
| sed 's/\\\*/[^\/]*/g' \
| sed 's/\x01/.*/g' \
| sed 's/\\\?/[^\/]/g')
if $anchored; then
printf '^%s(/|$)' "$regex"
else
printf '(^|/)%s(/|$)' "$regex"
fi
}
# ── Read .ftpignore (ftpignore-style globs) ─────────────────────────
IGNORE_PATTERNS=()
IGNORE_SOURCES=()
if [ -f "${SOURCE_DIR}/.ftpignore" ]; then
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
regex=$(ftpignore_to_regex "$line")
[ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
done < "${SOURCE_DIR}/.ftpignore"
fi
# ── Walk src/ and classify every file ────────────────────────────────
WILL_UPLOAD=()
IGNORED_FILES=()
while IFS= read -r -d '' file; do
rel="${file#${SOURCE_DIR}/}"
SKIP=false
for i in "${!IGNORE_PATTERNS[@]}"; do
if echo "$rel" | grep -qE "${IGNORE_PATTERNS[$i]}" 2>/dev/null; then
IGNORED_FILES+=("$rel | .ftpignore \`${IGNORE_SOURCES[$i]}\`")
SKIP=true; break
fi
done
$SKIP && continue
WILL_UPLOAD+=("$rel")
done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
UPLOAD_COUNT="${#WILL_UPLOAD[@]}"
IGNORE_COUNT="${#IGNORED_FILES[@]}"
echo "️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored"
# ── Write deployment preview to step summary ──────────────────────────
{
echo "## 📋 Deployment Preview"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Source | \`${SOURCE_DIR}/\` |"
echo "| Files to upload | **${UPLOAD_COUNT}** |"
echo "| Files ignored | **${IGNORE_COUNT}** |"
echo ""
if [ "${UPLOAD_COUNT}" -gt 0 ]; then
echo "### 📂 Files that will be uploaded"
echo '```'
printf '%s\n' "${WILL_UPLOAD[@]}"
echo '```'
echo ""
fi
if [ "${IGNORE_COUNT}" -gt 0 ]; then
echo "### ⏭️ Files excluded"
echo "| File | Reason |"
echo "|---|---|"
for entry in "${IGNORED_FILES[@]}"; do
f="${entry% | *}"; r="${entry##* | }"
echo "| \`${f}\` | ${r} |"
done
echo ""
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Resolve SFTP host and port
if: steps.source.outputs.skip == 'false'
id: conn
env:
HOST_RAW: ${{ vars.DEV_FTP_HOST }}
PORT_VAR: ${{ vars.DEV_FTP_PORT }}
run: |
HOST="$HOST_RAW"
PORT="$PORT_VAR"
# Priority 1 — explicit DEV_FTP_PORT variable
if [ -n "$PORT" ]; then
echo "️ Using explicit DEV_FTP_PORT=${PORT}"
# Priority 2 — port embedded in DEV_FTP_HOST (host:port)
elif [[ "$HOST" == *:* ]]; then
PORT="${HOST##*:}"
HOST="${HOST%:*}"
echo "️ Extracted port ${PORT} from DEV_FTP_HOST"
# Priority 3 — SFTP default
else
PORT="22"
echo "️ No port specified — defaulting to SFTP port 22"
fi
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "port=${PORT}" >> "$GITHUB_OUTPUT"
echo "SFTP target: ${HOST}:${PORT}"
- name: Build remote path
if: steps.source.outputs.skip == 'false'
id: remote
env:
DEV_FTP_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
BASE="$DEV_FTP_PATH"
if [ -z "$BASE" ]; then
echo "❌ DEV_FTP_PATH is not set."
echo " Configure it as an org-level variable (Settings → Variables) and"
echo " ensure this repository has been granted access to it."
exit 1
fi
# DEV_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
# Without it we cannot safely determine the deployment target.
if [ -z "$DEV_FTP_SUFFIX" ]; then
echo "⏭️ DEV_FTP_SUFFIX variable is not set — skipping deployment."
echo " Set DEV_FTP_SUFFIX as a repo or org variable to enable deploy-dev."
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "path=" >> "$GITHUB_OUTPUT"
exit 0
fi
REMOTE="${BASE%/}/${DEV_FTP_SUFFIX#/}"
# ── Platform-specific path safety guards ──────────────────────────────
PLATFORM=""
MOKO_FILE=".gitea/.mokostandards"
[ ! -f "$MOKO_FILE" ] && MOKO_FILE=".github/.mokostandards"
[ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"
if [ -f "$MOKO_FILE" ]; then
# XML format: extract <platform>value</platform>
if grep -q '<mokostandards' "$MOKO_FILE" 2>/dev/null; then
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' "$MOKO_FILE" | head -1)
else
# Legacy YAML-like format: platform: value
PLATFORM=$(grep -oP '(?<=^platform:\s).+' "$MOKO_FILE" 2>/dev/null | tr -d '"' || true)
fi
fi
if [ "$PLATFORM" = "crm-module" ]; then
# Dolibarr modules must deploy under htdocs/custom/ — guard against
# accidentally overwriting server root or unrelated directories.
if [[ "$REMOTE" != *custom* ]]; then
echo "❌ Safety check failed: Dolibarr (crm-module) remote path must contain 'custom'."
echo " Current path: ${REMOTE}"
echo " Set DEV_FTP_SUFFIX to the module's htdocs/custom/ subdirectory."
exit 1
fi
fi
if [ "$PLATFORM" = "waas-component" ]; then
# Joomla extensions may only deploy to the server's tmp/ directory.
if [[ "$REMOTE" != *tmp* ]]; then
echo "❌ Safety check failed: Joomla (waas-component) remote path must contain 'tmp'."
echo " Current path: ${REMOTE}"
echo " Set DEV_FTP_SUFFIX to a path under the server tmp/ directory."
exit 1
fi
fi
echo "️ Remote path: ${REMOTE}"
echo "path=${REMOTE}" >> "$GITHUB_OUTPUT"
- name: Detect SFTP authentication method
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
id: auth
env:
HAS_KEY: ${{ secrets.DEV_FTP_KEY }}
HAS_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
if [ -n "$HAS_KEY" ] && [ -n "$HAS_PASSWORD" ]; then
# Both set: key auth with password as passphrase; falls back to password-only if key fails
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=true" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Primary: SSH key + passphrase (DEV_FTP_KEY / DEV_FTP_PASSWORD)"
echo "️ Fallback: password-only auth if key authentication fails"
elif [ -n "$HAS_KEY" ]; then
# Key only: no passphrase, no password fallback
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=false" >> "$GITHUB_OUTPUT"
echo "️ Using SSH key authentication (DEV_FTP_KEY, no passphrase, no fallback)"
elif [ -n "$HAS_PASSWORD" ]; then
# Password only: direct SFTP password auth
echo "method=password" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Using password authentication (DEV_FTP_PASSWORD)"
else
echo "❌ No SFTP credentials configured."
echo " Set DEV_FTP_KEY (preferred) or DEV_FTP_PASSWORD as an org-level secret."
exit 1
fi
- name: Setup PHP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
run: |
php -v && composer --version
- name: Setup MokoStandards deploy tools
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
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 {{standards_branch}} --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
- name: Clear remote destination folder (manual only)
if: >-
steps.source.outputs.skip == 'false' &&
steps.remote.outputs.skip != 'true' &&
inputs.clear_remote == true
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
HAS_PASSWORD: ${{ steps.auth.outputs.has_password }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
run: |
cat > /tmp/moko_clear.php << 'PHPEOF'
<?php
declare(strict_types=1);
require '/tmp/mokostandards-api/vendor/autoload.php';
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
$host = (string) getenv('SFTP_HOST');
$port = (int) getenv('SFTP_PORT');
$username = (string) getenv('SFTP_USER');
$authMethod = (string) getenv('AUTH_METHOD');
$usePassphrase = getenv('USE_PASSPHRASE') === 'true';
$hasPassword = getenv('HAS_PASSWORD') === 'true';
$remotePath = rtrim((string) getenv('REMOTE_PATH'), '/');
echo "⚠️ Clearing remote folder: {$remotePath}\n";
$sftp = new SFTP($host, $port);
// ── Authentication ──────────────────────────────────────────────
if ($authMethod === 'key') {
$keyData = (string) getenv('SFTP_KEY');
$passphrase = $usePassphrase ? (string) getenv('SFTP_PASSWORD') : false;
$password = $hasPassword ? (string) getenv('SFTP_PASSWORD') : '';
$key = PublicKeyLoader::load($keyData, $passphrase);
if (!$sftp->login($username, $key)) {
if ($password !== '') {
echo "⚠️ Key auth failed — falling back to password\n";
if (!$sftp->login($username, $password)) {
fwrite(STDERR, "❌ Both key and password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication (key fallback)\n";
} else {
fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
exit(1);
}
} else {
echo "✅ Connected via SSH key authentication\n";
}
} else {
if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
fwrite(STDERR, "❌ Password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication\n";
}
// ── Recursive delete ────────────────────────────────────────────
function rmrf(SFTP $sftp, string $path): void
{
$entries = $sftp->nlist($path);
if ($entries === false) {
return; // path does not exist — nothing to clear
}
foreach ($entries as $name) {
if ($name === '.' || $name === '..') {
continue;
}
$entry = "{$path}/{$name}";
if ($sftp->is_dir($entry)) {
rmrf($sftp, $entry);
$sftp->rmdir($entry);
echo " 🗑️ Removed dir: {$entry}\n";
} else {
$sftp->delete($entry);
echo " 🗑️ Removed file: {$entry}\n";
}
}
}
// ── Create remote directory tree ────────────────────────────────
function sftpMakedirs(SFTP $sftp, string $path): void
{
$parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
$current = str_starts_with($path, '/') ? '' : '';
foreach ($parts as $part) {
$current .= '/' . $part;
$sftp->mkdir($current); // silently returns false if already exists
}
}
rmrf($sftp, $remotePath);
sftpMakedirs($sftp, $remotePath);
echo "✅ Remote folder ready: {$remotePath}\n";
PHPEOF
php /tmp/moko_clear.php
- name: Deploy via SFTP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Write SSH key to temp file (key auth only) ────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
printf '%s' "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
fi
# ── Generate sftp-config.json safely via jq ───────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg key "/tmp/deploy_key" \
'{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
> /tmp/sftp-config.json
else
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg pass "$SFTP_PASSWORD" \
'{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
> /tmp/sftp-config.json
fi
# Dev deploys skip minified files — use unminified sources for debugging
echo "*.min.js" >> "${SOURCE_DIR}/.ftpignore"
echo "*.min.css" >> "${SOURCE_DIR}/.ftpignore"
# ── Run deploy-sftp.php from MokoStandards ────────────────────────────
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
if [ "$USE_PASSPHRASE" = "true" ]; then
DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
fi
# Set platform version to "development" before deploy (Dolibarr + Joomla)
php /tmp/mokostandards-api/cli/version_set_platform.php --path . --version development
# Write update files — dev/** = development, rc/** = rc
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
REPO="${{ github.repository }}"
BRANCH="${{ github.ref_name }}"
# Determine stability tag from branch prefix
STABILITY="development"
VERSION_LABEL="development"
if [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
VERSION_LABEL=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "${BRANCH#rc/}")-rc
fi
if [ "$PLATFORM" = "crm-module" ]; then
printf '%s' "$VERSION_LABEL" > update.txt
fi
if [ "$PLATFORM" = "waas-component" ]; then
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
if [ -n "$MANIFEST" ]; then
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/' "$MANIFEST" 2>/dev/null | head -1 || true)
[ -n "$TARGET_PLATFORM" ] && TARGET_PLATFORM="${TARGET_PLATFORM}>"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
CLIENT_TAG=""
if [ -n "$EXT_CLIENT" ]; then
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
CLIENT_TAG="<client>site</client>"
fi
FOLDER_TAG=""
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
fi
DOWNLOAD_URL="https://git.mokoconsulting.tech/${{ github.repository }}/archive/refs/heads/${BRANCH}.zip"
{
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
printf '%s\n' '<updates>'
printf '%s\n' ' <update>'
printf '%s\n' " <name>${EXT_NAME}</name>"
printf '%s\n' " <description>${EXT_NAME} ${STABILITY} build</description>"
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
printf '%s\n' " <type>${EXT_TYPE}</type>"
printf '%s\n' " <version>${VERSION_LABEL}</version>"
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
printf '%s\n' ' <tags>'
printf '%s\n' " <tag>${STABILITY}</tag>"
printf '%s\n' ' </tags>'
printf '%s\n' " <infourl title=\"${EXT_NAME}\">https://git.mokoconsulting.tech/${{ github.repository }}/tree/${BRANCH}</infourl>"
printf '%s\n' ' <downloads>'
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
printf '%s\n' ' </downloads>'
printf '%s\n' " ${TARGET_PLATFORM}"
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
printf '%s\n' ' </update>'
printf '%s\n' '</updates>'
} > updates.xml
sed -i '/^[[:space:]]*$/d' updates.xml
fi
fi
# Use Joomla-aware deploy for waas-component (routes files to correct Joomla dirs)
# Use standard SFTP deploy for everything else
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
# (both scripts handle dotfile skipping and .ftpignore natively)
# Remove temp files that should never be left behind
rm -f /tmp/deploy_key /tmp/sftp-config.json
# Dev deploys fail silently — no issue creation.
# Demo and RS deploys create failure issues (production-facing).
- name: Deployment summary
if: always()
run: |
if [ "${{ steps.source.outputs.skip }}" == "true" ]; then
echo "### ⏭️ Deployment Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No \`src/\` directory found in this repository." >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ job.status }}" == "success" ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ Dev Deployment Successful" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Host | \`${{ steps.conn.outputs.host }}:${{ steps.conn.outputs.port }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Remote path | \`${{ steps.remote.outputs.path }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Source | \`src/\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Trigger | ${{ github.event_name }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Auth | ${{ steps.auth.outputs.method }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Clear remote | ${{ inputs.clear_remote || 'false' }} |" >> "$GITHUB_STEP_SUMMARY"
else
echo "### ❌ Dev Deployment Failed" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Check the job log above for error details." >> "$GITHUB_STEP_SUMMARY"
fi
@@ -1,672 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/shared/deploy-rs.yml.template
# VERSION: 04.06.00
# BRIEF: SFTP deployment workflow for release staging server — synced to all governed repos
# NOTE: Synced via bulk-repo-sync to .gitea/workflows/deploy-rs.yml in all governed repos.
# Port is resolved in order: RS_FTP_PORT variable → :port suffix in RS_FTP_HOST → 22.
name: Deploy to RS Server (SFTP)
# Deploys the contents of the src/ directory to the release staging server via SFTP.
# Triggers on push/merge to main — deploys the production-ready build to the release staging server.
#
# Required org-level variables: RS_FTP_HOST, RS_FTP_PATH, RS_FTP_USERNAME
# Optional org-level variable: RS_FTP_PORT (auto-detected from host or defaults to 22)
# Optional org/repo variable: RS_FTP_SUFFIX — when set, appended to RS_FTP_PATH to form the
# full remote destination: RS_FTP_PATH/RS_FTP_SUFFIX
# Ignore rules: Place a .ftpignore file in the src/ directory. Each non-empty,
# non-comment line is a glob pattern tested against the relative path
# of each file (e.g. "subdir/file.txt"). The .gitignore is NOT used.
# Required org-level secret: RS_FTP_KEY (preferred) or RS_FTP_PASSWORD
#
# Access control: only users with admin or maintain role on the repository may deploy.
on:
push:
branches:
- main
- master
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main
- master
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all files inside the remote destination folder before uploading'
required: false
default: false
type: boolean
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
check-permission:
name: Verify Deployment Permission
runs-on: ubuntu-latest
steps:
- name: Check actor permission
env:
# Prefer the org-scoped GH_TOKEN secret (needed for the org membership
# fallback). Falls back to the built-in github.token so the collaborator
# endpoint still works even if GH_TOKEN is not configured.
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
ALLOWED_USERS="jmiller gitea-actions[bot]"
REPO="${{ github.repository }}"
ORG="${{ github.repository_owner }}"
METHOD=""
AUTHORIZED="false"
# Hardcoded authorized users — always allowed to deploy
AUTHORIZED_USERS="jmiller gitea-actions[bot]"
for user in $AUTHORIZED_USERS; do
if [ "$ACTOR" = "$user" ]; then
AUTHORIZED="true"
METHOD="hardcoded allowlist"
PERMISSION="admin"
break
fi
done
# For other actors, check repo/org permissions via API
if [ "$AUTHORIZED" != "true" ]; then
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/collaborators/${ACTOR}/permission" 2>/dev/null \
2>/dev/null | jq -r '.permission')
METHOD="repo collaborator API"
if [ -z "$PERMISSION" ]; then
ORG_ROLE=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/orgs/${ORG}/memberships/${ACTOR}" \
2>/dev/null | jq -r '.role')
METHOD="org membership API"
if [ "$ORG_ROLE" = "owner" ]; then
PERMISSION="admin"
else
PERMISSION="none"
fi
fi
case "$PERMISSION" in
admin|maintain) AUTHORIZED="true" ;;
esac
fi
# Write detailed summary
{
echo "## 🔐 Deploy Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${AUTHORIZED} |"
echo "| **Trigger** | \`${{ github.event_name }}\` |"
echo "| **Branch** | \`${{ github.ref_name }}\` |"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$AUTHORIZED" = "true" ]; then
echo "✅ ${ACTOR} authorized to deploy (${METHOD})" >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ ${ACTOR} is NOT authorized to deploy." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Deployment requires one of:" >> "$GITHUB_STEP_SUMMARY"
echo "- Being in the hardcoded allowlist" >> "$GITHUB_STEP_SUMMARY"
echo "- Having \`admin\` or \`maintain\` role on the repository" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
deploy:
name: SFTP Deploy → RS
runs-on: ubuntu-latest
needs: [check-permission]
if: >-
!startsWith(github.head_ref || github.ref_name, 'chore/') &&
(github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
(github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened' ||
github.event.pull_request.merged == true)))
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Resolve source directory
id: source
run: |
# Resolve source directory: src/ preferred, htdocs/ as fallback
if [ -d "src" ]; then
SRC="src"
elif [ -d "htdocs" ]; then
SRC="htdocs"
else
echo "⚠️ No src/ or htdocs/ directory found — skipping deployment"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
COUNT=$(find "$SRC" -type f | wc -l)
echo "✅ Source: ${SRC}/ (${COUNT} file(s))"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
- name: Preview files to deploy
if: steps.source.outputs.skip == 'false'
env:
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Convert a ftpignore-style glob line to an ERE pattern ──────────────
ftpignore_to_regex() {
local line="$1"
local anchored=false
# Strip inline comments and whitespace
line=$(printf '%s' "$line" | sed 's/[[:space:]]*#.*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$line" ] && return
# Skip negation patterns (not supported)
[[ "$line" == !* ]] && return
# Trailing slash = directory marker; strip it
line="${line%/}"
# Leading slash = anchored to root; strip it
if [[ "$line" == /* ]]; then
anchored=true
line="${line#/}"
fi
# Escape ERE special chars, then restore glob semantics
local regex
regex=$(printf '%s' "$line" \
| sed 's/[.+^${}()|[\\]/\\&/g' \
| sed 's/\\\*\\\*/\x01/g' \
| sed 's/\\\*/[^\/]*/g' \
| sed 's/\x01/.*/g' \
| sed 's/\\\?/[^\/]/g')
if $anchored; then
printf '^%s(/|$)' "$regex"
else
printf '(^|/)%s(/|$)' "$regex"
fi
}
# ── Read .ftpignore (ftpignore-style globs) ─────────────────────────
IGNORE_PATTERNS=()
IGNORE_SOURCES=()
if [ -f "${SOURCE_DIR}/.ftpignore" ]; then
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
regex=$(ftpignore_to_regex "$line")
[ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
done < "${SOURCE_DIR}/.ftpignore"
fi
# ── Walk src/ and classify every file ────────────────────────────────
WILL_UPLOAD=()
IGNORED_FILES=()
while IFS= read -r -d '' file; do
rel="${file#${SOURCE_DIR}/}"
SKIP=false
for i in "${!IGNORE_PATTERNS[@]}"; do
if echo "$rel" | grep -qE "${IGNORE_PATTERNS[$i]}" 2>/dev/null; then
IGNORED_FILES+=("$rel | .ftpignore \`${IGNORE_SOURCES[$i]}\`")
SKIP=true; break
fi
done
$SKIP && continue
WILL_UPLOAD+=("$rel")
done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
UPLOAD_COUNT="${#WILL_UPLOAD[@]}"
IGNORE_COUNT="${#IGNORED_FILES[@]}"
echo "️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored"
# ── Write deployment preview to step summary ──────────────────────────
{
echo "## 📋 Deployment Preview"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Source | \`${SOURCE_DIR}/\` |"
echo "| Files to upload | **${UPLOAD_COUNT}** |"
echo "| Files ignored | **${IGNORE_COUNT}** |"
echo ""
if [ "${UPLOAD_COUNT}" -gt 0 ]; then
echo "### 📂 Files that will be uploaded"
echo '```'
printf '%s\n' "${WILL_UPLOAD[@]}"
echo '```'
echo ""
fi
if [ "${IGNORE_COUNT}" -gt 0 ]; then
echo "### ⏭️ Files excluded"
echo "| File | Reason |"
echo "|---|---|"
for entry in "${IGNORED_FILES[@]}"; do
f="${entry% | *}"; r="${entry##* | }"
echo "| \`${f}\` | ${r} |"
done
echo ""
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Resolve SFTP host and port
if: steps.source.outputs.skip == 'false'
id: conn
env:
HOST_RAW: ${{ vars.RS_FTP_HOST }}
PORT_VAR: ${{ vars.RS_FTP_PORT }}
run: |
HOST="$HOST_RAW"
PORT="$PORT_VAR"
if [ -z "$HOST" ]; then
echo "⏭️ RS_FTP_HOST not configured — skipping RS deployment."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Priority 1 — explicit RS_FTP_PORT variable
if [ -n "$PORT" ]; then
echo "️ Using explicit RS_FTP_PORT=${PORT}"
# Priority 2 — port embedded in RS_FTP_HOST (host:port)
elif [[ "$HOST" == *:* ]]; then
PORT="${HOST##*:}"
HOST="${HOST%:*}"
echo "️ Extracted port ${PORT} from RS_FTP_HOST"
# Priority 3 — SFTP default
else
PORT="22"
echo "️ No port specified — defaulting to SFTP port 22"
fi
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "port=${PORT}" >> "$GITHUB_OUTPUT"
echo "SFTP target: ${HOST}:${PORT}"
- name: Build remote path
if: steps.source.outputs.skip == 'false' && steps.conn.outputs.skip != 'true'
id: remote
env:
RS_FTP_PATH: ${{ vars.RS_FTP_PATH }}
RS_FTP_SUFFIX: ${{ vars.RS_FTP_SUFFIX }}
run: |
BASE="$RS_FTP_PATH"
if [ -z "$BASE" ]; then
echo "⏭️ RS_FTP_PATH not configured — skipping RS deployment."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# RS_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
# Without it we cannot safely determine the deployment target.
if [ -z "$RS_FTP_SUFFIX" ]; then
echo "⏭️ RS_FTP_SUFFIX variable is not set — skipping deployment."
echo " Set RS_FTP_SUFFIX as a repo or org variable to enable deploy-rs."
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "path=" >> "$GITHUB_OUTPUT"
exit 0
fi
REMOTE="${BASE%/}/${RS_FTP_SUFFIX#/}"
# ── Platform-specific path safety guards ──────────────────────────────
PLATFORM=""
MOKO_FILE=".gitea/.mokostandards"
[ ! -f "$MOKO_FILE" ] && MOKO_FILE=".github/.mokostandards"
[ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"
if [ -f "$MOKO_FILE" ]; then
# XML format: extract <platform>value</platform>
if grep -q '<mokostandards' "$MOKO_FILE" 2>/dev/null; then
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' "$MOKO_FILE" | head -1)
else
# Legacy YAML-like format: platform: value
PLATFORM=$(grep -E '^platform:' "$MOKO_FILE" | sed 's/.*:[[:space:]]*//' | tr -d '"')
fi
fi
# RS deployment: no path restrictions for any platform
echo "️ Remote path: ${REMOTE}"
echo "path=${REMOTE}" >> "$GITHUB_OUTPUT"
- name: Detect SFTP authentication method
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
id: auth
env:
HAS_KEY: ${{ secrets.RS_FTP_KEY }}
HAS_PASSWORD: ${{ secrets.RS_FTP_PASSWORD }}
run: |
if [ -n "$HAS_KEY" ] && [ -n "$HAS_PASSWORD" ]; then
# Both set: key auth with password as passphrase; falls back to password-only if key fails
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=true" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Primary: SSH key + passphrase (RS_FTP_KEY / RS_FTP_PASSWORD)"
echo "️ Fallback: password-only auth if key authentication fails"
elif [ -n "$HAS_KEY" ]; then
# Key only: no passphrase, no password fallback
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=false" >> "$GITHUB_OUTPUT"
echo "️ Using SSH key authentication (RS_FTP_KEY, no passphrase, no fallback)"
elif [ -n "$HAS_PASSWORD" ]; then
# Password only: direct SFTP password auth
echo "method=password" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Using password authentication (RS_FTP_PASSWORD)"
else
echo "❌ No SFTP credentials configured."
echo " Set RS_FTP_KEY (preferred) or RS_FTP_PASSWORD as an org-level secret."
exit 1
fi
- name: Setup PHP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
run: |
php -v && composer --version
- name: Setup MokoStandards deploy tools
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
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 {{standards_branch}} --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
- name: Clear remote destination folder (manual only)
if: >-
steps.source.outputs.skip == 'false' &&
steps.remote.outputs.skip != 'true' &&
inputs.clear_remote == true
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.RS_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.RS_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.RS_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
HAS_PASSWORD: ${{ steps.auth.outputs.has_password }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
run: |
cat > /tmp/moko_clear.php << 'PHPEOF'
<?php
declare(strict_types=1);
require '/tmp/mokostandards-api/vendor/autoload.php';
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
$host = (string) getenv('SFTP_HOST');
$port = (int) getenv('SFTP_PORT');
$username = (string) getenv('SFTP_USER');
$authMethod = (string) getenv('AUTH_METHOD');
$usePassphrase = getenv('USE_PASSPHRASE') === 'true';
$hasPassword = getenv('HAS_PASSWORD') === 'true';
$remotePath = rtrim((string) getenv('REMOTE_PATH'), '/');
echo "⚠️ Clearing remote folder: {$remotePath}\n";
$sftp = new SFTP($host, $port);
// ── Authentication ──────────────────────────────────────────────
if ($authMethod === 'key') {
$keyData = (string) getenv('SFTP_KEY');
$passphrase = $usePassphrase ? (string) getenv('SFTP_PASSWORD') : false;
$password = $hasPassword ? (string) getenv('SFTP_PASSWORD') : '';
$key = PublicKeyLoader::load($keyData, $passphrase);
if (!$sftp->login($username, $key)) {
if ($password !== '') {
echo "⚠️ Key auth failed — falling back to password\n";
if (!$sftp->login($username, $password)) {
fwrite(STDERR, "❌ Both key and password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication (key fallback)\n";
} else {
fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
exit(1);
}
} else {
echo "✅ Connected via SSH key authentication\n";
}
} else {
if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
fwrite(STDERR, "❌ Password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication\n";
}
// ── Recursive delete ────────────────────────────────────────────
function rmrf(SFTP $sftp, string $path): void
{
$entries = $sftp->nlist($path);
if ($entries === false) {
return; // path does not exist — nothing to clear
}
foreach ($entries as $name) {
if ($name === '.' || $name === '..') {
continue;
}
$entry = "{$path}/{$name}";
if ($sftp->is_dir($entry)) {
rmrf($sftp, $entry);
$sftp->rmdir($entry);
echo " 🗑️ Removed dir: {$entry}\n";
} else {
$sftp->delete($entry);
echo " 🗑️ Removed file: {$entry}\n";
}
}
}
// ── Create remote directory tree ────────────────────────────────
function sftpMakedirs(SFTP $sftp, string $path): void
{
$parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
$current = str_starts_with($path, '/') ? '' : '';
foreach ($parts as $part) {
$current .= '/' . $part;
$sftp->mkdir($current); // silently returns false if already exists
}
}
rmrf($sftp, $remotePath);
sftpMakedirs($sftp, $remotePath);
echo "✅ Remote folder ready: {$remotePath}\n";
PHPEOF
php /tmp/moko_clear.php
- name: Deploy via SFTP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.RS_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.RS_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.RS_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Write SSH key to temp file (key auth only) ────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
printf '%s' "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
fi
# ── Generate sftp-config.json safely via jq ───────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg key "/tmp/deploy_key" \
'{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
> /tmp/sftp-config.json
else
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg pass "$SFTP_PASSWORD" \
'{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
> /tmp/sftp-config.json
fi
# ── Run deploy-sftp.php from MokoStandards ────────────────────────────
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
if [ "$USE_PASSPHRASE" = "true" ]; then
DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
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 "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
# Remove temp files that should never be left behind
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Create or update failure issue
if: failure() && steps.remote.outputs.skip != 'true' && steps.conn.outputs.skip != 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
RUN_URL="${{ github.server_url }}/${REPO}/actions/runs/${{ github.run_id }}"
ACTOR="${{ github.actor }}"
ALLOWED_USERS="jmiller gitea-actions[bot]"
BRANCH="${{ github.ref_name }}"
EVENT="${{ github.event_name }}"
NOW=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
LABEL="deploy-failure"
TITLE="fix: RS deployment failed — ${REPO}"
BODY="## RS Deployment Failed
A deployment to the RS server failed and requires attention.
| Field | Value |
|-------|-------|
| **Repository** | \`${REPO}\` |
| **Branch** | \`${BRANCH}\` |
| **Trigger** | ${EVENT} |
| **Actor** | @${ACTOR} |
| **Failed at** | ${NOW} |
| **Run** | [View workflow run](${RUN_URL}) |
### Next steps
1. Review the [workflow run log](${RUN_URL}) for the specific error.
2. Fix the underlying issue (credentials, SFTP connectivity, permissions).
3. Re-trigger the deployment via **Actions → Deploy to RS Server → Run workflow**.
---
*Auto-created by deploy-rs.yml — close this issue once the deployment is resolved.*"
# Ensure the label exists (idempotent — no-op if already present)
gh label create "$LABEL" \
--repo "$REPO" \
--color "CC0000" \
--description "Automated deploy failure tracking" \
--force 2>/dev/null || true
# Look for an existing deploy-failure issue (any state — reopen if closed)
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues?labels=${LABEL}&state=all&per_page=1&sort=created&direction=desc" 2>/dev/null \
2>/dev/null | jq -r '.[0].number')
if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues/${EXISTING}" 2>/dev/null \
-X PATCH \
-f title="$TITLE" \
-f body="$BODY" \
-f state="open" \
--silent
echo "📋 Failure issue #${EXISTING} updated/reopened: ${REPO}" >> "$GITHUB_STEP_SUMMARY"
else
gh issue create \
--repo "$REPO" \
--title "$TITLE" \
--body "$BODY" \
--label "$LABEL" \
--assignee "jmiller" \
| tee -a "$GITHUB_STEP_SUMMARY"
fi
- name: Deployment summary
if: always()
run: |
if [ "${{ steps.source.outputs.skip }}" == "true" ]; then
echo "### ⏭️ Deployment Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No \`src/\` directory found in this repository." >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ job.status }}" == "success" ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ RS Deployment Successful" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Host | \`${{ steps.conn.outputs.host }}:${{ steps.conn.outputs.port }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Remote path | \`${{ steps.remote.outputs.path }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Source | \`src/\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Trigger | ${{ github.event_name }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Auth | ${{ steps.auth.outputs.method }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Clear remote | ${{ inputs.clear_remote || 'false' }} |" >> "$GITHUB_STEP_SUMMARY"
else
echo "### ❌ RS Deployment Failed" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Check the job log above for error details." >> "$GITHUB_STEP_SUMMARY"
fi
@@ -1,758 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Firewall
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/shared/enterprise-firewall-setup.yml.template
# VERSION: 04.06.00
# BRIEF: Enterprise firewall configuration — generates outbound allow-rules including SFTP deployment server
# NOTE: Reads DEV_FTP_HOST / DEV_FTP_PORT variables to include SFTP egress rules alongside HTTPS rules.
name: Enterprise Firewall Configuration
# This workflow provides firewall configuration guidance for enterprise-ready sites
# It generates firewall rules for allowing outbound access to trusted domains
# including license providers, documentation sources, package registries,
# and the SFTP deployment server (DEV_FTP_HOST / DEV_FTP_PORT).
#
# Runs automatically when:
# - Coding agent workflows are triggered (pull requests with copilot/ prefix)
# - Manual workflow dispatch for custom configurations
on:
workflow_dispatch:
inputs:
firewall_type:
description: 'Target firewall type'
required: true
type: choice
options:
- 'iptables'
- 'ufw'
- 'firewalld'
- 'aws-security-group'
- 'azure-nsg'
- 'gcp-firewall'
- 'cloudflare'
- 'all'
default: 'all'
output_format:
description: 'Output format'
required: true
type: choice
options:
- 'shell-script'
- 'json'
- 'yaml'
- 'markdown'
- 'all'
default: 'markdown'
# Auto-run when coding agent creates or updates PRs
pull_request:
branches:
- 'copilot/**'
- 'agent/**'
types: [opened, synchronize, reopened]
# Auto-run on push to coding agent branches
push:
branches:
- 'copilot/**'
- 'agent/**'
permissions:
contents: read
actions: read
jobs:
generate-firewall-rules:
name: Generate Firewall Rules
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Apply Firewall Rules to Runner (Auto-run only)
if: github.event_name != 'workflow_dispatch'
env:
DEV_FTP_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_FTP_PORT: ${{ vars.DEV_FTP_PORT }}
run: |
echo "🔥 Applying firewall rules for coding agent environment..."
echo ""
echo "This step ensures the GitHub Actions runner can access trusted domains"
echo "including license providers, package registries, and documentation sources."
echo ""
# Note: GitHub Actions runners are ephemeral and run in controlled environments
# This step documents what domains are being accessed during the workflow
# Actual firewall configuration is managed by GitHub
cat > /tmp/trusted-domains.txt << 'EOF'
# Trusted domains for coding agent environment
# License Providers
www.gnu.org
opensource.org
choosealicense.com
spdx.org
creativecommons.org
apache.org
fsf.org
# Documentation & Standards
semver.org
keepachangelog.com
conventionalcommits.org
# GitHub & Related
github.com
api.github.com
docs.github.com
raw.githubusercontent.com
ghcr.io
# Package Registries
npmjs.com
registry.npmjs.org
pypi.org
files.pythonhosted.org
packagist.org
repo.packagist.org
rubygems.org
# Platform-Specific
joomla.org
downloads.joomla.org
docs.joomla.org
php.net
getcomposer.org
dolibarr.org
wiki.dolibarr.org
docs.dolibarr.org
# Moko Consulting
mokoconsulting.tech
# SFTP Deployment Server (DEV_FTP_HOST)
${DEV_FTP_HOST:-<not configured>}
# Google Services
drive.google.com
docs.google.com
sheets.google.com
accounts.google.com
storage.googleapis.com
fonts.googleapis.com
fonts.gstatic.com
# GitHub Extended
upload.github.com
objects.githubusercontent.com
user-images.githubusercontent.com
codeload.github.com
pkg.github.com
# Developer Reference
developer.mozilla.org
stackoverflow.com
git-scm.com
# CDN & Infrastructure
cdn.jsdelivr.net
unpkg.com
cdnjs.cloudflare.com
img.shields.io
# Container Registries
hub.docker.com
registry-1.docker.io
# CI & Code Quality
codecov.io
sonarcloud.io
# Terraform & Infrastructure
registry.terraform.io
releases.hashicorp.com
checkpoint-api.hashicorp.com
EOF
echo "✓ Trusted domains documented for this runner"
echo "✓ GitHub Actions runners have network access to these domains"
echo ""
# Test connectivity to key domains
echo "Testing connectivity to key domains..."
for domain in "github.com" "www.gnu.org" "npmjs.com" "pypi.org"; do
if curl -s --max-time 3 -o /dev/null -w "%{http_code}" "https://$domain" | grep -q "200\|301\|302"; then
echo " ✓ $domain is accessible"
else
echo " ⚠️ $domain connectivity check failed (may be expected)"
fi
done
# Test SFTP server connectivity (TCP port check)
SFTP_HOST="${DEV_FTP_HOST:-}"
SFTP_PORT="${DEV_FTP_PORT:-22}"
if [ -n "$SFTP_HOST" ]; then
# Strip any embedded :port suffix
SFTP_HOST="${SFTP_HOST%%:*}"
echo ""
echo "Testing SFTP deployment server connectivity..."
if timeout 5 bash -c "echo >/dev/tcp/${SFTP_HOST}/${SFTP_PORT}" 2>/dev/null; then
echo " ✓ SFTP server ${SFTP_HOST}:${SFTP_PORT} is reachable"
else
echo " ⚠️ SFTP server ${SFTP_HOST}:${SFTP_PORT} is not reachable from runner (firewall rule needed)"
fi
else
echo ""
echo " ️ DEV_FTP_HOST not configured — skipping SFTP connectivity check"
fi
- name: Generate Firewall Configuration
id: generate
env:
DEV_FTP_HOST: ${{ vars.DEV_FTP_HOST }}
DEV_FTP_PORT: ${{ vars.DEV_FTP_PORT }}
run: |
cat > generate_firewall_config.py << 'PYTHON_EOF'
#!/usr/bin/env python3
"""
Enterprise Firewall Configuration Generator
Generates firewall rules for enterprise-ready deployments allowing
access to trusted domains including license providers, documentation
sources, package registries, and platform-specific sites.
"""
import json
import os
import yaml
import sys
from typing import List, Dict
# SFTP deployment server from org variables
_sftp_host_raw = os.environ.get("DEV_FTP_HOST", "").strip()
_sftp_port = os.environ.get("DEV_FTP_PORT", "").strip() or "22"
# Strip embedded :port suffix if present
_sftp_host = _sftp_host_raw.split(":")[0] if _sftp_host_raw else ""
if ":" in _sftp_host_raw and not _sftp_port:
_sftp_port = _sftp_host_raw.split(":")[1]
SFTP_HOST = _sftp_host
SFTP_PORT = int(_sftp_port) if _sftp_port.isdigit() else 22
# Trusted domains from .github/copilot.yml
TRUSTED_DOMAINS = {
"license_providers": [
"www.gnu.org",
"opensource.org",
"choosealicense.com",
"spdx.org",
"creativecommons.org",
"apache.org",
"fsf.org",
],
"documentation_standards": [
"semver.org",
"keepachangelog.com",
"conventionalcommits.org",
],
"github_related": [
"github.com",
"api.github.com",
"docs.github.com",
"raw.githubusercontent.com",
"ghcr.io",
],
"package_registries": [
"npmjs.com",
"registry.npmjs.org",
"pypi.org",
"files.pythonhosted.org",
"packagist.org",
"repo.packagist.org",
"rubygems.org",
],
"standards_organizations": [
"json-schema.org",
"w3.org",
"ietf.org",
],
"platform_specific": [
"joomla.org",
"downloads.joomla.org",
"docs.joomla.org",
"php.net",
"getcomposer.org",
"dolibarr.org",
"wiki.dolibarr.org",
"docs.dolibarr.org",
],
"moko_consulting": [
"mokoconsulting.tech",
],
"google_services": [
"drive.google.com",
"docs.google.com",
"sheets.google.com",
"accounts.google.com",
"storage.googleapis.com",
"fonts.googleapis.com",
"fonts.gstatic.com",
],
"github_extended": [
"upload.github.com",
"objects.githubusercontent.com",
"user-images.githubusercontent.com",
"codeload.github.com",
"pkg.github.com",
],
"developer_reference": [
"developer.mozilla.org",
"stackoverflow.com",
"git-scm.com",
],
"cdn_and_infrastructure": [
"cdn.jsdelivr.net",
"unpkg.com",
"cdnjs.cloudflare.com",
"img.shields.io",
],
"container_registries": [
"hub.docker.com",
"registry-1.docker.io",
],
"ci_code_quality": [
"codecov.io",
"sonarcloud.io",
],
"terraform_infrastructure": [
"registry.terraform.io",
"releases.hashicorp.com",
"checkpoint-api.hashicorp.com",
],
}
# Inject SFTP deployment server as a separate category (port 22, not 443)
if SFTP_HOST:
TRUSTED_DOMAINS["sftp_deployment_server"] = [SFTP_HOST]
print(f"️ SFTP deployment server: {SFTP_HOST}:{SFTP_PORT}")
def generate_sftp_iptables_rules(host: str, port: int) -> str:
"""Generate iptables rules specifically for SFTP egress"""
return (
f"# Allow SFTP to deployment server {host}:{port}\n"
f"iptables -A OUTPUT -p tcp -d $(dig +short {host} | head -1)"
f" --dport {port} -j ACCEPT # SFTP deploy\n"
)
def generate_sftp_ufw_rules(host: str, port: int) -> str:
"""Generate UFW rules for SFTP egress"""
return (
f"# Allow SFTP to deployment server\n"
f"ufw allow out to $(dig +short {host} | head -1)"
f" port {port} proto tcp comment 'SFTP deploy to {host}'\n"
)
def generate_sftp_firewalld_rules(host: str, port: int) -> str:
"""Generate firewalld rules for SFTP egress"""
return (
f"# Allow SFTP to deployment server\n"
f"firewall-cmd --permanent --add-rich-rule='"
f"rule family=ipv4 destination address=$(dig +short {host} | head -1)"
f" port port={port} protocol=tcp accept' # SFTP deploy\n"
)
def generate_iptables_rules(domains: List[str]) -> str:
"""Generate iptables firewall rules"""
rules = ["#!/bin/bash", "", "# Enterprise Firewall Rules - iptables", ""]
rules.append("# Allow outbound HTTPS to trusted domains")
rules.append("")
for domain in domains:
rules.append(f"# Allow {domain}")
rules.append(f"iptables -A OUTPUT -p tcp -d $(dig +short {domain} | head -1) --dport 443 -j ACCEPT")
rules.append("")
rules.append("# Allow DNS lookups")
rules.append("iptables -A OUTPUT -p udp --dport 53 -j ACCEPT")
rules.append("iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT")
return "\n".join(rules)
def generate_ufw_rules(domains: List[str]) -> str:
"""Generate UFW firewall rules"""
rules = ["#!/bin/bash", "", "# Enterprise Firewall Rules - UFW", ""]
rules.append("# Allow outbound HTTPS to trusted domains")
rules.append("")
for domain in domains:
rules.append(f"# Allow {domain}")
rules.append(f"ufw allow out to $(dig +short {domain} | head -1) port 443 proto tcp comment 'Allow {domain}'")
rules.append("")
rules.append("# Allow DNS")
rules.append("ufw allow out 53/udp comment 'Allow DNS UDP'")
rules.append("ufw allow out 53/tcp comment 'Allow DNS TCP'")
return "\n".join(rules)
def generate_firewalld_rules(domains: List[str]) -> str:
"""Generate firewalld rules"""
rules = ["#!/bin/bash", "", "# Enterprise Firewall Rules - firewalld", ""]
rules.append("# Add trusted domains to firewall")
rules.append("")
for domain in domains:
rules.append(f"# Allow {domain}")
rules.append(f"firewall-cmd --permanent --add-rich-rule='rule family=ipv4 destination address=$(dig +short {domain} | head -1) port port=443 protocol=tcp accept'")
rules.append("")
rules.append("# Reload firewall")
rules.append("firewall-cmd --reload")
return "\n".join(rules)
def generate_aws_security_group(domains: List[str]) -> Dict:
"""Generate AWS Security Group rules (JSON format)"""
rules = {
"SecurityGroupRules": {
"Egress": []
}
}
for domain in domains:
rules["SecurityGroupRules"]["Egress"].append({
"Description": f"Allow HTTPS to {domain}",
"IpProtocol": "tcp",
"FromPort": 443,
"ToPort": 443,
"CidrIp": "0.0.0.0/0", # In practice, resolve to specific IPs
"Tags": [{
"Key": "Domain",
"Value": domain
}]
})
# Add DNS
rules["SecurityGroupRules"]["Egress"].append({
"Description": "Allow DNS",
"IpProtocol": "udp",
"FromPort": 53,
"ToPort": 53,
"CidrIp": "0.0.0.0/0"
})
return rules
def generate_markdown_documentation(domains_by_category: Dict[str, List[str]]) -> str:
"""Generate markdown documentation"""
md = ["# Enterprise Firewall Configuration Guide", ""]
md.append("## Overview")
md.append("")
md.append("This document provides firewall configuration guidance for enterprise-ready deployments.")
md.append("It lists trusted domains that should be whitelisted for outbound access to ensure")
md.append("proper functionality of license validation, package management, and documentation access.")
md.append("")
md.append("## Trusted Domains by Category")
md.append("")
all_domains = []
for category, domains in domains_by_category.items():
category_name = category.replace("_", " ").title()
md.append(f"### {category_name}")
md.append("")
md.append("| Domain | Purpose |")
md.append("|--------|---------|")
for domain in domains:
all_domains.append(domain)
purpose = get_domain_purpose(domain)
md.append(f"| `{domain}` | {purpose} |")
md.append("")
md.append("## Implementation Examples")
md.append("")
md.append("### iptables Example")
md.append("")
md.append("```bash")
md.append("# Allow HTTPS to trusted domain")
md.append(f"iptables -A OUTPUT -p tcp -d $(dig +short {all_domains[0]}) --dport 443 -j ACCEPT")
md.append("```")
md.append("")
md.append("### UFW Example")
md.append("")
md.append("```bash")
md.append("# Allow HTTPS to trusted domain")
md.append(f"ufw allow out to {all_domains[0]} port 443 proto tcp")
md.append("```")
md.append("")
md.append("### AWS Security Group Example")
md.append("")
md.append("```json")
md.append("{")
md.append(' "IpPermissions": [{')
md.append(' "IpProtocol": "tcp",')
md.append(' "FromPort": 443,')
md.append(' "ToPort": 443,')
md.append(' "IpRanges": [{"CidrIp": "0.0.0.0/0", "Description": "HTTPS to trusted domains"}]')
md.append(" }]")
md.append("}")
md.append("```")
md.append("")
md.append("## Ports Required")
md.append("")
md.append("| Port | Protocol | Purpose |")
md.append("|------|----------|---------|")
md.append("| 443 | TCP | HTTPS (secure web access) |")
md.append("| 80 | TCP | HTTP (redirects to HTTPS) |")
md.append("| 53 | UDP/TCP | DNS resolution |")
md.append("")
md.append("## Security Considerations")
md.append("")
md.append("1. **DNS Resolution**: Ensure DNS queries are allowed (port 53 UDP/TCP)")
md.append("2. **Certificate Validation**: HTTPS requires ability to reach certificate authorities")
md.append("3. **Dynamic IPs**: Some domains use CDNs with dynamic IPs - consider using FQDNs in rules")
md.append("4. **Regular Updates**: Review and update whitelist as services change")
md.append("5. **Logging**: Enable logging for blocked connections to identify missing rules")
md.append("")
md.append("## Compliance Notes")
md.append("")
md.append("- All listed domains provide read-only access to public information")
md.append("- License providers enable GPL compliance verification")
md.append("- Package registries support dependency security scanning")
md.append("- No authentication credentials are transmitted to these domains")
md.append("")
return "\n".join(md)
def get_domain_purpose(domain: str) -> str:
"""Get human-readable purpose for a domain"""
purposes = {
"www.gnu.org": "GNU licenses and documentation",
"opensource.org": "Open Source Initiative resources",
"choosealicense.com": "GitHub license selection tool",
"spdx.org": "Software Package Data Exchange identifiers",
"creativecommons.org": "Creative Commons licenses",
"apache.org": "Apache Software Foundation licenses",
"fsf.org": "Free Software Foundation resources",
"semver.org": "Semantic versioning specification",
"keepachangelog.com": "Changelog format standards",
"conventionalcommits.org": "Commit message conventions",
"github.com": "GitHub platform access",
"api.github.com": "GitHub API access",
"docs.github.com": "GitHub documentation",
"raw.githubusercontent.com": "GitHub raw content access",
"npmjs.com": "npm package registry",
"pypi.org": "Python Package Index",
"packagist.org": "PHP Composer package registry",
"rubygems.org": "Ruby gems registry",
"joomla.org": "Joomla CMS platform",
"php.net": "PHP documentation and downloads",
"dolibarr.org": "Dolibarr ERP/CRM platform",
}
return purposes.get(domain, "Trusted resource")
def main():
# Use inputs if provided (manual dispatch), otherwise use defaults (auto-run)
firewall_type = "${{ github.event.inputs.firewall_type }}" or "all"
output_format = "${{ github.event.inputs.output_format }}" or "markdown"
print(f"Running in {'manual' if '${{ github.event.inputs.firewall_type }}' else 'automatic'} mode")
print(f"Firewall type: {firewall_type}")
print(f"Output format: {output_format}")
print("")
# Collect all domains
all_domains = []
for domains in TRUSTED_DOMAINS.values():
all_domains.extend(domains)
# Remove duplicates and sort
all_domains = sorted(set(all_domains))
print(f"Generating firewall rules for {len(all_domains)} trusted domains...")
print("")
# Exclude SFTP server from HTTPS rule generation (different port)
https_domains = [d for d in all_domains if d != SFTP_HOST]
# Generate based on firewall type
if firewall_type in ["iptables", "all"]:
rules = generate_iptables_rules(https_domains)
if SFTP_HOST:
rules += "\n# ── SFTP Deployment Server ──────────────────────────────\n"
rules += generate_sftp_iptables_rules(SFTP_HOST, SFTP_PORT)
with open("firewall-rules-iptables.sh", "w") as f:
f.write(rules)
print("✓ Generated iptables rules: firewall-rules-iptables.sh")
if firewall_type in ["ufw", "all"]:
rules = generate_ufw_rules(https_domains)
if SFTP_HOST:
rules += "\n# ── SFTP Deployment Server ──────────────────────────────\n"
rules += generate_sftp_ufw_rules(SFTP_HOST, SFTP_PORT)
with open("firewall-rules-ufw.sh", "w") as f:
f.write(rules)
print("✓ Generated UFW rules: firewall-rules-ufw.sh")
if firewall_type in ["firewalld", "all"]:
rules = generate_firewalld_rules(https_domains)
if SFTP_HOST:
rules += "\n# ── SFTP Deployment Server ──────────────────────────────\n"
rules += generate_sftp_firewalld_rules(SFTP_HOST, SFTP_PORT)
with open("firewall-rules-firewalld.sh", "w") as f:
f.write(rules)
print("✓ Generated firewalld rules: firewall-rules-firewalld.sh")
if firewall_type in ["aws-security-group", "all"]:
rules = generate_aws_security_group(all_domains)
with open("firewall-rules-aws-sg.json", "w") as f:
json.dump(rules, f, indent=2)
print("✓ Generated AWS Security Group rules: firewall-rules-aws-sg.json")
if output_format in ["yaml", "all"]:
with open("trusted-domains.yml", "w") as f:
yaml.dump(TRUSTED_DOMAINS, f, default_flow_style=False)
print("✓ Generated YAML domain list: trusted-domains.yml")
if output_format in ["json", "all"]:
with open("trusted-domains.json", "w") as f:
json.dump(TRUSTED_DOMAINS, f, indent=2)
print("✓ Generated JSON domain list: trusted-domains.json")
if output_format in ["markdown", "all"]:
md = generate_markdown_documentation(TRUSTED_DOMAINS)
with open("FIREWALL_CONFIGURATION.md", "w") as f:
f.write(md)
print("✓ Generated documentation: FIREWALL_CONFIGURATION.md")
print("")
print("Domain Categories:")
for category, domains in TRUSTED_DOMAINS.items():
print(f" - {category}: {len(domains)} domains")
print("")
print("Total unique domains: ", len(all_domains))
if __name__ == "__main__":
main()
PYTHON_EOF
chmod +x generate_firewall_config.py
pip install PyYAML
python3 generate_firewall_config.py
- name: Upload Firewall Configuration Artifacts
uses: actions/upload-artifact@v6
with:
name: firewall-configurations
path: |
firewall-rules-*.sh
firewall-rules-*.json
trusted-domains.*
FIREWALL_CONFIGURATION.md
retention-days: 90
- name: Display Summary
run: |
echo "## Firewall Configuration" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "**Mode**: Manual Execution" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Firewall rules have been generated for enterprise-ready deployments." >> $GITHUB_STEP_SUMMARY
else
echo "**Mode**: Automatic Execution (Coding Agent Active)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This workflow ran automatically because a coding agent (GitHub Copilot) is active." >> $GITHUB_STEP_SUMMARY
echo "Firewall configuration has been validated for the coding agent environment." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Files Generated" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if ls firewall-rules-* trusted-domains.* FIREWALL_CONFIGURATION.md 2>/dev/null; then
ls -lh firewall-rules-* trusted-domains.* FIREWALL_CONFIGURATION.md 2>/dev/null | awk '{print "- " $9 " (" $5 ")"}' >> $GITHUB_STEP_SUMMARY
else
echo "- Documentation generated" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "### Download Artifacts" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Download the generated firewall configurations from the workflow artifacts." >> $GITHUB_STEP_SUMMARY
else
echo "### Trusted Domains Active" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The coding agent has access to:" >> $GITHUB_STEP_SUMMARY
echo "- License providers (GPL, OSI, SPDX, Apache, etc.)" >> $GITHUB_STEP_SUMMARY
echo "- Package registries (npm, PyPI, Packagist, RubyGems)" >> $GITHUB_STEP_SUMMARY
echo "- Documentation sources (GitHub, Joomla, Dolibarr, PHP)" >> $GITHUB_STEP_SUMMARY
echo "- Standards organizations (W3C, IETF, JSON Schema)" >> $GITHUB_STEP_SUMMARY
fi
# Usage Instructions:
#
# This workflow runs in two modes:
#
# 1. AUTOMATIC MODE (Coding Agent):
# - Triggers when coding agent branches (copilot/**, agent/**) are pushed or PR'd
# - Validates firewall configuration for the coding agent environment
# - Documents accessible domains for compliance
# - Ensures license sources and package registries are available
#
# 2. MANUAL MODE (Enterprise Configuration):
# - Manually trigger from the Actions tab
# - Select desired firewall type and output format
# - Download generated artifacts
# - Apply firewall rules to your enterprise environment
#
# Configuration:
# - Trusted domains are sourced from .github/copilot.yml
# - Modify copilot.yml to add/remove trusted domains
# - Changes automatically propagate to firewall rules
#
# Important Notes:
# - Review generated rules before applying to production
# - Some domains may use CDNs with dynamic IPs
# - Consider using FQDN-based rules where supported
# - Test thoroughly in staging environment first
# - Monitor logs for blocked connections
# - Update rules as domains/services change
@@ -1,345 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards-API.Deployment
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/shared/export-mysql.yml.template
# VERSION: 04.06.12
# BRIEF: Export MySQL database from dev/demo server and save as artifact or commit
name: Export MySQL Database
on:
workflow_dispatch:
inputs:
environment:
description: 'Which server to export from'
required: true
type: choice
options:
- dev
- demo
default: 'dev'
database:
description: 'Database name (overrides variable)'
required: false
type: string
default: ''
save_to_repo:
description: 'Commit SQL dump to repo (otherwise artifact only)'
required: false
type: boolean
default: false
branch:
description: 'Branch to commit to (if save_to_repo)'
required: false
type: string
default: 'dev'
# ──────────────────────────────────────────────────────────────
# Required secrets and variables (per environment):
#
# DEV ENVIRONMENT — secrets/variables:
# DEV_SSH_HOST — Dev server hostname
# DEV_SSH_PORT — SSH port (default: 22)
# DEV_SSH_USERNAME — SSH user
# DEV_SSH_KEY — SSH private key
# DEV_PULL_PATH — Remote install path (repo variable)
#
# DEMO ENVIRONMENT — secrets/variables:
# DEMO_FTP_HOST — Demo server hostname (reused from deploy)
# DEMO_FTP_PORT — SSH port (reused, default: 22)
# DEMO_FTP_USERNAME — SSH user (reused from deploy)
# DEMO_FTP_KEY — SSH key (reused from deploy)
# DEMO_FTP_PATH — Remote install path (repo variable)
#
# MySQL credentials are read automatically from:
# Joomla: configuration.php ($user, $password, $db)
# Dolibarr: conf/conf.php ($dolibarr_main_db_user, etc.)
# No MySQL secrets needed — credentials come from the remote config file.
# ──────────────────────────────────────────────────────────────
permissions:
contents: write
jobs:
export-mysql:
name: Export MySQL — ${{ inputs.environment }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
if: inputs.save_to_repo
uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
- name: Resolve environment config
id: env
run: |
ENV="${{ inputs.environment }}"
if [ "$ENV" = "dev" ]; then
HOST="${{ vars.DEV_SSH_HOST }}"
PORT="${{ vars.DEV_SSH_PORT || '22' }}"
USER="${{ vars.DEV_SSH_USERNAME }}"
DB="${{ inputs.database || vars.DEV_MYSQL_DATABASE }}"
MYSQL_USER="${{ vars.DEV_MYSQL_USER || 'root' }}"
elif [ "$ENV" = "demo" ]; then
HOST="${{ vars.DEMO_FTP_HOST }}"
PORT="${{ vars.DEMO_FTP_PORT || '22' }}"
USER="${{ vars.DEMO_FTP_USERNAME }}"
DB="${{ inputs.database || vars.DEMO_MYSQL_DATABASE }}"
MYSQL_USER="${{ vars.DEMO_MYSQL_USER || 'root' }}"
fi
MISSING=""
[ -z "$HOST" ] && MISSING="${MISSING} ${ENV^^}_SSH_HOST"
[ -z "$USER" ] && MISSING="${MISSING} ${ENV^^}_SSH_USERNAME"
[ -z "$DB" ] && MISSING="${MISSING} ${ENV^^}_MYSQL_DATABASE"
if [ -n "$MISSING" ]; then
echo "ERROR: Missing variables:${MISSING}"
exit 1
fi
echo "host=${HOST}" >> $GITHUB_OUTPUT
echo "port=${PORT}" >> $GITHUB_OUTPUT
echo "user=${USER}" >> $GITHUB_OUTPUT
echo "database=${DB}" >> $GITHUB_OUTPUT
echo "mysql_user=${MYSQL_USER}" >> $GITHUB_OUTPUT
echo "Config OK — exporting ${DB} from ${USER}@${HOST}"
- name: Setup SSH
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ENV="${{ inputs.environment }}"
if [ "$ENV" = "dev" ]; then
KEY="${{ secrets.DEV_SSH_KEY }}"
else
KEY="${{ secrets.DEMO_FTP_KEY }}"
fi
if [ -n "$KEY" ]; then
echo "$KEY" > ~/.ssh/export_key
chmod 600 ~/.ssh/export_key
else
echo "ERROR: No SSH key found for ${ENV} environment"
exit 1
fi
echo "Host *" > ~/.ssh/config
echo " StrictHostKeyChecking no" >> ~/.ssh/config
echo " UserKnownHostsFile /dev/null" >> ~/.ssh/config
chmod 600 ~/.ssh/config
- name: Export MySQL database
id: export
run: |
HOST="${{ steps.env.outputs.host }}"
PORT="${{ steps.env.outputs.port }}"
USER="${{ steps.env.outputs.user }}"
DB="${{ steps.env.outputs.database }}"
ENV="${{ inputs.environment }}"
CONFIG_PATH="${{ vars.DEV_PULL_PATH || vars.DEMO_FTP_PATH }}"
# Read MySQL credentials from the remote config file
# Joomla: configuration.php → $user, $password, $db
# Dolibarr: conf/conf.php → $dolibarr_main_db_user, $dolibarr_main_db_pass, $dolibarr_main_db_name
echo "Reading MySQL credentials from remote config file..."
CREDS=$(ssh -p "${PORT}" -i ~/.ssh/export_key "${USER}@${HOST}" bash << 'SSHEOF'
# Try Joomla configuration.php first
for cfg in "{{CONFIG_PATH}}/configuration.php" "/var/www/html/configuration.php" "$(find /var/www -name 'configuration.php' -maxdepth 3 2>/dev/null | head -1)"; do
if [ -f "$cfg" ]; then
DB_USER=$(php -r "include '$cfg'; echo \$user ?? '';")
DB_PASS=$(php -r "include '$cfg'; echo \$password ?? '';")
DB_NAME=$(php -r "include '$cfg'; echo \$db ?? '';")
DB_HOST=$(php -r "include '$cfg'; echo \$host ?? 'localhost';")
if [ -n "$DB_USER" ] && [ -n "$DB_NAME" ]; then
echo "TYPE=joomla"
echo "DB_USER=$DB_USER"
echo "DB_PASS=$DB_PASS"
echo "DB_NAME=$DB_NAME"
echo "DB_HOST=$DB_HOST"
exit 0
fi
fi
done
# Try Dolibarr conf/conf.php
for cfg in "{{CONFIG_PATH}}/conf/conf.php" "/var/www/html/conf/conf.php" "$(find /var/www -name 'conf.php' -path '*/conf/*' -maxdepth 4 2>/dev/null | head -1)"; do
if [ -f "$cfg" ]; then
DB_USER=$(php -r "include '$cfg'; echo \$dolibarr_main_db_user ?? '';")
DB_PASS=$(php -r "include '$cfg'; echo \$dolibarr_main_db_pass ?? '';")
DB_NAME=$(php -r "include '$cfg'; echo \$dolibarr_main_db_name ?? '';")
DB_HOST=$(php -r "include '$cfg'; echo \$dolibarr_main_db_host ?? 'localhost';")
if [ -n "$DB_USER" ] && [ -n "$DB_NAME" ]; then
echo "TYPE=dolibarr"
echo "DB_USER=$DB_USER"
echo "DB_PASS=$DB_PASS"
echo "DB_NAME=$DB_NAME"
echo "DB_HOST=$DB_HOST"
exit 0
fi
fi
done
echo "TYPE=not_found"
SSHEOF
)
PLATFORM=$(echo "$CREDS" | grep "^TYPE=" | cut -d= -f2)
MYSQL_USER=$(echo "$CREDS" | grep "^DB_USER=" | cut -d= -f2-)
MYSQL_PASS=$(echo "$CREDS" | grep "^DB_PASS=" | cut -d= -f2-)
DB_NAME=$(echo "$CREDS" | grep "^DB_NAME=" | cut -d= -f2-)
DB_HOST_REMOTE=$(echo "$CREDS" | grep "^DB_HOST=" | cut -d= -f2-)
if [ "$PLATFORM" = "not_found" ]; then
echo "ERROR: Could not find Joomla configuration.php or Dolibarr conf/conf.php on remote server"
exit 1
fi
# Override DB name if explicitly provided
[ -n "$DB" ] && DB_NAME="$DB"
echo "Platform: ${PLATFORM}"
echo "Database: ${DB_NAME} (user: ${MYSQL_USER}, host: ${DB_HOST_REMOTE})"
TIMESTAMP=$(date -u +%Y%m%d_%H%M%S)
FILENAME="${DB_NAME}_${ENV}_${TIMESTAMP}.sql"
echo "Exporting ${DB_NAME} from ${HOST}..."
# Run mysqldump over SSH using credentials from config file
ssh -p "${PORT}" -i ~/.ssh/export_key "${USER}@${HOST}" \
"mysqldump --single-transaction --no-tablespaces --routines --triggers \
-h ${DB_HOST_REMOTE} -u ${MYSQL_USER} -p'${MYSQL_PASS}' ${DB_NAME}" \
> "/tmp/${FILENAME}" 2>/tmp/mysqldump.err
if [ $? -ne 0 ]; then
echo "ERROR: mysqldump failed"
cat /tmp/mysqldump.err
exit 1
fi
SIZE=$(du -h "/tmp/${FILENAME}" | cut -f1)
LINES=$(wc -l < "/tmp/${FILENAME}")
echo "Export complete: ${FILENAME} (${SIZE}, ${LINES} lines)"
# ── Sanitize PII / credentials ──────────────────────────
echo "Sanitizing sensitive data..."
SANITIZED="/tmp/${FILENAME%.sql}_sanitized.sql"
cp "/tmp/${FILENAME}" "$SANITIZED"
# Joomla sanitization
# - Clear user passwords (set to bcrypt hash of 'sanitized')
# - Clear session data
# - Clear user email addresses (keep admin)
# - Clear reset tokens
BCRYPT_SANITIZED='$2y$10$sanitized.sanitized.sanitized.sanitized.sanitized.sa'
sed -i \
-e "s/\(VALUES([^)]*,'[^']*','\)\$2[ayb]\$[^']*\('/\1${BCRYPT_SANITIZED}'/g" \
"$SANITIZED"
# Joomla: clear sessions table content
sed -i '/INSERT INTO.*_session/d' "$SANITIZED"
# Joomla: sanitize user emails (replace with user{id}@sanitized.local)
python3 -c "
import re, sys
with open('$SANITIZED', encoding='utf-8', errors='replace') as f:
content = f.read()
# Joomla users table: sanitize emails but keep structure
# Pattern: email addresses in INSERT statements for user tables
content = re.sub(
r\"(['\\\"])([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(['\\\"])\",
lambda m: m.group(1) + 'user@sanitized.local' + m.group(3)
if 'admin' not in m.group(2).lower() and 'moko' not in m.group(2).lower()
else m.group(0),
content
)
# Dolibarr: clear API keys, passwords, session tokens
content = re.sub(r\"'(api_key|pass_crypted|pass_temp|session_id|token)','[^']*'\", r\"'\1',''\", content)
# Dolibarr: clear LDAP passwords
content = re.sub(r\"'ldap_pass','[^']*'\", \"'ldap_pass',''\", content)
# Clear any raw passwords or tokens in conf values
content = re.sub(r\"'(smtp_pass|ftp_password|oauth_.*secret)','[^']*'\", r\"'\1','[SANITIZED]'\", content)
with open('$SANITIZED', 'w', encoding='utf-8') as f:
f.write(content)
print('Sanitization complete')
" 2>/dev/null || echo "Python sanitization skipped (no python3)"
mv "$SANITIZED" "/tmp/${FILENAME}"
echo "Sanitized: passwords, sessions, emails (admin/moko preserved)"
# Compress
gzip "/tmp/${FILENAME}"
GZ_SIZE=$(du -h "/tmp/${FILENAME}.gz" | cut -f1)
echo "Compressed: ${FILENAME}.gz (${GZ_SIZE})"
echo "filename=${FILENAME}" >> $GITHUB_OUTPUT
echo "gz_filename=${FILENAME}.gz" >> $GITHUB_OUTPUT
echo "size=${GZ_SIZE}" >> $GITHUB_OUTPUT
echo "lines=${LINES}" >> $GITHUB_OUTPUT
- name: Commit to repo
if: inputs.save_to_repo && steps.export.outputs.filename != ''
run: |
mkdir -p sql/exports
cp "/tmp/${{ steps.export.outputs.gz_filename }}" "sql/exports/"
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.git.mokoconsulting.tech"
git add sql/exports/
git commit -m "chore(db): export ${{ steps.env.outputs.database }} from ${{ inputs.environment }}
File: ${{ steps.export.outputs.gz_filename }}
Size: ${{ steps.export.outputs.size }}
Lines: ${{ steps.export.outputs.lines }}
Source: ${{ steps.env.outputs.user }}@${{ steps.env.outputs.host }}"
git push origin ${{ inputs.branch }}
echo "Committed to ${{ inputs.branch }}"
- name: Upload artifact
if: steps.export.outputs.filename != '' && github.server_url == 'https://github.com'
uses: actions/upload-artifact@v4
with:
name: mysql-export-${{ inputs.environment }}-${{ steps.env.outputs.database }}
path: /tmp/${{ steps.export.outputs.gz_filename }}
retention-days: 30
- name: Upload artifact (Gitea fallback)
if: steps.export.outputs.filename != '' && github.server_url != 'https://github.com'
run: |
echo "Artifact upload skipped (Gitea does not support actions/upload-artifact@v4)"
echo "Export file committed to branch: ${{ inputs.branch }}"
- name: Summary
run: |
echo "## MySQL Export — ${{ inputs.environment }}"
echo ""
echo "- Database: \`${{ steps.env.outputs.database }}\`"
echo "- Server: \`${{ steps.env.outputs.host }}\`"
echo "- File: \`${{ steps.export.outputs.gz_filename }}\`"
echo "- Size: ${{ steps.export.outputs.size }}"
echo "- Lines: ${{ steps.export.outputs.lines }}"
if [ "${{ inputs.save_to_repo }}" = "true" ]; then
echo "- Saved to: \`sql/exports/\` on branch \`${{ inputs.branch }}\`"
else
echo "- Available as workflow artifact (30 day retention)"
fi
- name: Cleanup
if: always()
run: rm -f ~/.ssh/export_key /tmp/*.sql /tmp/*.sql.gz
+70
View File
@@ -0,0 +1,70 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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"
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}"
+106
View File
@@ -0,0 +1,106 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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 '<extension' {} \; 2>/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; }
+274
View File
@@ -0,0 +1,274 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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
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)
NEW_PATCH=$(printf "%02d" $((10#$PATCH + 1)))
VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
TODAY=$(date +%Y-%m-%d)
echo "Bumping: ${CURRENT} → ${VERSION}"
# 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 '<extension' {} \; 2>/dev/null | head -1)
if [ -n "$MANIFEST" ]; then
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$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 '<extension' {} \; 2>/dev/null | head -1)
EXT_ELEMENT=""
if [ -n "$MANIFEST" ]; then
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/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"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
match = re.search(pattern, content, re.DOTALL)
if match:
block = match.group(1)
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
if "<sha256>" in updated:
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
else:
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", 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 <tag>{xml_tag}</tag> block in updates.xml")
with open("updates.xml", "w") as f:
f.write(content)
PYEOF
# Commit and push
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
@@ -1,173 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards-API.Deployment
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/shared/pull-from-dev.yml.template
# VERSION: 04.06.12
# BRIEF: Download files from dev server into repo src/ directory
name: Pull from Dev Server
on:
workflow_dispatch:
inputs:
remote_path:
description: 'Remote path to download (overrides DEV_PULL_PATH variable)'
required: false
type: string
default: ''
target_dir:
description: 'Local directory to save to'
required: false
type: string
default: 'src'
branch:
description: 'Branch to commit to'
required: false
type: string
default: 'dev'
dry_run:
description: 'Preview only (no commit)'
required: false
type: boolean
default: true
# ──────────────────────────────────────────────────────────────
# Required secrets and variables:
#
# SECRETS (org or repo level):
# DEV_SSH_KEY — SSH private key for dev server access
# DEV_SSH_PASSWORD — OR password auth (if not using key)
#
# VARIABLES (org or repo level):
# DEV_SSH_HOST — Dev server hostname (e.g., dev.mokoconsulting.tech)
# DEV_SSH_PORT — SSH port (default: 22)
# DEV_SSH_USERNAME — SSH user
# DEV_PULL_PATH — Remote path to download (e.g., /var/www/html/plugins/system/mokojoomtos)
# ──────────────────────────────────────────────────────────────
permissions:
contents: write
jobs:
pull-from-dev:
name: Pull from Dev Server
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
- name: Validate configuration
run: |
MISSING=""
[ -z "${{ vars.DEV_SSH_HOST }}" ] && MISSING="${MISSING} DEV_SSH_HOST"
[ -z "${{ vars.DEV_SSH_USERNAME }}" ] && MISSING="${MISSING} DEV_SSH_USERNAME"
REMOTE="${{ inputs.remote_path || vars.DEV_PULL_PATH }}"
[ -z "$REMOTE" ] && MISSING="${MISSING} DEV_PULL_PATH"
if [ -n "$MISSING" ]; then
echo "ERROR: Missing required variables:${MISSING}"
echo "Set these as org or repo variables in Gitea Actions settings."
exit 1
fi
echo "remote_path=${REMOTE}" >> $GITHUB_OUTPUT
echo "Config OK — pulling from ${{ vars.DEV_SSH_USERNAME }}@${{ vars.DEV_SSH_HOST }}:${REMOTE}"
id: config
- name: Setup SSH
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ -n "${{ secrets.DEV_SSH_KEY }}" ]; then
echo "${{ secrets.DEV_SSH_KEY }}" > ~/.ssh/dev_key
chmod 600 ~/.ssh/dev_key
echo "Auth: SSH key"
else
echo "Auth: password (sshpass)"
sudo apt-get install -y sshpass -qq
fi
# Disable host key checking for automation
echo "Host *" > ~/.ssh/config
echo " StrictHostKeyChecking no" >> ~/.ssh/config
echo " UserKnownHostsFile /dev/null" >> ~/.ssh/config
chmod 600 ~/.ssh/config
- name: Download from dev server
id: download
run: |
HOST="${{ vars.DEV_SSH_HOST }}"
PORT="${{ vars.DEV_SSH_PORT || '22' }}"
USER="${{ vars.DEV_SSH_USERNAME }}"
REMOTE="${{ steps.config.outputs.remote_path }}"
LOCAL="${{ inputs.target_dir }}"
echo "Downloading: ${USER}@${HOST}:${REMOTE} → ${LOCAL}/"
# Build rsync command
SSH_CMD="ssh -p ${PORT}"
if [ -f ~/.ssh/dev_key ]; then
SSH_CMD="${SSH_CMD} -i ~/.ssh/dev_key"
fi
# Rsync from remote to local (mirror mode, delete extra local files)
rsync -avz --delete \
-e "${SSH_CMD}" \
"${USER}@${HOST}:${REMOTE}/" \
"${LOCAL}/" \
--exclude='.git' \
--exclude='.gitignore' \
--exclude='node_modules' \
--exclude='vendor' \
--exclude='cache' \
--exclude='tmp' \
--exclude='log' \
2>&1 | tee /tmp/rsync.log
CHANGED=$(git status --porcelain "${LOCAL}/" | wc -l)
echo "changed=${CHANGED}" >> $GITHUB_OUTPUT
echo "Files changed: ${CHANGED}"
- name: Show diff
if: steps.download.outputs.changed != '0'
run: |
echo "=== Changed files ==="
git status --short "${{ inputs.target_dir }}/"
echo ""
echo "=== Diff summary ==="
git diff --stat "${{ inputs.target_dir }}/"
- name: Commit and push
if: steps.download.outputs.changed != '0' && inputs.dry_run != true
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.git.mokoconsulting.tech"
git add "${{ inputs.target_dir }}/"
git commit -m "chore(sync): pull latest from dev server
Source: ${{ vars.DEV_SSH_USERNAME }}@${{ vars.DEV_SSH_HOST }}:${{ steps.config.outputs.remote_path }}
Files changed: ${{ steps.download.outputs.changed }}
Triggered by: ${{ gitea.actor }}"
git push origin ${{ inputs.branch }}
echo "Pushed to ${{ inputs.branch }}"
- name: Summary
run: |
echo "## Pull from Dev Server"
echo ""
if [ "${{ inputs.dry_run }}" = "true" ]; then
echo "**DRY RUN** — no changes committed"
fi
echo "- Source: \`${{ vars.DEV_SSH_USERNAME }}@${{ vars.DEV_SSH_HOST }}:${{ steps.config.outputs.remote_path }}\`"
echo "- Target: \`${{ inputs.target_dir }}/\`"
echo "- Changed files: ${{ steps.download.outputs.changed }}"
- name: Cleanup
if: always()
run: rm -f ~/.ssh/dev_key
@@ -1,525 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# 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/shared/repository-cleanup.yml.template
# VERSION: 04.06.00
# BRIEF: Recurring repository maintenance — labels, branches, workflows, logs, doc indexes
# NOTE: Synced via bulk-repo-sync to .gitea/workflows/repository-cleanup.yml in all governed repos.
# Runs on the 1st and 15th of each month at 6:00 AM UTC, and on manual dispatch.
name: Repository Cleanup
on:
schedule:
- cron: '0 6 1,15 * *'
workflow_dispatch:
inputs:
reset_labels:
description: 'Delete ALL existing labels and recreate the standard set'
type: boolean
default: false
clean_branches:
description: 'Delete old chore/sync-mokostandards-* branches'
type: boolean
default: true
clean_workflows:
description: 'Delete orphaned workflow runs (cancelled, stale)'
type: boolean
default: true
clean_logs:
description: 'Delete workflow run logs older than 30 days'
type: boolean
default: true
fix_templates:
description: 'Strip copyright comment blocks from issue templates'
type: boolean
default: true
rebuild_indexes:
description: 'Rebuild docs/ index files'
type: boolean
default: true
delete_closed_issues:
description: 'Delete issues that have been closed for more than 30 days'
type: boolean
default: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
issues: write
actions: write
jobs:
cleanup:
name: Repository Maintenance
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
fetch-depth: 0
- name: Check actor permission
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
# Schedule triggers use gitea-actions[bot]
if [ "${{ github.event_name }}" = "schedule" ]; then
echo "✅ Scheduled run — authorized"
exit 0
fi
AUTHORIZED_USERS="jmiller gitea-actions[bot]"
for user in $AUTHORIZED_USERS; do
if [ "$ACTOR" = "$user" ]; then
echo "✅ ${ACTOR} authorized"
exit 0
fi
done
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/collaborators/${ACTOR}/permission" 2>/dev/null \
2>/dev/null | jq -r '.permission')
case "$PERMISSION" in
admin|maintain) echo "✅ ${ACTOR} has ${PERMISSION}" ;;
*) echo "❌ Admin or maintain required"; exit 1 ;;
esac
# ── Determine which tasks to run ─────────────────────────────────────
# On schedule: run all tasks with safe defaults (labels NOT reset)
# On dispatch: use input toggles
- name: Set task flags
id: tasks
run: |
if [ "${{ github.event_name }}" = "schedule" ]; then
echo "reset_labels=false" >> $GITHUB_OUTPUT
echo "clean_branches=true" >> $GITHUB_OUTPUT
echo "clean_workflows=true" >> $GITHUB_OUTPUT
echo "clean_logs=true" >> $GITHUB_OUTPUT
echo "fix_templates=true" >> $GITHUB_OUTPUT
echo "rebuild_indexes=true" >> $GITHUB_OUTPUT
echo "delete_closed_issues=false" >> $GITHUB_OUTPUT
else
echo "reset_labels=${{ inputs.reset_labels }}" >> $GITHUB_OUTPUT
echo "clean_branches=${{ inputs.clean_branches }}" >> $GITHUB_OUTPUT
echo "clean_workflows=${{ inputs.clean_workflows }}" >> $GITHUB_OUTPUT
echo "clean_logs=${{ inputs.clean_logs }}" >> $GITHUB_OUTPUT
echo "fix_templates=${{ inputs.fix_templates }}" >> $GITHUB_OUTPUT
echo "rebuild_indexes=${{ inputs.rebuild_indexes }}" >> $GITHUB_OUTPUT
echo "delete_closed_issues=${{ inputs.delete_closed_issues }}" >> $GITHUB_OUTPUT
fi
# ── DELETE RETIRED WORKFLOWS (always runs) ────────────────────────────
- name: Delete retired workflow files
run: |
echo "## 🗑️ Retired Workflow Cleanup" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
RETIRED=(
".gitea/workflows/build.yml"
".gitea/workflows/code-quality.yml"
".gitea/workflows/release-cycle.yml"
".gitea/workflows/release-pipeline.yml"
".gitea/workflows/branch-cleanup.yml"
".gitea/workflows/auto-update-changelog.yml"
".gitea/workflows/enterprise-issue-manager.yml"
".gitea/workflows/flush-actions-cache.yml"
".gitea/workflows/mokostandards-script-runner.yml"
".gitea/workflows/unified-ci.yml"
".gitea/workflows/unified-platform-testing.yml"
".gitea/workflows/reusable-build.yml"
".gitea/workflows/reusable-ci-validation.yml"
".gitea/workflows/reusable-deploy.yml"
".gitea/workflows/reusable-php-quality.yml"
".gitea/workflows/reusable-platform-testing.yml"
".gitea/workflows/reusable-project-detector.yml"
".gitea/workflows/reusable-release.yml"
".gitea/workflows/reusable-script-executor.yml"
".gitea/workflows/rebuild-docs-indexes.yml"
".gitea/workflows/setup-project-v2.yml"
".gitea/workflows/sync-docs-to-project.yml"
".gitea/workflows/release.yml"
".gitea/workflows/sync-changelogs.yml"
".gitea/workflows/version_branch.yml"
"update.json"
".gitea/workflows/auto-version-branch.yml"
".gitea/workflows/publish-to-mokodolibarr.yml"
".gitea/workflows/ci.yml"
".gitea/workflows/deploy-rs.yml"
"sftp-config.json"
"sftp-config.json.template"
"scripts/sftp-config"
)
DELETED=0
for wf in "${RETIRED[@]}"; do
if [ -f "$wf" ]; then
git rm "$wf" 2>/dev/null || rm -f "$wf"
echo " Deleted: \`$(basename $wf)\`" >> $GITHUB_STEP_SUMMARY
DELETED=$((DELETED+1))
fi
done
if [ "$DELETED" -gt 0 ]; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add -A
git commit -m "chore: delete ${DELETED} retired workflow file(s) [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
echo "✅ ${DELETED} retired workflow(s) deleted" >> $GITHUB_STEP_SUMMARY
else
echo "✅ No retired workflows found" >> $GITHUB_STEP_SUMMARY
fi
# ── LABEL RESET ──────────────────────────────────────────────────────
- name: Reset labels to standard set
if: steps.tasks.outputs.reset_labels == 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
echo "## 🏷️ Label Reset" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/labels?per_page=100" 2>/dev/null --jq '.[].name' | while read -r label; do
ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$label', safe=''))")
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/labels/${ENCODED}" 2>/dev/null || true
done
while IFS='|' read -r name color description; do
[ -z "$name" ] && continue
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/labels" 2>/dev/null \
-f name="$name" -f color="$color" -f description="$description" \
--silent 2>/dev/null || true
done << 'LABELS'
joomla|7F52FF|Joomla extension or component
dolibarr|FF6B6B|Dolibarr module or extension
generic|808080|Generic project or library
php|4F5D95|PHP code changes
javascript|F7DF1E|JavaScript code changes
typescript|3178C6|TypeScript code changes
python|3776AB|Python code changes
css|1572B6|CSS/styling changes
html|E34F26|HTML template changes
documentation|0075CA|Documentation changes
ci-cd|000000|CI/CD pipeline changes
docker|2496ED|Docker configuration changes
tests|00FF00|Test suite changes
security|FF0000|Security-related changes
dependencies|0366D6|Dependency updates
config|F9D0C4|Configuration file changes
build|FFA500|Build system changes
automation|8B4513|Automated processes or scripts
mokostandards|B60205|MokoStandards compliance
needs-review|FBCA04|Awaiting code review
work-in-progress|D93F0B|Work in progress, not ready for merge
breaking-change|D73A4A|Breaking API or functionality change
priority: critical|B60205|Critical priority, must be addressed immediately
priority: high|D93F0B|High priority
priority: medium|FBCA04|Medium priority
priority: low|0E8A16|Low priority
type: bug|D73A4A|Something isn't working
type: feature|A2EEEF|New feature or request
type: enhancement|84B6EB|Enhancement to existing feature
type: refactor|F9D0C4|Code refactoring
type: chore|FEF2C0|Maintenance tasks
type: version|0E8A16|Version-related change
status: pending|FBCA04|Pending action or decision
status: in-progress|0E8A16|Currently being worked on
status: blocked|B60205|Blocked by another issue or dependency
status: on-hold|D4C5F9|Temporarily on hold
status: wontfix|FFFFFF|This will not be worked on
size/xs|C5DEF5|Extra small change (1-10 lines)
size/s|6FD1E2|Small change (11-30 lines)
size/m|F9DD72|Medium change (31-100 lines)
size/l|FFA07A|Large change (101-300 lines)
size/xl|FF6B6B|Extra large change (301-1000 lines)
size/xxl|B60205|Extremely large change (1000+ lines)
health: excellent|0E8A16|Health score 90-100
health: good|FBCA04|Health score 70-89
health: fair|FFA500|Health score 50-69
health: poor|FF6B6B|Health score below 50
standards-update|B60205|MokoStandards sync update
standards-drift|FBCA04|Repository drifted from MokoStandards
sync-report|0075CA|Bulk sync run report
sync-failure|D73A4A|Bulk sync failure requiring attention
push-failure|D73A4A|File push failure requiring attention
health-check|0E8A16|Repository health check results
version-drift|FFA500|Version mismatch detected
deploy-failure|CC0000|Automated deploy failure tracking
template-validation-failure|D73A4A|Template workflow validation failure
version|0E8A16|Version bump or release
LABELS
echo "✅ Standard labels created" >> $GITHUB_STEP_SUMMARY
# ── BRANCH CLEANUP ───────────────────────────────────────────────────
- name: Delete old sync branches
if: steps.tasks.outputs.clean_branches == 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
CURRENT="chore/sync-mokostandards-v04.05"
echo "## 🌿 Branch Cleanup" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
FOUND=false
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/branches?per_page=100" | jq -r '.[].name' 2>/dev/null | \
grep "^chore/sync-mokostandards" | \
grep -v "^${CURRENT}$" | while read -r branch; do
gh pr list --repo "$REPO" --head "$branch" --state open --json number 2>/dev/null | jq -r '.[].number' | while read -r pr; do
gh pr close "$pr" --repo "$REPO" --comment "Superseded by \`${CURRENT}\`" 2>/dev/null || true
echo " Closed PR #${pr}" >> $GITHUB_STEP_SUMMARY
done
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/git/refs/heads/${branch}" 2>/dev/null || true
echo " Deleted: \`${branch}\`" >> $GITHUB_STEP_SUMMARY
FOUND=true
done
if [ "$FOUND" != "true" ]; then
echo "✅ No old sync branches found" >> $GITHUB_STEP_SUMMARY
fi
# ── WORKFLOW RUN CLEANUP ─────────────────────────────────────────────
- name: Clean up workflow runs
if: steps.tasks.outputs.clean_workflows == 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
echo "## 🔄 Workflow Run Cleanup" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
DELETED=0
# Delete cancelled and stale workflow runs
for status in cancelled stale; do
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/actions/runs?status=${status}&per_page=100" 2>/dev/null \
2>/dev/null | jq -r '.workflow_runs[].id' | while read -r run_id; do
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/actions/runs/${run_id}" 2>/dev/null || true
DELETED=$((DELETED+1))
done
done
echo "✅ Cleaned cancelled/stale workflow runs" >> $GITHUB_STEP_SUMMARY
# ── LOG CLEANUP ──────────────────────────────────────────────────────
- name: Delete old workflow run logs
if: steps.tasks.outputs.clean_logs == 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)
echo "## 📋 Log Cleanup" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Deleting logs older than: ${CUTOFF}" >> $GITHUB_STEP_SUMMARY
DELETED=0
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/actions/runs?created=<${CUTOFF}&per_page=100" 2>/dev/null \
2>/dev/null | jq -r '.workflow_runs[].id' | while read -r run_id; do
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/actions/runs/${run_id}/logs" 2>/dev/null || true
DELETED=$((DELETED+1))
done
echo "✅ Cleaned old workflow run logs" >> $GITHUB_STEP_SUMMARY
# ── ISSUE TEMPLATE FIX ──────────────────────────────────────────────
- name: Strip copyright headers from issue templates
if: steps.tasks.outputs.fix_templates == 'true'
run: |
echo "## 📋 Issue Template Cleanup" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
FIXED=0
for f in .github/ISSUE_TEMPLATE/*.md; do
[ -f "$f" ] || continue
if grep -q '^<!--$' "$f"; then
sed -i '/^<!--$/,/^-->$/d' "$f"
echo " Cleaned: \`$(basename $f)\`" >> $GITHUB_STEP_SUMMARY
FIXED=$((FIXED+1))
fi
done
if [ "$FIXED" -gt 0 ]; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add .github/ISSUE_TEMPLATE/
git commit -m "fix: strip copyright comment blocks from issue templates [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
echo "✅ ${FIXED} template(s) cleaned and committed" >> $GITHUB_STEP_SUMMARY
else
echo "✅ No templates need cleaning" >> $GITHUB_STEP_SUMMARY
fi
# ── REBUILD DOC INDEXES ─────────────────────────────────────────────
- name: Rebuild docs/ index files
if: steps.tasks.outputs.rebuild_indexes == 'true'
run: |
echo "## 📚 Documentation Index Rebuild" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ ! -d "docs" ]; then
echo "⏭️ No docs/ directory — skipping" >> $GITHUB_STEP_SUMMARY
exit 0
fi
UPDATED=0
# Generate index.md for each docs/ subdirectory
find docs -type d | while read -r dir; do
INDEX="${dir}/index.md"
FILES=$(find "$dir" -maxdepth 1 -name "*.md" ! -name "index.md" -printf "- [%f](./%f)\n" 2>/dev/null | sort)
if [ -z "$FILES" ]; then
continue
fi
cat > "$INDEX" << INDEXEOF
# $(basename "$dir")
## Documents
${FILES}
---
*Auto-generated by repository-cleanup workflow*
INDEXEOF
# Dedent
sed -i 's/^ //' "$INDEX"
UPDATED=$((UPDATED+1))
done
if [ "$UPDATED" -gt 0 ]; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add docs/
if ! git diff --cached --quiet; then
git commit -m "docs: rebuild documentation indexes [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
echo "✅ ${UPDATED} index file(s) rebuilt and committed" >> $GITHUB_STEP_SUMMARY
else
echo "✅ All indexes already up to date" >> $GITHUB_STEP_SUMMARY
fi
else
echo "✅ No indexes to rebuild" >> $GITHUB_STEP_SUMMARY
fi
# ── VERSION DRIFT DETECTION ──────────────────────────────────────────
- name: Check for version drift
run: |
echo "## 📦 Version Drift Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ ! -f "README.md" ]; then
echo "⏭️ No README.md — skipping" >> $GITHUB_STEP_SUMMARY
exit 0
fi
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md 2>/dev/null | head -1)
if [ -z "$README_VERSION" ]; then
echo "⚠️ No VERSION found in README.md FILE INFORMATION block" >> $GITHUB_STEP_SUMMARY
exit 0
fi
echo "**README version:** \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
DRIFT=0
CHECKED=0
# Check all files with FILE INFORMATION blocks
while IFS= read -r -d '' file; do
FILE_VERSION=$(grep -oP '^\s*\*?\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' "$file" 2>/dev/null | head -1)
[ -z "$FILE_VERSION" ] && continue
CHECKED=$((CHECKED+1))
if [ "$FILE_VERSION" != "$README_VERSION" ]; then
echo " ⚠️ \`${file}\`: \`${FILE_VERSION}\` (expected \`${README_VERSION}\`)" >> $GITHUB_STEP_SUMMARY
DRIFT=$((DRIFT+1))
fi
done < <(find . -maxdepth 4 -type f \( -name "*.php" -o -name "*.md" -o -name "*.yml" \) ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" -print0 2>/dev/null)
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$DRIFT" -gt 0 ]; then
echo "⚠️ **${DRIFT}** file(s) out of ${CHECKED} have version drift" >> $GITHUB_STEP_SUMMARY
echo "Run \`sync-version-on-merge\` workflow or update manually" >> $GITHUB_STEP_SUMMARY
else
echo "✅ All ${CHECKED} file(s) match README version \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
# ── PROTECT CUSTOM WORKFLOWS ────────────────────────────────────────
- name: Ensure custom workflow directory exists
run: |
echo "## 🔧 Custom Workflows" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ ! -d ".gitea/workflows/custom" ]; then
mkdir -p .gitea/workflows/custom
cat > .gitea/workflows/custom/README.md << 'CWEOF'
# Custom Workflows
Place repo-specific workflows here. Files in this directory are:
- **Never overwritten** by MokoStandards bulk sync
- **Never deleted** by the repository-cleanup workflow
- Safe for custom CI, notifications, or repo-specific automation
Synced workflows live in `.gitea/workflows/` (parent directory).
CWEOF
sed -i 's/^ //' .gitea/workflows/custom/README.md
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add .gitea/workflows/custom/
if ! git diff --cached --quiet; then
git commit -m "chore: create .gitea/workflows/custom/ for repo-specific workflows [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
echo "✅ Created \`.gitea/workflows/custom/\` directory" >> $GITHUB_STEP_SUMMARY
fi
else
CUSTOM_COUNT=$(find .gitea/workflows/custom -name "*.yml" -o -name "*.yaml" 2>/dev/null | wc -l)
echo "✅ Custom workflow directory exists (${CUSTOM_COUNT} workflow(s))" >> $GITHUB_STEP_SUMMARY
fi
# ── DELETE CLOSED ISSUES ──────────────────────────────────────────────
- name: Delete old closed issues
if: steps.tasks.outputs.delete_closed_issues == 'true'
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)
echo "## 🗑️ Closed Issue Cleanup" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Deleting issues closed before: ${CUTOFF}" >> $GITHUB_STEP_SUMMARY
DELETED=0
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues?state=closed&since=1970-01-01T00:00:00Z&per_page=100&sort=updated&direction=asc" 2>/dev/null \
--jq ".[] | select(.closed_at < \"${CUTOFF}\") | .number" 2>/dev/null | while read -r num; do
# Lock and close with "not_planned" to mark as cleaned up
curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${{GITEA_URL:-https://git.mokoconsulting.tech}}/api/v1/repos/${{ github.repository }}/issues/${num}/lock" 2>/dev/null -X PUT -f lock_reason="resolved" --silent 2>/dev/null || true
echo " Locked issue #${num}" >> $GITHUB_STEP_SUMMARY
DELETED=$((DELETED+1))
done
if [ "$DELETED" -eq 0 ] 2>/dev/null; then
echo "✅ No old closed issues found" >> $GITHUB_STEP_SUMMARY
else
echo "✅ Locked ${DELETED} old closed issue(s)" >> $GITHUB_STEP_SUMMARY
fi
- name: Summary
if: always()
run: |
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*Run by @${{ github.actor }} — trigger: ${{ github.event_name }}*" >> $GITHUB_STEP_SUMMARY
@@ -0,0 +1,82 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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
@@ -1,135 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Automation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/shared/sync-version-on-merge.yml.template
# VERSION: 04.06.00
# BRIEF: Auto-bump patch version on every push to main and propagate to all file headers
# NOTE: Synced via bulk-repo-sync to .gitea/workflows/sync-version-on-merge.yml in all governed repos.
# README.md is the single source of truth for the repository version.
name: Sync Version from README
on:
pull_request:
types: [closed]
branches:
- main
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (preview only, no commit)'
type: boolean
default: false
permissions:
contents: write
issues: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync-version:
name: Propagate README version
runs-on: ubuntu-latest
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 || secrets.GA_TOKEN || github.token }}
fetch-depth: 0
- name: Set up 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 {{standards_branch}} --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
- name: Auto-bump patch version
if: ${{ github.event_name != 'workflow_dispatch' && github.actor != 'gitea-actions[bot]' }}
run: |
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -q '^README\.md$'; then
echo "README.md changed in this push — skipping auto-bump"
exit 0
fi
RESULT=$(php /tmp/mokostandards-api/cli/version_bump.php --path .) || {
echo "⚠️ Could not bump version — skipping"
exit 0
}
echo "Auto-bumping patch: $RESULT"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add README.md
git commit -m "chore(version): auto-bump patch ${RESULT} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
- name: Extract version from README.md
id: readme_version
run: |
git pull --ff-only 2>/dev/null || true
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null)
if [ -z "$VERSION" ]; then
echo "⚠️ No VERSION in README.md — skipping propagation"
echo "skip=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "skip=false" >> $GITHUB_OUTPUT
echo "✅ README.md version: $VERSION"
- name: Run version sync
if: ${{ steps.readme_version.outputs.skip != 'true' && inputs.dry_run != true }}
run: |
php /tmp/mokostandards-api/maintenance/update_version_from_readme.php \
--path . \
--create-issue \
--repo "${{ github.repository }}"
env:
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
- name: Commit updated files
if: ${{ steps.readme_version.outputs.skip != 'true' && inputs.dry_run != true }}
run: |
git pull --ff-only 2>/dev/null || true
if git diff --quiet; then
echo "️ No version changes needed — already up to date"
exit 0
fi
VERSION="${{ steps.readme_version.outputs.version }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add -A
git commit -m "chore(version): sync badges and headers to ${VERSION} [skip ci]" \
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
git push
- name: Summary
run: |
VERSION="${{ steps.readme_version.outputs.version }}"
echo "## 📦 Version Sync — ${VERSION}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Source:** \`README.md\` FILE INFORMATION block" >> $GITHUB_STEP_SUMMARY
echo "**Version:** \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY