From d966df468fbaf52ec076b95e48ba16cd01b088d1 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 11 Sep 2026 14:24:42 +0530 Subject: [PATCH 1/4] fix: add success-conclusions as an optional input to control what sync-status accepts --- action.yaml | 4 ++++ dist/index.js | 48 +++++++++++++++++++++++++++++++++++---- src/main.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/action.yaml b/action.yaml index 5813872..588338a 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: 'Optional comma-separated run conclusions that sync-status treats as a pass. When set, anything not in this list fails. When not set, old behaviour is preserved (only failure and cancelled fail). Valid values: success, failure, neutral, cancelled, skipped, timed_out, action_required, stale, startup_failure.' + required: false + default: '' outputs: runId: diff --git a/dist/index.js b/dist/index.js index 7f07ab0..ad4f985 100644 --- a/dist/index.js +++ b/dist/index.js @@ -40787,6 +40787,17 @@ var version = "1.3.2"; // src/main.ts var API_VERSION = "2026-03-10"; +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 { @@ -40834,6 +40845,18 @@ async function run() { info(`\u{1F310} Run URL: ${dispatchResp.data.html_url}`); const waitForCompletion = getInput("wait-for-completion") === "true"; const syncStatus = getInput("sync-status") === "true"; + const configuredSuccessConclusions = getInput("success-conclusions"); + const successConclusions = configuredSuccessConclusions ? new Set( + configuredSuccessConclusions.split(",").map((c) => c.trim().toLowerCase()).filter(Boolean) + ) : null; + if (successConclusions) { + 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 timeoutSeconds = parseInt(getInput("wait-timeout-seconds") || "900", 10); const waitIntervalSeconds = parseInt(getInput("wait-interval-seconds") || "5", 10); let runStatus = "in_progress"; @@ -40878,13 +40901,28 @@ 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") { - 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}`); + if (runStatusNow !== "completed") { + setFailed( + `Workflow run did not complete (status: ${runStatusNow}). Check the run details here: ${dispatchResp.data.html_url}` + ); + } else if (successConclusions) { + if (conclusion && successConclusions.has(conclusion)) { + info(`\u{1F389} Workflow conclusion: ${conclusion}`); + } else { + setFailed( + `Workflow run concluded '${conclusion}'. Check the run details here: ${dispatchResp.data.html_url}` + ); + } } else { - info(`\u{1F389} Workflow conclusion: ${conclusion}`); + 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 { + info(`\u{1F389} Workflow conclusion: ${conclusion}`); + } } } } catch (error2) { diff --git a/src/main.ts b/src/main.ts index afe4f50..2953f16 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,18 @@ import * as PackageJSON from '../package.json' const API_VERSION = '2026-03-10' // Latest API version as of March 2026, update as needed +const KNOWN_CONCLUSIONS = new Set([ + 'success', + 'failure', + 'neutral', + 'cancelled', + 'skipped', + 'timed_out', + 'action_required', + 'stale', + 'startup_failure', +]) + type Workflow = { id: number name: string @@ -97,6 +109,27 @@ async function run(): Promise { // Handle wait for completion const waitForCompletion = core.getInput('wait-for-completion') === 'true' const syncStatus = core.getInput('sync-status') === 'true' + + // Read and validate success-conclusions before dispatching — a typo changes + // the verdict, so failing now beats triggering a run we then refuse to judge. + // If not set, successConclusions is null and old behaviour is preserved exactly. + const configuredSuccessConclusions = core.getInput('success-conclusions') + const successConclusions = configuredSuccessConclusions + ? new Set( + configuredSuccessConclusions + .split(',') + .map((c) => c.trim().toLowerCase()) + .filter(Boolean), + ) + : null + if (successConclusions) { + 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 timeoutSeconds = parseInt(core.getInput('wait-timeout-seconds') || '900', 10) // Default to 15 minutes const waitIntervalSeconds = parseInt(core.getInput('wait-interval-seconds') || '5', 10) // Default to 5 seconds let runStatus = 'in_progress' @@ -154,15 +187,33 @@ 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') { - 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}`) + // 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 (successConclusions) { + // New configurable behaviour — only when success-conclusions is explicitly set + if (conclusion && successConclusions.has(conclusion)) { + core.info(`🎉 Workflow conclusion: ${conclusion}`) + } else { + core.setFailed( + `Workflow run concluded '${conclusion}'. Check the run details here: ${dispatchResp.data.html_url}`, + ) + } } else { - core.info(`🎉 Workflow conclusion: ${conclusion}`) + // Old behaviour preserved exactly — only failure and cancelled fail + 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 { + core.info(`🎉 Workflow conclusion: ${conclusion}`) + } } } } catch (error) { From 4472e911db8057dfb44ac3cab0180702ddacd69e Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 11 Sep 2026 14:52:20 +0530 Subject: [PATCH 2/4] workflow tests updated workflow tests updated workflow tests updated --- .github/workflows/build-test.yaml | 155 ++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index ba9ba7a..81bf6dd 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -284,3 +284,158 @@ jobs: wait-for-completion: true wait-timeout-seconds: 1 ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + + # =========================================================================== + # success-conclusions input tests + # =========================================================================== + test-sync-status-strict-success: + name: 'Test: Sync status, strict, run succeeds' + needs: build + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # success-conclusions: success. Run concludes success → in list → passes. + - name: Dispatch echo-1, success-conclusions=success — should pass + uses: ./ + with: + workflow: echo-1.yaml + inputs: '{"message": "strict success test"}' + wait-for-completion: true + sync-status: true + success-conclusions: success + ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + + test-sync-status-strict-failure: + name: 'Test: Sync status, strict, run fails' + needs: build + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # success-conclusions: success. Run concludes failure → not in list → fails. + - name: Dispatch echo-3 (exits 1), success-conclusions=success — should fail + id: dispatch + continue-on-error: true + uses: ./ + with: + workflow: echo-3.yaml + wait-for-completion: true + sync-status: true + success-conclusions: success + ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + + - name: Assert action failed + run: | + if [[ "${{ steps.dispatch.outcome }}" != "failure" ]]; then + echo "FAIL: Expected action to fail when run fails with strict success-conclusions" + exit 1 + fi + echo "PASS: Action correctly failed" + + test-sync-status-invalid-conclusions: + name: 'Test: Sync status, typo in success-conclusions' + needs: build + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Typo "succes" is not a known conclusion. Action must fail BEFORE dispatching. + - name: Dispatch with typo in success-conclusions — should fail before dispatch + id: dispatch + continue-on-error: true + uses: ./ + with: + workflow: echo-1.yaml + inputs: '{"message": "typo test"}' + wait-for-completion: true + sync-status: true + success-conclusions: succes + ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + + - name: Assert action failed before dispatch + run: | + if [[ "${{ steps.dispatch.outcome }}" != "failure" ]]; then + echo "FAIL: Expected action to fail on invalid success-conclusions value" + exit 1 + fi + echo "PASS: Action correctly rejected invalid conclusion" + + # =========================================================================== + # Test: comma-separated parser — multiple values, whitespace, case + # =========================================================================== + test-sync-status-multi-conclusions: + name: 'Test: Sync status, multiple success-conclusions' + needs: build + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Verifies split(','), trim(), and toLowerCase() all work together. + # Run concludes success — "SUCCESS" after lowercase normalisation is in the list. + - name: Dispatch echo-1, multi-value conclusions with whitespace and mixed case + uses: ./ + with: + workflow: echo-1.yaml + inputs: '{"message": "multi conclusions test"}' + wait-for-completion: true + sync-status: true + success-conclusions: ' SUCCESS , neutral ' + ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + + # =========================================================================== + # Test: poll timeout — null conclusion always fails (unconditional fix) + # =========================================================================== + test-sync-status-timeout-no-input: + name: 'Test: Sync status, timeout, no input — must fail' + needs: build + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # echo-2 sleeps 11s. 5-second timeout fires while run is still in_progress. + # conclusion = null. Without input, old code would pass — new fix must fail. + - name: Dispatch echo-2, 5s timeout, no success-conclusions — should fail + id: dispatch + continue-on-error: true + uses: ./ + with: + workflow: echo-2.yaml + wait-for-completion: true + wait-timeout-seconds: 5 + sync-status: true + ref: ${{ github.event.pull_request.head.ref || github.ref_name }} + + - name: Assert action failed + run: | + if [[ "${{ steps.dispatch.outcome }}" != "failure" ]]; then + echo "FAIL: Expected action to fail when poll times out (null conclusion fix)" + exit 1 + fi + echo "PASS: Action correctly failed on poll timeout" + From e8729d8545e38fb37f5d3366ff8b41efb9fa2013 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 11 Sep 2026 15:32:06 +0530 Subject: [PATCH 3/4] validation put before dispatch success-conclusions only used if syncStatus is true --- dist/index.js | 28 ++++++++++++++-------------- src/main.ts | 50 +++++++++++++++++++++++++++----------------------- 2 files changed, 41 insertions(+), 37 deletions(-) diff --git a/dist/index.js b/dist/index.js index ad4f985..f69c304 100644 --- a/dist/index.js +++ b/dist/index.js @@ -40803,6 +40803,20 @@ async function run() { try { await validateSubscription(); const workflowRef = getInput("workflow"); + const waitForCompletion = getInput("wait-for-completion") === "true"; + const syncStatus = getInput("sync-status") === "true"; + const configuredSuccessConclusions = getInput("success-conclusions"); + const successConclusions = syncStatus && configuredSuccessConclusions ? new Set( + configuredSuccessConclusions.split(",").map((c) => c.trim().toLowerCase()).filter(Boolean) + ) : null; + if (successConclusions) { + 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]; @@ -40843,20 +40857,6 @@ async function run() { ); info(`\u{1F3C6} API response status: ${dispatchResp.status}`); info(`\u{1F310} Run URL: ${dispatchResp.data.html_url}`); - const waitForCompletion = getInput("wait-for-completion") === "true"; - const syncStatus = getInput("sync-status") === "true"; - const configuredSuccessConclusions = getInput("success-conclusions"); - const successConclusions = configuredSuccessConclusions ? new Set( - configuredSuccessConclusions.split(",").map((c) => c.trim().toLowerCase()).filter(Boolean) - ) : null; - if (successConclusions) { - 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 timeoutSeconds = parseInt(getInput("wait-timeout-seconds") || "900", 10); const waitIntervalSeconds = parseInt(getInput("wait-interval-seconds") || "5", 10); let runStatus = "in_progress"; diff --git a/src/main.ts b/src/main.ts index 2953f16..f7eb3d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,33 @@ async function run(): Promise { // Required inputs const workflowRef = core.getInput('workflow') + const waitForCompletion = core.getInput('wait-for-completion') === 'true' + const syncStatus = core.getInput('sync-status') === 'true' + + // Read and validate success-conclusions before dispatching the workflow — + // a typo changes the verdict, so failing here beats triggering a run we + // then refuse to judge. Only parsed when sync-status is true; when + // sync-status is false the input is documented as ignored, so we skip it. + // If not set, successConclusions is null and legacy conclusion handling + // is preserved (failure/cancelled fail, other completed conclusions pass). + const configuredSuccessConclusions = core.getInput('success-conclusions') + const successConclusions = + syncStatus && configuredSuccessConclusions + ? new Set( + configuredSuccessConclusions + .split(',') + .map((c) => c.trim().toLowerCase()) + .filter(Boolean), + ) + : null + if (successConclusions) { + 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') @@ -107,29 +134,6 @@ async function run(): Promise { core.info(`🌐 Run URL: ${dispatchResp.data.html_url}`) // Handle wait for completion - const waitForCompletion = core.getInput('wait-for-completion') === 'true' - const syncStatus = core.getInput('sync-status') === 'true' - - // Read and validate success-conclusions before dispatching — a typo changes - // the verdict, so failing now beats triggering a run we then refuse to judge. - // If not set, successConclusions is null and old behaviour is preserved exactly. - const configuredSuccessConclusions = core.getInput('success-conclusions') - const successConclusions = configuredSuccessConclusions - ? new Set( - configuredSuccessConclusions - .split(',') - .map((c) => c.trim().toLowerCase()) - .filter(Boolean), - ) - : null - if (successConclusions) { - 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 timeoutSeconds = parseInt(core.getInput('wait-timeout-seconds') || '900', 10) // Default to 15 minutes const waitIntervalSeconds = parseInt(core.getInput('wait-interval-seconds') || '5', 10) // Default to 5 seconds let runStatus = 'in_progress' From 26d3fab7eaf282b9f95f69c77e50b8cf0afeb1d7 Mon Sep 17 00:00:00 2001 From: Raj-StepSecurity Date: Fri, 11 Sep 2026 15:57:00 +0530 Subject: [PATCH 4/4] readme documentation added --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dea0555..bdb0e88 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,26 @@ 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. By default, this action will be set to failed if the triggered workflow run concludes with `failure` or `cancelled`. To customise which conclusions are treated as a pass, see [`success-conclusions`](#success-conclusions). Also fails if the triggered workflow does not reach a completed state before `wait-timeout-seconds` expires. 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. Any conclusion not in this list — and any run that never reaches `completed` — will cause this action to fail. This only applies when both `sync-status` and `wait-for-completion` are set to `true`. Default is empty (unset). + +**Valid values:** `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out`, `action_required`, `stale`, `startup_failure`. An unrecognised value fails the action before the workflow is dispatched. + +**When omitted** (default), legacy conclusion handling is preserved: only `failure` and `cancelled` cause this action to fail; every other completed conclusion is treated as a pass. + +**When set**, the input acts as an explicit allowlist. Use this when the triggered run is a gate (a required status check, a deploy blocker) 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 # only a genuine pass goes green +``` ## Action Outputs