Compare commits

..

4 Commits

11 changed files with 7 additions and 442 deletions
+2 -104
View File
@@ -8,7 +8,7 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g. v1.26.1-moko.05.01.00)'
description: 'Version tag (e.g. v1.26.1-moko.04.00.00)'
required: true
default: 'latest'
environment:
@@ -30,7 +30,6 @@ env:
DEPLOY_HOST: git.mokoconsulting.tech
DEPLOY_PORT: 2918
DEPLOY_USER: mokoconsulting
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
deploy:
@@ -48,30 +47,15 @@ jobs:
echo "source_dir=/opt/gitea/source" >> $GITHUB_OUTPUT
echo "branch=main" >> $GITHUB_OUTPUT
echo "tag=${VERSION}" >> $GITHUB_OUTPUT
echo "instance_url=https://git.mokoconsulting.tech" >> $GITHUB_OUTPUT
else
echo "compose_dir=/opt/gitea-dev" >> $GITHUB_OUTPUT
echo "container=mokogitea-dev" >> $GITHUB_OUTPUT
echo "source_dir=/opt/gitea-dev/source" >> $GITHUB_OUTPUT
echo "branch=dev" >> $GITHUB_OUTPUT
echo "tag=${VERSION}-dev" >> $GITHUB_OUTPUT
echo "instance_url=https://git.dev.mokoconsulting.tech" >> $GITHUB_OUTPUT
fi
- name: Enable maintenance mode
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
INSTANCE_URL: ${{ steps.config.outputs.instance_url }}
run: |
echo "Enabling maintenance mode on ${INSTANCE_URL}..."
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/x-www-form-urlencoded" \
"${INSTANCE_URL}/-/admin/config" \
-d 'key=instance.maintenance_mode&value={"AdminWebAccessOnly":true}' \
|| echo "WARNING: Could not enable maintenance mode (instance may be down)"
- name: Build and deploy via SSH
- name: Build, push, and deploy via SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
TAG: ${{ steps.config.outputs.tag }}
@@ -140,92 +124,6 @@ jobs:
exit 1
"
- name: Update updates.xml
if: success()
env:
GITEA_TOKEN: ${{ secrets.GA_TOKEN }}
TAG: ${{ steps.config.outputs.tag }}
INSTANCE_URL: ${{ steps.config.outputs.instance_url }}
DEPLOY_ENV: ${{ github.event.inputs.environment }}
run: |
# Only update updates.xml for production stable releases
if [ "$DEPLOY_ENV" != "production" ]; then
echo "Skipping updates.xml — dev deployments don't update stable channel"
exit 0
fi
# Extract moko version from tag (e.g. v1.26.1-moko.05.01.01 -> 05.01.01)
MOKO_VER=$(echo "$TAG" | sed -n 's/.*-moko\.\(.*\)/\1/p')
if [ -z "$MOKO_VER" ]; then
echo "Could not extract moko version from tag: $TAG"
exit 0
fi
RELEASE_URL="https://${REGISTRY}/MokoConsulting/MokoGitea/releases/tag/${TAG}"
DOCKER_IMG="${REGISTRY}/${IMAGE}:${TAG}"
python3 << PYEOF
import json, os, re, base64, urllib.request
token = os.environ["GITEA_TOKEN"]
registry = os.environ["REGISTRY"]
tag = os.environ["TAG"]
moko_ver = os.environ["MOKO_VER"]
release_url = os.environ["RELEASE_URL"]
docker_img = os.environ["DOCKER_IMG"]
api = f"https://{registry}/api/v1/repos/MokoConsulting/MokoGitea"
# Fetch current updates.xml
req = urllib.request.Request(f"{api}/contents/updates.xml?ref=main",
headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
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)
# Also update VERSION comment at top
content = re.sub(r"VERSION: [^\n]*", f"VERSION: {moko_ver}", content)
# Push updated file
encoded = base64.b64encode(content.encode()).decode()
payload = json.dumps({
"message": f"chore(ci): update updates.xml to {moko_ver}",
"content": encoded,
"sha": sha,
"branch": "main",
}).encode()
req = urllib.request.Request(f"{api}/contents/updates.xml",
data=payload, method="PUT",
headers={"Authorization": f"token {token}", "Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
print(f"updates.xml updated to {moko_ver}")
PYEOF
- name: Disable maintenance mode
if: always()
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
INSTANCE_URL: ${{ steps.config.outputs.instance_url }}
run: |
echo "Disabling maintenance mode on ${INSTANCE_URL}..."
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/x-www-form-urlencoded" \
"${INSTANCE_URL}/-/admin/config" \
-d 'key=instance.maintenance_mode&value={"AdminWebAccessOnly":false}' \
|| echo "WARNING: Could not disable maintenance mode"
- name: Verify
run: |
sleep 5
-73
View File
@@ -1,73 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
name: Create feature branch
runs-on: ubuntu-latest
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
# Build slug from title: lowercase, replace non-alnum with dash, trim
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
# Check dev branch exists
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API}/branches/dev" 2>/dev/null || echo "000")
if [ "${DEV_EXISTS}" != "200" ]; then
echo "No dev branch -- skipping"
exit 0
fi
# Create branch from dev
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/branches" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
if [ "${HTTP}" = "201" ]; then
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${GITEA_URL}/${{ github.repository }}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/comments" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
echo "Commented on issue #${ISSUE_NUM}"
else
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
fi
+1 -1
View File
@@ -32,7 +32,7 @@ var (
UpdateChecker = struct {
Enabled bool
Endpoint string
Channel string // stable, rc, beta, alpha, development
Channel string // stable, dev, security
}{
Enabled: true,
Endpoint: "https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/updates.xml",
+4 -15
View File
@@ -26,14 +26,9 @@ type UpdateInfo struct {
CheckedAt time.Time
}
// NotifyFunc is called when a new update is detected for the first time.
// Set this from the cron/mailer layer to send admin email notifications.
var NotifyFunc func(info *UpdateInfo)
var (
cachedInfo *UpdateInfo
lastNotifiedVer string
mu sync.RWMutex
cachedInfo *UpdateInfo
mu sync.RWMutex
)
// xmlUpdates mirrors the updates.xml structure (Joomla-style).
@@ -139,22 +134,16 @@ func CheckForUpdate() error {
}
// Update is available if the latest version string is not a prefix of the current version.
// e.g., current "1.26.1+305-gabcdef" does not start with "04.00.00"
// This handles both moko semver and git-describe suffixed versions.
info.UpdateAvailable = latestVersion != "" && !strings.Contains(currentVersion, latestVersion)
mu.Lock()
cachedInfo = info
// Notify only once per new version (avoid spamming on every cron tick)
shouldNotify := info.UpdateAvailable && latestVersion != lastNotifiedVer
if shouldNotify {
lastNotifiedVer = latestVersion
}
mu.Unlock()
if info.UpdateAvailable {
log.Info("MokoGitea update available: %s [%s] (current: %s)", latestVersion, channel, currentVersion)
if shouldNotify && NotifyFunc != nil {
NotifyFunc(info)
}
} else {
log.Debug("MokoGitea is up to date: %s [%s]", currentVersion, channel)
}
@@ -18,7 +18,6 @@ import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context/upload"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/convert"
release_service "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/release"
)
func checkReleaseMatchRepo(ctx *context.APIContext, releaseID int64) bool {
@@ -264,14 +263,6 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
return
}
// Regenerate checksums after new attachment
rel, relErr := repo_model.GetReleaseByID(ctx, releaseID)
if relErr == nil {
if checksumErr := release_service.GenerateReleaseChecksums(ctx, rel); checksumErr != nil {
log.Error("GenerateReleaseChecksums after upload: %v", checksumErr)
}
}
ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
}
-31
View File
@@ -144,14 +144,6 @@ func Dashboard(ctx *context.Context) {
ctx.Data["ReleaseURL"] = info.ReleaseURL
ctx.Data["UpdateChannel"] = info.Channel
ctx.Data["DockerImage"] = info.DockerImage
ctx.Data["CurrentUpdateChannel"] = setting.UpdateChecker.Channel
ctx.Data["UpdateChannels"] = []map[string]string{
{"value": "stable", "label": "Stable", "desc": "Production-ready releases"},
{"value": "rc", "label": "Release Candidate", "desc": "Pre-release builds from merged PRs"},
{"value": "beta", "label": "Beta", "desc": "Feature-complete, under testing"},
{"value": "alpha", "label": "Alpha", "desc": "Early access, may have rough edges"},
{"value": "development", "label": "Development", "desc": "Latest dev branch, bleeding edge"},
}
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
@@ -174,29 +166,6 @@ func DashboardPost(ctx *context.Context) {
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
// Handle update channel change
if channel := ctx.FormString("update_channel"); channel != "" {
validChannels := []string{"stable", "rc", "beta", "alpha", "development"}
isValid := false
for _, v := range validChannels {
if channel == v {
isValid = true
break
}
}
if isValid {
setting.UpdateChecker.Channel = channel
go func() {
if err := updatechecker.CheckForUpdate(); err != nil {
log.Error("CheckForUpdate after channel change: %v", err)
}
}()
ctx.Flash.Success("Update channel changed to: " + channel)
}
ctx.Redirect(setting.AppSubURL + "/-/admin")
return
}
// Run operation.
if form.Op != "" {
switch form.Op {
-6
View File
@@ -15,7 +15,6 @@ import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/updatechecker"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mailer"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/migrations"
mirror_service "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mirror"
packages_cleanup_service "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/packages/cleanup"
@@ -191,11 +190,6 @@ func initBasicTasks() {
}
func registerUpdateChecker() {
// Wire up email notification for admin when updates are detected
updatechecker.NotifyFunc = func(info *updatechecker.UpdateInfo) {
mailer.SendUpdateNotification(info.LatestVersion, info.Channel, info.ReleaseURL, info.DockerImage)
}
RegisterTaskFatal("update_checker", &BaseConfig{
Enabled: true,
RunAtStart: true,
-93
View File
@@ -1,93 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package mailer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
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"
)
// SendUpdateNotification emails the admin and sends ntfy push when a new MokoGitea version is available.
func SendUpdateNotification(version, channel, releaseURL, dockerImage string) {
subject := fmt.Sprintf("[MokoGitea] Update available: %s (%s)", version, channel)
body := fmt.Sprintf(`MokoGitea Update Available
A new version is available on the %s channel.
Version: %s
Channel: %s
Current: %s`, channel, version, channel, setting.AppVer)
if releaseURL != "" {
body += fmt.Sprintf("\nRelease: %s", releaseURL)
}
if dockerImage != "" {
body += fmt.Sprintf("\nDocker: docker pull %s", dockerImage)
}
body += fmt.Sprintf("\n\nUpdate the channel in Site Administration > Dashboard.\n\n— %s", setting.AppName)
// Send email to admin
if setting.MailService != nil {
admin, err := user_model.GetAdminUser(context.Background())
if err != nil {
log.Error("SendUpdateNotification: GetAdminUser: %v", err)
} else {
msg := sender_service.NewMessage(admin.EmailTo(), subject, body)
msg.Info = "Update notification"
SendAsync(msg)
log.Info("Update email sent to %s for version %s [%s]", admin.Email, version, channel)
}
}
// Send ntfy push notification
if setting.Ntfy.Enabled && setting.Ntfy.ServerURL != "" {
sendNtfyNotification(subject, body, releaseURL)
}
}
func sendNtfyNotification(title, body, clickURL string) {
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: create request: %v", err)
return
}
req.Header.Set("Title", title)
req.Header.Set("Priority", "high")
req.Header.Set("Tags", "arrow_up,mokogitea")
if clickURL != "" {
req.Header.Set("Click", clickURL)
}
if setting.Ntfy.Token != "" {
req.Header.Set("Authorization", "Bearer "+setting.Ntfy.Token)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
log.Error("ntfy: send notification: %v", err)
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 300 {
log.Error("ntfy: unexpected status %d", resp.StatusCode)
return
}
log.Info("ntfy: update notification sent to %s/%s", setting.Ntfy.ServerURL, setting.Ntfy.DefaultTopic)
}
-82
View File
@@ -1,82 +0,0 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package release
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"io"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
attachment_service "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/attachment"
)
// GenerateReleaseChecksums computes SHA256 checksums for all attachments
// on a release and adds a checksums.sha256 manifest file as an attachment.
func GenerateReleaseChecksums(ctx context.Context, rel *repo_model.Release) error {
// Load attachments into rel.Attachments
if err := repo_model.GetReleaseAttachments(ctx, rel); err != nil {
return fmt.Errorf("GetReleaseAttachments: %w", err)
}
if len(rel.Attachments) == 0 {
return nil
}
// Remove existing checksums file if present
for _, a := range rel.Attachments {
if a.Name == "checksums.sha256" {
if err := repo_model.DeleteAttachment(ctx, a, true); err != nil {
log.Warn("Failed to delete old checksums.sha256: %v", err)
}
break
}
}
// Compute SHA256 for each attachment
var manifest bytes.Buffer
for _, a := range rel.Attachments {
if a.Name == "checksums.sha256" {
continue
}
fr, err := storage.Attachments.Open(a.RelativePath())
if err != nil {
log.Warn("Cannot open attachment %s for checksumming: %v", a.Name, err)
continue
}
h := sha256.New()
if _, err := io.Copy(h, fr); err != nil {
fr.Close()
log.Warn("Cannot read attachment %s for checksumming: %v", a.Name, err)
continue
}
fr.Close()
fmt.Fprintf(&manifest, "%x %s\n", h.Sum(nil), a.Name)
}
if manifest.Len() == 0 {
return nil
}
// Create the checksums.sha256 attachment
checksumAttach := &repo_model.Attachment{
RepoID: rel.RepoID,
ReleaseID: rel.ID,
Name: "checksums.sha256",
}
if _, err := attachment_service.NewAttachment(ctx, checksumAttach, &manifest, int64(manifest.Len())); err != nil {
return fmt.Errorf("create checksums.sha256 attachment: %w", err)
}
log.Info("Generated checksums.sha256 for release %s (repo %d)", rel.TagName, rel.RepoID)
return nil
}
-14
View File
@@ -190,13 +190,6 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU
return err
}
// Generate SHA256 checksums for all release attachments
if len(attachmentUUIDs) > 0 {
if err := GenerateReleaseChecksums(gitRepo.Ctx, rel); err != nil {
log.Error("GenerateReleaseChecksums for %s: %v", rel.TagName, err)
}
}
if !rel.IsDraft {
notify_service.NewRelease(gitRepo.Ctx, rel)
}
@@ -351,13 +344,6 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
}
}
// Regenerate checksums when attachments change
if len(addAttachmentUUIDs) > 0 || len(delAttachmentUUIDs) > 0 {
if err := GenerateReleaseChecksums(ctx, rel); err != nil {
log.Error("GenerateReleaseChecksums for %s: %v", rel.TagName, err)
}
}
if !rel.IsDraft {
if !isTagCreated && !isConvertedFromTag {
notify_service.UpdateRelease(gitRepo.Ctx, doer, rel)
-14
View File
@@ -8,20 +8,6 @@
{{if .DockerImage}}<p><code>docker pull {{.DockerImage}}</code></p>{{end}}
</div>
{{end}}
<div class="ui segment">
<h4 class="ui header">{{svg "octicon-broadcast" 16}} Update Channel</h4>
<form method="post" action="{{AppSubUrl}}/-/admin" class="tw-flex tw-items-end tw-gap-4">
{{.CsrfTokenHtml}}
<div class="field tw-flex-1">
<select name="update_channel" class="ui dropdown">
{{range .UpdateChannels}}
<option value="{{.value}}" {{if eq $.CurrentUpdateChannel .value}}selected{{end}}>{{.label}}{{.desc}}</option>
{{end}}
</select>
</div>
<button type="submit" class="ui primary button">{{svg "octicon-sync" 14}} Apply</button>
</form>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.dashboard.maintenance_operations"}}
</h4>