-
Notifications
You must be signed in to change notification settings - Fork 0
Dry run before publishing #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ export interface PublishResult { | |
| dryRun: boolean; | ||
| items: PublishPlanItem[]; | ||
| errors?: PublishPlanErrorItem[]; | ||
| validationErrors?: PublishValidationErrorItem[]; | ||
| } | ||
|
|
||
| export interface PublishPlanErrorItem { | ||
|
|
@@ -38,6 +39,12 @@ export interface PublishPlanErrorItem { | |
| diff?: string; | ||
| } | ||
|
|
||
| export interface PublishValidationErrorItem { | ||
| type: string; | ||
| version: string; | ||
| message: string; | ||
| } | ||
|
|
||
| export interface ModuleDefinitionInput { | ||
| type: string; | ||
| name: string; | ||
|
|
@@ -66,6 +73,7 @@ export interface RavionApiClientOptions { | |
|
|
||
| export interface PublishOptions { | ||
| dryRun?: boolean; | ||
| validateRemote?: boolean; | ||
| localDev?: boolean; | ||
| localDevForce?: boolean; | ||
| localDevSourceRef?: string; | ||
|
|
@@ -80,6 +88,7 @@ export interface RavionModuleApiClient { | |
| patchModuleDefinition(input: ModuleDefinitionPatchInput): Promise<RemoteModuleDefinition>; | ||
| listModuleVersions(moduleDefinitionId: string): Promise<RemoteModuleVersion[]>; | ||
| createModuleVersion(input: ModuleVersionInput): Promise<RemoteModuleVersion>; | ||
| validateModuleVersion(input: ModuleVersionInput): Promise<void>; | ||
| } | ||
|
|
||
| export class PublishError extends Error { | ||
|
|
@@ -127,6 +136,7 @@ export async function publishDefinitions( | |
| inventory.definitions.map((definition) => [definition.type, definition]), | ||
| ); | ||
| const items: PublishPlanItem[] = []; | ||
| const validationErrors: PublishValidationErrorItem[] = []; | ||
|
|
||
| for (const definition of [...definitionsToPublish].sort((left, right) => | ||
| left.type.localeCompare(right.type), | ||
|
|
@@ -225,6 +235,31 @@ export async function publishDefinitions( | |
| latestRemoteVersion?.version, | ||
| ), | ||
| ); | ||
| if (dryRun && options.validateRemote) { | ||
| if (!remoteDefinition) { | ||
| options.logger?.( | ||
| `Skipping remote validation for ${definition.type}@${definition.version}; the module definition does not exist remotely yet.`, | ||
| ); | ||
| } else { | ||
| try { | ||
| await client.validateModuleVersion({ | ||
| moduleDefinitionId: remoteDefinition.id, | ||
| version: definition.version, | ||
| description: definition.releaseDescription, | ||
| config: definition.module, | ||
| }); | ||
| options.logger?.( | ||
| `Remote validation passed for ${definition.type}@${definition.version}.`, | ||
| ); | ||
| } catch (error) { | ||
| validationErrors.push({ | ||
| type: definition.type, | ||
| version: definition.version, | ||
| message: formatUnknownError(error), | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| if (!dryRun) { | ||
| if (!remoteDefinition) { | ||
| throw new PublishError( | ||
|
|
@@ -235,6 +270,13 @@ export async function publishDefinitions( | |
| } | ||
| } | ||
|
|
||
| if (validationErrors.length > 0) { | ||
| throw new PublishPlanError( | ||
| `Remote validation failed for ${validationErrors.length} module version(s).`, | ||
| { dryRun, items, validationErrors }, | ||
| ); | ||
| } | ||
|
|
||
| return { dryRun, items }; | ||
| } | ||
|
|
||
|
|
@@ -295,6 +337,24 @@ export function formatPublishPlanMarkdown(result: PublishResult): string { | |
| "", | ||
| ]; | ||
|
|
||
| if (result.validationErrors && result.validationErrors.length > 0) { | ||
| lines.push( | ||
| "### 🚨 Remote Validation Failures 🚨", | ||
| "", | ||
| "The Ravion API rejected these module version configs during dry-run validation. Fix the module definition config before merging.", | ||
| "", | ||
| "| Module | Release Version | Error |", | ||
| "| --- | --- | --- |", | ||
| ); | ||
| for (const error of result.validationErrors) { | ||
| lines.push( | ||
| `| \`${escapeMarkdownTableCell(error.type)}\` | \`${escapeMarkdownTableCell(error.version)}\` | ${escapeMarkdownTableCell(error.message)} |`, | ||
| ); | ||
| } | ||
| lines.push(""); | ||
| return lines.join("\n"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When one module fails remote validation, the result still contains planned changes for every processed definition, but this early return renders only the failure table. The workflow uses this Markdown directly for the PR comment, so reviewers cannot see the other valid releases or their diffs. Prompt To Fix With AIThis is a comment left during a code review.
Path: tools/ravion-modules/src/publish.ts
Line: 340-355
Comment:
**Validation errors hide plan items**
When one module fails remote validation, the result still contains planned changes for every processed definition, but this early return renders only the failure table. The workflow uses this Markdown directly for the PR comment, so reviewers cannot see the other valid releases or their diffs.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| } | ||
|
|
||
| if (result.errors && result.errors.length > 0) { | ||
| lines.push( | ||
| "### 🚨 Release Config Conflicts 🚨", | ||
|
|
@@ -771,6 +831,17 @@ class HttpRavionModuleApiClient implements RavionModuleApiClient { | |
| return this.call<RemoteModuleVersion>("POST", "/module-versions", { data: input }); | ||
| } | ||
|
|
||
| async validateModuleVersion(input: ModuleVersionInput): Promise<void> { | ||
| const { status } = await this.request("POST", "/module-versions", { | ||
| data: { ...input, dryRun: true }, | ||
| }); | ||
| if (status !== 202) { | ||
| throw new PublishError( | ||
| `POST /module-versions dry-run validation returned HTTP ${status} instead of 202. The Ravion API may not support dryRun yet, and the module version may have been created for real.`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| private async list<T>(path: string, query: Record<string, string> = {}): Promise<T[]> { | ||
| const items: T[] = []; | ||
| let cursor: string | undefined; | ||
|
|
@@ -797,6 +868,16 @@ class HttpRavionModuleApiClient implements RavionModuleApiClient { | |
| query: Record<string, string> = {}, | ||
| options: { unwrap?: boolean } = {}, | ||
| ): Promise<T> { | ||
| const { payload } = await this.request(method, path, input, query); | ||
| return (options.unwrap === false ? payload : unwrapApiPayload(payload)) as T; | ||
| } | ||
|
|
||
| private async request( | ||
| method: string, | ||
| path: string, | ||
| input?: unknown, | ||
| query: Record<string, string> = {}, | ||
| ): Promise<{ status: number; payload: unknown }> { | ||
| const headers: Record<string, string> = { "Content-Type": "application/json" }; | ||
| if (this.token) { | ||
| headers.Authorization = `Bearer ${this.token}`; | ||
|
|
@@ -823,7 +904,7 @@ class HttpRavionModuleApiClient implements RavionModuleApiClient { | |
| `${method} ${url} failed with HTTP ${response.status}: ${message}\nResponse body:\n${formatResponsePayload(payload)}`, | ||
| ); | ||
| } | ||
| return (options.unwrap === false ? payload : unwrapApiPayload(payload)) as T; | ||
| return { status: response.status, payload }; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,64 @@ describe("publish", () => { | |
| assert.equal(client.createdVersions.length, 0); | ||
| }); | ||
|
|
||
| it("validates create-version items remotely during dry run when requested", async () => { | ||
| const client = new MockRavionClient({ | ||
| definitions: [{ id: "vpc", type: "ravion-aws-vpc", name: "AWS VPC", description: "AWS VPC and subnets." }], | ||
| }); | ||
|
|
||
| const result = await publishDefinitions([createCompiledDefinition()], client, { dryRun: true, validateRemote: true }); | ||
|
|
||
| assert.deepEqual(result.items.map(({ action, dryRun }) => ({ action, dryRun })), [{ action: "create-version", dryRun: true }]); | ||
| assert.deepEqual(client.validatedVersions, [ | ||
| { moduleDefinitionId: "vpc", version: "1.2.3", description: "Add subnet options.", config: { inputs: [{ id: "name", type: "string", label: "Name" }] } }, | ||
| ]); | ||
| assert.equal(client.createdVersions.length, 0); | ||
| }); | ||
|
Comment on lines
+161
to
+173
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This test uses Prompt To Fix With AIThis is a comment left during a code review.
Path: tools/ravion-modules/test/publish.test.ts
Line: 56-68
Comment:
**HTTP validation path remains untested**
This test uses `MockRavionClient`, so it does not exercise the changed HTTP request envelope or the strict `202` response check in `HttpRavionModuleApiClient.validateModuleVersion`. Add an HTTP-client test for the dry-run request and success status so a mismatch with the API contract does not make every validation workflow fail.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
|
|
||
| it("fails the dry run with validation details when the API rejects a config", async () => { | ||
| const client = new MockRavionClient({ | ||
| definitions: [{ id: "vpc", type: "ravion-aws-vpc", name: "AWS VPC", description: "AWS VPC and subnets." }], | ||
| }); | ||
| client.onValidateVersion = async () => { | ||
| throw new Error('POST /module-versions failed with HTTP 422: inputs.0.required: field is not allowed'); | ||
| }; | ||
|
|
||
| await assert.rejects( | ||
| publishDefinitions([createCompiledDefinition()], client, { dryRun: true, validateRemote: true }), | ||
| (error: unknown) => { | ||
| assert.ok(error instanceof PublishPlanError); | ||
| assert.deepEqual(error.result.validationErrors, [ | ||
| { type: "ravion-aws-vpc", version: "1.2.3", message: "Error: POST /module-versions failed with HTTP 422: inputs.0.required: field is not allowed" }, | ||
| ]); | ||
| const markdown = formatPublishPlanMarkdown(error.result); | ||
| assert.match(markdown, /Remote Validation Failures/); | ||
| assert.match(markdown, /field is not allowed/); | ||
| return true; | ||
| }, | ||
| ); | ||
| assert.equal(client.createdVersions.length, 0); | ||
| }); | ||
|
|
||
| it("skips remote validation for definitions that do not exist remotely yet", async () => { | ||
| const client = new MockRavionClient(); | ||
|
|
||
| const result = await publishDefinitions([createCompiledDefinition()], client, { dryRun: true, validateRemote: true }); | ||
|
|
||
| assert.deepEqual(result.items.map(({ action }) => action), ["create-definition", "create-version"]); | ||
| assert.equal(client.validatedVersions.length, 0); | ||
| assert.equal(client.createdVersions.length, 0); | ||
| }); | ||
|
|
||
| it("does not validate remotely during dry run when not requested", async () => { | ||
| const client = new MockRavionClient({ | ||
| definitions: [{ id: "vpc", type: "ravion-aws-vpc", name: "AWS VPC", description: "AWS VPC and subnets." }], | ||
| }); | ||
|
|
||
| await publishDefinitions([createCompiledDefinition()], client, { dryRun: true }); | ||
|
|
||
| assert.equal(client.validatedVersions.length, 0); | ||
| }); | ||
|
|
||
| it("fails with structured conflict details when an existing version has different config", async () => { | ||
| const remoteConfig = { | ||
| inputs: [ | ||
|
|
@@ -439,8 +497,10 @@ class MockRavionClient implements RavionModuleApiClient { | |
| createdDefinitions: ModuleDefinitionInput[] = []; | ||
| patchedDefinitions: ModuleDefinitionPatchInput[] = []; | ||
| createdVersions: ModuleVersionInput[] = []; | ||
| validatedVersions: ModuleVersionInput[] = []; | ||
| onListModuleVersions?: (moduleDefinitionId: string) => Promise<RemoteModuleVersion[]>; | ||
| onCreateVersion?: (input: ModuleVersionInput) => Promise<void>; | ||
| onValidateVersion?: (input: ModuleVersionInput) => Promise<void>; | ||
|
|
||
| constructor(options: { definitions?: RemoteModuleDefinition[]; versionsByDefinitionId?: Record<string, RemoteModuleVersion[]> } = {}) { | ||
| this.definitions = options.definitions ?? []; | ||
|
|
@@ -485,4 +545,11 @@ class MockRavionClient implements RavionModuleApiClient { | |
| this.versionsByDefinitionId[input.moduleDefinitionId] = [...(this.versionsByDefinitionId[input.moduleDefinitionId] ?? []), version]; | ||
| return version; | ||
| } | ||
|
|
||
| async validateModuleVersion(input: ModuleVersionInput): Promise<void> { | ||
| if (this.onValidateVersion) { | ||
| await this.onValidateVersion(input); | ||
| } | ||
| this.validatedVersions.push(input); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a new module has config accepted locally but rejected by the Ravion API, both dry runs skip remote validation because the definition does not exist yet. The later
--applyrun creates and globally publishes the definition before version creation fails, leaving a published definition with no version and failing the main-branch publish workflow.Prompt To Fix With AI