diff --git a/.github/scripts/wait-for-validation-workflows.js b/.github/scripts/wait-for-validation-workflows.js new file mode 100644 index 0000000..350e30b --- /dev/null +++ b/.github/scripts/wait-for-validation-workflows.js @@ -0,0 +1,81 @@ +/** + * Wait for required validation workflows to complete successfully for a PR head SHA. + * + * Used by Build Documentation / Publish Dev so they do not fail while Test and + * CI/CD Pipeline are still queued or in progress. + * + * @param {object} params + * @param {import('@actions/github').GitHub} params.github + * @param {import('@actions/github').context} params.context + * @param {import('@actions/core')} params.core + * @param {string[]} [params.workflows] workflow file names to wait on + * @param {number} [params.timeoutMs] max wait (default 120 minutes) + * @param {number} [params.pollMs] poll interval (default 30 seconds) + */ +module.exports = async function waitForValidationWorkflows({ + github, + context, + core, + workflows = ["ci.yml", "test.yml"], + timeoutMs = 120 * 60 * 1000, + pollMs = 30 * 1000, +}) { + if (context.eventName !== "pull_request") { + core.info(`Skipping validation wait for event: ${context.eventName}`); + return; + } + + const headSha = context.payload.pull_request.head.sha; + const owner = context.repo.owner; + const repo = context.repo.repo; + const deadline = Date.now() + timeoutMs; + + const labels = { + "ci.yml": "CI/CD Pipeline", + "test.yml": "Test", + }; + + async function latestRun(workflowId) { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: workflowId, + head_sha: headSha, + per_page: 5, + }); + return data.workflow_runs.find((run) => run.head_sha === headSha) || null; + } + + for (const workflowId of workflows) { + const label = labels[workflowId] || workflowId; + while (true) { + const run = await latestRun(workflowId); + if (!run) { + if (Date.now() >= deadline) { + core.setFailed(`Timed out waiting for ${label} to start for ${headSha}`); + return; + } + core.info(`${label}: no run for ${headSha} yet; waiting...`); + } else if (run.status !== "completed") { + if (Date.now() >= deadline) { + core.setFailed( + `Timed out waiting for ${label} (status=${run.status}) for ${headSha}`, + ); + return; + } + core.info(`${label}: ${run.status} (run ${run.id}); waiting...`); + } else if (run.conclusion === "success") { + core.info(`${label}: success (run ${run.id})`); + break; + } else { + core.setFailed( + `${label} concluded '${run.conclusion}' (run ${run.id}); required before continuing`, + ); + return; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + } + + core.info("All required validation workflows passed"); +}; diff --git a/.github/workflows/README.md b/.github/workflows/README.md index bed5f73..63cce58 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -92,12 +92,14 @@ Workflows that run on **PR to main** use the environment **`approval-required`** - **Triggers**: PR to `main` (runs after approval), `workflow_dispatch` - **Purpose**: Build documentation for testing and verification - **Runs**: + - Waits for CI/CD Pipeline and Test workflow runs to succeed for the PR head SHA - Generate coverage report (for docs embedding) - Generate Bandit security report (for docs embedding) - Build documentation using patched build script - Upload documentation artifacts - **Rationale**: - PRs to `main` trigger the workflow but require approval; or run manually from any branch + - Validation gate polls instead of failing while Test/CI are still in progress --- @@ -166,7 +168,7 @@ Workflows that run on **PR to main** use the environment **`approval-required`** - **Triggers**: PR to `main` (runs after approval), `workflow_dispatch` - **Purpose**: Publish to PyPI as nightly builds - **Runs**: - - Validates CI/CD Pipeline and Test checks have passed (for PR to main) + - Waits for CI/CD Pipeline and Test workflow runs to succeed for the PR head SHA (polls; does not fail while they are still running) - Builds package and publishes to PyPI using `uv publish` - Requires `PYPI_API_TOKEN` secret - **Rationale**: diff --git a/.github/workflows/build-documentation.yml b/.github/workflows/build-documentation.yml index e3f3c37..c762208 100644 --- a/.github/workflows/build-documentation.yml +++ b/.github/workflows/build-documentation.yml @@ -21,32 +21,24 @@ jobs: check-validation: name: check-validation runs-on: ubuntu-latest + # Allow long wait while Test matrix finishes (up to ~2h in the wait script). + timeout-minutes: 150 if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' permissions: contents: read actions: read pull-requests: read steps: - - name: Check if validation workflows passed + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Wait for CI/CD Pipeline and Test to succeed uses: actions/github-script@v7 with: script: | - // For PRs, check if ci.yml and test.yml have passed - if (context.eventName === 'pull_request') { - const { data: checks } = await github.rest.checks.listForRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: context.payload.pull_request.head.sha, - }); - const requiredChecks = ['CI/CD Pipeline', 'Test']; - const passedChecks = checks.check_runs.filter( - check => requiredChecks.includes(check.name) && check.conclusion === 'success' - ); - if (passedChecks.length < requiredChecks.length) { - core.setFailed('Required validation workflows must pass first'); - } - } - // For workflow_dispatch, allow manual override + const waitForValidation = require('./.github/scripts/wait-for-validation-workflows.js'); + await waitForValidation({ github, context, core }); build-docs: name: build-docs diff --git a/.github/workflows/publish-pypi-dev.yml b/.github/workflows/publish-pypi-dev.yml index 5e74f46..0b6f093 100644 --- a/.github/workflows/publish-pypi-dev.yml +++ b/.github/workflows/publish-pypi-dev.yml @@ -19,30 +19,24 @@ jobs: check-validation: name: check-validation runs-on: ubuntu-latest + # Allow long wait while Test matrix finishes (up to ~2h in the wait script). + timeout-minutes: 150 if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' permissions: contents: read actions: read pull-requests: read steps: - - name: Check if validation workflows passed (PR to main only) + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Wait for CI/CD Pipeline and Test to succeed uses: actions/github-script@v7 with: script: | - if (context.eventName === 'pull_request') { - const { data: checks } = await github.rest.checks.listForRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: context.payload.pull_request.head.sha, - }); - const requiredChecks = ['CI/CD Pipeline', 'Test']; - const passedChecks = checks.check_runs.filter( - check => requiredChecks.includes(check.name) && check.conclusion === 'success' - ); - if (passedChecks.length < requiredChecks.length) { - core.setFailed('Required validation workflows must pass first'); - } - } + const waitForValidation = require('./.github/scripts/wait-for-validation-workflows.js'); + await waitForValidation({ github, context, core }); publish-nightly: name: publish-dev-to-pypi diff --git a/.gitignore b/.gitignore index 5426428..809036e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ MagicMock scripts !dev/scripts/ !dev/scripts/** +!.github/scripts/ +!.github/scripts/** compatibility_tests/ lint_outputs/ locales