From 3893a315256da7591cc8714a21b1c534a82c8437 Mon Sep 17 00:00:00 2001 From: Fernando Fernandes Date: Mon, 7 Sep 2026 13:07:37 +0200 Subject: [PATCH] Add typed manual TestFlight release-candidate workflows --- README.md | 30 ++++++++++- package-lock.json | 4 +- package.json | 2 +- src/api/types.ts | 4 +- src/index.ts | 2 +- src/tools/workflow-updates.ts | 59 +++++++++++++++++++- src/utils/workflow-details.ts | 13 ++++- tests/smoke.test.ts | 12 +++++ tests/workflow-release.test.ts | 98 ++++++++++++++++++++++++++++++++++ 9 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 tests/workflow-release.test.ts diff --git a/README.md b/README.md index e9d7d71..7e6b976 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ codex mcp add xcode-cloud \ - `update_workflow_general(workflowId, name?, description?, clean?)` - `update_workflow_start_conditions(workflowId, branchStartCondition?, manualBranchStartCondition?, pullRequestStartCondition?, manualPullRequestStartCondition?, scheduledStartCondition?, tagStartCondition?, manualTagStartCondition?)` - `update_workflow_actions(workflowId, actions)` +- `configure_manual_release_candidate(workflowId, scheme, branch, buildDistributionAudience)` - `get_build_issues(buildRunId? workflowId? buildNumber? buildSelector?)` - `get_build_logs(buildRunId? workflowId? buildNumber? buildSelector?, maxCharacters?)` - `materialize_build_logs(buildRunId? workflowId? buildNumber? buildSelector?)` @@ -174,7 +175,7 @@ Notes: - `environment` includes repository, `xcodeVersion`, and `macOsVersion` when App Store Connect returns them. - `actions` includes action type, scheme, platform, destination, required-to-pass state, and test-plan details when present. -- `postActions` is currently returned as an empty array with a note because the App Store Connect workflow payload does not expose separate post-actions in the observed API response. +- `postActions` is currently returned as an empty array with a note because Apple does not expose workflow post-actions in its public API. This does not mean no post-actions are configured; `testFlightDistribution` reports `UNSUPPORTED_BY_APPLE_API` and an actionable next step. ## Workflow Update Behavior @@ -210,3 +211,30 @@ Build the package: ```bash npm run build ``` + + +### Manual TestFlight release candidates + +`configure_manual_release_candidate` converts an existing workflow to one macOS archive action (`ARCHIVE`, `MACOS`, `ANY_MAC`) with manual starts from an exact branch. It replaces the entire action list and all seven start conditions in one PATCH, clearing automatic branch, pull-request, tag, and scheduled starts plus manual tag and pull-request starts. Fetch `get_workflow_details` first if you need to retain the original configuration. The preset preserves the workflow's enabled state, name, environment, and clean-build setting; enable a disabled workflow separately when ready. + +```json +{ + "workflowId": "abc123", + "scheme": "Headroom", + "branch": "main", + "buildDistributionAudience": "APP_STORE_ELIGIBLE" +} +``` + +The audience is required and cannot be null in this preset: + +- `APP_STORE_ELIGIBLE`: Deployment Preparation = TestFlight and App Store. +- `INTERNAL_ONLY`: Deployment Preparation = TestFlight internal testing only. + +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. + +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. diff --git a/package-lock.json b/package-lock.json index 0af5914..efc9c1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thatfactory/xcode-cloud-mcp", - "version": "0.5.3", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thatfactory/xcode-cloud-mcp", - "version": "0.5.3", + "version": "0.6.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", diff --git a/package.json b/package.json index 7f211f0..4fe88b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thatfactory/xcode-cloud-mcp", - "version": "0.5.3", + "version": "0.6.0", "description": "Minimal MCP server for discovering Xcode Cloud workflows and retrieving build logs, issues, and test artifacts.", "license": "MIT", "type": "module", diff --git a/src/api/types.ts b/src/api/types.ts index 345b670..5e23629 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -89,9 +89,11 @@ export interface CiWorkflow { /** * Xcode Cloud workflow action. */ +export type BuildAudienceType = 'INTERNAL_ONLY' | 'APP_STORE_ELIGIBLE'; + export interface CiWorkflowAction { actionType: string; - buildDistributionAudience?: string | null; + buildDistributionAudience?: BuildAudienceType | null; destination?: string | null; isRequiredToPass?: boolean | null; name: string; diff --git a/src/index.ts b/src/index.ts index c5ec599..abe29a4 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.5.3', + version: '0.6.0', }); registerDiscoveryTools(server, client); diff --git a/src/tools/workflow-updates.ts b/src/tools/workflow-updates.ts index cb2036e..e4b135c 100644 --- a/src/tools/workflow-updates.ts +++ b/src/tools/workflow-updates.ts @@ -8,11 +8,20 @@ import { formatWorkflowDetailsResponse } from '../utils/workflow-details.js'; const conditionSchema = z.record(z.string(), z.unknown()).nullable(); +const audienceSchema = z.enum(['INTERNAL_ONLY', 'APP_STORE_ELIGIBLE']); + +const releaseCandidateSchema = z.object({ + workflowId: z.string(), + scheme: z.string().trim().min(1), + branch: z.string().trim().min(1), + buildDistributionAudience: audienceSchema, +}).strict(); + const actionSchema = z.object({ name: z.string(), actionType: z.string(), destination: z.string().nullable().optional(), - buildDistributionAudience: z.string().nullable().optional(), + buildDistributionAudience: audienceSchema.nullable().optional(), testConfiguration: z .object({ kind: z.string().nullable().optional(), @@ -36,6 +45,52 @@ export function registerWorkflowUpdateTools( server: McpServer, client: AppStoreConnectClient, ): void { + server.registerTool( + 'configure_manual_release_candidate', + { + description: + 'Replace all actions with one macOS archive and all start conditions with manual builds from one exact branch. Requires TestFlight Deployment Preparation. Preserves enabled state. Does not configure tester-group post-actions; use Xcode or App Store Connect for those.', + inputSchema: releaseCandidateSchema.shape, + }, + async (arguments_) => { + try { + const { workflowId, scheme, branch, buildDistributionAudience } = + releaseCandidateSchema.parse(arguments_); + const workflowIdentifier = parseIdentifier(workflowId, 'workflow'); + await client.workflows.updateById(workflowIdentifier, { + branchStartCondition: null, + pullRequestStartCondition: null, + scheduledStartCondition: null, + tagStartCondition: null, + manualPullRequestStartCondition: null, + manualTagStartCondition: null, + manualBranchStartCondition: { + source: { + isAllMatch: false, + patterns: [{ pattern: branch, isPrefix: false }], + }, + }, + actions: [{ + name: 'Release Candidate', + actionType: 'ARCHIVE', + platform: 'MACOS', + destination: 'ANY_MAC', + scheme, + buildDistributionAudience, + isRequiredToPass: true, + }], + }); + const updated = await client.workflows.getById(workflowIdentifier); + return jsonResponse({ + operation: { applied: true, type: 'configure_manual_release_candidate' }, + ...formatWorkflowDetailsResponse(updated.workflow, updated.included), + }); + } catch (error) { + return errorResponse(error); + } + }, + ); + server.registerTool( 'set_workflow_enabled', { @@ -247,7 +302,7 @@ export function registerWorkflowUpdateTools( const workflowIdentifier = parseIdentifier(workflowId, 'workflow'); await client.workflows.updateActions( workflowIdentifier, - actions.map(normalizeAction), + z.array(actionSchema).parse(actions).map(normalizeAction), ); const updated = await client.workflows.getById(workflowIdentifier); diff --git a/src/utils/workflow-details.ts b/src/utils/workflow-details.ts index f3bd954..6a107ff 100644 --- a/src/utils/workflow-details.ts +++ b/src/utils/workflow-details.ts @@ -84,8 +84,13 @@ export function formatWorkflowDetailsResponse( }, actions: actions.map(formatWorkflowAction), postActions: [], + testFlightDistribution: { + status: 'UNSUPPORTED_BY_APPLE_API', + automaticTesterGroupAssignment: 'UNKNOWN', + nextAction: 'In Xcode or App Store Connect, edit the workflow and add a TestFlight Internal Testing post-action, then select the internal tester group. Archive eligibility alone does not assign builds to testers.', + }, postActionsNote: - 'The App Store Connect workflow payload did not expose separate post-actions, so this field is empty unless Apple adds that data.', + 'Post-actions are not exposed by Apple API; the empty list does not mean none are configured. Configure a TestFlight internal-group post-action in Xcode or App Store Connect.', }, }; } @@ -113,6 +118,12 @@ function formatWorkflowAction(action: CiWorkflowAction) { scheme: action.scheme ?? null, destination: action.destination ?? null, buildDistributionAudience: action.buildDistributionAudience ?? null, + deploymentPreparation: + action.buildDistributionAudience === 'INTERNAL_ONLY' + ? 'TestFlight internal testing only' + : action.buildDistributionAudience === 'APP_STORE_ELIGIBLE' + ? 'TestFlight and App Store' + : action.buildDistributionAudience == null ? 'None' : 'Unknown', isRequiredToPass: action.isRequiredToPass ?? null, testConfiguration: action.testConfiguration ? { diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index cc5592a..ba1dc7a 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -33,6 +33,7 @@ test('server starts over stdio and exposes the expected tools', async () => { assert.deepEqual(toolNames, [ 'cleanup_saved_logs', + 'configure_manual_release_candidate', 'get_build_issues', 'get_build_logs', 'get_failed_tests', @@ -49,5 +50,16 @@ test('server starts over stdio and exposes the expected tools', async () => { 'update_workflow_start_conditions', ]); + const invalidPreset = await client.callTool({ + name: 'configure_manual_release_candidate', + arguments: { workflowId: 'workflow-1', scheme: 'App', branch: 'main', buildDistributionAudience: null }, + }); + assert.equal(invalidPreset.isError, true); + const invalidAction = await client.callTool({ + name: 'update_workflow_actions', + arguments: { workflowId: 'workflow-1', actions: [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: 'TYPO' }] }, + }); + assert.equal(invalidAction.isError, true); + await client.close(); }); diff --git a/tests/workflow-release.test.ts b/tests/workflow-release.test.ts new file mode 100644 index 0000000..4ea339d --- /dev/null +++ b/tests/workflow-release.test.ts @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { AppStoreConnectClient } from '../src/api/client.js'; +import type { CiWorkflow } from '../src/api/types.js'; +import { registerWorkflowUpdateTools } from '../src/tools/workflow-updates.js'; +import { formatWorkflowDetailsResponse } from '../src/utils/workflow-details.js'; + +function fixture() { + const workflow: CiWorkflow = { + id: 'workflow-1', type: 'ciWorkflows', + attributes: { name: 'Existing', isEnabled: false, clean: false, + containerFilePath: 'App.xcodeproj', lastModifiedDate: null, + branchStartCondition: { source: { isAllMatch: true } }, + pullRequestStartCondition: {}, scheduledStartCondition: {}, tagStartCondition: {}, + manualPullRequestStartCondition: {}, manualTagStartCondition: {}, + actions: [{ name: 'Tests', actionType: 'TEST' }], + }, + }; + const writes: any[] = []; + const registry = new Map Promise }>(); + const server = { registerTool(name: string, config: any, handler: any) { + registry.set(name, { schema: z.object(config.inputSchema), handler }); + }}; + const client = { workflows: { + async updateById(id: string, attributes: any) { + writes.push({ id, attributes }); Object.assign(workflow.attributes, attributes); return workflow; + }, + async updateActions(id: string, actions: any) { writes.push({ id, actions }); }, + async getById() { return { workflow, included: [] }; }, + }}; + registerWorkflowUpdateTools(server as unknown as McpServer, client as unknown as AppStoreConnectClient); + return { workflow, writes, registry }; +} + +for (const audience of ['APP_STORE_ELIGIBLE', 'INTERNAL_ONLY']) { + test(`manual archive preset uses ${audience} and clears every other trigger in one update`, async () => { + const { registry, workflow, writes } = fixture(); + const tool = registry.get('configure_manual_release_candidate')!; + const result = await tool.handler(tool.schema.parse({ workflowId: workflow.id, + scheme: 'Headroom', branch: 'main', buildDistributionAudience: audience })); + assert.notEqual(result.isError, true); + assert.equal(writes.length, 1); + assert.equal(writes[0].id, workflow.id); + const a = writes[0].attributes; + for (const key of ['branchStartCondition', 'pullRequestStartCondition', 'scheduledStartCondition', + 'tagStartCondition', 'manualPullRequestStartCondition', 'manualTagStartCondition']) assert.equal(a[key], null); + assert.deepEqual(a.manualBranchStartCondition, { source: { isAllMatch: false, + patterns: [{ pattern: 'main', isPrefix: false }] } }); + assert.deepEqual(a.actions, [{ name: 'Release Candidate', actionType: 'ARCHIVE', + platform: 'MACOS', destination: 'ANY_MAC', scheme: 'Headroom', + buildDistributionAudience: audience, isRequiredToPass: true }]); + assert.equal(workflow.attributes.isEnabled, false); + assert.equal(workflow.attributes.name, 'Existing'); + const payload = JSON.parse(result.content[0].text); + assert.equal(payload.workflow.actions[0].deploymentPreparation, audience === 'INTERNAL_ONLY' + ? 'TestFlight internal testing only' : 'TestFlight and App Store'); + assert.equal(payload.workflow.testFlightDistribution.status, 'UNSUPPORTED_BY_APPLE_API'); + assert.equal(payload.workflow.testFlightDistribution.automaticTesterGroupAssignment, 'UNKNOWN'); + }); +} + +test('release candidate rejects missing, null, invalid audience and blank scheme/branch before writes', async () => { + const { registry, writes } = fixture(); + const tool = registry.get('configure_manual_release_candidate')!; + const valid = { workflowId: 'workflow-1', scheme: 'App', branch: 'main', buildDistributionAudience: 'INTERNAL_ONLY' }; + for (const change of [{ buildDistributionAudience: null }, { buildDistributionAudience: undefined }, + { buildDistributionAudience: 'TESTFLIGHT' }, { scheme: ' ' }, { branch: '' }]) { + const args = { ...valid, ...change }; + assert.equal(tool.schema.safeParse(args).success, false); + assert.equal((await tool.handler(args)).isError, true); + } + assert.equal(writes.length, 0); +}); + +test('general action edits accept nullable enum but reject arbitrary audiences', async () => { + const { registry, writes } = fixture(); + const tool = registry.get('update_workflow_actions')!; + for (const audience of [null, undefined, 'INTERNAL_ONLY', 'APP_STORE_ELIGIBLE']) { + const args = { workflowId: 'workflow-1', actions: [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: audience }] }; + assert.equal(tool.schema.safeParse(args).success, true); + assert.notEqual((await tool.handler(args)).isError, true); + } + const invalid = { workflowId: 'workflow-1', actions: [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: 'TYPO' }] }; + assert.equal(tool.schema.safeParse(invalid).success, false); + assert.equal((await tool.handler(invalid)).isError, true); + assert.equal(writes.length, 4); +}); + +test('read-back reports None for omitted/null preparation and Unknown for future values', () => { + const { workflow } = fixture(); + for (const audience of [null, undefined, 'FUTURE']) { + workflow.attributes.actions = [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: audience as any }]; + const result = formatWorkflowDetailsResponse(workflow, []); + assert.equal(result.workflow.actions[0].deploymentPreparation, audience === 'FUTURE' ? 'Unknown' : 'None'); + } +});