release: npm/mcp metadata + platform registry + update-server + packaging hook (dev -> main) [#367] #828
@@ -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}]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" .}}
|
||||
|
||||
Reference in New Issue
Block a user