From 92b6c1892ee547accaa56bfc303ae61042517864 Mon Sep 17 00:00:00 2001 From: Rihards Gailums Date: Mon, 18 May 2026 08:40:45 +0000 Subject: [PATCH 1/6] uapf: align import/export with UAPF v2.2.0 manifest - accept uapf.yaml / uapf.yml / process.uapf.* (legacy manifest.json still accepted); decode YAML manifests, validate as the v2.2.0 schema - replace embedded schema with the canonical UAPF v2.2.0 manifest schema - spec.Manifest now models kind/id/level/version/cornerstones/paths - cornerstone-aware structural validation (Level-4 requires bpmn cornerstone + resources/mappings.yaml + metadata/ownership.yaml + metadata/lifecycle.yaml) - empty-repo screen: add an Import .uapf button (modal already in repo header); repo-uapf-import.ts binds the trigger independently of the dropdown - refresh import-modal copy and home-page tree illustration to uapf.yaml --- modules/uapf/exporter.go | 80 ++++----- modules/uapf/importer.go | 99 +++++++---- modules/uapf/locate.go | 71 ++++++++ modules/uapf/spec/manifest.go | 98 +++++++--- modules/uapf/spec/validate.go | 95 +++++++--- modules/uapf/validate.go | 109 +++++++----- .../uapf/schemas/uapf-manifest.schema.json | 168 ++++++++++++------ templates/home.tmpl | 2 +- templates/repo/empty.tmpl | 1 + templates/repo/uapf_import_modal.tmpl | 2 +- web_src/js/features/repo-uapf-import.ts | 10 +- 11 files changed, 492 insertions(+), 243 deletions(-) create mode 100644 modules/uapf/locate.go diff --git a/modules/uapf/exporter.go b/modules/uapf/exporter.go index dd310bf..57255f4 100644 --- a/modules/uapf/exporter.go +++ b/modules/uapf/exporter.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "os" - "slices" "strings" repo_model "code.gitea.io/gitea/models/repo" @@ -36,49 +35,32 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i return nil, "", err } - manifestEntry, err := commit.GetTreeEntryByPath("manifest.json") + manifestName, manifestEntry, err := findManifestEntry(commit) if err != nil { - if git.IsErrNotExist(err) { - return nil, "", fmt.Errorf("manifest.json not found at ref %s", ref) - } return nil, "", err } manifestData, err := readTreeEntry(manifestEntry) if err != nil { - return nil, "", fmt.Errorf("read manifest.json: %w", err) + return nil, "", fmt.Errorf("read %s: %w", manifestName, err) } - if err := ValidateManifest(manifestData); err != nil { + if err := ValidateManifest(manifestName, manifestData); err != nil { return nil, "", err } - var manifest spec.Manifest - if err := json.Unmarshal(manifestData, &manifest); err != nil { - return nil, "", fmt.Errorf("manifest.json is not valid JSON: %w", err) - } - - refPaths, err := spec.ValidateManifest(&manifest) + manifestJSON, err := manifestToJSON(manifestName, manifestData) if err != nil { return nil, "", err } - requiredPaths := make(map[string]struct{}, len(refPaths)) - for _, rel := range refPaths { - if rel == "" { - continue - } - entry, err := commit.GetTreeEntryByPath(rel) - if err != nil { - if git.IsErrNotExist(err) { - return nil, "", fmt.Errorf("referenced path missing at ref %s: %s", ref, rel) - } - return nil, "", err - } - if entry.IsDir() { - return nil, "", fmt.Errorf("referenced path must be a file: %s", rel) - } - requiredPaths[rel] = struct{}{} + var manifest spec.Manifest + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + return nil, "", fmt.Errorf("manifest is not valid: %w", err) + } + + if err := spec.ValidateManifest(&manifest); err != nil { + return nil, "", err } entries, err := commit.Tree.ListEntriesRecursiveFast() @@ -89,7 +71,7 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i pr, pw := io.Pipe() go func() { zw := zip.NewWriter(pw) - if err := writeBytesEntry(zw, "manifest.json", manifestData); err != nil { + if err := writeBytesEntry(zw, manifestName, manifestData); err != nil { _ = pw.CloseWithError(err) return } @@ -99,8 +81,7 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i continue } name := entry.Name() - if name == "" || name == "manifest.json" { - delete(requiredPaths, name) + if name == "" || name == manifestName { continue } if entry.IsSubModule() { @@ -111,17 +92,6 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i _ = pw.CloseWithError(err) return } - delete(requiredPaths, name) - } - - if len(requiredPaths) > 0 { - missing := make([]string, 0, len(requiredPaths)) - for path := range requiredPaths { - missing = append(missing, path) - } - slices.Sort(missing) - _ = pw.CloseWithError(fmt.Errorf("referenced path missing at ref %s: %s", ref, strings.Join(missing, ", "))) - return } if err := zw.Close(); err != nil { @@ -135,16 +105,26 @@ func ExportUAPF(ctx context.Context, repo *repo_model.Repository, ref string) (i return pr, filename, nil } +// findManifestEntry locates the UAPF manifest in a commit tree, returning the +// highest-priority accepted manifest file name and its tree entry. +func findManifestEntry(commit *git.Commit) (string, *git.TreeEntry, error) { + for _, name := range ManifestNames { + entry, err := commit.GetTreeEntryByPath(name) + if err == nil { + return name, entry, nil + } + if !git.IsErrNotExist(err) { + return "", nil, err + } + } + return "", nil, fmt.Errorf("a UAPF manifest (uapf.yaml) is required in the repository") +} + func buildExportFilename(repo *repo_model.Repository, manifest spec.Manifest) string { name := manifest.Name version := manifest.Version - if manifest.Package != nil { - if manifest.Package.Name != "" { - name = manifest.Package.Name - } - if manifest.Package.Version != "" { - version = manifest.Package.Version - } + if name == "" { + name = manifest.ID } if name == "" { name = repo.Name diff --git a/modules/uapf/importer.go b/modules/uapf/importer.go index 9a2bdfb..37155b4 100644 --- a/modules/uapf/importer.go +++ b/modules/uapf/importer.go @@ -64,38 +64,36 @@ func ImportUAPF(ctx context.Context, repo *repo_model.Repository, doer *user_mod return err } - packageRoot, err := determinePackageRoot(tempDir) + packageRoot, manifestName, err := determinePackageRoot(tempDir) if err != nil { return err } - manifestPath := filepath.Join(packageRoot, "manifest.json") - manifestBytes, err := os.ReadFile(manifestPath) + manifestBytes, err := os.ReadFile(filepath.Join(packageRoot, manifestName)) if err != nil { - return fmt.Errorf("manifest.json is required in the UAPF package") + return fmt.Errorf("a UAPF manifest (uapf.yaml) is required in the package") } - if err := ValidateManifest(manifestBytes); err != nil { + if err := ValidateManifest(manifestName, manifestBytes); err != nil { + return err + } + + manifestJSON, err := manifestToJSON(manifestName, manifestBytes) + if err != nil { return err } var manifest spec.Manifest - if err := json.Unmarshal(manifestBytes, &manifest); err != nil { - return fmt.Errorf("manifest.json is not valid JSON: %w", err) + if err := json.Unmarshal(manifestJSON, &manifest); err != nil { + return fmt.Errorf("manifest is not valid: %w", err) } - refPaths, err := spec.ValidateManifest(&manifest) - if err != nil { + if err := spec.ValidateManifest(&manifest); err != nil { return err } - for _, ref := range refPaths { - if ref == "" { - return fmt.Errorf("referenced path cannot be empty") - } - if _, err := os.Stat(filepath.Join(packageRoot, filepath.FromSlash(ref))); err != nil { - return fmt.Errorf("referenced path missing in package: %s", ref) - } + if err := spec.ValidatePackageStructure(&manifest, osFileChecker{root: packageRoot}); err != nil { + return err } targetPath, err = normalizeTargetPath(targetPath) @@ -109,21 +107,15 @@ func ImportUAPF(ctx context.Context, repo *repo_model.Repository, doer *user_mod } if commitMsg == "" { - version := manifest.Version name := manifest.Name - if manifest.Package != nil { - if manifest.Package.Name != "" { - name = manifest.Package.Name - } - if manifest.Package.Version != "" { - version = manifest.Package.Version - } + if name == "" { + name = manifest.ID } if name == "" { name = "UAPF package" } - if version != "" { - commitMsg = fmt.Sprintf("Import UAPF package %s@%s", name, version) + if manifest.Version != "" { + commitMsg = fmt.Sprintf("Import UAPF package %s@%s", name, manifest.Version) } else { commitMsg = fmt.Sprintf("Import UAPF package %s", name) } @@ -202,24 +194,63 @@ func writeFile(dst string, r io.Reader, mode os.FileMode) error { return nil } -func determinePackageRoot(tempDir string) (string, error) { +func determinePackageRoot(tempDir string) (string, string, error) { + if name := findManifestName(tempDir); name != "" { + return tempDir, name, nil + } + entries, err := os.ReadDir(tempDir) if err != nil { - return "", fmt.Errorf("read archive contents: %w", err) + return "", "", fmt.Errorf("read archive contents: %w", err) } - if len(entries) == 1 && entries[0].IsDir() { single := filepath.Join(tempDir, entries[0].Name()) - if _, err := os.Stat(filepath.Join(single, "manifest.json")); err == nil { - return single, nil + if name := findManifestName(single); name != "" { + return single, name, nil } } - if _, err := os.Stat(filepath.Join(tempDir, "manifest.json")); err == nil { - return tempDir, nil + return "", "", fmt.Errorf("a UAPF manifest (uapf.yaml) is required in the package") +} + +// findManifestName returns the highest-priority manifest file name present +// directly in dir, or an empty string when none is found. +func findManifestName(dir string) string { + for _, n := range ManifestNames { + if info, err := os.Stat(filepath.Join(dir, n)); err == nil && !info.IsDir() { + return n + } } + return "" +} + +// osFileChecker implements spec.FileChecker against an extracted package dir. +type osFileChecker struct { + root string +} + +// FileExists reports whether the package-relative file exists and is a file. +func (c osFileChecker) FileExists(rel string) bool { + info, err := os.Stat(filepath.Join(c.root, filepath.FromSlash(rel))) + return err == nil && !info.IsDir() +} - return "", fmt.Errorf("manifest.json is required in the UAPF package") +// DirHasFiles reports whether dir contains at least one matching file. +func (c osFileChecker) DirHasFiles(dir, suffix string) bool { + entries, err := os.ReadDir(filepath.Join(c.root, filepath.FromSlash(dir))) + if err != nil { + return false + } + suffix = strings.ToLower(suffix) + for _, e := range entries { + if e.IsDir() { + continue + } + if suffix == "" || strings.HasSuffix(strings.ToLower(e.Name()), suffix) { + return true + } + } + return false } func normalizeTargetPath(target string) (string, error) { diff --git a/modules/uapf/locate.go b/modules/uapf/locate.go new file mode 100644 index 0000000..ce9e01b --- /dev/null +++ b/modules/uapf/locate.go @@ -0,0 +1,71 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package uapf + +import ( + "fmt" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/json" + + "gopkg.in/yaml.v3" +) + +// ManifestNames lists accepted UAPF manifest file names in priority order. +// UAPF v2.2.0 mandates uapf.yaml; the remaining names are accepted for +// compatibility with UAPF-IP packages and legacy archives. Whatever the file +// name, the contents are validated against the UAPF v2.2.0 manifest schema. +var ManifestNames = []string{ + "uapf.yaml", + "uapf.yml", + "process.uapf.yaml", + "process.uapf.yml", + "manifest.json", +} + +// IsManifestName reports whether base (a file name, not a path) is an accepted +// UAPF manifest file name. +func IsManifestName(base string) bool { + for _, n := range ManifestNames { + if base == n { + return true + } + } + return false +} + +// manifestPriority returns the priority index of a manifest base name; a lower +// value is preferred. Unknown names sort last. +func manifestPriority(base string) int { + for i, n := range ManifestNames { + if base == n { + return i + } + } + return len(ManifestNames) +} + +// isYAMLName reports whether the file name denotes a YAML document. +func isYAMLName(name string) bool { + l := strings.ToLower(name) + return strings.HasSuffix(l, ".yaml") || strings.HasSuffix(l, ".yml") +} + +// manifestToJSON normalizes manifest bytes to JSON, decoding YAML when the file +// name denotes a YAML document. JSON manifests are returned unchanged. +func manifestToJSON(name string, data []byte) ([]byte, error) { + if !isYAMLName(name) { + return data, nil + } + var v any + if err := yaml.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("%s is not valid YAML: %w", filepath.Base(name), err) + } + out, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("normalize %s: %w", filepath.Base(name), err) + } + return out, nil +} diff --git a/modules/uapf/spec/manifest.go b/modules/uapf/spec/manifest.go index 1b26a69..6e0cf09 100644 --- a/modules/uapf/spec/manifest.go +++ b/modules/uapf/spec/manifest.go @@ -3,27 +3,81 @@ package spec -// Manifest describes the structure of a UAPF manifest.json file. -// It mirrors the embedded schema and captures the references we need to validate. +// Manifest mirrors the UAPF v2.2.0 package manifest (uapf.yaml). Only the +// fields ProcessGit consumes are modelled here; full structural validation of +// the manifest is performed against the embedded JSON schema. type Manifest struct { - Name string `json:"name"` - Version string `json:"version"` - Package *Package `json:"package"` - Workflows []ReferencedEntry `json:"workflows"` - Resources []ReferencedEntry `json:"resources"` - Metadata map[string]any `json:"metadata"` -} - -// Package contains optional package metadata fields. -type Package struct { - Name string `json:"name"` - Version string `json:"version"` - Summary string `json:"summary"` - Maintainers []string `json:"maintainers"` -} - -// ReferencedEntry represents an item that points to a file within the package. -type ReferencedEntry struct { - Path string `json:"path"` - Type string `json:"type"` + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Level int `json:"level"` + Version string `json:"version"` + Lifecycle string `json:"lifecycle"` + Cornerstones *Cornerstones `json:"cornerstones"` + Paths *Paths `json:"paths"` +} + +// Cornerstones records which of the four UAPF cornerstones the package declares. +type Cornerstones struct { + BPMN bool `json:"bpmn"` + DMN bool `json:"dmn"` + CMMN bool `json:"cmmn"` + Resources bool `json:"resources"` +} + +// Paths records optional per-cornerstone directory overrides. +type Paths struct { + BPMN string `json:"bpmn"` + DMN string `json:"dmn"` + CMMN string `json:"cmmn"` + Resources string `json:"resources"` + Metadata string `json:"metadata"` +} + +func dirOr(v, def string) string { + if v == "" { + return def + } + return v +} + +// BPMNDir returns the effective bpmn cornerstone directory. +func (m *Manifest) BPMNDir() string { + if m.Paths != nil { + return dirOr(m.Paths.BPMN, "bpmn") + } + return "bpmn" +} + +// DMNDir returns the effective dmn cornerstone directory. +func (m *Manifest) DMNDir() string { + if m.Paths != nil { + return dirOr(m.Paths.DMN, "dmn") + } + return "dmn" +} + +// CMMNDir returns the effective cmmn cornerstone directory. +func (m *Manifest) CMMNDir() string { + if m.Paths != nil { + return dirOr(m.Paths.CMMN, "cmmn") + } + return "cmmn" +} + +// ResourcesDir returns the effective resources cornerstone directory. +func (m *Manifest) ResourcesDir() string { + if m.Paths != nil { + return dirOr(m.Paths.Resources, "resources") + } + return "resources" +} + +// MetadataDir returns the effective metadata directory. +func (m *Manifest) MetadataDir() string { + if m.Paths != nil { + return dirOr(m.Paths.Metadata, "metadata") + } + return "metadata" } diff --git a/modules/uapf/spec/validate.go b/modules/uapf/spec/validate.go index fca679d..206779c 100644 --- a/modules/uapf/spec/validate.go +++ b/modules/uapf/spec/validate.go @@ -5,40 +5,79 @@ package spec import ( "errors" - "path" + "fmt" ) -// ValidateManifest performs lightweight structural checks expected by the UAPF schema -// and returns a normalized list of referenced paths to verify on disk. -func ValidateManifest(manifest *Manifest) ([]string, error) { - if manifest == nil { - return nil, errors.New("manifest is missing") - } +// FileChecker abstracts package-content lookups so the same structural checks +// run against an extracted directory (import) or a git tree (export). +type FileChecker interface { + // DirHasFiles reports whether dir exists and contains at least one file + // whose name ends with suffix (case-insensitive). An empty suffix matches + // any file. + DirHasFiles(dir, suffix string) bool + // FileExists reports whether the given package-relative file exists. + FileExists(path string) bool +} - if manifest.Name == "" || manifest.Version == "" { - if manifest.Package == nil || manifest.Package.Name == "" || manifest.Package.Version == "" { - return nil, errors.New("manifest must include name and version or package.name and package.version") - } +// ValidateManifest performs manifest-internal consistency checks expected by +// UAPF v2.2.0, beyond what the embedded JSON schema already enforces. +func ValidateManifest(m *Manifest) error { + if m == nil { + return errors.New("manifest is missing") } - - refPaths := make([]string, 0, len(manifest.Workflows)+len(manifest.Resources)) - for _, wf := range manifest.Workflows { - if wf.Path == "" { - return nil, errors.New("workflows entry is missing path") - } - refPaths = append(refPaths, cleanRelativePath(wf.Path)) + if m.Kind != "uapf.package" { + return fmt.Errorf("manifest.kind must be \"uapf.package\", got %q", m.Kind) } - for _, res := range manifest.Resources { - if res.Path == "" { - return nil, errors.New("resources entry is missing path") - } - refPaths = append(refPaths, cleanRelativePath(res.Path)) + if m.ID == "" { + return errors.New("manifest.id is required") } - - return refPaths, nil + if m.Name == "" { + return errors.New("manifest.name is required") + } + if m.Version == "" { + return errors.New("manifest.version is required") + } + if m.Level < 0 || m.Level > 4 { + return fmt.Errorf("manifest.level must be between 0 and 4, got %d", m.Level) + } + if m.Cornerstones == nil { + return errors.New("manifest.cornerstones is required") + } + if m.Level >= 4 && !m.Cornerstones.BPMN { + return errors.New("a Level-4 package must declare the bpmn cornerstone") + } + return nil } -func cleanRelativePath(p string) string { - clean := path.Clean("/" + p) - return clean[1:] +// ValidatePackageStructure verifies that the package contents match what the +// manifest declares, following the UAPF v2.2.0 package-format rules. +func ValidatePackageStructure(m *Manifest, fc FileChecker) error { + if m == nil || m.Cornerstones == nil { + return errors.New("manifest is missing required fields") + } + if m.Cornerstones.BPMN && !fc.DirHasFiles(m.BPMNDir(), ".bpmn") { + return fmt.Errorf("bpmn cornerstone declared but no .bpmn file found in %s/", m.BPMNDir()) + } + if m.Cornerstones.DMN && !fc.DirHasFiles(m.DMNDir(), ".dmn") { + return fmt.Errorf("dmn cornerstone declared but no .dmn file found in %s/", m.DMNDir()) + } + if m.Cornerstones.CMMN && !fc.DirHasFiles(m.CMMNDir(), ".cmmn") { + return fmt.Errorf("cmmn cornerstone declared but no .cmmn file found in %s/", m.CMMNDir()) + } + if m.Cornerstones.Resources && !fc.DirHasFiles(m.ResourcesDir(), "") { + return fmt.Errorf("resources cornerstone declared but %s/ is empty", m.ResourcesDir()) + } + if m.Level >= 4 { + mappings := m.ResourcesDir() + "/mappings.yaml" + if !fc.FileExists(mappings) { + return fmt.Errorf("a Level-4 package requires %s", mappings) + } + for _, f := range []string{"ownership.yaml", "lifecycle.yaml"} { + p := m.MetadataDir() + "/" + f + if !fc.FileExists(p) { + return fmt.Errorf("a Level-4 package requires %s", p) + } + } + } + return nil } diff --git a/modules/uapf/validate.go b/modules/uapf/validate.go index 8025822..d3b21fb 100644 --- a/modules/uapf/validate.go +++ b/modules/uapf/validate.go @@ -36,23 +36,12 @@ func loadManifestSchema() (*jsonschema.Schema, error) { return manifestSchema, manifestSchemaErr } -// ValidatePackage ensures a .uapf archive contains a manifest.json that conforms to the embedded schema. -func ValidatePackage(data []byte) error { - readerAt := bytes.NewReader(data) - - zipReader, err := zip.NewReader(readerAt, int64(len(data))) - if err != nil { - return fmt.Errorf("invalid .uapf archive: %w", err) - } - - manifestJSON, err := extractManifest(zipReader) - if err != nil { - return err - } - +// validateAgainstSchema validates normalized JSON manifest bytes against the +// embedded UAPF v2.2.0 manifest schema. +func validateAgainstSchema(jsonData []byte) error { var manifest any - if err := json.Unmarshal(manifestJSON, &manifest); err != nil { - return fmt.Errorf("manifest.json is not valid JSON: %w", err) + if err := json.Unmarshal(jsonData, &manifest); err != nil { + return fmt.Errorf("manifest is not valid JSON: %w", err) } schema, err := loadManifestSchema() @@ -61,55 +50,81 @@ func ValidatePackage(data []byte) error { } if err := schema.Validate(manifest); err != nil { - if validationErr, ok := err.(*jsonschema.ValidationError); ok { - return fmt.Errorf("manifest validation failed: %s", validationErr) + var ve *jsonschema.ValidationError + if errors.As(err, &ve) { + return fmt.Errorf("manifest validation failed: %s", ve) } return fmt.Errorf("manifest validation failed: %w", err) } - return nil } -func extractManifest(zipReader *zip.Reader) ([]byte, error) { +// ValidatePackage ensures a .uapf archive contains a UAPF manifest (uapf.yaml) +// that conforms to the embedded UAPF v2.2.0 schema. +func ValidatePackage(data []byte) error { + zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return fmt.Errorf("invalid .uapf archive: %w", err) + } + + name, raw, err := extractManifest(zipReader) + if err != nil { + return err + } + + jsonData, err := manifestToJSON(name, raw) + if err != nil { + return err + } + + return validateAgainstSchema(jsonData) +} + +// extractManifest returns the highest-priority manifest file found anywhere in +// the archive: its base name and raw bytes. +func extractManifest(zipReader *zip.Reader) (string, []byte, error) { + bestName := "" + bestPriority := len(ManifestNames) + var bestFile *zip.File + for _, file := range zipReader.File { - name := filepath.Clean(file.Name) - if filepath.Base(name) != "manifest.json" { + if file.FileInfo().IsDir() { continue } - - manifestReader, err := file.Open() - if err != nil { - return nil, fmt.Errorf("open manifest.json: %w", err) + base := filepath.Base(filepath.Clean(file.Name)) + if !IsManifestName(base) { + continue } - defer manifestReader.Close() - - contents, err := io.ReadAll(manifestReader) - if err != nil { - return nil, fmt.Errorf("read manifest.json: %w", err) + if p := manifestPriority(base); p < bestPriority { + bestPriority = p + bestName = base + bestFile = file } - return contents, nil } - return nil, errors.New("manifest.json is required in the UAPF package") -} + if bestFile == nil { + return "", nil, errors.New("a UAPF manifest (uapf.yaml) is required in the package") + } -// ValidateManifest validates manifest.json contents against the embedded schema. -func ValidateManifest(data []byte) error { - var manifest any - if err := json.Unmarshal(data, &manifest); err != nil { - return fmt.Errorf("manifest.json is not valid JSON: %w", err) + rc, err := bestFile.Open() + if err != nil { + return "", nil, fmt.Errorf("open %s: %w", bestName, err) } + defer rc.Close() - schema, err := loadManifestSchema() + contents, err := io.ReadAll(rc) if err != nil { - return fmt.Errorf("load manifest schema: %w", err) + return "", nil, fmt.Errorf("read %s: %w", bestName, err) } + return bestName, contents, nil +} - if err := schema.Validate(manifest); err != nil { - if validationErr, ok := err.(*jsonschema.ValidationError); ok { - return fmt.Errorf("manifest validation failed: %s", validationErr) - } - return fmt.Errorf("manifest validation failed: %w", err) +// ValidateManifest validates a manifest file's raw bytes (YAML or JSON, +// selected by name) against the embedded UAPF v2.2.0 schema. +func ValidateManifest(name string, data []byte) error { + jsonData, err := manifestToJSON(name, data) + if err != nil { + return err } - return nil + return validateAgainstSchema(jsonData) } diff --git a/resources/uapf/schemas/uapf-manifest.schema.json b/resources/uapf/schemas/uapf-manifest.schema.json index 3b092d8..9f80c83 100644 --- a/resources/uapf/schemas/uapf-manifest.schema.json +++ b/resources/uapf/schemas/uapf-manifest.schema.json @@ -1,76 +1,132 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "UAPF manifest", - "description": "Validation schema for UAPF manifest.json files.", + "$id": "https://uapf.dev/schemas/uapf-manifest.schema.json", + "title": "UAPF Manifest (uapf.yaml) Schema", "type": "object", - "additionalProperties": true, + "additionalProperties": false, + "required": ["id", "name", "level", "version", "kind"], "properties": { - "name": { - "type": "string", - "minLength": 1 - }, + "kind": { "type": "string", "enum": ["uapf.package"] }, + "id": { "type": "string", "minLength": 3, "pattern": "^[a-z0-9][a-z0-9._-]+$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "level": { "type": "integer", "minimum": 0, "maximum": 4 }, "version": { "type": "string", - "minLength": 1 + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "includes": { "type": "array", "items": { "$ref": "#/$defs/packageRef" }, "default": [] }, + "dependencies": { + "type": "object", + "description": "External package dependencies with version constraints", + "additionalProperties": { + "type": "string", + "pattern": "^(\\^|~|>=|<=|>|<|=)?[0-9]+(\\.[0-9]+)*(\\.[0-9]+)?(-[a-zA-Z0-9.-]+)?(,\\s*(>=|<=|>|<|=)?[0-9]+(\\.[0-9]+)*(\\.[0-9]+)?(-[a-zA-Z0-9.-]+)?)*$|^latest$" + } + }, + "cornerstones": { + "type": "object", + "additionalProperties": false, + "required": ["bpmn", "dmn", "cmmn", "resources"], + "properties": { + "bpmn": { "type": "boolean" }, + "dmn": { "type": "boolean" }, + "cmmn": { "type": "boolean" }, + "resources": { "type": "boolean" } + } }, - "description": { - "type": "string" + "paths": { + "type": "object", + "additionalProperties": false, + "properties": { + "bpmn": { "type": "string", "default": "bpmn" }, + "dmn": { "type": "string", "default": "dmn" }, + "cmmn": { "type": "string", "default": "cmmn" }, + "resources": { "type": "string", "default": "resources" }, + "metadata": { "type": "string", "default": "metadata" } + } }, - "package": { + "exposure": { "type": "object", - "additionalProperties": true, + "description": "Declarative exposure rules for external bindings such as MCP", + "additionalProperties": false, "properties": { - "name": { - "type": "string", - "minLength": 1 - }, - "version": { - "type": "string", - "minLength": 1 - }, - "summary": { - "type": "string" - }, - "maintainers": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 + "mcp": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true }, + "runnable": { "type": "boolean" }, + "exposedEntrypoints": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["tool"], + "properties": { + "decision": { "type": "string" }, + "process": { "type": "string" }, + "tool": { "type": "string", "minLength": 1 } + } + } + ] + } + }, + "exposedArtifacts": { + "type": "array", + "items": { "enum": ["manifest", "bpmn", "dmn", "cmmn", "docs", "tests"] } + } } } - }, - "required": [ - "name", - "version" - ] - }, - "workflows": { - "type": "array", - "items": { - "type": "object" } }, - "resources": { - "type": "array", - "items": { - "type": "object" + "owners": { "type": "array", "items": { "$ref": "#/$defs/owner" }, "minItems": 1 }, + "lifecycle": { "type": "string", "enum": ["draft", "review", "approved", "deprecated"] }, + "artifacts": { + "type": "object", + "description": "Optional per-cornerstone artifact inventory", + "additionalProperties": false, + "properties": { + "bpmn": { "type": "array", "items": { "$ref": "#/$defs/artifactEntry" } }, + "dmn": { "type": "array", "items": { "$ref": "#/$defs/artifactEntry" } }, + "cmmn": { "type": "array", "items": { "$ref": "#/$defs/artifactEntry" } }, + "resources": { "type": "array", "items": { "$ref": "#/$defs/artifactEntry" } } } }, - "metadata": { - "type": "object" - } + "inputs": { "type": "array", "items": { "type": "string" } }, + "outputs": { "type": "array", "items": { "type": "string" } }, + "requires_capabilities": { "type": "array", "items": { "type": "string" } }, + "profiles_supported": { "type": "array", "items": { "type": "string" } }, + "guardrails": { "type": "string" } }, - "anyOf": [ - { - "required": [ - "name", - "version" - ] + "$defs": { + "packageRef": { + "type": "string", + "minLength": 1, + "pattern": "^(\\.\\.?/|package:)[^\\s]+$" + }, + "owner": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": { "type": "string", "enum": ["team", "person", "role"] }, + "id": { "type": "string", "minLength": 1 }, + "contact": { "type": "string" } + } }, - { - "required": [ - "package" - ] + "artifactEntry": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "role": { "type": "string" }, + "description": { "type": "string" } + } } - ] + } } diff --git a/templates/home.tmpl b/templates/home.tmpl index ff8c0d3..d277fb6 100644 --- a/templates/home.tmpl +++ b/templates/home.tmpl @@ -105,7 +105,7 @@

Learn more at uapf.dev → -
my-process.uapf/
  manifest.json
  models/
    bpmn/main.bpmn
    dmn/decisions.dmn
    cmmn/case.cmmn
  tests/
    scenarios/*.json
  docs/
    overview.md
+
my-process.uapf/
  uapf.yaml
  models/
    bpmn/main.bpmn
    dmn/decisions.dmn
    cmmn/case.cmmn
  tests/
    scenarios/*.json
  docs/
    overview.md
diff --git a/templates/repo/empty.tmpl b/templates/repo/empty.tmpl index fd3cf2b..bc10d77 100644 --- a/templates/repo/empty.tmpl +++ b/templates/repo/empty.tmpl @@ -27,6 +27,7 @@
{{if and .CanWriteCode (not .Repository.IsArchived)}} + {{svg "octicon-file-zip" 16 "tw-mr-1"}}Import .uapf {{ctx.Locale.Tr "repo.editor.new_file"}} diff --git a/templates/repo/uapf_import_modal.tmpl b/templates/repo/uapf_import_modal.tmpl index c8321b2..a08a581 100644 --- a/templates/repo/uapf_import_modal.tmpl +++ b/templates/repo/uapf_import_modal.tmpl @@ -16,7 +16,7 @@
- The uploaded package will be validated against UAPF v1 schemas before it is committed. + The uploaded package is validated against the UAPF v2.2.0 manifest schema before it is committed. The manifest may be uapf.yaml (preferred) or manifest.json.