Skip to content

Commit 85d8185

Browse files
authored
feat(extensions): add schema_version field for manifest schema evolution (#222)
* feat(extensions): add schema_version field for manifest schema evolution * test(extensions): add schema_version validation and loader tests * chore(extensions): add schema_version to contrib extension manifests
1 parent aad9264 commit 85d8185

File tree

7 files changed

+246
-8
lines changed

7 files changed

+246
-8
lines changed

contrib/extensions/commit-validator/extension.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
schema_version: 1
12
name: commit-validator
23
version: 1.0.0
34
description: Validates that commits follow conventional commit format

contrib/extensions/docker-tag-sync/extension.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
schema_version: 1
12
name: docker-tag-sync
23
version: 1.0.0
34
description: Tags and optionally pushes Docker images with the new version after bumps

contrib/extensions/github-version-sync/extension.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
schema_version: 1
12
name: github-version-sync
23
version: 1.0.0
34
description: Syncs the .version file to the latest release from a GitHub repository

internal/extensions/manifest.go

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@ import (
55
"strings"
66
)
77

8+
const (
9+
// CurrentSchemaVersion is the latest manifest schema version supported by this build.
10+
CurrentSchemaVersion = 1
11+
12+
// DefaultSchemaVersion is used when the schema_version field is omitted (backward compat).
13+
DefaultSchemaVersion = 1
14+
)
15+
16+
// SchemaVersionError indicates the manifest requires a newer schema version than supported.
17+
type SchemaVersionError struct {
18+
Path string
19+
Found int
20+
MaxSupported int
21+
}
22+
23+
func (e *SchemaVersionError) Error() string {
24+
return fmt.Sprintf("manifest at %s requires schema version %d, but this build only supports up to version %d",
25+
e.Path, e.Found, e.MaxSupported)
26+
}
27+
28+
// Suggestion returns guidance on resolving the version mismatch
29+
func (e *SchemaVersionError) Suggestion() string {
30+
var sb strings.Builder
31+
32+
fmt.Fprintf(&sb, "Manifest schema version %d is not supported (max: %d).\n\n", e.Found, e.MaxSupported)
33+
sb.WriteString("Please upgrade sley to a newer version that supports this manifest schema:\n\n")
34+
sb.WriteString(" go install github.com/indaco/sley@latest\n\n")
35+
sb.WriteString("Documentation: https://sley.indaco.dev/extensions/index.html\n")
36+
37+
return sb.String()
38+
}
39+
840
// ManifestNotFoundError indicates that an extension.yaml file is missing
941
type ManifestNotFoundError struct {
1042
Path string
@@ -21,6 +53,7 @@ func (e *ManifestNotFoundError) Suggestion() string {
2153

2254
fmt.Fprintf(&sb, "Extension manifest not found at: %s\n\n", e.Path)
2355
sb.WriteString("A valid extension.yaml file is required with these fields:\n\n")
56+
sb.WriteString(" schema_version: 1\n")
2457
sb.WriteString(" name: my-extension\n")
2558
sb.WriteString(" version: 1.0.0\n")
2659
sb.WriteString(" description: Brief description of what this extension does\n")
@@ -92,18 +125,39 @@ func (e *ManifestValidationError) Suggestion() string {
92125
// - Entry: Path to the executable script or binary (relative to extension directory)
93126
// - Hooks: List of hook points this extension supports (optional)
94127
type ExtensionManifest struct {
95-
Name string `yaml:"name"`
96-
Version string `yaml:"version"`
97-
Description string `yaml:"description"`
98-
Author string `yaml:"author"`
99-
Repository string `yaml:"repository"`
100-
Entry string `yaml:"entry"`
101-
Hooks []string `yaml:"hooks,omitempty"`
128+
SchemaVersion int `yaml:"schema_version,omitempty"`
129+
Name string `yaml:"name"`
130+
Version string `yaml:"version"`
131+
Description string `yaml:"description"`
132+
Author string `yaml:"author"`
133+
Repository string `yaml:"repository"`
134+
Entry string `yaml:"entry"`
135+
Hooks []string `yaml:"hooks,omitempty"`
102136
}
103137

104-
// ValidateManifest ensures all required fields are present.
138+
// ValidateManifest ensures all required fields are present and the manifest version is supported.
105139
// Returns an error listing all missing fields if validation fails.
106140
func (m *ExtensionManifest) ValidateManifest() error {
141+
// Default omitted schema_version to DefaultSchemaVersion
142+
if m.SchemaVersion == 0 {
143+
m.SchemaVersion = DefaultSchemaVersion
144+
}
145+
146+
// Reject negative or otherwise invalid schema versions
147+
if m.SchemaVersion < 1 {
148+
return &ManifestValidationError{
149+
MissingFields: []string{"schema_version (must be >= 1)"},
150+
}
151+
}
152+
153+
// Reject schema versions newer than what this build supports
154+
if m.SchemaVersion > CurrentSchemaVersion {
155+
return &SchemaVersionError{
156+
Found: m.SchemaVersion,
157+
MaxSupported: CurrentSchemaVersion,
158+
}
159+
}
160+
107161
var missingFields []string
108162

109163
if m.Name == "" {

internal/extensions/manifest_loader.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ func loadExtensionManifest(dir string) (*ExtensionManifest, error) {
4141

4242
if err := manifest.ValidateManifest(); err != nil {
4343
// If it's already our custom error type, add the path
44+
var versionErr *SchemaVersionError
45+
if errors.As(err, &versionErr) {
46+
versionErr.Path = manifestPath
47+
return nil, versionErr
48+
}
4449
var valErr *ManifestValidationError
4550
if errors.As(err, &valErr) {
4651
valErr.Path = manifestPath

internal/extensions/manifest_loader_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,56 @@ entry: actions.json
3636
if m.Name != "test" {
3737
t.Errorf("expected name 'test', got %q", m.Name)
3838
}
39+
// Omitted schema_version should default to 1
40+
if m.SchemaVersion != DefaultSchemaVersion {
41+
t.Errorf("expected SchemaVersion %d, got %d", DefaultSchemaVersion, m.SchemaVersion)
42+
}
43+
}
44+
45+
func TestLoadExtensionManifest_WithSchemaVersion(t *testing.T) {
46+
dir := t.TempDir()
47+
content := `
48+
schema_version: 1
49+
name: test
50+
version: 0.1.0
51+
description: test plugin
52+
author: me
53+
repository: https://example.com/repo
54+
entry: actions.json
55+
`
56+
writeExtensionYAML(t, dir, content)
57+
58+
m, err := LoadExtensionManifestFn(dir)
59+
if err != nil {
60+
t.Fatalf("expected no error, got %v", err)
61+
}
62+
if m.SchemaVersion != 1 {
63+
t.Errorf("expected SchemaVersion 1, got %d", m.SchemaVersion)
64+
}
65+
}
66+
67+
func TestLoadExtensionManifest_UnsupportedSchemaVersion(t *testing.T) {
68+
dir := t.TempDir()
69+
content := `
70+
schema_version: 99
71+
name: test
72+
version: 0.1.0
73+
description: test plugin
74+
author: me
75+
repository: https://example.com/repo
76+
entry: actions.json
77+
`
78+
writeExtensionYAML(t, dir, content)
79+
80+
_, err := LoadExtensionManifestFn(dir)
81+
if err == nil {
82+
t.Fatal("expected error for unsupported manifest version, got nil")
83+
}
84+
85+
var vErr *SchemaVersionError
86+
if !errors.As(err, &vErr) {
87+
t.Errorf("expected SchemaVersionError, got %T: %v", err, err)
88+
}
3989
}
4090

4191
func TestLoadExtensionManifest_MissingFile(t *testing.T) {

internal/extensions/manifest_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ func TestManifestNotFoundError(t *testing.T) {
349349
suggestion := err.Suggestion()
350350
expectedParts := []string{
351351
"Extension manifest not found",
352+
"schema_version:",
352353
"name:",
353354
"version:",
354355
"description:",
@@ -426,6 +427,131 @@ func TestManifestValidationError(t *testing.T) {
426427
}
427428
}
428429

430+
// TestSchemaVersionError tests the SchemaVersionError type
431+
func TestSchemaVersionError(t *testing.T) {
432+
err := &SchemaVersionError{
433+
Path: "/path/to/extension.yaml",
434+
Found: 99,
435+
MaxSupported: 1,
436+
}
437+
438+
// Test Error() method
439+
errMsg := err.Error()
440+
if !strings.Contains(errMsg, "schema version 99") {
441+
t.Errorf("Error() should contain found version, got: %s", errMsg)
442+
}
443+
if !strings.Contains(errMsg, "up to version 1") {
444+
t.Errorf("Error() should contain max supported version, got: %s", errMsg)
445+
}
446+
if !strings.Contains(errMsg, "/path/to/extension.yaml") {
447+
t.Errorf("Error() should contain path, got: %s", errMsg)
448+
}
449+
450+
// Test Suggestion() method
451+
suggestion := err.Suggestion()
452+
expectedParts := []string{
453+
"not supported",
454+
"upgrade sley",
455+
"go install",
456+
"Documentation:",
457+
}
458+
for _, part := range expectedParts {
459+
if !strings.Contains(suggestion, part) {
460+
t.Errorf("Suggestion() should contain %q, got: %s", part, suggestion)
461+
}
462+
}
463+
}
464+
465+
// TestSchemaVersion_Validation tests schema_version field behavior
466+
func TestSchemaVersion_Validation(t *testing.T) {
467+
base := ExtensionManifest{
468+
Name: "test-ext",
469+
Version: "1.0.0",
470+
Description: "Test extension",
471+
Author: "Author",
472+
Repository: "https://github.com/test/repo",
473+
Entry: "hook.sh",
474+
}
475+
476+
tests := []struct {
477+
name string
478+
schemaVersion int
479+
wantErr bool
480+
wantErrType string // "version" or "validation"
481+
wantDefault int // expected SchemaVersion after validation
482+
}{
483+
{
484+
name: "omitted defaults to 1",
485+
schemaVersion: 0,
486+
wantErr: false,
487+
wantDefault: DefaultSchemaVersion,
488+
},
489+
{
490+
name: "explicit version 1 accepted",
491+
schemaVersion: 1,
492+
wantErr: false,
493+
wantDefault: 1,
494+
},
495+
{
496+
name: "future version 99 rejected",
497+
schemaVersion: 99,
498+
wantErr: true,
499+
wantErrType: "version",
500+
},
501+
{
502+
name: "negative version rejected",
503+
schemaVersion: -1,
504+
wantErr: true,
505+
wantErrType: "validation",
506+
},
507+
}
508+
509+
for _, tt := range tests {
510+
t.Run(tt.name, func(t *testing.T) {
511+
m := base
512+
m.SchemaVersion = tt.schemaVersion
513+
514+
err := m.ValidateManifest()
515+
516+
if (err != nil) != tt.wantErr {
517+
t.Errorf("ValidateManifest() error = %v, wantErr %v", err, tt.wantErr)
518+
return
519+
}
520+
521+
if tt.wantErr {
522+
switch tt.wantErrType {
523+
case "version":
524+
var vErr *SchemaVersionError
525+
if !errors.As(err, &vErr) {
526+
t.Errorf("expected SchemaVersionError, got %T: %v", err, err)
527+
}
528+
case "validation":
529+
var valErr *ManifestValidationError
530+
if !errors.As(err, &valErr) {
531+
t.Errorf("expected ManifestValidationError, got %T: %v", err, err)
532+
}
533+
}
534+
} else if m.SchemaVersion != tt.wantDefault {
535+
t.Errorf("expected SchemaVersion %d, got %d", tt.wantDefault, m.SchemaVersion)
536+
}
537+
})
538+
}
539+
}
540+
541+
// TestSchemaVersionConstants tests that constants are consistent
542+
func TestSchemaVersionConstants(t *testing.T) {
543+
if CurrentSchemaVersion < 1 {
544+
t.Errorf("CurrentSchemaVersion should be >= 1, got %d", CurrentSchemaVersion)
545+
}
546+
if DefaultSchemaVersion < 1 {
547+
t.Errorf("DefaultSchemaVersion should be >= 1, got %d", DefaultSchemaVersion)
548+
}
549+
if DefaultSchemaVersion > CurrentSchemaVersion {
550+
t.Errorf("DefaultSchemaVersion (%d) should not exceed CurrentSchemaVersion (%d)",
551+
DefaultSchemaVersion, CurrentSchemaVersion)
552+
}
553+
}
554+
429555
// TestManifestValidation_MultipleErrors tests that all missing fields are reported
430556
func TestManifestValidation_MultipleErrors(t *testing.T) {
431557
manifest := ExtensionManifest{

0 commit comments

Comments
 (0)