Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cc7297f23 | |||
| 9dc85cfc2d | |||
| 6bc0cb5bc8 | |||
| 1fb97eeeeb | |||
| 1032ae4268 | |||
| af1c6178ef | |||
| 0f23219ee4 | |||
| bc475c91f6 | |||
| 25268d7dd7 | |||
| c7193abc0c | |||
| e6a4dfccf0 | |||
| a5c805b0f6 | |||
| 46ce0a7e32 | |||
| c236c4e018 | |||
| b79b48b760 | |||
| 22a529dfd8 | |||
| 09dc64eef0 | |||
| d541a07263 | |||
| 2eee4bbf1c | |||
| 3828411311 | |||
| 039ab896cc | |||
| bc4eae2e0f | |||
| 9f549fb801 | |||
| a97755417a | |||
| 26fde4a50e | |||
| 79377399dd | |||
| b6c8b9144e | |||
| 09bf669937 | |||
| 5996661b6f | |||
| 284a1a39c3 | |||
| b64ed1972a | |||
| 71e0af4196 | |||
| 435766841f | |||
| 3842bee2bc | |||
| 1726a2f1fb | |||
| d445496691 | |||
| d1b964235a | |||
| 299ec57b52 | |||
| 34e2a460f9 | |||
| 7921e007f1 | |||
| c21df45434 | |||
| e279f8dbe8 | |||
| 3b57aaff10 |
@@ -0,0 +1,761 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||
# PATH: /templates/workflows/universal/auto-release.yml.template
|
||||
# VERSION: 05.00.00
|
||||
# BRIEF: Universal build & release � detects platform from manifest.xml
|
||||
#
|
||||
# +========================================================================+
|
||||
# | UNIVERSAL BUILD & RELEASE PIPELINE |
|
||||
# +========================================================================+
|
||||
# | |
|
||||
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
|
||||
# | |
|
||||
# | Platform-specific: |
|
||||
# | joomla: XML manifest, updates.xml, type-prefixed packages |
|
||||
# | dolibarr: mod*.class.php, update.txt, dev version reset |
|
||||
# | generic: README-only, no update stream |
|
||||
# | |
|
||||
# +========================================================================+
|
||||
|
||||
name: "Universal: Build & Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release Pipeline
|
||||
runs-on: release
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup moko-platform tools
|
||||
env:
|
||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}'
|
||||
run: |
|
||||
# Ensure PHP + Composer are available
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
cd /tmp/moko-platform-api
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
|
||||
# -- PLATFORM DETECTION ---------------------------------------------------
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/manifest_read.php --path . --github-output
|
||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1 || true)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Step 1: Read version"
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php /tmp/moko-platform-api/cli/version_read.php --path .)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::No VERSION in README.md"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Step 1b: Bump version"
|
||||
id: bump
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
MOKO_API="/tmp/moko-platform-api/cli"
|
||||
BUMP=$(php ${MOKO_API}/version_bump.php --path . --minor)
|
||||
VERSION=$(echo "$BUMP" | grep -oP '\d{2}\.\d{2}\.\d{2}$' || true)
|
||||
[ -z "$VERSION" ] && VERSION=$(php ${MOKO_API}/version_read.php --path .)
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Bumped to: ${VERSION}"
|
||||
|
||||
- name: Check if already released
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
|
||||
TAG_EXISTS=false
|
||||
BRANCH_EXISTS=false
|
||||
|
||||
git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
|
||||
git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
|
||||
|
||||
echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Tag and branch may persist across patch releases — never skip
|
||||
echo "already_released=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# -- SANITY CHECKS -------------------------------------------------------
|
||||
- name: "Sanity: Pre-release validation"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
ERRORS=0
|
||||
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
echo "## Pre-Release Sanity Checks (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- Version drift check (must pass before release) --------
|
||||
README_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
||||
if [ "$README_VER" != "$VERSION" ]; then
|
||||
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check CHANGELOG version matches
|
||||
CL_VER=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' CHANGELOG.md 2>/dev/null | head -1)
|
||||
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
|
||||
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
|
||||
# Check composer.json version if present
|
||||
if [ -f "composer.json" ]; then
|
||||
COMP_VER=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' composer.json 2>/dev/null | head -1)
|
||||
if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
|
||||
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Common checks
|
||||
if [ ! -f "LICENSE" ]; then
|
||||
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
|
||||
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- Platform-specific checks --------
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
XML_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -n "$XML_VER" ] && [ "$XML_VER" != "$VERSION" ]; then
|
||||
echo "- Manifest drift: \`${XML_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Manifest version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null)
|
||||
echo "- Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- No Joomla XML manifest (WaaS site)" >> $GITHUB_STEP_SUMMARY
|
||||
fi ;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_VER=$(sed -n "s/.*\\\$this->version = '\([^']*\)'.*/\1/p" "$MOD_FILE" 2>/dev/null | head -1)
|
||||
if [ -n "$MOD_VER" ] && [ "$MOD_VER" != "$VERSION" ]; then
|
||||
echo "- Module drift: \`${MOD_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "- Module version: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "- No mod*.class.php found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi
|
||||
if [ ! -f "update.txt" ]; then
|
||||
echo "- Missing update.txt" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
fi ;;
|
||||
*) echo "- Generic platform � no manifest checks" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
|
||||
# Always runs — every version change on main archives to version/XX.YY
|
||||
- name: "Step 2: Version archive branch"
|
||||
if: steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
||||
PATCH="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
|
||||
|
||||
# Check if branch exists
|
||||
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
|
||||
git push origin HEAD:"$BRANCH" --force
|
||||
echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
|
||||
git push origin "$BRANCH" --force
|
||||
echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 3: Set platform version ----------------------------------------
|
||||
- name: "Step 3: Set platform version"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-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'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/badge_update.php --path . --version "${VERSION}" 2>/dev/null || true
|
||||
|
||||
- name: "Step 5: Write update stream"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
php /tmp/moko-platform-api/cli/updates_xml_build.php \
|
||||
--path . --version "${VERSION}" --stability stable \
|
||||
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
--github-output
|
||||
|
||||
- name: Commit release changes
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
# Set push URL with token for branch-protected repos
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git commit -m "chore(release): build ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
||||
git push -u origin HEAD
|
||||
|
||||
# -- STEP 6: Create tag ---------------------------------------------------
|
||||
- name: "Step 6: Create git tag"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.tag_exists != 'true' &&
|
||||
steps.version.outputs.is_minor == 'true'
|
||||
run: |
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
# Only create the major release tag if it doesn't exist yet
|
||||
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
git tag "$RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 7: Create or update Gitea Release --------------------------------
|
||||
- name: "Step 7: Gitea Release"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
MAJOR="${{ steps.version.outputs.major }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# Reuse metadata from Step 5 (single source of truth)
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
EXT_NAME="${{ steps.updates.outputs.ext_name }}"
|
||||
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
|
||||
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
|
||||
|
||||
# Fallbacks if Step 5 was skipped
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
[ -z "$EXT_NAME" ] && EXT_NAME="${GITEA_REPO}"
|
||||
|
||||
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
|
||||
# Build release name: "Pretty Name VERSION (type_element-VERSION)"
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
RELEASE_NAME="${EXT_NAME} ${VERSION} (${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION})"
|
||||
|
||||
# Delete existing release if present (overwrite, not append)
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
EXISTING_ID=$(echo "$EXISTING" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/tags/${RELEASE_TAG}" 2>/dev/null || true
|
||||
echo "Deleted previous stable release (id: ${EXISTING_ID})"
|
||||
fi
|
||||
|
||||
# Create fresh release
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/releases" \
|
||||
-d "$(python3 -c "import json; print(json.dumps({
|
||||
'tag_name': '${RELEASE_TAG}',
|
||||
'name': '${RELEASE_NAME}',
|
||||
'body': '''## ${VERSION} ($(date +%Y-%m-%d))\n${NOTES}''',
|
||||
'target_commitish': '${BRANCH}'
|
||||
}))")"
|
||||
echo "Release created: ${RELEASE_NAME}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8: Build Joomla install ZIP + SHA-256 checksum ------------------
|
||||
- name: "Step 8: Build package and update checksum"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
REPO="${{ github.repository }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
|
||||
# All ZIPs upload to the major release tag (vXX)
|
||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "No release ${RELEASE_TAG} found — skipping ZIP upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find extension element name from manifest
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
||||
[ -z "$MANIFEST" ] && exit 0
|
||||
|
||||
# Reuse element from Step 5, with same fallback chain
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(sed -n 's/.*plugin="\([^"]*\)".*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
# ZIP name: type_folder_element-VERSION (e.g. plg_system_mokojgdpc-01.01.00.zip)
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
||||
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
|
||||
|
||||
# -- Build install packages from src/ ----------------------------
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/"; exit 0; }
|
||||
|
||||
# ZIP package (type-aware via moko-platform PHP API)
|
||||
php /tmp/moko-platform-api/cli/joomla_build.php --path . --version "${VERSION}" --output /tmp
|
||||
# Match the expected ZIP_NAME for upload
|
||||
BUILT_ZIP=$(ls /tmp/${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip 2>/dev/null | head -1 || true)
|
||||
if [ -n "$BUILT_ZIP" ] && [ "$BUILT_ZIP" != "/tmp/${ZIP_NAME}" ]; then
|
||||
mv "$BUILT_ZIP" "/tmp/${ZIP_NAME}"
|
||||
fi
|
||||
|
||||
# tar.gz package (flat source archive)
|
||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" --exclude='.ftpignore' --exclude='sftp-config*' --exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
||||
|
||||
ZIP_SIZE=$(stat -c%s "/tmp/${ZIP_NAME}" 2>/dev/null || stat -f%z "/tmp/${ZIP_NAME}" 2>/dev/null || echo "unknown")
|
||||
TAR_SIZE=$(stat -c%s "/tmp/${TAR_NAME}" 2>/dev/null || stat -f%z "/tmp/${TAR_NAME}" 2>/dev/null || echo "unknown")
|
||||
|
||||
# -- Calculate SHA-256 for both ----------------------------------
|
||||
SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# -- Delete existing assets with same name before uploading ------
|
||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
||||
for ASSET_NAME in "$ZIP_NAME" "$TAR_NAME"; do
|
||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
||||
import sys,json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '${ASSET_NAME}':
|
||||
print(a['id']); break
|
||||
" 2>/dev/null || true)
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# -- Upload both to release tag ----------------------------------
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${ZIP_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"/tmp/${TAR_NAME}" \
|
||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
||||
|
||||
# -- Update updates.xml with both download formats ---------------
|
||||
if [ -f "updates.xml" ]; then
|
||||
ZIP_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}"
|
||||
TAR_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${TAR_NAME}"
|
||||
|
||||
# Use Python to update only the stable entry's downloads + sha256
|
||||
export PY_ZIP_URL="$ZIP_URL" PY_TAR_URL="$TAR_URL" PY_SHA="$SHA256_ZIP"
|
||||
python3 << 'PYEOF'
|
||||
import re, os
|
||||
|
||||
with open("updates.xml") as f:
|
||||
content = f.read()
|
||||
|
||||
zip_url = os.environ["PY_ZIP_URL"]
|
||||
tar_url = os.environ["PY_TAR_URL"]
|
||||
sha = os.environ["PY_SHA"]
|
||||
|
||||
# Find the stable update block and replace its downloads + sha256
|
||||
def replace_stable(m):
|
||||
block = m.group(0)
|
||||
# Replace downloads block
|
||||
new_downloads = (
|
||||
" <downloads>\n"
|
||||
f" <downloadurl type=\"full\" format=\"zip\">{zip_url}</downloadurl>\n"
|
||||
" </downloads>"
|
||||
)
|
||||
block = re.sub(r' <downloads>.*?</downloads>', new_downloads, block, flags=re.DOTALL)
|
||||
# Add or replace sha256
|
||||
if '<sha256>' in block:
|
||||
block = re.sub(r' <sha256>.*?</sha256>', f' <sha256>{sha}</sha256>', block)
|
||||
else:
|
||||
block = block.replace('</downloads>', f'</downloads>\n <sha256>{sha}</sha256>')
|
||||
return block
|
||||
|
||||
content = re.sub(
|
||||
r' <update>.*?<tag>stable</tag>.*?</update>',
|
||||
replace_stable,
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
with open("updates.xml", "w") as f:
|
||||
f.write(content)
|
||||
PYEOF
|
||||
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git add updates.xml
|
||||
git commit -m "chore(release): ZIP + tar.gz for ${VERSION} [skip ci]" \
|
||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" || true
|
||||
git push || true
|
||||
|
||||
# Sync updates.xml to main via direct API (always runs — may be on version/XX branch)
|
||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}"
|
||||
|
||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
||||
"${API}/contents/updates.xml?ref=main" | jq -r '.sha // empty')
|
||||
|
||||
if [ -n "$FILE_SHA" ]; then
|
||||
CONTENT=$(base64 -w0 updates.xml)
|
||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/contents/updates.xml" \
|
||||
-d "$(jq -n \
|
||||
--arg content "$CONTENT" \
|
||||
--arg sha "$FILE_SHA" \
|
||||
--arg msg "chore: sync updates.xml ${VERSION} [skip ci]" \
|
||||
--arg branch "main" \
|
||||
'{content: $content, sha: $sha, message: $msg, branch: $branch}'
|
||||
)" > /dev/null 2>&1 \
|
||||
&& echo "updates.xml synced to main via API" \
|
||||
|| echo "WARNING: failed to sync updates.xml to main"
|
||||
else
|
||||
echo "WARNING: could not get updates.xml SHA from main"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "### Packages" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | Size | SHA-256 |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|---------|------|---------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${ZIP_NAME}\` | ${ZIP_SIZE} | \`${SHA256_ZIP}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| \`${TAR_NAME}\` | ${TAR_SIZE} | \`${SHA256_TAR}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | \`${RELEASE_TAG}\` | |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Download | [${ZIP_NAME}](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${ZIP_NAME}) |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 8b: Update release description with changelog + SHA ----------------
|
||||
- name: "Step 8b: Update release body with changelog and SHA"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
EXT_ELEMENT="${{ steps.updates.outputs.ext_element }}"
|
||||
EXT_TYPE="${{ steps.updates.outputs.ext_type }}"
|
||||
EXT_FOLDER="${{ steps.updates.outputs.ext_folder }}"
|
||||
|
||||
# Build TYPE_PREFIX to match Step 8's ZIP naming
|
||||
TYPE_PREFIX=""
|
||||
case "${EXT_TYPE}" in
|
||||
plugin) TYPE_PREFIX="plg_${EXT_FOLDER}_" ;;
|
||||
module) TYPE_PREFIX="mod_" ;;
|
||||
component) TYPE_PREFIX="com_" ;;
|
||||
template) TYPE_PREFIX="tpl_" ;;
|
||||
library) TYPE_PREFIX="lib_" ;;
|
||||
package) TYPE_PREFIX="pkg_" ;;
|
||||
esac
|
||||
ZIP_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.zip"
|
||||
TAR_NAME="${TYPE_PREFIX}${EXT_ELEMENT}-${VERSION}.tar.gz"
|
||||
|
||||
# Get SHA from the built files
|
||||
SHA256_ZIP=""
|
||||
[ -f "/tmp/${ZIP_NAME}" ] && SHA256_ZIP=$(sha256sum "/tmp/${ZIP_NAME}" | cut -d' ' -f1)
|
||||
SHA256_TAR=""
|
||||
[ -f "/tmp/${TAR_NAME}" ] && SHA256_TAR=$(sha256sum "/tmp/${TAR_NAME}" | cut -d' ' -f1)
|
||||
|
||||
# Extract latest changelog entry (strip the ## header to avoid duplicate)
|
||||
CHANGELOG=""
|
||||
if [ -f "CHANGELOG.md" ]; then
|
||||
CHANGELOG=$(sed -n "/^## \[*${VERSION}/,/^## \[*[0-9]/p" CHANGELOG.md | sed '$d' | sed '1d')
|
||||
[ -z "$CHANGELOG" ] && CHANGELOG=$(sed -n '/^## /,/^## /p' CHANGELOG.md | sed '$d' | sed '1d' | head -30)
|
||||
fi
|
||||
|
||||
# Build release body (single header, no duplicate from changelog)
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))\n\n"
|
||||
if [ -n "$CHANGELOG" ]; then
|
||||
BODY="${BODY}${CHANGELOG}\n\n"
|
||||
fi
|
||||
BODY="${BODY}---\n\n### Checksums\n\n"
|
||||
BODY="${BODY}| File | SHA-256 |\n|------|--------|\n"
|
||||
[ -n "$SHA256_ZIP" ] && BODY="${BODY}| \`${ZIP_NAME}\` | \`${SHA256_ZIP}\` |\n"
|
||||
[ -n "$SHA256_TAR" ] && BODY="${BODY}| \`${TAR_NAME}\` | \`${SHA256_TAR}\` |\n"
|
||||
|
||||
# Get release ID and update body
|
||||
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
||||
python3 -c "
|
||||
import json, urllib.request
|
||||
body = '''$(printf '%b' "$BODY")'''
|
||||
data = json.dumps({'body': body}).encode()
|
||||
req = urllib.request.Request(
|
||||
'${API_BASE}/releases/${RELEASE_ID}',
|
||||
data=data,
|
||||
headers={'Authorization': 'token ${{ secrets.GA_TOKEN }}', 'Content-Type': 'application/json'},
|
||||
method='PATCH'
|
||||
)
|
||||
urllib.request.urlopen(req)
|
||||
" 2>/dev/null && echo "Release body updated with changelog + SHA" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
|
||||
- name: "Step 9: Mirror release to GitHub"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.version.outputs.stability == 'stable' &&
|
||||
secrets.GH_TOKEN != ''
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
|
||||
MAJOR="${{ steps.version.outputs.major }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
|
||||
NOTES=$(php /tmp/moko-platform-api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null || true)
|
||||
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
|
||||
echo "$NOTES" > /tmp/release_notes.md
|
||||
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".tag_name // empty" || true)
|
||||
|
||||
if [ -z "$EXISTING" ]; then
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--repo "$GH_REPO" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
--target "$BRANCH" || true
|
||||
else
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--repo "$GH_REPO" \
|
||||
--title "v${MAJOR} (latest: ${VERSION})" || true
|
||||
fi
|
||||
|
||||
# Upload assets to GitHub mirror
|
||||
for PKG in /tmp/${EXT_ELEMENT:-pkg}-${VERSION}.*; do
|
||||
if [ -f "$PKG" ]; then
|
||||
_RELID=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" 2>/dev/null | jq -r ".id // empty")
|
||||
[ -n "$_RELID" ] && curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" -H "Content-Type: application/octet-stream" "${GITEA_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${{ github.repository }}/releases/${_RELID}/assets?name=$(basename $PKG)" --data-binary "@$PKG" > /dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
echo "GitHub mirror updated: ${GH_REPO} ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# -- STEP 10: Sync main branch to GitHub mirror ----------------------------
|
||||
- name: "Step 10: Push main to GitHub mirror"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
secrets.GH_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
|
||||
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
|
||||
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
|
||||
git remote add github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
|
||||
git remote set-url github "https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
|
||||
git fetch origin main --depth=1
|
||||
git push github origin/main:refs/heads/main --force 2>/dev/null \
|
||||
&& echo "main branch pushed to GitHub mirror" \
|
||||
|| echo "WARNING: GitHub mirror push failed"
|
||||
|
||||
# -- Clean up lesser pre-releases (cascade) ---------------------------------
|
||||
# stable → deletes all | rc → beta,alpha,dev | beta → alpha,dev | alpha → dev
|
||||
- name: "Delete lesser pre-release channels"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
php /tmp/moko-platform-api/cli/release_cascade.php \
|
||||
--stability stable \
|
||||
--token "${{ secrets.GA_TOKEN }}" \
|
||||
--org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
|
||||
--gitea-url "${GITEA_URL}" 2>/dev/null || true
|
||||
|
||||
- name: "Step 11: Delete and recreate dev branch from main"
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
# Delete dev branch
|
||||
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
|
||||
|
||||
# Recreate dev from main (now includes version bump + changelog promotion)
|
||||
curl -sf -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API_BASE}/branches" \
|
||||
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
|
||||
|
||||
echo "Dev branch reset from main (keeps dev ahead after release)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
|
||||
# -- Dolibarr post-release: Reset dev version -----------------------------
|
||||
- name: "Dolibarr: Reset dev version"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.platform.outputs.platform == 'dolibarr' &&
|
||||
steps.platform.outputs.mod_file != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
ENCODED_PATH=$(echo "$MOD_FILE" | sed 's|^\./||' | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read().strip()))")
|
||||
FILE_RESP=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/contents/${ENCODED_PATH}?ref=dev" 2>/dev/null || true)
|
||||
FILE_SHA=$(echo "$FILE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
||||
FILE_CONTENT=$(echo "$FILE_RESP" | python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin).get('content','')).decode())" 2>/dev/null || true)
|
||||
if [ -n "$FILE_SHA" ] && [ -n "$FILE_CONTENT" ]; then
|
||||
UPDATED=$(echo "$FILE_CONTENT" | sed "s/\$this->version = '[^']*'/\$this->version = 'development'/")
|
||||
ENCODED=$(echo "$UPDATED" | base64 -w0)
|
||||
curl -sf -X PUT -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/contents/${ENCODED_PATH}" \
|
||||
-d "$(jq -n --arg content \"$ENCODED\" --arg sha \"$FILE_SHA\" --arg msg \"chore(version): reset dev version [skip ci]\" --arg branch \"dev\" '{content:$content,sha:$sha,message:$msg,branch:$branch}')" > /dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# -- Summary --------------------------------------------------------------
|
||||
- name: Pipeline Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
|
||||
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
|
||||
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -183,18 +183,18 @@ jobs:
|
||||
sha = data["sha"]
|
||||
content = base64.b64decode(data["content"]).decode("utf-8")
|
||||
|
||||
# Update stable channel version, infourl, and docker tag
|
||||
content = re.sub(
|
||||
r"(<tags><tag>stable</tag></tags>[\s\S]*?<version>)[^<]*(</version>)",
|
||||
rf"\g<1>{moko_ver}\2", content)
|
||||
content = re.sub(
|
||||
r"(<tags><tag>stable</tag></tags>[\s\S]*?<infourl[^>]*>)[^<]*(</infourl>)",
|
||||
rf"\g<1>{release_url}\2", content)
|
||||
content = re.sub(
|
||||
r"(<tags><tag>stable</tag></tags>[\s\S]*?<downloadurl[^>]*>)[^<]*(</downloadurl>)",
|
||||
rf"\g<1>{docker_img}\2", content)
|
||||
# Update stable channel — match the <update> block containing <tag>stable</tag>
|
||||
def replace_channel(xml, channel, ver, url, docker):
|
||||
pattern = rf"(<update>\s*<name>MokoGitea</name>[\s\S]*?<tags><tag>{channel}</tag></tags>[\s\S]*?</update>)"
|
||||
def replacer(m):
|
||||
block = m.group(1)
|
||||
block = re.sub(r"<version>[^<]*</version>", f"<version>{ver}</version>", block)
|
||||
block = re.sub(r"(<infourl[^>]*>)[^<]*(</infourl>)", rf"\1{url}\2", block)
|
||||
block = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\1{docker}\2", block)
|
||||
return block
|
||||
return re.sub(pattern, replacer, xml)
|
||||
|
||||
# Also update VERSION comment at top
|
||||
content = replace_channel(content, "stable", moko_ver, release_url, docker_img)
|
||||
content = re.sub(r"VERSION: [^\n]*", f"VERSION: {moko_ver}", content)
|
||||
|
||||
# Push updated file
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: moko-platform.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
||||
# VERSION: 05.01.00
|
||||
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
|
||||
|
||||
name: "Universal: Pre-Release"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stability:
|
||||
description: 'Pre-release channel'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- development
|
||||
- alpha
|
||||
- beta
|
||||
- release-candidate
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
||||
runs-on: release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GA_TOKEN }}
|
||||
|
||||
- name: Setup tools
|
||||
run: |
|
||||
# Update moko-platform CLI tools if available; install PHP if missing
|
||||
if command -v moko-platform-update &> /dev/null; then
|
||||
moko-platform-update
|
||||
elif [ -d "/opt/moko-platform" ]; then
|
||||
cd /opt/moko-platform && git pull origin main --quiet 2>/dev/null || true
|
||||
else
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl >/dev/null 2>&1
|
||||
fi
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
|
||||
/tmp/moko-platform-api
|
||||
fi
|
||||
# Set MOKO_CLI to whichever path exists
|
||||
if [ -d "/opt/moko-platform/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Detect platform
|
||||
id: platform
|
||||
run: |
|
||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve metadata and bump version
|
||||
id: meta
|
||||
run: |
|
||||
STABILITY="${{ inputs.stability }}"
|
||||
|
||||
case "$STABILITY" in
|
||||
development) SUFFIX="-dev"; TAG="development" ;;
|
||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
||||
esac
|
||||
|
||||
# Patch bump via CLI tool
|
||||
php ${MOKO_CLI}/version_bump.php --path .
|
||||
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
|
||||
[ -z "$VERSION" ] && VERSION="00.00.01"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# Update platform-specific manifest
|
||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
||||
|
||||
php ${MOKO_CLI}/version_set_platform.php \
|
||||
--path . --version "$VERSION" --branch "${{ github.ref_name }}" 2>/dev/null || true
|
||||
|
||||
# Commit version bump
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
||||
git add -A
|
||||
git diff --cached --quiet || {
|
||||
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1
|
||||
}
|
||||
|
||||
# Auto-detect element (platform-aware)
|
||||
EXT_ELEMENT=""
|
||||
case "$PLATFORM" in
|
||||
joomla)
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
||||
case "$EXT_ELEMENT" in
|
||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
||||
esac
|
||||
fi
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
dolibarr)
|
||||
if [ -n "$MOD_FILE" ]; then
|
||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
||||
else
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
||||
;;
|
||||
esac
|
||||
|
||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
SOURCE_DIR="src"
|
||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "::error::No src/ or htdocs/ directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
||||
EXT_TYPE=""
|
||||
if [ -n "$MANIFEST" ]; then
|
||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
||||
fi
|
||||
|
||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
||||
|
||||
mkdir -p build/package
|
||||
|
||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
||||
[ ! -d "$ext_dir" ] && continue
|
||||
EXT_NAME=$(basename "$ext_dir")
|
||||
echo " Packaging sub-extension: ${EXT_NAME}"
|
||||
cd "$ext_dir"
|
||||
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
|
||||
cd "$OLDPWD"
|
||||
done
|
||||
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
|
||||
[ -f "$f" ] && cp "$f" build/package/
|
||||
done
|
||||
else
|
||||
echo "=== Building standard extension ==="
|
||||
rsync -a \
|
||||
--exclude='sftp-config*' \
|
||||
--exclude='.ftpignore' \
|
||||
--exclude='*.ppk' \
|
||||
--exclude='*.pem' \
|
||||
--exclude='*.key' \
|
||||
--exclude='.env*' \
|
||||
--exclude='*.local' \
|
||||
--exclude='.build-trigger' \
|
||||
"${SOURCE_DIR}/" build/package/
|
||||
fi
|
||||
|
||||
- name: Create ZIP
|
||||
id: zip
|
||||
run: |
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
cd build/package
|
||||
zip -r "../${ZIP_NAME}" .
|
||||
cd ..
|
||||
|
||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
||||
|
||||
- name: Create or replace Gitea release
|
||||
id: release
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
BRANCH=$(git branch --show-current)
|
||||
|
||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
||||
**Channel:** ${STABILITY}
|
||||
**SHA-256:** \`${SHA256}\`"
|
||||
|
||||
# Delete existing release
|
||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create release
|
||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg target "$BRANCH" \
|
||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
||||
)" | jq -r '.id')
|
||||
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Upload ZIP
|
||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
--data-binary "@build/${ZIP_NAME}"
|
||||
|
||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
||||
|
||||
- name: Update updates.xml
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
|
||||
if [ ! -f "updates.xml" ]; then
|
||||
echo "No updates.xml -- skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Map stability to XML tag name
|
||||
case "$STABILITY" in
|
||||
development) XML_TAG="development" ;;
|
||||
alpha) XML_TAG="alpha" ;;
|
||||
beta) XML_TAG="beta" ;;
|
||||
release-candidate) XML_TAG="rc" ;;
|
||||
*) XML_TAG="$STABILITY" ;;
|
||||
esac
|
||||
|
||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${TAG}/${ZIP_NAME}"
|
||||
|
||||
# Use PHP to update the channel in updates.xml
|
||||
php -r '
|
||||
$xml_tag = $argv[1];
|
||||
$version = $argv[2];
|
||||
$sha256 = $argv[3];
|
||||
$url = $argv[4];
|
||||
$date = date("Y-m-d");
|
||||
|
||||
$content = file_get_contents("updates.xml");
|
||||
$pattern = "/(<update>(?:(?!<\/update>).)*?<tag>" . preg_quote($xml_tag) . "<\/tag>.*?<\/update>)/s";
|
||||
|
||||
$content = preg_replace_callback($pattern, function($m) use ($version, $sha256, $url, $date) {
|
||||
$block = $m[0];
|
||||
$block = preg_replace("/<version>[^<]*<\/version>/", "<version>{$version}</version>", $block);
|
||||
if (strpos($block, "<sha256>") !== false) {
|
||||
$block = preg_replace("/<sha256>[^<]*<\/sha256>/", "<sha256>{$sha256}</sha256>", $block);
|
||||
} else {
|
||||
$block = str_replace("</downloads>", "</downloads>\n <sha256>{$sha256}</sha256>", $block);
|
||||
}
|
||||
$block = preg_replace("/(<downloadurl[^>]*>)[^<]*(<\/downloadurl>)/", "\${1}{$url}\${2}", $block);
|
||||
return $block;
|
||||
}, $content);
|
||||
|
||||
file_put_contents("updates.xml", $content);
|
||||
echo "Updated {$xml_tag} channel: version={$version}\n";
|
||||
' "$XML_TAG" "$VERSION" "$SHA256" "$DOWNLOAD_URL"
|
||||
|
||||
# Commit and push
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
git add updates.xml
|
||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
||||
fi
|
||||
|
||||
- name: "Sync updates.xml to all branches"
|
||||
if: steps.platform.outputs.platform == 'joomla'
|
||||
run: |
|
||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
||||
git config --local user.name "gitea-actions[bot]"
|
||||
|
||||
for BRANCH in main dev; do
|
||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
||||
echo "Syncing updates.xml -> ${BRANCH}"
|
||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
||||
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
|
||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
||||
git add updates.xml
|
||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
||||
fi
|
||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
||||
done
|
||||
|
||||
- name: "Delete lesser pre-release channels (cascade)"
|
||||
continue-on-error: true
|
||||
run: |
|
||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
||||
|
||||
php ${MOKO_CLI}/release_cascade.php \
|
||||
--stability "${{ steps.meta.outputs.stability }}" \
|
||||
--token "${TOKEN}" \
|
||||
--api-base "${API_BASE}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
||||
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -410,6 +410,7 @@ func prepareMigrationTasks() []*migration {
|
||||
|
||||
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
|
||||
newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable),
|
||||
newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddRequire2FAToUser(x *xorm.Engine) error {
|
||||
type User struct {
|
||||
Require2FA bool `xorm:"NOT NULL DEFAULT false"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(User))
|
||||
return err
|
||||
}
|
||||
@@ -117,6 +117,9 @@ type User struct {
|
||||
// Maximum repository creation limit, -1 means use global default
|
||||
MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1"`
|
||||
|
||||
// Require2FA when true (and user is an org), all org members must have 2FA enabled
|
||||
Require2FA bool `xorm:"NOT NULL DEFAULT false"`
|
||||
|
||||
// IsActive true: primary email is activated, user can access Web UI and Git SSH.
|
||||
// false: an inactive user can only log in Web UI for account operations (ex: activate the account by email), no other access.
|
||||
IsActive bool `xorm:"INDEX"`
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestFile(t *testing.T) {
|
||||
{
|
||||
name: "tags.py",
|
||||
code: "<>",
|
||||
want: lines(`<span class="o"><</span><span class="o">></span>`),
|
||||
want: lines(`<span class="o"><></span>`),
|
||||
lexerName: "Python",
|
||||
},
|
||||
{
|
||||
@@ -102,7 +102,7 @@ c=2
|
||||
<span class="n">def</span><span class="p">:</span>\n
|
||||
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>\n
|
||||
\n
|
||||
<span class="n">b</span><span class="o">=</span><span class="sa"></span><span class="s1">'</span><span class="s1">'</span>\n
|
||||
<span class="n">b</span><span class="o">=</span><span class="s1">''</span>\n
|
||||
\n
|
||||
<span class="n">c</span><span class="o">=</span><span class="mi">2</span>`,
|
||||
),
|
||||
@@ -114,6 +114,18 @@ c=2
|
||||
want: []template.HTML{"<span class=\"c1\">--\n</span>", `<span class="k">SELECT</span>`},
|
||||
lexerName: "SQL",
|
||||
},
|
||||
{
|
||||
name: "test.http",
|
||||
code: `HTTP/1.0 400 Bad request
|
||||
Content-Type: text/html
|
||||
|
||||
<html></html>`,
|
||||
want: lines(`<span class="kr">HTTP</span><span class="o">/</span><span class="m">1.0</span> <span class="m">400</span> <span class="ne">Bad request</span>\n
|
||||
<span class="n">Content-Type</span><span class="o">:</span> <span class="l">text/html</span>\n
|
||||
\n
|
||||
<span class="p"><</span><span class="nt">html</span><span class="p">></</span><span class="nt">html</span><span class="p">></span>`),
|
||||
lexerName: "HTTP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -288,24 +288,24 @@ func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lex
|
||||
|
||||
// if lang is provided, and it matches a lexer, use it directly
|
||||
if byLang {
|
||||
return lexer
|
||||
return chroma.Coalesce(lexer)
|
||||
}
|
||||
|
||||
// if a lexer is detected and there is no conflict for the file extension, use it directly
|
||||
fileExt := path.Ext(fileName)
|
||||
_, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt]
|
||||
if !hasConflicts && lexer != lexers.Fallback {
|
||||
return lexer
|
||||
return chroma.Coalesce(lexer)
|
||||
}
|
||||
|
||||
// try to detect language by content, for best guessing for the language
|
||||
// when using "code" to detect, analyze.GetCodeLanguage is slow, it iterates many rules to detect language from content
|
||||
analyzedLanguage := analyze.GetCodeLanguage(fileName, code)
|
||||
lexer = DetectChromaLexerByFileName(fileName, analyzedLanguage)
|
||||
lexer, _ = detectChromaLexerByFileName(fileName, analyzedLanguage)
|
||||
if lexer == lexers.Fallback {
|
||||
if analyzedLanguage != enry.OtherLanguage {
|
||||
log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName)
|
||||
}
|
||||
}
|
||||
return lexer
|
||||
return chroma.Coalesce(lexer)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ var (
|
||||
Channel: "stable",
|
||||
}
|
||||
|
||||
// LoginNotification configuration for sign-in alerts
|
||||
LoginNotification = struct {
|
||||
Enabled bool
|
||||
}{
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
// IsInTesting indicates whether the testing is running (unit test or integration test). It can be used for:
|
||||
// * Skip nonsense error logs during testing caused by unreliable code (TODO: this is only a temporary solution, we should make the test code more reliable)
|
||||
// * Panic in dev or testing mode to make the problem more obvious and easier to debug
|
||||
@@ -171,6 +178,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
||||
loadOtherFrom(cfg)
|
||||
loadUpdateCheckerFrom(cfg)
|
||||
loadNtfyFrom(cfg)
|
||||
loadLoginNotificationFrom(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -181,6 +189,11 @@ func loadUpdateCheckerFrom(cfg ConfigProvider) {
|
||||
UpdateChecker.Channel = sec.Key("CHANNEL").MustString(UpdateChecker.Channel)
|
||||
}
|
||||
|
||||
func loadLoginNotificationFrom(cfg ConfigProvider) {
|
||||
sec := cfg.Section("login_notification")
|
||||
LoginNotification.Enabled = sec.Key("ENABLED").MustBool(true)
|
||||
}
|
||||
|
||||
func loadRunModeFrom(rootCfg ConfigProvider) {
|
||||
rootSec := rootCfg.Section("")
|
||||
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
|
||||
|
||||
@@ -29,12 +29,69 @@ func Branding(ctx *context.Context) {
|
||||
|
||||
imgDir := brandingImageDir()
|
||||
ctx.Data["HasNavIcon"] = fileExists(filepath.Join(imgDir, "logo-small.png"))
|
||||
ctx.Data["HasLogo"] = fileExists(filepath.Join(imgDir, "logo.png"))
|
||||
ctx.Data["HasLoginLogo"] = fileExists(filepath.Join(imgDir, "login-logo.png"))
|
||||
ctx.Data["HasFavicon"] = fileExists(filepath.Join(imgDir, "favicon.png"))
|
||||
|
||||
ctx.Data["MetaDescription"] = setting.UI.Meta.Description
|
||||
ctx.Data["MetaAuthor"] = setting.UI.Meta.Author
|
||||
ctx.Data["HelpURL"] = setting.HelpURL
|
||||
ctx.Data["SupportURL"] = setting.SupportURL
|
||||
|
||||
ctx.HTML(http.StatusOK, tplBranding)
|
||||
}
|
||||
|
||||
// BrandingSettings handles the text branding form submission.
|
||||
func BrandingSettings(ctx *context.Context) {
|
||||
appName := ctx.FormString("app_name")
|
||||
description := ctx.FormString("description")
|
||||
helpURL := ctx.FormString("help_url")
|
||||
supportURL := ctx.FormString("support_url")
|
||||
author := ctx.FormString("author")
|
||||
|
||||
// Update in-memory settings
|
||||
if appName != "" {
|
||||
setting.AppName = appName
|
||||
}
|
||||
if description != "" {
|
||||
setting.UI.Meta.Description = description
|
||||
}
|
||||
setting.HelpURL = helpURL
|
||||
setting.SupportURL = supportURL
|
||||
if author != "" {
|
||||
setting.UI.Meta.Author = author
|
||||
}
|
||||
|
||||
// Persist to app.ini
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
ctx.Flash.Error("Failed to load config: " + err.Error())
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/branding")
|
||||
return
|
||||
}
|
||||
|
||||
if appName != "" {
|
||||
cfg.Section("").Key("APP_NAME").SetValue(appName)
|
||||
}
|
||||
if description != "" {
|
||||
cfg.Section("ui.meta").Key("DESCRIPTION").SetValue(description)
|
||||
}
|
||||
cfg.Section("").Key("HELP_URL").SetValue(helpURL)
|
||||
cfg.Section("").Key("SUPPORT_URL").SetValue(supportURL)
|
||||
if author != "" {
|
||||
cfg.Section("ui.meta").Key("AUTHOR").SetValue(author)
|
||||
}
|
||||
|
||||
if err := cfg.SaveTo(setting.CustomConf); err != nil {
|
||||
ctx.Flash.Error("Failed to save config: " + err.Error())
|
||||
log.Error("SaveTo %s: %v", setting.CustomConf, err)
|
||||
} else {
|
||||
ctx.Flash.Success("Branding settings saved")
|
||||
log.Info("Branding settings updated: AppName=%s, Author=%s", appName, author)
|
||||
}
|
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/branding")
|
||||
}
|
||||
|
||||
// BrandingUpload handles branding image uploads.
|
||||
func BrandingUpload(ctx *context.Context) {
|
||||
imageType := ctx.FormString("type")
|
||||
@@ -48,8 +105,8 @@ func BrandingUpload(ctx *context.Context) {
|
||||
switch imageType {
|
||||
case "nav-icon":
|
||||
filename = "logo-small.png"
|
||||
case "logo":
|
||||
filename = "logo.png"
|
||||
case "login-logo":
|
||||
filename = "login-logo.png"
|
||||
case "favicon":
|
||||
filename = "favicon.png"
|
||||
default:
|
||||
@@ -66,14 +123,12 @@ func BrandingUpload(ctx *context.Context) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate file size (max 2MB)
|
||||
if header.Size > 2*1024*1024 {
|
||||
ctx.Flash.Error("File too large (max 2MB)")
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/branding")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the custom image directory exists
|
||||
imgDir := brandingImageDir()
|
||||
if err := os.MkdirAll(imgDir, 0o755); err != nil {
|
||||
ctx.Flash.Error("Failed to create image directory")
|
||||
@@ -82,7 +137,6 @@ func BrandingUpload(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Write the file
|
||||
destPath := filepath.Join(imgDir, filename)
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
@@ -100,11 +154,10 @@ func BrandingUpload(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Also remove SVG override if present (PNG should take priority)
|
||||
// Remove SVG override if present
|
||||
svgPath := filepath.Join(imgDir, filename[:len(filename)-4]+".svg")
|
||||
if fileExists(svgPath) {
|
||||
os.Remove(svgPath)
|
||||
log.Info("Removed SVG override: %s", svgPath)
|
||||
}
|
||||
|
||||
ctx.Flash.Success("Branding image updated: " + imageType)
|
||||
@@ -112,6 +165,40 @@ func BrandingUpload(ctx *context.Context) {
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/branding")
|
||||
}
|
||||
|
||||
// BrandingReset removes a custom branding image, reverting to the built-in default.
|
||||
func BrandingReset(ctx *context.Context) {
|
||||
imageType := ctx.FormString("type")
|
||||
|
||||
var filename string
|
||||
switch imageType {
|
||||
case "nav-icon":
|
||||
filename = "logo-small.png"
|
||||
case "login-logo":
|
||||
filename = "login-logo.png"
|
||||
case "favicon":
|
||||
filename = "favicon.png"
|
||||
default:
|
||||
ctx.Flash.Error("Invalid image type")
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/branding")
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(brandingImageDir(), filename)
|
||||
if fileExists(path) {
|
||||
if err := os.Remove(path); err != nil {
|
||||
ctx.Flash.Error("Failed to remove custom image")
|
||||
log.Error("Remove %s: %v", path, err)
|
||||
} else {
|
||||
ctx.Flash.Success("Reset to default: " + imageType)
|
||||
log.Info("Branding reset to default: %s", filename)
|
||||
}
|
||||
} else {
|
||||
ctx.Flash.Info("Already using default: " + imageType)
|
||||
}
|
||||
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/branding")
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
|
||||
@@ -440,6 +440,9 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
ctx.ServerError("UpdateUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send login notification (email + ntfy)
|
||||
go mailer.SendLoginNotification(u, ctx.RemoteAddr(), ctx.Req.UserAgent())
|
||||
}
|
||||
|
||||
// extractUserNameFromOAuth2 tries to extract a normalized username from the given OAuth2 user.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
auth_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// Check2FARequirement checks if the current org requires 2FA and if the user has it enabled.
|
||||
// If the user doesn't have 2FA and the org requires it, redirect to 2FA setup page.
|
||||
func Check2FARequirement(ctx *context.Context) {
|
||||
if ctx.Org == nil || ctx.Org.Organization == nil || ctx.Doer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Org.Organization.Require2FA {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has 2FA enabled
|
||||
has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("HasTwoFactorOrWebAuthn", err)
|
||||
return
|
||||
}
|
||||
|
||||
if has {
|
||||
return
|
||||
}
|
||||
|
||||
// User doesn't have 2FA — show warning and redirect to settings
|
||||
ctx.Flash.Warning("This organization requires two-factor authentication. Please enable 2FA to continue.")
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||
}
|
||||
@@ -80,12 +80,14 @@ func SettingsPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
require2FA := ctx.FormBool("require_2fa")
|
||||
opts := &user_service.UpdateOptions{
|
||||
FullName: optional.FromPtr(form.FullName),
|
||||
Description: optional.FromPtr(form.Description),
|
||||
Website: optional.FromPtr(form.Website),
|
||||
Location: optional.FromPtr(form.Location),
|
||||
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
|
||||
Require2FA: optional.Some(require2FA),
|
||||
}
|
||||
if ctx.Doer.IsAdmin {
|
||||
opts.MaxRepoCreation = optional.FromPtr(form.MaxRepoCreation)
|
||||
|
||||
+3
-1
@@ -768,6 +768,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Group("/branding", func() {
|
||||
m.Get("", admin.Branding)
|
||||
m.Post("/upload", admin.BrandingUpload)
|
||||
m.Post("/settings", admin.BrandingSettings)
|
||||
m.Get("/reset", admin.BrandingReset)
|
||||
})
|
||||
|
||||
m.Group("/monitor", func() {
|
||||
@@ -958,7 +960,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones)
|
||||
m.Post("/members/action/{action}", org.MembersAction)
|
||||
m.Get("/teams", org.Teams)
|
||||
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}))
|
||||
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}), org.Check2FARequirement)
|
||||
|
||||
m.Group("/{org}", func() {
|
||||
m.Get("/teams/{team}", org.TeamMembers)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
|
||||
sender_service "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mailer/sender"
|
||||
)
|
||||
|
||||
// SendLoginNotification sends email and ntfy notifications when a user signs in.
|
||||
func SendLoginNotification(u *user_model.User, ip, userAgent string) {
|
||||
if !setting.LoginNotification.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().UTC().Format("2006-01-02 15:04:05 UTC")
|
||||
subject := fmt.Sprintf("[%s] New sign-in: %s", setting.AppName, u.Name)
|
||||
|
||||
body := fmt.Sprintf(`New sign-in detected
|
||||
|
||||
Account: %s (%s)
|
||||
IP Address: %s
|
||||
Browser: %s
|
||||
Time: %s
|
||||
Instance: %s
|
||||
|
||||
If this wasn't you, change your password immediately and review your active sessions.
|
||||
|
||||
— %s`, u.Name, u.Email, ip, userAgent, timestamp, setting.AppURL, setting.AppName)
|
||||
|
||||
// Email notification
|
||||
if setting.MailService != nil && u.Email != "" {
|
||||
msg := sender_service.NewMessage(u.EmailTo(), subject, body)
|
||||
msg.Info = fmt.Sprintf("Login notification for %s", u.Name)
|
||||
SendAsync(msg)
|
||||
log.Debug("Login notification email sent to %s", u.Email)
|
||||
}
|
||||
|
||||
// ntfy push notification
|
||||
if setting.Ntfy.Enabled && setting.Ntfy.ServerURL != "" {
|
||||
go sendLoginNtfy(subject, u.Name, ip, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func sendLoginNtfy(title, username, ip, timestamp string) {
|
||||
body := fmt.Sprintf("User: %s\nIP: %s\nTime: %s", username, ip, timestamp)
|
||||
url := fmt.Sprintf("%s/%s", setting.Ntfy.ServerURL, setting.Ntfy.DefaultTopic)
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
log.Error("ntfy login: create request: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Title", title)
|
||||
req.Header.Set("Priority", "default")
|
||||
req.Header.Set("Tags", "key,login")
|
||||
req.Header.Set("Click", setting.AppURL+"-/admin")
|
||||
if setting.Ntfy.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+setting.Ntfy.Token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Error("ntfy login: send: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
log.Error("ntfy login: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ type UpdateOptions struct {
|
||||
EmailNotificationsPreference optional.Option[string]
|
||||
SetLastLogin bool
|
||||
RepoAdminChangeTeamAccess optional.Option[bool]
|
||||
Require2FA optional.Option[bool]
|
||||
}
|
||||
|
||||
func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error {
|
||||
@@ -169,6 +170,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
|
||||
|
||||
cols = append(cols, "repo_admin_change_team_access")
|
||||
}
|
||||
if opts.Require2FA.Has() {
|
||||
u.Require2FA = opts.Require2FA.Value()
|
||||
|
||||
cols = append(cols, "require_2fa")
|
||||
}
|
||||
|
||||
if opts.EmailNotificationsPreference.Has() {
|
||||
u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value()
|
||||
|
||||
@@ -6,6 +6,7 @@ package wiki
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
@@ -148,10 +149,24 @@ func WebPathFromRequest(s string) WebPath {
|
||||
return WebPath(s)
|
||||
}
|
||||
|
||||
var multiHyphenRe = regexp.MustCompile(`-{2,}`)
|
||||
var nonAlphanumRe = regexp.MustCompile(`[^a-zA-Z0-9\-]`)
|
||||
|
||||
// sanitizeWikiTitle converts a user-provided title into a clean, URL-friendly slug.
|
||||
// Spaces and special characters become hyphens, consecutive hyphens collapse to one.
|
||||
func sanitizeWikiTitle(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
title = strings.ReplaceAll(title, " ", "-")
|
||||
title = nonAlphanumRe.ReplaceAllString(title, "-")
|
||||
title = multiHyphenRe.ReplaceAllString(title, "-")
|
||||
title = strings.Trim(title, "-")
|
||||
return title
|
||||
}
|
||||
|
||||
func UserTitleToWebPath(base, title string) WebPath {
|
||||
// TODO: no support for subdirectory, because the old wiki code's behavior is always using %2F, instead of subdirectory.
|
||||
// So we do not add the support for writing slashes in title at the moment.
|
||||
title = strings.TrimSpace(title)
|
||||
title = sanitizeWikiTitle(title)
|
||||
title = util.PathJoinRelX(base, escapeSegToWeb(title, false))
|
||||
if title == "" || title == "." {
|
||||
title = "unnamed"
|
||||
|
||||
+104
-58
@@ -4,67 +4,113 @@
|
||||
{{svg "octicon-paintbrush" 16}} Branding
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<p>Upload custom branding images. Changes take effect immediately.</p>
|
||||
|
||||
<!-- Nav Icon -->
|
||||
<div class="ui segment">
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-mb-4">
|
||||
<img src="{{AssetUrlPrefix}}/img/logo-small.png?v={{ctx.CspScriptNonce}}" style="width: 48px; height: 48px; object-fit: contain;" onerror="this.src='{{AssetUrlPrefix}}/img/logo.png'">
|
||||
<div>
|
||||
<strong>Nav Icon</strong>
|
||||
<div class="tw-text-text-light">Top-left corner, 30x30px recommended</div>
|
||||
</div>
|
||||
{{if .HasNavIcon}}<span class="ui green label">Custom</span>{{else}}<span class="ui grey label">Default</span>{{end}}
|
||||
</div>
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/branding/upload" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="type" value="nav-icon">
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<input type="file" name="file" accept="image/png,image/svg+xml" required>
|
||||
<button type="submit" class="ui primary small button">{{svg "octicon-upload" 14}} Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Text Branding -->
|
||||
<h5>Identity</h5>
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/branding/settings">
|
||||
{{.CsrfTokenHtml}}
|
||||
<table class="ui very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 30%;"><strong>Application Name</strong><div class="tw-text-text-light tw-text-sm">Shown in page titles, emails, and footer</div></td>
|
||||
<td><input type="text" name="app_name" value="{{AppName}}" class="tw-w-full" placeholder="MokoGitea"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Description</strong><div class="tw-text-text-light tw-text-sm">Meta description for SEO and social sharing</div></td>
|
||||
<td><input type="text" name="description" value="{{.MetaDescription}}" class="tw-w-full" placeholder="Self-hosted Git service"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Help URL</strong><div class="tw-text-text-light tw-text-sm">Knowledge base or documentation link shown in help menus</div></td>
|
||||
<td><input type="text" name="help_url" value="{{.HelpURL}}" class="tw-w-full" placeholder="https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/wiki"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Support URL</strong><div class="tw-text-text-light tw-text-sm">Ticket system, email, or contact page for user support requests</div></td>
|
||||
<td><input type="text" name="support_url" value="{{.SupportURL}}" class="tw-w-full" placeholder="https://mokoconsulting.tech/support"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Author</strong><div class="tw-text-text-light tw-text-sm">Meta author tag</div></td>
|
||||
<td><input type="text" name="author" value="{{.MetaAuthor}}" class="tw-w-full" placeholder="Moko Consulting"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="submit" class="ui primary small button tw-mt-2">{{svg "octicon-check" 14}} Save Settings</button>
|
||||
</form>
|
||||
|
||||
<!-- Login Logo -->
|
||||
<div class="ui segment">
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-mb-4">
|
||||
<img src="{{AssetUrlPrefix}}/img/logo.png?v={{ctx.CspScriptNonce}}" style="max-width: 120px; max-height: 48px; object-fit: contain;">
|
||||
<div>
|
||||
<strong>Login Logo</strong>
|
||||
<div class="tw-text-text-light">Login page and homepage, wide format recommended</div>
|
||||
</div>
|
||||
{{if .HasLogo}}<span class="ui green label">Custom</span>{{else}}<span class="ui grey label">Default</span>{{end}}
|
||||
</div>
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/branding/upload" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="type" value="logo">
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<input type="file" name="file" accept="image/png,image/svg+xml" required>
|
||||
<button type="submit" class="ui primary small button">{{svg "octicon-upload" 14}} Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
|
||||
<!-- Favicon -->
|
||||
<div class="ui segment">
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-mb-4">
|
||||
<img src="{{AssetUrlPrefix}}/img/favicon.png?v={{ctx.CspScriptNonce}}" style="width: 48px; height: 48px; object-fit: contain;">
|
||||
<div>
|
||||
<strong>Favicon</strong>
|
||||
<div class="tw-text-text-light">Browser tab icon, 256x256px recommended</div>
|
||||
</div>
|
||||
{{if .HasFavicon}}<span class="ui green label">Custom</span>{{else}}<span class="ui grey label">Default</span>{{end}}
|
||||
</div>
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/branding/upload" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="type" value="favicon">
|
||||
<div class="tw-flex tw-gap-2">
|
||||
<input type="file" name="file" accept="image/png,image/svg+xml,image/x-icon" required>
|
||||
<button type="submit" class="ui primary small button">{{svg "octicon-upload" 14}} Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Image Branding -->
|
||||
<h5>Images</h5>
|
||||
<p class="tw-text-text-light tw-text-sm">Changes take effect immediately.</p>
|
||||
<table class="ui very basic table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30%;">Setting</th>
|
||||
<th style="width: 40%;">Upload</th>
|
||||
<th style="width: 30%;">Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Nav Icon -->
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Nav Icon</strong> {{if .HasNavIcon}}<span class="ui mini green label">Custom</span>{{else}}<span class="ui mini grey label">Default</span>{{end}}
|
||||
<div class="tw-text-text-light tw-text-sm tw-mt-1">Top-left corner across all pages. Square, 30x30px.</div>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/branding/upload" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="type" value="nav-icon">
|
||||
<input type="file" name="file" accept="image/png,image/svg+xml" required class="tw-mb-2" style="max-width: 100%;">
|
||||
<br><button type="submit" class="ui primary mini button">{{svg "octicon-upload" 12}} Upload</button>
|
||||
{{if .HasNavIcon}}<a href="{{AppSubUrl}}/-/admin/branding/reset?type=nav-icon" class="ui mini button tw-ml-2">{{svg "octicon-sync" 12}} Reset</a>{{end}}
|
||||
</form>
|
||||
</td>
|
||||
<td class="tw-text-center" style="background: var(--color-secondary); border-radius: var(--border-radius);">
|
||||
<img src="{{AssetUrlPrefix}}/img/logo-small.png?v={{ctx.CspScriptNonce}}" style="max-height: 48px; max-width: 48px; object-fit: contain;" onerror="this.src='{{AssetUrlPrefix}}/img/logo.png'">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Login Logo -->
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Login Logo</strong> {{if .HasLoginLogo}}<span class="ui mini green label">Custom</span>{{else}}<span class="ui mini grey label">None</span>{{end}}
|
||||
<div class="tw-text-text-light tw-text-sm tw-mt-1">Login page and homepage. Wide format, max 220px. Hidden when not set.</div>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/branding/upload" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="type" value="login-logo">
|
||||
<input type="file" name="file" accept="image/png,image/svg+xml" required class="tw-mb-2" style="max-width: 100%;">
|
||||
<br><button type="submit" class="ui primary mini button">{{svg "octicon-upload" 12}} Upload</button>
|
||||
{{if .HasLoginLogo}}<a href="{{AppSubUrl}}/-/admin/branding/reset?type=login-logo" class="ui mini button tw-ml-2">{{svg "octicon-sync" 12}} Reset</a>{{end}}
|
||||
</form>
|
||||
</td>
|
||||
<td class="tw-text-center" style="background: var(--color-secondary); border-radius: var(--border-radius);">
|
||||
{{if .HasLoginLogo}}<img src="{{AssetUrlPrefix}}/img/login-logo.png?v={{ctx.CspScriptNonce}}" style="max-height: 48px; max-width: 140px; object-fit: contain;">{{else}}<span class="tw-text-text-light">Not set</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Favicon -->
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Favicon</strong> {{if .HasFavicon}}<span class="ui mini green label">Custom</span>{{else}}<span class="ui mini grey label">Default</span>{{end}}
|
||||
<div class="tw-text-text-light tw-text-sm tw-mt-1">Browser tab and PWA app icon. Square, 256x256px.</div>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/branding/upload" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="type" value="favicon">
|
||||
<input type="file" name="file" accept="image/png,image/svg+xml,image/x-icon" required class="tw-mb-2" style="max-width: 100%;">
|
||||
<br><button type="submit" class="ui primary mini button">{{svg "octicon-upload" 12}} Upload</button>
|
||||
{{if .HasFavicon}}<a href="{{AppSubUrl}}/-/admin/branding/reset?type=favicon" class="ui mini button tw-ml-2">{{svg "octicon-sync" 12}} Reset</a>{{end}}
|
||||
</form>
|
||||
</td>
|
||||
<td class="tw-text-center" style="background: var(--color-secondary); border-radius: var(--border-radius);">
|
||||
<img src="{{AssetUrlPrefix}}/img/favicon.png?v={{ctx.CspScriptNonce}}" style="max-height: 48px; max-width: 48px; object-fit: contain;">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{template "admin/layout_footer" .}}
|
||||
|
||||
+29
-29
@@ -3,83 +3,83 @@
|
||||
<div class="header item">{{ctx.Locale.Tr "admin.settings"}}</div>
|
||||
|
||||
<details class="item toggleable-item" {{if or .PageIsAdminDashboard .PageIsAdminSelfCheck}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.maintenance"}}</summary>
|
||||
<summary>{{svg "octicon-tools" 16}} {{ctx.Locale.Tr "admin.maintenance"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsAdminDashboard}}active {{end}}item" href="{{AppSubUrl}}/-/admin">
|
||||
{{ctx.Locale.Tr "admin.dashboard"}}
|
||||
{{svg "octicon-meter" 16}} {{ctx.Locale.Tr "admin.dashboard"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminSelfCheck}}active {{end}}item" href="{{AppSubUrl}}/-/admin/self_check">
|
||||
{{ctx.Locale.Tr "admin.self_check"}}
|
||||
{{svg "octicon-check-circle" 16}} {{ctx.Locale.Tr "admin.self_check"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminBadges .PageIsAdminEmails .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.identity_access"}}</summary>
|
||||
<summary>{{svg "octicon-people" 16}} {{ctx.Locale.Tr "admin.identity_access"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsAdminAuthentications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/auths">
|
||||
{{ctx.Locale.Tr "admin.authentication"}}
|
||||
{{svg "octicon-shield-lock" 16}} {{ctx.Locale.Tr "admin.authentication"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminOrganizations}}active {{end}}item" href="{{AppSubUrl}}/-/admin/orgs">
|
||||
{{ctx.Locale.Tr "admin.organizations"}}
|
||||
{{svg "octicon-organization" 16}} {{ctx.Locale.Tr "admin.organizations"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminUsers}}active {{end}}item" href="{{AppSubUrl}}/-/admin/users">
|
||||
{{ctx.Locale.Tr "admin.users"}}
|
||||
{{svg "octicon-person" 16}} {{ctx.Locale.Tr "admin.users"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminBadges}}active {{end}}item" href="{{AppSubUrl}}/-/admin/badges">
|
||||
{{ctx.Locale.Tr "admin.badges"}}
|
||||
{{svg "octicon-id-badge" 16}} {{ctx.Locale.Tr "admin.badges"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminEmails}}active {{end}}item" href="{{AppSubUrl}}/-/admin/emails">
|
||||
{{ctx.Locale.Tr "admin.emails"}}
|
||||
{{svg "octicon-mail" 16}} {{ctx.Locale.Tr "admin.emails"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<details class="item toggleable-item" {{if or .PageIsAdminRepositories (and .EnablePackages .PageIsAdminPackages)}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.assets"}}</summary>
|
||||
<summary>{{svg "octicon-database" 16}} {{ctx.Locale.Tr "admin.assets"}}</summary>
|
||||
<div class="menu">
|
||||
{{if .EnablePackages}}
|
||||
<a class="{{if .PageIsAdminPackages}}active {{end}}item" href="{{AppSubUrl}}/-/admin/packages">
|
||||
{{ctx.Locale.Tr "packages.title"}}
|
||||
{{svg "octicon-package" 16}} {{ctx.Locale.Tr "packages.title"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsAdminRepositories}}active {{end}}item" href="{{AppSubUrl}}/-/admin/repos">
|
||||
{{ctx.Locale.Tr "admin.repositories"}}
|
||||
{{svg "octicon-repo" 16}} {{ctx.Locale.Tr "admin.repositories"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<!-- Webhooks and OAuth can be both disabled here, so add this if statement to display different ui -->
|
||||
{{if and (not DisableWebhooks) .EnableOAuth2}}
|
||||
<details class="item toggleable-item" {{if or .PageIsAdminDefaultHooks .PageIsAdminSystemHooks .PageIsAdminApplications}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.integrations"}}</summary>
|
||||
<summary>{{svg "octicon-plug" 16}} {{ctx.Locale.Tr "admin.integrations"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsAdminApplications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/applications">
|
||||
{{ctx.Locale.Tr "settings.applications"}}
|
||||
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "settings.applications"}}
|
||||
</a>
|
||||
<a class="{{if or .PageIsAdminDefaultHooks .PageIsAdminSystemHooks}}active {{end}}item" href="{{AppSubUrl}}/-/admin/hooks">
|
||||
{{ctx.Locale.Tr "admin.hooks"}}
|
||||
{{svg "octicon-webhook" 16}} {{ctx.Locale.Tr "admin.hooks"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
{{else}}
|
||||
{{if not DisableWebhooks}}
|
||||
<a class="{{if or .PageIsAdminDefaultHooks .PageIsAdminSystemHooks}}active {{end}}item" href="{{AppSubUrl}}/-/admin/hooks">
|
||||
{{ctx.Locale.Tr "admin.hooks"}}
|
||||
{{svg "octicon-webhook" 16}} {{ctx.Locale.Tr "admin.hooks"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .EnableOAuth2}}
|
||||
<a class="{{if .PageIsAdminApplications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/applications">
|
||||
{{ctx.Locale.Tr "settings.applications"}}
|
||||
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "settings.applications"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .EnableActions}}
|
||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsVariables}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<summary>{{svg "octicon-play" 16}} {{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsSharedSettingsRunners}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/runners">
|
||||
{{ctx.Locale.Tr "actions.runners"}}
|
||||
{{svg "octicon-server" 16}} {{ctx.Locale.Tr "actions.runners"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSharedSettingsVariables}}active {{end}}item" href="{{AppSubUrl}}/-/admin/actions/variables">
|
||||
{{ctx.Locale.Tr "actions.variables"}}
|
||||
{{svg "octicon-list-unordered" 16}} {{ctx.Locale.Tr "actions.variables"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
@@ -88,33 +88,33 @@
|
||||
{{svg "octicon-paintbrush" 16}} Branding
|
||||
</a>
|
||||
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.config"}}</summary>
|
||||
<summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsAdminConfigSummary}}active {{end}}item" href="{{AppSubUrl}}/-/admin/config">
|
||||
{{ctx.Locale.Tr "admin.config_summary"}}
|
||||
{{svg "octicon-info" 16}} {{ctx.Locale.Tr "admin.config_summary"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminConfigSettings}}active {{end}}item" href="{{AppSubUrl}}/-/admin/config/settings">
|
||||
{{ctx.Locale.Tr "admin.config_settings"}}
|
||||
{{svg "octicon-sliders" 16}} {{ctx.Locale.Tr "admin.config_settings"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<a class="{{if .PageIsAdminNotices}}active {{end}}item" href="{{AppSubUrl}}/-/admin/notices">
|
||||
{{ctx.Locale.Tr "admin.notices"}}
|
||||
{{svg "octicon-alert" 16}} {{ctx.Locale.Tr "admin.notices"}}
|
||||
</a>
|
||||
<details class="item toggleable-item" {{if or .PageIsAdminMonitorStats .PageIsAdminMonitorCron .PageIsAdminMonitorQueue .PageIsAdminMonitorTrace}}open{{end}}>
|
||||
<summary>{{ctx.Locale.Tr "admin.monitor"}}</summary>
|
||||
<summary>{{svg "octicon-pulse" 16}} {{ctx.Locale.Tr "admin.monitor"}}</summary>
|
||||
<div class="menu">
|
||||
<a class="{{if .PageIsAdminMonitorStats}}active {{end}}item" href="{{AppSubUrl}}/-/admin/monitor/stats">
|
||||
{{ctx.Locale.Tr "admin.monitor.stats"}}
|
||||
{{svg "octicon-graph" 16}} {{ctx.Locale.Tr "admin.monitor.stats"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminMonitorCron}}active {{end}}item" href="{{AppSubUrl}}/-/admin/monitor/cron">
|
||||
{{ctx.Locale.Tr "admin.monitor.cron"}}
|
||||
{{svg "octicon-clock" 16}} {{ctx.Locale.Tr "admin.monitor.cron"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminMonitorQueue}}active {{end}}item" href="{{AppSubUrl}}/-/admin/monitor/queue">
|
||||
{{ctx.Locale.Tr "admin.monitor.queues"}}
|
||||
{{svg "octicon-stack" 16}} {{ctx.Locale.Tr "admin.monitor.queues"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminMonitorTrace}}active {{end}}item" href="{{AppSubUrl}}/-/admin/monitor/stacktrace">
|
||||
{{ctx.Locale.Tr "admin.monitor.trace"}}
|
||||
{{svg "octicon-cpu" 16}} {{ctx.Locale.Tr "admin.monitor.trace"}}
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
</div>
|
||||
<a href="{{AssetUrlPrefix}}/licenses.txt">{{ctx.Locale.Tr "licenses"}}</a>
|
||||
{{if .EnableSwagger}}<a href="{{AppSubUrl}}/api/swagger">API</a>{{end}}
|
||||
<a href="{{HelpURL}}" target="_blank">{{ctx.Locale.Tr "help"}}</a>
|
||||
{{template "custom/extra_links_footer" .}}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -35,9 +35,7 @@
|
||||
|
||||
{{template "custom/extra_links" .}}
|
||||
|
||||
{{if not .IsSigned}}
|
||||
<a class="item" target="_blank" href="{{HelpURL}}">{{ctx.Locale.Tr "help"}}</a>
|
||||
{{end}}
|
||||
<a class="item" target="_blank" href="{{HelpURL}}">{{ctx.Locale.Tr "help"}}</a>
|
||||
</div>
|
||||
|
||||
<!-- the full dropdown menus -->
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<div role="main" aria-label="{{if .IsSigned}}{{ctx.Locale.Tr "dashboard"}}{{else}}{{ctx.Locale.Tr "home_title"}}{{end}}" class="page-content home">
|
||||
<div class="tw-mb-8 tw-px-8">
|
||||
<div class="center">
|
||||
<img class="logo" width="220" height="220" src="{{AssetUrlPrefix}}/img/logo.png" alt="{{ctx.Locale.Tr "logo"}}">
|
||||
<img class="logo" width="220" height="220" src="{{AssetUrlPrefix}}/img/login-logo.png" alt="{{ctx.Locale.Tr "logo"}}" onerror="this.style.display='none'">
|
||||
<div class="hero">
|
||||
<h1 class="ui icon header title tw-text-balance">
|
||||
{{AppName}}
|
||||
|
||||
@@ -48,6 +48,16 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="require_2fa" {{if .Org.Require2FA}}checked{{end}}>
|
||||
<label>{{svg "octicon-shield-lock" 16}} Require two-factor authentication for all members</label>
|
||||
</div>
|
||||
<p class="help">When enabled, organization members without 2FA configured will be prompted to set it up before accessing organization resources.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "org.settings.update_settings"}}</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="ui container fluid">
|
||||
<div class="tw-text-center tw-mb-4">
|
||||
<img src="{{AssetUrlPrefix}}/img/login-logo.png" style="max-width: 220px; max-height: 80px; object-fit: contain;" onerror="this.style.display='none'">
|
||||
</div>
|
||||
{{if or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn)}}
|
||||
{{template "base/alert" .}}
|
||||
{{end}}
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
VERSION: 05.00.00
|
||||
VERSION: 05.01.02
|
||||
-->
|
||||
|
||||
<updates>
|
||||
@@ -10,12 +10,12 @@
|
||||
<description>MokoGitea update</description>
|
||||
<element>mokogitea</element>
|
||||
<type>application</type>
|
||||
<version>05.00.00</version>
|
||||
<version>05.01.02</version>
|
||||
<client>server</client>
|
||||
<tags><tag>stable</tag></tags>
|
||||
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/v1.26.1-moko.05.00.00</infourl>
|
||||
<infourl title="MokoGitea">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/v1.26.1-moko.05.01.02</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="docker">git.mokoconsulting.tech/mokoconsulting/mokogitea:v1.26.1-moko.05.00.00</downloadurl>
|
||||
<downloadurl type="full" format="docker">git.mokoconsulting.tech/mokoconsulting/mokogitea:v1.26.1-moko.05.01.02</downloadurl>
|
||||
</downloads>
|
||||
<sha256></sha256>
|
||||
<targetplatform name="mokogitea" version="((1\.25\.)|(1\.26\.))" />
|
||||
@@ -27,12 +27,12 @@
|
||||
<description>MokoGitea update</description>
|
||||
<element>mokogitea</element>
|
||||
<type>application</type>
|
||||
<version>05.00.00</version>
|
||||
<version>05.01.02</version>
|
||||
<client>server</client>
|
||||
<tags><tag>rc</tag></tags>
|
||||
<infourl title="MokoGitea RC">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/v1.26.1-moko.05.00.00</infourl>
|
||||
<infourl title="MokoGitea RC">https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/v1.26.1-moko.05.01.02</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="docker">git.mokoconsulting.tech/mokoconsulting/mokogitea:v1.26.1-moko.05.00.00</downloadurl>
|
||||
<downloadurl type="full" format="docker">git.mokoconsulting.tech/mokoconsulting/mokogitea:v1.26.1-moko.05.01.02</downloadurl>
|
||||
</downloads>
|
||||
<sha256></sha256>
|
||||
<targetplatform name="mokogitea" version="((1\.25\.)|(1\.26\.))" />
|
||||
|
||||
@@ -9,8 +9,8 @@ details.toggleable-item .menu {
|
||||
|
||||
details.toggleable-item summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
padding: 0.92857143em 1.14285714em;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ details.toggleable-item summary::-webkit-details-marker /* Safari */ {
|
||||
}
|
||||
|
||||
details.toggleable-item summary::after {
|
||||
margin-left: auto;
|
||||
transition: transform 0.25s ease;
|
||||
content: "";
|
||||
width: 14px;
|
||||
@@ -35,3 +36,8 @@ details.toggleable-item summary::after {
|
||||
details.toggleable-item[open] summary::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.flex-container-nav .ui.menu .item {
|
||||
text-align: left !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user