diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index f754b15..da833f0 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -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 }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..83ea39e --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -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 diff --git a/README.md b/README.md index 51bf96e..72b1ff4 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/action.yaml b/action.yaml index 5813872..4188043 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 + 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: diff --git a/dist/index.js b/dist/index.js index acbd667..34970de 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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 { @@ -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."); @@ -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}`); } diff --git a/package-lock.json b/package-lock.json index 7f6856b..1dfb553 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1961,9 +1961,9 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d5d870c..4981dc0 100644 --- a/package.json +++ b/package.json @@ -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/" }, @@ -25,4 +26,4 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.56.0" } -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index d3b602f..900485f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, + 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) // ============================================================================= @@ -92,18 +129,22 @@ async function run(): Promise { // 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 @@ -111,14 +152,26 @@ async function run(): Promise { 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') { @@ -130,27 +183,24 @@ async function run(): Promise { } } - 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}`) }