diff --git a/package-lock.json b/package-lock.json index c8e6ec2..a0203cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@checkdigit/github-actions", - "version": "4.0.3", + "version": "4.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@checkdigit/github-actions", - "version": "4.0.3", + "version": "4.1.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.0", @@ -14,7 +14,8 @@ "@checkdigit/time": "^5.0.0", "@octokit/rest": "^22.0.1", "debug": "^4.4.3", - "semver": "^7.7.4" + "semver": "^7.7.4", + "yaml": "^2.8.3" }, "devDependencies": { "@checkdigit/eslint-config": "^11.6.1", @@ -9771,9 +9772,7 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 1aaec2b..bd0606c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@checkdigit/github-actions", - "version": "4.0.3", + "version": "4.1.0", "description": " Provides supporting operations for github action builds.", "homepage": "https://github.com/checkdigit/github-actions#readme", "bugs": { @@ -36,7 +36,8 @@ "@checkdigit/time": "^5.0.0", "@octokit/rest": "^22.0.1", "debug": "^4.4.3", - "semver": "^7.7.4" + "semver": "^7.7.4", + "yaml": "^2.8.3" }, "devDependencies": { "@checkdigit/eslint-config": "^11.6.1", diff --git a/src/check-label/check-label-swagger.spec.ts b/src/check-label/check-label-swagger.spec.ts new file mode 100644 index 0000000..a96908a --- /dev/null +++ b/src/check-label/check-label-swagger.spec.ts @@ -0,0 +1,229 @@ +// check-label/check-label-swagger.spec.ts + +/* eslint-disable @checkdigit/regular-expression-comment -- each expression matches a focused portion of a descriptive validation error */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { + type PackageJSON, + resolveSwaggerPaths, + validateSwaggerChange, +} from './check-label.ts'; + +const swaggerPath = 'src/api/v1/swagger.yml'; + +function packageJson(service?: PackageJSON['service']): PackageJSON { + return { + name: '@checkdigit/example', + version: '1.0.1', + files: [], + ...(service === undefined ? {} : { service }), + }; +} + +function swagger(version: string, suffix = ''): string { + return `openapi: 3.0.0 +info: + title: Example API + version: ${version} +paths: {} +${suffix}`; +} + +describe('resolve Swagger paths', () => { + it('returns no paths without a service API', () => { + assert.deepEqual(resolveSwaggerPaths(packageJson()), []); + assert.deepEqual(resolveSwaggerPaths(packageJson({})), []); + }); + + it('returns no paths for an empty endpoint list', () => { + assert.deepEqual( + resolveSwaggerPaths(packageJson({ api: { root: 'src', endpoints: [] } })), + [], + ); + }); + + it('resolves one endpoint relative to the API root', () => { + assert.deepEqual( + resolveSwaggerPaths( + packageJson({ api: { root: 'src', endpoints: ['api/v1'] } }), + ), + ['src/api/v1/swagger.yml'], + ); + }); + + it('resolves every endpoint relative to the API root', () => { + assert.deepEqual( + resolveSwaggerPaths( + packageJson({ + api: { root: 'src', endpoints: ['api/v1', 'admin/v2'] }, + }), + ), + ['src/api/v1/swagger.yml', 'src/admin/v2/swagger.yml'], + ); + }); +}); + +describe('validate Swagger change', () => { + it('does not require a bump when the Swagger is unchanged', () => { + const unchanged = 'not even valid YAML'; + assert.doesNotThrow(() => + validateSwaggerChange(swaggerPath, unchanged, unchanged, 'patch'), + ); + }); + + it('requires a changed Swagger version to increase', () => { + assert.throws( + () => + validateSwaggerChange( + swaggerPath, + swagger('1.0.0', '# changed'), + swagger('1.0.0'), + 'patch', + ), + /src\/api\/v1\/swagger\.yml: Swagger changed but branch info\.version 1\.0\.0 is not greater than main info\.version 1\.0\.0/u, + ); + assert.throws( + () => + validateSwaggerChange( + swaggerPath, + swagger('0.9.0', '# changed'), + swagger('1.0.0'), + 'major', + ), + /branch info\.version 0\.9\.0 is not greater/u, + ); + }); + + [ + { main: '1.2.3', branch: '1.2.4', label: 'patch' }, + { main: '1.2.3', branch: '1.3.0', label: 'minor' }, + { main: '1.2.3', branch: '1.3.0', label: 'major' }, + { main: '1.2.3', branch: '2.0.0', label: 'major' }, + ].forEach(({ main, branch, label }) => { + it(`accepts ${main} to ${branch} with a ${label} package bump`, () => { + assert.doesNotThrow(() => + validateSwaggerChange( + swaggerPath, + swagger(`'${branch}'`, '# changed'), + swagger(main), + label, + ), + ); + }); + }); + + it('allows patch Swagger bumps with larger package bumps', () => { + assert.doesNotThrow(() => + validateSwaggerChange( + swaggerPath, + swagger('1.2.4', '# changed'), + swagger('1.2.3'), + 'minor', + ), + ); + assert.doesNotThrow(() => + validateSwaggerChange( + swaggerPath, + swagger('1.2.4', '# changed again'), + swagger('1.2.3'), + 'major', + ), + ); + }); + + it('rejects package bumps below the Swagger bump severity', () => { + assert.throws( + () => + validateSwaggerChange( + swaggerPath, + swagger('1.3.0', '# changed'), + swagger('1.2.3'), + 'patch', + ), + /minor Swagger version bump.*requires at least a minor.*received patch/u, + ); + assert.throws( + () => + validateSwaggerChange( + swaggerPath, + swagger('2.0.0', '# changed'), + swagger('1.2.3'), + 'minor', + ), + /major Swagger version bump.*requires at least a major.*received minor/u, + ); + }); + + it('rejects missing info.version without matching unrelated version keys', () => { + const missingInfoVersion = `openapi: 3.0.0 +info: + title: Example API +components: + schemas: + Widget: + version: 9.9.9 +`; + assert.throws( + () => + validateSwaggerChange( + swaggerPath, + missingInfoVersion, + swagger('1.0.0'), + 'patch', + ), + /info\.version must be a non-empty string/u, + ); + }); + + it('supports flow mappings, anchors and aliases, and quoted keys', () => { + const main = `openapi: 3.0.0 +apiInfo: &apiInfo { title: Example API, "version": "1.2.3" } +"info": *apiInfo +paths: {} +`; + const branch = main.replace('"1.2.3"', '"1.2.4"'); + assert.doesNotThrow(() => + validateSwaggerChange(swaggerPath, branch, main, 'patch'), + ); + }); + + it('rejects non-string info.version values', () => { + assert.throws( + () => + validateSwaggerChange( + swaggerPath, + 'openapi: 3.0.0\ninfo: { version: 2 }\npaths: {}', + swagger('1.0.0'), + 'major', + ), + /info\.version must be a non-empty string/u, + ); + }); + + it('rejects malformed branch and main versions with the affected path', () => { + assert.throws( + () => + validateSwaggerChange( + swaggerPath, + swagger('next', '# changed'), + swagger('1.0.0'), + 'patch', + ), + /src\/api\/v1\/swagger\.yml: branch info\.version "next" is not valid semver/u, + ); + assert.throws( + () => + validateSwaggerChange( + swaggerPath, + swagger('1.0.1', '# changed'), + swagger('old'), + 'patch', + ), + /src\/api\/v1\/swagger\.yml: main info\.version "old" is not valid semver/u, + ); + }); +}); + +/* eslint-enable @checkdigit/regular-expression-comment */ diff --git a/src/check-label/check-label.spec.ts b/src/check-label/check-label.spec.ts index 57b7b77..cd0f2a4 100644 --- a/src/check-label/check-label.spec.ts +++ b/src/check-label/check-label.spec.ts @@ -150,4 +150,74 @@ describe('check label', async () => { } }); }); + + it('checks every Swagger endpoint configured by the branch package', async () => { + process.env['GITHUB_TOKEN'] = + 'token 0000000000000000000000000000000000000001'; + + const unchangedSwagger = `openapi: 3.0.0 +info: + version: 1.0.0 +paths: {} +`; + const changedMainSwagger = `swagger: '2.0' +info: + version: 2.3.4 +paths: {} +`; + const changedBranchSwagger = changedMainSwagger.replace( + 'version: 2.3.4', + 'version: 2.3.5', + ); + gitHubNock({ + labelPackageVersionMain: '1.0.0', + missingSwaggerFiles: ['src/new/v99/swagger.yml'], + swaggerFiles: { + 'src/api/v1/swagger.yml': unchangedSwagger, + 'src/admin/v2/swagger.yml': changedMainSwagger, + }, + }); + + const workFolder = path.join(os.tmpdir(), crypto.randomUUID()); + await fs.mkdir(path.join(workFolder, 'src/api/v1'), { recursive: true }); + await fs.mkdir(path.join(workFolder, 'src/admin/v2'), { recursive: true }); + await fs.mkdir(path.join(workFolder, 'src/new/v99'), { recursive: true }); + await fs.writeFile( + path.join(workFolder, 'package.json'), + JSON.stringify({ + version: '1.0.1', + service: { + api: { + root: 'src', + endpoints: ['api/v1', 'admin/v2', 'new/v99'], + }, + }, + }), + ); + await fs.writeFile( + path.join(workFolder, 'package-lock.json'), + JSON.stringify({ version: '1.0.1' }), + ); + await fs.writeFile( + path.join(workFolder, 'src/api/v1/swagger.yml'), + unchangedSwagger, + ); + await fs.writeFile( + path.join(workFolder, 'src/admin/v2/swagger.yml'), + changedBranchSwagger, + ); + await fs.writeFile( + path.join(workFolder, 'src/new/v99/swagger.yml'), + changedMainSwagger.replace('version: 2.3.4', 'version: 99.0.0'), + ); + + const originalCwd = process.cwd(); + try { + process.chdir(workFolder); + await createContext(PR_NUMBER_PATCH); + await assert.doesNotReject(checkLabel()); + } finally { + process.chdir(originalCwd); + } + }); }); diff --git a/src/check-label/check-label.ts b/src/check-label/check-label.ts index 7c16a0c..a9df665 100644 --- a/src/check-label/check-label.ts +++ b/src/check-label/check-label.ts @@ -6,23 +6,192 @@ import { readFile } from 'node:fs/promises'; import debug from 'debug'; import semver from 'semver'; +import { parse } from 'yaml'; import { getFileFromMain, getLabelsOnPR } from '../github-api/index.ts'; const log = debug('github-actions:check-label'); -interface PackageJSON { +export interface PackageJSON { name: string; version: string; files: string[]; + service?: { + api?: { + root: string; + endpoints: string[]; + }; + }; } -async function getLocalPackageJsonVersion(fileName: string): Promise { +type VersionBump = 'patch' | 'minor' | 'major'; + +async function getLocalPackageJson(fileName: string): Promise { const packageJSONPath = path.join(process.cwd(), fileName); const readPackageJson = await readFile(packageJSONPath, 'utf8'); - const packageJson = JSON.parse(readPackageJson) as PackageJSON; - return packageJson.version; + return JSON.parse(readPackageJson) as PackageJSON; +} + +export function resolveSwaggerPaths(packageJson: PackageJSON): string[] { + const api = packageJson.service?.api; + if (api === undefined) { + return []; + } + + return api.endpoints.map((endpoint) => + path.posix.join(api.root, endpoint, 'swagger.yml'), + ); +} + +function getInfoVersion(swagger: string, swaggerPath: string): string { + let document: unknown; + try { + document = parse(swagger); + } catch (error) { + throw new Error(`${swaggerPath}: unable to parse YAML`, { cause: error }); + } + if (typeof document !== 'object' || document === null) { + throw new Error(`${swaggerPath}: expected a YAML object`); + } + const info = (document as Record)['info']; + if (typeof info !== 'object' || info === null) { + throw new Error(`${swaggerPath}: expected info to be an object`); + } + const version = (info as Record)['version']; + if (typeof version !== 'string' || version === '') { + throw new Error(`${swaggerPath}: info.version must be a non-empty string`); + } + return version; +} + +function getValidSwaggerVersion( + swagger: string, + swaggerPath: string, + source: 'branch' | 'main', +): semver.SemVer { + const version = getInfoVersion(swagger, swaggerPath); + const parsedVersion = semver.parse(version); + if (parsedVersion === null) { + throw new Error( + `${swaggerPath}: ${source} info.version "${version}" is not valid semver`, + ); + } + return parsedVersion; +} + +function getSwaggerBump( + mainVersion: semver.SemVer, + branchVersion: semver.SemVer, +): VersionBump { + if (branchVersion.major > mainVersion.major) { + return 'major'; + } + if (branchVersion.minor > mainVersion.minor) { + return 'minor'; + } + return 'patch'; +} + +function getBumpSeverity(bump: string): number { + if (bump === 'patch') { + return 0; + } + if (bump === 'minor') { + return 1; + } + if (bump === 'major') { + return 2; + } + throw new Error(`Invalid package bump label: ${bump}`); +} + +export function validateSwaggerChange( + swaggerPath: string, + branchSwagger: string, + mainSwagger: string, + packageBump: string, +): void { + if (branchSwagger === mainSwagger) { + return; + } + + const branchVersion = getValidSwaggerVersion( + branchSwagger, + swaggerPath, + 'branch', + ); + const mainVersion = getValidSwaggerVersion(mainSwagger, swaggerPath, 'main'); + const branchVersionRaw = branchVersion.raw; + const mainVersionRaw = mainVersion.raw; + if (!semver.gt(branchVersion, mainVersion)) { + throw new Error( + `${swaggerPath}: Swagger changed but branch info.version ${branchVersionRaw} is not greater than main info.version ${mainVersionRaw}`, + ); + } + const swaggerBump = getSwaggerBump(mainVersion, branchVersion); + if (getBumpSeverity(packageBump) < getBumpSeverity(swaggerBump)) { + throw new Error( + `${swaggerPath}: ${swaggerBump} Swagger version bump from ${mainVersionRaw} to ${branchVersionRaw} requires at least a ${swaggerBump} package.json bump/PR label; received ${packageBump}`, + ); + } +} + +function isNotFoundError(error: unknown): boolean { + const httpNotFound = 404; + return ( + typeof error === 'object' && + error !== null && + 'status' in error && + error.status === httpNotFound + ); +} + +async function validateSwaggers( + branchPackageJson: PackageJSON, + packageBump: string, +): Promise { + for (const swaggerPath of resolveSwaggerPaths(branchPackageJson)) { + let branchSwagger: string; + try { + // eslint-disable-next-line no-await-in-loop + branchSwagger = await readFile( + path.join(process.cwd(), swaggerPath), + 'utf8', + ); + } catch (error) { + throw new Error(`Unable to read branch Swagger ${swaggerPath}`, { + cause: error, + }); + } + + let mainSwagger: string | undefined; + let isNewSwagger = false; + try { + // eslint-disable-next-line no-await-in-loop + mainSwagger = await getFileFromMain(swaggerPath); + } catch (error) { + if (isNotFoundError(error)) { + isNewSwagger = true; + } else { + throw new Error(`Unable to get Swagger ${swaggerPath} from main`, { + cause: error, + }); + } + } + if (isNewSwagger) { + getValidSwaggerVersion(branchSwagger, swaggerPath, 'branch'); + } else if (mainSwagger === undefined) { + throw new Error(`Unable to get Swagger ${swaggerPath} from main`); + } else { + validateSwaggerChange( + swaggerPath, + branchSwagger, + mainSwagger, + packageBump, + ); + } + } } export function validateVersion( @@ -71,8 +240,8 @@ export default async function (): Promise { const label = labelsPullRequest[0]?.toLowerCase(); assert.ok(label !== undefined, 'Unable to get label from PR'); - const branchPackageJsonVersion = - await getLocalPackageJsonVersion('package.json'); + const branchPackageJson = await getLocalPackageJson('package.json'); + const branchPackageJsonVersion = branchPackageJson.version; const mainPackageJsonVersionRaw = await getFileFromMain('package.json'); if (mainPackageJsonVersionRaw === undefined) { @@ -88,12 +257,14 @@ export default async function (): Promise { label, ); - const branchLockFile = await getLocalPackageJsonVersion('package-lock.json'); + const branchLockFile = await getLocalPackageJson('package-lock.json'); assert.equal( branchPackageJsonVersion, - branchLockFile, + branchLockFile.version, 'package.json and package-lock.json versions do not match', ); + await validateSwaggers(branchPackageJson, label); + log('Action end'); } diff --git a/src/nocks/github.test.ts b/src/nocks/github.test.ts index 4766236..fada760 100644 --- a/src/nocks/github.test.ts +++ b/src/nocks/github.test.ts @@ -12,6 +12,8 @@ export const PR_NUMBER_DEFAULT: typeof PR_NUMBER_PATCH = PR_NUMBER_PATCH; export interface GithubNock { labelPackageVersionMain?: string; + missingSwaggerFiles?: string[]; + swaggerFiles?: Record; } export async function createGithubEventFile( @@ -283,6 +285,23 @@ export default function (options?: GithubNock): void { JSON.stringify({ version: options?.labelPackageVersionMain ?? '1.0.0' }), ); + for (const [swaggerPath, swagger] of Object.entries( + options?.swaggerFiles ?? {}, + )) { + nock('https://api.github.com/') + .get( + `/repos/checkdigit/testlabel/contents/${encodeURIComponent(swaggerPath)}?ref=main`, + ) + .reply(200, swagger); + } + for (const swaggerPath of options?.missingSwaggerFiles ?? []) { + nock('https://api.github.com/') + .get( + `/repos/checkdigit/testlabel/contents/${encodeURIComponent(swaggerPath)}?ref=main`, + ) + .reply(404, { message: 'Not Found' }); + } + // allow delete operations to the two comments that should be deleted nock('https://api.github.com/') .persist()