diff --git a/.github/workflows/check-backstage-compatibility.yaml b/.github/workflows/check-backstage-compatibility.yaml index 8ffa7ba..76cf55e 100644 --- a/.github/workflows/check-backstage-compatibility.yaml +++ b/.github/workflows/check-backstage-compatibility.yaml @@ -81,7 +81,8 @@ jobs: echo "OVERLAY_REPO=${{ github.repository }}" >> $GITHUB_OUTPUT fi - - uses: actions/checkout@v4.2.2 + name: Checkout overlay repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ steps.set-overlay-repo-ref.outputs.OVERLAY_REPO_REF }} repository: ${{ steps.set-overlay-repo.outputs.OVERLAY_REPO }} diff --git a/.github/workflows/export-dynamic.yaml b/.github/workflows/export-dynamic.yaml index 57ec0ca..04427e5 100644 --- a/.github/workflows/export-dynamic.yaml +++ b/.github/workflows/export-dynamic.yaml @@ -77,6 +77,11 @@ on: default: false required: false + target-backstage-version: + description: Target Backstage version for validating OCI tag format in metadata (e.g., "1.42.5") + type: string + required: true + image-repository-prefix: description: Repository prefix of the dynamic plugin container images type: string @@ -119,6 +124,15 @@ on: value: '${{ jobs.export.outputs.published-exports }}' failed-exports: value: '${{ jobs.export.outputs.failed-exports }}' + metadata-validation-passed: + description: Whether the metadata validation passed (true/false) + value: '${{ jobs.export.outputs.metadata-validation-passed }}' + metadata-validation-errors: + description: JSON array of metadata validation errors, empty array if validation passed + value: '${{ jobs.export.outputs.metadata-validation-errors }}' + metadata-validation-error-count: + description: Number of metadata validation errors found + value: '${{ jobs.export.outputs.metadata-validation-error-count }}' jobs: export: @@ -136,6 +150,9 @@ jobs: outputs: published-exports: '${{ steps.export-dynamic.outputs.published-exports }}' failed-exports: '${{ steps.export-dynamic.outputs.failed-exports }}' + metadata-validation-passed: '${{ steps.validate-metadata.outputs.validation-passed }}' + metadata-validation-errors: '${{ steps.validate-metadata.outputs.validation-errors }}' + metadata-validation-error-count: '${{ steps.validate-metadata.outputs.validation-error-count }}' steps: - name: Validate Inputs @@ -155,7 +172,7 @@ jobs: } - name: Checkout plugins repository ${{ inputs.plugins-repo }} - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ inputs.plugins-root == '.' }} with: repository: ${{ inputs.plugins-repo }} @@ -163,7 +180,7 @@ jobs: path: source-repo - name: Checkout plugins repository ${{ inputs.plugins-repo }} at ${{ inputs.plugins-root }} - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ inputs.plugins-root != '.' }} with: repository: ${{ inputs.plugins-repo }} @@ -177,7 +194,7 @@ jobs: path: source-repo - name: Checkout overlay repository ${{ inputs.overlay-repo }} in the 'overlay-repo' sub-folder - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ inputs.overlay-repo }} ref: ${{ inputs.overlay-repo-ref }} @@ -220,8 +237,8 @@ jobs: sudo apt-get update sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev - - name: Use node.js ${{ inputs.node-version }} - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - name: Setup Node.js ${{ inputs.node-version }} + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ inputs.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth @@ -287,6 +304,16 @@ jobs: image-tag-prefix: ${{ inputs.image-tag-prefix }} last-publish-commit: ${{ inputs.last-publish-commit }} + - name: Validate Catalog Metadata + if: ${{ success() && steps.export-dynamic.outputs.workspace-skipped-unchanged-since == 'false' }} + id: validate-metadata + uses: redhat-developer/rhdh-plugin-export-utils/validate-metadata@main + with: + overlay-root: ${{ github.workspace }}/overlay-repo/${{inputs.overlay-root}} + plugins-root: ${{ github.workspace }}/source-repo/${{inputs.plugins-root}} + target-backstage-version: ${{ inputs.target-backstage-version }} + image-repository-prefix: ${{ steps.set-image-tag-name.outputs.IMAGE_REPOSITORY_PREFIX }} + - name: Set artifacts name suffix id: set-artifacts-name-suffix shell: bash @@ -320,6 +347,7 @@ jobs: ${{ github.workspace }} !${{ github.workspace }}/dynamic-plugin-archives !${{ github.workspace }}/node_modules + !.git if-no-files-found: warn retention-days: ${{ inputs.artifact-retention-days }} overwrite: true diff --git a/.github/workflows/export-workspaces-as-dynamic.yaml b/.github/workflows/export-workspaces-as-dynamic.yaml index 6c223d9..69dab24 100644 --- a/.github/workflows/export-workspaces-as-dynamic.yaml +++ b/.github/workflows/export-workspaces-as-dynamic.yaml @@ -80,6 +80,18 @@ on: failed-exports: value: '${{ jobs.export.outputs.failed-exports }}' + + metadata-validation-passed: + description: Whether the metadata validation passed (true/false) + value: '${{ jobs.export.outputs.metadata-validation-passed }}' + + metadata-validation-errors: + description: JSON array of metadata validation errors + value: '${{ jobs.export.outputs.metadata-validation-errors }}' + + metadata-validation-error-count: + description: Number of metadata validation errors found + value: '${{ jobs.export.outputs.metadata-validation-error-count }}' jobs: prepare: @@ -131,7 +143,8 @@ jobs: echo "OVERLAY_REPO=${{ github.repository }}" >> $GITHUB_OUTPUT fi - - uses: actions/checkout@v4.2.2 + - name: Checkout overlay repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ steps.set-overlay-repo-ref.outputs.OVERLAY_REPO_REF }} repository: ${{ steps.set-overlay-repo.outputs.OVERLAY_REPO }} @@ -226,6 +239,7 @@ jobs: publish-container: ${{ inputs.publish-container }} image-repository-prefix: ${{ inputs.image-repository-prefix }} image-tag-prefix: ${{ inputs.image-tag-prefix != '' && inputs.image-tag-prefix || format('bs_{0}__', needs.prepare.outputs.backstage-version) }} + target-backstage-version: ${{ needs.prepare.outputs.backstage-version }} last-publish-commit: ${{ inputs.last-publish-commit }} image-registry-user: ${{ inputs.image-registry-user }} diff --git a/.github/workflows/test-validate-metadata.yaml b/.github/workflows/test-validate-metadata.yaml new file mode 100644 index 0000000..026c46f --- /dev/null +++ b/.github/workflows/test-validate-metadata.yaml @@ -0,0 +1,400 @@ +name: Test Validate Metadata +on: + workflow_dispatch: + push: + paths: + - validate-metadata/** + +env: + TEST_DIR: ${{ github.workspace }}/validate-metadata/test + IMAGE_REPOSITORY_PREFIX: ghcr.io/test-org/test-repo + TARGET_BACKSTAGE_VERSION: 1.42.5 + +jobs: + test-validation-pass: + name: Test Validation (Should Pass) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js 24.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24.x + + - name: Run Validate Metadata + id: validate + uses: ./validate-metadata + with: + overlay-root: ${{ env.TEST_DIR }}/cases/pass + plugins-root: ${{ env.TEST_DIR }}/source + target-backstage-version: ${{ env.TARGET_BACKSTAGE_VERSION }} + image-repository-prefix: ${{ env.IMAGE_REPOSITORY_PREFIX }} + + - name: Verify validation passed + env: + VALIDATION_PASSED: ${{ steps.validate.outputs.validation-passed }} + VALIDATION_ERROR_COUNT: ${{ steps.validate.outputs.validation-error-count }} + VALIDATION_ERRORS: ${{ steps.validate.outputs.validation-errors }} + shell: node {0} + run: | + import assert from 'node:assert/strict'; + const { VALIDATION_PASSED, VALIDATION_ERROR_COUNT, VALIDATION_ERRORS } = process.env; + + assert.equal(VALIDATION_PASSED, 'true'); + assert.equal(VALIDATION_ERROR_COUNT, '0'); + assert.equal(VALIDATION_ERRORS, '[]'); + + test-validation-fail: + name: Test Validation (Should Fail) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js 24.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24.x + + - name: Run Validate Metadata + id: validate + continue-on-error: true + uses: ./validate-metadata + with: + overlay-root: ${{ env.TEST_DIR }}/cases/fail + plugins-root: ${{ env.TEST_DIR }}/source + target-backstage-version: ${{ env.TARGET_BACKSTAGE_VERSION }} + image-repository-prefix: ${{ env.IMAGE_REPOSITORY_PREFIX }} + + - name: Verify validation failed with expected errors + env: + STEP_OUTCOME: ${{ steps.validate.outcome }} + VALIDATION_PASSED: ${{ steps.validate.outputs.validation-passed }} + VALIDATION_ERROR_COUNT: ${{ steps.validate.outputs.validation-error-count }} + VALIDATION_ERRORS: ${{ steps.validate.outputs.validation-errors }} + shell: node {0} + run: | + import assert from 'node:assert/strict'; + const { STEP_OUTCOME, VALIDATION_PASSED, VALIDATION_ERROR_COUNT, VALIDATION_ERRORS } = process.env; + const errors = JSON.parse(VALIDATION_ERRORS); + + function assertError(field, expectedError) { + const error = errors.find(e => e.field === field); + assert.ok(error, `Missing ${field} error`); + assert.deepEqual(error, expectedError); + } + + assert.equal(STEP_OUTCOME, 'failure'); + assert.equal(VALIDATION_PASSED, 'false'); + assert.equal(VALIDATION_ERROR_COUNT, '4'); + + assertError('version', { + kind: 'mismatch', + file: 'test-fail.yaml', + field: 'version', + expected: '1.0.0', + actual: '9.9.9', + message: 'Version mismatch: expected "1.0.0" but got "9.9.9"' + }); + + assertError('dynamicArtifact.tag', { + kind: 'mismatch', + file: 'test-fail.yaml', + field: 'dynamicArtifact.tag', + expected: 'bs_1.42.5__1.0.0', + actual: 'wrong_tag_1.0.0', + message: 'OCI tag mismatch: expected "bs_1.42.5__1.0.0" but got "wrong_tag_1.0.0"' + }); + + assertError('dynamicArtifact.reference', { + kind: 'mismatch', + file: 'test-fail.yaml', + field: 'dynamicArtifact.reference', + expected: 'oci://ghcr.io/test-org/test-repo/test-org-plugin-test', + actual: 'oci://ghcr.io/wrong-org/wrong-repo/wrong-image', + message: 'OCI reference mismatch: expected "oci://ghcr.io/test-org/test-repo/test-org-plugin-test" but got "oci://ghcr.io/wrong-org/wrong-repo/wrong-image"' + }); + + assertError('backstage.supportedVersions', { + kind: 'mismatch', + file: 'test-fail.yaml', + field: 'backstage.supportedVersions', + expected: '1.42.x (from dist-dynamic/package.json: 1.42.5)', + actual: '1.99.0', + message: 'Backstage supportedVersions mismatch: expected "1.42.x" but got "1.99.x"' + }); + + test-validation-invalid-yaml: + name: Test Validation (Invalid YAML) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js 24.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24.x + + - name: Run Validate Metadata + id: validate + continue-on-error: true + uses: ./validate-metadata + with: + overlay-root: ${{ env.TEST_DIR }}/cases/invalid-yaml + plugins-root: ${{ env.TEST_DIR }}/source + target-backstage-version: ${{ env.TARGET_BACKSTAGE_VERSION }} + image-repository-prefix: ${{ env.IMAGE_REPOSITORY_PREFIX }} + + - name: Verify validation failed with parse error + env: + STEP_OUTCOME: ${{ steps.validate.outcome }} + VALIDATION_PASSED: ${{ steps.validate.outputs.validation-passed }} + VALIDATION_ERROR_COUNT: ${{ steps.validate.outputs.validation-error-count }} + VALIDATION_ERRORS: ${{ steps.validate.outputs.validation-errors }} + shell: node {0} + run: | + import assert from 'node:assert/strict'; + const { STEP_OUTCOME, VALIDATION_PASSED, VALIDATION_ERROR_COUNT, VALIDATION_ERRORS } = process.env; + const [error] = JSON.parse(VALIDATION_ERRORS); + + assert.equal(STEP_OUTCOME, 'failure'); + assert.equal(VALIDATION_PASSED, 'false'); + assert.equal(VALIDATION_ERROR_COUNT, '1'); + + assert.deepEqual(error, { + kind: 'parse-error', + file: 'test-invalid-yaml.yaml', + message: 'Failed to parse YAML' + }); + + test-validation-missing-spec: + name: Test Validation (Missing Spec) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js 24.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24.x + + - name: Run Validate Metadata + id: validate + continue-on-error: true + uses: ./validate-metadata + with: + overlay-root: ${{ env.TEST_DIR }}/cases/missing-spec + plugins-root: ${{ env.TEST_DIR }}/source + target-backstage-version: ${{ env.TARGET_BACKSTAGE_VERSION }} + image-repository-prefix: ${{ env.IMAGE_REPOSITORY_PREFIX }} + + - name: Verify validation failed with missing spec error + env: + STEP_OUTCOME: ${{ steps.validate.outcome }} + VALIDATION_PASSED: ${{ steps.validate.outputs.validation-passed }} + VALIDATION_ERROR_COUNT: ${{ steps.validate.outputs.validation-error-count }} + VALIDATION_ERRORS: ${{ steps.validate.outputs.validation-errors }} + shell: node {0} + run: | + import assert from 'node:assert/strict'; + const { STEP_OUTCOME, VALIDATION_PASSED, VALIDATION_ERROR_COUNT, VALIDATION_ERRORS } = process.env; + const [error] = JSON.parse(VALIDATION_ERRORS); + + assert.equal(STEP_OUTCOME, 'failure'); + assert.equal(VALIDATION_PASSED, 'false'); + assert.equal(VALIDATION_ERROR_COUNT, '1'); + + assert.deepEqual(error, { + kind: 'missing-field', + file: 'test-missing-spec.yaml', + field: 'spec', + message: 'Missing required field: spec' + }); + + test-validation-unknown-package: + name: Test Validation (Unknown Package) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js 24.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24.x + + - name: Run Validate Metadata + id: validate + continue-on-error: true + uses: ./validate-metadata + with: + overlay-root: ${{ env.TEST_DIR }}/cases/unknown-package + plugins-root: ${{ env.TEST_DIR }}/source + target-backstage-version: ${{ env.TARGET_BACKSTAGE_VERSION }} + image-repository-prefix: ${{ env.IMAGE_REPOSITORY_PREFIX }} + + - name: Verify validation failed with unknown package error + env: + STEP_OUTCOME: ${{ steps.validate.outcome }} + VALIDATION_PASSED: ${{ steps.validate.outputs.validation-passed }} + VALIDATION_ERROR_COUNT: ${{ steps.validate.outputs.validation-error-count }} + VALIDATION_ERRORS: ${{ steps.validate.outputs.validation-errors }} + shell: node {0} + run: | + import assert from 'node:assert/strict'; + const { STEP_OUTCOME, VALIDATION_PASSED, VALIDATION_ERROR_COUNT, VALIDATION_ERRORS } = process.env; + const [error] = JSON.parse(VALIDATION_ERRORS); + + assert.equal(STEP_OUTCOME, 'failure'); + assert.equal(VALIDATION_PASSED, 'false'); + assert.equal(VALIDATION_ERROR_COUNT, '1'); + + assert.deepEqual(error, { + kind: 'unknown-package', + file: 'test-unknown-package.yaml', + packageName: '@unknown-org/plugin-that-does-not-exist', + message: 'Package "@unknown-org/plugin-that-does-not-exist" not found in plugins-list.yaml' + }); + + test-validation-missing-packagename: + name: Test Validation (Missing Package Name) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js 24.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24.x + + - name: Run Validate Metadata + id: validate + continue-on-error: true + uses: ./validate-metadata + with: + overlay-root: ${{ env.TEST_DIR }}/cases/missing-packagename + plugins-root: ${{ env.TEST_DIR }}/source + target-backstage-version: ${{ env.TARGET_BACKSTAGE_VERSION }} + image-repository-prefix: ${{ env.IMAGE_REPOSITORY_PREFIX }} + + - name: Verify validation failed with missing packageName error + env: + STEP_OUTCOME: ${{ steps.validate.outcome }} + VALIDATION_PASSED: ${{ steps.validate.outputs.validation-passed }} + VALIDATION_ERROR_COUNT: ${{ steps.validate.outputs.validation-error-count }} + VALIDATION_ERRORS: ${{ steps.validate.outputs.validation-errors }} + shell: node {0} + run: | + import assert from 'node:assert/strict'; + const { STEP_OUTCOME, VALIDATION_PASSED, VALIDATION_ERROR_COUNT, VALIDATION_ERRORS } = process.env; + const [error] = JSON.parse(VALIDATION_ERRORS); + + assert.equal(STEP_OUTCOME, 'failure'); + assert.equal(VALIDATION_PASSED, 'false'); + assert.equal(VALIDATION_ERROR_COUNT, '1'); + + assert.deepEqual(error, { + kind: 'missing-field', + file: 'test-missing-packagename.yaml', + field: 'packageName', + message: 'Missing required field: packageName' + }); + + test-validation-missing-files: + name: Test Validation (Missing Required Files) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js 24.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24.x + + - name: Run Validate Metadata + id: validate + continue-on-error: true + uses: ./validate-metadata + with: + overlay-root: ${{ env.TEST_DIR }}/cases/missing-files + plugins-root: ${{ env.TEST_DIR }}/source + target-backstage-version: ${{ env.TARGET_BACKSTAGE_VERSION }} + image-repository-prefix: ${{ env.IMAGE_REPOSITORY_PREFIX }} + + - name: Verify validation failed with missing files errors + env: + STEP_OUTCOME: ${{ steps.validate.outcome }} + VALIDATION_PASSED: ${{ steps.validate.outputs.validation-passed }} + VALIDATION_ERROR_COUNT: ${{ steps.validate.outputs.validation-error-count }} + VALIDATION_ERRORS: ${{ steps.validate.outputs.validation-errors }} + shell: node {0} + run: | + import assert from 'node:assert/strict'; + const { STEP_OUTCOME, VALIDATION_PASSED, VALIDATION_ERROR_COUNT, VALIDATION_ERRORS, TEST_DIR } = process.env; + const [metadataDirError, pluginsListError] = JSON.parse(VALIDATION_ERRORS); + + assert.equal(STEP_OUTCOME, 'failure'); + assert.equal(VALIDATION_PASSED, 'false'); + assert.equal(VALIDATION_ERROR_COUNT, '2'); + + assert.deepEqual(metadataDirError, { + kind: 'missing-file', + file: 'metadata', + message: `Metadata directory not found at ${TEST_DIR}/cases/missing-files/metadata` + }); + + assert.deepEqual(pluginsListError, { + kind: 'missing-file', + file: 'plugins-list.yaml', + message: `plugins-list.yaml not found at ${TEST_DIR}/cases/missing-files/plugins-list.yaml` + }); + + test-validation-invalid-plugins-list: + name: Test Validation (Invalid plugins-list.yaml) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js 24.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24.x + + - name: Run Validate Metadata + id: validate + continue-on-error: true + uses: ./validate-metadata + with: + overlay-root: ${{ env.TEST_DIR }}/cases/invalid-plugins-list + plugins-root: ${{ env.TEST_DIR }}/source + target-backstage-version: ${{ env.TARGET_BACKSTAGE_VERSION }} + image-repository-prefix: ${{ env.IMAGE_REPOSITORY_PREFIX }} + + - name: Verify validation failed with invalid plugins-list error + env: + STEP_OUTCOME: ${{ steps.validate.outcome }} + VALIDATION_PASSED: ${{ steps.validate.outputs.validation-passed }} + VALIDATION_ERROR_COUNT: ${{ steps.validate.outputs.validation-error-count }} + VALIDATION_ERRORS: ${{ steps.validate.outputs.validation-errors }} + shell: node {0} + run: | + import assert from 'node:assert/strict'; + const { STEP_OUTCOME, VALIDATION_PASSED, VALIDATION_ERROR_COUNT, VALIDATION_ERRORS } = process.env; + const [error] = JSON.parse(VALIDATION_ERRORS); + + assert.equal(STEP_OUTCOME, 'failure'); + assert.equal(VALIDATION_PASSED, 'false'); + assert.equal(VALIDATION_ERROR_COUNT, '1'); + + assert.deepEqual(error, { + kind: 'parse-error', + file: 'plugins-list.yaml', + message: 'plugins-list.yaml must be a YAML dictionary with plugin paths as keys' + }); diff --git a/.github/workflows/update-plugins-repo-refs.yaml b/.github/workflows/update-plugins-repo-refs.yaml index 0318b15..8907e6f 100644 --- a/.github/workflows/update-plugins-repo-refs.yaml +++ b/.github/workflows/update-plugins-repo-refs.yaml @@ -52,13 +52,14 @@ jobs: workspace-keys: ${{ steps.gather-workspaces.outputs.workspace-keys }} steps: - - name: Use node.js 20.x - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - name: Setup Node.js 20.x + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 20.x registry-url: https://registry.npmjs.org/ # Needed for auth - - uses: actions/checkout@v4.2.2 + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Get published community plugins id: get-published-community-plugins @@ -481,7 +482,8 @@ jobs: steps: - - uses: actions/checkout@v4.2.2 + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download workspaces json file uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md index f635a43..7a49131 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,40 @@ Applies patches and source overlays to modify plugin sources before export. This - `patches-applied`: Number of patches applied - `source-overlay-applied`: Whether source overlay files were copied +### validate-metadata + +Validates catalog metadata files against plugin `package.json` files to ensure consistency. This should be run **after** the `export-dynamic` action. + +**Features:** +- Validates `packageName` corresponds to a plugin from `plugins-list.yaml` +- Validates `version` matches the plugin's `package.json` version +- Validates OCI reference format in `dynamicArtifact` (tag and repository prefix) +- Validates `backstage.supportedVersions` matches major.minor of `dist-dynamic/package.json` +- Reports detailed errors to GitHub workflow summary +- Provides JSON output for downstream workflow consumption + +**Usage:** +```yaml +- name: Validate Catalog Metadata + uses: ./validate-metadata + with: + overlay-root: ${{ github.workspace }}/overlay-repo/workspaces/my-workspace + plugins-root: ${{ github.workspace }}/source-repo/workspaces/my-workspace + target-backstage-version: 1.42.5 + image-repository-prefix: ghcr.io/my-org/my-repo # Optional +``` + +**Inputs:** +- `overlay-root`: Absolute path to the overlay workspace folder containing `metadata/` and `plugins-list.yaml` +- `plugins-root`: Absolute path to the source plugins folder containing plugin directories with `package.json` files +- `target-backstage-version`: Target Backstage version for validating OCI tag format +- `image-repository-prefix`: Repository prefix for validating OCI reference format (optional) + +**Outputs:** +- `validation-passed`: Whether the metadata validation passed (`true`/`false`) +- `validation-errors`: JSON array of validation errors (see [validate-metadata/README.md](validate-metadata/README.md) for format details) +- `validation-error-count`: Number of validation errors found + ## Workflow Example ```yaml @@ -58,8 +92,8 @@ jobs: export-plugins: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v6 - name: Override Sources (apply patches and overlays) uses: ./override-sources @@ -73,4 +107,10 @@ jobs: plugins-root: plugins plugins-file: ${{ github.workspace }}/plugins-list.yaml destination: ${{ github.workspace }}/archives + + - name: Validate Catalog Metadata + uses: ./validate-metadata + with: + overlay-root: ${{ github.workspace }}/overlay-repo + plugins-root: ${{ github.workspace }}/plugins ``` \ No newline at end of file diff --git a/validate-metadata/README.md b/validate-metadata/README.md new file mode 100644 index 0000000..b37f516 --- /dev/null +++ b/validate-metadata/README.md @@ -0,0 +1,161 @@ +# Validate Catalog Metadata Action + +This GitHub Action validates catalog metadata files against plugin `package.json` files to ensure consistency during the dynamic plugin export workflow. + +## Validations Performed + +For each YAML file in the `metadata/` folder of the overlay workspace, the following checks are performed: + +1. **Package Exists**: The `packageName` field in the metadata must correspond to a plugin from `plugins-list.yaml` whose `package.json` has a matching `name` field + +2. **Version Match**: The `version` field in the metadata matches the `version` field in the corresponding plugin's `package.json` + +3. **OCI Reference Validation** (if `dynamicArtifact` starts with `oci://ghcr.io`): + - **Tag Format**: The image tag should be `bs___` + - **Reference Format**: The image reference (without tag) should be `/` + +4. **Backstage Supported Versions Match**: The `backstage.supportedVersions` field in the metadata matches the major.minor version of `supportedVersions` in the plugin's `dist-dynamic/package.json` + +## Usage + +```yaml +- name: Validate Catalog Metadata + uses: redhat-developer/rhdh-plugin-export-utils/validate-metadata@main + with: + overlay-root: ${{ github.workspace }}/overlay-repo/workspaces/my-workspace + plugins-root: ${{ github.workspace }}/source-repo/workspaces/my-workspace + target-backstage-version: 1.42.5 + image-repository-prefix: ghcr.io/my-org/my-repo # Optional +``` + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `overlay-root` | Yes | - | Absolute path to the overlay workspace folder containing `metadata/` and `plugins-list.yaml` | +| `plugins-root` | Yes | - | Absolute path to the source plugins folder containing plugin directories with `package.json` files | +| `target-backstage-version` | Yes | - | Target Backstage version for validating OCI tag format (e.g., "1.42.5") | +| `image-repository-prefix` | No | `""` | Repository prefix for validating OCI reference format in `dynamicArtifact` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `validation-passed` | Whether the metadata validation passed (`true`/`false`) | +| `validation-errors` | JSON array of validation errors, empty array if validation passed | +| `validation-error-count` | Number of validation errors found | + +### JSON Output Format + +The `validation-errors` output is a JSON array of error objects. Each error has a `kind` field that determines its structure: + +### Error Kinds + +#### `mismatch` - Field value doesn't match expected + +```json +{ + "kind": "mismatch", + "file": "plugin-name.yaml", + "field": "version", + "expected": "1.2.3", + "actual": "1.2.0", + "message": "Version mismatch: expected \"1.2.3\" but got \"1.2.0\"" +} +``` + +#### `missing-field` - Required field is missing + +```json +{ + "kind": "missing-field", + "file": "plugin-name.yaml", + "field": "spec", + "message": "Missing required field: spec" +} +``` + +#### `missing-file` - Required file or directory is missing + +```json +{ + "kind": "missing-file", + "file": "metadata", + "message": "Metadata directory not found at /path/to/metadata" +} +``` + +#### `parse-error` - File could not be parsed + +```json +{ + "kind": "parse-error", + "file": "plugin-name.yaml", + "message": "Failed to parse YAML" +} +``` + +#### `unknown-package` - Package not found in plugins-list.yaml + +```json +{ + "kind": "unknown-package", + "file": "plugin-name.yaml", + "packageName": "@org/unknown-plugin", + "message": "Package \"@org/unknown-plugin\" not found in plugins-list.yaml" +} +``` + +### Common Properties + +| Property | Description | +|----------|-------------| +| `kind` | Error type: `mismatch`, `missing-field`, `missing-file`, `parse-error`, or `unknown-package` | +| `file` | Filename of the metadata file with the validation error | +| `message` | Human-readable description of the validation error | + +## Error Reporting + +When validation fails, the action: + +1. **Writes to GitHub Step Summary**: A detailed markdown table is added to the workflow summary showing all mismatches +2. **Sets Workflow Outputs**: The validation errors are available as JSON for downstream steps +3. **Fails the Workflow**: The step exits with a non-zero code, failing the workflow + +## Testing + +The `test/` directory contains fixtures for testing the validation action. The test workflow is located at [.github/workflows/test-validate-metadata.yaml](../.github/workflows/test-validate-metadata.yaml), which runs automatically on CI when changes are made to `validate-metadata/**`. + +### Running Tests Locally + +#### Direct Script Execution + +You can test the validation script directly: + +```bash +cd validate-metadata + +# Install dependencies +npm install + +# Run validation (should pass) +INPUTS_OVERLAY_ROOT="$(pwd)/test/cases/pass" \ +INPUTS_PLUGINS_ROOT="$(pwd)/test/source" \ +INPUTS_IMAGE_REPOSITORY_PREFIX="ghcr.io/test-org/test-repo" \ +INPUTS_TARGET_BACKSTAGE_VERSION="1.42.5" \ +node validate-metadata.ts +``` + +#### Using `act` (GitHub Actions Local Runner) + +You can use [act](https://github.com/nektos/act) to run the test workflow locally: + +```bash +# Run all test jobs +act -W .github/workflows/test-validate-metadata.yaml + +# Run a specific test job (e.g., test-validation-pass) +act -j test-validation-pass -W .github/workflows/test-validate-metadata.yaml +``` + +See [.github/workflows/test-validate-metadata.yaml](../.github/workflows/test-validate-metadata.yaml) for available test jobs. diff --git a/validate-metadata/action.yaml b/validate-metadata/action.yaml new file mode 100644 index 0000000..5980bda --- /dev/null +++ b/validate-metadata/action.yaml @@ -0,0 +1,43 @@ +name: Validate Catalog Metadata +description: Validates catalog metadata files against plugin package.json files for consistency +inputs: + overlay-root: + description: Absolute path to the overlay workspace folder containing metadata/ and plugins-list.yaml + required: true + plugins-root: + description: Absolute path to the source plugins folder containing plugin directories with package.json files + required: true + target-backstage-version: + description: Target Backstage version for validating OCI tag format (e.g., "1.42.5") + required: true + image-repository-prefix: + description: Repository prefix for validating OCI reference format in dynamicArtifact + required: false + default: "" + +outputs: + validation-passed: + description: Whether the metadata validation passed (true/false) + value: ${{ steps.validate.outputs.metadata-validation-passed }} + validation-errors: + description: JSON array of validation errors, empty array if validation passed + value: ${{ steps.validate.outputs.metadata-validation-errors }} + validation-error-count: + description: Number of validation errors found + value: ${{ steps.validate.outputs.metadata-validation-error-count }} + +runs: + using: composite + steps: + - name: Validate Catalog Metadata + id: validate + shell: bash + working-directory: ${{ github.action_path }} + env: + INPUTS_OVERLAY_ROOT: ${{ inputs.overlay-root }} + INPUTS_PLUGINS_ROOT: ${{ inputs.plugins-root }} + INPUTS_TARGET_BACKSTAGE_VERSION: ${{ inputs.target-backstage-version }} + INPUTS_IMAGE_REPOSITORY_PREFIX: ${{ inputs.image-repository-prefix }} + run: | + npm install + node validate-metadata.ts diff --git a/validate-metadata/package-lock.json b/validate-metadata/package-lock.json new file mode 100644 index 0000000..9f4a1b0 --- /dev/null +++ b/validate-metadata/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "validate-metadata", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validate-metadata", + "version": "1.0.0", + "dependencies": { + "yaml": "^2.8.2" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/validate-metadata/package.json b/validate-metadata/package.json new file mode 100644 index 0000000..9c207d4 --- /dev/null +++ b/validate-metadata/package.json @@ -0,0 +1,9 @@ +{ + "name": "validate-metadata", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "yaml": "^2.8.2" + } +} diff --git a/validate-metadata/test/cases/fail/metadata/test-fail.yaml b/validate-metadata/test/cases/fail/metadata/test-fail.yaml new file mode 100644 index 0000000..0d21351 --- /dev/null +++ b/validate-metadata/test/cases/fail/metadata/test-fail.yaml @@ -0,0 +1,10 @@ +apiVersion: extensions.backstage.io/v1alpha1 +kind: Package +metadata: + name: test-fail +spec: + packageName: '@test-org/plugin-test' + version: 9.9.9 + dynamicArtifact: oci://ghcr.io/wrong-org/wrong-repo/wrong-image:wrong_tag_1.0.0!hash + backstage: + supportedVersions: 1.99.0 diff --git a/validate-metadata/test/cases/fail/plugins-list.yaml b/validate-metadata/test/cases/fail/plugins-list.yaml new file mode 100644 index 0000000..f273645 --- /dev/null +++ b/validate-metadata/test/cases/fail/plugins-list.yaml @@ -0,0 +1 @@ +plugins/test-plugin: diff --git a/validate-metadata/test/cases/invalid-plugins-list/metadata/test-plugin.yaml b/validate-metadata/test/cases/invalid-plugins-list/metadata/test-plugin.yaml new file mode 100644 index 0000000..d77a0b8 --- /dev/null +++ b/validate-metadata/test/cases/invalid-plugins-list/metadata/test-plugin.yaml @@ -0,0 +1,7 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: test-plugin +spec: + packageName: "@test-org/plugin-test" + version: "1.0.0" diff --git a/validate-metadata/test/cases/invalid-plugins-list/plugins-list.yaml b/validate-metadata/test/cases/invalid-plugins-list/plugins-list.yaml new file mode 100644 index 0000000..b1d343b --- /dev/null +++ b/validate-metadata/test/cases/invalid-plugins-list/plugins-list.yaml @@ -0,0 +1,3 @@ +# Invalid format - should be a dictionary, not an array +- plugins/test-plugin +- plugins/test-backend diff --git a/validate-metadata/test/cases/invalid-yaml/metadata/test-invalid-yaml.yaml b/validate-metadata/test/cases/invalid-yaml/metadata/test-invalid-yaml.yaml new file mode 100644 index 0000000..d8d50e5 --- /dev/null +++ b/validate-metadata/test/cases/invalid-yaml/metadata/test-invalid-yaml.yaml @@ -0,0 +1,4 @@ +this is not valid yaml: + - missing proper structure + unindented: value +: invalid key diff --git a/validate-metadata/test/cases/invalid-yaml/plugins-list.yaml b/validate-metadata/test/cases/invalid-yaml/plugins-list.yaml new file mode 100644 index 0000000..f273645 --- /dev/null +++ b/validate-metadata/test/cases/invalid-yaml/plugins-list.yaml @@ -0,0 +1 @@ +plugins/test-plugin: diff --git a/validate-metadata/test/cases/missing-files/placeholder.txt b/validate-metadata/test/cases/missing-files/placeholder.txt new file mode 100644 index 0000000..3fa6e29 --- /dev/null +++ b/validate-metadata/test/cases/missing-files/placeholder.txt @@ -0,0 +1,2 @@ +# This directory is intentionally missing metadata/ and plugins-list.yaml +# to test the validation of required files diff --git a/validate-metadata/test/cases/missing-packagename/metadata/test-missing-packagename.yaml b/validate-metadata/test/cases/missing-packagename/metadata/test-missing-packagename.yaml new file mode 100644 index 0000000..8c42ba4 --- /dev/null +++ b/validate-metadata/test/cases/missing-packagename/metadata/test-missing-packagename.yaml @@ -0,0 +1,7 @@ +apiVersion: extensions.backstage.io/v1alpha1 +kind: Package +metadata: + name: test-missing-packagename +spec: + version: 1.0.0 + # Note: no packageName field diff --git a/validate-metadata/test/cases/missing-packagename/plugins-list.yaml b/validate-metadata/test/cases/missing-packagename/plugins-list.yaml new file mode 100644 index 0000000..f273645 --- /dev/null +++ b/validate-metadata/test/cases/missing-packagename/plugins-list.yaml @@ -0,0 +1 @@ +plugins/test-plugin: diff --git a/validate-metadata/test/cases/missing-spec/metadata/test-missing-spec.yaml b/validate-metadata/test/cases/missing-spec/metadata/test-missing-spec.yaml new file mode 100644 index 0000000..051a584 --- /dev/null +++ b/validate-metadata/test/cases/missing-spec/metadata/test-missing-spec.yaml @@ -0,0 +1,5 @@ +apiVersion: extensions.backstage.io/v1alpha1 +kind: Package +metadata: + name: test-missing-spec +# Note: no spec section at all diff --git a/validate-metadata/test/cases/missing-spec/plugins-list.yaml b/validate-metadata/test/cases/missing-spec/plugins-list.yaml new file mode 100644 index 0000000..f273645 --- /dev/null +++ b/validate-metadata/test/cases/missing-spec/plugins-list.yaml @@ -0,0 +1 @@ +plugins/test-plugin: diff --git a/validate-metadata/test/cases/pass/metadata/test-backend.yaml b/validate-metadata/test/cases/pass/metadata/test-backend.yaml new file mode 100644 index 0000000..91474d6 --- /dev/null +++ b/validate-metadata/test/cases/pass/metadata/test-backend.yaml @@ -0,0 +1,12 @@ +apiVersion: extensions.backstage.io/v1alpha1 +kind: Package +metadata: + name: test-backend + namespace: rhdh +spec: + packageName: '@test-org/plugin-test-backend' + dynamicArtifact: ./dynamic-plugins/dist/test-org-plugin-test-backend + version: 2.0.0 + backstage: + role: backend-plugin + supportedVersions: 1.42.5 diff --git a/validate-metadata/test/cases/pass/metadata/test-plugin.yaml b/validate-metadata/test/cases/pass/metadata/test-plugin.yaml new file mode 100644 index 0000000..9d2c839 --- /dev/null +++ b/validate-metadata/test/cases/pass/metadata/test-plugin.yaml @@ -0,0 +1,12 @@ +apiVersion: extensions.backstage.io/v1alpha1 +kind: Package +metadata: + name: test-plugin + namespace: rhdh +spec: + packageName: '@test-org/plugin-test' + dynamicArtifact: oci://ghcr.io/test-org/test-repo/test-org-plugin-test:bs_1.42.5__1.0.0!test-org-plugin-test + version: 1.0.0 + backstage: + role: frontend-plugin + supportedVersions: 1.42.5 diff --git a/validate-metadata/test/cases/pass/plugins-list.yaml b/validate-metadata/test/cases/pass/plugins-list.yaml new file mode 100644 index 0000000..6c62493 --- /dev/null +++ b/validate-metadata/test/cases/pass/plugins-list.yaml @@ -0,0 +1,2 @@ +plugins/test-plugin: +plugins/test-backend: diff --git a/validate-metadata/test/cases/unknown-package/metadata/test-unknown-package.yaml b/validate-metadata/test/cases/unknown-package/metadata/test-unknown-package.yaml new file mode 100644 index 0000000..df4bf91 --- /dev/null +++ b/validate-metadata/test/cases/unknown-package/metadata/test-unknown-package.yaml @@ -0,0 +1,10 @@ +apiVersion: extensions.backstage.io/v1alpha1 +kind: Package +metadata: + name: test-unknown-package +spec: + packageName: '@unknown-org/plugin-that-does-not-exist' + version: 1.0.0 + dynamicArtifact: oci://ghcr.io/test-org/test-repo/unknown-plugin:prefix_1.0.0!hash + backstage: + supportedVersions: 1.42.5 diff --git a/validate-metadata/test/cases/unknown-package/plugins-list.yaml b/validate-metadata/test/cases/unknown-package/plugins-list.yaml new file mode 100644 index 0000000..f273645 --- /dev/null +++ b/validate-metadata/test/cases/unknown-package/plugins-list.yaml @@ -0,0 +1 @@ +plugins/test-plugin: diff --git a/validate-metadata/test/source/plugins/test-backend/dist-dynamic/package.json b/validate-metadata/test/source/plugins/test-backend/dist-dynamic/package.json new file mode 100644 index 0000000..27e4328 --- /dev/null +++ b/validate-metadata/test/source/plugins/test-backend/dist-dynamic/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test-org/plugin-test-backend", + "version": "2.0.0", + "backstage": { + "role": "backend-plugin", + "supportedVersions": "1.42.5" + } +} diff --git a/validate-metadata/test/source/plugins/test-backend/package.json b/validate-metadata/test/source/plugins/test-backend/package.json new file mode 100644 index 0000000..ccc1731 --- /dev/null +++ b/validate-metadata/test/source/plugins/test-backend/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test-org/plugin-test-backend", + "version": "2.0.0", + "backstage": { + "role": "backend-plugin" + } +} diff --git a/validate-metadata/test/source/plugins/test-plugin/dist-dynamic/package.json b/validate-metadata/test/source/plugins/test-plugin/dist-dynamic/package.json new file mode 100644 index 0000000..bf59a3e --- /dev/null +++ b/validate-metadata/test/source/plugins/test-plugin/dist-dynamic/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test-org/plugin-test", + "version": "1.0.0", + "backstage": { + "role": "frontend-plugin", + "supportedVersions": "1.42.5" + } +} diff --git a/validate-metadata/test/source/plugins/test-plugin/package.json b/validate-metadata/test/source/plugins/test-plugin/package.json new file mode 100644 index 0000000..139f830 --- /dev/null +++ b/validate-metadata/test/source/plugins/test-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "@test-org/plugin-test", + "version": "1.0.0", + "backstage": { + "role": "frontend-plugin" + } +} diff --git a/validate-metadata/validate-metadata.ts b/validate-metadata/validate-metadata.ts new file mode 100644 index 0000000..e944d27 --- /dev/null +++ b/validate-metadata/validate-metadata.ts @@ -0,0 +1,497 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import yaml from 'yaml'; + +interface MissingFileError { + kind: 'missing-file'; + file: string; + message: string; +} + +interface ParseError { + kind: 'parse-error'; + file: string; + message: string; +} + +interface MissingFieldError { + kind: 'missing-field'; + file: string; + field: string; + message: string; +} + +interface UnknownPackageError { + kind: 'unknown-package'; + file: string; + packageName: string; + message: string; +} + +interface MismatchError { + kind: 'mismatch'; + file: string; + field: string; + expected: string; + actual?: string; + message: string; +} + +type ValidationError = MissingFileError | ParseError | MissingFieldError | UnknownPackageError | MismatchError; + +type Result = { value: T; error?: undefined } | { value?: undefined; error: E }; + +interface BackstageMetadata { + role?: string; + supportedVersions?: string; +} + +interface PackageJson { + name: string; + version: string; + backstage?: BackstageMetadata; +} + +interface PluginInfo { + path: string; + packageJson: PackageJson; +} + +interface OciReference { + reference: string; + tag?: string; +} + +interface MetadataSpec { + packageName?: string; + version?: string; + dynamicArtifact?: string; + backstage?: BackstageMetadata; +} + +interface Metadata { + spec?: MetadataSpec; +} + +// Configuration from environment variables +const OVERLAY_ROOT = process.env.INPUTS_OVERLAY_ROOT; +const PLUGINS_ROOT = process.env.INPUTS_PLUGINS_ROOT; +const TARGET_BACKSTAGE_VERSION = process.env.INPUTS_TARGET_BACKSTAGE_VERSION; +const IMAGE_REPOSITORY_PREFIX = process.env.INPUTS_IMAGE_REPOSITORY_PREFIX || ''; + +// Validate required environment variables +if (!OVERLAY_ROOT) { + console.error('ERROR: INPUTS_OVERLAY_ROOT environment variable is required'); + process.exit(1); +} + +if (!PLUGINS_ROOT) { + console.error('ERROR: INPUTS_PLUGINS_ROOT environment variable is required'); + process.exit(1); +} + +if (!TARGET_BACKSTAGE_VERSION) { + console.error('ERROR: INPUTS_TARGET_BACKSTAGE_VERSION environment variable is required'); + process.exit(1); +} + +const metadataDir = path.join(OVERLAY_ROOT, 'metadata'); +const pluginsListPath = path.join(OVERLAY_ROOT, 'plugins-list.yaml'); + +const errors: ValidationError[] = []; + +// Check if metadata directory exists +if (!fs.existsSync(metadataDir)) { + errors.push({ + kind: 'missing-file', + file: metadataDir, + message: `Metadata directory not found at ${metadataDir}` + }); +} + +// Check if plugins-list.yaml exists +if (!fs.existsSync(pluginsListPath)) { + errors.push({ + kind: 'missing-file', + file: pluginsListPath, + message: `plugins-list.yaml not found at ${pluginsListPath}` + }); +} + +// If required files are missing, report and exit +if (errors.length > 0) { + reportErrorsAndExit(errors); +} + +console.log('Building plugin mapping from plugins-list.yaml...'); + +const { value: pluginPaths, error: pluginsListError } = parsePluginsList(pluginsListPath); + +if (pluginsListError) { + errors.push(pluginsListError); + reportErrorsAndExit(errors); +} + +const pluginsMapping = buildPluginMapping(pluginPaths, PLUGINS_ROOT); + +console.log(`Found ${pluginsMapping.size} plugins in plugins-list.yaml`); + +// Find all YAML files in metadata directory +const metadataFiles = fs.readdirSync(metadataDir) + .filter(file => file.endsWith('.yaml') || file.endsWith('.yml')) + .map(file => path.join(metadataDir, file)); + +console.log(`Found ${metadataFiles.length} metadata files to validate`); + +for (const metadataFile of metadataFiles) { + console.log(`Validating ${path.basename(metadataFile)}...`); + const fileErrors = validateMetadataFile(metadataFile, pluginsMapping); + if (fileErrors.length > 0) { + console.log(` Found ${fileErrors.length} error(s)`); + errors.push(...fileErrors); + } else { + console.log(' āœ… Valid'); + } +} + +reportErrorsAndExit(errors); + +/** + * Report validation errors to GitHub Actions and exit + */ +function reportErrorsAndExit(errors: ValidationError[]): never { + const summary = formatErrorsForSummary(errors); + const githubStepSummary = process.env.GITHUB_STEP_SUMMARY; + if (githubStepSummary) { + fs.appendFileSync(githubStepSummary, `\n${summary}\n`); + } + console.log('\n' + summary); + + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + const errorsJson = formatErrorsAsJson(errors); + fs.appendFileSync(githubOutput, `metadata-validation-errors=${errorsJson}\n`); + fs.appendFileSync(githubOutput, `metadata-validation-passed=${errors.length === 0}\n`); + fs.appendFileSync(githubOutput, `metadata-validation-error-count=${errors.length}\n`); + } + + if (errors.length > 0) { + console.error(`\nValidation failed with ${errors.length} error(s)`); + process.exit(1); + } + + console.log('\nāœ… All metadata files validated successfully'); + process.exit(0); +} + +/** + * Format errors for GitHub Actions summary + */ +function formatErrorsForSummary(errors: ValidationError[]): string { + if (errors.length === 0) { + return '## āœ… Metadata Validation Passed\n\nAll metadata files are consistent with their corresponding plugin packages.'; + } + + let summary = '## āŒ Metadata Validation Failed\n\n'; + summary += `Found **${errors.length}** error(s) in catalog metadata files:\n\n`; + summary += '| File | Kind | Message |\n'; + summary += '|------|------|---------|\n'; + + for (const error of errors) { + const fileName = path.basename(error.file); + const escapedMessage = error.message.replaceAll('|', '\\|'); + summary += `| ${fileName} | ${error.kind} | ${escapedMessage} |\n`; + } + + return summary; +} + +/** + * Format errors as JSON for workflow output + */ +function formatErrorsAsJson(errors: ValidationError[]): string { + return JSON.stringify(errors.map(error => ({ + ...error, + file: path.basename(error.file), + }))); +} + +/** + * Parse and validate plugins-list.yaml format + */ +function parsePluginsList(pluginsListPath: string): Result { + const { value: pluginsList, error: parseError } = parseYamlFile(pluginsListPath); + + if (parseError) { + return { error: parseError }; + } + + if (typeof pluginsList !== 'object' || Array.isArray(pluginsList) || pluginsList === null) { + return { + error: { + kind: 'parse-error', + file: pluginsListPath, + message: 'plugins-list.yaml must be a YAML dictionary with plugin paths as keys' + } + }; + } + + return { value: Object.keys(pluginsList) }; +} + +/** + * Build mapping from packageName to plugin path from plugins-list.yaml + */ +function buildPluginMapping(pluginPaths: string[], pluginsRoot: string): Map { + const pluginsMapping = new Map(); + + for (const pluginPath of pluginPaths) { + const fullPluginPath = path.join(pluginsRoot, pluginPath); + const packageJsonPath = path.join(fullPluginPath, 'package.json'); + + if (fs.existsSync(packageJsonPath)) { + const packageJson = parseJsonFile(packageJsonPath); + if (packageJson?.name) { + pluginsMapping.set(packageJson.name, { + path: fullPluginPath, + packageJson + }); + } + } + } + + return pluginsMapping; +} + +/** + * Validate a single metadata file against its corresponding plugin + */ +function validateMetadataFile(metadataFilePath: string, pluginsMapping: Map): ValidationError[] { + const { value: rawMetadata, error: parseError } = parseYamlFile(metadataFilePath); + + if (parseError) { + return [parseError]; + } + + const metadata: Metadata | null = rawMetadata && typeof rawMetadata === 'object' ? rawMetadata as Metadata : null; + + if (!metadata) { + return [{ + kind: 'parse-error', + file: metadataFilePath, + message: 'Metadata file must be a YAML object' + }]; + } + + const spec = metadata.spec; + if (!spec) { + return [{ + kind: 'missing-field', + file: metadataFilePath, + field: 'spec', + message: 'Missing required field: spec' + }]; + } + + const packageName = spec.packageName; + const metadataVersion = spec.version; + const dynamicArtifact = spec.dynamicArtifact; + const backstageSupportedVersions = spec.backstage?.supportedVersions; + + // Check if packageName exists in plugins mapping + if (!packageName) { + return [{ + kind: 'missing-field', + file: metadataFilePath, + field: 'packageName', + message: 'Missing required field: packageName' + }]; + } + + const pluginInfo = pluginsMapping.get(packageName); + if (!pluginInfo) { + return [{ + kind: 'unknown-package', + file: metadataFilePath, + packageName, + message: `Package "${packageName}" not found in plugins-list.yaml` + }]; + } + + const errors: ValidationError[] = []; + + const pluginPackageJson = pluginInfo.packageJson; + const pluginVersion = pluginPackageJson.version; + + // Validate version matches + if (metadataVersion !== pluginVersion) { + errors.push({ + kind: 'mismatch', + file: metadataFilePath, + field: 'version', + expected: pluginVersion, + actual: metadataVersion, + message: `Version mismatch: expected "${pluginVersion}" but got "${metadataVersion}"` + }); + } + + // Validate dynamicArtifact if it's an OCI reference + if (dynamicArtifact?.startsWith('oci://ghcr.io')) { + validateOciReference(errors, metadataFilePath, dynamicArtifact, pluginVersion, packageName); + } + + // Validate backstage.supportedVersions matches dist-dynamic/package.json + const distDynamicPackageJsonPath = path.join(pluginInfo.path, 'dist-dynamic', 'package.json'); + if (fs.existsSync(distDynamicPackageJsonPath)) { + validateBackstageSupportedVersions(errors, metadataFilePath, distDynamicPackageJsonPath, backstageSupportedVersions); + } + + return errors; +} + +/** + * Validate OCI reference against expected values + */ +function validateOciReference( + errors: ValidationError[], + metadataFilePath: string, + dynamicArtifact: string, + pluginVersion: string, + packageName: string +): void { + const { reference, tag } = parseOciReference(dynamicArtifact); + const expectedTag = `bs_${TARGET_BACKSTAGE_VERSION}__${pluginVersion}`; + + if (tag !== expectedTag) { + errors.push({ + kind: 'mismatch', + file: metadataFilePath, + field: 'dynamicArtifact.tag', + expected: expectedTag, + actual: tag, + message: `OCI tag mismatch: expected "${expectedTag}" but got "${tag}"` + }); + } + + // Validate reference format: / + if (!IMAGE_REPOSITORY_PREFIX) { + return; + } + + const expectedImageName = packageNameToImageName(packageName); + const expectedReference = `oci://${IMAGE_REPOSITORY_PREFIX}/${expectedImageName}`; + if (reference !== expectedReference) { + errors.push({ + kind: 'mismatch', + file: metadataFilePath, + field: 'dynamicArtifact.reference', + expected: expectedReference, + actual: reference, + message: `OCI reference mismatch: expected "${expectedReference}" but got "${reference}"` + }); + } +} + +/** + * Validate backstage.supportedVersions matches dist-dynamic/package.json + */ +function validateBackstageSupportedVersions( + errors: ValidationError[], + metadataFilePath: string, + distDynamicPackageJsonPath: string, + backstageSupportedVersions: string | undefined +): void { + const pkg = parseJsonFile(distDynamicPackageJsonPath); + if (!pkg) { + return; + } + + const supportedVersions = pkg.backstage?.supportedVersions; + if (!supportedVersions || !backstageSupportedVersions) { + return; + } + + const expectedMajorMinor = getMajorMinorVersion(supportedVersions); + const actualMajorMinor = getMajorMinorVersion(backstageSupportedVersions); + + if (expectedMajorMinor !== actualMajorMinor) { + errors.push({ + kind: 'mismatch', + file: metadataFilePath, + field: 'backstage.supportedVersions', + expected: `${expectedMajorMinor}.x (from dist-dynamic/package.json: ${supportedVersions})`, + actual: backstageSupportedVersions, + message: `Backstage supportedVersions mismatch: expected "${expectedMajorMinor}.x" but got "${actualMajorMinor}.x"` + }); + } +} + +/** + * Parse a YAML file and return its content + */ +function parseYamlFile(filePath: string): Result { + try { + const content = fs.readFileSync(filePath, 'utf8'); + return { value: yaml.parse(content) }; + } catch { + return { + error: { + kind: 'parse-error', + file: filePath, + message: 'Failed to parse YAML' + } + }; + } +} + +/** + * Parse a JSON file and return its content + */ +function parseJsonFile(filePath: string): PackageJson | null { + try { + const content = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(content); + } catch { + return null; + } +} + +/** + * Get major.minor version string from a full version + */ +function getMajorMinorVersion(version: string | undefined): string | null { + if (!version) return null; + const parts = version.split('.'); + if (parts.length >= 2) { + return `${parts[0]}.${parts[1]}`; + } + return version; +} + +/** + * Convert package name to image name format + * Replace @ with empty string and / with - + */ +function packageNameToImageName(packageName: string): string { + return packageName.replace(/^@/, '').replaceAll('/', '-'); +} + +/** + * Parse OCI reference into components + * Format: oci://ghcr.io///:! + */ +function parseOciReference(ociRef: string): OciReference { + // Remove hash suffix if present + const withoutHash = ociRef.split('!')[0]; + + // Split on last colon to get reference and tag + const lastColonIndex = withoutHash.lastIndexOf(':'); + if (lastColonIndex === -1) { + return { reference: withoutHash }; + } + + return { + reference: withoutHash.substring(0, lastColonIndex), + tag: withoutHash.substring(lastColonIndex + 1) + }; +}