Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions .github/workflows/build-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing README documentation — this new input has no entry in README.md's "Action Inputs" section. Please add a ### success-conclusions block describing:

  • what it does (comma-separated list of conclusions treated as pass)
  • valid values (success, failure, neutral, cancelled, skipped, timed_out, action_required, stale, startup_failure)
  • that it only takes effect when sync-status: true and wait-for-completion: true
  • that omitting it preserves the old behaviour (only failure/cancelled fail)

Also update the ### sync-status description to cross-reference this input, since the current wording ("fails or is cancelled") is no longer the full picture.


outputs:
runId:
Expand Down
52 changes: 45 additions & 7 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40787,11 +40787,36 @@ 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 {
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];
Expand Down Expand Up @@ -40832,8 +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 timeoutSeconds = parseInt(getInput("wait-timeout-seconds") || "900", 10);
const waitIntervalSeconds = parseInt(getInput("wait-interval-seconds") || "5", 10);
let runStatus = "in_progress";
Expand Down Expand Up @@ -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) {
Expand Down
71 changes: 63 additions & 8 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +42,33 @@ async function run(): Promise<void> {

// 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 =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard syncStatus && configuredSuccessConclusions means that if a user sets success-conclusions: "invalud_typo" but leaves sync-status: false, the value is silently accepted with no warning — the input is unused but the typo is invisible. Consider emitting a core.warning() when configuredSuccessConclusions is non-empty but syncStatus is false, so users catch config drift early:

Suggested change
const successConclusions =
const configuredSuccessConclusions = core.getInput('success-conclusions')
if (configuredSuccessConclusions && !syncStatus) {
core.warning("'success-conclusions' is set but 'sync-status' is false — the input will be ignored.")
}
const successConclusions =
syncStatus && configuredSuccessConclusions

syncStatus && configuredSuccessConclusions
? new Set(
configuredSuccessConclusions
.split(',')
.map((c) => c.trim().toLowerCase())
.filter(Boolean),
)
: null
if (successConclusions) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation guard checks successConclusions (truthy when syncStatus && configuredSuccessConclusions), but the input is only applied when both syncStatus && waitForCompletion are true (line 186). If a user sets sync-status: true but omits wait-for-completion, an invalid success-conclusions value will throw here and abort the run before dispatch — even though the input would never be consulted. Consider tightening the condition to match the documented precondition:

Suggested change
if (successConclusions) {
if (successConclusions && waitForCompletion) {

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')
Expand Down Expand Up @@ -95,8 +134,6 @@ async function run(): Promise<void> {
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'
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'
Expand Down Expand Up @@ -154,15 +191,33 @@ async function run(): Promise<void> {
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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conclusion && guard is redundant here: we already confirmed runStatusNow === 'completed' on line 199, and a completed GitHub Actions run always carries a non-null conclusion. Removing the guard makes the intent clearer — a completed run that somehow has no conclusion is still treated as a failure, which is the correct safe default.

Suggested change
if (conclusion && successConclusions.has(conclusion)) {
if (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) {
Expand Down
Loading