Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/publish-create-apiops.yml
Original file line number Diff line number Diff line change
@@ -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 }}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-apiops/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/create-apiops/template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<profile>.json` as the canonical machine-readable result
Expand Down
5 changes: 3 additions & 2 deletions packages/create-apiops/template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -19,7 +19,8 @@
},
"@stoplight/spectral-ruleset-bundler": {
"rollup": "2.80.0"
}
},
"lodash": "~4.18.0"
},
"scripts": {
"preinstall": "node ./scripts/check-node-version.js",
Expand Down
221 changes: 221 additions & 0 deletions scripts/test-method-content-integrity.mjs
Original file line number Diff line number Diff line change
@@ -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.");
1 change: 1 addition & 0 deletions src/data/method/fr/labels.criteria.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading