From d536636fd4c2031b64fda8a4bcd0affbfb4fa748 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 26 Oct 2026 17:09:23 +0200 Subject: [PATCH 01/13] ci(release): add SBOM evidence for published packages, disable npm publish Pack every publishable package once and describe the exact tarballs with validated CycloneDX 1.6 SBOMs, build provenance and per-package SBOM attestations, then attach the evidence to the GitHub release. Licenses a package does not declare are looked up on ClearlyDefined; gaps produce a warning instead of failing the release. npm publish (and the MCP Registry job that depends on it) is disabled until the flow is validated with a dummy GitHub release. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/npm-publish.yml | 323 ++++++++++++++++++++- package.json | 1 + scripts/build-sbom.ts | 453 ++++++++++++++++++++++++++++++ scripts/sbom-document.ts | 181 ++++++++++++ scripts/sbom-licenses.ts | 144 ++++++++++ scripts/sbom-notes.ts | 166 +++++++++++ scripts/templates/sbom-readme.md | 64 +++++ scripts/update-release-notes.ts | 13 + spec/unit/sbom-document-spec.ts | 85 ++++++ spec/unit/sbom-licenses-spec.ts | 113 ++++++++ spec/unit/sbom-notes-spec.ts | 135 +++++++++ yarn.lock | 184 +++++++++++- 12 files changed, 1842 insertions(+), 20 deletions(-) create mode 100644 scripts/build-sbom.ts create mode 100644 scripts/sbom-document.ts create mode 100644 scripts/sbom-licenses.ts create mode 100644 scripts/sbom-notes.ts create mode 100644 scripts/templates/sbom-readme.md create mode 100644 scripts/update-release-notes.ts create mode 100644 spec/unit/sbom-document-spec.ts create mode 100644 spec/unit/sbom-licenses-spec.ts create mode 100644 spec/unit/sbom-notes-spec.ts diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index d5728f6e3..06cd57261 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -10,25 +10,36 @@ on: description: "Release tag to register with the MCP Registry (e.g. 15.8.0)" required: true -permissions: - id-token: write - contents: read +permissions: {} jobs: + # Builds, packs and inventories the release. Holds no publishing rights and no OIDC token. build: + name: Build and inventory if: ${{ github.event_name == 'release' }} runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + env: + VERSION: ${{ github.event.release.tag_name }} + outputs: + npm-tag: ${{ steps.npm-tag.outputs.npm-tag }} + packages: ${{ steps.digests.outputs.packages }} + checksums: ${{ steps.digests.outputs.checksums }} steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 registry-url: "https://registry.npmjs.org" - - run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV - - run: echo ${VERSION} - - name: Install packages run: yarn install --frozen-lockfile @@ -41,27 +52,313 @@ jobs: run: npm run validate:package --workspace=@igniteui/mcp-server -- --expected-version "$VERSION" - name: Define npm tag + id: npm-tag run: | if [[ ${VERSION} == *"alpha"* || ${VERSION} == *"beta"* || ${VERSION} == *"rc"* ]]; then NPM_TAG=next else NPM_TAG=latest fi - echo "NPM_TAG=$NPM_TAG" >> $GITHUB_ENV + echo "npm-tag=$NPM_TAG" >> $GITHUB_OUTPUT echo "$NPM_TAG" + # Packs every publishable package, then describes the exact tarballs it produced. Everything + # downstream uses those files, so their digests, SBOMs, attestations and publication agree. + # License gaps (nothing declared, nothing on ClearlyDefined) are reported as warnings, not failures. + - name: Generate delivered SBOMs (CycloneDX 1.6) + run: node scripts/build-sbom.js --release="$VERSION" --out=artifacts + + - name: Generate build environment SBOM (CycloneDX 1.6) + # Supplementary best-effort inventory: npm tree errors are captured for the release notes. + run: node scripts/build-sbom.js --release="$VERSION" --out=artifacts --build-env + + - name: Record package digests + id: digests + run: | + set -euo pipefail + cd artifacts + + { + echo "checksums<> $GITHUB_OUTPUT + + { echo "### Packed artifacts"; echo '```'; sha256sum *.tgz; echo '```'; } >> $GITHUB_STEP_SUMMARY + + # Rendered here because this job has the compiled tooling; the write-scoped job only applies it. + - name: Prepare release notes + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p release-notes + gh release view "$VERSION" -R "${{ github.repository }}" --json body --jq .body > release-notes/original.md + node scripts/update-release-notes.js \ + release-notes/original.md \ + "artifacts/igniteui-cli-release-${VERSION}.supply-chain.md" \ + release-notes/updated.md + + - name: Upload release artifacts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-artifacts + path: artifacts/* + retention-days: 30 + if-no-files-found: error + + - name: Upload release notes + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-notes + path: release-notes/* + retention-days: 30 + if-no-files-found: error + + attest: + name: Attest build provenance + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + env: + # Artifact attestations are unavailable in private repositories outside GitHub Enterprise Cloud. + ATTESTATIONS_ENABLED: ${{ !github.event.repository.private }} + + steps: + - name: Download release artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-artifacts + path: artifacts + digest-mismatch: error + + - name: Verify the packages are the ones that were built + env: + EXPECTED: ${{ needs.build.outputs.checksums }} + run: | + set -euo pipefail + cd artifacts + printf '%s\n' "$EXPECTED" | sha256sum --check --strict + + - name: Attest build provenance + id: attest-provenance + if: env.ATTESTATIONS_ENABLED == 'true' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: artifacts/*.tgz + + - name: Collect attestation bundle + if: env.ATTESTATIONS_ENABLED == 'true' + run: | + set -euo pipefail + mkdir -p attestations + cp "${{ steps.attest-provenance.outputs.bundle-path }}" attestations/provenance.sigstore.json + echo "- Provenance: ${{ steps.attest-provenance.outputs.attestation-url }}" >> $GITHUB_STEP_SUMMARY + + - name: Upload attestation bundle + if: env.ATTESTATIONS_ENABLED == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: attestations-provenance + path: attestations/* + retention-days: 30 + if-no-files-found: error + + # One SBOM attestation per package: each binds that package's delivered SBOM to its tarball digest. + attest-sbom: + name: Attest SBOM (${{ matrix.package.name }}) + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + strategy: + matrix: + package: ${{ fromJSON(needs.build.outputs.packages) }} + env: + ATTESTATIONS_ENABLED: ${{ !github.event.repository.private }} + + steps: + - name: Download release artifacts + if: env.ATTESTATIONS_ENABLED == 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-artifacts + path: artifacts + digest-mismatch: error + + - name: Verify the packages are the ones that were built + if: env.ATTESTATIONS_ENABLED == 'true' + env: + EXPECTED: ${{ needs.build.outputs.checksums }} + run: | + set -euo pipefail + cd artifacts + printf '%s\n' "$EXPECTED" | sha256sum --check --strict + + - name: Attest SBOM (CycloneDX) + id: attest-sbom + if: env.ATTESTATIONS_ENABLED == 'true' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: artifacts/${{ matrix.package.tarball }} + sbom-path: artifacts/${{ matrix.package.sbom }} + + - name: Collect attestation bundle + if: env.ATTESTATIONS_ENABLED == 'true' + env: + PACKAGE_ID: ${{ matrix.package.id }} + PACKAGE_NAME: ${{ matrix.package.name }} + run: | + set -euo pipefail + mkdir -p attestations + cp "${{ steps.attest-sbom.outputs.bundle-path }}" "attestations/${PACKAGE_ID}.sbom-cyclonedx.sigstore.json" + echo "- SBOM (CycloneDX) for ${PACKAGE_NAME}: ${{ steps.attest-sbom.outputs.attestation-url }}" >> $GITHUB_STEP_SUMMARY + + - name: Upload attestation bundle + if: env.ATTESTATIONS_ENABLED == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: attestations-sbom-${{ matrix.package.id }} + path: attestations/* + retention-days: 30 + if-no-files-found: error + + # The only job that can publish to npm. It compiles nothing and never checks out the repository. + # TODO: re-enable once the release flow has been validated end to end with a dummy GitHub release. + publish: + name: Publish to npmjs.org + needs: [build, attest, attest-sbom] + if: false + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + id-token: write + + steps: + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + + - name: Download release artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-artifacts + path: artifacts + digest-mismatch: error + + - name: Verify the packages that were built and attested + env: + EXPECTED: ${{ needs.build.outputs.checksums }} + run: | + set -euo pipefail + cd artifacts + printf '%s\n' "$EXPECTED" | sha256sum --check --strict + + # Publishing the packed tarballs guarantees the published bytes are the attested bytes. + # Authentication and npm provenance both come from the OIDC token, so no npm token is needed. - name: Publish packages - # use npm run as yarn run changes the registry and publishes to https://registry.yarnpkg.com - run: npm run deploy ${NPM_TAG} + env: + NPM_TAG: ${{ needs.build.outputs.npm-tag }} + PACKAGES: ${{ needs.build.outputs.packages }} + run: | + set -euo pipefail + cd artifacts + # In dependency order, so no package is published before the workspace packages it depends on. + jq -r '.[].tarball' <<< "$PACKAGES" | while read -r tarball; do + npm publish "./${tarball}" --tag "$NPM_TAG" + done + + attach-to-release: + name: Attach release evidence + needs: [build, attest, attest-sbom] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + env: + VERSION: ${{ github.event.release.tag_name }} + + steps: + - name: Download release artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-artifacts + path: artifacts + + - name: Download attestation bundles + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + continue-on-error: true + with: + pattern: attestations-* + merge-multiple: true + path: attestations + + - name: Download release notes + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-notes + path: release-notes + + - name: Publish supply-chain notes to the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # The notes were merged into the body the build job saw; never overwrite a later edit. + gh release view "$VERSION" -R "${{ github.repository }}" --json body --jq .body > current.md + if cmp -s current.md release-notes/original.md; then + gh release edit "$VERSION" -R "${{ github.repository }}" --notes-file release-notes/updated.md + else + echo "::warning title=Release notes::The release notes changed during the build; the supply-chain section was not added. It is in the SBOM archive." + fi + + - name: Package the SBOM archive + run: | + set -euo pipefail + + zip -j "igniteui-cli-release-${VERSION}.sbom.zip" \ + artifacts/*.cdx.json \ + "artifacts/igniteui-cli-release-${VERSION}.supply-chain.md" \ + "artifacts/igniteui-cli-release-${VERSION}.sbom.README.md" + + - name: Attach evidence to the release + env: + GH_TOKEN: ${{ github.token }} + PACKAGES: ${{ needs.build.outputs.packages }} + run: | + set -euo pipefail + + gh release upload "$VERSION" --clobber -R "${{ github.repository }}" \ + artifacts/*.tgz \ + artifacts/*.tgz.sha256 \ + artifacts/*.tgz.sha512 \ + $(jq -r '.[].sbom | "artifacts/" + .' <<< "$PACKAGES") \ + "igniteui-cli-release-${VERSION}.sbom.zip" \ + $(ls attestations/*.json 2>/dev/null || true) # Separate job so a failed registry publish can be retried with "Re-run failed jobs" # without repeating the npm publish. # Skipped for prereleases — the MCP registry has no dist-tag equivalent, # so publishing an alpha/beta/rc would make it the current version. + # Skipped while the publish job is disabled: the registry validates the package against npm. mcp-registry: - needs: build - if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || (needs.build.result == 'success' && !github.event.release.prerelease)) }} + needs: publish + if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || (needs.publish.result == 'success' && !github.event.release.prerelease)) }} runs-on: ubuntu-latest + permissions: + contents: read + id-token: write defaults: run: working-directory: packages/igniteui-mcp/igniteui-doc-mcp diff --git a/package.json b/package.json index d3dd0cfc4..7121ad8cf 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "handlebars": "4.7.10" }, "devDependencies": { + "@cyclonedx/cyclonedx-npm": "^6.0.1", "@inquirer/prompts": "^7.9.0", "@types/jasmine": "^5.1.13", "@types/minimatch": "^5.1.2", diff --git a/scripts/build-sbom.ts b/scripts/build-sbom.ts new file mode 100644 index 000000000..7f0728cd1 --- /dev/null +++ b/scripts/build-sbom.ts @@ -0,0 +1,453 @@ +import { execFileSync, spawnSync } from "child_process"; +import { createHash } from "crypto"; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { createRequire } from "module"; +import * as path from "path"; +import { parseArgs } from "util"; +import { + assertDeliveredShape, Bom, npmPurl, PackedArtifact, promoteMainComponent, Supplier +} from "./sbom-document"; +import { + applyLicenses, clearlyDefinedCoordinate, describeComponent, resolveLicenses, unlicensedComponents +} from "./sbom-licenses"; +import { AuditResult, formatSupplyChainNotes, NpmTreeDiagnostics, PackageEvidence } from "./sbom-notes"; + +const SPEC_VERSION = "1.6"; +const SUPPLIER: Supplier = { + name: "Infragistics", + url: ["https://www.infragistics.com"] +}; + +const repoRoot = path.resolve(__dirname, ".."); +// Resolved by path: the package's exports map deliberately hides both the CLI and its manifest. +const cycloneDxDir = path.join(repoRoot, "node_modules", "@cyclonedx", "cyclonedx-npm"); + +interface WorkspacePackage { + dir: string; + name: string; + version: string; + /** Other publishable workspace packages this one depends on. */ + siblings: string[]; +} + +interface Delivery { + pkg: WorkspacePackage; + artifact: PackedArtifact; + workspaceDir: string; + bom: Bom; + componentCount: number; +} + +function log(message: string) { + // eslint-disable-next-line no-console + console.log(`[sbom] ${message}`); +} + +function warn(message: string) { + // eslint-disable-next-line no-console + console.warn(process.env.GITHUB_ACTIONS ? `::warning title=SBOM::${message}` : `[sbom] warning: ${message}`); +} + +function readJson(file: string) { + return JSON.parse(readFileSync(file, "utf8")); +} + +/** + * Locate npm's JS entry point. Node refuses to spawn "npm.cmd" without a shell on Windows, + * and the same path lets the CycloneDX generator reuse this exact npm via npm_execpath. + */ +function resolveNpmCli(): string { + const nodeDir = path.dirname(process.execPath); + const candidates = [ + process.env.npm_execpath, + path.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js"), + path.join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js") + ]; + const cli = candidates.find(candidate => candidate?.endsWith(".js") && existsSync(candidate)); + + if (!cli) { + throw new Error("Could not locate npm-cli.js next to the running Node.js installation."); + } + + return cli; +} + +const npmCli = resolveNpmCli(); + +/** Run a command without a shell, so no argument can be interpreted as shell syntax. */ +function run(command: string, args: string[], cwd: string, stdout: "pipe" | "inherit" = "pipe"): string { + return execFileSync(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", stdout, "inherit"], + env: { ...process.env, npm_execpath: npmCli } + }) ?? ""; +} + +function npm(args: string[], cwd: string, stdout: "pipe" | "inherit" = "pipe"): string { + return run(process.execPath, [npmCli, ...args], cwd, stdout); +} + +function sbomFileOf(artifact: PackedArtifact): string { + return artifact.name.replace(/\.tgz$/, ".cdx.json"); +} + +function digest(file: string, algorithm: string): string { + return createHash(algorithm).update(readFileSync(file)).digest("hex"); +} + +function ensureDir(dir: string): string { + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** The CycloneDX CLI from node_modules, so generation never fetches a generator from the network. */ +function resolveCycloneDxCli(): string { + const cli = path.join(cycloneDxDir, "bin", "cyclonedx-npm-cli.js"); + + if (!existsSync(cli)) { + throw new Error(`@cyclonedx/cyclonedx-npm CLI not found at ${cli}. Run "yarn install" first.`); + } + + return cli; +} + +function cycloneDxToolVersion(): string { + return readJson(path.join(cycloneDxDir, "package.json")).version; +} + +/** + * The packages lerna publishes, ordered so each one follows the workspace packages it depends on. + * Read from lerna.json rather than listed here, so a new package cannot silently ship without an SBOM. + */ +function publishablePackages(): WorkspacePackage[] { + const patterns: string[] = readJson(path.join(repoRoot, "lerna.json")).packages; + const dirs = patterns.flatMap(pattern => { + if (!pattern.endsWith("/*")) { + return [pattern]; + } + const parent = pattern.slice(0, -2); + return readdirSync(path.join(repoRoot, parent), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => `${parent}/${entry.name}`); + }); + const manifests = dirs + .filter(dir => existsSync(path.join(repoRoot, dir, "package.json"))) + .map(dir => ({ dir, manifest: readJson(path.join(repoRoot, dir, "package.json")) })) + .filter(({ manifest }) => !manifest.private); + const names = new Set(manifests.map(({ manifest }) => manifest.name as string)); + const packages = new Map(manifests.map(({ dir, manifest }) => [manifest.name, { + dir, + name: manifest.name, + version: manifest.version, + siblings: Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies }).filter(name => names.has(name)) + }])); + + const ordered: WorkspacePackage[] = []; + const visit = (pkg: WorkspacePackage, trail: string[]) => { + if (ordered.includes(pkg)) { + return; + } + if (trail.includes(pkg.name)) { + throw new Error(`Workspace dependency cycle: ${[...trail, pkg.name].join(" -> ")}`); + } + pkg.siblings.forEach(name => visit(packages.get(name), [...trail, pkg.name])); + ordered.push(pkg); + }; + [...packages.values()].sort((a, b) => a.name.localeCompare(b.name)).forEach(pkg => visit(pkg, [])); + + return ordered; +} + +/** Pack a built package so the SBOM subject is the exact artifact that gets published. */ +function packArtifact(pkg: WorkspacePackage, outDir: string): PackedArtifact { + // Leading "./" is required: without it npm treats "packages/..." as a GitHub shorthand spec. + const output = npm(["pack", `./${pkg.dir}`, "--pack-destination", outDir, "--json"], repoRoot); + const name = JSON.parse(output)[0].filename; + const tarball = path.join(outDir, name); + + log(`packed ${name}`); + + return { + name, + path: tarball, + sha256: digest(tarball, "sha256"), + sha512: digest(tarball, "sha512") + }; +} + +/** + * Install the packed tarball in isolation, producing the dependency closure a consumer resolves. + * Workspace siblings are released together and may not be on npm yet, so they resolve from the + * tarballs packed by this same build instead of the registry. + */ +function installDeliveredClosure(pkg: WorkspacePackage, artifacts: Map, workspaceDir: string): string { + rmSync(workspaceDir, { recursive: true, force: true }); + ensureDir(workspaceDir); + + const tarballSpec = (name: string) => + `file:${path.relative(workspaceDir, artifacts.get(name).path).replace(/\\/g, "/")}`; + // npm rejects an override for a direct dependency, so the package itself is left out. + const overrides = [...artifacts.keys()] + .filter(name => name !== pkg.name) + .reduce((result, name) => ({ ...result, [name]: tarballSpec(name) }), {}); + + writeFileSync( + path.join(workspaceDir, "package.json"), + `${JSON.stringify({ + name: "igniteui-cli-sbom-workspace", + version: "0.0.0", + private: true, + dependencies: { [pkg.name]: tarballSpec(pkg.name) }, + overrides + }, null, 2)}\n` + ); + + // The lockfile this writes is the evidence npm audit reads back from this same tree. + npm(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], workspaceDir, "inherit"); + + log(`installed the delivered dependency closure of ${pkg.name}`); + return workspaceDir; +} + +function generateCycloneDx(cwd: string, extraArgs: string[]): Bom { + // Not --output-reproducible: that flag drops serialNumber, which actions/attest requires + // (alongside bomFormat and specVersion) to recognize the document as CycloneDX. + const document = run( + process.execPath, + [resolveCycloneDxCli(), "--spec-version", SPEC_VERSION, "--output-format", "JSON", "--output-file", "-", "--validate", ...extraArgs], + cwd + ); + + return JSON.parse(document); +} + +function writeBom(file: string, bom: Bom) { + writeFileSync(file, `${JSON.stringify(bom, null, 2)}\n`); + log(`wrote ${path.relative(repoRoot, file)}`); +} + +/** Capture npm's dependency-tree diagnostics without failing supplementary inventory generation. */ +function captureNpmTreeDiagnostics(): NpmTreeDiagnostics { + const result = spawnSync(process.execPath, [npmCli, "ls", "--json", "--all"], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + env: { ...process.env, npm_execpath: npmCli } + }); + const problems: string[] = []; + + try { + const pending = [JSON.parse(result.stdout || "{}")]; + while (pending.length) { + const entry = pending.pop(); + if (!entry || typeof entry !== "object") { + continue; + } + if (Array.isArray(entry.problems)) { + problems.push(...entry.problems); + } + pending.push(...Object.values(entry)); + } + } catch (error) { + problems.push(`Could not parse npm ls JSON: ${error.message}`); + } + + return { + status: result.status ?? 1, + problems, + stderr: [result.error?.message, result.stderr].filter(Boolean).join("\n") + }; +} + +/** Audit a delivered closure. A nonzero exit only means findings, so it never fails the release. */ +function captureDeliveredAudit(workspaceDir: string): AuditResult { + const result = spawnSync(process.execPath, [npmCli, "audit", "--omit=dev", "--json"], { + cwd: workspaceDir, + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + env: { ...process.env, npm_execpath: npmCli } + }); + + try { + const report = JSON.parse(result.stdout || "{}"); + + if (!report.metadata?.vulnerabilities) { + throw new Error(report.error?.summary ?? (result.stderr.trim() || "npm audit produced no report")); + } + + return { + available: true, + counts: report.metadata.vulnerabilities, + packages: Object.values(report.vulnerabilities ?? {}).map(entry => ({ + name: entry.name, + severity: entry.severity, + direct: entry.isDirect === true, + fixAvailable: entry.fixAvailable !== false + })) + }; + } catch (error) { + return { available: false, reason: error.message }; + } +} + +/** Validate a written document against the official CycloneDX schema. */ +async function assertValidCycloneDx(file: string) { + const requireFromGenerator = createRequire(path.join(cycloneDxDir, "package.json")); + const { Validation } = requireFromGenerator("@cyclonedx/cyclonedx-library"); + const error = await new Validation.JsonValidator(SPEC_VERSION).validate(readFileSync(file, "utf8")); + + if (error !== null) { + throw new Error(`${path.basename(file)} is not valid CycloneDX ${SPEC_VERSION}: ${JSON.stringify(error.details ?? error)}`); + } + + log(`validated ${path.basename(file)} against CycloneDX ${SPEC_VERSION}`); +} + +function renderNote(values: Record): string { + const template = readFileSync(path.join(repoRoot, "scripts", "templates", "sbom-readme.md"), "utf8"); + + return template.replace(/{{(\w+)}}/g, (match, key) => { + if (!(key in values)) { + throw new Error(`No value supplied for template placeholder ${match}.`); + } + return values[key]; + }); +} + +const { values: args } = parseArgs({ + options: { + "out": { type: "string", default: "artifacts" }, + "release": { type: "string" }, + "build-env": { type: "boolean", default: false }, + "keep": { type: "boolean", default: false }, + "skip-license-lookup": { type: "boolean", default: false } + } +}); + +const outDir = ensureDir(path.resolve(repoRoot, args.out)); +const release = args.release || readJson(path.join(repoRoot, "packages", "cli", "package.json")).version; +const releaseBaseName = `igniteui-cli-release-${release}`; +const fileNames = { + buildEnvCycloneDxFile: `${releaseBaseName}.build-env.cdx.json`, + supplyChainNotesFile: `${releaseBaseName}.supply-chain.md`, + readmeFile: `${releaseBaseName}.sbom.README.md` +}; + +/** The repository tree, dev dependencies included: an inventory of the toolchain, not the product. */ +async function buildEnvironmentSbom() { + // Tolerates npm-ls complaints about the yarn-installed dev tree; the delivered documents never do. + const bom = generateCycloneDx(repoRoot, ["--mc-type", "application", "--ignore-npm-errors"]); + bom.metadata.supplier = SUPPLIER; + + const file = path.join(outDir, fileNames.buildEnvCycloneDxFile); + writeBom(file, bom); + await assertValidCycloneDx(file); +} + +async function deliveredSboms() { + const packages = publishablePackages(); + log(`publishable packages: ${packages.map(pkg => `${pkg.name}@${pkg.version}`).join(", ")}`); + + const artifacts = new Map(packages.map(pkg => [pkg.name, packArtifact(pkg, outDir)] as [string, PackedArtifact])); + const deliveries: Delivery[] = packages.map(pkg => { + const artifact = artifacts.get(pkg.name); + const workspaceDir = installDeliveredClosure( + pkg, artifacts, path.join(outDir, ".delivered", artifact.name.replace(/\.tgz$/, ""))); + const bom = promoteMainComponent( + generateCycloneDx(workspaceDir, ["--mc-type", "library", "--omit", "dev"]), + { artifact, packageName: pkg.name, supplier: SUPPLIER, version: pkg.version } + ); + const componentCount = assertDeliveredShape(bom, { packageName: pkg.name, version: pkg.version }); + log(`${pkg.name} SBOM covers ${componentCount} components`); + + return { pkg, artifact, workspaceDir, bom, componentCount }; + }); + + // One lookup for every document, so a component shared by several packages is queried once. + const coordinates = deliveries + .flatMap(delivery => unlicensedComponents(delivery.bom).map(clearlyDefinedCoordinate)) + .filter(Boolean); + if (coordinates.length > 0 && !args["skip-license-lookup"]) { + const licenses = await resolveLicenses(coordinates, { log }); + for (const delivery of deliveries) { + const applied = applyLicenses(delivery.bom, licenses); + if (applied > 0) { + log(`${delivery.pkg.name}: ${applied} licenses resolved from ClearlyDefined`); + } + } + } + + const evidence: PackageEvidence[] = []; + for (const { pkg, artifact, workspaceDir, bom, componentCount } of deliveries) { + const unlicensed = unlicensedComponents(bom).map(describeComponent); + if (unlicensed.length > 0) { + // A gap in license data is reported, not fatal: the document is still a complete inventory. + warn(`${pkg.name}: ${unlicensed.length} of ${componentCount + 1} components have no resolved license ` + + `and are recorded without one: ${unlicensed.join(", ")}`); + } + + const sbomFile = path.join(outDir, sbomFileOf(artifact)); + writeBom(sbomFile, bom); + await assertValidCycloneDx(sbomFile); + + writeFileSync(`${artifact.path}.sha256`, `${artifact.sha256} ${artifact.name}\n`); + writeFileSync(`${artifact.path}.sha512`, `${artifact.sha512} ${artifact.name}\n`); + + evidence.push({ + name: pkg.name, + version: pkg.version, + componentCount, + audit: captureDeliveredAudit(workspaceDir), + unlicensed + }); + } + + if (!args.keep) { + rmSync(path.join(outDir, ".delivered"), { recursive: true, force: true }); + } + + const supplyChainNotes = formatSupplyChainNotes({ packages: evidence, npmTree: captureNpmTreeDiagnostics() }); + writeFileSync(path.join(outDir, fileNames.supplyChainNotesFile), supplyChainNotes); + + // Read by the release workflow to attest and publish each package in dependency order. + writeFileSync(path.join(outDir, "packages.json"), `${JSON.stringify(deliveries.map(({ pkg, artifact }) => ({ + name: pkg.name, + version: pkg.version, + id: artifact.name.replace(/\.tgz$/, ""), + tarball: artifact.name, + sbom: sbomFileOf(artifact) + })), null, 2)}\n`); + + const repository = process.env.GITHUB_REPOSITORY ?? "IgniteUI/igniteui-cli"; + writeFileSync(path.join(outDir, fileNames.readmeFile), renderNote({ + ...fileNames, + release, + generatedAt: new Date().toISOString().slice(0, 10), + documentRows: deliveries.map(({ pkg, artifact }) => + `| \`${sbomFileOf(artifact)}\` | CycloneDX ${SPEC_VERSION} JSON | Delivered - every component resolved by \`npm install ${pkg.name}\` |` + ).join("\n"), + packageRows: deliveries.map(({ pkg, artifact }) => + `| \`${pkg.name}\` | \`${pkg.version}\` | \`${npmPurl(pkg.name, pkg.version)}\` | \`${artifact.name}\` | \`${artifact.sha256}\` |` + ).join("\n"), + hashCommands: deliveries.map(({ artifact }) => `shasum -a 256 ${artifact.name}`).join("\n"), + validateCommands: [...deliveries.map(({ artifact }) => sbomFileOf(artifact)), fileNames.buildEnvCycloneDxFile] + .map(file => `cyclonedx-cli validate --input-file ${file} --input-format json --input-version v1_6 --fail-on-errors`) + .join("\n"), + attestationCommands: deliveries.map(({ artifact }) => + `gh attestation verify ${artifact.name} --repo ${repository} --predicate-type https://cyclonedx.org/bom` + ).join("\n"), + cycloneDxToolVersion: cycloneDxToolVersion(), + nodeVersion: process.version, + repository, + workflow: process.env.GITHUB_WORKFLOW ?? "a local build", + supplyChainNotes: supplyChainNotes.trim() + })); +} + +(args["build-env"] ? buildEnvironmentSbom() : deliveredSboms()).catch(error => { + // eslint-disable-next-line no-console + console.error("[sbom] SBOM generation failed", error); + process.exitCode = 1; +}); diff --git a/scripts/sbom-document.ts b/scripts/sbom-document.ts new file mode 100644 index 000000000..a585e9bb0 --- /dev/null +++ b/scripts/sbom-document.ts @@ -0,0 +1,181 @@ +export interface BomProperty { + name: string; + value: string; +} + +/** The parts of a CycloneDX component this repository reads or rewrites. */ +export interface BomComponent { + "name": string; + "group"?: string; + "version"?: string; + "bom-ref"?: string; + "purl"?: string; + "licenses"?: unknown[]; + "properties"?: BomProperty[]; + "components"?: BomComponent[]; + [key: string]: unknown; +} + +export interface BomDependency { + ref: string; + dependsOn?: string[]; + provides?: string[]; +} + +export interface Bom { + metadata: { component?: BomComponent; [key: string]: unknown }; + components: BomComponent[]; + dependencies: BomDependency[]; + [key: string]: unknown; +} + +export interface Supplier { + name: string; + url: string[]; +} + +export interface PackedArtifact { + name: string; + path: string; + sha256: string; + sha512: string; +} + +/** Every component in the document, nested components included. */ +export function componentsIn(components: BomComponent[]): BomComponent[] { + return components.flatMap(component => [component, ...componentsIn(component.components ?? [])]); +} + +/** CycloneDX splits a scoped npm name into group and name; this joins them back. */ +export function packageNameOf(component: BomComponent): string { + return component.group ? `${component.group}/${component.name}` : component.name; +} + +export function npmPurl(packageName: string, version: string): string { + return `pkg:npm/${packageName.replace(/^@/, "%40")}@${version}`; +} + +function replacePrefix(value: string | undefined, prefix: string): string | undefined { + return value?.startsWith(prefix) ? value.slice(prefix.length) : value; +} + +/** + * Replace the generated workspace root with the published package and normalize all graph refs. + */ +export function promoteMainComponent( + bom: Bom, + options: { artifact: PackedArtifact, packageName: string, supplier: Supplier, version: string } +): Bom { + const { artifact, packageName, supplier, version } = options; + const index = bom.components.findIndex(component => packageNameOf(component) === packageName); + + if (index === -1) { + throw new Error(`${packageName} is missing from the generated SBOM.`); + } + + const [main] = bom.components.splice(index, 1); + const workspaceRef = bom.metadata.component["bom-ref"]; + const workspacePrefix = `${workspaceRef}|`; + const normalizeRef = (value: string) => replacePrefix(value, workspacePrefix); + + for (const component of [main, ...componentsIn(bom.components)]) { + component["bom-ref"] = normalizeRef(component["bom-ref"]); + } + + bom.metadata.component = { + ...main, + type: "library", + purl: npmPurl(packageName, version), + supplier, + publisher: supplier.name, + hashes: [ + { alg: "SHA-256", content: artifact.sha256 }, + { alg: "SHA-512", content: artifact.sha512 } + ], + properties: [ + ...(main.properties ?? []), + { name: "ig:artifact:fileName", value: artifact.name } + ] + }; + bom.metadata.supplier = supplier; + bom.dependencies = bom.dependencies + .filter(entry => entry.ref !== workspaceRef) + .map(entry => ({ + ...entry, + ref: normalizeRef(entry.ref), + ...(entry.dependsOn && { dependsOn: entry.dependsOn.map(normalizeRef) }), + ...(entry.provides && { provides: entry.provides.map(normalizeRef) }) + })); + + return bom; +} + +/** + * Validate the identity, references and reachability of a delivered CycloneDX dependency graph. + * @returns Total dependency component count, including nested components. + */ +export function assertDeliveredShape(bom: Bom, expected: { packageName: string, version: string }): number { + const { packageName, version } = expected; + const failures: string[] = []; + const main = bom.metadata?.component; + const components = componentsIn(bom.components ?? []); + const componentRefs = components.map(component => component["bom-ref"]); + const knownRefs = new Set([main?.["bom-ref"], ...componentRefs]); + const dependencyGraph = new Map( + (bom.dependencies ?? []).map(entry => [entry.ref, entry.dependsOn ?? []] as [string, string[]]) + ); + + if (main?.purl !== npmPurl(packageName, version)) { + failures.push(`metadata.component.purl is "${main?.purl}"`); + } + if (components.length === 0) { + failures.push("no dependency components were resolved"); + } + if (components.some(component => packageNameOf(component) === packageName)) { + failures.push(`${packageName} is still listed as its own dependency`); + } + + const duplicateRefs = componentRefs.filter((ref, index) => componentRefs.indexOf(ref) !== index); + for (const ref of new Set(duplicateRefs)) { + failures.push(`component bom-ref "${ref}" is duplicated`); + } + + for (const entry of bom.dependencies ?? []) { + if (!knownRefs.has(entry.ref)) { + failures.push(`dependency entry references unknown component "${entry.ref}"`); + } + for (const ref of [...(entry.dependsOn ?? []), ...(entry.provides ?? [])]) { + if (!knownRefs.has(ref)) { + failures.push(`dependency entry references unknown component "${ref}"`); + } + } + } + + if (!dependencyGraph.has(main?.["bom-ref"])) { + failures.push("the main component is not the root of the dependency graph"); + } else { + const reachable = new Set(); + const pending = [main["bom-ref"]]; + + while (pending.length > 0) { + const ref = pending.pop(); + if (reachable.has(ref)) { + continue; + } + reachable.add(ref); + pending.push(...(dependencyGraph.get(ref) ?? [])); + } + + for (const ref of componentRefs) { + if (!reachable.has(ref)) { + failures.push(`component "${ref}" is unreachable from the main component`); + } + } + } + + if (failures.length > 0) { + throw new Error(`SBOM validation failed:\n - ${failures.join("\n - ")}`); + } + + return components.length; +} diff --git a/scripts/sbom-licenses.ts b/scripts/sbom-licenses.ts new file mode 100644 index 000000000..01469bb28 --- /dev/null +++ b/scripts/sbom-licenses.ts @@ -0,0 +1,144 @@ +import { Bom, BomComponent, componentsIn, packageNameOf } from "./sbom-document"; + +const CLEARLYDEFINED_DEFINITIONS = "https://api.clearlydefined.io/definitions?expand=-files"; +// ClearlyDefined's way of saying it found no usable license. +const UNRESOLVED_LICENSE = /\b(?:NOASSERTION|NONE|OTHER)\b/; + +export interface ClearlyDefinedDefinition { + licensed?: { declared?: string }; +} + +export type DefinitionFetcher = (coordinates: string[], timeoutMs: number) => + Promise>; + +export interface LicenseLookupOptions { + maxAttempts?: number; + retryDelayMs?: number; + timeoutMs?: number; + batchSize?: number; + fetchDefinitions?: DefinitionFetcher; + sleep?: (ms: number) => Promise; + log?: (message: string) => void; +} + +/** The ClearlyDefined coordinate of an npm component, or null for anything else. */ +export function clearlyDefinedCoordinate(component: BomComponent): string | null { + if (!component.purl?.startsWith("pkg:npm/") || !component.version) { + return null; + } + return `npm/npmjs/${component.group || "-"}/${component.name}/${component.version}`; +} + +/** The main component and every dependency that declares no license. */ +export function unlicensedComponents(bom: Bom): BomComponent[] { + const main = bom.metadata.component ? [bom.metadata.component] : []; + return [...main, ...componentsIn(bom.components)] + .filter(component => !component.licenses?.length); +} + +export function describeComponent(component: BomComponent): string { + return `${packageNameOf(component)}@${component.version}`; +} + +async function fetchClearlyDefined(coordinates: string[], timeoutMs: number): + Promise> { + const response = await fetch(CLEARLYDEFINED_DEFINITIONS, { + method: "POST", + headers: { "Accept": "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(coordinates), + signal: AbortSignal.timeout(timeoutMs) + }); + + if (!response.ok) { + throw new Error(`ClearlyDefined returned HTTP ${response.status}`); + } + + return response.json(); +} + +/** + * Look up licenses that the packages themselves do not declare. + * + * ClearlyDefined harvests definitions on demand, so a coordinate it has not seen before can stall until + * the gateway times out, while the same request succeeds once the definition is cached. Only coordinates + * whose lookup failed are retried; an answer without a license is final for this run. Licenses already + * resolved are kept across attempts, so a degraded retry never loses them. Never throws: an unreachable + * service leaves the components without a license and the caller reports the gap. + * @returns Resolved SPDX expressions keyed by ClearlyDefined coordinate. + */ +export async function resolveLicenses(coordinates: string[], options: LicenseLookupOptions = {}): + Promise> { + const { + maxAttempts = 3, + retryDelayMs = 15000, + timeoutMs = 60000, + batchSize = 100, + fetchDefinitions = fetchClearlyDefined, + sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)), + log = () => { } + } = options; + const resolved = new Map(); + let pending = [...new Set(coordinates)]; + + for (let attempt = 1; attempt <= maxAttempts && pending.length > 0; attempt++) { + log(`Looking up ${pending.length} licenses on ClearlyDefined (attempt ${attempt} of ${maxAttempts})...`); + const unanswered: string[] = []; + + for (let start = 0; start < pending.length; start += batchSize) { + const batch = pending.slice(start, start + batchSize); + let definitions: Record; + + try { + definitions = await fetchDefinitions(batch, timeoutMs); + } catch (error) { + log(`ClearlyDefined lookup failed: ${error instanceof Error ? error.message : error}`); + unanswered.push(...batch); + continue; + } + + for (const coordinate of batch) { + const definition = definitions?.[coordinate]; + if (!definition) { + unanswered.push(coordinate); + continue; + } + const declared = definition.licensed?.declared?.trim(); + if (declared && !UNRESOLVED_LICENSE.test(declared)) { + resolved.set(coordinate, declared); + } + } + } + + pending = unanswered; + if (pending.length > 0 && attempt < maxAttempts) { + log(`Retrying ${pending.length} unanswered lookups in ${retryDelayMs / 1000} seconds...`); + await sleep(retryDelayMs); + } + } + + return resolved; +} + +/** + * Record resolved licenses on the components that had none. + * @returns How many components received a license. + */ +export function applyLicenses(bom: Bom, licenses: Map): number { + let applied = 0; + + for (const component of unlicensedComponents(bom)) { + const expression = licenses.get(clearlyDefinedCoordinate(component)); + if (!expression) { + continue; + } + // "concluded": established by analysis, not declared in the package's own manifest. + component.licenses = [{ expression, acknowledgement: "concluded" }]; + component.properties = [ + ...(component.properties ?? []), + { name: "ig:license:source", value: "ClearlyDefined" } + ]; + applied++; + } + + return applied; +} diff --git a/scripts/sbom-notes.ts b/scripts/sbom-notes.ts new file mode 100644 index 000000000..d59ff9ddc --- /dev/null +++ b/scripts/sbom-notes.ts @@ -0,0 +1,166 @@ +const SECTION_START = ""; +const SECTION_END = ""; +const SEVERITIES = ["critical", "high", "moderate", "low", "info"]; +const MAX_DIAGNOSTIC_LINES = 20; +const MAX_LISTED_PACKAGES = 25; +const MAX_AUDIT_REASON_LENGTH = 500; + +export interface AuditPackage { + name: string; + severity: string; + direct: boolean; + fixAvailable: boolean; +} + +export type AuditResult = + | { available: true, counts: Record, packages: AuditPackage[] } + | { available: false, reason: string }; + +export interface NpmTreeDiagnostics { + status: number; + problems: string[]; + stderr: string; +} + +export interface PackageEvidence { + name: string; + version: string; + componentCount: number; + audit: AuditResult; + /** name@version of every component left without a license. */ + unlicensed: string[]; +} + +/** + * Strip terminal noise, npm log prefixes and absolute paths from one diagnostic line. + * Nothing is HTML-escaped: the result is rendered inside a fenced code block. + */ +function sanitize(line: string): string { + return String(line) + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "") + .replace(/^npm (?:error|warn|notice)\s+/i, "") + .replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, "[URL]") + .replace(/(^|\s)["']?(?:[A-Za-z]:)?[\\/].*$/g, "$1") + .replace(/```/g, "'''") + .trim(); +} + +function markdownText(value: string, maxLength = Number.POSITIVE_INFINITY): string { + return sanitize(value) + .replace(/\s+/g, " ") + .slice(0, maxLength) + .replace(/([\\`*_[\]<>|])/g, "\\$1"); +} + +/** Deduplicated, bounded diagnostic lines. */ +function diagnosticLines(npmTree: NpmTreeDiagnostics): string[] { + return [...npmTree.problems, ...npmTree.stderr.split(/\r?\n/)] + .map(sanitize) + .filter(line => line && !/complete log of this run/i.test(line)) + .filter((line, index, lines) => lines.indexOf(line) === index) + .slice(0, MAX_DIAGNOSTIC_LINES); +} + +function vulnerabilityLines(audit: AuditResult): string[] { + if (audit.available === false) { + const reason = markdownText(audit.reason, MAX_AUDIT_REASON_LENGTH) || "npm audit returned no diagnostic"; + return [`The vulnerability scan could not be completed: ${reason}.`]; + } + + if (audit.counts.total === 0) { + return ["`npm audit` reported no known vulnerabilities in the delivered dependencies."]; + } + + const packages = [...audit.packages] + .sort((left, right) => + SEVERITIES.indexOf(left.severity) - SEVERITIES.indexOf(right.severity) || + left.name.localeCompare(right.name)) + .slice(0, MAX_LISTED_PACKAGES); + + return [ + `\`npm audit\` reported ${audit.counts.total} known vulnerabilities in the delivered dependencies.`, + "", + "| Severity | Count |", + "|---|---|", + ...SEVERITIES.filter(severity => audit.counts[severity] > 0).map( + severity => `| ${severity} | ${audit.counts[severity]} |` + ), + "", + "| Package | Severity | Direct | Fix available |", + "|---|---|---|---|", + ...packages.map(entry => + `| ${markdownText(entry.name)} | ${markdownText(entry.severity)} | ${entry.direct ? "yes" : "no"} | ${entry.fixAvailable ? "yes" : "no"} |` + ) + ]; +} + +function licenseLines(evidence: PackageEvidence): string[] { + // The package itself counts too: it is the document's main component. + const total = evidence.componentCount + 1; + + if (evidence.unlicensed.length === 0) { + return [`All ${total} components carry a license.`]; + } + + const listed = evidence.unlicensed.slice(0, MAX_LISTED_PACKAGES).map(name => `\`${markdownText(name)}\``); + const more = evidence.unlicensed.length - listed.length; + + return [ + `${evidence.unlicensed.length} of ${total} components have no license: their packages declare none and ` + + `ClearlyDefined resolved none, so they are recorded without one: ${listed.join(", ")}${more > 0 ? ` and ${more} more` : ""}.` + ]; +} + +/** + * Render the supply-chain section published to the SBOM archive and the GitHub release notes. + */ +export function formatSupplyChainNotes(evidence: { packages: PackageEvidence[], npmTree: NpmTreeDiagnostics }): string { + const diagnostics = diagnosticLines(evidence.npmTree); + const lines = ["## Supply chain", ""]; + + for (const entry of evidence.packages) { + lines.push( + `### \`${markdownText(entry.name)}\` ${markdownText(entry.version)}`, + "", + ...vulnerabilityLines(entry.audit), + "", + ...licenseLines(entry), + "" + ); + } + + lines.push( + "
", + "Build-environment SBOM diagnostics (best-effort)", + "", + "The build-environment CycloneDX SBOM is a best-effort inventory of the repository toolchain. npm dependency-tree errors are tolerated there and do not affect the delivered-package SBOMs.", + "" + ); + + if (diagnostics.length === 0) { + lines.push("`npm ls` reported no dependency-tree problems."); + } else { + lines.push(`\`npm ls\` exited with status ${evidence.npmTree.status}.`, "", "```text", ...diagnostics, "```"); + } + + lines.push("", "
"); + + return `${lines.join("\n")}\n`; +} + +/** + * Add or replace the generated section while preserving authored GitHub release notes. + */ +export function mergeSupplyChainNotes(releaseNotes: string, notes: string): string { + const section = `${SECTION_START}\n${notes.trim()}\n${SECTION_END}`; + const start = releaseNotes.indexOf(SECTION_START); + const end = releaseNotes.indexOf(SECTION_END, start); + + if (start >= 0 && end >= start) { + return `${releaseNotes.slice(0, start)}${section}${releaseNotes.slice(end + SECTION_END.length)}`; + } + + const authored = releaseNotes.trimEnd(); + return authored ? `${authored}\n\n${section}\n` : `${section}\n`; +} diff --git a/scripts/templates/sbom-readme.md b/scripts/templates/sbom-readme.md new file mode 100644 index 000000000..e72d76c1f --- /dev/null +++ b/scripts/templates/sbom-readme.md @@ -0,0 +1,64 @@ +# Software Bill of Materials - Ignite UI CLI {{release}} + +Generated {{generatedAt}} from the release build of the Ignite UI CLI packages. + +## Documents in this archive + +| File | Format | Scope | +|---|---|---| +{{documentRows}} +| `{{buildEnvCycloneDxFile}}` | CycloneDX 1.6 JSON | Best-effort build environment - development and build-time tooling | +| `{{supplyChainNotesFile}}` | Markdown | Known vulnerabilities and license gaps in the delivered dependencies, plus build diagnostics | + +## Scope + +Each **delivered** document describes exactly what a consuming application resolves when it installs one package: that package plus the transitive closure of its runtime `dependencies` and the `peerDependencies` it requires at runtime. Development-only packages are excluded, because they are never installed by consumers. The packages of this release depend on each other; those dependencies are resolved from the tarballs built in the same release, so every document describes the versions released together. These are the documents to feed a vulnerability scanner or a compliance review of the shipped product. + +The **build environment** document is a supplementary, best-effort view of the repository dependency tree, including development and build-time tooling. npm dependency-tree errors are tolerated for this document and are recorded below. It exists to answer toolchain inventory questions and is deliberately *not* an inventory of what ships to customers. + +Components in the delivered documents are enumerated from evidence - the installed package tree - not from declared ranges. A component's license is the one its own package manifest declares. Where a package declares none, the license is looked up on [ClearlyDefined](https://clearlydefined.io) and recorded as `concluded`, with the property `ig:license:source` set to `ClearlyDefined`. A component neither source resolves is recorded without a license and listed in the supply-chain notes below. All CycloneDX documents are validated against the official CycloneDX schema before packaging. Only the delivered documents are included in signed GitHub SBOM attestations. + +{{supplyChainNotes}} + +## Subject artifacts + +| Package | Version | PURL | File | SHA-256 | +|---|---|---|---|---| +{{packageRows}} + +The SHA-512 digest of each file is attached to the GitHub release next to it, as `.sha512`. + +## Verify + +Recompute the digests against the artifacts you received: + +```bash +{{hashCommands}} +``` + +To re-validate the documents independently, install the [official CycloneDX CLI](https://github.com/CycloneDX/cyclonedx-cli/releases) and run: + +```bash +{{validateCommands}} +``` + +Each delivered document is embedded as the predicate of a signed GitHub artifact attestation bound to its package's digest above. The build-environment document is not attested. Every package also carries a build provenance attestation. Verify the delivered SBOM predicates explicitly with: + +```bash +{{attestationCommands}} +``` + +Drop `--predicate-type` to verify the build provenance instead. + +## Generated by + +| Tool | Version | +|---|---| +| `@cyclonedx/cyclonedx-npm` | {{cycloneDxToolVersion}} | +| Node.js | {{nodeVersion}} | + +Built by {{workflow}} in `{{repository}}`. + +## Questions + +For license clarification or a scope decision recorded in this document, contact [Infragistics support](https://www.infragistics.com/about-us/contact-us). diff --git a/scripts/update-release-notes.ts b/scripts/update-release-notes.ts new file mode 100644 index 000000000..330a68c94 --- /dev/null +++ b/scripts/update-release-notes.ts @@ -0,0 +1,13 @@ +import { readFileSync, writeFileSync } from "fs"; +import { mergeSupplyChainNotes } from "./sbom-notes"; + +const [releaseNotesFile, notesFile, outputFile] = process.argv.slice(2); + +if (!releaseNotesFile || !notesFile || !outputFile) { + throw new Error("Usage: node scripts/update-release-notes.js "); +} + +const releaseNotes = readFileSync(releaseNotesFile, "utf8"); +const notes = readFileSync(notesFile, "utf8"); + +writeFileSync(outputFile, mergeSupplyChainNotes(releaseNotes, notes)); diff --git a/spec/unit/sbom-document-spec.ts b/spec/unit/sbom-document-spec.ts new file mode 100644 index 000000000..32fa5452a --- /dev/null +++ b/spec/unit/sbom-document-spec.ts @@ -0,0 +1,85 @@ +import { assertDeliveredShape, Bom, promoteMainComponent } from "../../scripts/sbom-document"; + +const WORKSPACE_REF = "igniteui-cli-sbom-workspace@0.0.0"; +const SUPPLIER = { name: "Infragistics", url: ["https://www.infragistics.com"] }; + +function generatedBom(main: { name: string, group?: string, version: string }): Bom { + const fullName = main.group ? `${main.group}/${main.name}` : main.name; + const mainRef = `${WORKSPACE_REF}|${fullName}@${main.version}`; + const dependencyRef = `${WORKSPACE_REF}|dependency@2.0.0`; + const nestedRef = `${dependencyRef}|nested@3.0.0`; + + return { + metadata: { + component: { "name": "igniteui-cli-sbom-workspace", "bom-ref": WORKSPACE_REF } + }, + components: [ + { ...main, "bom-ref": mainRef }, + { + "name": "dependency", + "version": "2.0.0", + "bom-ref": dependencyRef, + "components": [{ "name": "nested", "version": "3.0.0", "bom-ref": nestedRef }] + } + ], + dependencies: [ + { ref: WORKSPACE_REF, dependsOn: [mainRef] }, + { ref: mainRef, dependsOn: [dependencyRef] }, + { ref: dependencyRef, dependsOn: [nestedRef] }, + { ref: nestedRef, dependsOn: [] } + ] + }; +} + +function promote(bom: Bom, packageName: string, version: string): Bom { + return promoteMainComponent(bom, { + artifact: { name: "package.tgz", path: "package.tgz", sha256: "a".repeat(64), sha512: "b".repeat(128) }, + packageName, + supplier: SUPPLIER, + version + }); +} + +describe("Unit - SBOM document", () => { + it("re-roots every component and dependency reference on the published package", () => { + const bom = promote(generatedBom({ name: "igniteui-cli", version: "1.2.3" }), "igniteui-cli", "1.2.3"); + + expect(assertDeliveredShape(bom, { packageName: "igniteui-cli", version: "1.2.3" })).toBe(2); + expect(bom.metadata.component["bom-ref"]).toBe("igniteui-cli@1.2.3"); + expect(JSON.stringify(bom)).not.toMatch(/igniteui-cli-sbom-workspace/); + expect(bom.dependencies.map(entry => entry.ref)).toEqual( + ["igniteui-cli@1.2.3", "dependency@2.0.0", "dependency@2.0.0|nested@3.0.0"] + ); + }); + + it("finds a scoped package, which CycloneDX splits into group and name", () => { + const bom = promote( + generatedBom({ name: "cli-core", group: "@igniteui", version: "1.2.3" }), "@igniteui/cli-core", "1.2.3"); + + expect(assertDeliveredShape(bom, { packageName: "@igniteui/cli-core", version: "1.2.3" })).toBe(2); + expect(bom.metadata.component.purl).toBe("pkg:npm/%40igniteui/cli-core@1.2.3"); + expect(bom.metadata.component["bom-ref"]).toBe("@igniteui/cli-core@1.2.3"); + }); + + it("rejects a document that does not contain the package", () => { + expect(() => promote(generatedBom({ name: "other", version: "1.2.3" }), "igniteui-cli", "1.2.3")) + .toThrowError(/igniteui-cli is missing/); + }); + + it("rejects dependency references that do not identify a component", () => { + const bom = promote(generatedBom({ name: "igniteui-cli", version: "1.2.3" }), "igniteui-cli", "1.2.3"); + bom.dependencies[0].dependsOn.push("missing@1.0.0"); + + expect(() => assertDeliveredShape(bom, { packageName: "igniteui-cli", version: "1.2.3" })) + .toThrowError(/unknown component "missing@1\.0\.0"/); + }); + + it("rejects components that are unreachable from the published package", () => { + const bom = promote(generatedBom({ name: "igniteui-cli", version: "1.2.3" }), "igniteui-cli", "1.2.3"); + bom.components.push({ "name": "orphan", "version": "4.0.0", "bom-ref": "orphan@4.0.0" }); + bom.dependencies.push({ ref: "orphan@4.0.0", dependsOn: [] }); + + expect(() => assertDeliveredShape(bom, { packageName: "igniteui-cli", version: "1.2.3" })) + .toThrowError(/component "orphan@4\.0\.0" is unreachable/); + }); +}); diff --git a/spec/unit/sbom-licenses-spec.ts b/spec/unit/sbom-licenses-spec.ts new file mode 100644 index 000000000..1815dd7d5 --- /dev/null +++ b/spec/unit/sbom-licenses-spec.ts @@ -0,0 +1,113 @@ +import { Bom } from "../../scripts/sbom-document"; +import { + applyLicenses, clearlyDefinedCoordinate, ClearlyDefinedDefinition, resolveLicenses, unlicensedComponents +} from "../../scripts/sbom-licenses"; + +const LICENSED = "npm/npmjs/-/licensed/1.0.0"; +const SCOPED = "npm/npmjs/@igniteui/mcp-server/1.0.0"; + +function bom(): Bom { + return { + metadata: { + component: { "name": "mcp-server", "group": "@igniteui", "version": "1.0.0", "purl": "pkg:npm/%40igniteui/mcp-server@1.0.0" } + }, + components: [ + { "name": "declared", "version": "1.0.0", "purl": "pkg:npm/declared@1.0.0", "licenses": [{ license: { id: "MIT" } }] }, + { + "name": "licensed", "version": "1.0.0", "purl": "pkg:npm/licensed@1.0.0", + "components": [{ "name": "nested", "version": "2.0.0", "purl": "pkg:npm/nested@2.0.0" }] + } + ], + dependencies: [] + }; +} + +function answer(declared: Record): Record { + const definitions: Record = {}; + for (const coordinate of Object.keys(declared)) { + definitions[coordinate] = { licensed: { declared: declared[coordinate] } }; + } + return definitions; +} + +const noDelay = { sleep: () => Promise.resolve() }; + +describe("Unit - SBOM licenses", () => { + it("builds ClearlyDefined coordinates for scoped and unscoped npm packages only", () => { + expect(clearlyDefinedCoordinate({ name: "licensed", version: "1.0.0", purl: "pkg:npm/licensed@1.0.0" })) + .toBe(LICENSED); + expect(clearlyDefinedCoordinate(bom().metadata.component)).toBe(SCOPED); + expect(clearlyDefinedCoordinate({ name: "x", version: "1.0.0", purl: "pkg:nuget/x@1.0.0" })).toBeNull(); + }); + + it("finds the main component and nested dependencies without a license", () => { + expect(unlicensedComponents(bom()).map(clearlyDefinedCoordinate)) + .toEqual([SCOPED, LICENSED, "npm/npmjs/-/nested/2.0.0"]); + }); + + it("records resolved licenses as concluded and marks their source", () => { + const document = bom(); + + expect(applyLicenses(document, new Map([[LICENSED, "Apache-2.0"]]))).toBe(1); + expect(document.components[1].licenses).toEqual([{ expression: "Apache-2.0", acknowledgement: "concluded" }]); + expect(document.components[1].properties).toEqual([{ name: "ig:license:source", value: "ClearlyDefined" }]); + expect(document.components[0].licenses).toEqual([{ license: { id: "MIT" } }]); + expect(unlicensedComponents(document).length).toBe(2); + }); + + it("ignores answers in which ClearlyDefined found no usable license", async () => { + const licenses = await resolveLicenses([LICENSED, SCOPED, "npm/npmjs/-/other/1.0.0"], { + ...noDelay, + fetchDefinitions: async () => answer({ [LICENSED]: "MIT", [SCOPED]: "NOASSERTION", "npm/npmjs/-/other/1.0.0": "OTHER" }) + }); + + expect([...licenses]).toEqual([[LICENSED, "MIT"]]); + }); + + it("retries only failed lookups and keeps what earlier attempts resolved", async () => { + const requests: string[][] = []; + const licenses = await resolveLicenses([LICENSED, SCOPED], { + ...noDelay, + batchSize: 1, + fetchDefinitions: async coordinates => { + requests.push(coordinates); + if (coordinates[0] === SCOPED && requests.length < 3) { + throw new Error("gateway timeout"); + } + return answer({ [LICENSED]: "MIT", [SCOPED]: "Apache-2.0" }); + } + }); + + expect(requests).toEqual([[LICENSED], [SCOPED], [SCOPED]]); + expect([...licenses]).toEqual([[LICENSED, "MIT"], [SCOPED, "Apache-2.0"]]); + }); + + it("does not retry an answer that carries no license", async () => { + let calls = 0; + const licenses = await resolveLicenses([SCOPED], { + ...noDelay, + fetchDefinitions: async () => { + calls++; + return { [SCOPED]: { licensed: {} } }; + } + }); + + expect(calls).toBe(1); + expect(licenses.size).toBe(0); + }); + + it("gives up without throwing when ClearlyDefined stays unreachable", async () => { + let calls = 0; + const licenses = await resolveLicenses([LICENSED, SCOPED], { + ...noDelay, + maxAttempts: 3, + fetchDefinitions: async () => { + calls++; + throw new Error("ClearlyDefined returned HTTP 503"); + } + }); + + expect(calls).toBe(3); + expect(licenses.size).toBe(0); + }); +}); diff --git a/spec/unit/sbom-notes-spec.ts b/spec/unit/sbom-notes-spec.ts new file mode 100644 index 000000000..c090169b4 --- /dev/null +++ b/spec/unit/sbom-notes-spec.ts @@ -0,0 +1,135 @@ +import { AuditResult, formatSupplyChainNotes, mergeSupplyChainNotes, PackageEvidence } from "../../scripts/sbom-notes"; + +const CLEAN_TREE = { status: 0, problems: [] as string[], stderr: "" }; +const NO_VULNERABILITIES: AuditResult = { + available: true, + counts: { critical: 0, high: 0, moderate: 0, low: 0, info: 0, total: 0 }, + packages: [] +}; +const NOISY_TREE = { + status: 1, + problems: ["invalid: chokidar@5.0.0 /home/runner/work/igniteui-cli/node_modules/chokidar"], + stderr: [ + "npm error code ELSPROBLEMS", + "npm error invalid: chokidar@5.0.0 /home/runner/work/igniteui-cli/node_modules/chokidar", + "npm error A complete log of this run can be found in: /home/runner/.npm/_logs/2026-08-27T16_04_30-debug-0.log" + ].join("\n") +}; + +function evidence(overrides: Partial = {}): PackageEvidence { + return { name: "igniteui-cli", version: "1.2.3", componentCount: 4, audit: NO_VULNERABILITIES, unlicensed: [], ...overrides }; +} + +describe("Unit - SBOM supply-chain notes", () => { + it("reports a clean delivered closure for every package", () => { + const notes = formatSupplyChainNotes({ + packages: [evidence(), evidence({ name: "@igniteui/cli-core" })], + npmTree: CLEAN_TREE + }); + + expect(notes).toMatch(/### `igniteui-cli` 1\.2\.3/); + expect(notes).toMatch(/### `@igniteui\/cli-core` 1\.2\.3/); + expect(notes.match(/no known vulnerabilities/gi)?.length).toBe(2); + expect(notes).toMatch(/All 5 components carry a license/); + expect(notes).not.toMatch(/critical/i); + }); + + it("lists the components left without a license", () => { + const notes = formatSupplyChainNotes({ + packages: [evidence({ unlicensed: ["@igniteui/mcp-server@1.2.3"] })], + npmTree: CLEAN_TREE + }); + + expect(notes).toMatch(/1 of 5 components have no license/); + expect(notes).toMatch(/`@igniteui\/mcp-server@1\.2\.3`/); + }); + + it("summarizes vulnerabilities by severity and names the affected packages", () => { + const notes = formatSupplyChainNotes({ + packages: [evidence({ + audit: { + available: true, + counts: { critical: 1, high: 0, moderate: 2, low: 0, info: 0, total: 3 }, + packages: [ + { name: "tar-fs", severity: "critical", direct: false, fixAvailable: true }, + { name: "postcss", severity: "moderate", direct: true, fixAvailable: false } + ] + } + })], + npmTree: CLEAN_TREE + }); + + expect(notes).toMatch(/critical \| 1/i); + expect(notes).toMatch(/moderate \| 2/i); + expect(notes).toMatch(/tar-fs/); + expect(notes).toMatch(/postcss/); + }); + + it("states when the vulnerability scan could not run", () => { + const notes = formatSupplyChainNotes({ + packages: [evidence({ + audit: { + available: false, + reason: "registry https://npm.internal.example.test unreachable at C:\\Users\\runner\\audit.json ```details" + } + })], + npmTree: CLEAN_TREE + }); + + expect(notes).toMatch(/could not be completed/i); + expect(notes).toMatch(/registry .* unreachable/); + expect(notes).not.toMatch(/npm\.internal|Users|audit\.json|```details/); + }); + + it("escapes Markdown table delimiters in vulnerability details", () => { + const notes = formatSupplyChainNotes({ + packages: [evidence({ + audit: { + available: true, + counts: { critical: 0, high: 1, moderate: 0, low: 0, info: 0, total: 1 }, + packages: [{ name: "package|name", severity: "high|unexpected", direct: false, fixAvailable: true }] + } + })], + npmTree: CLEAN_TREE + }); + + expect(notes).toMatch(/package\\\|name/); + expect(notes).toMatch(/high\\\|unexpected/); + }); + + it("collapses best-effort build diagnostics and removes paths, prefixes and duplicates", () => { + const notes = formatSupplyChainNotes({ packages: [evidence()], npmTree: NOISY_TREE }); + + expect(notes).toMatch(/
/); + expect(notes).toMatch(/best-effort/i); + expect(notes).toMatch(/`npm ls` exited with status 1/); + expect(notes).toMatch(/invalid: chokidar@5\.0\.0/); + expect(notes).toMatch(/code ELSPROBLEMS/); + + expect(notes).not.toMatch(/home\/runner/); + expect(notes).not.toMatch(/npm error/); + expect(notes).not.toMatch(/complete log of this run/); + expect(notes.match(/invalid: chokidar@5\.0\.0/g)?.length).toBe(1); + }); + + it("does not HTML-escape diagnostics rendered inside a code fence", () => { + const notes = formatSupplyChainNotes({ + packages: [evidence()], + npmTree: { status: 1, problems: ["invalid: pkg@<1.0.0>"], stderr: "" } + }); + + expect(notes).toMatch(/invalid: pkg@<1\.0\.0>/); + expect(notes).not.toMatch(/<|>/); + }); + + it("replaces its own release-notes section without changing authored notes", () => { + const notes = formatSupplyChainNotes({ packages: [evidence()], npmTree: CLEAN_TREE }); + const authored = "# Release 1.2.3\n\nCustomer-facing changes."; + const first = mergeSupplyChainNotes(authored, notes); + const second = mergeSupplyChainNotes(first, notes); + + expect(first).toMatch(/^# Release 1\.2\.3\n\nCustomer-facing changes\./); + expect(first).toBe(second); + expect(first.match(/## Supply chain/g)?.length).toBe(1); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4767e5189..ed3e9c878 100644 --- a/yarn.lock +++ b/yarn.lock @@ -205,6 +205,28 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@cyclonedx/cyclonedx-library@^10.0.0": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@cyclonedx/cyclonedx-library/-/cyclonedx-library-10.3.0.tgz#92ccd1553d64d7c593b1adc31203cba01583d3df" + integrity sha512-RIT28sp1U0PCHaaeF3yNRwk6Jig/XJ8UsYAVOMYaHQ+0VVOvsjcp0PTWQFLUqJFF8XMud+Kdq6G+wtDAh+QJFA== + +"@cyclonedx/cyclonedx-npm@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@cyclonedx/cyclonedx-npm/-/cyclonedx-npm-6.0.1.tgz#269085c8d52880e81697918dbc8ae8a43735727d" + integrity sha512-/aU3bBC6qP6cV/qQ5SfUSygE/+2hQhwgg6sJML31/gZ96NyMvIUuwdk637H4z+LS/NryRT2kjR2wtD0qBEVVHQ== + dependencies: + "@cyclonedx/cyclonedx-library" "^10.0.0" + commander "^14.0.0" + normalize-package-data "^7.0.0 || ^8.0.0" + packageurl-js "^2.0.1" + spdx-expression-parse "^3.0.1 || ^4.0.0" + xmlbuilder2 "^3.0.2 || ^4.0.3" + optionalDependencies: + ajv "^8.12.0" + ajv-formats "^3.0.1" + ajv-formats-draft2019 "^1.6.1" + libxmljs2 "^0.35||^0.37" + "@emnapi/core@1.4.5": version "1.4.5" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.5.tgz#bfbb0cbbbb9f96ec4e2c4fd917b7bbe5495ceccb" @@ -1661,6 +1683,35 @@ "@octokit/request-error" "^7.0.0" "@octokit/webhooks-methods" "^6.0.0" +"@oozcitak/dom@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@oozcitak/dom/-/dom-2.0.2.tgz#0f447f0b736aa6f36c5556ba811a44e56c71c26c" + integrity sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w== + dependencies: + "@oozcitak/infra" "^2.0.2" + "@oozcitak/url" "^3.0.0" + "@oozcitak/util" "^10.0.0" + +"@oozcitak/infra@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@oozcitak/infra/-/infra-2.0.2.tgz#e2f1cc0eeca3ac5cd551f0326a5f66f00cf1138b" + integrity sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA== + dependencies: + "@oozcitak/util" "^10.0.0" + +"@oozcitak/url@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@oozcitak/url/-/url-3.0.0.tgz#a03c959c67e28aba9e29b2d35cd50d26cb5f2cc4" + integrity sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ== + dependencies: + "@oozcitak/infra" "^2.0.2" + "@oozcitak/util" "^10.0.0" + +"@oozcitak/util@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@oozcitak/util/-/util-10.0.0.tgz#f6b40472d96c210094a556ee5ccb8e77f1bd30af" + integrity sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" @@ -2262,6 +2313,16 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-formats-draft2019@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.1.tgz#6affe2220e7828360793776f1976de0420acccfb" + integrity sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q== + dependencies: + punycode "^2.1.1" + schemes "^1.4.0" + smtp-address-parser "^1.0.3" + uri-js "^4.4.1" + ajv-formats@3.0.1, ajv-formats@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz" @@ -2269,7 +2330,7 @@ ajv-formats@3.0.1, ajv-formats@^3.0.1: dependencies: ajv "^8.0.0" -ajv@8.20.0, ajv@^8.0.0, ajv@^8.17.1: +ajv@8.20.0, ajv@^8.0.0, ajv@^8.12.0, ajv@^8.17.1: version "8.20.0" resolved "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz" integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== @@ -2571,7 +2632,7 @@ binary-extensions@^2.0.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -bindings@^1.5.0: +bindings@^1.5.0, bindings@~1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz" integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== @@ -3101,7 +3162,12 @@ combined-stream@1.0.8, combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^2.2.0: +commander@^14.0.0: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + +commander@^2.19.0, commander@^2.2.0: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -3527,6 +3593,11 @@ diff@^4.0.1: resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +discontinuous-range@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a" + integrity sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ== + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz" @@ -4182,6 +4253,11 @@ express@^5.2.1: type-is "^2.0.1" vary "^1.1.2" +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + fast-content-type-parse@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz" @@ -5872,6 +5948,16 @@ libnpmpublish@11.1.2: sigstore "^4.0.0" ssri "^12.0.0" +libxmljs2@^0.35||^0.37: + version "0.37.0" + resolved "https://registry.yarnpkg.com/libxmljs2/-/libxmljs2-0.37.0.tgz#ee637d6220b348b74ef2697d1a9d2b2007628643" + integrity sha512-Xb78V8GZouoZFrq8cCwx7+G3WYOcJG0xb3YUbweSyE4z2EIrQCZMr3Ye/dHn4mESs6YxUMeQeUZm5IXg+iLHog== + dependencies: + bindings "~1.5.0" + nan "~2.22.2" + node-gyp "^11.2.0" + prebuild-install "^7.1.3" + limiter@^1.0.5: version "1.1.5" resolved "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz" @@ -6454,6 +6540,11 @@ modify-values@^1.0.1: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== +moo@^0.5.0: + version "0.5.3" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.3.tgz#dfcdb40ff6f1a03c34af5df7f9717cecfa88c38c" + integrity sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA== + ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -6489,6 +6580,11 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@~2.22.2: + version "2.22.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.2.tgz#6b504fd029fb8f38c0990e52ad5c26772fdacfbb" + integrity sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ== + nanoid@^3.3.18: version "3.3.19" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" @@ -6504,6 +6600,16 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +nearley@^2.20.1: + version "2.20.1" + resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474" + integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ== + dependencies: + commander "^2.19.0" + moo "^0.5.0" + railroad-diagrams "^1.0.0" + randexp "0.4.6" + negotiator@0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" @@ -6531,7 +6637,7 @@ node-abi@^3.3.0: dependencies: semver "^7.3.5" -node-gyp@^11.0.0: +node-gyp@^11.0.0, node-gyp@^11.2.0: version "11.5.0" resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz" integrity sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ== @@ -6586,6 +6692,15 @@ normalize-package-data@^3.0.0, normalize-package-data@^3.0.3: semver "^7.3.4" validate-npm-package-license "^3.0.1" +"normalize-package-data@^7.0.0 || ^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-8.0.0.tgz#bdce7ff2d6ba891b853e179e45a5337766e304a7" + integrity sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ== + dependencies: + hosted-git-info "^9.0.0" + semver "^7.3.5" + validate-npm-package-license "^3.0.4" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" @@ -7188,6 +7303,11 @@ package-json-from-dist@^1.0.0: resolved "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== +packageurl-js@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/packageurl-js/-/packageurl-js-2.0.1.tgz#a8fa43a64971b5dd0dca5fb904b950a6cc317a6f" + integrity sha512-N5ixXjzTy4QDQH0Q9YFjqIWd6zH6936Djpl2m9QNFmDv5Fum8q8BjkpAcHNMzOFE0IwQrFhJWex3AN6kS0OSwg== + pacote@21.0.1, pacote@^21.0.0: version "21.0.1" resolved "https://registry.npmjs.org/pacote/-/pacote-21.0.1.tgz" @@ -7449,7 +7569,7 @@ postcss@^8.5.6: picocolors "^1.1.1" source-map-js "^1.2.1" -prebuild-install@^7.1.1: +prebuild-install@^7.1.1, prebuild-install@^7.1.3: version "7.1.3" resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz" integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== @@ -7579,7 +7699,7 @@ punycode.js@^2.3.1: resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz" integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -7602,6 +7722,19 @@ quick-lru@^4.0.1: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +railroad-diagrams@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" + integrity sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A== + +randexp@0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3" + integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ== + dependencies: + discontinuous-range "1.0.0" + ret "~0.1.10" + range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz" @@ -7857,6 +7990,11 @@ restore-cursor@^5.0.0: onetime "^7.0.0" signal-exit "^4.1.0" +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + retry@^0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz" @@ -8004,6 +8142,13 @@ safe-stable-stringify@^2.2.0: resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +schemes@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/schemes/-/schemes-1.4.0.tgz#9d03302275e562488dd3afa7b654ed42e00569ec" + integrity sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ== + dependencies: + extend "^3.0.0" + "semver@2 || 3 || 4 || 5", semver@^5.6.0: version "5.7.2" resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" @@ -8300,6 +8445,13 @@ smol-toml@1.6.1: resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.6.1.tgz#4fceb5f7c4b86c2544024ef686e12ff0983465be" integrity sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg== +smtp-address-parser@^1.0.3: + version "1.1.1" + resolved "https://registry.yarnpkg.com/smtp-address-parser/-/smtp-address-parser-1.1.1.tgz#71070328e9f2006ad4fcabc7aa36c1ea246bbf78" + integrity sha512-02QRuo0uL+I6+VHY5LxYiKJMjbyrTJWFM6JWkWOcdLkfInjLvGdPQTjC+mUOt/0PzeNnRSCYNIDZfn6QRIQYug== + dependencies: + nearley "^2.20.1" + socket.io-adapter@~2.5.2: version "2.5.5" resolved "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz" @@ -8428,6 +8580,14 @@ spdx-expression-parse@^3.0.0: spdx-exceptions "^2.1.0" spdx-license-ids "^3.0.0" +"spdx-expression-parse@^3.0.1 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz#a23af9f3132115465dac215c099303e4ceac5794" + integrity sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + spdx-license-ids@^3.0.0: version "3.0.21" resolved "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz" @@ -9176,7 +9336,7 @@ update-browserslist-db@^1.3.3: escalade "^3.2.0" picocolors "^1.1.1" -uri-js@^4.2.2: +uri-js@^4.2.2, uri-js@^4.4.1: version "4.4.1" resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz" integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== @@ -9481,6 +9641,16 @@ ws@~8.21.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc" integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw== +"xmlbuilder2@^3.0.2 || ^4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz#91660fa6d30f19d716f8b1194c567686d4402c63" + integrity sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA== + dependencies: + "@oozcitak/dom" "^2.0.2" + "@oozcitak/infra" "^2.0.2" + "@oozcitak/util" "^10.0.0" + js-yaml "^4.1.1" + xmlhttprequest-ssl@~2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz" From ebd5c3f5b95dd10f6d421e67f0752a4437d1771d Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 26 Oct 2026 17:49:20 +0200 Subject: [PATCH 02/13] chore(mcp): declare the MIT license in @igniteui/mcp-server's package.json The README already states MIT; without the field the package's SBOM component carried no license and every release raised a license-gap warning. Co-Authored-By: Claude Opus 5.5 --- packages/igniteui-mcp/igniteui-doc-mcp/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/package.json b/packages/igniteui-mcp/igniteui-doc-mcp/package.json index a879bfccb..ee5e412d4 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/package.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/package.json @@ -3,6 +3,7 @@ "mcpName": "io.github.IgniteUI/mcp-server", "version": "15.8.2", "description": "Unified MCP server for Ignite UI — documentation, GitHub API, and CLI scaffolding", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/IgniteUI/igniteui-cli.git", From c4faf5b0ea23f2aa5259abecaea330fa3583de48 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 26 Oct 2026 18:00:37 +0200 Subject: [PATCH 03/13] ci(release): drop registry-url from the build job setup-node v7 no longer exports a dummy NODE_AUTH_TOKEN, and yarn 1 fails on the ${NODE_AUTH_TOKEN} reference in the .npmrc that registry-url writes. The build job never publishes, so it does not need the registry configuration. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/npm-publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 06cd57261..d3b79b792 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -34,11 +34,12 @@ jobs: with: persist-credentials: false + # No registry-url: this job never publishes, and the .npmrc it writes references + # ${NODE_AUTH_TOKEN}, which setup-node v7 no longer defaults and yarn 1 refuses to leave unset. - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 - registry-url: "https://registry.npmjs.org" - name: Install packages run: yarn install --frozen-lockfile From 48562a5d6ee06b06e0c86163724cbdcc10b136e9 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 26 Oct 2026 18:08:39 +0200 Subject: [PATCH 04/13] chore(release): set versions to 0.0.0 for the dummy release TEST ONLY - revert before merging. The release workflow validates that the MCP server's package.json and server.json match the release tag, so the dummy 0.0.0 release needs igniteui-cli, @igniteui/cli-core and @igniteui/mcp-server at 0.0.0 and the internal ~ranges pointing at it. Co-Authored-By: Claude Opus 5.5 --- packages/cli/package.json | 6 +++--- packages/core/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/server.json | 4 ++-- packages/igx-templates/package.json | 2 +- packages/ng-schematics/package.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 6d8d61794..3577b57d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "igniteui-cli", - "version": "15.8.2", + "version": "0.0.0", "description": "CLI tool for creating Ignite UI projects", "keywords": [ "CLI", @@ -67,8 +67,8 @@ }, "dependencies": { "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~15.8.2", - "@igniteui/mcp-server": "~15.8.2", + "@igniteui/cli-core": "~0.0.0", + "@igniteui/mcp-server": "~0.0.0", "@inquirer/prompts": "^7.9.0", "chalk": "^5.3.0", "glob": "^11.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index d24a51a7d..4b34ecab7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@igniteui/cli-core", - "version": "15.8.2", + "version": "0.0.0", "description": "Base types and functionality for Ignite UI CLI", "repository": { "type": "git", diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/package.json b/packages/igniteui-mcp/igniteui-doc-mcp/package.json index ee5e412d4..79fa46e5c 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/package.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@igniteui/mcp-server", "mcpName": "io.github.IgniteUI/mcp-server", - "version": "15.8.2", + "version": "0.0.0", "description": "Unified MCP server for Ignite UI — documentation, GitHub API, and CLI scaffolding", "license": "MIT", "repository": { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/server.json b/packages/igniteui-mcp/igniteui-doc-mcp/server.json index d08dbfa9d..145a22f7f 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/server.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/server.json @@ -7,12 +7,12 @@ "source": "github", "url": "https://github.com/IgniteUI/igniteui-cli" }, - "version": "15.8.2", + "version": "0.0.0", "packages": [ { "registryType": "npm", "identifier": "@igniteui/mcp-server", - "version": "15.8.2", + "version": "0.0.0", "transport": { "type": "stdio" } diff --git a/packages/igx-templates/package.json b/packages/igx-templates/package.json index 61a268da2..b76b277b7 100644 --- a/packages/igx-templates/package.json +++ b/packages/igx-templates/package.json @@ -12,7 +12,7 @@ "author": "Infragistics", "license": "MIT", "dependencies": { - "@igniteui/cli-core": "~15.8.2", + "@igniteui/cli-core": "~0.0.0", "typescript": "~5.5.4" } } diff --git a/packages/ng-schematics/package.json b/packages/ng-schematics/package.json index ad5e76a26..de03f8480 100644 --- a/packages/ng-schematics/package.json +++ b/packages/ng-schematics/package.json @@ -21,7 +21,7 @@ "@angular-devkit/core": "^22.0.0", "@angular-devkit/schematics": "^22.0.0", "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~15.8.2", + "@igniteui/cli-core": "~0.0.0", "@schematics/angular": "^22.0.0", "minimatch": "^10.0.1", "rxjs": "~7.8.1", From ecf2d4afcd16a6624e6c23c645d5d2b980c18ef0 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 27 Oct 2026 16:47:53 +0200 Subject: [PATCH 05/13] Revert "chore(release): set versions to 0.0.0 for the dummy release" This reverts commit 48562a5d6ee06b06e0c86163724cbdcc10b136e9. The dummy 0.0.0 release validated the workflow; the packages return to 15.8.2. Co-Authored-By: Claude Opus 5.5 --- packages/cli/package.json | 6 +++--- packages/core/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/server.json | 4 ++-- packages/igx-templates/package.json | 2 +- packages/ng-schematics/package.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 3577b57d8..6d8d61794 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "igniteui-cli", - "version": "0.0.0", + "version": "15.8.2", "description": "CLI tool for creating Ignite UI projects", "keywords": [ "CLI", @@ -67,8 +67,8 @@ }, "dependencies": { "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~0.0.0", - "@igniteui/mcp-server": "~0.0.0", + "@igniteui/cli-core": "~15.8.2", + "@igniteui/mcp-server": "~15.8.2", "@inquirer/prompts": "^7.9.0", "chalk": "^5.3.0", "glob": "^11.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index 4b34ecab7..d24a51a7d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@igniteui/cli-core", - "version": "0.0.0", + "version": "15.8.2", "description": "Base types and functionality for Ignite UI CLI", "repository": { "type": "git", diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/package.json b/packages/igniteui-mcp/igniteui-doc-mcp/package.json index 79fa46e5c..ee5e412d4 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/package.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@igniteui/mcp-server", "mcpName": "io.github.IgniteUI/mcp-server", - "version": "0.0.0", + "version": "15.8.2", "description": "Unified MCP server for Ignite UI — documentation, GitHub API, and CLI scaffolding", "license": "MIT", "repository": { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/server.json b/packages/igniteui-mcp/igniteui-doc-mcp/server.json index 145a22f7f..d08dbfa9d 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/server.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/server.json @@ -7,12 +7,12 @@ "source": "github", "url": "https://github.com/IgniteUI/igniteui-cli" }, - "version": "0.0.0", + "version": "15.8.2", "packages": [ { "registryType": "npm", "identifier": "@igniteui/mcp-server", - "version": "0.0.0", + "version": "15.8.2", "transport": { "type": "stdio" } diff --git a/packages/igx-templates/package.json b/packages/igx-templates/package.json index b76b277b7..61a268da2 100644 --- a/packages/igx-templates/package.json +++ b/packages/igx-templates/package.json @@ -12,7 +12,7 @@ "author": "Infragistics", "license": "MIT", "dependencies": { - "@igniteui/cli-core": "~0.0.0", + "@igniteui/cli-core": "~15.8.2", "typescript": "~5.5.4" } } diff --git a/packages/ng-schematics/package.json b/packages/ng-schematics/package.json index de03f8480..ad5e76a26 100644 --- a/packages/ng-schematics/package.json +++ b/packages/ng-schematics/package.json @@ -21,7 +21,7 @@ "@angular-devkit/core": "^22.0.0", "@angular-devkit/schematics": "^22.0.0", "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~0.0.0", + "@igniteui/cli-core": "~15.8.2", "@schematics/angular": "^22.0.0", "minimatch": "^10.0.1", "rxjs": "~7.8.1", From 560edd44ba370fa59836874adf5fd42d2d3f692a Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 27 Oct 2026 16:47:54 +0200 Subject: [PATCH 06/13] ci(release): re-enable npm and MCP Registry publishing The release flow was validated end to end with a dummy 0.0.0 release: the publish job runs again after the attestations, and the MCP Registry job follows a successful npm publish. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/npm-publish.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index d3b79b792..7727de31c 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -233,11 +233,9 @@ jobs: if-no-files-found: error # The only job that can publish to npm. It compiles nothing and never checks out the repository. - # TODO: re-enable once the release flow has been validated end to end with a dummy GitHub release. publish: name: Publish to npmjs.org needs: [build, attest, attest-sbom] - if: false runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -352,7 +350,6 @@ jobs: # without repeating the npm publish. # Skipped for prereleases — the MCP registry has no dist-tag equivalent, # so publishing an alpha/beta/rc would make it the current version. - # Skipped while the publish job is disabled: the registry validates the package against npm. mcp-registry: needs: publish if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || (needs.publish.result == 'success' && !github.event.release.prerelease)) }} From 69a01d6f6daf4e61c98d16da0d0b71abac85691e Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 28 Oct 2026 18:46:05 +0200 Subject: [PATCH 07/13] Remove SPDX SBOM-related logic; was not required in the first place --- .github/workflows/npm-publish.yml | 1 - package.json | 1 - scripts/build-sbom.ts | 81 +++++------------ scripts/sbom-document.ts | 1 - scripts/sbom-licenses.ts | 144 ------------------------------ scripts/sbom-notes.ts | 22 ----- scripts/templates/sbom-readme.md | 12 +-- spec/unit/sbom-document-spec.ts | 85 ------------------ spec/unit/sbom-licenses-spec.ts | 113 ----------------------- spec/unit/sbom-notes-spec.ts | 135 ---------------------------- 10 files changed, 28 insertions(+), 567 deletions(-) delete mode 100644 scripts/sbom-licenses.ts delete mode 100644 spec/unit/sbom-document-spec.ts delete mode 100644 spec/unit/sbom-licenses-spec.ts delete mode 100644 spec/unit/sbom-notes-spec.ts diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 7727de31c..4119d4727 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -65,7 +65,6 @@ jobs: # Packs every publishable package, then describes the exact tarballs it produced. Everything # downstream uses those files, so their digests, SBOMs, attestations and publication agree. - # License gaps (nothing declared, nothing on ClearlyDefined) are reported as warnings, not failures. - name: Generate delivered SBOMs (CycloneDX 1.6) run: node scripts/build-sbom.js --release="$VERSION" --out=artifacts diff --git a/package.json b/package.json index 7121ad8cf..f289e189b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ }, "scripts": { "release": "lerna version --conventional-commits --force-publish=* --yes --no-git-tag-version --no-push", - "deploy": "lerna exec 'npm publish --tag'", "version": "node ./scripts/versionScript.js", "build": "node ./node_modules/typescript/lib/tsc.js && npm run config-schema && npm run build:mcp", "build:mcp": "cd packages/igniteui-mcp/igniteui-doc-mcp && npx tsc && npx tsx scripts/build.ts", diff --git a/scripts/build-sbom.ts b/scripts/build-sbom.ts index 7f0728cd1..04a9d478c 100644 --- a/scripts/build-sbom.ts +++ b/scripts/build-sbom.ts @@ -1,15 +1,12 @@ import { execFileSync, spawnSync } from "child_process"; import { createHash } from "crypto"; -import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { createRequire } from "module"; import * as path from "path"; import { parseArgs } from "util"; import { assertDeliveredShape, Bom, npmPurl, PackedArtifact, promoteMainComponent, Supplier } from "./sbom-document"; -import { - applyLicenses, clearlyDefinedCoordinate, describeComponent, resolveLicenses, unlicensedComponents -} from "./sbom-licenses"; import { AuditResult, formatSupplyChainNotes, NpmTreeDiagnostics, PackageEvidence } from "./sbom-notes"; const SPEC_VERSION = "1.6"; @@ -30,24 +27,11 @@ interface WorkspacePackage { siblings: string[]; } -interface Delivery { - pkg: WorkspacePackage; - artifact: PackedArtifact; - workspaceDir: string; - bom: Bom; - componentCount: number; -} - function log(message: string) { // eslint-disable-next-line no-console console.log(`[sbom] ${message}`); } -function warn(message: string) { - // eslint-disable-next-line no-console - console.warn(process.env.GITHUB_ACTIONS ? `::warning title=SBOM::${message}` : `[sbom] warning: ${message}`); -} - function readJson(file: string) { return JSON.parse(readFileSync(file, "utf8")); } @@ -321,8 +305,7 @@ const { values: args } = parseArgs({ "out": { type: "string", default: "artifacts" }, "release": { type: "string" }, "build-env": { type: "boolean", default: false }, - "keep": { type: "boolean", default: false }, - "skip-license-lookup": { type: "boolean", default: false } + "keep": { type: "boolean", default: false } } }); @@ -351,7 +334,9 @@ async function deliveredSboms() { log(`publishable packages: ${packages.map(pkg => `${pkg.name}@${pkg.version}`).join(", ")}`); const artifacts = new Map(packages.map(pkg => [pkg.name, packArtifact(pkg, outDir)] as [string, PackedArtifact])); - const deliveries: Delivery[] = packages.map(pkg => { + const evidence: PackageEvidence[] = []; + + for (const pkg of packages) { const artifact = artifacts.get(pkg.name); const workspaceDir = installDeliveredClosure( pkg, artifacts, path.join(outDir, ".delivered", artifact.name.replace(/\.tgz$/, ""))); @@ -362,32 +347,6 @@ async function deliveredSboms() { const componentCount = assertDeliveredShape(bom, { packageName: pkg.name, version: pkg.version }); log(`${pkg.name} SBOM covers ${componentCount} components`); - return { pkg, artifact, workspaceDir, bom, componentCount }; - }); - - // One lookup for every document, so a component shared by several packages is queried once. - const coordinates = deliveries - .flatMap(delivery => unlicensedComponents(delivery.bom).map(clearlyDefinedCoordinate)) - .filter(Boolean); - if (coordinates.length > 0 && !args["skip-license-lookup"]) { - const licenses = await resolveLicenses(coordinates, { log }); - for (const delivery of deliveries) { - const applied = applyLicenses(delivery.bom, licenses); - if (applied > 0) { - log(`${delivery.pkg.name}: ${applied} licenses resolved from ClearlyDefined`); - } - } - } - - const evidence: PackageEvidence[] = []; - for (const { pkg, artifact, workspaceDir, bom, componentCount } of deliveries) { - const unlicensed = unlicensedComponents(bom).map(describeComponent); - if (unlicensed.length > 0) { - // A gap in license data is reported, not fatal: the document is still a complete inventory. - warn(`${pkg.name}: ${unlicensed.length} of ${componentCount + 1} components have no resolved license ` + - `and are recorded without one: ${unlicensed.join(", ")}`); - } - const sbomFile = path.join(outDir, sbomFileOf(artifact)); writeBom(sbomFile, bom); await assertValidCycloneDx(sbomFile); @@ -395,13 +354,7 @@ async function deliveredSboms() { writeFileSync(`${artifact.path}.sha256`, `${artifact.sha256} ${artifact.name}\n`); writeFileSync(`${artifact.path}.sha512`, `${artifact.sha512} ${artifact.name}\n`); - evidence.push({ - name: pkg.name, - version: pkg.version, - componentCount, - audit: captureDeliveredAudit(workspaceDir), - unlicensed - }); + evidence.push({ name: pkg.name, version: pkg.version, audit: captureDeliveredAudit(workspaceDir) }); } if (!args.keep) { @@ -411,8 +364,10 @@ async function deliveredSboms() { const supplyChainNotes = formatSupplyChainNotes({ packages: evidence, npmTree: captureNpmTreeDiagnostics() }); writeFileSync(path.join(outDir, fileNames.supplyChainNotesFile), supplyChainNotes); + const delivered = packages.map(pkg => ({ pkg, artifact: artifacts.get(pkg.name) })); + // Read by the release workflow to attest and publish each package in dependency order. - writeFileSync(path.join(outDir, "packages.json"), `${JSON.stringify(deliveries.map(({ pkg, artifact }) => ({ + writeFileSync(path.join(outDir, "packages.json"), `${JSON.stringify(delivered.map(({ pkg, artifact }) => ({ name: pkg.name, version: pkg.version, id: artifact.name.replace(/\.tgz$/, ""), @@ -425,19 +380,25 @@ async function deliveredSboms() { ...fileNames, release, generatedAt: new Date().toISOString().slice(0, 10), - documentRows: deliveries.map(({ pkg, artifact }) => + documentRows: delivered.map(({ pkg, artifact }) => `| \`${sbomFileOf(artifact)}\` | CycloneDX ${SPEC_VERSION} JSON | Delivered - every component resolved by \`npm install ${pkg.name}\` |` ).join("\n"), - packageRows: deliveries.map(({ pkg, artifact }) => + packageRows: delivered.map(({ pkg, artifact }) => `| \`${pkg.name}\` | \`${pkg.version}\` | \`${npmPurl(pkg.name, pkg.version)}\` | \`${artifact.name}\` | \`${artifact.sha256}\` |` ).join("\n"), - hashCommands: deliveries.map(({ artifact }) => `shasum -a 256 ${artifact.name}`).join("\n"), - validateCommands: [...deliveries.map(({ artifact }) => sbomFileOf(artifact)), fileNames.buildEnvCycloneDxFile] + hashCommands: delivered.flatMap(({ artifact }) => + [`shasum -a 256 ${artifact.name}`, `shasum -a 512 ${artifact.name}`] + ).join("\n"), + powerShellHashCommands: delivered.flatMap(({ artifact }) => + [`Get-FileHash -Algorithm SHA256 ${artifact.name}`, `Get-FileHash -Algorithm SHA512 ${artifact.name}`] + ).join("\n"), + validateCommands: [...delivered.map(({ artifact }) => sbomFileOf(artifact)), fileNames.buildEnvCycloneDxFile] .map(file => `cyclonedx-cli validate --input-file ${file} --input-format json --input-version v1_6 --fail-on-errors`) .join("\n"), - attestationCommands: deliveries.map(({ artifact }) => + attestationCommands: delivered.flatMap(({ artifact }) => [ + `gh attestation verify ${artifact.name} --repo ${repository}`, `gh attestation verify ${artifact.name} --repo ${repository} --predicate-type https://cyclonedx.org/bom` - ).join("\n"), + ]).join("\n"), cycloneDxToolVersion: cycloneDxToolVersion(), nodeVersion: process.version, repository, diff --git a/scripts/sbom-document.ts b/scripts/sbom-document.ts index a585e9bb0..841697e15 100644 --- a/scripts/sbom-document.ts +++ b/scripts/sbom-document.ts @@ -10,7 +10,6 @@ export interface BomComponent { "version"?: string; "bom-ref"?: string; "purl"?: string; - "licenses"?: unknown[]; "properties"?: BomProperty[]; "components"?: BomComponent[]; [key: string]: unknown; diff --git a/scripts/sbom-licenses.ts b/scripts/sbom-licenses.ts deleted file mode 100644 index 01469bb28..000000000 --- a/scripts/sbom-licenses.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Bom, BomComponent, componentsIn, packageNameOf } from "./sbom-document"; - -const CLEARLYDEFINED_DEFINITIONS = "https://api.clearlydefined.io/definitions?expand=-files"; -// ClearlyDefined's way of saying it found no usable license. -const UNRESOLVED_LICENSE = /\b(?:NOASSERTION|NONE|OTHER)\b/; - -export interface ClearlyDefinedDefinition { - licensed?: { declared?: string }; -} - -export type DefinitionFetcher = (coordinates: string[], timeoutMs: number) => - Promise>; - -export interface LicenseLookupOptions { - maxAttempts?: number; - retryDelayMs?: number; - timeoutMs?: number; - batchSize?: number; - fetchDefinitions?: DefinitionFetcher; - sleep?: (ms: number) => Promise; - log?: (message: string) => void; -} - -/** The ClearlyDefined coordinate of an npm component, or null for anything else. */ -export function clearlyDefinedCoordinate(component: BomComponent): string | null { - if (!component.purl?.startsWith("pkg:npm/") || !component.version) { - return null; - } - return `npm/npmjs/${component.group || "-"}/${component.name}/${component.version}`; -} - -/** The main component and every dependency that declares no license. */ -export function unlicensedComponents(bom: Bom): BomComponent[] { - const main = bom.metadata.component ? [bom.metadata.component] : []; - return [...main, ...componentsIn(bom.components)] - .filter(component => !component.licenses?.length); -} - -export function describeComponent(component: BomComponent): string { - return `${packageNameOf(component)}@${component.version}`; -} - -async function fetchClearlyDefined(coordinates: string[], timeoutMs: number): - Promise> { - const response = await fetch(CLEARLYDEFINED_DEFINITIONS, { - method: "POST", - headers: { "Accept": "application/json", "Content-Type": "application/json" }, - body: JSON.stringify(coordinates), - signal: AbortSignal.timeout(timeoutMs) - }); - - if (!response.ok) { - throw new Error(`ClearlyDefined returned HTTP ${response.status}`); - } - - return response.json(); -} - -/** - * Look up licenses that the packages themselves do not declare. - * - * ClearlyDefined harvests definitions on demand, so a coordinate it has not seen before can stall until - * the gateway times out, while the same request succeeds once the definition is cached. Only coordinates - * whose lookup failed are retried; an answer without a license is final for this run. Licenses already - * resolved are kept across attempts, so a degraded retry never loses them. Never throws: an unreachable - * service leaves the components without a license and the caller reports the gap. - * @returns Resolved SPDX expressions keyed by ClearlyDefined coordinate. - */ -export async function resolveLicenses(coordinates: string[], options: LicenseLookupOptions = {}): - Promise> { - const { - maxAttempts = 3, - retryDelayMs = 15000, - timeoutMs = 60000, - batchSize = 100, - fetchDefinitions = fetchClearlyDefined, - sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)), - log = () => { } - } = options; - const resolved = new Map(); - let pending = [...new Set(coordinates)]; - - for (let attempt = 1; attempt <= maxAttempts && pending.length > 0; attempt++) { - log(`Looking up ${pending.length} licenses on ClearlyDefined (attempt ${attempt} of ${maxAttempts})...`); - const unanswered: string[] = []; - - for (let start = 0; start < pending.length; start += batchSize) { - const batch = pending.slice(start, start + batchSize); - let definitions: Record; - - try { - definitions = await fetchDefinitions(batch, timeoutMs); - } catch (error) { - log(`ClearlyDefined lookup failed: ${error instanceof Error ? error.message : error}`); - unanswered.push(...batch); - continue; - } - - for (const coordinate of batch) { - const definition = definitions?.[coordinate]; - if (!definition) { - unanswered.push(coordinate); - continue; - } - const declared = definition.licensed?.declared?.trim(); - if (declared && !UNRESOLVED_LICENSE.test(declared)) { - resolved.set(coordinate, declared); - } - } - } - - pending = unanswered; - if (pending.length > 0 && attempt < maxAttempts) { - log(`Retrying ${pending.length} unanswered lookups in ${retryDelayMs / 1000} seconds...`); - await sleep(retryDelayMs); - } - } - - return resolved; -} - -/** - * Record resolved licenses on the components that had none. - * @returns How many components received a license. - */ -export function applyLicenses(bom: Bom, licenses: Map): number { - let applied = 0; - - for (const component of unlicensedComponents(bom)) { - const expression = licenses.get(clearlyDefinedCoordinate(component)); - if (!expression) { - continue; - } - // "concluded": established by analysis, not declared in the package's own manifest. - component.licenses = [{ expression, acknowledgement: "concluded" }]; - component.properties = [ - ...(component.properties ?? []), - { name: "ig:license:source", value: "ClearlyDefined" } - ]; - applied++; - } - - return applied; -} diff --git a/scripts/sbom-notes.ts b/scripts/sbom-notes.ts index d59ff9ddc..1104afaf4 100644 --- a/scripts/sbom-notes.ts +++ b/scripts/sbom-notes.ts @@ -25,10 +25,7 @@ export interface NpmTreeDiagnostics { export interface PackageEvidence { name: string; version: string; - componentCount: number; audit: AuditResult; - /** name@version of every component left without a license. */ - unlicensed: string[]; } /** @@ -95,23 +92,6 @@ function vulnerabilityLines(audit: AuditResult): string[] { ]; } -function licenseLines(evidence: PackageEvidence): string[] { - // The package itself counts too: it is the document's main component. - const total = evidence.componentCount + 1; - - if (evidence.unlicensed.length === 0) { - return [`All ${total} components carry a license.`]; - } - - const listed = evidence.unlicensed.slice(0, MAX_LISTED_PACKAGES).map(name => `\`${markdownText(name)}\``); - const more = evidence.unlicensed.length - listed.length; - - return [ - `${evidence.unlicensed.length} of ${total} components have no license: their packages declare none and ` + - `ClearlyDefined resolved none, so they are recorded without one: ${listed.join(", ")}${more > 0 ? ` and ${more} more` : ""}.` - ]; -} - /** * Render the supply-chain section published to the SBOM archive and the GitHub release notes. */ @@ -124,8 +104,6 @@ export function formatSupplyChainNotes(evidence: { packages: PackageEvidence[], `### \`${markdownText(entry.name)}\` ${markdownText(entry.version)}`, "", ...vulnerabilityLines(entry.audit), - "", - ...licenseLines(entry), "" ); } diff --git a/scripts/templates/sbom-readme.md b/scripts/templates/sbom-readme.md index e72d76c1f..b070e5978 100644 --- a/scripts/templates/sbom-readme.md +++ b/scripts/templates/sbom-readme.md @@ -8,7 +8,7 @@ Generated {{generatedAt}} from the release build of the Ignite UI CLI packages. |---|---|---| {{documentRows}} | `{{buildEnvCycloneDxFile}}` | CycloneDX 1.6 JSON | Best-effort build environment - development and build-time tooling | -| `{{supplyChainNotesFile}}` | Markdown | Known vulnerabilities and license gaps in the delivered dependencies, plus build diagnostics | +| `{{supplyChainNotesFile}}` | Markdown | Known vulnerabilities in the delivered dependencies, plus build diagnostics | ## Scope @@ -16,7 +16,7 @@ Each **delivered** document describes exactly what a consuming application resol The **build environment** document is a supplementary, best-effort view of the repository dependency tree, including development and build-time tooling. npm dependency-tree errors are tolerated for this document and are recorded below. It exists to answer toolchain inventory questions and is deliberately *not* an inventory of what ships to customers. -Components in the delivered documents are enumerated from evidence - the installed package tree - not from declared ranges. A component's license is the one its own package manifest declares. Where a package declares none, the license is looked up on [ClearlyDefined](https://clearlydefined.io) and recorded as `concluded`, with the property `ig:license:source` set to `ClearlyDefined`. A component neither source resolves is recorded without a license and listed in the supply-chain notes below. All CycloneDX documents are validated against the official CycloneDX schema before packaging. Only the delivered documents are included in signed GitHub SBOM attestations. +Components in the delivered documents are enumerated from evidence - the installed package tree - not from declared ranges. All CycloneDX documents are validated against the official CycloneDX schema before packaging. Only the delivered documents are included in signed GitHub SBOM attestations. {{supplyChainNotes}} @@ -36,20 +36,22 @@ Recompute the digests against the artifacts you received: {{hashCommands}} ``` +```powershell +{{powerShellHashCommands}} +``` + To re-validate the documents independently, install the [official CycloneDX CLI](https://github.com/CycloneDX/cyclonedx-cli/releases) and run: ```bash {{validateCommands}} ``` -Each delivered document is embedded as the predicate of a signed GitHub artifact attestation bound to its package's digest above. The build-environment document is not attested. Every package also carries a build provenance attestation. Verify the delivered SBOM predicates explicitly with: +Each delivered document is embedded as the predicate of a signed GitHub artifact attestation bound to its package's digest above. The build-environment document is not attested. Verify the build provenance and delivered SBOM predicate of each package explicitly with: ```bash {{attestationCommands}} ``` -Drop `--predicate-type` to verify the build provenance instead. - ## Generated by | Tool | Version | diff --git a/spec/unit/sbom-document-spec.ts b/spec/unit/sbom-document-spec.ts deleted file mode 100644 index 32fa5452a..000000000 --- a/spec/unit/sbom-document-spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { assertDeliveredShape, Bom, promoteMainComponent } from "../../scripts/sbom-document"; - -const WORKSPACE_REF = "igniteui-cli-sbom-workspace@0.0.0"; -const SUPPLIER = { name: "Infragistics", url: ["https://www.infragistics.com"] }; - -function generatedBom(main: { name: string, group?: string, version: string }): Bom { - const fullName = main.group ? `${main.group}/${main.name}` : main.name; - const mainRef = `${WORKSPACE_REF}|${fullName}@${main.version}`; - const dependencyRef = `${WORKSPACE_REF}|dependency@2.0.0`; - const nestedRef = `${dependencyRef}|nested@3.0.0`; - - return { - metadata: { - component: { "name": "igniteui-cli-sbom-workspace", "bom-ref": WORKSPACE_REF } - }, - components: [ - { ...main, "bom-ref": mainRef }, - { - "name": "dependency", - "version": "2.0.0", - "bom-ref": dependencyRef, - "components": [{ "name": "nested", "version": "3.0.0", "bom-ref": nestedRef }] - } - ], - dependencies: [ - { ref: WORKSPACE_REF, dependsOn: [mainRef] }, - { ref: mainRef, dependsOn: [dependencyRef] }, - { ref: dependencyRef, dependsOn: [nestedRef] }, - { ref: nestedRef, dependsOn: [] } - ] - }; -} - -function promote(bom: Bom, packageName: string, version: string): Bom { - return promoteMainComponent(bom, { - artifact: { name: "package.tgz", path: "package.tgz", sha256: "a".repeat(64), sha512: "b".repeat(128) }, - packageName, - supplier: SUPPLIER, - version - }); -} - -describe("Unit - SBOM document", () => { - it("re-roots every component and dependency reference on the published package", () => { - const bom = promote(generatedBom({ name: "igniteui-cli", version: "1.2.3" }), "igniteui-cli", "1.2.3"); - - expect(assertDeliveredShape(bom, { packageName: "igniteui-cli", version: "1.2.3" })).toBe(2); - expect(bom.metadata.component["bom-ref"]).toBe("igniteui-cli@1.2.3"); - expect(JSON.stringify(bom)).not.toMatch(/igniteui-cli-sbom-workspace/); - expect(bom.dependencies.map(entry => entry.ref)).toEqual( - ["igniteui-cli@1.2.3", "dependency@2.0.0", "dependency@2.0.0|nested@3.0.0"] - ); - }); - - it("finds a scoped package, which CycloneDX splits into group and name", () => { - const bom = promote( - generatedBom({ name: "cli-core", group: "@igniteui", version: "1.2.3" }), "@igniteui/cli-core", "1.2.3"); - - expect(assertDeliveredShape(bom, { packageName: "@igniteui/cli-core", version: "1.2.3" })).toBe(2); - expect(bom.metadata.component.purl).toBe("pkg:npm/%40igniteui/cli-core@1.2.3"); - expect(bom.metadata.component["bom-ref"]).toBe("@igniteui/cli-core@1.2.3"); - }); - - it("rejects a document that does not contain the package", () => { - expect(() => promote(generatedBom({ name: "other", version: "1.2.3" }), "igniteui-cli", "1.2.3")) - .toThrowError(/igniteui-cli is missing/); - }); - - it("rejects dependency references that do not identify a component", () => { - const bom = promote(generatedBom({ name: "igniteui-cli", version: "1.2.3" }), "igniteui-cli", "1.2.3"); - bom.dependencies[0].dependsOn.push("missing@1.0.0"); - - expect(() => assertDeliveredShape(bom, { packageName: "igniteui-cli", version: "1.2.3" })) - .toThrowError(/unknown component "missing@1\.0\.0"/); - }); - - it("rejects components that are unreachable from the published package", () => { - const bom = promote(generatedBom({ name: "igniteui-cli", version: "1.2.3" }), "igniteui-cli", "1.2.3"); - bom.components.push({ "name": "orphan", "version": "4.0.0", "bom-ref": "orphan@4.0.0" }); - bom.dependencies.push({ ref: "orphan@4.0.0", dependsOn: [] }); - - expect(() => assertDeliveredShape(bom, { packageName: "igniteui-cli", version: "1.2.3" })) - .toThrowError(/component "orphan@4\.0\.0" is unreachable/); - }); -}); diff --git a/spec/unit/sbom-licenses-spec.ts b/spec/unit/sbom-licenses-spec.ts deleted file mode 100644 index 1815dd7d5..000000000 --- a/spec/unit/sbom-licenses-spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Bom } from "../../scripts/sbom-document"; -import { - applyLicenses, clearlyDefinedCoordinate, ClearlyDefinedDefinition, resolveLicenses, unlicensedComponents -} from "../../scripts/sbom-licenses"; - -const LICENSED = "npm/npmjs/-/licensed/1.0.0"; -const SCOPED = "npm/npmjs/@igniteui/mcp-server/1.0.0"; - -function bom(): Bom { - return { - metadata: { - component: { "name": "mcp-server", "group": "@igniteui", "version": "1.0.0", "purl": "pkg:npm/%40igniteui/mcp-server@1.0.0" } - }, - components: [ - { "name": "declared", "version": "1.0.0", "purl": "pkg:npm/declared@1.0.0", "licenses": [{ license: { id: "MIT" } }] }, - { - "name": "licensed", "version": "1.0.0", "purl": "pkg:npm/licensed@1.0.0", - "components": [{ "name": "nested", "version": "2.0.0", "purl": "pkg:npm/nested@2.0.0" }] - } - ], - dependencies: [] - }; -} - -function answer(declared: Record): Record { - const definitions: Record = {}; - for (const coordinate of Object.keys(declared)) { - definitions[coordinate] = { licensed: { declared: declared[coordinate] } }; - } - return definitions; -} - -const noDelay = { sleep: () => Promise.resolve() }; - -describe("Unit - SBOM licenses", () => { - it("builds ClearlyDefined coordinates for scoped and unscoped npm packages only", () => { - expect(clearlyDefinedCoordinate({ name: "licensed", version: "1.0.0", purl: "pkg:npm/licensed@1.0.0" })) - .toBe(LICENSED); - expect(clearlyDefinedCoordinate(bom().metadata.component)).toBe(SCOPED); - expect(clearlyDefinedCoordinate({ name: "x", version: "1.0.0", purl: "pkg:nuget/x@1.0.0" })).toBeNull(); - }); - - it("finds the main component and nested dependencies without a license", () => { - expect(unlicensedComponents(bom()).map(clearlyDefinedCoordinate)) - .toEqual([SCOPED, LICENSED, "npm/npmjs/-/nested/2.0.0"]); - }); - - it("records resolved licenses as concluded and marks their source", () => { - const document = bom(); - - expect(applyLicenses(document, new Map([[LICENSED, "Apache-2.0"]]))).toBe(1); - expect(document.components[1].licenses).toEqual([{ expression: "Apache-2.0", acknowledgement: "concluded" }]); - expect(document.components[1].properties).toEqual([{ name: "ig:license:source", value: "ClearlyDefined" }]); - expect(document.components[0].licenses).toEqual([{ license: { id: "MIT" } }]); - expect(unlicensedComponents(document).length).toBe(2); - }); - - it("ignores answers in which ClearlyDefined found no usable license", async () => { - const licenses = await resolveLicenses([LICENSED, SCOPED, "npm/npmjs/-/other/1.0.0"], { - ...noDelay, - fetchDefinitions: async () => answer({ [LICENSED]: "MIT", [SCOPED]: "NOASSERTION", "npm/npmjs/-/other/1.0.0": "OTHER" }) - }); - - expect([...licenses]).toEqual([[LICENSED, "MIT"]]); - }); - - it("retries only failed lookups and keeps what earlier attempts resolved", async () => { - const requests: string[][] = []; - const licenses = await resolveLicenses([LICENSED, SCOPED], { - ...noDelay, - batchSize: 1, - fetchDefinitions: async coordinates => { - requests.push(coordinates); - if (coordinates[0] === SCOPED && requests.length < 3) { - throw new Error("gateway timeout"); - } - return answer({ [LICENSED]: "MIT", [SCOPED]: "Apache-2.0" }); - } - }); - - expect(requests).toEqual([[LICENSED], [SCOPED], [SCOPED]]); - expect([...licenses]).toEqual([[LICENSED, "MIT"], [SCOPED, "Apache-2.0"]]); - }); - - it("does not retry an answer that carries no license", async () => { - let calls = 0; - const licenses = await resolveLicenses([SCOPED], { - ...noDelay, - fetchDefinitions: async () => { - calls++; - return { [SCOPED]: { licensed: {} } }; - } - }); - - expect(calls).toBe(1); - expect(licenses.size).toBe(0); - }); - - it("gives up without throwing when ClearlyDefined stays unreachable", async () => { - let calls = 0; - const licenses = await resolveLicenses([LICENSED, SCOPED], { - ...noDelay, - maxAttempts: 3, - fetchDefinitions: async () => { - calls++; - throw new Error("ClearlyDefined returned HTTP 503"); - } - }); - - expect(calls).toBe(3); - expect(licenses.size).toBe(0); - }); -}); diff --git a/spec/unit/sbom-notes-spec.ts b/spec/unit/sbom-notes-spec.ts deleted file mode 100644 index c090169b4..000000000 --- a/spec/unit/sbom-notes-spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { AuditResult, formatSupplyChainNotes, mergeSupplyChainNotes, PackageEvidence } from "../../scripts/sbom-notes"; - -const CLEAN_TREE = { status: 0, problems: [] as string[], stderr: "" }; -const NO_VULNERABILITIES: AuditResult = { - available: true, - counts: { critical: 0, high: 0, moderate: 0, low: 0, info: 0, total: 0 }, - packages: [] -}; -const NOISY_TREE = { - status: 1, - problems: ["invalid: chokidar@5.0.0 /home/runner/work/igniteui-cli/node_modules/chokidar"], - stderr: [ - "npm error code ELSPROBLEMS", - "npm error invalid: chokidar@5.0.0 /home/runner/work/igniteui-cli/node_modules/chokidar", - "npm error A complete log of this run can be found in: /home/runner/.npm/_logs/2026-08-27T16_04_30-debug-0.log" - ].join("\n") -}; - -function evidence(overrides: Partial = {}): PackageEvidence { - return { name: "igniteui-cli", version: "1.2.3", componentCount: 4, audit: NO_VULNERABILITIES, unlicensed: [], ...overrides }; -} - -describe("Unit - SBOM supply-chain notes", () => { - it("reports a clean delivered closure for every package", () => { - const notes = formatSupplyChainNotes({ - packages: [evidence(), evidence({ name: "@igniteui/cli-core" })], - npmTree: CLEAN_TREE - }); - - expect(notes).toMatch(/### `igniteui-cli` 1\.2\.3/); - expect(notes).toMatch(/### `@igniteui\/cli-core` 1\.2\.3/); - expect(notes.match(/no known vulnerabilities/gi)?.length).toBe(2); - expect(notes).toMatch(/All 5 components carry a license/); - expect(notes).not.toMatch(/critical/i); - }); - - it("lists the components left without a license", () => { - const notes = formatSupplyChainNotes({ - packages: [evidence({ unlicensed: ["@igniteui/mcp-server@1.2.3"] })], - npmTree: CLEAN_TREE - }); - - expect(notes).toMatch(/1 of 5 components have no license/); - expect(notes).toMatch(/`@igniteui\/mcp-server@1\.2\.3`/); - }); - - it("summarizes vulnerabilities by severity and names the affected packages", () => { - const notes = formatSupplyChainNotes({ - packages: [evidence({ - audit: { - available: true, - counts: { critical: 1, high: 0, moderate: 2, low: 0, info: 0, total: 3 }, - packages: [ - { name: "tar-fs", severity: "critical", direct: false, fixAvailable: true }, - { name: "postcss", severity: "moderate", direct: true, fixAvailable: false } - ] - } - })], - npmTree: CLEAN_TREE - }); - - expect(notes).toMatch(/critical \| 1/i); - expect(notes).toMatch(/moderate \| 2/i); - expect(notes).toMatch(/tar-fs/); - expect(notes).toMatch(/postcss/); - }); - - it("states when the vulnerability scan could not run", () => { - const notes = formatSupplyChainNotes({ - packages: [evidence({ - audit: { - available: false, - reason: "registry https://npm.internal.example.test unreachable at C:\\Users\\runner\\audit.json ```details" - } - })], - npmTree: CLEAN_TREE - }); - - expect(notes).toMatch(/could not be completed/i); - expect(notes).toMatch(/registry .* unreachable/); - expect(notes).not.toMatch(/npm\.internal|Users|audit\.json|```details/); - }); - - it("escapes Markdown table delimiters in vulnerability details", () => { - const notes = formatSupplyChainNotes({ - packages: [evidence({ - audit: { - available: true, - counts: { critical: 0, high: 1, moderate: 0, low: 0, info: 0, total: 1 }, - packages: [{ name: "package|name", severity: "high|unexpected", direct: false, fixAvailable: true }] - } - })], - npmTree: CLEAN_TREE - }); - - expect(notes).toMatch(/package\\\|name/); - expect(notes).toMatch(/high\\\|unexpected/); - }); - - it("collapses best-effort build diagnostics and removes paths, prefixes and duplicates", () => { - const notes = formatSupplyChainNotes({ packages: [evidence()], npmTree: NOISY_TREE }); - - expect(notes).toMatch(/
/); - expect(notes).toMatch(/best-effort/i); - expect(notes).toMatch(/`npm ls` exited with status 1/); - expect(notes).toMatch(/invalid: chokidar@5\.0\.0/); - expect(notes).toMatch(/code ELSPROBLEMS/); - - expect(notes).not.toMatch(/home\/runner/); - expect(notes).not.toMatch(/npm error/); - expect(notes).not.toMatch(/complete log of this run/); - expect(notes.match(/invalid: chokidar@5\.0\.0/g)?.length).toBe(1); - }); - - it("does not HTML-escape diagnostics rendered inside a code fence", () => { - const notes = formatSupplyChainNotes({ - packages: [evidence()], - npmTree: { status: 1, problems: ["invalid: pkg@<1.0.0>"], stderr: "" } - }); - - expect(notes).toMatch(/invalid: pkg@<1\.0\.0>/); - expect(notes).not.toMatch(/<|>/); - }); - - it("replaces its own release-notes section without changing authored notes", () => { - const notes = formatSupplyChainNotes({ packages: [evidence()], npmTree: CLEAN_TREE }); - const authored = "# Release 1.2.3\n\nCustomer-facing changes."; - const first = mergeSupplyChainNotes(authored, notes); - const second = mergeSupplyChainNotes(first, notes); - - expect(first).toMatch(/^# Release 1\.2\.3\n\nCustomer-facing changes\./); - expect(first).toBe(second); - expect(first.match(/## Supply chain/g)?.length).toBe(1); - }); -}); From 27dbfd9c50987c0e89c6d3eea1d3dd3125671362 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 28 Oct 2026 18:51:05 +0200 Subject: [PATCH 08/13] chore(release): disable publishing and set versions to 0.0.1 for a dummy release TEST ONLY - revert before merging. Validates the per-package CycloneDX SBOM flow with a dummy 0.0.1 GitHub release in this fork: - the npm publish job is disabled, and the MCP Registry job is skipped with it, since it runs only after a successful publish; - igniteui-cli, @igniteui/cli-core and @igniteui/mcp-server (package.json and server.json) are set to 0.0.1, with the internal ~ranges pointing at it, because the workflow checks the MCP server version against the release tag. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/npm-publish.yml | 3 +++ packages/cli/package.json | 6 +++--- packages/core/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/server.json | 4 ++-- packages/igx-templates/package.json | 2 +- packages/ng-schematics/package.json | 2 +- 7 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 4119d4727..175ca6b8e 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -232,9 +232,11 @@ jobs: if-no-files-found: error # The only job that can publish to npm. It compiles nothing and never checks out the repository. + # TODO: re-enable once the release flow has been validated end to end with a dummy GitHub release. publish: name: Publish to npmjs.org needs: [build, attest, attest-sbom] + if: false runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -349,6 +351,7 @@ jobs: # without repeating the npm publish. # Skipped for prereleases — the MCP registry has no dist-tag equivalent, # so publishing an alpha/beta/rc would make it the current version. + # Skipped while the publish job is disabled: the registry validates the package against npm. mcp-registry: needs: publish if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || (needs.publish.result == 'success' && !github.event.release.prerelease)) }} diff --git a/packages/cli/package.json b/packages/cli/package.json index 6d8d61794..eb84154c9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "igniteui-cli", - "version": "15.8.2", + "version": "0.0.1", "description": "CLI tool for creating Ignite UI projects", "keywords": [ "CLI", @@ -67,8 +67,8 @@ }, "dependencies": { "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~15.8.2", - "@igniteui/mcp-server": "~15.8.2", + "@igniteui/cli-core": "~0.0.1", + "@igniteui/mcp-server": "~0.0.1", "@inquirer/prompts": "^7.9.0", "chalk": "^5.3.0", "glob": "^11.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index d24a51a7d..053688e24 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@igniteui/cli-core", - "version": "15.8.2", + "version": "0.0.1", "description": "Base types and functionality for Ignite UI CLI", "repository": { "type": "git", diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/package.json b/packages/igniteui-mcp/igniteui-doc-mcp/package.json index c453efece..9c95eef7f 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/package.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@igniteui/mcp-server", "mcpName": "io.github.IgniteUI/mcp-server", - "version": "15.8.2", + "version": "0.0.1", "description": "Unified MCP server for Ignite UI — documentation, GitHub API, and CLI scaffolding", "license": "MIT", "repository": { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/server.json b/packages/igniteui-mcp/igniteui-doc-mcp/server.json index d08dbfa9d..8a13f9157 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/server.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/server.json @@ -7,12 +7,12 @@ "source": "github", "url": "https://github.com/IgniteUI/igniteui-cli" }, - "version": "15.8.2", + "version": "0.0.1", "packages": [ { "registryType": "npm", "identifier": "@igniteui/mcp-server", - "version": "15.8.2", + "version": "0.0.1", "transport": { "type": "stdio" } diff --git a/packages/igx-templates/package.json b/packages/igx-templates/package.json index 61a268da2..140773a54 100644 --- a/packages/igx-templates/package.json +++ b/packages/igx-templates/package.json @@ -12,7 +12,7 @@ "author": "Infragistics", "license": "MIT", "dependencies": { - "@igniteui/cli-core": "~15.8.2", + "@igniteui/cli-core": "~0.0.1", "typescript": "~5.5.4" } } diff --git a/packages/ng-schematics/package.json b/packages/ng-schematics/package.json index ad5e76a26..ea34888bd 100644 --- a/packages/ng-schematics/package.json +++ b/packages/ng-schematics/package.json @@ -21,7 +21,7 @@ "@angular-devkit/core": "^22.0.0", "@angular-devkit/schematics": "^22.0.0", "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~15.8.2", + "@igniteui/cli-core": "~0.0.1", "@schematics/angular": "^22.0.0", "minimatch": "^10.0.1", "rxjs": "~7.8.1", From fb44ef482fd75b6ffb2aa4cb9ef182165aa8b47e Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 4 Nov 2026 16:37:09 +0200 Subject: [PATCH 09/13] Account for Typescript strict git push --- scripts/build-sbom.ts | 27 +++++++++++++++++++++------ scripts/sbom-document.ts | 28 +++++++++++++++++++--------- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/scripts/build-sbom.ts b/scripts/build-sbom.ts index 04a9d478c..08770c603 100644 --- a/scripts/build-sbom.ts +++ b/scripts/build-sbom.ts @@ -32,6 +32,21 @@ function log(message: string) { console.log(`[sbom] ${message}`); } +/** Map lookup for keys the caller knows are present; a miss is a programming error. */ +function lookup(map: Map, key: string): V { + const value = map.get(key); + + if (value === undefined) { + throw new Error(`No entry for ${key}.`); + } + + return value; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function readJson(file: string) { return JSON.parse(readFileSync(file, "utf8")); } @@ -136,7 +151,7 @@ function publishablePackages(): WorkspacePackage[] { if (trail.includes(pkg.name)) { throw new Error(`Workspace dependency cycle: ${[...trail, pkg.name].join(" -> ")}`); } - pkg.siblings.forEach(name => visit(packages.get(name), [...trail, pkg.name])); + pkg.siblings.forEach(name => visit(lookup(packages, name), [...trail, pkg.name])); ordered.push(pkg); }; [...packages.values()].sort((a, b) => a.name.localeCompare(b.name)).forEach(pkg => visit(pkg, [])); @@ -171,7 +186,7 @@ function installDeliveredClosure(pkg: WorkspacePackage, artifacts: Map - `file:${path.relative(workspaceDir, artifacts.get(name).path).replace(/\\/g, "/")}`; + `file:${path.relative(workspaceDir, lookup(artifacts, name).path).replace(/\\/g, "/")}`; // npm rejects an override for a direct dependency, so the package itself is left out. const overrides = [...artifacts.keys()] .filter(name => name !== pkg.name) @@ -235,7 +250,7 @@ function captureNpmTreeDiagnostics(): NpmTreeDiagnostics { pending.push(...Object.values(entry)); } } catch (error) { - problems.push(`Could not parse npm ls JSON: ${error.message}`); + problems.push(`Could not parse npm ls JSON: ${errorMessage(error)}`); } return { @@ -272,7 +287,7 @@ function captureDeliveredAudit(workspaceDir: string): AuditResult { })) }; } catch (error) { - return { available: false, reason: error.message }; + return { available: false, reason: errorMessage(error) }; } } @@ -337,7 +352,7 @@ async function deliveredSboms() { const evidence: PackageEvidence[] = []; for (const pkg of packages) { - const artifact = artifacts.get(pkg.name); + const artifact = lookup(artifacts, pkg.name); const workspaceDir = installDeliveredClosure( pkg, artifacts, path.join(outDir, ".delivered", artifact.name.replace(/\.tgz$/, ""))); const bom = promoteMainComponent( @@ -364,7 +379,7 @@ async function deliveredSboms() { const supplyChainNotes = formatSupplyChainNotes({ packages: evidence, npmTree: captureNpmTreeDiagnostics() }); writeFileSync(path.join(outDir, fileNames.supplyChainNotesFile), supplyChainNotes); - const delivered = packages.map(pkg => ({ pkg, artifact: artifacts.get(pkg.name) })); + const delivered = packages.map(pkg => ({ pkg, artifact: lookup(artifacts, pkg.name) })); // Read by the release workflow to attest and publish each package in dependency order. writeFileSync(path.join(outDir, "packages.json"), `${JSON.stringify(delivered.map(({ pkg, artifact }) => ({ diff --git a/scripts/sbom-document.ts b/scripts/sbom-document.ts index 841697e15..b566cbb3e 100644 --- a/scripts/sbom-document.ts +++ b/scripts/sbom-document.ts @@ -54,8 +54,8 @@ export function npmPurl(packageName: string, version: string): string { return `pkg:npm/${packageName.replace(/^@/, "%40")}@${version}`; } -function replacePrefix(value: string | undefined, prefix: string): string | undefined { - return value?.startsWith(prefix) ? value.slice(prefix.length) : value; +function replacePrefix(value: string, prefix: string): string { + return value.startsWith(prefix) ? value.slice(prefix.length) : value; } /** @@ -73,12 +73,20 @@ export function promoteMainComponent( } const [main] = bom.components.splice(index, 1); - const workspaceRef = bom.metadata.component["bom-ref"]; + const workspaceRef = bom.metadata.component?.["bom-ref"]; + + if (workspaceRef === undefined) { + throw new Error("The generated SBOM has no metadata.component bom-ref to replace."); + } + const workspacePrefix = `${workspaceRef}|`; const normalizeRef = (value: string) => replacePrefix(value, workspacePrefix); for (const component of [main, ...componentsIn(bom.components)]) { - component["bom-ref"] = normalizeRef(component["bom-ref"]); + const ref = component["bom-ref"]; + if (ref !== undefined) { + component["bom-ref"] = normalizeRef(ref); + } } bom.metadata.component = { @@ -150,14 +158,16 @@ export function assertDeliveredShape(bom: Bom, expected: { packageName: string, } } - if (!dependencyGraph.has(main?.["bom-ref"])) { + const mainRef = main?.["bom-ref"]; + + if (mainRef === undefined || !dependencyGraph.has(mainRef)) { failures.push("the main component is not the root of the dependency graph"); } else { const reachable = new Set(); - const pending = [main["bom-ref"]]; + const pending = [mainRef]; + let ref: string | undefined; - while (pending.length > 0) { - const ref = pending.pop(); + while ((ref = pending.pop()) !== undefined) { if (reachable.has(ref)) { continue; } @@ -166,7 +176,7 @@ export function assertDeliveredShape(bom: Bom, expected: { packageName: string, } for (const ref of componentRefs) { - if (!reachable.has(ref)) { + if (ref === undefined || !reachable.has(ref)) { failures.push(`component "${ref}" is unreachable from the main component`); } } From 6b46eee04be1bf78798a5404c5eefdb21ae2f35d Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 4 Nov 2026 16:51:00 +0200 Subject: [PATCH 10/13] chore(release): restore versions and re-enable npm and MCP Registry publishing Reverts 27dbfd9c now that the dummy 0.0.1 release validated the SBOM flow end to end: - igniteui-cli, @igniteui/cli-core and @igniteui/mcp-server (package.json and server.json) are back at 15.8.2, matching IgniteUI/igniteui-cli master, with the internal ~ranges pointing at it again; - the npm publish job runs again, and the MCP Registry job with it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/npm-publish.yml | 3 --- packages/cli/package.json | 6 +++--- packages/core/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/server.json | 4 ++-- packages/igx-templates/package.json | 2 +- packages/ng-schematics/package.json | 2 +- 7 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 175ca6b8e..4119d4727 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -232,11 +232,9 @@ jobs: if-no-files-found: error # The only job that can publish to npm. It compiles nothing and never checks out the repository. - # TODO: re-enable once the release flow has been validated end to end with a dummy GitHub release. publish: name: Publish to npmjs.org needs: [build, attest, attest-sbom] - if: false runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -351,7 +349,6 @@ jobs: # without repeating the npm publish. # Skipped for prereleases — the MCP registry has no dist-tag equivalent, # so publishing an alpha/beta/rc would make it the current version. - # Skipped while the publish job is disabled: the registry validates the package against npm. mcp-registry: needs: publish if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || (needs.publish.result == 'success' && !github.event.release.prerelease)) }} diff --git a/packages/cli/package.json b/packages/cli/package.json index f2481375f..5cb9f244c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "igniteui-cli", - "version": "0.0.1", + "version": "15.8.2", "description": "CLI tool for creating Ignite UI projects", "keywords": [ "CLI", @@ -67,8 +67,8 @@ }, "dependencies": { "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~0.0.1", - "@igniteui/mcp-server": "~0.0.1", + "@igniteui/cli-core": "~15.8.2", + "@igniteui/mcp-server": "~15.8.2", "@inquirer/prompts": "^7.9.0", "chalk": "^5.3.0", "glob": "^11.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index 91f81dde3..f2fe37e0d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@igniteui/cli-core", - "version": "0.0.1", + "version": "15.8.2", "description": "Base types and functionality for Ignite UI CLI", "repository": { "type": "git", diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/package.json b/packages/igniteui-mcp/igniteui-doc-mcp/package.json index 9c95eef7f..c453efece 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/package.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@igniteui/mcp-server", "mcpName": "io.github.IgniteUI/mcp-server", - "version": "0.0.1", + "version": "15.8.2", "description": "Unified MCP server for Ignite UI — documentation, GitHub API, and CLI scaffolding", "license": "MIT", "repository": { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/server.json b/packages/igniteui-mcp/igniteui-doc-mcp/server.json index 8a13f9157..d08dbfa9d 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/server.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/server.json @@ -7,12 +7,12 @@ "source": "github", "url": "https://github.com/IgniteUI/igniteui-cli" }, - "version": "0.0.1", + "version": "15.8.2", "packages": [ { "registryType": "npm", "identifier": "@igniteui/mcp-server", - "version": "0.0.1", + "version": "15.8.2", "transport": { "type": "stdio" } diff --git a/packages/igx-templates/package.json b/packages/igx-templates/package.json index 140773a54..61a268da2 100644 --- a/packages/igx-templates/package.json +++ b/packages/igx-templates/package.json @@ -12,7 +12,7 @@ "author": "Infragistics", "license": "MIT", "dependencies": { - "@igniteui/cli-core": "~0.0.1", + "@igniteui/cli-core": "~15.8.2", "typescript": "~5.5.4" } } diff --git a/packages/ng-schematics/package.json b/packages/ng-schematics/package.json index 8a7692bc1..3a72d2e39 100644 --- a/packages/ng-schematics/package.json +++ b/packages/ng-schematics/package.json @@ -21,7 +21,7 @@ "@angular-devkit/core": "^22.0.0", "@angular-devkit/schematics": "^22.0.0", "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~0.0.1", + "@igniteui/cli-core": "~15.8.2", "@schematics/angular": "^22.0.0", "minimatch": "^11.0.1", "rxjs": "~7.8.1", From 75104809ace38c483c0a0e3dc6f6680c852bf860 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 4 Nov 2026 16:56:41 +0200 Subject: [PATCH 11/13] chore(release): set versions to 15.8.3-alpha.0 Prepares a full prerelease of the SBOM release flow: - igniteui-cli, @igniteui/cli-core and @igniteui/mcp-server (package.json and server.json) are set to 15.8.3-alpha.0; - @igniteui/angular-templates and @igniteui/angular-schematics are set to 22.2.1583-alpha.0, following the scheme in scripts/versionScript.ts; - the internal ~ranges point at the new versions. Co-Authored-By: Claude Opus 5.5 --- packages/cli/package.json | 8 ++++---- packages/core/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/package.json | 2 +- packages/igniteui-mcp/igniteui-doc-mcp/server.json | 4 ++-- packages/igx-templates/package.json | 4 ++-- packages/ng-schematics/package.json | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 5cb9f244c..f20f79331 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "igniteui-cli", - "version": "15.8.2", + "version": "15.8.3-alpha.0", "description": "CLI tool for creating Ignite UI projects", "keywords": [ "CLI", @@ -66,9 +66,9 @@ "all": true }, "dependencies": { - "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~15.8.2", - "@igniteui/mcp-server": "~15.8.2", + "@igniteui/angular-templates": "~22.2.1583-alpha.0", + "@igniteui/cli-core": "~15.8.3-alpha.0", + "@igniteui/mcp-server": "~15.8.3-alpha.0", "@inquirer/prompts": "^7.9.0", "chalk": "^5.3.0", "glob": "^11.0.0", diff --git a/packages/core/package.json b/packages/core/package.json index f2fe37e0d..b1781e1ec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@igniteui/cli-core", - "version": "15.8.2", + "version": "15.8.3-alpha.0", "description": "Base types and functionality for Ignite UI CLI", "repository": { "type": "git", diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/package.json b/packages/igniteui-mcp/igniteui-doc-mcp/package.json index c453efece..730f0e9f0 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/package.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@igniteui/mcp-server", "mcpName": "io.github.IgniteUI/mcp-server", - "version": "15.8.2", + "version": "15.8.3-alpha.0", "description": "Unified MCP server for Ignite UI — documentation, GitHub API, and CLI scaffolding", "license": "MIT", "repository": { diff --git a/packages/igniteui-mcp/igniteui-doc-mcp/server.json b/packages/igniteui-mcp/igniteui-doc-mcp/server.json index d08dbfa9d..1f508ea4d 100644 --- a/packages/igniteui-mcp/igniteui-doc-mcp/server.json +++ b/packages/igniteui-mcp/igniteui-doc-mcp/server.json @@ -7,12 +7,12 @@ "source": "github", "url": "https://github.com/IgniteUI/igniteui-cli" }, - "version": "15.8.2", + "version": "15.8.3-alpha.0", "packages": [ { "registryType": "npm", "identifier": "@igniteui/mcp-server", - "version": "15.8.2", + "version": "15.8.3-alpha.0", "transport": { "type": "stdio" } diff --git a/packages/igx-templates/package.json b/packages/igx-templates/package.json index 61a268da2..b7b85829a 100644 --- a/packages/igx-templates/package.json +++ b/packages/igx-templates/package.json @@ -1,6 +1,6 @@ { "name": "@igniteui/angular-templates", - "version": "22.2.1582", + "version": "22.2.1583-alpha.0", "description": "Templates for Ignite UI for Angular projects and components", "repository": { "type": "git", @@ -12,7 +12,7 @@ "author": "Infragistics", "license": "MIT", "dependencies": { - "@igniteui/cli-core": "~15.8.2", + "@igniteui/cli-core": "~15.8.3-alpha.0", "typescript": "~5.5.4" } } diff --git a/packages/ng-schematics/package.json b/packages/ng-schematics/package.json index 3a72d2e39..159a89cb9 100644 --- a/packages/ng-schematics/package.json +++ b/packages/ng-schematics/package.json @@ -1,6 +1,6 @@ { "name": "@igniteui/angular-schematics", - "version": "22.2.1582", + "version": "22.2.1583-alpha.0", "description": "Ignite UI for Angular Schematics for ng new and ng generate", "repository": { "type": "git", @@ -20,8 +20,8 @@ "dependencies": { "@angular-devkit/core": "^22.0.0", "@angular-devkit/schematics": "^22.0.0", - "@igniteui/angular-templates": "~22.2.1582", - "@igniteui/cli-core": "~15.8.2", + "@igniteui/angular-templates": "~22.2.1583-alpha.0", + "@igniteui/cli-core": "~15.8.3-alpha.0", "@schematics/angular": "^22.0.0", "minimatch": "^11.0.1", "rxjs": "~7.8.1", From 986032891d745dce967a633cd516647cc10fd302 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 4 Nov 2026 17:44:40 +0200 Subject: [PATCH 12/13] Add conditional execution for downloading artifacts Copilot code review comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/npm-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 4119d4727..c267f026c 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -295,7 +295,7 @@ jobs: - name: Download attestation bundles uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - continue-on-error: true + if: ${{ !github.event.repository.private }} with: pattern: attestations-* merge-multiple: true From d57350f678802079c6ef43b13f49521b96a59f0a Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 4 Nov 2026 23:39:42 +0200 Subject: [PATCH 13/13] Refactor direct dependency check in SBOM build Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/build-sbom.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/build-sbom.ts b/scripts/build-sbom.ts index 08770c603..ff7b2ff9b 100644 --- a/scripts/build-sbom.ts +++ b/scripts/build-sbom.ts @@ -276,13 +276,19 @@ function captureDeliveredAudit(workspaceDir: string): AuditResult { throw new Error(report.error?.summary ?? (result.stderr.trim() || "npm audit produced no report")); } + const packageName = Object.keys(readJson(path.join(workspaceDir, "package.json")).dependencies)[0]; + const manifest = readJson(path.join(workspaceDir, "node_modules", packageName, "package.json")); + const directDependencies = new Set(Object.keys({ + ...manifest.dependencies, ...manifest.peerDependencies, ...manifest.optionalDependencies + })); + return { available: true, counts: report.metadata.vulnerabilities, packages: Object.values(report.vulnerabilities ?? {}).map(entry => ({ name: entry.name, severity: entry.severity, - direct: entry.isDirect === true, + direct: directDependencies.has(entry.name), fixAvailable: entry.fixAvailable !== false })) };