From d331b3af879a8749873c706360b0f3b83e00a27b Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Sat, 26 Sep 2026 20:45:34 +0200 Subject: [PATCH] Add Xcode Cloud build start control --- README.md | 16 ++++- package-lock.json | 4 +- package.json | 4 +- src/api/base-client.ts | 20 ++++++ src/api/resources/builds.ts | 67 +++++++++++++++++- src/api/types.ts | 42 +++++++++++ src/index.ts | 2 +- src/tools/build-runs.ts | 90 +++++++++++++++++++++++- tests/build-run-tools.test.ts | 90 ++++++++++++++++++++++++ tests/builds.test.ts | 129 ++++++++++++++++++++++++++++++++++ tests/smoke.test.ts | 11 +++ tests/tools.test.ts | 39 +++++++++- 12 files changed, 503 insertions(+), 11 deletions(-) create mode 100644 tests/build-run-tools.test.ts create mode 100644 tests/builds.test.ts diff --git a/README.md b/README.md index 7e6b976..20fc201 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ # xcode-cloud-mcp -Minimal MCP server for discovering Xcode Cloud products, inspecting and editing workflows, monitoring build runs, and retrieving build issues, logs, test summaries, and UI test artifacts through the App Store Connect API. +MCP server for discovering Xcode Cloud products, inspecting and editing workflows, starting and monitoring build runs, and retrieving build issues, logs, test summaries, and UI test artifacts through the App Store Connect API. ## Features @@ -19,6 +19,7 @@ Minimal MCP server for discovering Xcode Cloud products, inspecting and editing | Discover workflows | `list_workflows` | "List the workflows for product `def456`." | `Feature Branch`, `description`, `isEnabled: true`, `containerFilePath: Chauffeur.xcodeproj` | | Inspect workflow configuration | `get_workflow_details` | "Show me the full workflow details for `abc123`, including environment and actions." | `general`, `environment`, `startConditions`, `actions`, `postActions` | | Monitor running or recent builds | `list_build_runs` | "Show me the running builds for workflow `abc123` so I can monitor them." | `number: 93`, `executionProgress: RUNNING`, `completionStatus: null`, `startedDate: ...` | +| Start an Xcode Cloud build | `start_build` | "Start one build for workflow `abc123`." | newly created build-run ID, number, and execution state | | Enable or disable a workflow | `set_workflow_enabled` | "Disable workflow `abc123` while we are testing new settings." | `operation.type: set_workflow_enabled`, `workflow.general.isEnabled: false` | | Update name, description, or clean mode | `update_workflow_general` | "Rename workflow `abc123` to `Feature Branch v2` and adjust its description." | `changedFields: [name, description]`, updated `workflow.general` | | Update start conditions explicitly | `update_workflow_start_conditions` | "Change workflow `abc123` so pull-request builds no longer auto-cancel." | updated `workflow.startConditions.pullRequest.autoCancel: false` | @@ -37,6 +38,14 @@ Build lookup is workflow-scoped. Retrieval tools accept a direct `buildRunId`, o `list_build_runs` supports `status: "all" | "failed" | "succeeded" | "running" | "pending"` and an optional `limit`, which defaults to `20`, so agents can poll active workflows without post-processing every run locally or inflating MCP response size. +## Build Start Behavior + +`start_build` starts exactly one build run through Apple's public `POST /v1/ciBuildRuns` endpoint. `workflowId` is required. The optional `sourceBranchOrTagId` and `pullRequestId` values are App Store Connect resource IDs, not branch names, tag names, or pull-request numbers; `buildRunId` may be a bare resource ID or an `xcode-cloud://build-run/...` URI. The optional `clean` value applies only to the new run and does not modify the workflow. + +The operation is externally side-effecting and non-idempotent: repeating the same invocation can create another build. The server sends one POST and does not automatically retry an ambiguous failure; inspect `list_build_runs` before deciding whether to try again. + +Apple's public App Store Connect API does not expose an operation for canceling an individual Xcode Cloud build run. This server therefore provides no cancellation tool and does not disable or otherwise mutate a workflow as a cancellation surrogate. + ## Requirements - Node.js `20+` @@ -84,6 +93,7 @@ codex mcp add xcode-cloud \ - `list_workflows(productId)` - `get_workflow_details(workflowId)` - `list_build_runs(workflowId, limit?, status?)` +- `start_build(workflowId, clean?, sourceBranchOrTagId?, pullRequestId?, buildRunId?)` - `set_workflow_enabled(workflowId, enabled)` - `update_workflow_general(workflowId, name?, description?, clean?)` - `update_workflow_start_conditions(workflowId, branchStartCondition?, manualBranchStartCondition?, pullRequestStartCondition?, manualPullRequestStartCondition?, scheduledStartCondition?, tagStartCondition?, manualTagStartCondition?)` @@ -233,8 +243,8 @@ The audience is required and cannot be null in this preset: The general `update_workflow_actions` tool accepts only these two audience values or null/omission (Deployment Preparation = None). Use the preset when requesting a TestFlight release candidate: null/omission is rejected before any API mutation. Workflow details retain the raw `buildDistributionAudience` and add the human-readable `deploymentPreparation` value for each action. -Creating an archive, making it eligible for TestFlight, and assigning its processed build to tester groups are separate steps. This preset configures eligibility; it does not start a build, guarantee successful upload/processing, or assign testers. To distribute automatically, edit the workflow in Xcode or App Store Connect, add a TestFlight Internal Testing post-action, and select the internal group. +Creating an archive, making it eligible for TestFlight, and assigning its processed build to tester groups are separate steps. This preset configures eligibility; it does not itself start a build, guarantee successful upload/processing, or assign testers. Call `start_build` explicitly to create a build run. To distribute automatically, edit the workflow in Xcode or App Store Connect, add a TestFlight Internal Testing post-action, and select the internal group. Apple's [OpenAPI specification](https://developer.apple.com/sample-code/app-store-connect/app-store-connect-openapi-specification.zip), version 4.4.1, inspected on 2026-09-07, exposes no post-action field or relationship in `CiWorkflow`, `CiWorkflowCreateRequest`, or `CiWorkflowUpdateRequest`, and no workflow post-action endpoint. Consequently, workflow responses explicitly report `testFlightDistribution.status: "UNSUPPORTED_BY_APPLE_API"` and group assignment as `UNKNOWN`; the legacy empty `postActions` array is not evidence that no post-actions exist. See Apple's [BuildAudienceType](https://developer.apple.com/documentation/appstoreconnectapi/buildaudiencetype) and [TestFlight distribution guide](https://developer.apple.com/documentation/xcode/distributing-your-xcode-cloud-builds-through-testflight). -A separate build-start/wait/processing/beta-group orchestration could use the TestFlight API, but is outside this server's current scope and would not be a native Xcode Cloud post-action. +Waiting for completion, TestFlight processing, beta-group assignment, and broader release orchestration remain outside this server's current scope and would not be native Xcode Cloud post-actions. diff --git a/package-lock.json b/package-lock.json index 35d41f3..a2067f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thatfactory/xcode-cloud-mcp", - "version": "0.6.2", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thatfactory/xcode-cloud-mcp", - "version": "0.6.2", + "version": "0.7.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", diff --git a/package.json b/package.json index a234f7a..d1e6c5e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@thatfactory/xcode-cloud-mcp", - "version": "0.6.2", - "description": "Minimal MCP server for discovering Xcode Cloud workflows and retrieving build logs, issues, and test artifacts.", + "version": "0.7.0", + "description": "MCP server for configuring Xcode Cloud workflows, starting builds, and retrieving build logs, issues, and test artifacts.", "license": "MIT", "type": "module", "main": "dist/index.js", diff --git a/src/api/base-client.ts b/src/api/base-client.ts index a0d2078..4809eed 100644 --- a/src/api/base-client.ts +++ b/src/api/base-client.ts @@ -132,6 +132,26 @@ export class BaseAPIClient { }); } + protected async post( + path: string, + body: TBody, + ): Promise>; + protected async post( + path: string, + body: TBody, + ): Promise>; + protected async post( + path: string, + body: TBody, + ): Promise> { + const url = new URL(path, this.baseUrl); + + return this.request(url.toString(), { + method: 'POST', + body: JSON.stringify(body), + }); + } + protected async download(url: string): Promise { const response = await fetch(url, { headers: { diff --git a/src/api/resources/builds.ts b/src/api/resources/builds.ts index 7ca5134..1da654f 100644 --- a/src/api/resources/builds.ts +++ b/src/api/resources/builds.ts @@ -1,5 +1,10 @@ import { BaseAPIClient } from '../base-client.js'; -import type { CiBuildAction, CiBuildRun } from '../types.js'; +import type { + CiBuildAction, + CiBuildRun, + CiBuildRunCreateRequest, + CiBuildRunStartOptions, +} from '../types.js'; /** * Build run endpoints. @@ -7,6 +12,66 @@ import type { CiBuildAction, CiBuildRun } from '../types.js'; export class BuildsClient extends BaseAPIClient { static readonly buildLocatorScanLimit = 2000; + /** + * Start exactly one Xcode Cloud build run. + */ + async start(options: CiBuildRunStartOptions): Promise { + const attributes: CiBuildRunCreateRequest['data']['attributes'] = {}; + if (options.clean !== undefined) { + attributes.clean = options.clean; + } + + const relationships: CiBuildRunCreateRequest['data']['relationships'] = { + workflow: { + data: { + type: 'ciWorkflows', + id: options.workflowId, + }, + }, + }; + + if (options.sourceBranchOrTagId !== undefined) { + relationships.sourceBranchOrTag = { + data: { + type: 'scmGitReferences', + id: options.sourceBranchOrTagId, + }, + }; + } + + if (options.pullRequestId !== undefined) { + relationships.pullRequest = { + data: { + type: 'scmPullRequests', + id: options.pullRequestId, + }, + }; + } + + if (options.buildRunId !== undefined) { + relationships.buildRun = { + data: { + type: 'ciBuildRuns', + id: options.buildRunId, + }, + }; + } + + const request: CiBuildRunCreateRequest = { + data: { + type: 'ciBuildRuns', + attributes, + relationships, + }, + }; + const response = await this.post( + '/v1/ciBuildRuns', + request, + ); + + return response.data; + } + /** * Get a build run by id. */ diff --git a/src/api/types.ts b/src/api/types.ts index 5e23629..df5e969 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -189,6 +189,48 @@ export interface CiBuildRun { }; } +interface ResourceIdentifier { + type: TType; + id: string; +} + +/** + * Options for creating one Xcode Cloud build run. + */ +export interface CiBuildRunStartOptions { + workflowId: string; + clean?: boolean; + sourceBranchOrTagId?: string; + pullRequestId?: string; + buildRunId?: string; +} + +/** + * App Store Connect request envelope for starting one Xcode Cloud build run. + */ +export interface CiBuildRunCreateRequest { + data: { + type: 'ciBuildRuns'; + attributes: { + clean?: boolean; + }; + relationships: { + workflow: { + data: ResourceIdentifier<'ciWorkflows'>; + }; + sourceBranchOrTag?: { + data: ResourceIdentifier<'scmGitReferences'>; + }; + pullRequest?: { + data: ResourceIdentifier<'scmPullRequests'>; + }; + buildRun?: { + data: ResourceIdentifier<'ciBuildRuns'>; + }; + }; + }; +} + /** * Xcode Cloud build action. */ diff --git a/src/index.ts b/src/index.ts index 131d3af..0005505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ export function createServer(): McpServer { const server = new McpServer({ name: 'Xcode Cloud MCP', - version: '0.6.2', + version: '0.7.0', }); registerDiscoveryTools(server, client); diff --git a/src/tools/build-runs.ts b/src/tools/build-runs.ts index 641173a..b1ae670 100644 --- a/src/tools/build-runs.ts +++ b/src/tools/build-runs.ts @@ -11,6 +11,8 @@ import { errorResponse, jsonResponse } from '../utils/tool-response.js'; type BuildRunStatusFilter = 'all' | 'failed' | 'pending' | 'running' | 'succeeded'; +const identifierSchema = z.string().trim().min(1); + /** * Register build run listing tools. */ @@ -18,6 +20,92 @@ export function registerBuildRunTools( server: McpServer, client: AppStoreConnectClient, ): void { + server.registerTool( + 'start_build', + { + title: 'Start Xcode Cloud Build', + description: + "Start exactly one new Xcode Cloud build through Apple's public App Store Connect API. Each successful invocation creates a new build run, and repeating the same call can create another build. Individual build-run cancellation is not supported by Apple's public API.", + inputSchema: { + workflowId: identifierSchema, + clean: z + .boolean() + .optional() + .describe('Override clean-build behavior for this run only.'), + sourceBranchOrTagId: identifierSchema + .optional() + .describe( + 'App Store Connect scmGitReferences resource ID, not a branch or tag name.', + ), + pullRequestId: identifierSchema + .optional() + .describe( + 'App Store Connect scmPullRequests resource ID, not a pull-request number.', + ), + buildRunId: identifierSchema + .optional() + .describe( + 'Optional prior build-run ID or xcode-cloud://build-run URI.', + ), + }, + annotations: { + readOnlyHint: false, + idempotentHint: false, + openWorldHint: true, + destructiveHint: false, + }, + }, + async ({ + workflowId, + clean, + sourceBranchOrTagId, + pullRequestId, + buildRunId, + }: { + workflowId: string; + clean?: boolean; + sourceBranchOrTagId?: string; + pullRequestId?: string; + buildRunId?: string; + }) => { + try { + const parsedWorkflowId = parseIdentifier(workflowId, 'workflow'); + const buildRun = await client.builds.start({ + workflowId: parsedWorkflowId, + clean, + sourceBranchOrTagId, + pullRequestId, + buildRunId: + buildRunId === undefined + ? undefined + : parseIdentifier(buildRunId, 'build-run'), + }); + + return jsonResponse({ + operation: { + type: 'start_build', + applied: true, + }, + buildRun: { + id: buildRun.id, + workflowId: + buildRun.relationships?.workflow?.data.id ?? parsedWorkflowId, + number: buildRun.attributes.number, + executionProgress: buildRun.attributes.executionProgress, + completionStatus: buildRun.attributes.completionStatus, + createdDate: buildRun.attributes.createdDate, + startedDate: buildRun.attributes.startedDate, + finishedDate: buildRun.attributes.finishedDate, + isPullRequestBuild: buildRun.attributes.isPullRequestBuild, + sourceCommit: buildRun.attributes.sourceCommit, + }, + }); + } catch (error) { + return errorResponse(error); + } + }, + ); + server.registerTool( 'list_build_runs', { @@ -99,4 +187,4 @@ function filterBuildRuns( return buildRuns.filter( (buildRun) => buildRun.attributes.completionStatus === 'SUCCEEDED', ); -} \ No newline at end of file +} diff --git a/tests/build-run-tools.test.ts b/tests/build-run-tools.test.ts new file mode 100644 index 0000000..32c62da --- /dev/null +++ b/tests/build-run-tools.test.ts @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { AppStoreConnectClient } from '../src/api/client.js'; +import type { CiBuildRunStartOptions } from '../src/api/types.js'; +import { registerBuildRunTools } from '../src/tools/build-runs.js'; + +type ToolResult = { + content: Array<{ type: string; text: string }>; + isError?: boolean; +}; + +type ToolHandler = (arguments_: Record) => Promise; + +test('start_build supports a workflow-only request', async () => { + const { startBuild, starts } = fixture(); + const payload = parsePayload(await startBuild({ workflowId: 'workflow-1' })); + + assert.deepEqual(starts, [ + { + workflowId: 'workflow-1', + clean: undefined, + sourceBranchOrTagId: undefined, + pullRequestId: undefined, + buildRunId: undefined, + }, + ]); + assert.equal(payload.operation.type, 'start_build'); + assert.equal(payload.buildRun.id, 'created-build'); + assert.equal(payload.buildRun.workflowId, 'workflow-1'); +}); + +test('start_build converts API failures to MCP errors', async () => { + const { startBuild, starts } = fixture(new Error('API unavailable')); + const result = await startBuild({ workflowId: 'workflow-1' }); + + assert.equal(starts.length, 1); + assert.equal(result.isError, true); + assert.match(result.content[0]?.text ?? '', /API unavailable/); +}); + +function fixture(startError?: Error): { + startBuild: ToolHandler; + starts: CiBuildRunStartOptions[]; +} { + const registry = new Map(); + const starts: CiBuildRunStartOptions[] = []; + const server = { + registerTool: ( + name: string, + _config: unknown, + callback: ToolHandler, + ): void => { + registry.set(name, callback); + }, + }; + const client = { + builds: { + start: async (options: CiBuildRunStartOptions) => { + starts.push(options); + if (startError !== undefined) { + throw startError; + } + + return { + type: 'ciBuildRuns' as const, + id: 'created-build', + attributes: { + number: 1, + createdDate: '2026-09-26T12:00:00Z', + executionProgress: 'PENDING' as const, + isPullRequestBuild: false, + }, + }; + }, + listForWorkflow: async () => [], + }, + } as unknown as AppStoreConnectClient; + + registerBuildRunTools(server as never, client); + const startBuild = registry.get('start_build'); + assert.ok(startBuild); + + return { startBuild, starts }; +} + +function parsePayload(result: ToolResult): any { + assert.equal(result.isError, undefined); + assert.equal(result.content[0]?.type, 'text'); + return JSON.parse(result.content[0]!.text); +} diff --git a/tests/builds.test.ts b/tests/builds.test.ts new file mode 100644 index 0000000..00c64e7 --- /dev/null +++ b/tests/builds.test.ts @@ -0,0 +1,129 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { AuthManager } from '../src/api/auth.js'; +import { BuildsClient } from '../src/api/resources/builds.js'; +import type { CiBuildRun } from '../src/api/types.js'; + +const createdBuildRun: CiBuildRun = { + type: 'ciBuildRuns', + id: 'created-build', + attributes: { + number: 73, + createdDate: '2026-09-26T12:00:00Z', + executionProgress: 'PENDING', + isPullRequestBuild: false, + }, +}; + +test('start creates one minimal build run request', async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), init }); + return Response.json({ data: createdBuildRun }, { status: 201 }); + }; + + try { + const client = new BuildsClient(fakeAuth(), 'https://example.com'); + const result = await client.start({ workflowId: 'workflow-1' }); + + assert.equal(result.id, 'created-build'); + assert.equal(requests.length, 1); + assert.equal(requests[0]?.url, 'https://example.com/v1/ciBuildRuns'); + assert.equal(requests[0]?.init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(requests[0]?.init?.body)), { + data: { + type: 'ciBuildRuns', + attributes: {}, + relationships: { + workflow: { + data: { type: 'ciWorkflows', id: 'workflow-1' }, + }, + }, + }, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('start includes every optional input and preserves clean false', async () => { + let requestBody: unknown; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + requestBody = JSON.parse(String(init?.body)); + return Response.json({ data: createdBuildRun }, { status: 201 }); + }; + + try { + const client = new BuildsClient(fakeAuth(), 'https://example.com'); + await client.start({ + workflowId: 'workflow-1', + clean: false, + sourceBranchOrTagId: 'reference-1', + pullRequestId: 'pull-request-1', + buildRunId: 'prior-build-1', + }); + + assert.deepEqual(requestBody, { + data: { + type: 'ciBuildRuns', + attributes: { clean: false }, + relationships: { + workflow: { + data: { type: 'ciWorkflows', id: 'workflow-1' }, + }, + sourceBranchOrTag: { + data: { type: 'scmGitReferences', id: 'reference-1' }, + }, + pullRequest: { + data: { type: 'scmPullRequests', id: 'pull-request-1' }, + }, + buildRun: { + data: { type: 'ciBuildRuns', id: 'prior-build-1' }, + }, + }, + }, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('start reports Apple errors without retrying', async () => { + let requestCount = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + requestCount += 1; + return Response.json( + { + errors: [ + { + status: '409', + code: 'ENTITY_ERROR', + title: 'Conflict', + detail: 'The build cannot be started.', + }, + ], + }, + { status: 409 }, + ); + }; + + try { + const client = new BuildsClient(fakeAuth(), 'https://example.com'); + await assert.rejects( + client.start({ workflowId: 'workflow-1' }), + /API Error \(409\): Conflict: The build cannot be started\./, + ); + assert.equal(requestCount, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +function fakeAuth(): AuthManager { + return { + getToken: () => 'test-token', + } as AuthManager; +} diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index ba1dc7a..304ddb2 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -45,6 +45,7 @@ test('server starts over stdio and exposes the expected tools', async () => { 'list_workflows', 'materialize_build_logs', 'set_workflow_enabled', + 'start_build', 'update_workflow_actions', 'update_workflow_general', 'update_workflow_start_conditions', @@ -60,6 +61,16 @@ test('server starts over stdio and exposes the expected tools', async () => { arguments: { workflowId: 'workflow-1', actions: [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: 'TYPO' }] }, }); assert.equal(invalidAction.isError, true); + const invalidStart = await client.callTool({ + name: 'start_build', + arguments: { workflowId: ' ' }, + }); + assert.equal(invalidStart.isError, true); + const unexpectedStartInput = await client.callTool({ + name: 'start_build', + arguments: { workflowId: 'workflow-1', unexpected: true }, + }); + assert.equal(unexpectedStartInput.isError, true); await client.close(); }); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 3678863..5b46fae 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -27,13 +27,15 @@ type ToolHandler = ( test('registered tools expose product, workflow, and build data', async () => { const registry = new Map(); + const toolConfigs = new Map(); const server = { registerTool: ( name: string, - _config: unknown, + config: unknown, callback: ToolHandler, ): void => { registry.set(name, callback); + toolConfigs.set(name, config); }, }; @@ -236,6 +238,7 @@ test('registered tools expose product, workflow, and build data', async () => { }; let currentWorkflow: CiWorkflow = structuredClone(workflow); + let startOptions: Record | undefined; const client = { products: { @@ -302,6 +305,10 @@ test('registered tools expose product, workflow, and build data', async () => { }, }, builds: { + start: async (options: Record) => { + startOptions = options; + return pendingBuildRun; + }, getById: async () => buildRun, listForWorkflow: async () => [buildRun, runningBuildRun, pendingBuildRun], getActions: async () => [buildAction], @@ -328,6 +335,7 @@ test('registered tools expose product, workflow, and build data', async () => { registerWorkflowUpdateTools(server as never, client); const listProducts = registry.get('list_products'); + const startBuild = registry.get('start_build'); const listBuildRuns = registry.get('list_build_runs'); const getWorkflowDetails = registry.get('get_workflow_details'); const setWorkflowEnabled = registry.get('set_workflow_enabled'); @@ -343,6 +351,7 @@ test('registered tools expose product, workflow, and build data', async () => { const cleanupSavedLogs = registry.get('cleanup_saved_logs'); assert.ok(listProducts); + assert.ok(startBuild); assert.ok(listBuildRuns); assert.ok(getWorkflowDetails); assert.ok(setWorkflowEnabled); @@ -356,6 +365,15 @@ test('registered tools expose product, workflow, and build data', async () => { assert.ok(cleanupSavedLogs); const productsPayload = parsePayload(await listProducts!({})); + const startedBuildPayload = parsePayload( + await startBuild!({ + workflowId: 'xcode-cloud://workflow/workflow-1', + clean: false, + sourceBranchOrTagId: 'reference-1', + pullRequestId: 'pull-request-1', + buildRunId: 'xcode-cloud://build-run/build-1', + }), + ); const buildsPayload = parsePayload( await listBuildRuns!({ workflowId: 'workflow-1' }), ); @@ -428,6 +446,25 @@ test('registered tools expose product, workflow, and build data', async () => { const cleanupPayload = parsePayload(await cleanupSavedLogs!({ maxAgeHours: 1 })); assert.equal(productsPayload.products[0].id, 'product-1'); + assert.deepEqual(startOptions, { + workflowId: 'workflow-1', + clean: false, + sourceBranchOrTagId: 'reference-1', + pullRequestId: 'pull-request-1', + buildRunId: 'build-1', + }); + assert.equal(startedBuildPayload.operation.type, 'start_build'); + assert.equal(startedBuildPayload.operation.applied, true); + assert.equal(startedBuildPayload.buildRun.id, 'build-3'); + assert.equal(startedBuildPayload.buildRun.executionProgress, 'PENDING'); + assert.deepEqual(toolConfigs.get('start_build')?.annotations, { + readOnlyHint: false, + idempotentHint: false, + openWorldHint: true, + destructiveHint: false, + }); + assert.equal(registry.has('cancel_build'), false); + assert.equal(registry.has('cancel_build_run'), false); assert.equal(buildsPayload.buildRuns[0].number, 44); assert.equal(runningBuildsPayload.buildRuns.length, 1); assert.equal(runningBuildsPayload.buildRuns[0].executionProgress, 'RUNNING');