|
| 1 | +name: 'Find reusable build from prior run' |
| 2 | +description: >- |
| 3 | + Searches recent workflow runs across three tiers (same branch, base branch, |
| 4 | + then any open PR branch) for a run whose `build-source-hash` commit status |
| 5 | + matches the current fingerprint AND whose required build artifacts are still |
| 6 | + available. If a match is found, outputs the run id so a subsequent |
| 7 | + `actions/download-artifact` step can pull the artifacts directly instead of |
| 8 | + triggering a fresh native build. |
| 9 | +
|
| 10 | + The third (cross-PR) tier is required because GitHub's `listWorkflowRuns` |
| 11 | + `branch` parameter filters against `head_branch` — the PR source branch for |
| 12 | + `pull_request` events — so branch-scoped lookups can never discover other |
| 13 | + PRs' runs. The cross-PR tier drops the branch filter and instead uses |
| 14 | + `event: pull_request` to let the fingerprint itself act as the cross-PR |
| 15 | + deduplication key. |
| 16 | +
|
| 17 | +inputs: |
| 18 | + fingerprint: |
| 19 | + description: 'The @expo/fingerprint hash the candidate must match' |
| 20 | + required: true |
| 21 | + artifact-names: |
| 22 | + description: 'JSON array of artifact names that must all be present on the candidate run' |
| 23 | + required: true |
| 24 | + github-token: |
| 25 | + description: 'GitHub token with `actions: read` and `statuses: read` permissions' |
| 26 | + required: true |
| 27 | + workflow-file: |
| 28 | + description: 'Workflow filename whose runs will be searched' |
| 29 | + required: false |
| 30 | + default: 'ci.yml' |
| 31 | + base-branch: |
| 32 | + description: 'Fallback branch when no same-branch match is found' |
| 33 | + required: false |
| 34 | + default: 'main' |
| 35 | + status-context: |
| 36 | + description: 'Commit status context that carries the fingerprint' |
| 37 | + required: false |
| 38 | + default: 'build-source-hash' |
| 39 | + max-candidates-per-branch: |
| 40 | + description: 'How many recent runs to inspect per branch-scoped tier (same-branch, base-branch)' |
| 41 | + required: false |
| 42 | + default: '10' |
| 43 | + max-candidates-cross-pr: |
| 44 | + description: >- |
| 45 | + How many recent `pull_request`-event runs (across all branches) to inspect |
| 46 | + in the cross-PR tier. The fingerprint filter is highly discriminating, so |
| 47 | + the practical cost is one `getCombinedStatusForRef` call per candidate |
| 48 | + until a match is found. |
| 49 | + required: false |
| 50 | + default: '30' |
| 51 | + |
| 52 | +outputs: |
| 53 | + found: |
| 54 | + description: "'true' when a reusable run was found" |
| 55 | + value: ${{ steps.lookup.outputs.found }} |
| 56 | + run-id: |
| 57 | + description: 'Workflow run id that produced the reusable artifacts' |
| 58 | + value: ${{ steps.lookup.outputs.run-id }} |
| 59 | + source-sha: |
| 60 | + description: 'Commit SHA of the reusable run' |
| 61 | + value: ${{ steps.lookup.outputs.source-sha }} |
| 62 | + source-branch: |
| 63 | + description: 'Branch of the reusable run (same-branch or base-branch)' |
| 64 | + value: ${{ steps.lookup.outputs.source-branch }} |
| 65 | + |
| 66 | +runs: |
| 67 | + using: 'composite' |
| 68 | + steps: |
| 69 | + - name: Search prior runs for matching fingerprint |
| 70 | + id: lookup |
| 71 | + uses: actions/github-script@v7 |
| 72 | + continue-on-error: true |
| 73 | + env: |
| 74 | + TARGET_FINGERPRINT: ${{ inputs.fingerprint }} |
| 75 | + ARTIFACT_NAMES_JSON: ${{ inputs.artifact-names }} |
| 76 | + WORKFLOW_FILE: ${{ inputs.workflow-file }} |
| 77 | + BASE_BRANCH: ${{ inputs.base-branch }} |
| 78 | + STATUS_CONTEXT: ${{ inputs.status-context }} |
| 79 | + MAX_CANDIDATES: ${{ inputs.max-candidates-per-branch }} |
| 80 | + MAX_CANDIDATES_CROSS_PR: ${{ inputs.max-candidates-cross-pr }} |
| 81 | + HEAD_BRANCH: ${{ github.head_ref || github.ref_name }} |
| 82 | + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} |
| 83 | + CURRENT_RUN_ID: ${{ github.run_id }} |
| 84 | + with: |
| 85 | + github-token: ${{ inputs.github-token }} |
| 86 | + script: | |
| 87 | + const { |
| 88 | + TARGET_FINGERPRINT, |
| 89 | + ARTIFACT_NAMES_JSON, |
| 90 | + WORKFLOW_FILE, |
| 91 | + BASE_BRANCH, |
| 92 | + STATUS_CONTEXT, |
| 93 | + MAX_CANDIDATES, |
| 94 | + MAX_CANDIDATES_CROSS_PR, |
| 95 | + HEAD_BRANCH, |
| 96 | + HEAD_SHA, |
| 97 | + CURRENT_RUN_ID, |
| 98 | + } = process.env; |
| 99 | +
|
| 100 | + const setNotFound = () => { |
| 101 | + core.setOutput('found', 'false'); |
| 102 | + core.setOutput('run-id', ''); |
| 103 | + core.setOutput('source-sha', ''); |
| 104 | + core.setOutput('source-branch', ''); |
| 105 | + }; |
| 106 | +
|
| 107 | + if (!TARGET_FINGERPRINT) { |
| 108 | + core.warning('No fingerprint provided; skipping lookup'); |
| 109 | + setNotFound(); |
| 110 | + return; |
| 111 | + } |
| 112 | +
|
| 113 | + let requiredArtifacts; |
| 114 | + try { |
| 115 | + requiredArtifacts = JSON.parse(ARTIFACT_NAMES_JSON); |
| 116 | + } catch (err) { |
| 117 | + core.warning(`Could not parse artifact-names input: ${err.message}`); |
| 118 | + setNotFound(); |
| 119 | + return; |
| 120 | + } |
| 121 | + if (!Array.isArray(requiredArtifacts) || requiredArtifacts.length === 0) { |
| 122 | + core.warning('artifact-names must be a non-empty JSON array'); |
| 123 | + setNotFound(); |
| 124 | + return; |
| 125 | + } |
| 126 | +
|
| 127 | + const maxCandidates = Number(MAX_CANDIDATES) || 10; |
| 128 | + const maxCandidatesCrossPr = Number(MAX_CANDIDATES_CROSS_PR) || 30; |
| 129 | + const currentRunId = String(CURRENT_RUN_ID); |
| 130 | +
|
| 131 | + // Three-tier discovery: |
| 132 | + // 1. same-branch — fastest path, catches retries and new commits |
| 133 | + // on the current PR. |
| 134 | + // 2. base-branch — catches post-merge CI runs on `main`. Only |
| 135 | + // matches `push`-event runs (pull_request runs |
| 136 | + // have head_branch=<source branch>, not main). |
| 137 | + // 3. cross-pr — searches recent `pull_request` runs across |
| 138 | + // ALL source branches so two unrelated PRs with |
| 139 | + // the same fingerprint can reuse each other's |
| 140 | + // artifacts. This tier deliberately drops the |
| 141 | + // `branch` filter; without it, branch-scoped |
| 142 | + // lookups can never discover another PR's run |
| 143 | + // (GitHub filters `branch` against head_branch, |
| 144 | + // which is the PR source branch). |
| 145 | + const tiers = [ |
| 146 | + { |
| 147 | + label: `same-branch (branch=${HEAD_BRANCH})`, |
| 148 | + params: { branch: HEAD_BRANCH, per_page: maxCandidates }, |
| 149 | + }, |
| 150 | + ]; |
| 151 | + if (BASE_BRANCH && BASE_BRANCH !== HEAD_BRANCH) { |
| 152 | + tiers.push({ |
| 153 | + label: `base-branch (branch=${BASE_BRANCH})`, |
| 154 | + params: { branch: BASE_BRANCH, per_page: maxCandidates }, |
| 155 | + }); |
| 156 | + } |
| 157 | + tiers.push({ |
| 158 | + label: `cross-pr (event=pull_request, any branch, last ${maxCandidatesCrossPr} runs)`, |
| 159 | + params: { event: 'pull_request', per_page: maxCandidatesCrossPr }, |
| 160 | + // Skip runs already visited by the same-branch tier to avoid |
| 161 | + // wasting API calls on duplicates. |
| 162 | + skipHeadBranch: HEAD_BRANCH, |
| 163 | + }); |
| 164 | +
|
| 165 | + async function getFingerprintForSha(sha) { |
| 166 | + try { |
| 167 | + const { data } = await github.rest.repos.getCombinedStatusForRef({ |
| 168 | + owner: context.repo.owner, |
| 169 | + repo: context.repo.repo, |
| 170 | + ref: sha, |
| 171 | + per_page: 100, |
| 172 | + }); |
| 173 | + const status = data.statuses.find((s) => s.context === STATUS_CONTEXT); |
| 174 | + return status ? status.description : null; |
| 175 | + } catch (err) { |
| 176 | + core.info(`getCombinedStatusForRef failed for ${sha}: ${err.message}`); |
| 177 | + return null; |
| 178 | + } |
| 179 | + } |
| 180 | +
|
| 181 | + async function hasAllArtifacts(runId) { |
| 182 | + try { |
| 183 | + const artifacts = await github.paginate( |
| 184 | + github.rest.actions.listWorkflowRunArtifacts, |
| 185 | + { |
| 186 | + owner: context.repo.owner, |
| 187 | + repo: context.repo.repo, |
| 188 | + run_id: runId, |
| 189 | + per_page: 100, |
| 190 | + }, |
| 191 | + ); |
| 192 | + const available = new Set( |
| 193 | + artifacts |
| 194 | + .filter((a) => !a.expired) |
| 195 | + .map((a) => a.name), |
| 196 | + ); |
| 197 | + const missing = requiredArtifacts.filter((n) => !available.has(n)); |
| 198 | + if (missing.length > 0) { |
| 199 | + core.info(`Run ${runId} missing artifacts: ${missing.join(', ')}`); |
| 200 | + return false; |
| 201 | + } |
| 202 | + return true; |
| 203 | + } catch (err) { |
| 204 | + core.info(`listWorkflowRunArtifacts failed for ${runId}: ${err.message}`); |
| 205 | + return false; |
| 206 | + } |
| 207 | + } |
| 208 | +
|
| 209 | + const seenRunIds = new Set(); |
| 210 | + seenRunIds.add(currentRunId); |
| 211 | +
|
| 212 | + for (const tier of tiers) { |
| 213 | + core.info(`Searching tier: ${tier.label}`); |
| 214 | + let runs; |
| 215 | + try { |
| 216 | + const { data } = await github.rest.actions.listWorkflowRuns({ |
| 217 | + owner: context.repo.owner, |
| 218 | + repo: context.repo.repo, |
| 219 | + workflow_id: WORKFLOW_FILE, |
| 220 | + ...tier.params, |
| 221 | + }); |
| 222 | + runs = data.workflow_runs || []; |
| 223 | + } catch (err) { |
| 224 | + core.warning(`listWorkflowRuns failed for tier "${tier.label}": ${err.message}`); |
| 225 | + continue; |
| 226 | + } |
| 227 | +
|
| 228 | + for (const run of runs) { |
| 229 | + const runIdStr = String(run.id); |
| 230 | + if (seenRunIds.has(runIdStr)) continue; |
| 231 | + seenRunIds.add(runIdStr); |
| 232 | +
|
| 233 | + if (tier.skipHeadBranch && run.head_branch === tier.skipHeadBranch) continue; |
| 234 | + |
| 235 | + if (run.status !== 'completed' && run.status !== 'in_progress') continue; |
| 236 | +
|
| 237 | + const fingerprint = await getFingerprintForSha(run.head_sha); |
| 238 | + if (!fingerprint) continue; |
| 239 | + if (fingerprint !== TARGET_FINGERPRINT) continue; |
| 240 | +
|
| 241 | + if (!(await hasAllArtifacts(run.id))) continue; |
| 242 | +
|
| 243 | + core.info( |
| 244 | + `Match: tier="${tier.label}" run=${run.id} sha=${run.head_sha} branch=${run.head_branch} url=${run.html_url}`, |
| 245 | + ); |
| 246 | + core.setOutput('found', 'true'); |
| 247 | + core.setOutput('run-id', runIdStr); |
| 248 | + core.setOutput('source-sha', run.head_sha); |
| 249 | + core.setOutput('source-branch', run.head_branch || ''); |
| 250 | + return; |
| 251 | + } |
| 252 | + } |
| 253 | +
|
| 254 | + core.info('No reusable build found across any tier'); |
| 255 | + setNotFound(); |
0 commit comments