From c0a6957a640b39740a58c6bba7e57adbfb2fb6f7 Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Thu, 10 Sep 2026 11:51:46 +0100 Subject: [PATCH 1/2] fix: sync-status reports success for runs that did not succeed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync-status only fails on a `failure` or `cancelled` conclusion, so every other non-success outcome sets the step to success: - `timed_out` — the triggered run hit a job timeout - `startup_failure` — the run never started (e.g. an invalid workflow file) - `stale` — the run was discarded before it could report - a `null` conclusion, which is what the API returns whenever the run has not completed, including after `wait-for-completion` gives up at its own timeout The last case is the easiest to hit: the wait loop exits on its timeout, warns, and then sync-status reads a `null` conclusion and passes the step — so a job that waited the full timeout reports success while the run it triggered is still going. Fail on the conclusions that unambiguously mean the run did not succeed, and on any run that has not reached `completed`. `neutral`, `skipped` and `action_required` keep passing as before, so runs that deliberately end without doing work are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- dist/index.js | 12 +++++++++++- src/main.ts | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/dist/index.js b/dist/index.js index 7f07ab0..05eb659 100644 --- a/dist/index.js +++ b/dist/index.js @@ -40787,6 +40787,7 @@ var version = "1.3.2"; // src/main.ts var API_VERSION = "2026-03-10"; +var FAILED_CONCLUSIONS = /* @__PURE__ */ new Set(["failure", "cancelled", "timed_out", "startup_failure", "stale"]); async function run() { info(`\u{1F3C3} Workflow Dispatch Action v${version}`); try { @@ -40878,11 +40879,20 @@ Note: The workflow is still running but we have stopped waiting. You can check t headers: { "x-github-api-version": API_VERSION } } ); + const runStatusNow = finalRunData.status; const conclusion = finalRunData.conclusion; - if (conclusion === "failure") { + if (runStatusNow !== "completed") { + setFailed( + `Workflow run did not complete (status: ${runStatusNow}). Check the run details here: ${dispatchResp.data.html_url}` + ); + } else if (conclusion === "failure") { setFailed(`Workflow run failed. Check the run details here: ${dispatchResp.data.html_url}`); } else if (conclusion === "cancelled") { setFailed(`Workflow run was cancelled. Check the run details here: ${dispatchResp.data.html_url}`); + } else if (FAILED_CONCLUSIONS.has(String(conclusion))) { + setFailed( + `Workflow run concluded '${conclusion}'. Check the run details here: ${dispatchResp.data.html_url}` + ); } else { info(`\u{1F389} Workflow conclusion: ${conclusion}`); } diff --git a/src/main.ts b/src/main.ts index afe4f50..c55815a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,10 @@ import * as PackageJSON from '../package.json' const API_VERSION = '2026-03-10' // Latest API version as of March 2026, update as needed +// Conclusions that unambiguously mean the run did not succeed. `neutral`, +// `skipped` and `action_required` are left passing, as before. +const FAILED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'stale']) + type Workflow = { id: number name: string @@ -154,13 +158,23 @@ async function run(): Promise { headers: { 'x-github-api-version': API_VERSION }, }, ) + const runStatusNow = finalRunData.status const conclusion = finalRunData.conclusion - // Set this action to failed if the triggered workflow run failed or was cancelled - if (conclusion === 'failure') { + // An incomplete run has no conclusion yet, so passing here would report + // success for work still in flight (e.g. after the wait above timed out). + if (runStatusNow !== 'completed') { + core.setFailed( + `Workflow run did not complete (status: ${runStatusNow}). Check the run details here: ${dispatchResp.data.html_url}`, + ) + } else if (conclusion === 'failure') { core.setFailed(`Workflow run failed. Check the run details here: ${dispatchResp.data.html_url}`) } else if (conclusion === 'cancelled') { core.setFailed(`Workflow run was cancelled. Check the run details here: ${dispatchResp.data.html_url}`) + } else if (FAILED_CONCLUSIONS.has(String(conclusion))) { + core.setFailed( + `Workflow run concluded '${conclusion}'. Check the run details here: ${dispatchResp.data.html_url}`, + ) } else { core.info(`🎉 Workflow conclusion: ${conclusion}`) } From baf293dfe3b354a211b99290f966ffed6c09bfde Mon Sep 17 00:00:00 2001 From: Alec Sammon Date: Thu, 10 Sep 2026 13:50:56 +0100 Subject: [PATCH 2/2] feat: add success-conclusions to control what sync-status accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which conclusions should count as a pass depends on why the run was triggered. A gate wants only `success`; a caller that triggers optional work may be happy for a `skipped` run to pass. Hard-coding either choice is wrong for the other. `success-conclusions` takes a comma-separated list of conclusions to treat as a pass, defaulting to the conclusions that pass today, so behaviour is unchanged unless it is set. Setting it to `success` makes the action green only on a genuine pass. The value is validated against the conclusions the API can return, and an unrecognised entry fails before the workflow is dispatched — a typo would otherwise silently widen or narrow what counts as a pass, changing the verdict with no signal. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 19 ++++++++++++++++++- action.yaml | 4 ++++ dist/index.js | 28 ++++++++++++++++++++++++---- src/main.ts | 40 ++++++++++++++++++++++++++++++++++------ 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index dea0555..b26ab4d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,24 @@ This option is also left for backwards compatibility with older versions where t ### `sync-status` -**Optional.** Set to `'true'` to sync the status of this action with the triggered workflow run. If the triggered workflow run fails or is cancelled, this action will also be set to failed. This only applies if `wait-for-completion` is set to `true`. Default is `false`. +**Optional.** Set to `'true'` to sync the status of this action with the triggered workflow run. If the triggered workflow run does not succeed, this action will also be set to failed. This only applies if `wait-for-completion` is set to `true`. Default is `false`. + +### `success-conclusions` + +**Optional.** Comma-separated list of run conclusions that `sync-status` should treat as a pass. Anything else fails this action, as does a run that never reaches `completed`. This only applies if `sync-status` is set to `true`. Default is `success,neutral,skipped,action_required`. + +Set it to `success` when the triggered run is a gate and only a genuine pass should go green: + +```yaml +- uses: step-security/workflow-dispatch@v1 + with: + workflow: e2e.yml + wait-for-completion: 'true' + sync-status: 'true' + success-conclusions: success +``` + +Valid values are `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out`, `action_required`, `stale` and `startup_failure`. An unrecognised value fails the action before the workflow is dispatched. ## Action Outputs diff --git a/action.yaml b/action.yaml index 5813872..2189819 100644 --- a/action.yaml +++ b/action.yaml @@ -35,6 +35,10 @@ inputs: description: 'Whether to set the status of this action to failed if the triggered workflow run fails, or is cancelled. Only applies if wait-for-completion is true.' required: false default: false + success-conclusions: + description: 'Comma-separated run conclusions that sync-status should treat as a pass. Set to "success" to fail on anything else. Only applies if sync-status is true. Valid values: success, failure, neutral, cancelled, skipped, timed_out, action_required, stale, startup_failure.' + required: false + default: 'success,neutral,skipped,action_required' outputs: runId: diff --git a/dist/index.js b/dist/index.js index 05eb659..9281535 100644 --- a/dist/index.js +++ b/dist/index.js @@ -40787,12 +40787,32 @@ var version = "1.3.2"; // src/main.ts var API_VERSION = "2026-03-10"; -var FAILED_CONCLUSIONS = /* @__PURE__ */ new Set(["failure", "cancelled", "timed_out", "startup_failure", "stale"]); +var DEFAULT_SUCCESS_CONCLUSIONS = "success,neutral,skipped,action_required"; +var KNOWN_CONCLUSIONS = /* @__PURE__ */ new Set([ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" +]); async function run() { info(`\u{1F3C3} Workflow Dispatch Action v${version}`); try { await validateSubscription(); const workflowRef = getInput("workflow"); + const successConclusions = new Set( + (getInput("success-conclusions") || DEFAULT_SUCCESS_CONCLUSIONS).split(",").map((c) => c.trim().toLowerCase()).filter((c) => c.length > 0) + ); + const unknownConclusions = [...successConclusions].filter((c) => !KNOWN_CONCLUSIONS.has(c)); + if (unknownConclusions.length > 0) { + throw new Error( + `Invalid 'success-conclusions' value(s): ${unknownConclusions.join(", ")}. Valid values: ${[...KNOWN_CONCLUSIONS].join(", ")}` + ); + } const token = getInput("token"); const ref = getInput("ref"); const [owner, repo] = getInput("repo") ? getInput("repo").split("/") : [context2.repo.owner, context2.repo.repo]; @@ -40885,16 +40905,16 @@ Note: The workflow is still running but we have stopped waiting. You can check t setFailed( `Workflow run did not complete (status: ${runStatusNow}). Check the run details here: ${dispatchResp.data.html_url}` ); + } else if (successConclusions.has(String(conclusion))) { + info(`\u{1F389} Workflow conclusion: ${conclusion}`); } else if (conclusion === "failure") { setFailed(`Workflow run failed. Check the run details here: ${dispatchResp.data.html_url}`); } else if (conclusion === "cancelled") { setFailed(`Workflow run was cancelled. Check the run details here: ${dispatchResp.data.html_url}`); - } else if (FAILED_CONCLUSIONS.has(String(conclusion))) { + } else { setFailed( `Workflow run concluded '${conclusion}'. Check the run details here: ${dispatchResp.data.html_url}` ); - } else { - info(`\u{1F389} Workflow conclusion: ${conclusion}`); } } } catch (error2) { diff --git a/src/main.ts b/src/main.ts index c55815a..a5f4ba9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,9 +14,22 @@ import * as PackageJSON from '../package.json' const API_VERSION = '2026-03-10' // Latest API version as of March 2026, update as needed -// Conclusions that unambiguously mean the run did not succeed. `neutral`, -// `skipped` and `action_required` are left passing, as before. -const FAILED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'stale']) +// Conclusions `sync-status` treats as a pass unless `success-conclusions` says +// otherwise. The default keeps runs that deliberately end without doing work +// passing, as they always have. +const DEFAULT_SUCCESS_CONCLUSIONS = 'success,neutral,skipped,action_required' + +const KNOWN_CONCLUSIONS = new Set([ + 'success', + 'failure', + 'neutral', + 'cancelled', + 'skipped', + 'timed_out', + 'action_required', + 'stale', + 'startup_failure', +]) type Workflow = { id: number @@ -35,6 +48,21 @@ async function run(): Promise { // Required inputs const workflowRef = core.getInput('workflow') + // Read and validate before dispatching: a typo here changes the verdict, so + // failing now beats triggering a run we then refuse to judge. + const successConclusions = new Set( + (core.getInput('success-conclusions') || DEFAULT_SUCCESS_CONCLUSIONS) + .split(',') + .map((c) => c.trim().toLowerCase()) + .filter((c) => c.length > 0), + ) + const unknownConclusions = [...successConclusions].filter((c) => !KNOWN_CONCLUSIONS.has(c)) + if (unknownConclusions.length > 0) { + throw new Error( + `Invalid 'success-conclusions' value(s): ${unknownConclusions.join(', ')}. Valid values: ${[...KNOWN_CONCLUSIONS].join(', ')}`, + ) + } + // Optional inputs, with defaults const token = core.getInput('token') const ref = core.getInput('ref') @@ -167,16 +195,16 @@ async function run(): Promise { core.setFailed( `Workflow run did not complete (status: ${runStatusNow}). Check the run details here: ${dispatchResp.data.html_url}`, ) + } else if (successConclusions.has(String(conclusion))) { + core.info(`🎉 Workflow conclusion: ${conclusion}`) } else if (conclusion === 'failure') { core.setFailed(`Workflow run failed. Check the run details here: ${dispatchResp.data.html_url}`) } else if (conclusion === 'cancelled') { core.setFailed(`Workflow run was cancelled. Check the run details here: ${dispatchResp.data.html_url}`) - } else if (FAILED_CONCLUSIONS.has(String(conclusion))) { + } else { core.setFailed( `Workflow run concluded '${conclusion}'. Check the run details here: ${dispatchResp.data.html_url}`, ) - } else { - core.info(`🎉 Workflow conclusion: ${conclusion}`) } } } catch (error) {