From b01aae17e7f9f7a836564b2d0c89c008e7abb030 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 27 Aug 2026 18:59:06 +0200 Subject: [PATCH 1/3] feat(prinfo): promote prinfo to a public package Move internal/prinfo to pkg/prinfo so consumers outside this module can use the CHAINLOOP_PR_INFO types instead of hand-copying them. The pr-info JSON schemas move alongside the types, and the package now exposes the schema versions, the canonical schema URLs, the raw schema documents and a Validate function. The PRInfoVersion constants and ValidatePRInfo are removed from internal/schemavalidators so the versions are declared in a single place. Also restores pr-info-1.2.schema.json, which a stale //go:generate directive had silently regenerated from a later revision of the Data struct, making the published 1.2 schema require the object-form author introduced in 1.3. The directive is removed: published schemas are immutable and the generator is now a manual scaffold for cutting a new version. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 1fcad8c1-fedc-4783-aece-b4fbb6966acc --- internal/schemavalidators/schemavalidators.go | 71 ------- .../schemavalidators/schemavalidators_test.go | 41 ---- .../crafter/collector_prmetadata.go | 2 +- .../crafter/materials/chainloop_pr_info.go | 5 +- .../materials/chainloop_pr_info_test.go | 9 +- pkg/attestation/crafter/prmetadata.go | 2 +- pkg/attestation/crafter/prmetadata_test.go | 2 +- {internal => pkg}/prinfo/generator.go | 10 +- {internal => pkg}/prinfo/prinfo.go | 0 {internal => pkg}/prinfo/prinfo_test.go | 10 +- pkg/prinfo/schema.go | 130 +++++++++++ pkg/prinfo/schema_test.go | 201 ++++++++++++++++++ {internal => pkg}/prinfo/schemas/generate.go | 20 +- .../prinfo/schemas}/pr-info-1.0.schema.json | 0 .../prinfo/schemas}/pr-info-1.1.schema.json | 0 .../prinfo/schemas}/pr-info-1.2.schema.json | 26 +-- .../prinfo/schemas}/pr-info-1.3.schema.json | 0 .../testdata/pr_info_missing_required.json | 0 .../prinfo}/testdata/pr_info_valid.json | 0 19 files changed, 366 insertions(+), 163 deletions(-) rename {internal => pkg}/prinfo/generator.go (82%) rename {internal => pkg}/prinfo/prinfo.go (100%) rename {internal => pkg}/prinfo/prinfo_test.go (96%) create mode 100644 pkg/prinfo/schema.go create mode 100644 pkg/prinfo/schema_test.go rename {internal => pkg}/prinfo/schemas/generate.go (50%) rename {internal/schemavalidators/internal_schemas/prinfo => pkg/prinfo/schemas}/pr-info-1.0.schema.json (100%) rename {internal/schemavalidators/internal_schemas/prinfo => pkg/prinfo/schemas}/pr-info-1.1.schema.json (100%) rename {internal/schemavalidators/internal_schemas/prinfo => pkg/prinfo/schemas}/pr-info-1.2.schema.json (83%) rename {internal/schemavalidators/internal_schemas/prinfo => pkg/prinfo/schemas}/pr-info-1.3.schema.json (100%) rename {internal/schemavalidators => pkg/prinfo}/testdata/pr_info_missing_required.json (100%) rename {internal/schemavalidators => pkg/prinfo}/testdata/pr_info_valid.json (100%) diff --git a/internal/schemavalidators/schemavalidators.go b/internal/schemavalidators/schemavalidators.go index 3e410a188..3a234e92d 100644 --- a/internal/schemavalidators/schemavalidators.go +++ b/internal/schemavalidators/schemavalidators.go @@ -45,9 +45,6 @@ type AsyncAPIVersion string // RunnerContextVersion represents the version of Runner Context schema. type RunnerContextVersion string -// PRInfoVersion represents the version of PR/MR Info schema. -type PRInfoVersion string - // AIAgentConfigVersion represents the version of AI Agent Config schema. type AIAgentConfigVersion string @@ -62,14 +59,6 @@ type ScorecardVersion string const ( // RunnerContextVersion0_1 represents Runner Context version 0.1 schema. RunnerContextVersion0_1 RunnerContextVersion = "0.1" - // PRInfoVersion1_0 represents PR/MR Info version 1.0 schema. - PRInfoVersion1_0 PRInfoVersion = "1.0" - // PRInfoVersion1_1 represents PR/MR Info version 1.1 schema (adds reviewers). - PRInfoVersion1_1 PRInfoVersion = "1.1" - // PRInfoVersion1_2 represents PR/MR Info version 1.2 schema (adds requested and review_status to reviewers). - PRInfoVersion1_2 PRInfoVersion = "1.2" - // PRInfoVersion1_3 represents PR/MR Info version 1.3 schema (author as object with type). - PRInfoVersion1_3 PRInfoVersion = "1.3" // CycloneDXVersion1_5 represents CycloneDX version 1.5 schema. CycloneDXVersion1_5 CycloneDXVersion = "1.5" // CycloneDXVersion1_6 represents CycloneDX version 1.6 schema. @@ -129,16 +118,6 @@ var ( //go:embed internal_schemas/runnercontext/runner-context-response-0.1.schema.json runnerContextSpecVersion0_1 string - // PR/MR Info schemas - //go:embed internal_schemas/prinfo/pr-info-1.0.schema.json - prInfoSpecVersion1_0 string - //go:embed internal_schemas/prinfo/pr-info-1.1.schema.json - prInfoSpecVersion1_1 string - //go:embed internal_schemas/prinfo/pr-info-1.2.schema.json - prInfoSpecVersion1_2 string - //go:embed internal_schemas/prinfo/pr-info-1.3.schema.json - prInfoSpecVersion1_3 string - // AI Agent Config schemas //go:embed internal_schemas/aiagentconfig/ai-agent-config-0.1.schema.json aiAgentConfigSpecVersion0_1 string @@ -181,8 +160,6 @@ var ( csafOnce sync.Once compiledRunnerContextSchemas map[RunnerContextVersion]*jsonschema.Schema runnerContextOnce sync.Once - compiledPRInfoSchemas map[PRInfoVersion]*jsonschema.Schema - prInfoOnce sync.Once compiledAIAgentConfigSchemas map[AIAgentConfigVersion]*jsonschema.Schema aiAgentConfigOnce sync.Once compiledAICodingSessionSchemas map[AICodingSessionVersion]*jsonschema.Schema @@ -258,29 +235,6 @@ func initRunnerContextSchemas() { } } -func initPRInfoSchemas() { - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_0)); err != nil { - panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json", err)) - } - if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_1)); err != nil { - panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json", err)) - } - if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_2)); err != nil { - panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json", err)) - } - if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_3)); err != nil { - panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json", err)) - } - - compiledPRInfoSchemas = map[PRInfoVersion]*jsonschema.Schema{ - PRInfoVersion1_0: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json"), - PRInfoVersion1_1: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json"), - PRInfoVersion1_2: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json"), - PRInfoVersion1_3: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json"), - } -} - func initAIAgentConfigSchemas() { compiler := jsonschema.NewCompiler() if err := compiler.AddResource("https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json", strings.NewReader(aiAgentConfigSpecVersion0_1)); err != nil { @@ -415,31 +369,6 @@ func ValidateChainloopRunnerContext(data interface{}, version RunnerContextVersi return nil } -// ValidatePRInfo validates the PR/MR info schema. -func ValidatePRInfo(data interface{}, version PRInfoVersion) error { - prInfoOnce.Do(initPRInfoSchemas) - - if version == "" { - version = PRInfoVersion1_3 - } - - schema, ok := compiledPRInfoSchemas[version] - if !ok { - return errors.New("invalid PR info schema version") - } - - if err := schema.Validate(data); err != nil { - var invalidJSONTypeError jsonschema.InvalidJSONTypeError - if errors.As(err, &invalidJSONTypeError) { - return ErrInvalidJSONPayload - } - return err - } - - return nil -} - -// ValidateAIAgentConfig validates the AI agent config schema. func ValidateAIAgentConfig(data any, version AIAgentConfigVersion) error { aiAgentConfigOnce.Do(initAIAgentConfigSchemas) diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 432dd9d79..f04c28b8c 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -235,47 +235,6 @@ func TestValidateRunnerContext(t *testing.T) { } } -func TestValidatePRInfo(t *testing.T) { - testCases := []struct { - name string - filePath string - wantErr string - }{ - { - name: "valid PR info with all fields", - filePath: "./testdata/pr_info_valid.json", - }, - { - name: "missing required fields", - filePath: "./testdata/pr_info_missing_required.json", - wantErr: "missing properties", - }, - { - name: "completely wrong format", - filePath: "./testdata/sbom-spdx.json", - wantErr: "missing properties", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - f, err := os.ReadFile(tc.filePath) - require.NoError(t, err) - - var v any - require.NoError(t, json.Unmarshal(f, &v)) - - err = schemavalidators.ValidatePRInfo(v, "") - if tc.wantErr != "" { - require.ErrorContains(t, err, tc.wantErr) - return - } - - require.NoError(t, err) - }) - } -} - func TestValidateAICodingSession(t *testing.T) { testCases := []struct { name string diff --git a/pkg/attestation/crafter/collector_prmetadata.go b/pkg/attestation/crafter/collector_prmetadata.go index 8426a2479..2df29b677 100644 --- a/pkg/attestation/crafter/collector_prmetadata.go +++ b/pkg/attestation/crafter/collector_prmetadata.go @@ -22,8 +22,8 @@ import ( "os" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/prinfo" "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/chainloop-dev/chainloop/pkg/prinfo" ) // PRMetadataCollector collects pull/merge request metadata from the CI environment. diff --git a/pkg/attestation/crafter/materials/chainloop_pr_info.go b/pkg/attestation/crafter/materials/chainloop_pr_info.go index e5a2c421d..275052d39 100644 --- a/pkg/attestation/crafter/materials/chainloop_pr_info.go +++ b/pkg/attestation/crafter/materials/chainloop_pr_info.go @@ -22,10 +22,9 @@ import ( "os" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/prinfo" - "github.com/chainloop-dev/chainloop/internal/schemavalidators" api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/chainloop-dev/chainloop/pkg/prinfo" "github.com/rs/zerolog" ) @@ -75,7 +74,7 @@ func (i *ChainloopPRInfoCrafter) Craft(ctx context.Context, artifactPath string) } // Validate the data against JSON schema - if err := schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_3); err != nil { + if err := prinfo.Validate(rawData, prinfo.LatestVersion); err != nil { i.logger.Debug().Err(err).Msg("schema validation failed") return nil, fmt.Errorf("PR info validation failed: %w", err) } diff --git a/pkg/attestation/crafter/materials/chainloop_pr_info_test.go b/pkg/attestation/crafter/materials/chainloop_pr_info_test.go index 8e433819e..31144270e 100644 --- a/pkg/attestation/crafter/materials/chainloop_pr_info_test.go +++ b/pkg/attestation/crafter/materials/chainloop_pr_info_test.go @@ -22,8 +22,7 @@ import ( "testing" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/prinfo" - "github.com/chainloop-dev/chainloop/internal/schemavalidators" + "github.com/chainloop-dev/chainloop/pkg/prinfo" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -118,7 +117,7 @@ func TestChainloopPRInfoCrafter_Validation(t *testing.T) { require.NoError(t, err) // Validate the data against JSON schema - err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_3) + err = prinfo.Validate(rawData, prinfo.LatestVersion) if tc.wantErr { require.Error(t, err) @@ -143,7 +142,7 @@ func TestChainloopPRInfoCrafter_BackwardCompat(t *testing.T) { err := json.Unmarshal([]byte(oldFormatJSON), &rawData) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_3) + err = prinfo.Validate(rawData, prinfo.LatestVersion) require.NoError(t, err) // New object author format should also validate against v1.3 @@ -158,7 +157,7 @@ func TestChainloopPRInfoCrafter_BackwardCompat(t *testing.T) { err = json.Unmarshal([]byte(newFormatJSON), &rawData) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(rawData, schemavalidators.PRInfoVersion1_3) + err = prinfo.Validate(rawData, prinfo.LatestVersion) require.NoError(t, err) } diff --git a/pkg/attestation/crafter/prmetadata.go b/pkg/attestation/crafter/prmetadata.go index 5b62702d3..1c500490e 100644 --- a/pkg/attestation/crafter/prmetadata.go +++ b/pkg/attestation/crafter/prmetadata.go @@ -26,7 +26,7 @@ import ( "time" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - "github.com/chainloop-dev/chainloop/internal/prinfo" + "github.com/chainloop-dev/chainloop/pkg/prinfo" ) // PRMetadata holds extracted PR/MR information diff --git a/pkg/attestation/crafter/prmetadata_test.go b/pkg/attestation/crafter/prmetadata_test.go index 017996a91..17f9b5ed8 100644 --- a/pkg/attestation/crafter/prmetadata_test.go +++ b/pkg/attestation/crafter/prmetadata_test.go @@ -24,7 +24,7 @@ import ( "path/filepath" "testing" - "github.com/chainloop-dev/chainloop/internal/prinfo" + "github.com/chainloop-dev/chainloop/pkg/prinfo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/prinfo/generator.go b/pkg/prinfo/generator.go similarity index 82% rename from internal/prinfo/generator.go rename to pkg/prinfo/generator.go index 713a04117..e418d3cbc 100644 --- a/internal/prinfo/generator.go +++ b/pkg/prinfo/generator.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,8 +32,8 @@ func NewGenerator() *Generator { return &Generator{} } -// GeneratePRInfoSchema generates a JSON schema for the PR/MR info data. -func (g *Generator) GeneratePRInfoSchema(version string) *jsonschema.Schema { +// GenerateSchema generates a JSON schema for the PR/MR info data. +func (g *Generator) GenerateSchema(version Version) *jsonschema.Schema { r := &jsonschema.Reflector{ DoNotReference: true, ExpandedStruct: true, @@ -44,7 +44,7 @@ func (g *Generator) GeneratePRInfoSchema(version string) *jsonschema.Schema { schema := r.Reflect(&Data{}) - schema.ID = jsonschema.ID(fmt.Sprintf("https://schemas.chainloop.dev/prinfo/%s/pr-info.schema.json", version)) + schema.ID = jsonschema.ID(SchemaURL(version)) schema.Title = "Pull Request / Merge Request Information" schema.Description = "Schema for Pull Request or Merge Request metadata collected during attestation" // we want to have a specific version of the schema to avoid compatibility issues @@ -54,7 +54,7 @@ func (g *Generator) GeneratePRInfoSchema(version string) *jsonschema.Schema { } // Save writes the schema to a file -func (g *Generator) Save(schema *jsonschema.Schema, outputDir, version string) error { +func (g *Generator) Save(schema *jsonschema.Schema, outputDir string, version Version) error { schemaJSON, err := json.MarshalIndent(schema, "", " ") if err != nil { return fmt.Errorf("failed to marshal schema to JSON: %w", err) diff --git a/internal/prinfo/prinfo.go b/pkg/prinfo/prinfo.go similarity index 100% rename from internal/prinfo/prinfo.go rename to pkg/prinfo/prinfo.go diff --git a/internal/prinfo/prinfo_test.go b/pkg/prinfo/prinfo_test.go similarity index 96% rename from internal/prinfo/prinfo_test.go rename to pkg/prinfo/prinfo_test.go index 098d121a8..8ad045068 100644 --- a/internal/prinfo/prinfo_test.go +++ b/pkg/prinfo/prinfo_test.go @@ -19,8 +19,6 @@ import ( "encoding/json" "testing" - "github.com/chainloop-dev/chainloop/internal/schemavalidators" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -175,7 +173,7 @@ func TestValidatePRInfo(t *testing.T) { require.NoError(t, err) } - err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_1) + err = Validate(data, Version1_1) if tc.wantErr { assert.Error(t, err) } else { @@ -253,7 +251,7 @@ func TestValidatePRInfoV1_2(t *testing.T) { err := json.Unmarshal([]byte(tc.data), &data) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_2) + err = Validate(data, Version1_2) if tc.wantErr { assert.Error(t, err) } else { @@ -299,7 +297,7 @@ func TestValidatePRInfoV1_0BackwardCompat(t *testing.T) { err := json.Unmarshal([]byte(tc.data), &data) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_0) + err = Validate(data, Version1_0) if tc.wantErr { assert.Error(t, err) } else { @@ -429,7 +427,7 @@ func TestValidatePRInfoV1_3(t *testing.T) { err := json.Unmarshal([]byte(tc.data), &data) require.NoError(t, err) - err = schemavalidators.ValidatePRInfo(data, schemavalidators.PRInfoVersion1_3) + err = Validate(data, Version1_3) if tc.wantErr { assert.Error(t, err) } else { diff --git a/pkg/prinfo/schema.go b/pkg/prinfo/schema.go new file mode 100644 index 000000000..123284849 --- /dev/null +++ b/pkg/prinfo/schema.go @@ -0,0 +1,130 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prinfo + +import ( + _ "embed" + "errors" + "fmt" + "strings" + "sync" + + "github.com/santhosh-tekuri/jsonschema/v5" +) + +// Version represents the version of the PR/MR info schema. +type Version string + +const ( + // Version1_0 represents PR/MR Info version 1.0 schema. + Version1_0 Version = "1.0" + // Version1_1 represents PR/MR Info version 1.1 schema (adds reviewers). + Version1_1 Version = "1.1" + // Version1_2 represents PR/MR Info version 1.2 schema (adds requested and review_status to reviewers). + Version1_2 Version = "1.2" + // Version1_3 represents PR/MR Info version 1.3 schema (author as object with type). + Version1_3 Version = "1.3" + + // LatestVersion is the schema version emitted by this package. + LatestVersion = Version1_3 +) + +// ErrInvalidJSONPayload represents an error for an invalid JSON payload. +var ErrInvalidJSONPayload = errors.New("invalid JSON payload") + +var ( + //go:embed schemas/pr-info-1.0.schema.json + specVersion1_0 string + //go:embed schemas/pr-info-1.1.schema.json + specVersion1_1 string + //go:embed schemas/pr-info-1.2.schema.json + specVersion1_2 string + //go:embed schemas/pr-info-1.3.schema.json + specVersion1_3 string +) + +// rawSchemas holds the canonical JSON schema documents indexed by version. +var rawSchemas = map[Version]string{ + Version1_0: specVersion1_0, + Version1_1: specVersion1_1, + Version1_2: specVersion1_2, + Version1_3: specVersion1_3, +} + +var ( + compiledSchemas map[Version]*jsonschema.Schema + compileOnce sync.Once +) + +// SchemaURL returns the canonical, published URL of the given schema version. +func SchemaURL(version Version) string { + return fmt.Sprintf("https://schemas.chainloop.dev/prinfo/%s/pr-info.schema.json", version) +} + +// Schema returns the raw JSON schema document for the given version. +func Schema(version Version) (string, error) { + raw, ok := rawSchemas[version] + if !ok { + return "", fmt.Errorf("invalid PR info schema version %q", version) + } + + return raw, nil +} + +// Versions returns the supported schema versions, oldest first. +func Versions() []Version { + return []Version{Version1_0, Version1_1, Version1_2, Version1_3} +} + +func initSchemas() { + compiler := jsonschema.NewCompiler() + for _, version := range Versions() { + url := SchemaURL(version) + if err := compiler.AddResource(url, strings.NewReader(rawSchemas[version])); err != nil { + panic(fmt.Sprintf("prinfo: failed to add resource %s: %v", url, err)) + } + } + + compiledSchemas = make(map[Version]*jsonschema.Schema, len(rawSchemas)) + for _, version := range Versions() { + compiledSchemas[version] = compiler.MustCompile(SchemaURL(version)) + } +} + +// Validate validates a generically-decoded PR/MR info data payload against the +// given schema version. An empty version defaults to LatestVersion. +func Validate(data any, version Version) error { + compileOnce.Do(initSchemas) + + if version == "" { + version = LatestVersion + } + + schema, ok := compiledSchemas[version] + if !ok { + return fmt.Errorf("invalid PR info schema version %q", version) + } + + if err := schema.Validate(data); err != nil { + var invalidJSONTypeError jsonschema.InvalidJSONTypeError + if errors.As(err, &invalidJSONTypeError) { + return ErrInvalidJSONPayload + } + return err + } + + return nil +} diff --git a/pkg/prinfo/schema_test.go b/pkg/prinfo/schema_test.go new file mode 100644 index 000000000..9ca50bc88 --- /dev/null +++ b/pkg/prinfo/schema_test.go @@ -0,0 +1,201 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prinfo_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/prinfo" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The evidence advertises a schema id, and that id must point at a schema this +// package actually ships. Consumers rely on it to pick the right validator. +func TestEvidenceSchemaURLMatchesLatestVersion(t *testing.T) { + assert.Equal(t, prinfo.SchemaURL(prinfo.LatestVersion), prinfo.EvidenceSchemaURL) + + raw, err := prinfo.Schema(prinfo.LatestVersion) + require.NoError(t, err) + + var doc struct { + ID string `json:"$id"` + } + require.NoError(t, json.Unmarshal([]byte(raw), &doc)) + assert.Equal(t, prinfo.EvidenceSchemaURL, doc.ID) +} + +func TestSchema(t *testing.T) { + for _, version := range prinfo.Versions() { + t.Run(string(version), func(t *testing.T) { + raw, err := prinfo.Schema(version) + require.NoError(t, err) + assert.NotEmpty(t, raw) + + var doc struct { + ID string `json:"$id"` + } + require.NoError(t, json.Unmarshal([]byte(raw), &doc)) + assert.Equal(t, prinfo.SchemaURL(version), doc.ID) + }) + } + + t.Run("unknown version", func(t *testing.T) { + _, err := prinfo.Schema("9.9") + assert.ErrorContains(t, err, `invalid PR info schema version "9.9"`) + }) +} + +// Published schemas are immutable: 1.0-1.2 take the author as a plain string and only +// 1.3 introduced the object form (keeping the string one for backwards compatibility). +// A stale //go:generate directive once rewrote the published 1.2 schema from a later +// revision of Data, making it reject the very payloads it had been published to accept. +func TestPublishedAuthorShapeIsStable(t *testing.T) { + const stringAuthor = `"octocat"` + const objectAuthor = `{"login": "octocat", "type": "User"}` + + testCases := []struct { + version prinfo.Version + author string + wantErr bool + authorForm string + }{ + {version: prinfo.Version1_0, author: stringAuthor, authorForm: "string"}, + {version: prinfo.Version1_0, author: objectAuthor, authorForm: "object", wantErr: true}, + {version: prinfo.Version1_1, author: stringAuthor, authorForm: "string"}, + {version: prinfo.Version1_1, author: objectAuthor, authorForm: "object", wantErr: true}, + {version: prinfo.Version1_2, author: stringAuthor, authorForm: "string"}, + {version: prinfo.Version1_2, author: objectAuthor, authorForm: "object", wantErr: true}, + {version: prinfo.Version1_3, author: stringAuthor, authorForm: "string"}, + {version: prinfo.Version1_3, author: objectAuthor, authorForm: "object"}, + } + + for _, tc := range testCases { + t.Run(string(tc.version)+"/"+tc.authorForm, func(t *testing.T) { + payload := `{ + "platform": "github", + "type": "pull_request", + "number": "123", + "url": "https://github.com/owner/repo/pull/123", + "author": ` + tc.author + ` + }` + + var data any + require.NoError(t, json.Unmarshal([]byte(payload), &data)) + + err := prinfo.Validate(data, tc.version) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestNewEvidence(t *testing.T) { + evidence := prinfo.NewEvidence(prinfo.Data{ + Platform: "github", + Type: "pull_request", + Number: "1", + URL: "https://github.com/owner/repo/pull/1", + }) + + assert.Equal(t, prinfo.EvidenceID, evidence.ID) + assert.Equal(t, prinfo.EvidenceSchemaURL, evidence.Schema) + + // the payload it produces must validate against the schema it declares + raw, err := json.Marshal(evidence.Data) + require.NoError(t, err) + + var data any + require.NoError(t, json.Unmarshal(raw, &data)) + require.NoError(t, prinfo.Validate(data, prinfo.LatestVersion)) +} + +func TestValidateFromFile(t *testing.T) { + testCases := []struct { + name string + filePath string + wantErr string + }{ + { + name: "valid PR info with all fields", + filePath: "./testdata/pr_info_valid.json", + }, + { + name: "missing required fields", + filePath: "./testdata/pr_info_missing_required.json", + wantErr: "missing properties", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + f, err := os.ReadFile(tc.filePath) + require.NoError(t, err) + + var v any + require.NoError(t, json.Unmarshal(f, &v)) + + // an empty version falls back to the latest one + err = prinfo.Validate(v, "") + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + }) + } +} + +func TestValidateErrors(t *testing.T) { + testCases := []struct { + name string + data any + version prinfo.Version + wantErr string + }{ + { + name: "completely wrong format", + data: map[string]any{"spdxVersion": "SPDX-2.3"}, + version: prinfo.LatestVersion, + wantErr: "missing properties", + }, + { + name: "unknown version", + data: map[string]any{}, + version: "9.9", + wantErr: `invalid PR info schema version "9.9"`, + }, + { + name: "payload not decoded from JSON", + data: struct{ Platform string }{Platform: "github"}, + version: prinfo.LatestVersion, + wantErr: prinfo.ErrInvalidJSONPayload.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.ErrorContains(t, prinfo.Validate(tc.data, tc.version), tc.wantErr) + }) + } +} diff --git a/internal/prinfo/schemas/generate.go b/pkg/prinfo/schemas/generate.go similarity index 50% rename from internal/prinfo/schemas/generate.go rename to pkg/prinfo/schemas/generate.go index c4267bd47..7eeb89e07 100644 --- a/internal/prinfo/schemas/generate.go +++ b/pkg/prinfo/schemas/generate.go @@ -20,23 +20,31 @@ import ( "fmt" "os" - "github.com/chainloop-dev/chainloop/internal/prinfo" + "github.com/chainloop-dev/chainloop/pkg/prinfo" ) -//go:generate go run ./generate.go --output-dir ../../../internal/schemavalidators/internal_schemas/prinfo --version 1.2 +// Scaffolds the JSON schema for a new PR/MR info version out of the prinfo.Data struct: +// +// go run ./pkg/prinfo/schemas/generate.go --output-dir ./pkg/prinfo/schemas --version +// +// Deliberately not wired to `go generate`: published schemas are immutable, they are what +// already-crafted attestations are validated against. A previous //go:generate directive +// silently rewrote the published 1.2 schema from a later revision of the struct. +// Hand-written constructs the reflector cannot express (the string/object `author` union +// in 1.3) would be lost too. Run this once when cutting a version, then edit the result. func main() { var outputDir string var version string - flag.StringVar(&outputDir, "output-dir", "../../../internal/schemavalidators/internal_schemas/prinfo", "Directory to output the schema files") - flag.StringVar(&version, "version", "1.2", "Schema version") + flag.StringVar(&outputDir, "output-dir", ".", "Directory to output the schema files") + flag.StringVar(&version, "version", string(prinfo.LatestVersion), "Schema version") flag.Parse() generator := prinfo.NewGenerator() fmt.Printf("Generating JSON schema for PR/MR Info\n") - sch := generator.GeneratePRInfoSchema(version) - if err := generator.Save(sch, outputDir, version); err != nil { + sch := generator.GenerateSchema(prinfo.Version(version)) + if err := generator.Save(sch, outputDir, prinfo.Version(version)); err != nil { fmt.Fprintf(os.Stderr, "Error writing schema: %v\n", err) os.Exit(1) } diff --git a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.0.schema.json b/pkg/prinfo/schemas/pr-info-1.0.schema.json similarity index 100% rename from internal/schemavalidators/internal_schemas/prinfo/pr-info-1.0.schema.json rename to pkg/prinfo/schemas/pr-info-1.0.schema.json diff --git a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.1.schema.json b/pkg/prinfo/schemas/pr-info-1.1.schema.json similarity index 100% rename from internal/schemavalidators/internal_schemas/prinfo/pr-info-1.1.schema.json rename to pkg/prinfo/schemas/pr-info-1.1.schema.json diff --git a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.2.schema.json b/pkg/prinfo/schemas/pr-info-1.2.schema.json similarity index 83% rename from internal/schemavalidators/internal_schemas/prinfo/pr-info-1.2.schema.json rename to pkg/prinfo/schemas/pr-info-1.2.schema.json index a7238db51..96e31b1ee 100644 --- a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.2.schema.json +++ b/pkg/prinfo/schemas/pr-info-1.2.schema.json @@ -44,28 +44,8 @@ "description": "Direct URL to the PR/MR" }, "author": { - "properties": { - "login": { - "type": "string", - "description": "Username of the PR/MR author" - }, - "type": { - "type": "string", - "enum": [ - "User", - "Bot", - "unknown" - ], - "description": "Account type of the PR/MR author" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "login", - "type" - ], - "description": "The PR/MR author" + "type": "string", + "description": "Username of the PR/MR author" }, "reviewers": { "items": { @@ -121,4 +101,4 @@ ], "title": "Pull Request / Merge Request Information", "description": "Schema for Pull Request or Merge Request metadata collected during attestation" -} \ No newline at end of file +} diff --git a/internal/schemavalidators/internal_schemas/prinfo/pr-info-1.3.schema.json b/pkg/prinfo/schemas/pr-info-1.3.schema.json similarity index 100% rename from internal/schemavalidators/internal_schemas/prinfo/pr-info-1.3.schema.json rename to pkg/prinfo/schemas/pr-info-1.3.schema.json diff --git a/internal/schemavalidators/testdata/pr_info_missing_required.json b/pkg/prinfo/testdata/pr_info_missing_required.json similarity index 100% rename from internal/schemavalidators/testdata/pr_info_missing_required.json rename to pkg/prinfo/testdata/pr_info_missing_required.json diff --git a/internal/schemavalidators/testdata/pr_info_valid.json b/pkg/prinfo/testdata/pr_info_valid.json similarity index 100% rename from internal/schemavalidators/testdata/pr_info_valid.json rename to pkg/prinfo/testdata/pr_info_valid.json From 3fd013c81094e4079d9f54029352b31de8c5ab83 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 27 Aug 2026 23:31:58 +0200 Subject: [PATCH 2/3] remove comment Signed-off-by: Jose I. Paris --- pkg/prinfo/schemas/generate.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/prinfo/schemas/generate.go b/pkg/prinfo/schemas/generate.go index 7eeb89e07..30e9ee35c 100644 --- a/pkg/prinfo/schemas/generate.go +++ b/pkg/prinfo/schemas/generate.go @@ -26,12 +26,6 @@ import ( // Scaffolds the JSON schema for a new PR/MR info version out of the prinfo.Data struct: // // go run ./pkg/prinfo/schemas/generate.go --output-dir ./pkg/prinfo/schemas --version -// -// Deliberately not wired to `go generate`: published schemas are immutable, they are what -// already-crafted attestations are validated against. A previous //go:generate directive -// silently rewrote the published 1.2 schema from a later revision of the struct. -// Hand-written constructs the reflector cannot express (the string/object `author` union -// in 1.3) would be lost too. Run this once when cutting a version, then edit the result. func main() { var outputDir string var version string From 33c3802a2148ab6c84b950d07628129c20c43959 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Thu, 27 Aug 2026 23:36:09 +0200 Subject: [PATCH 3/3] fix(prinfo): refuse to regenerate an already published schema The generator defaulted to LatestVersion, so running it with no arguments overwrote the published 1.3 schema from the current Data struct, dropping the hand-written string/object author union. That is the same corruption this branch repairs for 1.2, reachable by simply running the tool. --version is now required, and a version that is already embedded is refused unless --force is passed. Assisted-by: Claude Code Signed-off-by: Jose I. Paris Chainloop-Trace-Sessions: 1fcad8c1-fedc-4783-aece-b4fbb6966acc --- pkg/prinfo/schemas/generate.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/prinfo/schemas/generate.go b/pkg/prinfo/schemas/generate.go index 30e9ee35c..948b56800 100644 --- a/pkg/prinfo/schemas/generate.go +++ b/pkg/prinfo/schemas/generate.go @@ -29,11 +29,26 @@ import ( func main() { var outputDir string var version string + var force bool flag.StringVar(&outputDir, "output-dir", ".", "Directory to output the schema files") - flag.StringVar(&version, "version", string(prinfo.LatestVersion), "Schema version") + flag.StringVar(&version, "version", "", "Schema version to generate, e.g. 1.4 (required)") + flag.BoolVar(&force, "force", false, "Overwrite an already published schema version") flag.Parse() + if version == "" { + fmt.Fprintf(os.Stderr, "Error: --version is required, latest published is %s\n", prinfo.LatestVersion) + os.Exit(1) + } + + // Published schemas are immutable: they are what already-crafted attestations are + // validated against, and they may carry constructs the reflector cannot express, + // such as the string/object author union in 1.3. + if _, err := prinfo.Schema(prinfo.Version(version)); err == nil && !force { + fmt.Fprintf(os.Stderr, "Error: schema %s is already published, refusing to overwrite it. Pass --force to override.\n", version) + os.Exit(1) + } + generator := prinfo.NewGenerator() fmt.Printf("Generating JSON schema for PR/MR Info\n")