feat(publish): add --dry-run validation endpoint and CLI option

Add a validate-only endpoint (POST /api/cli/v1/skills/{namespace}/publish/validate)
that runs the full pre-publish validation chain without persisting anything.
This allows developers to check their package locally before actual publishing.

The validation covers:
- SKILL.md existence and frontmatter parsing (name, description required)
- File extension whitelist and size limits
- Credential leak scanning with line-number precision
- Slug generation and name conflict detection

CLI usage: `skillhub publish <path> --dry-run`

Closes #429
This commit is contained in:
dongmucat 2026-05-18 10:36:58 +08:00
parent 098616dcb6
commit 1067d0ff6e
11 changed files with 549 additions and 3 deletions

View file

@ -44,6 +44,14 @@ export interface PublishResponse {
visibility: string
}
export interface DryRunResponse {
valid: boolean
errors: string[]
warnings: string[]
resolvedSlug: string | null
resolvedVersion: string | null
}
export class SkillHubClient {
constructor(
readonly registry: string,
@ -113,6 +121,22 @@ export class SkillHubClient {
return this.handleJsonResponse<PublishResponse>(response)
}
async validatePublish(namespace: string, file: Blob, fileName = 'skill.zip'): Promise<DryRunResponse> {
const formData = new FormData()
formData.append('file', file, fileName)
let response: Response
try {
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
method: 'POST',
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
body: formData
})
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
return this.handleJsonResponse<DryRunResponse>(response)
}
private async getJson<T>(path: string): Promise<T> {
let response: Response
try {

View file

@ -14,6 +14,7 @@ export interface PublishCommandOptions {
registry?: string
token?: string
json?: boolean
dryRun?: boolean
}
export async function publishCommand(path: string, options: PublishCommandOptions): Promise<string> {
@ -40,7 +41,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
let archiveBlob: Blob
let archiveName: string
if (pathStat.isFile()) {
// If input is a file, check if it's already a zip
if (await isZipFile(path)) {
const buffer = await readFile(path)
archiveBlob = new Blob([buffer], { type: 'application/zip' })
@ -49,7 +49,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path })
}
} else if (pathStat.isDirectory()) {
// If input is a directory, create zip from it
archiveBlob = await createZip(path)
archiveName = `${basename(path)}.zip`
} else {
@ -57,6 +56,41 @@ export async function publishCommand(path: string, options: PublishCommandOption
}
const client = new SkillHubClient(registry, token)
if (options.dryRun) {
const result = await client.validatePublish(namespace, archiveBlob, archiveName)
if (options.json) {
return JSON.stringify(result)
}
const lines: string[] = []
if (result.valid) {
lines.push('Validation passed')
} else {
lines.push('Validation failed')
}
if (result.resolvedSlug) {
lines.push(` Slug: ${result.resolvedSlug}`)
}
if (result.resolvedVersion) {
lines.push(` Version: ${result.resolvedVersion}`)
}
if (result.errors.length > 0) {
lines.push('Errors:')
for (const error of result.errors) {
lines.push(` - ${error}`)
}
}
if (result.warnings.length > 0) {
lines.push('Warnings:')
for (const warning of result.warnings) {
lines.push(` - ${warning}`)
}
}
return lines.join('\n')
}
const result = await client.publish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
const detailUrl = `${registry}/space/${result.namespace}/${encodeURIComponent(result.slug)}`

View file

@ -278,6 +278,7 @@ cli
.command('publish <path>', 'Publish a local skill package')
.option('--namespace <slug>', 'Namespace')
.option('--visibility <v>', 'Visibility (public|namespace-only|private)')
.option('--dry-run', 'Validate without publishing')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')

View file

@ -91,6 +91,11 @@ export interface CapturedPublish {
visibility: string
}
export interface CapturedValidate {
namespace: string
fileName: string
}
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
export interface CapturedResolve {
namespace: string
@ -116,6 +121,8 @@ interface FakeRegistryOptions {
searchItems?: Array<{ namespace: string; slug: string; latestVersion: string; summary: string }>
/** Skills available for resolve / download / delete / publish. */
skills?: FakeSkill[]
/** Response to return for publish/validate (dry-run) requests. */
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
/**
* Per-endpoint failure injection. When set for an endpoint, that endpoint
* ignores all other logic and returns the specified failure (or throws for
@ -128,6 +135,7 @@ interface FakeRegistryOptions {
download?: FailureMode
deleteRemote?: FailureMode
publish?: FailureMode
validate?: FailureMode
}
}
@ -167,7 +175,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
publish: CapturedPublish | null
resolve: CapturedResolve | null
delete: CapturedDelete | null
} = { publish: null, resolve: null, delete: null }
validate: CapturedValidate | null
} = { publish: null, resolve: null, delete: null, validate: null }
// If any endpoint is configured with 'network' failure mode, we need a real
// TCP-level failure. Start a connection-dropping server and return its URL
@ -339,6 +348,33 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
})
}
// Validate (dry-run): POST /api/cli/v1/skills/:namespace/publish/validate
const validateMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish\/validate$/)
if (validateMatch && req.method === 'POST') {
if (options.failures?.validate) return failureResponse(options.failures.validate)
const authErr = checkAuth(req)
if (authErr) return authErr
const namespace = validateMatch[1]!
return req.formData().then(form => {
const fileField = form.get('file')
let fileName = 'skill.zip'
if (fileField instanceof File) {
fileName = fileField.name || fileName
}
state.validate = { namespace, fileName }
const dryRunData = options.dryRunResponse ?? {
valid: true,
errors: [],
warnings: [],
resolvedSlug: fileName.replace(/\.zip$/, ''),
resolvedVersion: '1.0.0'
}
return Response.json({ code: 0, data: dryRunData })
})
}
// Publish: POST /api/cli/v1/skills/:namespace/publish
const publishMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish$/)
if (publishMatch && req.method === 'POST') {

View file

@ -0,0 +1,147 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
async function login(env: { home: string }, registryUrl: string) {
const result = await runCli(['login', '--registry', registryUrl, '--token', 'sk_ok'], {
HOME: env.home,
USERPROFILE: env.home
})
if (result.exitCode !== 0) {
throw new Error(`login failed: ${result.stderr}`)
}
}
async function makeTempDir(...files: Array<[string, string]>) {
const dir = await mkdtemp(join(tmpdir(), 'skillhub-dryrun-'))
for (const [name, content] of files) {
await writeFile(join(dir, name), content)
}
return dir
}
describe('publish --dry-run', () => {
test('calls validate endpoint and reports success', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: A test\n---\n# Hello'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Validation passed')
expect(registry.received.validate).not.toBeNull()
expect(registry.received.validate!.namespace).toBe('global')
expect(registry.received.publish).toBeNull()
})
test('--dry-run with --json returns structured response', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
dryRunResponse: {
valid: true,
errors: [],
warnings: ['Disallowed file extension: data.bin'],
resolvedSlug: 'my-skill',
resolvedVersion: '2.0.0'
}
})
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--json', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
const json = JSON.parse(result.stdout)
expect(json.valid).toBe(true)
expect(json.resolvedSlug).toBe('my-skill')
expect(json.resolvedVersion).toBe('2.0.0')
expect(json.warnings).toContain('Disallowed file extension: data.bin')
})
test('--dry-run reports validation errors', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
dryRunResponse: {
valid: false,
errors: ['Missing required file: SKILL.md at root'],
warnings: [],
resolvedSlug: null,
resolvedVersion: null
}
})
await login(env, registry.url)
const dir = await makeTempDir(['README.md', '# No SKILL.md here'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Validation failed')
expect(result.stdout).toContain('Missing required file: SKILL.md at root')
})
test('--dry-run does not actually publish', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(registry.received.publish).toBeNull()
})
test('--dry-run respects --namespace', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
await login(env, registry.url)
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
await runCli(['publish', dir, '--dry-run', '--namespace', 'myteam', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(registry.received.validate!.namespace).toBe('myteam')
})
test('--dry-run requires authentication', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({ token: 'sk_ok' })
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
HOME: env.home,
USERPROFILE: env.home
})
expect(result.exitCode).toBe(2)
expect(result.stderr).toContain('authentication')
})
})

View file

@ -95,6 +95,23 @@ public class CliSkillController extends BaseApiController {
namespace, slug, principal.userId(), AuditRequestContext.from(request)));
}
@PostMapping(value = "/{namespace}/publish/validate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
public ApiResponse<CliDryRunResponse> validatePublish(
@PathVariable String namespace,
@RequestPart("file") MultipartFile file,
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
List<PackageEntry> entries;
try {
entries = archiveExtractor.extract(file);
} catch (IllegalArgumentException e) {
throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage());
}
var result = cliSkillAppService.validatePublish(
namespace, entries, principal.userId(), principal.platformRoles());
return ok("response.success.read", result);
}
@PostMapping(value = "/{namespace}/publish", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
public ApiResponse<CliPublishResponse> publish(

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto.cli;
import java.util.List;
public record CliDryRunResponse(
boolean valid,
List<String> errors,
List<String> warnings,
String resolvedSlug,
String resolvedVersion
) {}

View file

@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.dto.cli.CliDeleteResponse;
import com.iflytek.skillhub.dto.cli.CliDryRunResponse;
import com.iflytek.skillhub.dto.cli.CliPublishResponse;
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.AuditRequestContext;
@ -119,6 +120,18 @@ public class CliSkillAppService {
);
}
public CliDryRunResponse validatePublish(String namespace, List<PackageEntry> entries, String publisherId, Set<String> platformRoles) {
SkillPublishService.DryRunResult result = skillPublishService.validateOnly(
namespace, entries, publisherId, platformRoles);
return new CliDryRunResponse(
result.valid(),
result.errors(),
result.warnings(),
result.resolvedSlug(),
result.resolvedVersion()
);
}
public CliPublishResponse publish(String namespace, List<PackageEntry> entries, String publisherId, SkillVisibility visibility, Set<String> platformRoles) {
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
namespace, entries, publisherId, visibility, platformRoles, false

View file

@ -0,0 +1,90 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.cli.CliDryRunResponse;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliDryRunValidateTest {
@Autowired MockMvc mockMvc;
@MockBean CliSkillAppService cliSkillAppService;
private UsernamePasswordAuthenticationToken auth() {
PlatformPrincipal principal = new PlatformPrincipal(
"user-1", "tester", "t@example.com", "", "api_token", Set.of("USER"));
return new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
}
@Test
void validatePublish_returnsValidResult() throws Exception {
given(cliSkillAppService.validatePublish(eq("global"), any(), eq("user-1"), eq(Set.of("USER"))))
.willReturn(new CliDryRunResponse(
true, List.of(), List.of("Disallowed file extension: data.bin"),
"my-skill", "1.0.0"));
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.with(authentication(auth())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.valid").value(true))
.andExpect(jsonPath("$.data.resolvedSlug").value("my-skill"))
.andExpect(jsonPath("$.data.resolvedVersion").value("1.0.0"))
.andExpect(jsonPath("$.data.warnings[0]").value("Disallowed file extension: data.bin"));
}
@Test
void validatePublish_returnsInvalidResult() throws Exception {
given(cliSkillAppService.validatePublish(eq("global"), any(), eq("user-1"), eq(Set.of("USER"))))
.willReturn(new CliDryRunResponse(
false, List.of("Missing required file: SKILL.md at root"), List.of(),
null, null));
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.with(authentication(auth())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.valid").value(false))
.andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root"))
.andExpect(jsonPath("$.data.resolvedSlug").doesNotExist());
}
@Test
void validatePublish_requiresAuthentication() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file))
.andExpect(status().isUnauthorized());
}
}

View file

@ -119,6 +119,118 @@ public class SkillPublishService {
this.clock = clock;
}
public record DryRunResult(
boolean valid,
List<String> errors,
List<String> warnings,
String resolvedSlug,
String resolvedVersion
) {}
/**
* Validates a package without persisting anything. Used by the --dry-run CLI flow.
*/
@Transactional(readOnly = true)
public DryRunResult validateOnly(
String namespaceSlug,
List<PackageEntry> entries,
String publisherId,
Set<String> platformRoles) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
String resolvedSlug = null;
String resolvedVersion = null;
// 1. Find namespace
var namespaceOpt = namespaceRepository.findBySlug(namespaceSlug);
if (namespaceOpt.isEmpty()) {
errors.add("Namespace not found: " + namespaceSlug);
return new DryRunResult(false, errors, warnings, null, null);
}
Namespace namespace = namespaceOpt.get();
if (namespace.getStatus() == NamespaceStatus.FROZEN) {
errors.add("Namespace is frozen: " + namespaceSlug);
}
if (namespace.getStatus() == NamespaceStatus.ARCHIVED) {
errors.add("Namespace is archived: " + namespaceSlug);
}
// 2. Check membership
boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN");
if (!isSuperAdmin) {
var member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId);
if (member.isEmpty()) {
errors.add("Publisher is not a member of namespace: " + namespaceSlug);
}
}
// 3. Package validation
ValidationResult packageValidation = skillPackageValidator.validate(entries);
errors.addAll(packageValidation.errors());
warnings.addAll(packageValidation.warnings());
if (!packageValidation.passed()) {
return new DryRunResult(false, errors, warnings, null, null);
}
// 4. Parse SKILL.md
PackageEntry skillMd = entries.stream()
.filter(e -> e.path().equals("SKILL.md"))
.findFirst()
.orElse(null);
if (skillMd == null) {
errors.add("Missing required file: SKILL.md at root");
return new DryRunResult(false, errors, warnings, null, null);
}
SkillMetadata metadata;
try {
metadata = skillMetadataParser.parse(new String(skillMd.content()));
} catch (Exception e) {
errors.add("Invalid SKILL.md: " + e.getMessage());
return new DryRunResult(false, errors, warnings, null, null);
}
if (metadata.version() == null || metadata.version().isBlank()) {
resolvedVersion = AUTO_VERSION_FORMATTER.format(currentTime());
} else {
resolvedVersion = metadata.version();
}
try {
resolvedSlug = SlugValidator.slugify(metadata.name());
} catch (Exception e) {
errors.add("Invalid skill name for slug generation: " + e.getMessage());
return new DryRunResult(false, errors, warnings, resolvedSlug, resolvedVersion);
}
// 5. Pre-publish validation (credential scan)
PrePublishValidator.SkillPackageContext context = new PrePublishValidator.SkillPackageContext(
entries, metadata, publisherId, namespace.getId());
ValidationResult prePublishValidation = prePublishValidator.validate(context);
errors.addAll(prePublishValidation.errors());
warnings.addAll(prePublishValidation.warnings());
// 6. Slug conflict check
if (resolvedSlug != null && errors.isEmpty()) {
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), resolvedSlug);
for (Skill existing : existingSkills) {
if (!existing.getOwnerId().equals(publisherId)) {
boolean hasPublished = !skillVersionRepository
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (hasPublished) {
errors.add("Name conflict: slug \"" + resolvedSlug + "\" is already published by another user");
break;
}
}
}
}
return new DryRunResult(errors.isEmpty(), errors, warnings, resolvedSlug, resolvedVersion);
}
/**
* Publishes an extracted package into the target namespace.
*

View file

@ -1652,6 +1652,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/cli/v1/skills/{namespace}/publish/validate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["validatePublish"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/user/profile": {
parameters: {
query?: never;
@ -3948,6 +3964,22 @@ export interface components {
version?: string;
visibility?: string;
};
ApiResponseCliDryRunResponse: {
/** Format: int32 */
code?: number;
msg?: string;
data?: components["schemas"]["CliDryRunResponse"];
/** Format: date-time */
timestamp?: string;
requestId?: string;
};
CliDryRunResponse: {
valid?: boolean;
errors?: string[];
warnings?: string[];
resolvedSlug?: string;
resolvedVersion?: string;
};
UpdateProfileRequest: {
displayName?: string;
};
@ -8294,6 +8326,35 @@ export interface operations {
};
};
};
validatePublish: {
parameters: {
query?: never;
header?: never;
path: {
namespace: string;
};
cookie?: never;
};
requestBody?: {
content: {
"multipart/form-data": {
/** Format: binary */
file: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseCliDryRunResponse"];
};
};
};
};
getProfile: {
parameters: {
query?: never;