release: npm/mcp metadata + platform registry + update-server + packaging hook (dev -> main) [#367] #828

Merged
jmiller merged 10 commits from dev into main 2026-07-19 04:00:40 +00:00
20 changed files with 967 additions and 29 deletions
+20
View File
@@ -2999,3 +2999,23 @@ LEVEL = Info
;SERVICE_TYPE = memory
;; Ignored for the "memory" type. For "redis" use something like `redis://127.0.0.1:6379/0`
;SERVICE_CONN_STR =
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Repository metadata / platform registry
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[metadata]
;; Comma-separated list of allowed values for the repo metadata "platform" field.
;; Also editable via Admin -> Metadata (persisted here). Default taxonomy:
;PLATFORM_OPTIONS = joomla,dolibarr,go,npm,generic
;;
;; Structured platform registry: capability attributes per platform key, read by
;; CI, the update server, and workflow sync (exposed at GET /api/v1/platforms).
;; Value is EITHER an inline JSON array of platform objects OR a path to a JSON
;; file (relative paths resolve against the custom/ dir). When unset, a built-in
;; default registry is used (joomla, dolibarr, go, npm, mcp, generic), so
;; behavior is unchanged if this is not configured.
;; Example (file path):
;PLATFORM_REGISTRY = custom/platforms.json
;; Example (inline JSON):
;;PLATFORM_REGISTRY = [{"key":"joomla","label":"Joomla Extension","family":"joomla","artifact":true,"update_generator":"joomla","feed_format":"xml","template_repo":"Template-Joomla","manifest_based":true}]
+1
View File
@@ -449,6 +449,7 @@ func prepareMigrationTasks() []*migration {
newMigration(369, "Rename mokogitea-actions system user to mokogit-actions", v1_27.RenameActionsUserToMokoGit),
newMigration(370, "Remap saved gitea-* user themes to mokogit-*", v1_27.RemapGiteaThemesToMokogit),
newMigration(371, "Widen action_run_job.workflow_payload to MEDIUMBLOB", v1_27.WidenActionRunJobWorkflowPayload),
newMigration(372, "Add npm/mcp fields to repo manifest", v1_27.AddNpmFieldsToRepoManifest),
}
return preparedMigrations
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"xorm.io/xorm"
)
// AddNpmFieldsToRepoManifest adds npm/mcp metadata columns to repo_manifest so
// the PUT /api/v1/repos/{owner}/{repo}/metadata handler can persist them
// instead of silently dropping them. See issue #363.
func AddNpmFieldsToRepoManifest(x *xorm.Engine) error {
type RepoManifest struct {
NodeMinimum string `xorm:"VARCHAR(20) 'node_minimum'"`
NpmPackage string `xorm:"TEXT 'npm_package'"`
NpmScope string `xorm:"VARCHAR(100) 'npm_scope'"`
RegistryURL string `xorm:"TEXT 'registry_url'"`
Bin string `xorm:"TEXT 'bin'"`
PublishTarget string `xorm:"VARCHAR(50) 'publish_target'"`
}
return x.Sync(new(RepoManifest))
}
+8
View File
@@ -52,6 +52,14 @@ type RepoMetadata struct {
ExtensionType string `xorm:"VARCHAR(50) 'extension_type'"` // component, module, plugin, package, template, library, file
EntryPoint string `xorm:"TEXT 'entry_point'"` // build entry point path
// npm/mcp section (used by node/npm publish workflows and mokocli)
NodeMinimum string `xorm:"VARCHAR(20) 'node_minimum'"` // minimum Node.js version, e.g. "18"
NpmPackage string `xorm:"TEXT 'npm_package'"` // published npm package name, e.g. "@moko/foo"
NpmScope string `xorm:"VARCHAR(100) 'npm_scope'"` // npm scope, e.g. "@moko"
RegistryURL string `xorm:"TEXT 'registry_url'"` // npm registry URL
Bin string `xorm:"TEXT 'bin'"` // bin entry (name or JSON map)
PublishTarget string `xorm:"VARCHAR(50) 'publish_target'"` // publish target, e.g. "npm", "registry"
// deploy section
DeployHost string `xorm:"VARCHAR(255) 'deploy_host'"` // SSH host for deploy
DeployPort string `xorm:"VARCHAR(10) 'deploy_port'"` // SSH port (default 2918)
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import "strings"
// Update-feed platform identifiers. These are the recognized values for the
// UpdateStreamConfig.Platform column and for the resolved "RepoUpdatePlatform"
// context value used by the Serve* update-feed handlers.
//
// The Platform column is a plain string (no DB-level enum/CHECK constraint), so
// these constants are enforced at the application layer only — adding new values
// requires no schema migration.
const (
PlatformJoomla = "joomla"
PlatformDolibarr = "dolibarr"
PlatformBoth = "both" // joomla + dolibarr
PlatformWordPress = "wordpress"
PlatformPrestaShop = "prestashop"
PlatformDrupal = "drupal"
PlatformComposer = "composer"
PlatformWHMCS = "whmcs"
// Feedless platforms: repositories whose distribution channel is an external
// package registry (npm, MCP). They must NOT emit any Joomla/Dolibarr/etc.
// update feed — the Serve* handlers return NotFound for these. See #810.
PlatformNPM = "npm"
PlatformMCP = "mcp"
)
// feedlessPlatforms are platforms that never emit a legacy update feed.
var feedlessPlatforms = map[string]struct{}{
PlatformNPM: {},
PlatformMCP: {},
}
// IsFeedlessPlatform reports whether the given platform must not serve any
// update feed (npm / mcp). These repositories are published to an external
// registry, so every Serve* handler returns NotFound for them. See #810.
func IsFeedlessPlatform(platform string) bool {
_, ok := feedlessPlatforms[strings.ToLower(strings.TrimSpace(platform))]
return ok
}
// ResolvePlatform determines the effective update-feed platform for a repo.
//
// - If the update-stream config sets an explicit platform, that wins.
// - Otherwise the platform is derived from the repository metadata platform
// (metadata.platform), so the two stores can no longer drift. See #811.
// - Feedless metadata values (npm / mcp) are propagated verbatim so the
// handlers can return NotFound instead of falling back to a Joomla feed.
// - Only when neither source is set do we fall back to the historical
// "joomla" default (used by legacy Joomla extension repos).
//
// This is the single source of truth for the old "empty ⇒ joomla" fallback,
// which previously caused npm/mcp repos with no config row to emit a Joomla
// feed. See #810 and #811.
func ResolvePlatform(cfgPlatform, metadataPlatform string) string {
if p := strings.ToLower(strings.TrimSpace(cfgPlatform)); p != "" {
return p
}
if p := strings.ToLower(strings.TrimSpace(metadataPlatform)); p != "" {
return p
}
return PlatformJoomla
}
+4
View File
@@ -19,4 +19,8 @@ func loadMetadataFrom(rootCfg ConfigProvider) {
if opts := sec.Key("PLATFORM_OPTIONS").Strings(","); len(opts) > 0 {
Metadata.PlatformOptions = opts
}
// Load the structured platform registry ([metadata] PLATFORM_REGISTRY).
// Falls back to a built-in default reconciled with PLATFORM_OPTIONS.
loadPlatformRegistryFrom(rootCfg)
}
+255
View File
@@ -0,0 +1,255 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
// PlatformDef is a single platform's capability record in the platform registry.
//
// A platform key (matching repo_manifest.platform and UpdateStreamConfig.Platform)
// carries the capability attributes that CI, the update server, and the workflow
// sync tooling read instead of hardcoding per-platform behavior. Only Key is
// required; every other field has a sensible zero/default so a bare key still
// yields a usable (generic) record.
//
// This registry is NON-SECRET capability config only. It never carries
// credentials, tokens, or deploy keys.
type PlatformDef struct {
// Key is the stable lowercase identifier (e.g. "joomla"). Matches
// repo_manifest.platform and UpdateStreamConfig.Platform.
Key string `json:"key"`
// Label is the human-readable display name.
Label string `json:"label,omitempty"`
// Aliases are alternate keys matched (by prefix) in the release gate,
// e.g. joomla5, joomla6.
Aliases []string `json:"aliases,omitempty"`
// Family groups related platforms: joomla | dolibarr | node | go | generic | dot.
Family string `json:"family,omitempty"`
// Artifact reports whether the platform produces a build artifact.
Artifact bool `json:"artifact"`
// UpdateGenerator names the update-feed generator the update server uses:
// joomla | dolibarr | wordpress | composer | prestashop | drupal | whmcs | npm | none.
UpdateGenerator string `json:"update_generator,omitempty"`
// FeedFormat is the update-feed wire format: xml | json | none.
FeedFormat string `json:"feed_format,omitempty"`
// TemplateRepo is the workflow-template source repo (e.g. Template-Joomla).
TemplateRepo string `json:"template_repo,omitempty"`
// ManifestBased reports whether a manifest is parsed for element/version.
ManifestBased bool `json:"manifest_based"`
// PublishTarget names where artifacts are published (e.g. npm, updateserver, git-release).
PublishTarget string `json:"publish_target,omitempty"`
// DetectGlobs are file-pattern globs used to auto-detect this platform.
DetectGlobs []string `json:"detect_globs,omitempty"`
}
// Platforms holds the loaded platform registry indexed by key (lowercase).
// It is populated by loadMetadataFrom at startup. Callers should use
// PlatformRegistry(), GetPlatform() and PlatformList() rather than reading the
// map directly.
var platformRegistry map[string]*PlatformDef
// PlatformRegistry returns the loaded registry as a key->def map. Never nil.
func PlatformRegistry() map[string]*PlatformDef {
if platformRegistry == nil {
platformRegistry = buildDefaultRegistry(Metadata.PlatformOptions)
}
return platformRegistry
}
// PlatformList returns registry entries in the order of Metadata.PlatformOptions
// (falling back to a stable built-in order for any extra keys).
func PlatformList() []*PlatformDef {
reg := PlatformRegistry()
out := make([]*PlatformDef, 0, len(reg))
seen := make(map[string]bool, len(reg))
// Preserve admin-configured order first.
for _, k := range Metadata.PlatformOptions {
k = strings.ToLower(strings.TrimSpace(k))
if def, ok := reg[k]; ok && !seen[k] {
out = append(out, def)
seen[k] = true
}
}
// Append any registry-only keys not present in PlatformOptions.
for k, def := range reg {
if !seen[k] {
out = append(out, def)
seen[k] = true
}
}
return out
}
// GetPlatform returns the definition for a platform key. If the key is unknown,
// it synthesizes a generic default so callers never get a nil (matches the
// back-compat guarantee that an unlisted platform behaves like "generic").
// The bool reports whether the key was explicitly present in the registry.
func GetPlatform(key string) (*PlatformDef, bool) {
key = strings.ToLower(strings.TrimSpace(key))
reg := PlatformRegistry()
if def, ok := reg[key]; ok {
return def, true
}
return genericPlatformDef(key), false
}
// genericPlatformDef returns a safe generic capability record for an unknown key.
func genericPlatformDef(key string) *PlatformDef {
return &PlatformDef{
Key: key,
Label: key,
Family: "generic",
Artifact: false,
UpdateGenerator: "none",
FeedFormat: "none",
TemplateRepo: "Template-Generic",
ManifestBased: false,
PublishTarget: "git-release",
}
}
// builtinRegistry is the default platform registry. It reproduces current
// behavior so an unconfigured instance is unchanged: Joomla still maps to the
// joomla generator (xml), Dolibarr to dolibarr (json), npm/mcp are feedless, etc.
func builtinRegistry() []*PlatformDef {
return []*PlatformDef{
{
Key: "joomla", Label: "Joomla Extension", Aliases: []string{"joomla5", "joomla6"},
Family: "joomla", Artifact: true, UpdateGenerator: "joomla", FeedFormat: "xml",
TemplateRepo: "Template-Joomla", ManifestBased: true, PublishTarget: "updateserver",
DetectGlobs: []string{"source/pkg_*.xml", "**/templateDetails.xml"},
},
{
Key: "dolibarr", Label: "Dolibarr Module",
Family: "dolibarr", Artifact: true, UpdateGenerator: "dolibarr", FeedFormat: "json",
TemplateRepo: "Template-Dolibarr", ManifestBased: true, PublishTarget: "updateserver",
DetectGlobs: []string{"htdocs/**/core/modules/mod*.class.php"},
},
{
Key: "go", Label: "Go Module",
Family: "go", Artifact: false, UpdateGenerator: "none", FeedFormat: "none",
TemplateRepo: "Template-Go", ManifestBased: false, PublishTarget: "git-release",
DetectGlobs: []string{"go.mod"},
},
{
Key: "npm", Label: "NPM Package",
Family: "node", Artifact: true, UpdateGenerator: "npm", FeedFormat: "none",
TemplateRepo: "Template-NPM", ManifestBased: true, PublishTarget: "npm",
DetectGlobs: []string{"package.json"},
},
{
Key: "mcp", Label: "MCP Server",
Family: "node", Artifact: true, UpdateGenerator: "npm", FeedFormat: "none",
TemplateRepo: "Template-NPM", ManifestBased: true, PublishTarget: "npm",
DetectGlobs: []string{"**/mcp.json", "**/.mcp.json"},
},
{
Key: "generic", Label: "Generic", Family: "generic", Artifact: false,
UpdateGenerator: "none", FeedFormat: "none", TemplateRepo: "Template-Generic",
ManifestBased: false, PublishTarget: "git-release",
},
}
}
// buildDefaultRegistry indexes the built-in registry by key and then ensures a
// record exists for every key in the admin PLATFORM_OPTIONS list (synthesizing a
// generic default for any key without a built-in entry). This keeps the flat
// PLATFORM_OPTIONS list and the structured registry consistent.
func buildDefaultRegistry(options []string) map[string]*PlatformDef {
reg := make(map[string]*PlatformDef)
for _, def := range builtinRegistry() {
d := def
reg[d.Key] = d
}
for _, opt := range options {
k := strings.ToLower(strings.TrimSpace(opt))
if k == "" {
continue
}
if _, ok := reg[k]; !ok {
reg[k] = genericPlatformDef(k)
}
}
return reg
}
// loadPlatformRegistryFrom loads the structured platform registry.
//
// Storage: [metadata] PLATFORM_REGISTRY in app.ini. The value is EITHER an
// inline JSON array of PlatformDef objects, OR a path to a JSON file (relative
// paths are resolved against CustomPath, e.g. custom/platforms.json). When
// unset, the built-in default registry is used, merged with any extra keys from
// PLATFORM_OPTIONS. This keeps behavior unchanged for unconfigured instances and
// avoids a DB schema change (idiomatic: mirrors the existing app.ini + file-path
// config pattern used elsewhere in modules/setting).
func loadPlatformRegistryFrom(rootCfg ConfigProvider) {
// Base defaults, reconciled with the flat PLATFORM_OPTIONS list.
platformRegistry = buildDefaultRegistry(Metadata.PlatformOptions)
raw := strings.TrimSpace(rootCfg.Section("metadata").Key("PLATFORM_REGISTRY").String())
if raw == "" {
return
}
var data []byte
if strings.HasPrefix(raw, "[") {
// Inline JSON array.
data = []byte(raw)
} else {
// Treat as a file path (resolve relative to CustomPath).
path := raw
if !filepath.IsAbs(path) {
path = filepath.Join(CustomPath, path)
}
b, err := os.ReadFile(path)
if err != nil {
// Non-fatal: keep the default registry so the forge still boots.
return
}
data = b
}
var defs []*PlatformDef
if err := json.Unmarshal(data, &defs); err != nil {
// Non-fatal: malformed registry falls back to the default.
return
}
if len(defs) == 0 {
return
}
// Replace the registry with the configured set, filling defaults per entry.
reg := make(map[string]*PlatformDef, len(defs))
for _, def := range defs {
if def == nil {
continue
}
key := strings.ToLower(strings.TrimSpace(def.Key))
if key == "" {
continue
}
def.Key = key
if def.Label == "" {
def.Label = key
}
if def.Family == "" {
def.Family = "generic"
}
if def.UpdateGenerator == "" {
def.UpdateGenerator = "none"
}
if def.FeedFormat == "" {
def.FeedFormat = "none"
}
reg[key] = def
}
if len(reg) > 0 {
platformRegistry = reg
}
}
+28
View File
@@ -113,6 +113,34 @@ type LicenseTemplateInfo struct {
Body string `json:"body"`
}
// Platform is a single platform capability record from the platform registry.
// It exposes the non-secret capability attributes CI, the update server, and the
// workflow sync tooling read instead of hardcoding per-platform behavior.
type Platform struct {
// Key is the stable lowercase platform identifier
Key string `json:"key"`
// Label is the human-readable display name
Label string `json:"label"`
// Aliases are alternate keys matched by prefix in the release gate
Aliases []string `json:"aliases,omitempty"`
// Family groups related platforms (joomla, dolibarr, node, go, generic, dot)
Family string `json:"family"`
// Artifact reports whether the platform produces a build artifact
Artifact bool `json:"artifact"`
// UpdateGenerator names the update-feed generator (joomla, dolibarr, npm, none, ...)
UpdateGenerator string `json:"update_generator"`
// FeedFormat is the update-feed wire format (xml, json, none)
FeedFormat string `json:"feed_format"`
// TemplateRepo is the workflow-template source repo
TemplateRepo string `json:"template_repo,omitempty"`
// ManifestBased reports whether a manifest is parsed for element/version
ManifestBased bool `json:"manifest_based"`
// PublishTarget names where artifacts are published
PublishTarget string `json:"publish_target,omitempty"`
// DetectGlobs are file-pattern globs used to auto-detect this platform
DetectGlobs []string `json:"detect_globs,omitempty"`
}
// APIError is an api error with a message
type APIError struct {
// Message contains the error description
+4
View File
@@ -1009,6 +1009,10 @@ func Routes() *web.Router {
m.Get("/label/templates", misc.ListLabelTemplates)
m.Get("/label/templates/{name}", misc.GetLabelTemplate)
// Platform registry (non-secret capability config read by CI / mokocli).
m.Get("/platforms", misc.ListPlatforms)
m.Get("/platforms/{key}", misc.GetPlatform)
m.Group("/settings", func() {
m.Get("/ui", settings.GetGeneralUISettings)
m.Get("/api", settings.GetGeneralAPISettings)
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package misc
import (
"net/http"
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/setting"
api "code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/structs"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/services/context"
)
// toAPIPlatform converts a setting.PlatformDef into its API representation.
func toAPIPlatform(def *setting.PlatformDef) *api.Platform {
return &api.Platform{
Key: def.Key,
Label: def.Label,
Aliases: def.Aliases,
Family: def.Family,
Artifact: def.Artifact,
UpdateGenerator: def.UpdateGenerator,
FeedFormat: def.FeedFormat,
TemplateRepo: def.TemplateRepo,
ManifestBased: def.ManifestBased,
PublishTarget: def.PublishTarget,
DetectGlobs: def.DetectGlobs,
}
}
// ListPlatforms returns the whole platform registry.
func ListPlatforms(ctx *context.APIContext) {
// swagger:operation GET /platforms miscellaneous listPlatforms
// ---
// summary: Returns the platform registry (all platform capability records)
// produces:
// - application/json
// responses:
// "200":
// "$ref": "#/responses/PlatformList"
defs := setting.PlatformList()
response := make([]*api.Platform, len(defs))
for i, def := range defs {
response[i] = toAPIPlatform(def)
}
ctx.JSON(http.StatusOK, response)
}
// GetPlatform returns a single platform record by key.
func GetPlatform(ctx *context.APIContext) {
// swagger:operation GET /platforms/{key} miscellaneous getPlatform
// ---
// summary: Returns a single platform capability record by key
// produces:
// - application/json
// parameters:
// - name: key
// in: path
// description: platform key
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/Platform"
// "404":
// "$ref": "#/responses/notFound"
key := strings.ToLower(strings.TrimSpace(ctx.PathParam("key")))
def, ok := setting.GetPlatform(key)
if !ok {
// Unknown key: only 404 when it is not an admin-listed platform.
// (GetPlatform synthesizes a generic default for unknown keys, but the
// endpoint should be honest about what is actually configured.)
ctx.APIErrorNotFound()
return
}
ctx.JSON(http.StatusOK, toAPIPlatform(def))
}
+26
View File
@@ -33,6 +33,14 @@ type apiMetadata struct {
ExtensionType string `json:"extension_type"`
EntryPoint string `json:"entry_point"`
// npm/mcp
NodeMinimum string `json:"node_minimum,omitempty"`
NpmPackage string `json:"npm_package,omitempty"`
NpmScope string `json:"npm_scope,omitempty"`
RegistryURL string `json:"registry_url,omitempty"`
Bin string `json:"bin,omitempty"`
PublishTarget string `json:"publish_target,omitempty"`
// deploy
DeployHost string `json:"deploy_host,omitempty"`
DeployPort string `json:"deploy_port,omitempty"`
@@ -109,6 +117,12 @@ func GetRepoMetadata(ctx *context.APIContext) {
Language: m.Language,
ExtensionType: m.ExtensionType,
EntryPoint: m.EntryPoint,
NodeMinimum: m.NodeMinimum,
NpmPackage: m.NpmPackage,
NpmScope: m.NpmScope,
RegistryURL: m.RegistryURL,
Bin: m.Bin,
PublishTarget: m.PublishTarget,
DeployHost: m.DeployHost,
DeployPort: m.DeployPort,
DeployUser: m.DeployUser,
@@ -188,6 +202,12 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
setStr("language", &m.Language)
setStr("extension_type", &m.ExtensionType)
setStr("entry_point", &m.EntryPoint)
setStr("node_minimum", &m.NodeMinimum)
setStr("npm_package", &m.NpmPackage)
setStr("npm_scope", &m.NpmScope)
setStr("registry_url", &m.RegistryURL)
setStr("bin", &m.Bin)
setStr("publish_target", &m.PublishTarget)
setStr("deploy_host", &m.DeployHost)
setStr("deploy_port", &m.DeployPort)
setStr("deploy_user", &m.DeployUser)
@@ -223,6 +243,12 @@ func UpdateRepoMetadata(ctx *context.APIContext) {
Language: m.Language,
ExtensionType: m.ExtensionType,
EntryPoint: m.EntryPoint,
NodeMinimum: m.NodeMinimum,
NpmPackage: m.NpmPackage,
NpmScope: m.NpmScope,
RegistryURL: m.RegistryURL,
Bin: m.Bin,
PublishTarget: m.PublishTarget,
DeployHost: m.DeployHost,
DeployPort: m.DeployPort,
DeployUser: m.DeployUser,
+14
View File
@@ -62,3 +62,17 @@ type swaggerResponseLabelTemplateInfo struct {
// in:body
Body []api.LabelTemplate `json:"body"`
}
// PlatformList
// swagger:response PlatformList
type swaggerResponsePlatformList struct {
// in:body
Body []api.Platform `json:"body"`
}
// Platform
// swagger:response Platform
type swaggerResponsePlatform struct {
// in:body
Body api.Platform `json:"body"`
}
+4
View File
@@ -20,6 +20,10 @@ func Metadata(ctx *context.Context) {
ctx.Data["Title"] = "Metadata"
ctx.Data["PageIsAdminMetadata"] = true
ctx.Data["PlatformOptions"] = strings.Join(setting.Metadata.PlatformOptions, "\n")
// Resolved platform registry (read-only view). Capability attributes are
// edited via app.ini [metadata] PLATFORM_REGISTRY; the textarea above still
// edits the flat key list (PLATFORM_OPTIONS).
ctx.Data["PlatformRegistry"] = setting.PlatformList()
ctx.HTML(http.StatusOK, tplMetadata)
}
+30 -17
View File
@@ -78,10 +78,13 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool,
// ServeUpdatesXML generates and serves a Joomla-compatible updates.xml
// from the repository's releases.
func ServeUpdatesXML(ctx *context.Context) {
// Block if platform is set to a non-Joomla value.
// Empty/unset defaults to joomla for backwards compatibility.
// Only serve the Joomla feed for joomla/both platforms. The platform is
// resolved upstream (config override → repo metadata → "joomla" default),
// so an unset or feedless (npm/mcp) platform never reaches the Joomla
// generator. Any other platform — including npm/mcp — returns NotFound.
// See #810 / #811.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != "" && platform != "joomla" && platform != "both" {
if platform != updateserver_model.PlatformJoomla && platform != updateserver_model.PlatformBoth {
ctx.NotFound(nil)
return
}
@@ -114,9 +117,10 @@ func ServeUpdatesXML(ctx *context.Context) {
// from the repository's releases. Uses the same license key validation as the
// Joomla XML feed — all platforms share the same licensing system.
func ServeDolibarrJSON(ctx *context.Context) {
// Block if platform doesn't include dolibarr.
platform := ctx.Data["RepoUpdatePlatform"]
if platform == "joomla" || platform == "" {
// Only serve for dolibarr/both. Feedless platforms (npm/mcp) and any other
// platform return NotFound rather than a feed. See #810 / #811.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformDolibarr && platform != updateserver_model.PlatformBoth {
ctx.NotFound(nil)
return
}
@@ -149,9 +153,10 @@ func ServeDolibarrJSON(ctx *context.Context) {
// ServeWordPressJSON generates and serves a WordPress PUC-compatible update feed.
// Compatible with the YahnisElsts plugin-update-checker library.
func ServeWordPressJSON(ctx *context.Context) {
// Block if platform doesn't include wordpress.
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "wordpress" && platform != "both" {
// Only serve for wordpress/both. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformWordPress && platform != updateserver_model.PlatformBoth {
ctx.NotFound(nil)
return
}
@@ -195,8 +200,10 @@ func ServeWordPressJSON(ctx *context.Context) {
// ServeComposerJSON generates and serves a Composer packages.json feed.
func ServeComposerJSON(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "composer" {
// Only serve for composer. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformComposer {
ctx.NotFound(nil)
return
}
@@ -233,8 +240,10 @@ func ServeComposerJSON(ctx *context.Context) {
// ServePrestaShopXML generates and serves a PrestaShop module update XML.
func ServePrestaShopXML(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "prestashop" {
// Only serve for prestashop. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformPrestaShop {
ctx.NotFound(nil)
return
}
@@ -260,8 +269,10 @@ func ServePrestaShopXML(ctx *context.Context) {
// ServeDrupalXML generates and serves a Drupal update status XML.
func ServeDrupalXML(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "drupal" {
// Only serve for drupal. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformDrupal {
ctx.NotFound(nil)
return
}
@@ -287,8 +298,10 @@ func ServeDrupalXML(ctx *context.Context) {
// ServeWHMCSJSON generates and serves a WHMCS module update JSON.
func ServeWHMCSJSON(ctx *context.Context) {
platform := ctx.Data["RepoUpdatePlatform"]
if platform != "whmcs" {
// Only serve for whmcs. Feedless platforms (npm/mcp) and any other
// platform return NotFound. See #810.
platform, _ := ctx.Data["RepoUpdatePlatform"].(string)
if platform != updateserver_model.PlatformWHMCS {
ctx.NotFound(nil)
return
}
+13 -4
View File
@@ -701,12 +701,21 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
// Load repo update config for platform-aware UI.
// Resolve the effective update platform for platform-aware UI. When the
// update-stream config has no explicit platform, derive it from the repo
// metadata (metadata.platform) so npm/mcp repos are recognized and the two
// stores can't drift. See #810 / #811.
cfgPlatform := ""
if repoUpdateCfg != nil {
ctx.Data["RepoUpdatePlatform"] = repoUpdateCfg.Platform
} else {
ctx.Data["RepoUpdatePlatform"] = "joomla"
cfgPlatform = repoUpdateCfg.Platform
}
metadataPlatform := ""
if meta, err := repo_model.GetRepoMetadata(ctx, repo.ID); err != nil {
log.Error("GetRepoMetadata: %v", err)
} else if meta != nil {
metadataPlatform = meta.Platform
}
ctx.Data["RepoUpdatePlatform"] = updateserver_model.ResolvePlatform(cfgPlatform, metadataPlatform)
ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name
ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName
+12 -4
View File
@@ -4,8 +4,8 @@
package context
import (
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/updateserver"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/repo"
updateserver_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/updateserver"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/user"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/log"
)
@@ -50,10 +50,18 @@ func RepoAssignmentPublicFeed() func(ctx *Context) {
return
}
ctx.Data["RepoUpdatePlatform"] = cfg.Platform
if cfg.Platform == "" {
ctx.Data["RepoUpdatePlatform"] = "joomla"
// Resolve the effective feed platform. When the update-stream config has
// no explicit platform override, derive it from the repo metadata
// (metadata.platform) so the two stores can't drift, and so npm/mcp repos
// are recognized as feedless instead of falling back to a Joomla feed.
// See #810 / #811.
metadataPlatform := ""
if meta, err := repo_model.GetRepoMetadata(ctx, repo.ID); err != nil {
log.Error("GetRepoMetadata: %v", err)
} else if meta != nil {
metadataPlatform = meta.Platform
}
ctx.Data["RepoUpdatePlatform"] = updateserver_model.ResolvePlatform(cfg.Platform, metadataPlatform)
log.Trace("Public feed access: %s/%s", ownerName, repoName)
}
+241
View File
@@ -0,0 +1,241 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package release
import (
"context"
"fmt"
"io"
"path"
"strings"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/repo"
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGIT/models/user"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/gitrepo"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/globallock"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/log"
attachment_service "code.mokoconsulting.tech/MokoConsulting/MokoGIT/services/attachment"
)
// generatedArtifactUploaderID marks a release attachment as server-generated by
// the packaging hook rather than uploaded by a human. Real uploads always set
// UploaderID to a positive doer ID (see routers/**/release_attachment.go), so
// this negative built-in bot id can never collide with a user asset. Only
// attachments carrying this marker are ever deleted by the packaging hook, which
// prevents it from destroying a user-uploaded asset that happens to share a name
// with a generated zip.
const generatedArtifactUploaderID = user_model.ActionsUserID
// getReleasePackagingLockKey builds the global-lock key that serializes the
// delete+attach sequence for a single release, following the forge convention
// (see getPullWorkingLockKey / getWikiWorkingLockKey).
func getReleasePackagingLockKey(relID int64) string {
return fmt.Sprintf("release_packaging_%d", relID)
}
// isRootEntryPoint reports whether an entry point refers to the repository root
// (and therefore does not warrant a separate subtree archive).
func isRootEntryPoint(entryPoint string) bool {
switch normalizeEntryPoint(entryPoint) {
case "", ".":
return true
default:
return false
}
}
// normalizeEntryPoint reduces an entry point to a repo-relative, cleaned path
// suitable for `git archive <commit> <path>`. It trims surrounding whitespace,
// strips a leading slash (git archive rejects absolute paths), drops a trailing
// slash, and cleans the result. A root-equivalent entry point normalizes to "".
func normalizeEntryPoint(entryPoint string) string {
ep := strings.TrimSpace(entryPoint)
ep = strings.TrimPrefix(ep, "/")
ep = strings.TrimSuffix(ep, "/")
if ep == "" {
return ""
}
ep = path.Clean(ep)
if ep == "." || ep == "/" {
return ""
}
return ep
}
// sanitizeNameSegment makes a string safe and tidy for use inside an attachment
// display name. Attachment storage paths are UUID-based (see
// AttachmentRelativePath), so there is no traversal risk — this only keeps the
// display name clean, e.g. a tag "release/1.0" would otherwise render as
// "repo-release/1.0.zip".
func sanitizeNameSegment(s string) string {
return strings.NewReplacer(
"/", "-",
"\\", "-",
"\x00", "-",
).Replace(s)
}
// findGeneratedArtifacts returns the existing, server-generated attachments on
// the release with the given name. User-uploaded assets (any UploaderID other
// than the marker) are never included, so they can never be touched.
func findGeneratedArtifacts(rel *repo_model.Release, name string) []*repo_model.Attachment {
var out []*repo_model.Attachment
for _, a := range rel.Attachments {
if a.Name == name && a.UploaderID == generatedArtifactUploaderID {
out = append(out, a)
}
}
return out
}
// attachArchive streams a `git archive` of the given commit/paths directly into
// a new release attachment via an io.Pipe, so the full zip is never buffered in
// memory. paths == nil archives the whole repository; a single-element slice
// archives that subtree. The attachment is tagged with the generated-artifact
// marker so a later run can safely recognize and replace it.
func attachArchive(ctx context.Context, rel *repo_model.Release, name, commitID string, paths []string) error {
pr, pw := io.Pipe()
go func() {
// "zip" is a valid archive format for gitrepo.CreateArchive (it only
// rejects "unknown"). usePrefix=false keeps entries at the archive root.
err := gitrepo.CreateArchive(ctx, rel.Repo, "zip", pw, false, commitID, paths)
// Closing the writer with err (nil on success) propagates any archive
// failure to the reader side so NewAttachment aborts instead of storing
// a truncated zip.
_ = pw.CloseWithError(err)
}()
attach := &repo_model.Attachment{
RepoID: rel.RepoID,
ReleaseID: rel.ID,
UploaderID: generatedArtifactUploaderID,
Name: name,
}
// size == -1: the archive is streamed with an unknown length, matching the
// unknown-size upload path already used by the attachment service.
if _, err := attachment_service.NewAttachment(ctx, attach, pr, -1); err != nil {
_ = pr.CloseWithError(err)
return fmt.Errorf("NewAttachment %s: %w", name, err)
}
return nil
}
// regenerateArtifact attaches a fresh archive under the canonical name and only
// then, on success, removes any previously generated copies of that name
// (attach-new-then-delete-old). This ordering guarantees a failed regeneration
// never nets asset loss: if the archive fails, the previous artifact is left
// intact and nothing is deleted. On success the stale generated copies are
// removed, leaving exactly one generated artifact under the name.
//
// Only artifacts carrying the generated-artifact marker are considered for
// deletion, so a user-uploaded asset of the same name is never removed. The
// caller must hold the per-release packaging lock so the brief window in which
// two same-named generated copies coexist is never observed concurrently.
func regenerateArtifact(ctx context.Context, rel *repo_model.Release, name, commitID string, paths []string) error {
// Snapshot prior generated copies BEFORE attaching the new one, so the
// post-attach cleanup removes only the old rows, not the fresh insert.
stale := findGeneratedArtifacts(rel, name)
if err := attachArchive(ctx, rel, name, commitID, paths); err != nil {
// Attach failed — leave any existing artifact untouched. No net loss.
return err
}
// New artifact is stored under the canonical name; drop the stale copies.
for _, old := range stale {
if err := repo_model.DeleteAttachment(ctx, old, true); err != nil {
// Non-fatal: log and continue. Worst case a superseded copy lingers,
// but the fresh artifact is already in place under the real name.
log.Warn("Failed to delete superseded release artifact %s (uuid %s): %v", old.Name, old.UUID, err)
}
}
return nil
}
// GenerateReleaseArtifacts packages a published release into release
// attachments: a full-repository zip always, plus an entry-point subtree zip
// when the repository metadata declares a non-root build entry point.
//
// It is idempotent and safe on re-run: only server-generated artifacts (tagged
// with the generated-artifact marker) are ever replaced, so a user-uploaded
// asset sharing a name is never destroyed. The delete+attach sequence for the
// release is serialized under a global lock, and each artifact is attached
// before its stale copy is removed, so concurrent runs and mid-run failures can
// never net an asset loss.
//
// It is intended to run BEFORE GenerateReleaseChecksums so the subsequent
// checksum pass mints a .sha256 sidecar for each generated zip automatically.
//
// Following the convention of GenerateReleaseChecksums, a failure to produce an
// individual artifact is logged and does not abort the release; only setup
// failures (metadata lookup, attachment enumeration, lock acquisition) are
// returned.
func GenerateReleaseArtifacts(ctx context.Context, rel *repo_model.Release) error {
if rel.Repo == nil {
if err := rel.LoadAttributes(ctx); err != nil {
return fmt.Errorf("LoadAttributes: %w", err)
}
}
commitID := rel.Sha1
if commitID == "" {
// No resolved commit (e.g. a draft with no tag) — nothing to archive.
return nil
}
version := sanitizeNameSegment(rel.TagName)
repoName := rel.Repo.Name
fullName := fmt.Sprintf("%s-%s.zip", repoName, version)
sourceName := fmt.Sprintf("%s-%s-source.zip", repoName, version)
// Resolve the build entry point from repo metadata, if any.
var entryPoint string
meta, err := repo_model.GetRepoMetadata(ctx, rel.RepoID)
if err != nil {
log.Warn("GenerateReleaseArtifacts: GetRepoMetadata for repo %d: %v", rel.RepoID, err)
} else if meta != nil {
entryPoint = meta.EntryPoint
}
wantSource := !isRootEntryPoint(entryPoint)
// Serialize the delete+attach sequence per-release so concurrent
// CreateRelease/UpdateRelease calls on the same release cannot interleave
// (which could duplicate zips or delete a freshly attached artifact).
return globallock.LockAndDo(ctx, getReleasePackagingLockKey(rel.ID), func(ctx context.Context) error {
// Re-load attachments inside the lock so replace decisions see a
// consistent view (including any generated artifacts from a prior run).
if err := repo_model.GetReleaseAttachments(ctx, rel); err != nil {
return fmt.Errorf("GetReleaseAttachments: %w", err)
}
created := 0
// Full-repository archive (paths == nil).
if err := regenerateArtifact(ctx, rel, fullName, commitID, nil); err != nil {
log.Error("GenerateReleaseArtifacts: full-repo zip for release %s (repo %d): %v", version, rel.RepoID, err)
} else {
created++
}
// Entry-point subtree archive. The stale-snapshot is taken from the same
// in-lock attachment read above; the full-repo archive uses a different
// name, so no re-read is needed here.
if wantSource {
subPath := normalizeEntryPoint(entryPoint)
if err := regenerateArtifact(ctx, rel, sourceName, commitID, []string{subPath}); err != nil {
log.Error("GenerateReleaseArtifacts: subtree zip (%s) for release %s (repo %d): %v", subPath, version, rel.RepoID, err)
} else {
created++
}
}
if created > 0 {
log.Info("Generated %d release artifact(s) for release %s (repo %d)", created, version, rel.RepoID)
}
return nil
})
}
+29 -4
View File
@@ -254,8 +254,20 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU
return err
}
// Generate SHA256 checksums for all release attachments
if len(attachmentUUIDs) > 0 {
// Server-side packaging: attach full-repo + entry_point subtree zips.
// Skip drafts (mirrors the notify guard below); run before the checksum
// pass so the generated zips receive .sha256 sidecars automatically.
if !rel.IsDraft {
if err := GenerateReleaseArtifacts(gitRepo.Ctx, rel); err != nil {
log.Error("GenerateReleaseArtifacts for %s: %v", rel.TagName, err)
}
}
// Generate SHA256 checksums for all release attachments.
// FOLLOW-UP (#809): GenerateReleaseChecksums re-hashes every attachment on
// the release, so publishing a release with many large assets re-reads them
// all. Consider hashing only newly added/changed attachments.
if len(attachmentUUIDs) > 0 || !rel.IsDraft {
if err := GenerateReleaseChecksums(gitRepo.Ctx, rel); err != nil {
log.Error("GenerateReleaseChecksums for %s: %v", rel.TagName, err)
}
@@ -415,8 +427,21 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
}
}
// Regenerate checksums when attachments change
if len(addAttachmentUUIDs) > 0 || len(delAttachmentUUIDs) > 0 {
// Server-side packaging on publish: (re)attach full-repo + entry_point
// subtree zips. Skip drafts (mirrors the notify guard below). The helper is
// idempotent, so a draft→publish transition replaces any prior artifacts.
// Run before the checksum pass so the zips receive .sha256 sidecars.
if !rel.IsDraft {
if err := GenerateReleaseArtifacts(ctx, rel); err != nil {
log.Error("GenerateReleaseArtifacts for %s: %v", rel.TagName, err)
}
}
// Regenerate checksums when attachments change or artifacts were generated.
// FOLLOW-UP (#809): GenerateReleaseChecksums re-hashes every attachment on
// the release, so a non-draft edit re-reads them all even if only one asset
// changed. Consider hashing only newly added/changed attachments.
if len(addAttachmentUUIDs) > 0 || len(delAttachmentUUIDs) > 0 || !rel.IsDraft {
if err := GenerateReleaseChecksums(ctx, rel); err != nil {
log.Error("GenerateReleaseChecksums for %s: %v", rel.TagName, err)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"strings"
"code.mokoconsulting.tech/MokoConsulting/MokoGIT/modules/setting"
)
// This file rewires the update server's per-platform behavior onto the platform
// registry (modules/setting) so that the update-feed generator and wire format
// for a platform are looked up from config instead of hardcoded.
//
// Overlap note (for reviewer): PR #825 (feature/updateserver-npm-mcp) also
// touches routers/web/repo/updateserver.go and adds models/updateserver/
// platform.go (platform constants + ResolvePlatform + IsFeedlessPlatform). To
// stay compatible and avoid a heavy conflict, this change is intentionally
// ADDITIVE: it adds registry lookups here rather than rewriting the Serve*
// handler bodies. The helpers below are drop-in replacements for the hardcoded
// per-platform decisions and can be adopted by the handlers once #825 lands.
// GeneratorForPlatform returns the update-feed generator name for a platform
// key, read from the registry. Falls back to "none" for unknown platforms.
//
// This replaces the hardcoded per-platform generator selection: instead of a
// switch on the platform string, callers read registry[platform].UpdateGenerator.
func GeneratorForPlatform(platform string) string {
def, _ := setting.GetPlatform(platform)
if def.UpdateGenerator == "" {
return "none"
}
return def.UpdateGenerator
}
// FeedFormatForPlatform returns the update-feed wire format (xml|json|none) for a
// platform key, read from the registry. Content-Type is derived from this.
func FeedFormatForPlatform(platform string) string {
def, _ := setting.GetPlatform(platform)
if def.FeedFormat == "" {
return "none"
}
return def.FeedFormat
}
// ContentTypeForPlatform returns the HTTP Content-Type for a platform's update
// feed, derived from the registry feed_format. Empty string when the platform
// emits no feed (feed_format=none), letting callers 404.
func ContentTypeForPlatform(platform string) string {
switch FeedFormatForPlatform(platform) {
case "xml":
return "application/xml; charset=utf-8"
case "json":
return "application/json; charset=utf-8"
default:
return ""
}
}
// PlatformServesGenerator reports whether the given platform key resolves (via
// the registry) to the named update-feed generator. This lets a Serve* handler
// gate on capability config instead of a hardcoded string compare, e.g.
//
// if !PlatformServesGenerator(platform, "joomla") { ctx.NotFound(nil); return }
//
// The empty platform string is treated as "joomla" for backwards compatibility
// with legacy Joomla repos that predate the registry (mirrors the historic
// "empty ⇒ joomla" default; PR #825's ResolvePlatform centralizes this).
func PlatformServesGenerator(platform, generator string) bool {
if strings.TrimSpace(platform) == "" {
platform = "joomla"
}
return GeneratorForPlatform(platform) == generator
}
+35
View File
@@ -15,5 +15,40 @@
</form>
<p class="tw-text-text-light tw-text-sm tw-mt-3">Existing repositories keep their stored platform value even if it is later removed from this list; it simply won't be offered to other repos.</p>
</div>
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-table" 16}} Platform Registry
</h4>
<div class="ui attached segment">
<p class="tw-text-text-light tw-text-sm">Resolved capability attributes per platform. These drive CI, the update server, and workflow sync (exposed at <code>GET /api/v1/platforms</code>). Capabilities are edited via <code>app.ini</code> <code>[metadata] PLATFORM_REGISTRY</code> (inline JSON array or a path to a JSON file such as <code>custom/platforms.json</code>); when unset, a built-in default registry is used.</p>
<table class="ui small compact table">
<thead>
<tr>
<th>Key</th>
<th>Label</th>
<th>Family</th>
<th>Artifact</th>
<th>Update Generator</th>
<th>Feed Format</th>
<th>Manifest</th>
<th>Template Repo</th>
</tr>
</thead>
<tbody>
{{range .PlatformRegistry}}
<tr>
<td><code>{{.Key}}</code></td>
<td>{{.Label}}</td>
<td>{{.Family}}</td>
<td>{{if .Artifact}}yes{{else}}no{{end}}</td>
<td>{{.UpdateGenerator}}</td>
<td>{{.FeedFormat}}</td>
<td>{{if .ManifestBased}}yes{{else}}no{{end}}</td>
<td>{{.TemplateRepo}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{template "admin/layout_footer" .}}