feat: initial MCP server template with placeholder-driven scaffolding

Template repository for creating MokoStandards-compliant MCP servers.
Includes 4-file src/ structure (index, client, config, types), setup
wizard, example tools, 12 CI/CD workflows, full docs, and {{placeholder}}
tokens for search-and-replace customization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-07 14:39:19 -05:00
commit 91b78f8da1
30 changed files with 7811 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
###############################################################################
# Core normalization
###############################################################################
* text=auto eol=lf
# Ensure consistent line endings for scripts
*.sh text eol=lf
*.bash text eol=lf
*.ps1 text eol=lf
*.cmd text eol=crlf
*.bat text eol=crlf
###############################################################################
# Binary handling
###############################################################################
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.svg binary
*.ico binary
*.pdf binary
*.zip binary
*.tar binary
*.tar.gz binary
*.7z binary
*.docx binary
*.xlsx binary
*.pptx binary
*.mp3 binary
*.mp4 binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
###############################################################################
# Export control for GitHub Releases
# These paths will NOT appear in generated release archives
###############################################################################
# CI and automation
.github/ export-ignore
.github/workflows/ export-ignore
.gitea/ export-ignore
.gitlab/ export-ignore
# Development and tooling
tests/ export-ignore
testing/ export-ignore
tmp/ export-ignore
docs-internal/ export-ignore
tools/ export-ignore
# Dependency folders that should not ship in release bundles
node_modules/ export-ignore
vendor-dev/ export-ignore
# Local environment and editor configs
*.local export-ignore
*.env export-ignore
*.env.example export-ignore
*.code-workspace export-ignore
# Project specific non release scaffolding
dev-assets/ export-ignore
analysis/ export-ignore
research/ export-ignore
###############################################################################
# Linguistic settings for code statistics
###############################################################################
*.css linguist-language=CSS
*.scss linguist-language=SCSS
*.js linguist-language=JavaScript
*.ts linguist-language=TypeScript
*.php linguist-language=PHP
*.xml linguist-language=XML
*.json linguist-language=JSON
*.ini linguist-language=INI
*.sql linguist-language=SQL
*.md linguist-language=Markdown
###############################################################################
# Prevent diff noise for vendor or minified content
###############################################################################
vendor/* -diff
*.min.js -diff
*.min.css -diff
###############################################################################
# Lockdown for generated files
###############################################################################
*.min.js linguist-generated=true
*.min.css linguist-generated=true
+1
View File
@@ -0,0 +1 @@
platform: mcp-server
+76
View File
@@ -0,0 +1,76 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: GitHub.Workflow
# INGROUP: MokoStandards.Workflows.Shared
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /.github/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:
GH_TOKEN: ${{ secrets.GH_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=$(gh api "repos/$REPO/issues?state=open&per_page=100&assignee=none" --jq '.[].number' 2>/dev/null || true)
for NUM in $ISSUES; do
# Skip PRs (the issues endpoint returns PRs too)
IS_PR=$(gh api "repos/$REPO/issues/$NUM" --jq '.pull_request // empty' 2>/dev/null || true)
if [ -z "$IS_PR" ]; then
gh api "repos/$REPO/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=$(gh api "repos/$REPO/pulls?state=open&per_page=100" --jq '.[] | select(.assignees | length == 0) | .number' 2>/dev/null || true)
for NUM in $PRS; do
gh api "repos/$REPO/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
+207
View File
@@ -0,0 +1,207 @@
# 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.Automation
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# 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 .github/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:
GH_TOKEN: ${{ secrets.GH_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=$(gh api "repos/${REPO}/issues?state=open&per_page=10" \
--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](.github/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=$(gh api "repos/${REPO}/issues?state=open&per_page=20" \
--jq ".[] | select(.title == \"${SUB_FULL_TITLE}\") | .number" 2>/dev/null | head -1)
if [ -n "$SUB_NUM" ]; then
gh api "repos/${REPO}/issues/${SUB_NUM}" -X PATCH \
-f body="$(gh api "repos/${REPO}/issues/${SUB_NUM}" --jq '.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=$(gh release view "$RELEASE_TAG" --json tagName -q .tagName 2>/dev/null || 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
+337
View File
@@ -0,0 +1,337 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: GitHub.Workflow
# INGROUP: MokoStandards.Release
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# 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:
push:
branches:
- main
- master
paths:
- 'src/**'
- 'htdocs/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
jobs:
release:
name: Build & Release Pipeline
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
github.actor != 'github-actions[bot]'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GH_TOKEN || github.token }}
fetch-depth: 0
- name: Setup MokoStandards tools
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch version/04 --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
/tmp/mokostandards
cd /tmp/mokostandards
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 "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add -A
git commit -m "chore(release): build ${VERSION} [skip ci]" \
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
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:
GH_TOKEN: ${{ secrets.GH_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=$(gh release view "$RELEASE_TAG" --json tagName -q .tagName 2>/dev/null || 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=$(gh release view "$RELEASE_TAG" --json body -q .body 2>/dev/null || 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
+101
View File
@@ -0,0 +1,101 @@
# 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.Template
# INGROUP: MokoStandards.CI
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /templates/workflows/shared/changelog-validation.yml.template
# VERSION: 04.06.00
# BRIEF: Validates CHANGELOG.md format and version consistency
# NOTE: Deployed to .github/workflows/changelog-validation.yml in governed repos.
name: Changelog Validation
on:
push:
branches:
- main
pull_request:
branches:
- main
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
+115
View File
@@ -0,0 +1,115 @@
# 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.Template
# INGROUP: MokoStandards.Security
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /templates/workflows/generic/codeql-analysis.yml.template
# VERSION: 04.05.00
# BRIEF: CodeQL security scanning workflow (generic — all repo types)
# NOTE: Deployed to .github/workflows/codeql-analysis.yml in governed repos.
# CodeQL does not support PHP directly; JavaScript scans JSON/YAML/shell.
# For PHP-specific security scanning see standards-compliance.yml.
name: CodeQL Security Scanning
on:
push:
branches:
- main
- dev/**
- rc/**
- version/**
pull_request:
branches:
- main
- dev/**
- rc/**
schedule:
# Weekly on Monday at 06:00 UTC
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
actions: read
contents: read
security-events: write
pull-requests: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
# CodeQL does not support PHP. Use 'javascript' to scan JSON, YAML,
# and shell scripts. Add 'actions' to scan GitHub Actions workflows.
language: ['javascript', 'actions']
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended,security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
upload: true
output: sarif-results
wait-for-processing: true
- name: Upload SARIF results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.5.0
with:
name: codeql-results-${{ matrix.language }}
path: sarif-results
retention-days: 30
- name: Step summary
if: always()
run: |
echo "### 🔍 CodeQL — ${{ matrix.language }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
URL="https://github.com/${{ github.repository }}/security/code-scanning"
echo "See the [Security tab]($URL) for findings." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Severity | SLA |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-----|" >> $GITHUB_STEP_SUMMARY
echo "| Critical | 7 days |" >> $GITHUB_STEP_SUMMARY
echo "| High | 14 days |" >> $GITHUB_STEP_SUMMARY
echo "| Medium | 30 days |" >> $GITHUB_STEP_SUMMARY
echo "| Low | 60 days / next release |" >> $GITHUB_STEP_SUMMARY
summary:
name: Security Scan Summary
runs-on: ubuntu-latest
needs: analyze
if: always()
steps:
- name: Summary
run: |
echo "### 🛡️ CodeQL Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Trigger:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
SECURITY_URL="https://github.com/${{ github.repository }}/security"
echo "" >> $GITHUB_STEP_SUMMARY
echo "📊 [View all security alerts]($SECURITY_URL)" >> $GITHUB_STEP_SUMMARY
+44
View File
@@ -0,0 +1,44 @@
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
#
# GitHub Actions workflow for Copilot coding agent
# This workflow demonstrates how to use the firewall configuration
name: Copilot Coding Agent
on:
pull_request:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
jobs:
copilot-agent:
name: Run Copilot Coding Agent
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Copilot Firewall
run: |
echo "Configuring firewall allowlist for enterprise-ready sites..."
bash .github/copilot/setup-firewall.sh
echo "Firewall configuration completed"
- name: Run Copilot Agent
uses: github/copilot-swe-agent@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
issue_number: ${{ github.event.issue.number || github.event.pull_request.number }}
env:
# Environment variables are set by setup-firewall.sh
COPILOT_FIREWALL_ALLOWLIST: ${{ env.COPILOT_FIREWALL_ALLOWLIST }}
+734
View File
@@ -0,0 +1,734 @@
# 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: GitHub.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# 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 .github/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:
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.
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
REPO="${{ github.repository }}"
ORG="${{ github.repository_owner }}"
METHOD=""
AUTHORIZED="false"
# Hardcoded authorized users — always allowed to deploy
AUTHORIZED_USERS="jmiller github-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=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \
--jq '.permission' 2>/dev/null)
METHOD="repo collaborator API"
if [ -z "$PERMISSION" ]; then
ORG_ROLE=$(gh api "orgs/${ORG}/memberships/${ACTOR}" \
--jq '.role' 2>/dev/null)
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: >-
!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.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=".github/.mokostandards"; [ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"; if [ -f "$MOKO_FILE" ]; then
PLATFORM=$(grep -E '^platform:' "$MOKO_FILE" | sed 's/.*:[[:space:]]*//' | tr -d '"')
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'
uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
with:
php-version: '8.1'
tools: composer
- name: Setup MokoStandards deploy tools
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch version/04 --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
/tmp/mokostandards
cd /tmp/mokostandards
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/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.*" %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://github.com/${REPO}/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:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
RUN_URL="${{ github.server_url }}/${REPO}/actions/runs/${{ github.run_id }}"
ACTOR="${{ github.actor }}"
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=$(gh api "repos/${REPO}/issues?labels=${LABEL}&state=all&per_page=1&sort=created&direction=desc" \
--jq '.[0].number' 2>/dev/null)
if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
gh api "repos/${REPO}/issues/${EXISTING}" \
-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
+700
View File
@@ -0,0 +1,700 @@
# 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: GitHub.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# 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 .github/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:
push:
branches:
- 'dev/**'
- 'rc/**'
- develop
- development
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [opened, synchronize, reopened, 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.
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
REPO="${{ github.repository }}"
ORG="${{ github.repository_owner }}"
METHOD=""
AUTHORIZED="false"
# Hardcoded authorized users — always allowed to deploy
AUTHORIZED_USERS="jmiller github-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=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \
--jq '.permission' 2>/dev/null)
METHOD="repo collaborator API"
if [ -z "$PERMISSION" ]; then
ORG_ROLE=$(gh api "orgs/${ORG}/memberships/${ACTOR}" \
--jq '.role' 2>/dev/null)
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: >-
!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.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=".github/.mokostandards"; [ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"; if [ -f "$MOKO_FILE" ]; then
PLATFORM=$(grep -oP '^platform:.*' "$MOKO_FILE" 2>/dev/null || true)
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'
uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
with:
php-version: '8.1'
tools: composer
- name: Setup MokoStandards deploy tools
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch version/04 --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
/tmp/mokostandards
cd /tmp/mokostandards
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/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.*" %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://github.com/${REPO}/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://github.com/${REPO}/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
@@ -0,0 +1,758 @@
# 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: GitHub.Workflow
# INGROUP: MokoStandards.Firewall
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# 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
+525
View File
@@ -0,0 +1,525 @@
# 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.Maintenance
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# 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 .github/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.GH_TOKEN || github.token }}
fetch-depth: 0
- name: Check actor permission
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
# Schedule triggers use github-actions[bot]
if [ "${{ github.event_name }}" = "schedule" ]; then
echo "✅ Scheduled run — authorized"
exit 0
fi
AUTHORIZED_USERS="jmiller github-actions[bot]"
for user in $AUTHORIZED_USERS; do
if [ "$ACTOR" = "$user" ]; then
echo "✅ ${ACTOR} authorized"
exit 0
fi
done
PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
--jq '.permission' 2>/dev/null)
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=(
".github/workflows/build.yml"
".github/workflows/code-quality.yml"
".github/workflows/release-cycle.yml"
".github/workflows/release-pipeline.yml"
".github/workflows/branch-cleanup.yml"
".github/workflows/auto-update-changelog.yml"
".github/workflows/enterprise-issue-manager.yml"
".github/workflows/flush-actions-cache.yml"
".github/workflows/mokostandards-script-runner.yml"
".github/workflows/unified-ci.yml"
".github/workflows/unified-platform-testing.yml"
".github/workflows/reusable-build.yml"
".github/workflows/reusable-ci-validation.yml"
".github/workflows/reusable-deploy.yml"
".github/workflows/reusable-php-quality.yml"
".github/workflows/reusable-platform-testing.yml"
".github/workflows/reusable-project-detector.yml"
".github/workflows/reusable-release.yml"
".github/workflows/reusable-script-executor.yml"
".github/workflows/rebuild-docs-indexes.yml"
".github/workflows/setup-project-v2.yml"
".github/workflows/sync-docs-to-project.yml"
".github/workflows/release.yml"
".github/workflows/sync-changelogs.yml"
".github/workflows/version_branch.yml"
"update.json"
".github/workflows/auto-version-branch.yml"
".github/workflows/publish-to-mokodolibarr.yml"
".github/workflows/ci.yml"
".github/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 "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add -A
git commit -m "chore: delete ${DELETED} retired workflow file(s) [skip ci]" \
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
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:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
echo "## 🏷️ Label Reset" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
gh api "repos/${REPO}/labels?per_page=100" --paginate --jq '.[].name' | while read -r label; do
ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$label', safe=''))")
gh api -X DELETE "repos/${REPO}/labels/${ENCODED}" --silent 2>/dev/null || true
done
while IFS='|' read -r name color description; do
[ -z "$name" ] && continue
gh api "repos/${REPO}/labels" \
-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:
GH_TOKEN: ${{ secrets.GH_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
gh api "repos/${REPO}/branches?per_page=100" --jq '.[].name' | \
grep "^chore/sync-mokostandards" | \
grep -v "^${CURRENT}$" | while read -r branch; do
gh pr list --repo "$REPO" --head "$branch" --state open --json number --jq '.[].number' 2>/dev/null | 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
gh api -X DELETE "repos/${REPO}/git/refs/heads/${branch}" --silent 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:
GH_TOKEN: ${{ secrets.GH_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
gh api "repos/${REPO}/actions/runs?status=${status}&per_page=100" \
--jq '.workflow_runs[].id' 2>/dev/null | while read -r run_id; do
gh api -X DELETE "repos/${REPO}/actions/runs/${run_id}" --silent 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:
GH_TOKEN: ${{ secrets.GH_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
gh api "repos/${REPO}/actions/runs?created=<${CUTOFF}&per_page=100" \
--jq '.workflow_runs[].id' 2>/dev/null | while read -r run_id; do
gh api -X DELETE "repos/${REPO}/actions/runs/${run_id}/logs" --silent 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 "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .github/ISSUE_TEMPLATE/
git commit -m "fix: strip copyright comment blocks from issue templates [skip ci]" \
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
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 "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add docs/
if ! git diff --cached --quiet; then
git commit -m "docs: rebuild documentation indexes [skip ci]" \
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
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 ".github/workflows/custom" ]; then
mkdir -p .github/workflows/custom
cat > .github/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 `.github/workflows/` (parent directory).
CWEOF
sed -i 's/^ //' .github/workflows/custom/README.md
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .github/workflows/custom/
if ! git diff --cached --quiet; then
git commit -m "chore: create .github/workflows/custom/ for repo-specific workflows [skip ci]" \
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
git push
echo "✅ Created \`.github/workflows/custom/\` directory" >> $GITHUB_STEP_SUMMARY
fi
else
CUSTOM_COUNT=$(find .github/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:
GH_TOKEN: ${{ secrets.GH_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
gh api "repos/${REPO}/issues?state=closed&since=1970-01-01T00:00:00Z&per_page=100&sort=updated&direction=asc" \
--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
gh api "repos/${REPO}/issues/${num}/lock" -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
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# 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.Automation
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# 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 .github/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:
push:
branches:
- main
- master
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
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GH_TOKEN || github.token }}
fetch-depth: 0
- name: Set up PHP
uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
with:
php-version: '8.1'
tools: composer
- name: Setup MokoStandards tools
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch version/04 --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
/tmp/mokostandards
cd /tmp/mokostandards
composer install --no-dev --no-interaction --quiet
- name: Auto-bump patch version
if: ${{ github.event_name == 'push' && github.actor != 'github-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 "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add README.md
git commit -m "chore(version): auto-bump patch ${RESULT} [skip ci]" \
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
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:
GH_TOKEN: ${{ secrets.GH_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 "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add -A
git commit -m "chore(version): sync badges and headers to ${VERSION} [skip ci]" \
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
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
+204
View File
@@ -0,0 +1,204 @@
# ============================================================
# Local task tracking (not version controlled)
# ============================================================
TODO.md
# ============================================================
# Environment and secrets
# ============================================================
.env
.env.local
.env.*.local
*.local.php
*.secret.php
configuration.php
configuration.*.php
configuration.local.php
conf/conf.php
conf/conf*.php
secrets/
*.secrets.*
# ============================================================
# Logs, dumps and databases
# ============================================================
*.db
*.db-journal
*.dump
*.log
*.pid
*.seed
# ============================================================
# OS / Editor / IDE cruft
# ============================================================
.DS_Store
Thumbs.db
desktop.ini
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
$RECYCLE.BIN/
System Volume Information/
*.lnk
Icon?
.idea/
.settings/
.claude/
.vscode/*
!.vscode/tasks.json
!.vscode/settings.json.example
!.vscode/extensions.json
*.code-workspace
*.sublime*
.project
.buildpath
.classpath
*.bak
*.swp
*.swo
*.tmp
*.old
*.orig
# ============================================================
# Dev scripts and scratch
# ============================================================
TODO.md
todo*
*ffs*
# ============================================================
# SFTP / sync tools
# ============================================================
sftp-config*.json
sftp-config.json.template
sftp-settings.json
# ============================================================
# Sublime SFTP / FTP sync
# ============================================================
*.sublime-project
*.sublime-workspace
*.sublime-settings
.libsass.json
*.ffs*
# ============================================================
# Replit / cloud IDE
# ============================================================
.replit
replit.md
# ============================================================
# Archives / release artifacts
# ============================================================
*.7z
*.rar
*.tar
*.tar.gz
*.tgz
*.zip
artifacts/
release/
releases/
# ============================================================
# Build outputs and site generators
# ============================================================
.mkdocs-build/
.cache/
.parcel-cache/
build/
dist/
out/
site/
*.map
*.css.map
*.js.map
*.tsbuildinfo
# ============================================================
# CI / test artifacts
# ============================================================
.coverage
.coverage.*
coverage/
coverage.xml
htmlcov/
junit.xml
reports/
test-results/
tests/_output/
.github/local/
.github/workflows/*.log
# ============================================================
# Node / JavaScript
# ============================================================
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
.yarn/
.npmrc
.eslintcache
package-lock.json
# ============================================================
# PHP / Composer tooling
# ============================================================
vendor/
!src/media/vendor/
composer.lock
*.phar
codeception.phar
.phpunit.result.cache
.php_cs.cache
.php-cs-fixer.cache
.phpstan.cache
.phplint-cache
phpmd-cache/
.psalm/
.rector/
# ============================================================
# Python
# ============================================================
__pycache__/
*.py[cod]
*.pyc
*$py.class
*.so
.Python
.eggs/
*.egg
*.egg-info/
.installed.cfg
MANIFEST
develop-eggs/
downloads/
eggs/
parts/
sdist/
var/
wheels/
ENV/
env/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pyright/
.tox/
.nox/
*.cover
*.coverage
hypothesis/
profile.ps1
.mcp.json
+9
View File
@@ -0,0 +1,9 @@
# <type>(<scope>): <subject>
# types: build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test
# subject: imperative, lower-case, no trailing period
# Body: what and why
# BREAKING CHANGE: <description>
# Closes: #123
# Signed-off-by: <Your Name> <you@example.com>
+56
View File
@@ -0,0 +1,56 @@
# MCP Server Makefile
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
PROJECT_NAME := {{PROJECT_NAME}}
PROJECT_VERSION := 1.0.0
NPM := npm
COLOR_RESET := \033[0m
COLOR_GREEN := \033[32m
COLOR_BLUE := \033[34m
.PHONY: help
help: ## Show this help message
@echo "$(COLOR_BLUE)$(PROJECT_NAME) v$(PROJECT_VERSION)$(COLOR_RESET)"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
.PHONY: install-deps
install-deps: ## Install dependencies
@$(NPM) install
@echo "$(COLOR_GREEN)✓ Dependencies installed$(COLOR_RESET)"
.PHONY: build
build: ## Build TypeScript
@$(NPM) run build
@echo "$(COLOR_GREEN)✓ Build complete$(COLOR_RESET)"
.PHONY: dev
dev: ## Watch and rebuild on changes
@$(NPM) run dev
.PHONY: clean
clean: ## Clean build artifacts
@rm -rf dist
@echo "$(COLOR_GREEN)✓ Cleaned$(COLOR_RESET)"
.PHONY: setup
setup: ## Run interactive setup wizard
@$(NPM) run setup
.PHONY: start
start: ## Start the MCP server
@$(NPM) run start
.PHONY: lint
lint: ## Run linter
@$(NPM) run lint
.PHONY: ci
ci: install-deps build ## Run CI pipeline
@echo "$(COLOR_GREEN)✓ CI pipeline complete$(COLOR_RESET)"
.DEFAULT_GOAL := help
+219
View File
@@ -0,0 +1,219 @@
<!-- 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: MokoStandards-Template-MCP.Documentation
INGROUP: MokoStandards-Template-MCP
REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-MCP
VERSION: 01.00.00
PATH: ./README.md
BRIEF: Template repository for MCP API servers
-->
[![Version](https://img.shields.io/badge/version-01.00.00-blue.svg?logo=v&logoColor=white)](https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-MCP/releases/tag/v00)
[![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green.svg?logo=gnu&logoColor=white)](LICENSE)
[![Node](https://img.shields.io/badge/Node.js-20%2B-339933.svg?logo=node.js&logoColor=white)](https://nodejs.org)
# MokoStandards-Template-MCP
[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme)
Template repository for creating MokoStandards-compliant Model Context Protocol (MCP) servers that expose REST APIs as AI assistant tools.
Use this template to scaffold a new MCP server for any API — Dolibarr, Joomla, GitHub, Stripe, or any REST service. Comes pre-configured with the standard file structure, CI/CD workflows, setup wizard, and example tools.
## Table of Contents
- [Quick Start](#quick-start)
- [What You Get](#what-you-get)
- [Customization Guide](#customization-guide)
- [File Structure](#file-structure)
- [Adding Tools](#adding-tools)
- [License](#license)
## Quick Start
### 1. Create from template
On Gitea, click **"Use this template"** to create a new repo, or clone and reinitialize:
```sh
git clone https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-Template-MCP.git my-api-mcp
cd my-api-mcp
rm -rf .git && git init
```
### 2. Search and replace placeholders
Replace these placeholders across all files:
| Placeholder | Replace With | Example |
|---|---|---|
| `{{PROJECT_NAME}}` | Your project name (kebab-case) | `dolibarr-api-mcp` |
| `{{DISPLAY_NAME}}` | Human-readable API name | `Dolibarr ERP/CRM` |
| `{{ENV_PREFIX}}` | Env var prefix (UPPER_SNAKE) | `DOLIBARR_API_MCP` |
```sh
# Linux/macOS
find . -type f -not -path './.git/*' -exec sed -i 's/{{PROJECT_NAME}}/my-api-mcp/g' {} +
find . -type f -not -path './.git/*' -exec sed -i 's/{{DISPLAY_NAME}}/My API/g' {} +
find . -type f -not -path './.git/*' -exec sed -i 's/{{ENV_PREFIX}}/MY_API_MCP/g' {} +
```
### 3. Customize the auth mechanism
Edit `src/client.ts` — update the `headers` in the constructor to match your API's authentication:
```typescript
// Bearer token (GitHub, Joomla)
this.headers = { 'Authorization': `Bearer ${conn.apiKey}` };
// Custom header (Dolibarr)
this.headers = { 'DOLAPIKEY': conn.apiKey };
// Basic auth
this.headers = { 'Authorization': `Basic ${Buffer.from(user + ':' + pass).toString('base64')}` };
```
### 4. Update the API prefix
In `src/client.ts`, set `API_PREFIX` to your API's base path:
```typescript
const API_PREFIX = '/api/v1'; // GitHub
const API_PREFIX = '/api/index.php'; // Dolibarr
const API_PREFIX = '/api/index.php/v1'; // Joomla
```
### 5. Add your tools
Replace the example tools in `src/index.ts` with your API endpoints. See [Adding Tools](#adding-tools).
### 6. Build and test
```sh
npm install
npm run build
npm run setup # configure your first connection
npm start # verify it starts
```
## What You Get
| Component | Description |
|---|---|
| `src/index.ts` | MCP server with example tools, shared helpers, and connection management |
| `src/client.ts` | HTTP client supporting GET/POST/PUT/PATCH/DELETE with TLS bypass |
| `src/config.ts` | Multi-connection config loader (`~/.<project>.json`) |
| `src/types.ts` | TypeScript interfaces for connection, config, and response |
| `scripts/setup.mjs` | Interactive setup wizard for adding API connections |
| `config.example.json` | Example multi-connection config |
| `.gitea/workflows/` | 12 CI/CD workflows (auto-release, compliance, cleanup, etc.) |
| `Makefile` | Build automation (install, build, dev, clean, setup, start) |
| Standard files | `.gitignore`, `.gitattributes`, `.gitmessage`, `tsconfig.json` |
## Customization Guide
### Config & Auth
| File | What to Change |
|---|---|
| `src/types.ts` | Rename `apiKey` field if your API uses different auth (e.g. `apiToken`, `username`/`password`) |
| `src/client.ts` | Update `API_PREFIX`, auth `headers`, and HTTP methods (`PUT` vs `PATCH`) |
| `src/config.ts` | Update `CONFIG_FILENAME` and `CONFIG_ENV_VAR` |
| `scripts/setup.mjs` | Update `AUTH_FIELD`, `AUTH_PROMPT`, `API_LABEL` constants |
| `config.example.json` | Update field names to match your auth scheme |
### Setup Wizard
The setup wizard in `scripts/setup.mjs` has labeled constants at the top — update these to change the prompts, config filename, and auth field name.
## File Structure
```
├── src/
│ ├── index.ts # MCP server — register tools here
│ ├── client.ts # HTTP client — customize auth & API prefix
│ ├── config.ts # Config loader — customize filename & env var
│ └── types.ts # TypeScript interfaces — customize auth fields
├── scripts/
│ └── setup.mjs # Interactive setup wizard
├── .gitea/
│ ├── .mokostandards # Platform: mcp-server
│ └── workflows/ # 12 CI/CD workflows
├── docs/ # Documentation
├── package.json
├── tsconfig.json
├── config.example.json
├── Makefile
├── .gitignore
├── .gitattributes
└── .gitmessage
```
## Adding Tools
Each tool follows this pattern:
```typescript
server.tool(
'prefix_resource_list', // snake_case name
'List resources with optional filtering', // description for AI
{
search: z.string().optional().describe('Search query'),
status: z.enum(['active', 'archived']).optional().describe('Filter by status'),
...PaginationParams,
...ConnectionParam,
},
async ({ search, status, limit, page, connection }) => {
const client = clientFor(connection);
const params: Record<string, string> = { ...paginationQuery({ limit, page }) };
if (search) params['search'] = search;
if (status) params['status'] = status;
return formatResponse(await client.get('/resources', params));
},
);
```
### Naming conventions
- **List**: `prefix_resources_list` (plural noun)
- **Get**: `prefix_resource_get` (singular)
- **Create**: `prefix_resource_create`
- **Update**: `prefix_resource_update`
- **Delete**: `prefix_resource_delete`
- **Actions**: `prefix_resource_validate`, `prefix_resource_close`, etc.
### Organization
Group tools by resource type with section comments:
```typescript
// ── Invoices ────────────────────────────────────────────────────────────
// ── Products ────────────────────────────────────────────────────────────
// ── Users ───────────────────────────────────────────────────────────────
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
GPL-3.0-or-later — see [LICENSE](LICENSE).
Copyright © 2026 Moko Consulting <hello@mokoconsulting.tech>
## Maintainers
[@mokoconsulting-tech](https://git.mokoconsulting.tech/MokoConsulting)
## Revision History
| Date | Version | Author | Notes |
| --- | --- | --- | --- |
| 2026-05-07 | 0.0.1 | jmiller | Initial MCP server template |
+18
View File
@@ -0,0 +1,18 @@
{
"defaultConnection": "production",
"connections": {
"local-dev": {
"baseUrl": "https://localhost:8080",
"apiKey": "your-api-key-here",
"insecure": true
},
"production": {
"baseUrl": "https://api.example.com",
"apiKey": "your-production-api-key"
},
"staging": {
"baseUrl": "https://api-staging.example.com",
"apiKey": "your-staging-api-key"
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: {{PROJECT_NAME}}.Documentation
PATH: /docs/API.md
VERSION: 01.00.00
BRIEF: MCP tool reference documentation
-->
# API Reference
All tools accept an optional `connection` parameter to target a specific named connection. If omitted, the default connection is used.
## Example Resources
> Replace these example tools with your actual API tools.
### `example_resources_list`
List resources with optional search.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `search` | string | No | Search query |
| `limit` | number | No | Max results |
| `page` | number | No | Page number (0-based) |
### `example_resource_get`
Get a single resource by ID.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | number | Yes | Resource ID |
### `example_resource_create`
Create a new resource.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Resource name |
| `description` | string | No | Resource description |
## Generic
### `api_request`
Make a raw API request to any endpoint.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `method` | `"GET"` / `"POST"` / `"PUT"` / `"PATCH"` / `"DELETE"` | Yes | HTTP method |
| `endpoint` | string | Yes | API path |
| `body` | object | No | Request body |
| `params` | object | No | Query parameters |
### `list_connections`
List all configured connections. No parameters.
## Revision History
| Date | Version | Author | Notes |
| --- | --- | --- | --- |
| 2026-05-07 | 0.0.1 | jmiller | Initial template API reference |
+73
View File
@@ -0,0 +1,73 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: {{PROJECT_NAME}}.Documentation
PATH: /docs/ARCHITECTURE.md
VERSION: 01.00.00
BRIEF: Architecture overview and design decisions
-->
# Architecture
## Overview
{{PROJECT_NAME}} is a Model Context Protocol (MCP) server that bridges AI assistants with a REST API.
```
AI Assistant <--> MCP (stdio) <--> ApiClient <--> REST API
```
## Components
### `src/index.ts` — Server Entry Point
Registers all MCP tools with `McpServer` from `@modelcontextprotocol/sdk`. Each tool maps to one or more API endpoints. Uses Zod schemas for input validation.
Includes shared helpers:
- `formatResponse()` — normalizes error/success responses into MCP text content
- `paginationQuery()` — builds pagination query params
- `ConnectionParam` / `PaginationParams` — reusable Zod parameter spreads
### `src/client.ts` — HTTP Client
The `ApiClient` class handles all HTTP communication:
- Uses `node:https` / `node:http` (not `fetch`) for reliable self-signed cert support
- Supports GET, POST, PUT, PATCH, DELETE
- JSON serialization/deserialization with error handling
### `src/config.ts` — Configuration Loader
Loads connection details from `~/.<project>.json`. Supports multiple named connections with a configurable default.
### `src/types.ts` — Type Definitions
TypeScript interfaces for `ApiConnection`, `ApiConfig`, and `ApiResponse`.
### `scripts/setup.mjs` — Interactive Setup
Node.js script using `readline/promises` for interactive config creation.
## Design Decisions
### Why `node:https` instead of `fetch`?
Node.js 24's built-in `fetch` does not honor self-signed certificate bypass. The classic `node:https` module with `rejectUnauthorized: false` works reliably across all Node.js versions.
### Why multiple named connections?
Multi-instance support is a core use case — managing staging, production, and dev environments from a single MCP server.
## Data Flow
1. AI assistant sends a tool call via MCP stdio transport
2. `index.ts` validates parameters with Zod and resolves the connection
3. `ApiClient` constructs the API URL, attaches auth headers, and makes the HTTP request
4. Response is parsed as JSON and returned as MCP tool output
## Revision History
| Date | Version | Author | Notes |
| --- | --- | --- | --- |
| 2026-05-07 | 0.0.1 | jmiller | Initial architecture document |
+102
View File
@@ -0,0 +1,102 @@
<!--
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
DEFGROUP: {{PROJECT_NAME}}.Documentation
PATH: /docs/INSTALLATION.md
VERSION: 01.00.00
BRIEF: Installation and setup instructions
-->
# Installation
## Prerequisites
- **Node.js** 20.0.0 or later
- **npm** (included with Node.js)
- Access to the target API with valid credentials
## Install
```sh
git clone https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}.git
cd {{PROJECT_NAME}}
npm install
npm run build
npm run setup
```
The setup wizard will prompt for:
1. **Connection name** — a label for this instance (e.g. `production`, `staging`)
2. **API URL** — the base URL of the instance
3. **API key/token** — authentication credentials
4. **TLS verification** — whether to skip certificate verification (for self-signed certs)
Run `npm run setup` again to add more connections.
## Register with Claude Code
Add to your Claude Code MCP settings (`~/.claude.json` or project `.mcp.json`):
```json
{
"mcpServers": {
"{{PROJECT_NAME}}": {
"type": "stdio",
"command": "node",
"args": ["/path/to/{{PROJECT_NAME}}/dist/index.js"]
}
}
}
```
Restart Claude Code after adding the server.
## Configuration File
The config is stored at `~/.{{PROJECT_NAME}}.json`:
```json
{
"defaultConnection": "production",
"connections": {
"production": {
"baseUrl": "https://api.example.com",
"apiKey": "your-api-key"
},
"staging": {
"baseUrl": "https://api-staging.example.com",
"apiKey": "your-staging-key",
"insecure": true
}
}
}
```
You can also set the `{{ENV_PREFIX}}_CONFIG` environment variable to use a config file at a custom path.
## Verification
```sh
npm start
```
If configured correctly, the server will start listening on stdio.
## Troubleshooting
### "Failed to load config" error
Run `npm run setup` to create the config file.
### Connection errors
- Verify the API is reachable from your machine
- For self-signed certs, set `"insecure": true`
- Ensure the API key/token is valid
## Revision History
| Date | Version | Author | Notes |
| --- | --- | --- | --- |
| 2026-05-07 | 0.0.1 | jmiller | Initial installation guide |
+12
View File
@@ -0,0 +1,12 @@
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
SPDX-License-Identifier: GPL-3.0-or-later -->
# {{PROJECT_NAME}} Documentation
See the [README](../README.md) for quick start, customization guide, and tool patterns.
## Documents
- [INSTALLATION.md](./INSTALLATION.md) — Prerequisites, install, setup, troubleshooting
- [ARCHITECTURE.md](./ARCHITECTURE.md) — Component overview, design decisions, data flow
- [API.md](./API.md) — Full MCP tool reference with parameter tables
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@mokoconsulting/{{PROJECT_NAME}}",
"version": "1.0.0",
"description": "MCP server for {{DISPLAY_NAME}} API operations",
"type": "module",
"main": "dist/index.js",
"bin": {
"{{PROJECT_NAME}}": "dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"lint": "eslint src/",
"setup": "node scripts/setup.mjs",
"clean": "rm -rf dist/"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"zod": "^3.24.4"
},
"devDependencies": {
"@types/node": "^22.15.3",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=20.0.0"
},
"license": "GPL-3.0-or-later",
"author": "Moko Consulting <hello@mokoconsulting.tech>",
"repository": {
"type": "git",
"url": "https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}.git"
}
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/* 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: {{PROJECT_NAME}}.Scripts
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /scripts/setup.mjs
* VERSION: 01.00.00
* BRIEF: Interactive setup — prompts for API connection details and writes config
*/
import { createInterface } from 'node:readline/promises';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
// ── Customize these ─────────────────────────────────────────────────────
const PROJECT_NAME = '{{PROJECT_NAME}}';
const CONFIG_PATH = resolve(homedir(), `.${PROJECT_NAME}.json`);
const API_LABEL = '{{DISPLAY_NAME}}'; // e.g. "Dolibarr", "Joomla"
const AUTH_FIELD = 'apiKey'; // field name in config
const AUTH_PROMPT = 'API key'; // what to ask the user for
// ────────────────────────────────────────────────────────────────────────
const rl = createInterface({ input: process.stdin, output: process.stdout });
async function prompt(question, defaultValue) {
const suffix = defaultValue ? ` [${defaultValue}]` : '';
const answer = await rl.question(`${question}${suffix}: `);
return answer.trim() || defaultValue || '';
}
async function promptRequired(question) {
let answer = '';
while (!answer) {
answer = (await rl.question(`${question}: `)).trim();
if (!answer) {
console.log(' This field is required.');
}
}
return answer;
}
async function main() {
console.log('');
console.log(`=== ${PROJECT_NAME} Setup ===`);
console.log('');
console.log('This will create your configuration file at:');
console.log(` ${CONFIG_PATH}`);
console.log('');
let existing = null;
try {
const raw = await readFile(CONFIG_PATH, 'utf-8');
existing = JSON.parse(raw);
console.log('Existing config found. You can add a new connection or overwrite.');
console.log(` Current connections: ${Object.keys(existing.connections).join(', ')}`);
console.log('');
} catch {
// No existing config
}
const connectionName = await prompt('Connection name', 'production');
const baseUrl = await promptRequired(`${API_LABEL} URL (e.g. https://api.example.com)`);
const authValue = await promptRequired(`${API_LABEL} ${AUTH_PROMPT}`);
const cleanUrl = baseUrl.replace(/\/+$/, '');
const insecureAnswer = await prompt('Skip TLS verification for self-signed certs? (y/N)', 'N');
const insecure = insecureAnswer.toLowerCase() === 'y';
const connection = { baseUrl: cleanUrl, [AUTH_FIELD]: authValue };
if (insecure) {
connection.insecure = true;
}
let config;
if (existing) {
config = existing;
config.connections[connectionName] = connection;
const setDefault = await prompt(`Set "${connectionName}" as default connection? (y/N)`, 'N');
if (setDefault.toLowerCase() === 'y') {
config.defaultConnection = connectionName;
}
} else {
config = {
defaultConnection: connectionName,
connections: {
[connectionName]: connection,
},
};
}
await writeFile(CONFIG_PATH, JSON.stringify(config, null, '\t') + '\n', 'utf-8');
console.log('');
console.log(`Config written to ${CONFIG_PATH}`);
console.log(` Connection "${connectionName}" configured for ${cleanUrl}`);
console.log('');
const addAnother = await prompt('Add another connection? (y/N)', 'N');
if (addAnother.toLowerCase() === 'y') {
rl.close();
const { execFileSync } = await import('node:child_process');
execFileSync('node', [new URL(import.meta.url).pathname], { stdio: 'inherit' });
return;
}
console.log('Setup complete. You can now use the MCP server.');
console.log('');
rl.close();
}
main().catch((err) => {
console.error(`Setup failed: ${err.message}`);
rl.close();
process.exit(1);
});
+129
View File
@@ -0,0 +1,129 @@
/* 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: {{PROJECT_NAME}}.Client
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /src/client.ts
* VERSION: 01.00.00
* BRIEF: HTTP client for {{DISPLAY_NAME}} API
*/
import * as https from 'node:https';
import * as http from 'node:http';
import type { ApiConnection, ApiResponse } from './types.js';
// ── Customize these ─────────────────────────────────────────────────────
// API path prefix appended to baseUrl (e.g. "/api/index.php", "/api/v1")
const API_PREFIX = '/api';
const TIMEOUT_MS = 30_000;
// ────────────────────────────────────────────────────────────────────────
export class ApiClient {
private readonly base_url: string;
private readonly headers: Record<string, string>;
private readonly insecure: boolean;
constructor(conn: ApiConnection) {
this.base_url = conn.baseUrl.replace(/\/+$/, '') + API_PREFIX;
// ── Customize auth headers ──────────────────────────────────
// Examples:
// Bearer token: { 'Authorization': `Bearer ${conn.apiKey}` }
// API key header: { 'DOLAPIKEY': conn.apiKey }
// Basic auth: { 'Authorization': `Basic ${btoa(user + ':' + pass)}` }
this.headers = {
'Authorization': `Bearer ${conn.apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
this.insecure = conn.insecure ?? false;
}
async get(endpoint: string, params?: Record<string, string>): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint, params), 'GET');
}
async post(endpoint: string, body?: unknown): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint), 'POST', body);
}
async put(endpoint: string, body: unknown): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint), 'PUT', body);
}
async patch(endpoint: string, body: unknown): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint), 'PATCH', body);
}
async delete(endpoint: string): Promise<ApiResponse> {
return this.request(this.buildUrl(endpoint), 'DELETE');
}
private buildUrl(endpoint: string, params?: Record<string, string>): string {
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = new URL(`${this.base_url}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
private request(url: string, method: string, body?: unknown): Promise<ApiResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const is_https = parsed.protocol === 'https:';
const transport = is_https ? https : http;
const options: https.RequestOptions = {
hostname: parsed.hostname,
port: parsed.port || (is_https ? 443 : 80),
path: parsed.pathname + parsed.search,
method,
headers: { ...this.headers },
timeout: TIMEOUT_MS,
};
if (this.insecure && is_https) {
options.rejectUnauthorized = false;
}
const payload = body !== undefined ? JSON.stringify(body) : undefined;
if (payload) {
(options.headers as Record<string, string>)['Content-Length'] = Buffer.byteLength(payload).toString();
}
const req = transport.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
data = raw;
}
resolve({ status: res.statusCode ?? 0, data });
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
if (payload) {
req.write(payload);
}
req.end();
});
}
}
+63
View File
@@ -0,0 +1,63 @@
/* 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: {{PROJECT_NAME}}.Config
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /src/config.ts
* VERSION: 01.00.00
* BRIEF: Configuration loader for {{DISPLAY_NAME}} MCP connections
*/
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
import type { ApiConfig, ApiConnection } from './types.js';
// ── Customize this ──────────────────────────────────────────────────────
// Change the filename to match your project (e.g. ".dolibarr-api-mcp.json")
const CONFIG_FILENAME = '.{{PROJECT_NAME}}.json';
// Change the env var name to match your project
const CONFIG_ENV_VAR = '{{ENV_PREFIX}}_CONFIG';
// ────────────────────────────────────────────────────────────────────────
export async function loadConfig(): Promise<ApiConfig> {
const config_path = process.env[CONFIG_ENV_VAR]
? resolve(process.env[CONFIG_ENV_VAR]!)
: resolve(homedir(), CONFIG_FILENAME);
try {
const raw = await readFile(config_path, 'utf-8');
const parsed = JSON.parse(raw) as Partial<ApiConfig>;
if (!parsed.connections || Object.keys(parsed.connections).length === 0) {
throw new Error('No connections defined in config');
}
return {
connections: parsed.connections,
defaultConnection: parsed.defaultConnection ?? Object.keys(parsed.connections)[0],
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to load config from ${config_path}: ${message}\n` +
`Create ${config_path} — see config.example.json for format.`,
);
}
}
export function getConnection(config: ApiConfig, name?: string): ApiConnection {
const key = name ?? config.defaultConnection;
const conn = config.connections[key];
if (!conn) {
throw new Error(
`Connection "${key}" not found. Available: ${Object.keys(config.connections).join(', ')}`,
);
}
return conn;
}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
/* 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: {{PROJECT_NAME}}.Server
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /src/index.ts
* VERSION: 01.00.00
* BRIEF: MCP server entry point — registers all {{DISPLAY_NAME}} API tools
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { loadConfig, getConnection } from './config.js';
import { ApiClient } from './client.js';
import type { ApiConfig, ApiResponse } from './types.js';
let config: ApiConfig;
function clientFor(connection?: string): ApiClient {
return new ApiClient(getConnection(config, connection));
}
function formatResponse(res: ApiResponse): { content: Array<{ type: 'text'; text: string }> } {
if (res.status >= 400) {
return {
content: [{ type: 'text' as const, text: `Error: HTTP ${res.status}: ${JSON.stringify(res.data, null, 2)}` }],
};
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(res.data, null, 2) }],
};
}
// ── Shared parameter definitions ────────────────────────────────────────
const ConnectionParam = {
connection: z.string().optional().describe('Named connection from config (uses default if omitted)'),
};
const PaginationParams = {
limit: z.number().optional().describe('Max results'),
page: z.number().optional().describe('Page number (0-based)'),
};
function paginationQuery(params: { limit?: number; page?: number }): Record<string, string> {
const q: Record<string, string> = {};
if (params.limit !== undefined) q['limit'] = String(params.limit);
if (params.page !== undefined) q['page'] = String(params.page);
return q;
}
// ── Server ──────────────────────────────────────────────────────────────
const server = new McpServer({
name: '{{PROJECT_NAME}}',
version: '1.0.0',
});
// ════════════════════════════════════════════════════════════════════════
// ADD YOUR TOOLS BELOW
// ════════════════════════════════════════════════════════════════════════
//
// Follow this pattern for each tool:
//
// server.tool(
// 'prefix_resource_action', // tool name (snake_case)
// 'Human-readable description', // shown to the AI assistant
// { // Zod schema for parameters
// id: z.number().describe('Resource ID'),
// ...ConnectionParam,
// },
// async ({ id, connection }) => {
// const client = clientFor(connection);
// return formatResponse(await client.get(`/resources/${id}`));
// },
// );
//
// Tips:
// - Group tools by resource type with section comments
// - Use consistent naming: prefix_resource_list, prefix_resource_get,
// prefix_resource_create, prefix_resource_update, prefix_resource_delete
// - Spread ...ConnectionParam into every tool's schema
// - Spread ...PaginationParams into list tools
// - Use paginationQuery() to build query params for list endpoints
//
// ════════════════════════════════════════════════════════════════════════
// ── Example: Resources ──────────────────────────────────────────────────
server.tool(
'example_resources_list',
'List resources (EXAMPLE — replace with your API resources)',
{
search: z.string().optional().describe('Search query'),
...PaginationParams,
...ConnectionParam,
},
async ({ search, limit, page, connection }) => {
const client = clientFor(connection);
const params: Record<string, string> = { ...paginationQuery({ limit, page }) };
if (search) params['search'] = search;
return formatResponse(await client.get('/resources', params));
},
);
server.tool(
'example_resource_get',
'Get a single resource by ID (EXAMPLE — replace with your API resources)',
{
id: z.number().describe('Resource ID'),
...ConnectionParam,
},
async ({ id, connection }) => {
const client = clientFor(connection);
return formatResponse(await client.get(`/resources/${id}`));
},
);
server.tool(
'example_resource_create',
'Create a new resource (EXAMPLE — replace with your API resources)',
{
name: z.string().describe('Resource name'),
description: z.string().optional().describe('Resource description'),
...ConnectionParam,
},
async ({ name, description, connection }) => {
const client = clientFor(connection);
const body: Record<string, unknown> = { name };
if (description) body.description = description;
return formatResponse(await client.post('/resources', body));
},
);
// ── Generic API Call ────────────────────────────────────────────────────
server.tool(
'api_request',
'Make a raw API request to any endpoint',
{
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).describe('HTTP method'),
endpoint: z.string().describe('API endpoint path (e.g. "/resources")'),
body: z.record(z.string(), z.unknown()).optional().describe('Request body for POST/PUT/PATCH'),
params: z.record(z.string(), z.string()).optional().describe('Query parameters'),
...ConnectionParam,
},
async ({ method, endpoint, body, params, connection }) => {
const client = clientFor(connection);
switch (method) {
case 'GET':
return formatResponse(await client.get(endpoint, params));
case 'POST':
return formatResponse(await client.post(endpoint, body));
case 'PUT':
return formatResponse(await client.put(endpoint, body));
case 'PATCH':
return formatResponse(await client.patch(endpoint, body));
case 'DELETE':
return formatResponse(await client.delete(endpoint));
}
},
);
// ── Connections Management ──────────────────────────────────────────────
server.tool(
'list_connections',
'List configured API connections',
{},
async () => {
const lines = Object.entries(config.connections).map(([name, conn]) => {
const is_default = name === config.defaultConnection ? ' (default)' : '';
return ` ${name}${is_default}: ${conn.baseUrl}`;
});
return {
content: [{ type: 'text' as const, text: `Configured connections:\n${lines.join('\n')}` }],
};
},
);
// ── Start Server ────────────────────────────────────────────────────────
async function main(): Promise<void> {
config = await loadConfig();
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
process.stderr.write(`Fatal: ${err}\n`);
process.exit(1);
});
+48
View File
@@ -0,0 +1,48 @@
/* 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: {{PROJECT_NAME}}.Types
* INGROUP: {{PROJECT_NAME}}
* REPO: https://git.mokoconsulting.tech/MokoConsulting/{{PROJECT_NAME}}
* PATH: /src/types.ts
* VERSION: 01.00.00
* BRIEF: TypeScript type definitions for {{DISPLAY_NAME}} MCP server
*/
/**
* Connection configuration for a single API instance.
*
* Rename and extend these fields to match your target API's auth mechanism:
* - `apiKey` + DOLAPIKEY header (Dolibarr)
* - `apiToken` + Bearer header (Joomla, GitHub)
* - `username`/`password` (Basic auth)
* - `oauth` fields (OAuth2 flows)
*/
export interface ApiConnection {
/** Base URL of the API instance (no trailing slash) */
baseUrl: string;
/** API key or token for authentication */
apiKey: string;
/** Skip TLS certificate verification (self-signed certs) */
insecure?: boolean;
}
/**
* Top-level configuration supporting multiple named connections.
*/
export interface ApiConfig {
connections: Record<string, ApiConnection>;
defaultConnection: string;
}
/**
* Normalized API response returned by the HTTP client.
*/
export interface ApiResponse {
status: number;
data: unknown;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}