Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/build-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ jobs:
- name: Dispatch echo-1 by numeric ID
uses: ./
with:
workflow: '1854247'
# Hardcoded ID for echo-2.yaml
workflow: '312931751'
inputs: '{"message": "dispatched by id"}'
ref: ${{ github.event.pull_request.head.ref || github.ref_name }}

Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: "Publish Release"
run-name: Publish Release [${{ inputs.release_name }}] by @${{ github.actor }}

on:
workflow_dispatch:
inputs:
release_name:
type: string
description: release name (ie v1, v1.5, v2)
required: true
new_release_description:
type: string
description: release description (ie 'Adding new input migration-command')
required: false
default: ''

jobs:
publish_release:
name: "publish release"
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
steps:
- uses: actions/checkout@v6

- name: Tag and create or update GitHub Release
env:
GH_TOKEN: ${{ env.RELEASE_TOKEN }}
RELEASE_NAME: ${{ inputs.release_name }}
run: |
set -euo pipefail

if [ -z "${{ inputs.new_release_description }}" ]; then
DESCRIPTION_STR="release ${{ inputs.release_name }}"
else
DESCRIPTION_STR="${{ inputs.new_release_description }}"
fi

git config --local user.email "gh-automation@abovelending.com"
git config --local user.name "$GITHUB_ACTOR"

git tag -a -f -m "${DESCRIPTION_STR}" "${RELEASE_NAME}"
git push -f --tags

if gh release view "${RELEASE_NAME}" &>/dev/null; then
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
--method POST \
-f "tag_name=${RELEASE_NAME}" \
-f "target_commitish=${GITHUB_SHA}" \
--jq .body \
| gh release edit "${RELEASE_NAME}" \
--title "${RELEASE_NAME}" \
--notes-file -
else
gh release create "${RELEASE_NAME}" \
--title "${RELEASE_NAME}" \
--generate-notes \
--verify-tag
fi
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ This option is also left for backwards compatibility with older versions where t

**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`.

### `propagate-pending-wait`

**Optional.** Set to `'true'` to handle the case where the triggered run is cancelled while still pending/queued, typically because a newer run in the same concurrency group superseded it. When enabled, the action looks for that newer run and continues waiting on it instead of treating the cancellation as final. This only applies if `wait-for-completion` is set to `true`. Default is `false`.

## Action Outputs

| Output | Description |
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
propagate-pending-wait:
description: 'If the triggered run is cancelled while pending/queued, look for a newer run in the same concurrency group that superseded it and continue waiting on that run instead. Only applies if wait-for-completion is true.'
required: false
default: false

outputs:
runId:
Expand Down
63 changes: 44 additions & 19 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23588,6 +23588,21 @@ var version = "1.3.2";

// src/main.ts
var API_VERSION = "2026-03-10";
var ACTIVE_RUN_STATUSES = ["in_progress", "queued", "waiting", "pending"];
async function findSupersedingRun(octokit, owner, repo, workflowId, cancelledRun) {
const { data } = await octokit.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: workflowId,
branch: cancelledRun.head_branch,
per_page: 20,
headers: { "x-github-api-version": API_VERSION }
});
const candidates = data.workflow_runs.filter(
(candidateRun) => candidateRun.id !== cancelledRun.id && new Date(candidateRun.created_at).getTime() >= new Date(cancelledRun.created_at).getTime() && ACTIVE_RUN_STATUSES.includes(candidateRun.status ?? "")
).sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return candidates[0];
}
async function run() {
info(`\u{1F3C3} Workflow Dispatch Action v${version}`);
try {
Expand Down Expand Up @@ -23634,30 +23649,43 @@ 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 propagatePendingWait = getInput("propagate-pending-wait") === "true";
const timeoutSeconds = parseInt(getInput("wait-timeout-seconds") || "900", 10);
const waitIntervalSeconds = parseInt(getInput("wait-interval-seconds") || "5", 10);
let runStatus = "in_progress";
let currentRunId = dispatchResp.data.workflow_run_id;
let currentRunUrl = dispatchResp.data.run_url;
let currentRunHtmlUrl = dispatchResp.data.html_url;
if (waitForCompletion) {
info(`\u23F3 Waiting for workflow run to complete with a timeout of ${timeoutSeconds} seconds...`);
const startTime = Date.now();
while (runStatus === "in_progress" || runStatus === "queued" || runStatus === "waiting") {
while (ACTIVE_RUN_STATUSES.includes(runStatus)) {
if ((Date.now() - startTime) / 1e3 > timeoutSeconds) {
warning(
`\u26A0\uFE0F Workflow run did not complete within ${timeoutSeconds} seconds, timing out.
Note: The workflow is still running but we have stopped waiting. You can check the run status here: ${dispatchResp.data.html_url}`
Note: The workflow is still running but we have stopped waiting. You can check the run status here: ${currentRunHtmlUrl}`
);
runStatus = "timed_out";
break;
}
await new Promise((resolve) => setTimeout(resolve, waitIntervalSeconds * 1e3));
const { data: runData } = await octokit.request(
`GET /repos/${owner}/${repo}/actions/runs/${dispatchResp.data.workflow_run_id}`,
{
headers: { "x-github-api-version": API_VERSION }
}
);
const { data: runData } = await octokit.request(`GET /repos/${owner}/${repo}/actions/runs/${currentRunId}`, {
headers: { "x-github-api-version": API_VERSION }
});
runStatus = runData.status;
info(`\u{1F504} Current run status: ${runStatus}`);
if (propagatePendingWait && runStatus === "completed" && runData.conclusion === "cancelled") {
const supersedingRun = await findSupersedingRun(octokit, owner, repo, foundWorkflow.id, runData);
if (supersedingRun) {
warning(
`\u26A0\uFE0F Run ${currentRunId} was cancelled, likely superseded by run ${supersedingRun.id}. Switching to wait on the new run: ${supersedingRun.html_url}`
);
currentRunId = supersedingRun.id;
currentRunUrl = supersedingRun.url;
currentRunHtmlUrl = supersedingRun.html_url;
runStatus = supersedingRun.status ?? "queued";
}
}
}
if (runStatus === "completed") {
info("\u2705 Workflow run completed, the final status can be found in the workflow run details.");
Expand All @@ -23667,22 +23695,19 @@ Note: The workflow is still running but we have stopped waiting. You can check t
warning(`\u26A0\uFE0F Workflow run completed with status: ${runStatus}`);
}
}
setOutput("runId", dispatchResp.data.workflow_run_id);
setOutput("runUrl", dispatchResp.data.run_url);
setOutput("runUrlHtml", dispatchResp.data.html_url);
setOutput("runId", currentRunId);
setOutput("runUrl", currentRunUrl);
setOutput("runUrlHtml", currentRunHtmlUrl);
setOutput("workflowId", foundWorkflow.id);
if (syncStatus && waitForCompletion) {
const { data: finalRunData } = await octokit.request(
`GET /repos/${owner}/${repo}/actions/runs/${dispatchResp.data.workflow_run_id}`,
{
headers: { "x-github-api-version": API_VERSION }
}
);
const { data: finalRunData } = await octokit.request(`GET /repos/${owner}/${repo}/actions/runs/${currentRunId}`, {
headers: { "x-github-api-version": API_VERSION }
});
const conclusion = finalRunData.conclusion;
if (conclusion === "failure") {
setFailed(`Workflow run failed. Check the run details here: ${dispatchResp.data.html_url}`);
setFailed(`Workflow run failed. Check the run details here: ${currentRunHtmlUrl}`);
} else if (conclusion === "cancelled") {
setFailed(`Workflow run was cancelled. Check the run details here: ${dispatchResp.data.html_url}`);
setFailed(`Workflow run was cancelled. Check the run details here: ${currentRunHtmlUrl}`);
} else {
info(`\u{1F389} Workflow conclusion: ${conclusion}`);
}
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --target=node24 --outfile=dist/index.js",
"lint": "eslint src/ --ext .ts && prettier --check src/",
"lint-write": "eslint src/ --ext .ts && prettier --write src/",
"lint-fix": "eslint src/ --fix",
"format": "prettier --write src/"
},
Expand All @@ -25,4 +26,4 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0"
}
}
}
88 changes: 69 additions & 19 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,49 @@ import * as PackageJSON from '../package.json'

const API_VERSION = '2026-03-10' // Latest API version as of March 2026, update as needed

// Workflow run statuses that are considered still active/not yet finished
const ACTIVE_RUN_STATUSES = ['in_progress', 'queued', 'waiting', 'pending']

type Workflow = {
id: number
name: string
path: string
}

// =============================================================================
// When a run is cancelled while pending/queued, it may have been superseded by a newer
// run in the same concurrency group (e.g. a higher priority dispatch on the same branch).
// GitHub's API has no direct link between the two, so we infer it: the newest still-active
// run for the same workflow + branch, created at or after the cancelled run.
// =============================================================================
async function findSupersedingRun(
octokit: ReturnType<typeof github.getOctokit>,
owner: string,
repo: string,
workflowId: number,
cancelledRun: { id: number; head_branch: string; created_at: string },
) {
const { data } = await octokit.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: workflowId,
branch: cancelledRun.head_branch,
per_page: 20,
headers: { 'x-github-api-version': API_VERSION },
})

const candidates = data.workflow_runs
.filter(
(candidateRun) =>
candidateRun.id !== cancelledRun.id &&
new Date(candidateRun.created_at).getTime() >= new Date(cancelledRun.created_at).getTime() &&
ACTIVE_RUN_STATUSES.includes(candidateRun.status ?? ''),
)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())

return candidates[0]
}

// =============================================================================
// Main task function (async wrapper)
// =============================================================================
Expand Down Expand Up @@ -92,33 +129,49 @@ async function run(): Promise<void> {
// Handle wait for completion
const waitForCompletion = core.getInput('wait-for-completion') === 'true'
const syncStatus = core.getInput('sync-status') === 'true'
const propagatePendingWait = core.getInput('propagate-pending-wait') === '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'
let currentRunId = dispatchResp.data.workflow_run_id
let currentRunUrl = dispatchResp.data.run_url
let currentRunHtmlUrl = dispatchResp.data.html_url

// Polling loop to check workflow run status until it completes or times out
if (waitForCompletion) {
core.info(`⏳ Waiting for workflow run to complete with a timeout of ${timeoutSeconds} seconds...`)
const startTime = Date.now()
while (runStatus === 'in_progress' || runStatus === 'queued' || runStatus === 'waiting' || runStatus === 'pending') {
while (ACTIVE_RUN_STATUSES.includes(runStatus)) {
if ((Date.now() - startTime) / 1000 > timeoutSeconds) {
core.warning(
`⚠️ Workflow run did not complete within ${timeoutSeconds} seconds, timing out.\nNote: The workflow is still running but we have stopped waiting. You can check the run status here: ${dispatchResp.data.html_url}`,
`⚠️ Workflow run did not complete within ${timeoutSeconds} seconds, timing out.\nNote: The workflow is still running but we have stopped waiting. You can check the run status here: ${currentRunHtmlUrl}`,
)
runStatus = 'timed_out'
break
}

await new Promise((resolve) => setTimeout(resolve, waitIntervalSeconds * 1000)) // Wait for waitIntervalSeconds before polling again

const { data: runData } = await octokit.request(
`GET /repos/${owner}/${repo}/actions/runs/${dispatchResp.data.workflow_run_id}`,
{
headers: { 'x-github-api-version': API_VERSION },
},
)
const { data: runData } = await octokit.request(`GET /repos/${owner}/${repo}/actions/runs/${currentRunId}`, {
headers: { 'x-github-api-version': API_VERSION },
})
runStatus = runData.status
core.info(`🔄 Current run status: ${runStatus}`)

// If the run was cancelled while still pending/queued, check whether a newer run in the
// same concurrency group superseded it, and if so switch to waiting on that run instead.
if (propagatePendingWait && runStatus === 'completed' && runData.conclusion === 'cancelled') {
const supersedingRun = await findSupersedingRun(octokit, owner, repo, foundWorkflow.id, runData)
if (supersedingRun) {
core.warning(
`⚠️ Run ${currentRunId} was cancelled, likely superseded by run ${supersedingRun.id}. Switching to wait on the new run: ${supersedingRun.html_url}`,
)
currentRunId = supersedingRun.id
currentRunUrl = supersedingRun.url
currentRunHtmlUrl = supersedingRun.html_url
runStatus = supersedingRun.status ?? 'queued'
}
}
}

if (runStatus === 'completed') {
Expand All @@ -130,27 +183,24 @@ async function run(): Promise<void> {
}
}

core.setOutput('runId', dispatchResp.data.workflow_run_id)
core.setOutput('runUrl', dispatchResp.data.run_url)
core.setOutput('runUrlHtml', dispatchResp.data.html_url)
core.setOutput('runId', currentRunId)
core.setOutput('runUrl', currentRunUrl)
core.setOutput('runUrlHtml', currentRunHtmlUrl)
core.setOutput('workflowId', foundWorkflow.id)

// Sync the status of this action with the triggered workflow run if requested
if (syncStatus && waitForCompletion) {
// Get the final conclusion of the workflow run if we were waiting for completion
const { data: finalRunData } = await octokit.request(
`GET /repos/${owner}/${repo}/actions/runs/${dispatchResp.data.workflow_run_id}`,
{
headers: { 'x-github-api-version': API_VERSION },
},
)
const { data: finalRunData } = await octokit.request(`GET /repos/${owner}/${repo}/actions/runs/${currentRunId}`, {
headers: { 'x-github-api-version': API_VERSION },
})
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}`)
core.setFailed(`Workflow run failed. Check the run details here: ${currentRunHtmlUrl}`)
} else if (conclusion === 'cancelled') {
core.setFailed(`Workflow run was cancelled. Check the run details here: ${dispatchResp.data.html_url}`)
core.setFailed(`Workflow run was cancelled. Check the run details here: ${currentRunHtmlUrl}`)
} else {
core.info(`🎉 Workflow conclusion: ${conclusion}`)
}
Expand Down
Loading