From 5b5298f30ed7631c29915c8fe9e1bf452454f2e9 Mon Sep 17 00:00:00 2001 From: Marjukka Niinioja Date: Mon, 6 Apr 2026 18:52:12 +0300 Subject: [PATCH 1/3] housekeeping: patch to spectral vulnerabilities, action to publish create-apiops manually, test for content integrity, missing FR criteria label --- .github/workflows/publish-create-apiops.yml | 51 +++++ package.json | 2 +- packages/create-apiops/template/README.md | 2 + packages/create-apiops/template/package.json | 3 +- scripts/test-method-content-integrity.mjs | 221 +++++++++++++++++++ src/data/method/fr/labels.criteria.json | 1 + 6 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-create-apiops.yml create mode 100644 scripts/test-method-content-integrity.mjs diff --git a/.github/workflows/publish-create-apiops.yml b/.github/workflows/publish-create-apiops.yml new file mode 100644 index 0000000..8cacd7e --- /dev/null +++ b/.github/workflows/publish-create-apiops.yml @@ -0,0 +1,51 @@ +name: Publish create-apiops + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Inspect root package contents + run: npm run check:package:contents + + - name: Run create-apiops scaffold integration test + run: npm run test:create-apiops + + - name: Read scaffolded root package dependency version + shell: bash + run: | + ROOT_VERSION=$(node --input-type=module -e "import fs from 'node:fs'; const pkg = JSON.parse(fs.readFileSync('packages/create-apiops/template/package.json', 'utf8')); const version = pkg.dependencies?.['apiops-cycles-method-data']; if (!version) { process.exit(1); } console.log(version.replace(/^[^0-9]*/, ''));") + if [ -z "$ROOT_VERSION" ]; then + echo "Failed to resolve apiops-cycles-method-data dependency version from template/package.json" + exit 1 + fi + echo "ROOT_VERSION=$ROOT_VERSION" >> "$GITHUB_ENV" + echo "Resolved template dependency version: $ROOT_VERSION" + + - name: Verify referenced root package version exists on npm + run: npm view apiops-cycles-method-data@${ROOT_VERSION} version + + - name: Publish create-apiops + run: npm publish --workspace packages/create-apiops --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index 867cf3c..69377b3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "./snippets/*": "./src/snippets/*" }, "scripts": { - "test": "node scripts/validate.mjs && node scripts/test-method-stakeholders.mjs && node scripts/test-note-colors.mjs && node scripts/test-print-method-snippet.mjs", + "test": "node scripts/validate.mjs && node scripts/test-method-stakeholders.mjs && node scripts/test-note-colors.mjs && node scripts/test-print-method-snippet.mjs && node scripts/test-method-content-integrity.mjs", "release:create-apiops:pack": "npm pack --workspace packages/create-apiops", "release:create-apiops:publish": "npm publish --workspace packages/create-apiops --access public", "check:packaging:skills": "node scripts/check-packaging-skills.mjs", diff --git a/packages/create-apiops/template/README.md b/packages/create-apiops/template/README.md index d5fb8fc..248ae79 100644 --- a/packages/create-apiops/template/README.md +++ b/packages/create-apiops/template/README.md @@ -110,6 +110,8 @@ In practice: - `scripts/run-design-audit.js` also selects the matching Spectral ruleset directly when generating coverage reports - `.spectral.yaml` is still useful as the default config for editor integrations or manual commands like `spectral lint specs/openapi/api.yaml` +Note: the Spectral toolchain currently relies on some transitive dependencies that may otherwise trigger npm audit findings. The `overrides` in `package.json` are intentional and are used to keep the scaffolded dependency tree in a working and lower-risk state. + The audit command writes multiple outputs so the same result can be used by developers, docs, and CI: - `specs/audit/design-audit..json` as the canonical machine-readable result diff --git a/packages/create-apiops/template/package.json b/packages/create-apiops/template/package.json index 6c53253..c6f9d77 100644 --- a/packages/create-apiops/template/package.json +++ b/packages/create-apiops/template/package.json @@ -19,7 +19,8 @@ }, "@stoplight/spectral-ruleset-bundler": { "rollup": "2.80.0" - } + }, + "lodash": "~4.18.0" }, "scripts": { "preinstall": "node ./scripts/check-node-version.js", diff --git a/scripts/test-method-content-integrity.mjs b/scripts/test-method-content-integrity.mjs new file mode 100644 index 0000000..96e3e64 --- /dev/null +++ b/scripts/test-method-content-integrity.mjs @@ -0,0 +1,221 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +const stationsJson = readJson("src/data/method/stations.json"); +const linesJson = readJson("src/data/method/lines.json"); +const resourcesJson = readJson("src/data/method/resources.json"); +const criteriaJson = readJson("src/data/method/criteria.json"); +const stakeholdersJson = readJson("src/data/method/stakeholders.json"); +const canvasDataJson = readJson("src/data/canvas/canvasData.json"); +const localizedCanvasDataJson = readJson("src/data/canvas/localizedData.json"); +const knownResourceIds = new Set((resourcesJson.resources || []).map((resource) => resource.id)); +const stationGroups = [ + ...(stationsJson["core-stations"]?.items || []).map((station) => ({ ...station, group: "core-stations" })), + ...(stationsJson["sub-stations"]?.items || []).map((station) => ({ ...station, group: "sub-stations" })) +]; +const knownStationIds = new Set(stationGroups.map((station) => station.id)); +const knownCanvasIds = new Set(Object.keys(canvasDataJson)); +const localeDirs = readdirSync("src/data/method", { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +const findings = []; + +function collectMatchingStringValues(node, pattern, results = new Set()) { + if (typeof node === "string") { + if (pattern.test(node)) { + results.add(node); + } + return results; + } + + if (Array.isArray(node)) { + for (const item of node) { + collectMatchingStringValues(item, pattern, results); + } + return results; + } + + if (node && typeof node === "object") { + for (const value of Object.values(node)) { + collectMatchingStringValues(value, pattern, results); + } + } + + return results; +} + +function validateLabelFile(locale, filename, expectedKeys, allowedExtras = []) { + const filePath = path.join("src", "data", "method", locale, filename); + const labels = readJson(filePath); + const actualKeys = new Set(Object.keys(labels)); + const allowedExtrasSet = new Set(allowedExtras); + + for (const expectedKey of expectedKeys) { + if (!actualKeys.has(expectedKey)) { + findings.push(`Locale ${locale} is missing label "${expectedKey}" in ${filename}.`); + } + } + + for (const actualKey of actualKeys) { + if (!expectedKeys.has(actualKey) && !allowedExtrasSet.has(actualKey)) { + findings.push(`Locale ${locale} has unused label "${actualKey}" in ${filename}.`); + } + } +} + +for (const station of stationGroups) { + const steps = station.how_it_works || station["how-it-works"] || []; + const seenStepKeys = new Map(); + const seenResources = new Map(); + + for (const [index, step] of steps.entries()) { + const stepKey = String(step?.step || "").trim(); + const resourceId = String(step?.resource || "").trim(); + + if (!stepKey) { + findings.push(`Station ${station.id} has an empty step value at index ${index}.`); + } else if (seenStepKeys.has(stepKey)) { + findings.push( + `Station ${station.id} contains duplicate step key "${stepKey}" at indexes ${seenStepKeys.get(stepKey)} and ${index}.` + ); + } else { + seenStepKeys.set(stepKey, index); + } + + if (!resourceId) { + continue; + } + + if (seenResources.has(resourceId)) { + findings.push( + `Station ${station.id} contains duplicate resource reference "${resourceId}" at indexes ${seenResources.get(resourceId)} and ${index}.` + ); + } else { + seenResources.set(resourceId, index); + } + + if (!knownResourceIds.has(resourceId)) { + findings.push( + `Station ${station.id} references unknown resource "${resourceId}" at index ${index}.` + ); + } + } +} + +for (const line of linesJson.lines?.items || []) { + const seenStations = new Map(); + + for (const [index, stationId] of (line.stations || []).entries()) { + if (seenStations.has(stationId)) { + findings.push( + `Line ${line.id} contains duplicate station "${stationId}" at indexes ${seenStations.get(stationId)} and ${index}.` + ); + } else { + seenStations.set(stationId, index); + } + + if (!knownStationIds.has(stationId)) { + findings.push(`Line ${line.id} references unknown station "${stationId}" at index ${index}.`); + } + } +} + +const stationsUsedInLines = new Set( + (linesJson.lines?.items || []).flatMap((line) => line.stations || []) +); +for (const station of stationGroups) { + if (!stationsUsedInLines.has(station.id)) { + findings.push(`Station ${station.id} does not belong to any line.`); + } +} + +for (const resource of resourcesJson.resources || []) { + if (resource.category === "canvas") { + if (!resource.canvas) { + findings.push(`Canvas resource ${resource.id} is missing its canvas id.`); + } else if (!knownCanvasIds.has(resource.canvas)) { + findings.push(`Canvas resource ${resource.id} references unknown canvas "${resource.canvas}".`); + } + } + + if (resource.snippet) { + const snippetPath = path.join("src", "snippets", resource.snippet); + if (!existsSync(snippetPath)) { + findings.push(`Resource ${resource.id} references missing snippet "${resource.snippet}".`); + } + } +} + +const expectedStationLabels = collectMatchingStringValues(stationsJson, /^(group|station)\./); +const expectedLineLabels = collectMatchingStringValues(linesJson, /^(lines|line)\./); +const expectedResourceLabels = collectMatchingStringValues(resourcesJson, /^resource\./); +const expectedCriteriaLabels = new Set(criteriaJson.map((criterion) => `criterion.${criterion.id}`)); +const expectedStakeholderLabels = collectMatchingStringValues(stakeholdersJson, /^stakeholder\./); + +for (const locale of localeDirs) { + validateLabelFile(locale, "labels.stations.json", expectedStationLabels); + validateLabelFile(locale, "labels.lines.json", expectedLineLabels); + validateLabelFile(locale, "labels.resources.json", expectedResourceLabels); + validateLabelFile(locale, "labels.criteria.json", expectedCriteriaLabels, ["entry_criteria", "exit_criteria"]); + validateLabelFile( + locale, + "labels.stakeholders.json", + expectedStakeholderLabels, + [ + "stakeholder.involvement.lead", + "stakeholder.involvement.core", + "stakeholder.involvement.consulted" + ] + ); +} + +for (const locale of Object.keys(localizedCanvasDataJson)) { + const localizedCanvases = localizedCanvasDataJson[locale] || {}; + + for (const [canvasId, canvas] of Object.entries(canvasDataJson)) { + const localizedCanvas = localizedCanvases[canvasId]; + if (!localizedCanvas) { + findings.push(`Locale ${locale} is missing canvas localization for "${canvasId}".`); + continue; + } + + if (!String(localizedCanvas.title || "").trim()) { + findings.push(`Locale ${locale} is missing canvas title for "${canvasId}".`); + } + if (!String(localizedCanvas.purpose || "").trim()) { + findings.push(`Locale ${locale} is missing canvas purpose for "${canvasId}".`); + } + if (!String(localizedCanvas.howToUse || "").trim()) { + findings.push(`Locale ${locale} is missing canvas howToUse for "${canvasId}".`); + } + + for (const section of canvas.sections || []) { + const localizedSection = localizedCanvas.sections?.[section.id]; + if (!localizedSection) { + findings.push(`Locale ${locale} is missing section localization for "${canvasId}.${section.id}".`); + continue; + } + + if (!String(localizedSection.section || "").trim()) { + findings.push(`Locale ${locale} is missing section title for "${canvasId}.${section.id}".`); + } + if (!String(localizedSection.description || "").trim()) { + findings.push(`Locale ${locale} is missing section description for "${canvasId}.${section.id}".`); + } + } + } +} + +if (findings.length > 0) { + console.error("Method content validation failed:"); + for (const finding of findings) { + console.error(`- ${finding}`); + } + process.exit(1); +} + +console.log("Method content validation passed."); diff --git a/src/data/method/fr/labels.criteria.json b/src/data/method/fr/labels.criteria.json index 27a2673..be8b511 100644 --- a/src/data/method/fr/labels.criteria.json +++ b/src/data/method/fr/labels.criteria.json @@ -14,6 +14,7 @@ "criterion.architecture-patterns-validated": "Les modèles d'architecture et de plateforme API choisis ont été validés avec les parties prenantes d'architecture, de sécurité et de plateforme pertinentes.", "criterion.design-reflects-business-value": "La conception de l'API et ses capacités exposées reflètent clairement la valeur commerciale et les besoins des utilisateurs.", "criterion.api-consistency": "La conception de l'API suit nos conventions partagées pour le produit API et la conception.", + "criterion.api-contract-tested": "Le contrat d'API est testé et répond aux exigences fonctionnelles et non fonctionnelles.", "criterion.api-description-available": "L'API et ses capacités exposées sont décrites de manière suffisante pour être examinées, auditées et intégrées.", "criterion.audit-passed": "L'API passe avec succès les contrôles de conformité, de sécurité et d'audit.", "criterion.audit-reports-shared": "Les résultats de l'audit et les décisions de correction sont partagés avec les parties prenantes pertinentes.", From 3f25b90213cdc37affd47b7e35b28800096d9e10 Mon Sep 17 00:00:00 2001 From: Marjukka Niinioja Date: Mon, 6 Apr 2026 18:57:32 +0300 Subject: [PATCH 2/3] update packages to 3.4.1 and create-apiops 1.1.1 --- packages/create-apiops/package.json | 2 +- packages/create-apiops/template/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-apiops/package.json b/packages/create-apiops/package.json index 8f8b0e8..fc8755a 100644 --- a/packages/create-apiops/package.json +++ b/packages/create-apiops/package.json @@ -1,6 +1,6 @@ { "name": "create-apiops", - "version": "1.1.0", + "version": "1.1.1", "description": "Scaffold a new APIOps project with an OpenAPI spec, audit scaffolding, and APIOps documentation templates.", "license": "Apache-2.0", "type": "module", diff --git a/packages/create-apiops/template/package.json b/packages/create-apiops/template/package.json index c6f9d77..e7f2527 100644 --- a/packages/create-apiops/template/package.json +++ b/packages/create-apiops/template/package.json @@ -6,7 +6,7 @@ "node": ">=22" }, "dependencies": { - "apiops-cycles-method-data": "^3.4.0", + "apiops-cycles-method-data": "^3.4.1", "canvascreator": "^1.7.3" }, "devDependencies": { From db398ccb9e3c8aa7f68627b50b19e86bfdc5f658 Mon Sep 17 00:00:00 2001 From: Marjukka Niinioja Date: Mon, 6 Apr 2026 18:57:45 +0300 Subject: [PATCH 3/3] 3.4.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b755d41..b357c14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "apiops-cycles-method-data", - "version": "3.4.0", + "version": "3.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "apiops-cycles-method-data", - "version": "3.4.0", + "version": "3.4.1", "license": "Apache-2.0", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 69377b3..841fd16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apiops-cycles-method-data", - "version": "3.4.0", + "version": "3.4.1", "description": "APIOps Cycles Method data and canvases", "license": "Apache-2.0", "type": "module",