feat(ci): add RAML 1.0 package generation pipeline
Build and Push Docker Image / build (push) Successful in 1m27s
CI / Security audit (push) Successful in 1m43s
CI / Swagger Validation & Coverage (push) Failing after 1m56s
CI / Tests & coverage (push) Failing after 1m56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 53s

- Add generate:openapi, generate:raml, package:raml scripts to package.json
- Add archiver dependency for creating tar.gz archives
- Create scripts/generate-openapi.js to fetch merged OpenAPI spec from running server
- Create scripts/package-raml.js to build versioned RAML tar.gz archive
- Create .spectral.yml with minimal OpenAPI linting rules
- Add /api/swagger.json endpoint to server/app.js for serving merged spec
- Extend swagger job in ci.yml with RAML generation steps
- Upload raml-package artifact to CI with 14-day retention
This commit is contained in:
2026-05-21 14:04:26 +01:00
parent afa6ebc3c7
commit 1a4ff73067
7 changed files with 1253 additions and 3 deletions
+26
View File
@@ -85,3 +85,29 @@ jobs:
DATA_DIR: /tmp/sofarr-ci-data
SKIP_RATE_LIMIT: "1"
NODE_ENV: test
- name: Generate merged OpenAPI spec
run: npm run generate:openapi
env:
NODE_ENV: test
DATA_DIR: /tmp/sofarr-ci-data
SKIP_RATE_LIMIT: "1"
- name: Convert to RAML
run: npm run generate:raml
continue-on-error: true
- name: Package RAML artifact
run: npm run package:raml
env:
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF_TYPE: ${{ github.ref_type }}
GITHUB_REF_NAME: ${{ github.ref_name }}
- name: Upload RAML package artifact
uses: actions/upload-artifact@v3
if: always()
with:
name: raml-package
path: dist/raml-*.tar.gz
retention-days: 14
+10
View File
@@ -0,0 +1,10 @@
extends: spectral:oas
rules:
# Ensure all operations have descriptions
operation-description: warn
# Ensure all paths have parameters defined
path-params-defined: error
# Ensure all schemas have examples where appropriate
example-provided: warn
# Disable rules that are too strict for this project
operation-operationId: off
+915 -2
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -13,7 +13,10 @@
"test:ui": "vitest --ui",
"audit": "npm audit --audit-level=high",
"audit:fix": "npm audit fix",
"audit:critical": "npm audit --audit-level=critical"
"audit:critical": "npm audit --audit-level=critical",
"generate:openapi": "node scripts/generate-openapi.js",
"generate:raml": "npx oas3-to-raml -i dist/openapi-merged.json -o dist/api.raml",
"package:raml": "node scripts/package-raml.js"
},
"dependencies": {
"axios": "^1.6.0",
@@ -31,6 +34,7 @@
"devDependencies": {
"@stoplight/spectral-cli": "^6.16.0",
"@vitest/coverage-v8": "^4.1.6",
"archiver": "^7.0.1",
"concurrently": "^7.6.0",
"nock": "^14.0.15",
"nodemon": "^3.1.14",
+108
View File
@@ -0,0 +1,108 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Generates the merged OpenAPI spec by bootstrapping the Express app
* and fetching the spec from /api/swagger.json.
*
* This ensures the generated spec matches exactly what users see in production.
*/
const { createApp } = require('../server/app.js');
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = 34567; // Use a different port to avoid conflicts
const OUTPUT_DIR = path.join(process.cwd(), 'dist');
const OUTPUT_FILE = path.join(OUTPUT_DIR, 'openapi-merged.json');
async function generateOpenApiSpec() {
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
console.log('Bootstrapping Express app in test mode...');
const app = createApp({ skipRateLimits: true });
return new Promise((resolve, reject) => {
const server = http.createServer(app);
server.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
// Fetch the merged spec
const options = {
hostname: 'localhost',
port: PORT,
path: '/api/swagger.json',
method: 'GET'
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const spec = JSON.parse(data);
// Validate it's a proper OpenAPI spec
if (!spec.openapi || !spec.info) {
throw new Error('Invalid OpenAPI spec: missing openapi or info field');
}
// Write to file
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(spec, null, 2));
console.log(`✓ OpenAPI spec written to ${OUTPUT_FILE}`);
console.log(` Version: ${spec.openapi}`);
console.log(` Title: ${spec.info.title}`);
server.close(() => {
resolve();
});
} catch (error) {
console.error('Error processing OpenAPI spec:', error.message);
server.close(() => {
reject(error);
});
}
});
});
req.on('error', (error) => {
console.error('Error fetching spec:', error.message);
server.close(() => {
reject(error);
});
});
req.end();
});
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
reject(new Error(`Port ${PORT} is already in use`));
} else {
reject(error);
}
});
});
}
// Run if executed directly
if (require.main === module) {
generateOpenApiSpec()
.then(() => {
console.log('OpenAPI spec generation complete');
process.exit(0);
})
.catch((error) => {
console.error('Failed to generate OpenAPI spec:', error);
process.exit(1);
});
}
module.exports = { generateOpenApiSpec };
+184
View File
@@ -0,0 +1,184 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* Creates a versioned tar.gz archive containing the RAML spec,
* original OpenAPI spec, version metadata, and README.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const archiver = require('archiver');
const DIST_DIR = path.join(process.cwd(), 'dist');
const RAML_FILE = path.join(DIST_DIR, 'api.raml');
const OPENAPI_FILE = path.join(DIST_DIR, 'openapi-merged.json');
function getVersion() {
try {
// Try to get the exact tag if we're on one
const tag = execSync('git describe --tags --exact-match 2>/dev/null', { encoding: 'utf-8' }).trim();
if (tag) return tag;
} catch (e) {
// Not on a tag, fall back to SHA
}
try {
// Get short commit SHA
const sha = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
return sha;
} catch (e) {
// Not in a git repo, use timestamp
return `dev-${Date.now()}`;
}
}
function getCommitSha() {
try {
return execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
} catch (e) {
return 'unknown';
}
}
function createVersionJson(version, commitSha) {
return {
version,
commit: commitSha,
generatedAt: new Date().toISOString(),
tool: 'oas3-to-raml',
openapiVersion: '3.1.0',
ramlVersion: '1.0'
};
}
function createReadme(version, commitSha) {
return `# sofarr RAML 1.0 Specification
## Origin
This RAML specification was automatically generated from the sofarr OpenAPI 3.1.0 specification.
- **Version:** ${version}
- **Commit:** ${commitSha}
- **Generated At:** ${new Date().toISOString()}
- **Conversion Tool:** oas3-to-raml (npx)
## Contents
- \`api.raml\` - The RAML 1.0 specification
- \`openapi-merged.json\` - Original merged OpenAPI 3.1.0 spec (for reference)
- \`version.json\` - Metadata about this generation
## Known Limitations
This RAML spec was converted from OpenAPI 3.1.0. Some OpenAPI 3.1 features may not translate perfectly to RAML 1.0:
- Cookie-based authentication (CookieAuth) may require manual mapping to RAML security schemes
- Advanced schema features (e.g., certain keywords, complex polymorphism) may be approximated or dropped
- Webhook-specific features may not be fully represented
For the most accurate API documentation, refer to the live Swagger UI at \`/api/swagger\` or the original OpenAPI spec included in this archive.
## Verification Steps
1. Validate the RAML spec:
\`\`\`bash
npx raml-1-parser validate api.raml
\`\`\`
2. Compare endpoints with the live Swagger UI at \`/api/swagger\`
3. Test in a RAML-aware tool (e.g., API Workbench, MuleSoft Anypoint)
## Quick Start
To use this RAML spec:
1. Extract the archive
2. Open \`api.raml\` in your preferred RAML tool
3. For development, import it into API Workbench or similar tools
## Source
This artifact was generated from the sofarr project:
https://git.i3omb.com/Gandalf/sofarr
Generated from CI run on commit ${commitSha}.
`;
}
async function packageRaml() {
const version = getVersion();
const commitSha = getCommitSha();
const archiveName = `raml-${version}`;
const archivePath = path.join(DIST_DIR, `${archiveName}.tar.gz`);
const stagingDir = path.join(DIST_DIR, archiveName);
console.log(`Packaging RAML for version: ${version}`);
console.log(`Commit: ${commitSha}`);
// Check that required files exist
if (!fs.existsSync(RAML_FILE)) {
throw new Error(`RAML file not found: ${RAML_FILE}`);
}
if (!fs.existsSync(OPENAPI_FILE)) {
throw new Error(`OpenAPI file not found: ${OPENAPI_FILE}`);
}
// Create staging directory
if (fs.existsSync(stagingDir)) {
fs.rmSync(stagingDir, { recursive: true, force: true });
}
fs.mkdirSync(stagingDir, { recursive: true });
// Copy files to staging directory
fs.copyFileSync(RAML_FILE, path.join(stagingDir, 'api.raml'));
fs.copyFileSync(OPENAPI_FILE, path.join(stagingDir, 'openapi-merged.json'));
// Create version.json
const versionJson = createVersionJson(version, commitSha);
fs.writeFileSync(path.join(stagingDir, 'version.json'), JSON.stringify(versionJson, null, 2));
// Create README.md
const readme = createReadme(version, commitSha);
fs.writeFileSync(path.join(stagingDir, 'README.md'), readme);
// Create tar.gz archive
console.log(`Creating archive: ${archivePath}`);
const output = fs.createWriteStream(archivePath);
const archive = archiver('tar', { gzip: true });
return new Promise((resolve, reject) => {
output.on('close', () => {
console.log(`✓ Archive created: ${archivePath}`);
console.log(` Size: ${archive.pointer()} bytes`);
resolve();
});
archive.on('error', (err) => {
reject(err);
});
archive.pipe(output);
archive.directory(stagingDir, false);
archive.finalize();
}).then(() => {
// Clean up staging directory
fs.rmSync(stagingDir, { recursive: true, force: true });
});
}
// Run if executed directly
if (require.main === module) {
packageRaml()
.then(() => {
console.log('RAML packaging complete');
process.exit(0);
})
.catch((error) => {
console.error('Failed to package RAML:', error);
process.exit(1);
});
}
module.exports = { packageRaml };
+5
View File
@@ -189,6 +189,11 @@ function createApp({ skipRateLimits = false } = {}) {
]
}));
// Serve the raw OpenAPI spec as JSON
app.get('/api/swagger.json', (req, res) => {
res.json(swaggerSpec);
});
// API routes
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);