name: Wait for Jobs description: Poll and wait for specified jobs in the current workflow run to complete inputs: stage-name: description: 'Human-readable stage name for log messages (e.g. "stage-a")' required: true jobs: description: | JSON array of job specs to wait for. Each element is either: - a string: exact job name (e.g. "stage-a-test-1-gpu-small") - an object { "prefix": "...", "expected_count": N }: for matrix jobs required: true max-wait-minutes: description: 'Maximum time to wait before timing out' required: false default: '240' poll-interval-seconds: description: 'Seconds between polling attempts' required: false default: '60' github-token: description: 'GitHub token for API calls' required: false default: ${{ github.token }} outputs: result: description: 'Overall result: success, failure, or timeout' value: ${{ steps.wait.outputs.result }} runs: using: composite steps: - name: Wait for jobs to complete id: wait uses: actions/github-script@v7 env: INPUT_STAGE_NAME: ${{ inputs.stage-name }} INPUT_JOBS: ${{ inputs.jobs }} INPUT_MAX_WAIT_MINUTES: ${{ inputs.max-wait-minutes }} INPUT_POLL_INTERVAL_SECONDS: ${{ inputs.poll-interval-seconds }} with: github-token: ${{ inputs.github-token }} script: | const stageName = process.env.INPUT_STAGE_NAME; const jobSpecs = JSON.parse(process.env.INPUT_JOBS); const maxWaitMinutes = parseInt(process.env.INPUT_MAX_WAIT_MINUTES); const pollIntervalSeconds = parseInt(process.env.INPUT_POLL_INTERVAL_SECONDS); const maxAttempts = (maxWaitMinutes * 60) / pollIntervalSeconds; // Normalize job specs into a uniform format const normalizedSpecs = jobSpecs.map(spec => { if (typeof spec === 'string') { return { prefix: spec, expected_count: 1, exact: true }; } return { ...spec, exact: false }; }); const totalExpectedJobs = normalizedSpecs.reduce((sum, s) => sum + s.expected_count, 0); const matchesSpec = (jobName, spec) => { if (spec.exact) { return jobName === spec.prefix; } return jobName === spec.prefix || jobName.startsWith(spec.prefix + ' ('); }; // Use ETag conditional requests to avoid consuming rate limit when nothing changed. // GitHub returns 304 Not Modified for unchanged data, which is FREE (no rate limit cost). let lastEtag = ''; let lastJobs = null; let apiCalls = 0; let cachedCalls = 0; async function fetchJobs() { const url = `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs`; const params = { owner: context.repo.owner, repo: context.repo.repo, run_id: context.runId, per_page: 100, headers: {}, }; if (lastEtag) { params.headers['if-none-match'] = lastEtag; } try { const response = await github.request(url, params); apiCalls++; const rateRemaining = response.headers['x-ratelimit-remaining'] || '?'; const rateLimit = response.headers['x-ratelimit-limit'] || '?'; console.log(`[rate-limit] ${rateRemaining}/${rateLimit} remaining (ETag: ${lastEtag ? 'sent' : 'none'}) | this session: ${apiCalls} paid, ${cachedCalls} free`); lastEtag = response.headers.etag || ''; const jobs = response.data.jobs; // Handle pagination if >100 jobs // ETag only covers page 1, so invalidate it to avoid stale cache // when later pages change but page 1 doesn't. if (response.data.total_count > 100) { lastEtag = ''; for (let page = 2; page <= Math.ceil(response.data.total_count / 100); page++) { const { data: pageData } = await github.request(url, { ...params, page, headers: {}, }); jobs.push(...pageData.jobs); } } lastJobs = jobs; return { jobs, cached: false }; } catch (err) { if (err.status === 304 && lastJobs) { cachedCalls++; console.log(`[rate-limit] 304 Not Modified | this session: ${apiCalls} paid, ${cachedCalls} free`); return { jobs: lastJobs, cached: true }; } throw err; } } for (let attempt = 0; attempt < maxAttempts; attempt++) { const { jobs, cached } = await fetchJobs(); let allCompleted = true; let failedJobs = []; let completedCount = 0; let totalCount = 0; for (const spec of normalizedSpecs) { const matchingJobs = jobs.filter(job => matchesSpec(job.name, spec)); for (const job of matchingJobs) { totalCount++; if (!cached) { console.log(`${job.name}: status=${job.status}, conclusion=${job.conclusion}`); } if (job.status === 'completed') { completedCount++; if (job.conclusion !== 'success' && job.conclusion !== 'skipped') { failedJobs.push(job.name); } } else { allCompleted = false; } } if (matchingJobs.length < spec.expected_count) { console.log(`${spec.prefix}: found ${matchingJobs.length}/${spec.expected_count} jobs (waiting for more)`); allCompleted = false; } } console.log(`[${stageName}] Progress: ${completedCount}/${totalCount} jobs completed (expected ${totalExpectedJobs})${cached ? ' (cached, no rate limit cost)' : ''}`); // Fail fast if any jobs failed if (failedJobs.length > 0) { core.setOutput('result', 'failure'); core.setFailed(`${stageName} jobs failed: ${failedJobs.join(', ')}`); return; } if (allCompleted && totalCount >= totalExpectedJobs) { core.setOutput('result', 'success'); return; } console.log(`Waiting ${pollIntervalSeconds}s... (attempt ${attempt + 1}/${maxAttempts})`); await new Promise(resolve => setTimeout(resolve, pollIntervalSeconds * 1000)); } core.setFailed(`Timeout waiting for ${stageName} jobs`); core.setOutput('result', 'timeout');