From 801d1ca5370593d7834aad6c3c7b0a6f40659958 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:17:31 +0100 Subject: [PATCH] feat: add check-name so each run posts its own PR check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PR that submits twice — iOS and Android, say — got two GitHub checks with the same name. Nothing in the PR list tells them apart, and because GitHub matches required checks by name, branch protection can only hold one entry for both: the gate follows whichever run finished last, so a green Android can clear what a red iOS should have held. check-name rides the existing github-context metadata as gh_check_name; the backend appends it to the base name, giving "DeviceCloud / iOS". Unset, the check name is unchanged, so no existing branch-protection rule moves. Warns when check-name is set with include-github-context off — there is no commit context then, so no check is posted at all and the input would otherwise look like it had done something. dist is rebuilt here because CI only builds it on main, and the e2e runs the action from this branch. --- README.md | 28 ++++++++++++++++++++++++++++ action.yml | 3 +++ dist/index.js | 36 ++++++++++++++++++++++++++++-------- src/methods/params.test.ts | 29 ++++++++++++++++++++++++++++- src/methods/params.ts | 25 +++++++++++++++++++++++-- 5 files changed, 110 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8a2587e..eb0cd93 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,34 @@ button. Make it a required status check in branch protection to block merges on failures. No `permissions: checks: write` is needed in your workflow; the App posts the check. +### One check per job + +If a PR runs more than once — iOS and Android as separate jobs, say — give each +run a `check-name` so they don't both post a check with the same name: + +```yaml +- uses: devicecloud-dev/device-cloud-for-maestro@v2 + with: + api-key: ${{ secrets.DCD_API_KEY }} + app-file: build/app.ipa + async: true + check-name: iOS # -> a check called "DeviceCloud / iOS" + +- uses: devicecloud-dev/device-cloud-for-maestro@v2 + with: + api-key: ${{ secrets.DCD_API_KEY }} + app-file: build/app.apk + async: true + check-name: Android # -> a check called "DeviceCloud / Android" +``` + +Each one can then be required separately in branch protection. Without it both +runs post the same check name, GitHub treats them as one required check, and the +gate follows whichever run finished last — a passing Android run can clear a gate +the failing iOS run should have held. Keep the value fixed for a given job: +GitHub matches required checks by name, so a name that changes per commit can +never be required. + ## Migrating from Maestro Cloud Replace the `uses` line in your workflow: diff --git a/action.yml b/action.yml index 9a79036..705ecb9 100644 --- a/action.yml +++ b/action.yml @@ -110,6 +110,9 @@ inputs: description: 'Automatically attach GitHub/PR context (branch, SHA, PR number, PR URL, run ID, repo) to the test run as metadata. Set to "false" to opt out.' required: false default: 'true' + check-name: + description: 'Name this run''s GitHub check, e.g. "iOS" gives a check called "DeviceCloud / iOS". Use it when a PR runs more than once (iOS and Android, say) so each run gets its own check that can be required separately in branch protection. Keep it the same on every run of a given job.' + required: false outputs: DEVICE_CLOUD_CONSOLE_URL: diff --git a/dist/index.js b/dist/index.js index 7372fa5..494f294 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2216,7 +2216,7 @@ const Context = __importStar(__nccwpck_require__(8663)); const Utils = __importStar(__nccwpck_require__(1365)); // octokit + plugins const core_1 = __nccwpck_require__(6895); -const plugin_rest_endpoint_methods_1 = __nccwpck_require__(9289); +const plugin_rest_endpoint_methods_1 = __nccwpck_require__(6495); const plugin_paginate_rest_1 = __nccwpck_require__(6212); exports.context = new Context.Context(); const baseUrl = Utils.getApiBaseUrl(); @@ -43947,7 +43947,16 @@ function getInferredName() { } return github.context.sha; } -function getGithubContextMetadata() { +/** + * `checkName` names the GitHub check this run posts. It is appended to the + * backend's base name ("DeviceCloud / iOS"), which is what lets a PR that runs + * iOS and Android as two submissions carry two checks that branch protection + * can require separately — GitHub matches required checks by name, so two runs + * sharing one name collapse into a single gate that follows whichever finished + * last. Keep it constant for a given job; a value that varies per commit can + * never be a required check. + */ +function getGithubContextMetadata(checkName) { var _a, _b, _c, _d, _e; const ctx = github.context; const pr = ctx.payload.pull_request; @@ -43963,6 +43972,8 @@ function getGithubContextMetadata() { ]; if (branch) pairs.push(`gh_branch=${branch}`); + if (checkName) + pairs.push(`gh_check_name=${checkName}`); if (pr) { pairs.push(`gh_pr_number=${pr.number}`); if (pr.html_url) @@ -44027,8 +44038,17 @@ function getParameters() { required: false, }); const useBeta = core.getInput('use-beta', { required: false }) === 'true'; + const checkName = core.getInput('check-name', { required: false }).trim(); const includeGithubContext = core.getInput('include-github-context', { required: false }) !== 'false'; - const githubContext = includeGithubContext ? getGithubContextMetadata() : undefined; + const githubContext = includeGithubContext + ? getGithubContextMetadata(checkName) + : undefined; + if (checkName && !includeGithubContext) { + // Without the context there is no sha to post against, so no check at all — + // say so rather than letting the input look like it did something. + core.warning('check-name is ignored because include-github-context is false: with no ' + + 'commit context attached, DeviceCloud posts no check on this run.'); + } const maestroChromeOnboarding = core.getInput('maestro-chrome-onboarding', { required: false }) === 'true'; const androidNoSnapshot = core.getInput('android-no-snapshot', { required: false }) === 'true'; const disableAnimations = core.getInput('disable-animations', { required: false }) === 'true'; @@ -46245,7 +46265,7 @@ paginateRest.VERSION = VERSION; /***/ }), -/***/ 9289: +/***/ 6495: /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __nccwpck_require__) => { "use strict"; @@ -46258,12 +46278,12 @@ __nccwpck_require__.d(__webpack_exports__, { restEndpointMethods: () => (/* binding */ restEndpointMethods) }); -;// CONCATENATED MODULE: ./node_modules/.pnpm/@octokit+plugin-rest-endpoint-methods@17.0.0_@octokit+core@7.0.6/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/version.js +;// CONCATENATED MODULE: ./node_modules/.pnpm/@octokit+plugin-rest-endpoi_88f1cfdccbcd12f9bd89a662a3d08bce/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/version.js const VERSION = "17.0.0"; //# sourceMappingURL=version.js.map -;// CONCATENATED MODULE: ./node_modules/.pnpm/@octokit+plugin-rest-endpoint-methods@17.0.0_@octokit+core@7.0.6/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/generated/endpoints.js +;// CONCATENATED MODULE: ./node_modules/.pnpm/@octokit+plugin-rest-endpoi_88f1cfdccbcd12f9bd89a662a3d08bce/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/generated/endpoints.js const Endpoints = { actions: { addCustomLabelsToSelfHostedRunnerForOrg: [ @@ -48557,7 +48577,7 @@ var endpoints_default = Endpoints; //# sourceMappingURL=endpoints.js.map -;// CONCATENATED MODULE: ./node_modules/.pnpm/@octokit+plugin-rest-endpoint-methods@17.0.0_@octokit+core@7.0.6/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/endpoints-to-methods.js +;// CONCATENATED MODULE: ./node_modules/.pnpm/@octokit+plugin-rest-endpoi_88f1cfdccbcd12f9bd89a662a3d08bce/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/endpoints-to-methods.js const endpointMethodsMap = /* @__PURE__ */ new Map(); for (const [scope, endpoints] of Object.entries(endpoints_default)) { @@ -48683,7 +48703,7 @@ function decorate(octokit, scope, methodName, defaults, decorations) { //# sourceMappingURL=endpoints-to-methods.js.map -;// CONCATENATED MODULE: ./node_modules/.pnpm/@octokit+plugin-rest-endpoint-methods@17.0.0_@octokit+core@7.0.6/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/index.js +;// CONCATENATED MODULE: ./node_modules/.pnpm/@octokit+plugin-rest-endpoi_88f1cfdccbcd12f9bd89a662a3d08bce/node_modules/@octokit/plugin-rest-endpoint-methods/dist-src/index.js function restEndpointMethods(octokit) { diff --git a/src/methods/params.test.ts b/src/methods/params.test.ts index 91dd592..afcca5b 100644 --- a/src/methods/params.test.ts +++ b/src/methods/params.test.ts @@ -2,12 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Drive getParameters() through mocked GitHub Actions SDKs. `inputs` is the // per-test action-input map; getInput/getMultilineInput read from it. -const { inputs } = vi.hoisted(() => ({ inputs: {} as Record })); +const { inputs, warnings } = vi.hoisted(() => ({ + inputs: {} as Record, + warnings: [] as string[], +})); vi.mock('@actions/core', () => ({ getInput: (name: string) => inputs[name] ?? '', getMultilineInput: (name: string) => inputs[name] ? inputs[name].split('\n').filter((l) => l.trim() !== '') : [], + warning: (message: string) => warnings.push(message), })); vi.mock('@actions/github', () => ({ @@ -25,6 +29,7 @@ import { getParameters } from './params'; beforeEach(() => { for (const k of Object.keys(inputs)) delete inputs[k]; + warnings.length = 0; // A valid baseline: api key + exactly one app source. inputs['api-key'] = 'k'; inputs['app-file'] = 'app.apk'; @@ -131,6 +136,28 @@ describe('getParameters', () => { expect(withoutCtx.githubContext).toBeUndefined(); }); + it('sends check-name so a PR can carry one check per job', async () => { + // Two runs on one commit otherwise post two identically named checks, which + // branch protection can only gate as a single entry. + const withoutName = await getParameters(); + expect( + withoutName.githubContext?.some((p) => p.startsWith('gh_check_name=')) + ).toBe(false); + + inputs['check-name'] = ' iOS smoke '; + const withName = await getParameters(); + expect(withName.githubContext).toContain('gh_check_name=iOS smoke'); + }); + + it('warns rather than silently dropping check-name when context is off', async () => { + inputs['check-name'] = 'iOS'; + inputs['include-github-context'] = 'false'; + + await getParameters(); + + expect(warnings.join('\n')).toContain('check-name is ignored'); + }); + it('uses the PR head sha (not the merge sha) on pull_request events', async () => { // On pull_request events github.context.sha is a throwaway merge commit; the // metadata (and the GitHub check the backend posts) must use the head sha. diff --git a/src/methods/params.ts b/src/methods/params.ts index 2100fb4..be017b5 100644 --- a/src/methods/params.ts +++ b/src/methods/params.ts @@ -91,7 +91,16 @@ function getInferredName(): string { return github.context.sha; } -function getGithubContextMetadata(): string[] { +/** + * `checkName` names the GitHub check this run posts. It is appended to the + * backend's base name ("DeviceCloud / iOS"), which is what lets a PR that runs + * iOS and Android as two submissions carry two checks that branch protection + * can require separately — GitHub matches required checks by name, so two runs + * sharing one name collapse into a single gate that follows whichever finished + * last. Keep it constant for a given job; a value that varies per commit can + * never be a required check. + */ +function getGithubContextMetadata(checkName?: string): string[] { const ctx = github.context; const pr = ctx.payload.pull_request; @@ -109,6 +118,7 @@ function getGithubContextMetadata(): string[] { ]; if (branch) pairs.push(`gh_branch=${branch}`); + if (checkName) pairs.push(`gh_check_name=${checkName}`); if (pr) { pairs.push(`gh_pr_number=${pr.number}`); if (pr.html_url) pairs.push(`gh_pr_url=${pr.html_url}`); @@ -206,9 +216,20 @@ export async function getParameters(): Promise { }); const useBeta = core.getInput('use-beta', { required: false }) === 'true'; + const checkName = core.getInput('check-name', { required: false }).trim(); const includeGithubContext = core.getInput('include-github-context', { required: false }) !== 'false'; - const githubContext = includeGithubContext ? getGithubContextMetadata() : undefined; + const githubContext = includeGithubContext + ? getGithubContextMetadata(checkName) + : undefined; + if (checkName && !includeGithubContext) { + // Without the context there is no sha to post against, so no check at all — + // say so rather than letting the input look like it did something. + core.warning( + 'check-name is ignored because include-github-context is false: with no ' + + 'commit context attached, DeviceCloud posts no check on this run.' + ); + } const maestroChromeOnboarding = core.getInput('maestro-chrome-onboarding', { required: false }) === 'true'; const androidNoSnapshot = core.getInput('android-no-snapshot', { required: false }) === 'true';