diff --git a/src/commands/validate-schema.ts b/src/commands/validate-schema.ts index 4940f197a..e6697f1ab 100644 --- a/src/commands/validate-schema.ts +++ b/src/commands/validate-schema.ts @@ -9,12 +9,13 @@ import { readAndValidateInputSchema, readInputSchema, readStorageSchema, + validateActorVersion, validateDatasetSchema, validateKvsSchema, validateOutputSchema, } from '../lib/input_schema.js'; import { error, info, success } from '../lib/outputs.js'; -import { Ajv2019 } from '../lib/utils.js'; +import { Ajv2019, getLocalConfig } from '../lib/utils.js'; export class ValidateSchemaCommand extends ApifyCommand { static override name = 'validate-schema' as const; @@ -77,6 +78,18 @@ When no path is provided, validates all schemas found in '${LOCAL_CONFIG_PATH}': let foundAny = false; let hasErrors = false; + try { + const localConfig = getLocalConfig(cwd); + + if (localConfig) { + validateActorVersion(localConfig); + } + } catch (err) { + foundAny = true; + hasErrors = true; + error({ message: (err as Error).message }); + } + // Input schema — not using readAndValidateInputSchema here because it throws // when no schema is found; in the all-schemas scan, a missing input schema // should be silently skipped, not treated as an error. diff --git a/src/lib/input_schema.ts b/src/lib/input_schema.ts index 47b32af98..7e325b12b 100644 --- a/src/lib/input_schema.ts +++ b/src/lib/input_schema.ts @@ -15,6 +15,11 @@ import { ACTOR_SPECIFICATION_FOLDER, LOCAL_CONFIG_PATH } from './consts.js'; import { info, warning } from './outputs.js'; import { Ajv2019, getJsonFileContent, getLocalConfig, getLocalKeyValueStorePath } from './utils.js'; +// Actor versions on the platform are MAJOR.MINOR with non-negative integers and no leading +// zeros. Three-part SemVer (e.g. "1.0.0") is a build-number format the platform rejects. +// TODO: replace with the shared export from @apify/consts once apify-shared-js #655 lands. +const ACTOR_VERSION_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + const DEFAULT_INPUT_SCHEMA_PATHS = [ '.actor/INPUT_SCHEMA.json', './INPUT_SCHEMA.json', @@ -231,6 +236,22 @@ export const readOutputSchema = ({ }; }; +export function validateActorVersion(config: Record): void { + if (config.version === undefined) { + return; + } + + if (typeof config.version === 'string' && ACTOR_VERSION_REGEX.test(config.version)) { + return; + } + + const received = typeof config.version === 'string' ? `"${config.version}"` : JSON.stringify(config.version); + + throw new Error( + `Actor version in '${LOCAL_CONFIG_PATH}' must be in MAJOR.MINOR format, for example "1.0". Received ${received}.`, + ); +} + /** * Goes to the Actor directory and creates INPUT.json file from the input schema prefills. diff --git a/test/local/commands/validate-schema.test.ts b/test/local/commands/validate-schema.test.ts index a0ea91965..cd4f3f6b6 100644 --- a/test/local/commands/validate-schema.test.ts +++ b/test/local/commands/validate-schema.test.ts @@ -1,8 +1,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; +import process from 'node:process'; import { ValidateSchemaCommand } from '../../../src/commands/validate-schema.js'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; +import { CommandExitCodes } from '../../../src/lib/consts.js'; import { validDatasetSchemaPath } from '../../__setup__/dataset-schemas/paths.js'; import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; @@ -20,11 +22,13 @@ async function setupActorConfig( basePath: string, { inputSchema, + version, datasetSchemaRef, outputSchemaRef, kvsSchemaRef, }: { inputSchema?: Record; + version?: string; datasetSchemaRef?: string | Record; outputSchemaRef?: string | Record; kvsSchemaRef?: string | Record; @@ -47,7 +51,7 @@ async function setupActorConfig( const actorJson: Record = { actorSpecification: 1, name: 'test-actor', - version: '0.1', + version: version ?? '0.1', input: './input_schema.json', }; @@ -131,6 +135,7 @@ describe('apify validate-schema', () => { }); beforeEach(async () => { + process.exitCode = undefined; await beforeAllCalls(); }); @@ -166,6 +171,33 @@ describe('apify validate-schema', () => { expect(allMessages).not.toContain('Key-Value Store'); }); + it.each(['1.0.0', '01.0', '1.01'])('should reject platform-invalid Actor version %s', async (version) => { + await setupActorConfig(joinPath(), { version }); + + await testRunCommand(ValidateSchemaCommand, {}); + + const allMessages = logMessages.error.join('\n'); + expect(allMessages).toContain( + `Actor version in '.actor/actor.json' must be in MAJOR.MINOR format, for example "1.0". Received "${version}".`, + ); + expect(allMessages).toContain('Input schema is valid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); + }); + + it.each(['0.0', '1.0', '99.99', '100.0', '1.100'])( + 'should accept platform-valid Actor version %s', + async (version) => { + await setupActorConfig(joinPath(), { version }); + + await testRunCommand(ValidateSchemaCommand, {}); + + const allMessages = logMessages.error.join('\n'); + expect(allMessages).toContain('Input schema is valid'); + expect(allMessages).not.toContain('Actor version in'); + expect(process.exitCode).toBeUndefined(); + }, + ); + it('should report error for invalid dataset schema', async () => { await setupActorConfig(joinPath(), { datasetSchemaRef: { @@ -214,6 +246,7 @@ describe('apify validate-schema', () => { it('should only validate input schema when path arg is provided', async () => { await setupActorConfig(joinPath(), { + version: '1.0.0', datasetSchemaRef: validDatasetSchemaPath, outputSchemaRef: validOutputSchemaPath, kvsSchemaRef: validKvsSchemaPath, @@ -225,6 +258,7 @@ describe('apify validate-schema', () => { const allMessages = logMessages.error.join('\n'); expect(allMessages).toContain('Input schema is valid'); + expect(allMessages).not.toContain('Actor version in'); expect(allMessages).not.toContain('Dataset'); expect(allMessages).not.toContain('Output'); expect(allMessages).not.toContain('Key-Value Store');