From 4ed3fbedf5fafd9f4068db6e2cb1f449fd921c55 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 7 Aug 2026 19:53:47 +0300 Subject: [PATCH 1/6] fix: scope the workflow status check and match pending runs The script passed workflow_id to listWorkflowRunsForRepo. That endpoint has no such parameter, so GitHub returned runs for every workflow in the repo. With fail-if-exists set, a release failed whenever anything in the repo sat in the requested status, a dependabot update included. Call listWorkflowRuns, which scopes to workflow_id, and fail clearly when the name does not resolve. Add non-terminal as a workflow-status, matching every run that has yet to finish. A run blocked by a concurrency group reports queued or pending depending on the trigger, so naming a single status leaves gaps. Add created-within-minutes to bound the lookback, so a run stuck in a non-terminal state stops blocking releases. It defaults to 0, which considers every run. Both inputs are additive and existing callers keep their behaviour. Inputs now reach the script through the environment, keeping them out of the source. --- .github/workflows/check-workflow-status.yml | 83 +++++++++++++++++---- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/.github/workflows/check-workflow-status.yml b/.github/workflows/check-workflow-status.yml index 6611869..647be92 100644 --- a/.github/workflows/check-workflow-status.yml +++ b/.github/workflows/check-workflow-status.yml @@ -11,7 +11,11 @@ on: workflow-status: type: string required: true - description: "Status to check for (e.g., in_progress, queued, completed)" + description: "Status to check for (e.g., in_progress, queued, completed), or non-terminal for any run still going" + created-within-minutes: + type: number + default: 0 + description: "Only consider runs started within this many minutes. 0 considers every run." fail-if-exists: type: boolean default: true @@ -30,22 +34,75 @@ jobs: steps: - name: Check workflow status uses: actions/github-script@v7 + # Inputs reach the script through the environment, keeping them out of the source. + env: + WORKFLOW_NAME: ${{ inputs.workflow-name }} + WORKFLOW_STATUS: ${{ inputs.workflow-status }} + CREATED_WITHIN_MINUTES: ${{ inputs.created-within-minutes }} + FAIL_IF_EXISTS: ${{ inputs.fail-if-exists }} + ERROR_MESSAGE: ${{ inputs.error-message }} with: github-token: ${{ inputs.token || github.token }} script: | - const workflows = await github.rest.actions.listWorkflowRunsForRepo({ + const workflowName = process.env.WORKFLOW_NAME; + const status = process.env.WORKFLOW_STATUS; + const windowMinutes = Number(process.env.CREATED_WITHIN_MINUTES) || 0; + const failIfExists = process.env.FAIL_IF_EXISTS === 'true'; + + // A run blocked by a concurrency group reports queued or pending depending on + // how it was triggered, so match everything that has yet to finish instead. + const TERMINAL = [ + 'completed', + 'success', + 'failure', + 'cancelled', + 'timed_out', + 'skipped', + 'neutral', + 'stale', + ]; + + const params = { owner: context.repo.owner, repo: context.repo.repo, - workflow_id: '${{ inputs.workflow-name }}', - status: '${{ inputs.workflow-status }}', - }); - - if (workflows.data.total_count > 0) { - if (${{ inputs.fail-if-exists }}) { - core.setFailed(`${{ inputs.workflow-name }}: ${{ inputs.error-message }}`); - } else { - console.log(`Found ${{ inputs.workflow-status }} workflows for ${{ inputs.workflow-name }}. Continuing as configured.`); + workflow_id: workflowName, + per_page: 100, + }; + + if (status !== 'non-terminal') { + params.status = status; + } + + if (windowMinutes > 0) { + params.created = `>=${new Date(Date.now() - windowMinutes * 60 * 1000).toISOString()}`; + } + + // listWorkflowRuns scopes to workflow_id. The repo-level endpoint drops it and + // returns runs for every workflow, dependabot included. + let data; + try { + ({ data } = await github.rest.actions.listWorkflowRuns(params)); + } catch (error) { + if (error.status === 404) { + core.setFailed(`Workflow ${workflowName} not found in ${context.repo.owner}/${context.repo.repo}`); + return; } + throw error; + } + + const runs = status === 'non-terminal' + ? data.workflow_runs.filter(run => !TERMINAL.includes(run.status)) + : data.workflow_runs; + + if (runs.length === 0) { + console.log(`No ${status} runs found for ${workflowName}. Proceeding.`); + return; + } + + const found = runs.map(run => `${run.name} ${run.status} (${run.html_url})`).join('\n'); + + if (failIfExists) { + core.setFailed(`${workflowName}: ${process.env.ERROR_MESSAGE}\n${found}`); } else { - console.log(`No ${{ inputs.workflow-status }} workflows found for ${{ inputs.workflow-name }}. Proceeding.`); - } \ No newline at end of file + console.log(`Found ${status} runs for ${workflowName}. Continuing as configured.\n${found}`); + } From 3c98c2e26fa485010e44a95cbf9b5d998485847c Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 7 Aug 2026 19:53:55 +0300 Subject: [PATCH 2/6] fix: fetch history so the tag comparison finds a tag The checkout set fetch-tags and left fetch-depth at 1. git describe walks back from HEAD, so on a shallow clone it found nothing, the step fell back to v0.0.0, and the package version always differed. The guard passed whatever the real state was. --- .github/workflows/check-tag-version-equality.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-tag-version-equality.yml b/.github/workflows/check-tag-version-equality.yml index 8ea9fbb..c916c56 100644 --- a/.github/workflows/check-tag-version-equality.yml +++ b/.github/workflows/check-tag-version-equality.yml @@ -37,8 +37,10 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} ref: ${{ inputs.ref }} - # Need git tags to determine version comparison + # Need git tags to determine version comparison, and the history to reach them: + # git describe walks back from HEAD, so a shallow clone finds nothing. fetch-tags: true + fetch-depth: 0 - name: Get package.json version id: package-version From a70df2945fee3d4a78f2ac6f5fb55f42cf00d812 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 7 Aug 2026 19:53:55 +0300 Subject: [PATCH 3/6] feat: report the bump commit sha build-image and tag-image resolve - from a checkout of ref. A caller passing a branch name gets the tip at the moment each job starts, so a merge landing mid-run shifts the tag between them. Report the commit this job pushed, so callers pin both to it. --- .github/workflows/bump.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 1da5a3e..4381e08 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -24,6 +24,9 @@ on: bumped_version: description: "The version after bumping" value: ${{ jobs.bump.outputs.bumped_version }} + commit_sha: + description: "The commit this job pushed, for downstream jobs to build and tag" + value: ${{ jobs.bump.outputs.commit_sha }} jobs: bump: @@ -31,6 +34,7 @@ jobs: environment: ${{ inputs.environment }} outputs: bumped_version: ${{ steps.get-version.outputs.bumped_version }} + commit_sha: ${{ steps.commit.outputs.commit_sha }} steps: - name: Create deployooor app token uses: actions/create-github-app-token@v1 @@ -93,8 +97,11 @@ jobs: run: echo "bumped_version=$(jq -r .version package.json)" >> $GITHUB_OUTPUT - name: Commit and push + id: commit env: LOCKFILE: ${{ steps.pm.outputs.lockfile }} + VERSION: ${{ steps.get-version.outputs.bumped_version }} + REF: ${{ inputs.ref }} run: | git config --local user.email "deployooor@nexusmutual.io" git config --local user.name "Version bumpooor" @@ -107,5 +114,9 @@ jobs: fi git add package.json "$LOCKFILE" $(git ls-files "**/package.json" "**/$LOCKFILE") - git commit -m "chore(release): v${{ steps.get-version.outputs.bumped_version }} [skip ci]" - git push origin ${{ inputs.ref }} + git commit -m "chore(release): v${VERSION} [skip ci]" + git push origin "$REF" + + # Downstream jobs build and tag this exact commit, rather than whatever the + # branch tip holds by the time they start. + echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" From 807e929d8c730d40461f3757dde34b1046bb5a00 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 7 Aug 2026 19:53:55 +0300 Subject: [PATCH 4/6] feat: accept a source tag when promoting an image Reading version and sha from ref resolves against the branch tip, which moves. A caller that already knows what build-image pushed passes it in as source-tag and skips the lookup. --- .github/workflows/tag-image.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tag-image.yml b/.github/workflows/tag-image.yml index bba0a18..f7dab9c 100644 --- a/.github/workflows/tag-image.yml +++ b/.github/workflows/tag-image.yml @@ -15,6 +15,10 @@ on: target-tag: type: string required: true + source-tag: + type: string + default: "" + description: "Tag to promote, e.g. needs.build-image.outputs.image-tag. Defaults to - read from ref." secrets: TOKEN: required: true @@ -35,10 +39,17 @@ jobs: - name: Get version and revision id: vars + env: + SOURCE_TAG: ${{ inputs.source-tag }} run: | - VERSION=$(jq -r .version package.json) - SHA_SHORT=$(git rev-parse --short HEAD) - echo "SOURCE_TAG=${VERSION}-${SHA_SHORT}" >> $GITHUB_OUTPUT + # Reading version and sha from ref resolves against the branch tip, which moves. + # Callers that already know the tag they pushed pass it in instead. + if [ -z "$SOURCE_TAG" ]; then + VERSION=$(jq -r .version package.json) + SHA_SHORT=$(git rev-parse --short HEAD) + SOURCE_TAG="${VERSION}-${SHA_SHORT}" + fi + echo "SOURCE_TAG=${SOURCE_TAG}" >> $GITHUB_OUTPUT echo 'IMAGE=${{ inputs.image }}' | tr '[:upper:]' '[:lower:]' >> $GITHUB_OUTPUT - name: Tag image From 9d30a39e3c10e1930978e5536f0b0241ac6b8447 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 7 Aug 2026 21:33:12 +0300 Subject: [PATCH 5/6] fix: count runs with total_count and validate the status input Tallying a single page missed runs beyond the first hundred, which is the stuck-run case the lookback window exists for. Query each non-terminal status in turn and sum total_count, which covers every page. The API returns an empty list for a status it fails to recognise, so a typo in workflow-status passed the guard silently. Accept only documented values. Drop fetch-tags from check-tag-version-equality, since depth 0 brings tags. Make ref optional in tag-image and skip the checkout when source-tag is set. --- .../workflows/check-tag-version-equality.yml | 5 +- .github/workflows/check-workflow-status.yml | 64 +++++++++++-------- .github/workflows/tag-image.yml | 10 ++- 3 files changed, 48 insertions(+), 31 deletions(-) diff --git a/.github/workflows/check-tag-version-equality.yml b/.github/workflows/check-tag-version-equality.yml index c916c56..4123d9c 100644 --- a/.github/workflows/check-tag-version-equality.yml +++ b/.github/workflows/check-tag-version-equality.yml @@ -37,9 +37,8 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} ref: ${{ inputs.ref }} - # Need git tags to determine version comparison, and the history to reach them: - # git describe walks back from HEAD, so a shallow clone finds nothing. - fetch-tags: true + # git describe walks back from HEAD to find a tag, so it needs the history. + # Depth 0 brings the tags along with it. fetch-depth: 0 - name: Get package.json version diff --git a/.github/workflows/check-workflow-status.yml b/.github/workflows/check-workflow-status.yml index 647be92..3726946 100644 --- a/.github/workflows/check-workflow-status.yml +++ b/.github/workflows/check-workflow-status.yml @@ -50,17 +50,27 @@ jobs: const failIfExists = process.env.FAIL_IF_EXISTS === 'true'; // A run blocked by a concurrency group reports queued or pending depending on - // how it was triggered, so match everything that has yet to finish instead. - const TERMINAL = [ + // how it was triggered, so non-terminal asks for each of these in turn. + const NON_TERMINAL = ['queued', 'in_progress', 'waiting', 'pending', 'requested']; + + // The API returns an empty list for a status it fails to recognise, which would + // pass this guard silently. Accept only the values it documents. + const ALLOWED = NON_TERMINAL.concat([ 'completed', - 'success', - 'failure', + 'action_required', 'cancelled', - 'timed_out', - 'skipped', + 'failure', 'neutral', + 'skipped', 'stale', - ]; + 'success', + 'timed_out', + ]); + + if (status !== 'non-terminal' && !ALLOWED.includes(status)) { + core.setFailed(`Unknown workflow-status "${status}". Use non-terminal, or one of: ${ALLOWED.join(', ')}`); + return; + } const params = { owner: context.repo.owner, @@ -69,40 +79,40 @@ jobs: per_page: 100, }; - if (status !== 'non-terminal') { - params.status = status; - } - if (windowMinutes > 0) { params.created = `>=${new Date(Date.now() - windowMinutes * 60 * 1000).toISOString()}`; } // listWorkflowRuns scopes to workflow_id. The repo-level endpoint drops it and // returns runs for every workflow, dependabot included. - let data; - try { - ({ data } = await github.rest.actions.listWorkflowRuns(params)); - } catch (error) { - if (error.status === 404) { - core.setFailed(`Workflow ${workflowName} not found in ${context.repo.owner}/${context.repo.repo}`); - return; + // total_count covers every page, so the tally stays exact. + let total = 0; + const found = []; + + for (const runStatus of status === 'non-terminal' ? NON_TERMINAL : [status]) { + let data; + try { + ({ data } = await github.rest.actions.listWorkflowRuns({ ...params, status: runStatus })); + } catch (error) { + if (error.status === 404) { + core.setFailed(`Workflow ${workflowName} not found in ${context.repo.owner}/${context.repo.repo}`); + return; + } + throw error; } - throw error; + total += data.total_count; + found.push(...data.workflow_runs.map(run => `${run.name} ${run.status} (${run.html_url})`)); } - const runs = status === 'non-terminal' - ? data.workflow_runs.filter(run => !TERMINAL.includes(run.status)) - : data.workflow_runs; - - if (runs.length === 0) { + if (total === 0) { console.log(`No ${status} runs found for ${workflowName}. Proceeding.`); return; } - const found = runs.map(run => `${run.name} ${run.status} (${run.html_url})`).join('\n'); + const detail = found.join('\n'); if (failIfExists) { - core.setFailed(`${workflowName}: ${process.env.ERROR_MESSAGE}\n${found}`); + core.setFailed(`${workflowName}: ${process.env.ERROR_MESSAGE}\n${detail}`); } else { - console.log(`Found ${status} runs for ${workflowName}. Continuing as configured.\n${found}`); + console.log(`Found ${total} ${status} run(s) for ${workflowName}. Continuing as configured.\n${detail}`); } diff --git a/.github/workflows/tag-image.yml b/.github/workflows/tag-image.yml index f7dab9c..c01fa6b 100644 --- a/.github/workflows/tag-image.yml +++ b/.github/workflows/tag-image.yml @@ -8,7 +8,8 @@ on: required: true ref: type: string - required: true + default: "" + description: "Ref to read version and sha from. Set this or source-tag." image: type: string required: true @@ -32,7 +33,9 @@ jobs: runs-on: ubuntu-22.04 environment: ${{ inputs.environment }} steps: + # Only a caller relying on ref needs the checkout, to read version and sha from it. - uses: actions/checkout@v4 + if: inputs.source-tag == '' with: token: ${{ secrets.TOKEN }} ref: ${{ inputs.ref }} @@ -41,10 +44,15 @@ jobs: id: vars env: SOURCE_TAG: ${{ inputs.source-tag }} + REF: ${{ inputs.ref }} run: | # Reading version and sha from ref resolves against the branch tip, which moves. # Callers that already know the tag they pushed pass it in instead. if [ -z "$SOURCE_TAG" ]; then + if [ -z "$REF" ]; then + echo "::error::Set either ref or source-tag" + exit 1 + fi VERSION=$(jq -r .version package.json) SHA_SHORT=$(git rev-parse --short HEAD) SOURCE_TAG="${VERSION}-${SHA_SHORT}" From 99dadb7b673e9c1b1bd79d73155a8a26bcd7ce72 Mon Sep 17 00:00:00 2001 From: Roxana Danila Date: Fri, 7 Aug 2026 21:41:24 +0300 Subject: [PATCH 6/6] test: cover the workflow status check Twelve repos depend on these workflows and nothing ran against them before they reached master. The status check carries the only real logic here, so read its script out of the workflow and run it against a stubbed octokit. 15 cases: workflow_id scoping, total_count past one page, non-terminal across all five states, an unrecognised status failing before any request, the lookback window, the 404 path, fail-if-exists, and error-message. A pull-request workflow runs them. actionlint stays for #21, since it reports findings across workflows this branch leaves alone. --- .github/workflows/pull-request.yml | 34 +++++ .gitignore | 1 + package-lock.json | 45 +++++++ package.json | 12 ++ test/check-workflow-status.test.js | 202 +++++++++++++++++++++++++++++ 5 files changed, 294 insertions(+) create mode 100644 .github/workflows/pull-request.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 test/check-workflow-status.test.js diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000..5ec171c --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,34 @@ +name: pull-request + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: '22' + +jobs: + checks: + if: github.event.pull_request.draft == false + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --ignore-scripts --prefer-offline --no-audit --no-fund + + - name: Test + run: npm test diff --git a/.gitignore b/.gitignore index 090a1f0..0ac92e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea .DS_Store +node_modules diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1c688ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "@nexusmutual/workflows", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nexusmutual/workflows", + "version": "0.0.0", + "devDependencies": { + "js-yaml": "^4.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e195a97 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nexusmutual/workflows", + "version": "0.0.0", + "private": true, + "description": "Reusable GitHub Actions workflows for NexusMutual repositories", + "scripts": { + "test": "node --test" + }, + "devDependencies": { + "js-yaml": "^4.1.0" + } +} diff --git a/test/check-workflow-status.test.js b/test/check-workflow-status.test.js new file mode 100644 index 0000000..a6678fb --- /dev/null +++ b/test/check-workflow-status.test.js @@ -0,0 +1,202 @@ +const { test, describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const yaml = require("js-yaml"); + +// The logic under test lives in a github-script `script:` block, so read it out of +// the workflow and run it against a stubbed octokit. +const WORKFLOW = path.join( + __dirname, + "..", + ".github", + "workflows", + "check-workflow-status.yml", +); +const script = yaml.load(fs.readFileSync(WORKFLOW, "utf8")).jobs[ + "wait-for-workflow" +].steps[0].with.script; + +const NON_TERMINAL = [ + "queued", + "in_progress", + "waiting", + "pending", + "requested", +]; + +/** + * Run the script with the given inputs. + * byStatus maps a run status to the total_count the API reports for it. + */ +async function run({ env = {}, byStatus = {}, throwStatus } = {}) { + const calls = []; + const failures = []; + const logs = []; + + const github = { + rest: { + actions: { + listWorkflowRuns: async (params) => { + calls.push(params); + if (throwStatus) { + const error = new Error("request failed"); + error.status = throwStatus; + throw error; + } + const total = byStatus[params.status] || 0; + return { + data: { + total_count: total, + // The API caps a page at per_page, while total_count spans every page. + workflow_runs: Array.from( + { length: Math.min(total, params.per_page || 100) }, + () => ({ + name: "Merge to dev or hotfix", + status: params.status, + html_url: "https://github.test/run/1", + }), + ), + }, + }; + }, + listWorkflowRunsForRepo: async () => { + throw new Error( + "the repo-level endpoint ignores workflow_id and must stay unused", + ); + }, + }, + }, + }; + + const context = { repo: { owner: "NexusMutual", repo: "demo" } }; + const core = { setFailed: (message) => failures.push(message) }; + + const previousLog = console.log; + console.log = (message) => logs.push(message); + + Object.assign(process.env, { + WORKFLOW_NAME: "merge-to-dev-or-hotfix.yml", + WORKFLOW_STATUS: "", + CREATED_WITHIN_MINUTES: "0", + FAIL_IF_EXISTS: "true", + ERROR_MESSAGE: "Workflow is currently running.", + ...env, + }); + + // The script body is the workflow file in this repo, and running it is the point of + // the test. github, context and core arrive as arguments, the way github-script + // supplies them. + const fn = new Function( + "github", + "context", + "core", + `"use strict"; return (async () => {${script}})();`, + ); + + try { + await fn(github, context, core); + } finally { + console.log = previousLog; + } + + return { calls, failures, logs }; +} + +describe("check-workflow-status", () => { + test("scopes the query to the named workflow", async () => { + const { calls, failures } = await run({ + env: { WORKFLOW_STATUS: "in_progress" }, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].workflow_id, "merge-to-dev-or-hotfix.yml"); + assert.equal(calls[0].status, "in_progress"); + assert.equal(failures.length, 0); + }); + + test("counts with total_count, which spans every page", async () => { + const { failures } = await run({ + env: { WORKFLOW_STATUS: "in_progress" }, + byStatus: { in_progress: 250 }, + }); + assert.equal(failures.length, 1); + }); + + test("non-terminal asks for each unfinished status", async () => { + const { calls, failures } = await run({ + env: { WORKFLOW_STATUS: "non-terminal" }, + }); + assert.deepEqual( + calls.map((call) => call.status), + NON_TERMINAL, + ); + assert.equal(failures.length, 0); + }); + + for (const status of NON_TERMINAL) { + test(`non-terminal catches a run that is ${status}`, async () => { + const { failures } = await run({ + env: { WORKFLOW_STATUS: "non-terminal" }, + byStatus: { [status]: 1 }, + }); + assert.equal(failures.length, 1); + }); + } + + test("non-terminal leaves finished runs alone", async () => { + const { calls, failures } = await run({ + env: { WORKFLOW_STATUS: "non-terminal" }, + byStatus: { completed: 99 }, + }); + assert.ok(calls.every((call) => call.status !== "completed")); + assert.equal(failures.length, 0); + }); + + test("an unrecognised status fails before any request", async () => { + const { calls, failures } = await run({ + env: { WORKFLOW_STATUS: "in_progres" }, + }); + assert.match(failures[0], /Unknown workflow-status/); + assert.equal(calls.length, 0); + }); + + test("created-within-minutes bounds every query", async () => { + const { calls } = await run({ + env: { WORKFLOW_STATUS: "non-terminal", CREATED_WITHIN_MINUTES: "60" }, + }); + assert.ok(calls.every((call) => /^>=\d{4}-\d{2}-\d{2}T/.test(call.created))); + }); + + test("a window of zero sends no created filter", async () => { + const { calls } = await run({ env: { WORKFLOW_STATUS: "non-terminal" } }); + assert.ok(calls.every((call) => call.created === undefined)); + }); + + test("a missing workflow fails with a clear message", async () => { + const { failures } = await run({ + env: { WORKFLOW_STATUS: "in_progress" }, + throwStatus: 404, + }); + assert.match(failures[0], /not found/); + }); + + test("fail-if-exists false reports and continues", async () => { + const { failures, logs } = await run({ + env: { WORKFLOW_STATUS: "in_progress", FAIL_IF_EXISTS: "false" }, + byStatus: { in_progress: 2 }, + }); + assert.equal(failures.length, 0); + assert.match(logs.join("\n"), /Continuing as configured/); + }); + + test("error-message reaches the failure", async () => { + const { failures } = await run({ + env: { + WORKFLOW_STATUS: "in_progress", + ERROR_MESSAGE: "Release blocked, retry later.", + }, + byStatus: { in_progress: 1 }, + }); + assert.match(failures[0], /Release blocked, retry later\./); + }); +});