diff --git a/.github/workflows/publish-schema.yml b/.github/workflows/publish-schema.yml new file mode 100644 index 0000000..344d478 --- /dev/null +++ b/.github/workflows/publish-schema.yml @@ -0,0 +1,133 @@ +name: Publish Schema + +on: + push: + tags: + - "schema-v*" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve contract metadata + id: contract + shell: bash + run: | + set -euo pipefail + + extract_const() { + local key="$1" + grep -E "^[[:space:]]*${key}[[:space:]]*=" internal/schema/constants.go | sed -E 's/.*"([^"]+)".*/\1/' + } + + SCHEMA_VERSION="$(extract_const ExpectedSchemaVersion)" + SCHEMA_URI="$(extract_const ExpectedSchemaURI)" + SCHEMA_DIGEST="$(extract_const ExpectedSchemaDigest)" + + if [[ -z "${SCHEMA_VERSION}" || -z "${SCHEMA_URI}" || -z "${SCHEMA_DIGEST}" ]]; then + echo "failed to resolve schema constants from internal/schema/constants.go" + exit 1 + fi + + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + if [[ "${GITHUB_REF_NAME}" != schema-v* ]]; then + echo "unexpected tag: ${GITHUB_REF_NAME}; expected schema-v*" + exit 1 + fi + TAG_VERSION="${GITHUB_REF_NAME#schema-v}" + if [[ "${TAG_VERSION}" != "${SCHEMA_VERSION}" ]]; then + echo "tag version mismatch: tag=${TAG_VERSION} schema=${SCHEMA_VERSION}" + exit 1 + fi + else + TAG_VERSION="${SCHEMA_VERSION}" + fi + + echo "schema_version=${SCHEMA_VERSION}" >> "${GITHUB_OUTPUT}" + echo "schema_uri=${SCHEMA_URI}" >> "${GITHUB_OUTPUT}" + echo "schema_digest=${SCHEMA_DIGEST}" >> "${GITHUB_OUTPUT}" + echo "tag_version=${TAG_VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Validate schema JSON and $id binding + shell: bash + env: + EXPECTED_URI: ${{ steps.contract.outputs.schema_uri }} + EXPECTED_DIGEST: ${{ steps.contract.outputs.schema_digest }} + run: | + set -euo pipefail + python - <<'PY' + import hashlib + import json + import os + from pathlib import Path + + schema_path = Path("api/schema/model.schema.json") + raw = schema_path.read_bytes() + payload = json.loads(raw.decode("utf-8")) + expected_uri = os.environ["EXPECTED_URI"] + actual_uri = payload.get("$id") + if actual_uri != expected_uri: + raise SystemExit(f"schema $id mismatch: got={actual_uri!r} want={expected_uri!r}") + + digest = "sha256:" + hashlib.sha256(raw).hexdigest() + expected_digest = os.environ["EXPECTED_DIGEST"] + if digest != expected_digest: + raise SystemExit(f"schema digest mismatch: got={digest!r} want={expected_digest!r}") + PY + + - name: Build publish artifact + shell: bash + env: + SCHEMA_VERSION: ${{ steps.contract.outputs.schema_version }} + SCHEMA_URI: ${{ steps.contract.outputs.schema_uri }} + SCHEMA_DIGEST: ${{ steps.contract.outputs.schema_digest }} + run: | + set -euo pipefail + + ROOT="out" + VERSION_DIR="${ROOT}/schema/model/v${SCHEMA_VERSION}" + LATEST_DIR="${ROOT}/schema/model/latest" + + mkdir -p "${VERSION_DIR}" "${LATEST_DIR}" "${ROOT}/schema" + + cp api/schema/model.schema.json "${VERSION_DIR}/model.schema.json" + cp api/schema/model.schema.json "${LATEST_DIR}/model.schema.json" + + UPDATED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + cat > "${ROOT}/schema/index.json" < Pages`. +2. Set source to `GitHub Actions`. +3. Ensure `Settings -> Actions -> General` allows workflows to deploy Pages. + +After first deployment, GitHub creates the `github-pages` environment automatically. + +## Publish workflow + +- File: `.github/workflows/publish-schema.yml` +- Trigger: `push` tags matching `schema-v*` +- Optional emergency path: `workflow_dispatch` + +The workflow: + +1. Reads `ExpectedSchemaVersion`, `ExpectedSchemaURI`, and `ExpectedSchemaDigest` from `internal/schema/constants.go`. +2. Validates tag/version binding (`schema-vX.Y.Z` must match `ExpectedSchemaVersion`). +3. Validates schema JSON and `$id` binding. +4. Builds a Pages artifact with: + - `schema/model/v/model.schema.json` + - `schema/model/latest/model.schema.json` + - `schema/index.json` +5. Deploys to GitHub Pages. + +## Release operation model + +1. Merge schema changes into `main`. +2. Create and push tag `schema-v` (for example, `schema-v1.0.0`). +3. Wait for workflow completion. +4. Verify: + - schema URL returns `200` + - downloaded schema digest matches `ExpectedSchemaDigest` + +## Notes + +- This stage updates Bering only. +- Sheaft currently pins strict URI and digest independently and must be migrated in a separate coordinated change. + diff --git a/examples/outputs/bering-model.normalized.sample.json b/examples/outputs/bering-model.normalized.sample.json index fb0f0b8..df3ebf2 100644 --- a/examples/outputs/bering-model.normalized.sample.json +++ b/examples/outputs/bering-model.normalized.sample.json @@ -40,9 +40,9 @@ "confidence": 0.94, "discovered_at": "2026-03-03T00:00:00Z", "schema": { - "digest": "sha256:7dc733936a9d3f94ab92f46a30d4c8d0f5c05d60670c4247786c59a3fe7630f7", + "digest": "sha256:272277c093f37580adcd2dded225bd37c86539d642d7910baad7e4228227d1a7", "name": "io.mb3r.bering.model", - "uri": "https://schemas.mb3r.dev/bering/model/v1.0.0/model.schema.json", + "uri": "https://mb3r-lab.github.io/Bering/schema/model/v1.0.0/model.schema.json", "version": "1.0.0" }, "source_ref": "bering://discover?input=examples%2Ftraces%2Fnormalized.sample.json", diff --git a/examples/outputs/bering-model.otel.sample.json b/examples/outputs/bering-model.otel.sample.json index 81c3e2a..c0f3274 100644 --- a/examples/outputs/bering-model.otel.sample.json +++ b/examples/outputs/bering-model.otel.sample.json @@ -40,9 +40,9 @@ "confidence": 0.94, "discovered_at": "2026-03-03T00:00:00Z", "schema": { - "digest": "sha256:7dc733936a9d3f94ab92f46a30d4c8d0f5c05d60670c4247786c59a3fe7630f7", + "digest": "sha256:272277c093f37580adcd2dded225bd37c86539d642d7910baad7e4228227d1a7", "name": "io.mb3r.bering.model", - "uri": "https://schemas.mb3r.dev/bering/model/v1.0.0/model.schema.json", + "uri": "https://mb3r-lab.github.io/Bering/schema/model/v1.0.0/model.schema.json", "version": "1.0.0" }, "source_ref": "bering://discover?input=examples%2Ftraces%2Fotel.sample.json", diff --git a/internal/schema/constants.go b/internal/schema/constants.go index bb8a6be..a56d911 100644 --- a/internal/schema/constants.go +++ b/internal/schema/constants.go @@ -3,8 +3,8 @@ package schema const ( ExpectedSchemaName = "io.mb3r.bering.model" ExpectedSchemaVersion = "1.0.0" - ExpectedSchemaURI = "https://schemas.mb3r.dev/bering/model/v1.0.0/model.schema.json" - ExpectedSchemaDigest = "sha256:7dc733936a9d3f94ab92f46a30d4c8d0f5c05d60670c4247786c59a3fe7630f7" + ExpectedSchemaURI = "https://mb3r-lab.github.io/Bering/schema/model/v1.0.0/model.schema.json" + ExpectedSchemaDigest = "sha256:272277c093f37580adcd2dded225bd37c86539d642d7910baad7e4228227d1a7" ) type SchemaRef struct { diff --git a/internal/schema/contract_test.go b/internal/schema/contract_test.go index df6bc76..66001c9 100644 --- a/internal/schema/contract_test.go +++ b/internal/schema/contract_test.go @@ -1,6 +1,12 @@ package schema -import "testing" +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "testing" +) func TestEmbeddedSchemaDigestMatchesPinned(t *testing.T) { t.Parallel() @@ -17,3 +23,31 @@ func TestValidateStrict(t *testing.T) { t.Fatalf("expected strict validation to pass, got error: %v", err) } } + +func TestEmbeddedSchemaIDMatchesExpectedURI(t *testing.T) { + t.Parallel() + + var payload map[string]any + if err := json.Unmarshal(EmbeddedSchema(), &payload); err != nil { + t.Fatalf("decode embedded schema: %v", err) + } + + id, _ := payload["$id"].(string) + if id != ExpectedSchemaURI { + t.Fatalf("schema $id mismatch: got=%q want=%q", id, ExpectedSchemaURI) + } +} + +func TestExpectedSchemaURIVersionPathMatchesConstant(t *testing.T) { + t.Parallel() + + parsed, err := url.Parse(ExpectedSchemaURI) + if err != nil { + t.Fatalf("parse ExpectedSchemaURI: %v", err) + } + + wantSegment := fmt.Sprintf("/v%s/", ExpectedSchemaVersion) + if !strings.Contains(parsed.Path, wantSegment) { + t.Fatalf("ExpectedSchemaURI path %q must contain %q", parsed.Path, wantSegment) + } +} diff --git a/internal/schema/schema/model.schema.json b/internal/schema/schema/model.schema.json index 9b1a8d5..477398f 100644 --- a/internal/schema/schema/model.schema.json +++ b/internal/schema/schema/model.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.mb3r.dev/bering/model/v1.0.0/model.schema.json", + "$id": "https://mb3r-lab.github.io/Bering/schema/model/v1.0.0/model.schema.json", "title": "BeringResilienceModel", "type": "object", "required": [ diff --git a/internal/schema/validator_test.go b/internal/schema/validator_test.go index 6cddf58..4c5019a 100644 --- a/internal/schema/validator_test.go +++ b/internal/schema/validator_test.go @@ -19,8 +19,8 @@ func TestValidateJSON_Success(t *testing.T) { "schema":{ "name":"io.mb3r.bering.model", "version":"1.0.0", - "uri":"https://schemas.mb3r.dev/bering/model/v1.0.0/model.schema.json", - "digest":"sha256:7dc733936a9d3f94ab92f46a30d4c8d0f5c05d60670c4247786c59a3fe7630f7" + "uri":"https://mb3r-lab.github.io/Bering/schema/model/v1.0.0/model.schema.json", + "digest":"sha256:272277c093f37580adcd2dded225bd37c86539d642d7910baad7e4228227d1a7" } } }`) @@ -45,7 +45,7 @@ func TestValidateJSON_StrictDigestFail(t *testing.T) { "schema":{ "name":"io.mb3r.bering.model", "version":"1.0.0", - "uri":"https://schemas.mb3r.dev/bering/model/v1.0.0/model.schema.json", + "uri":"https://mb3r-lab.github.io/Bering/schema/model/v1.0.0/model.schema.json", "digest":"sha256:deadbeef" } }