|
| 1 | +name: Mobile Fingerprint Label |
| 2 | + |
| 3 | +# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code |
| 4 | +# (fork PRs included) with a read-only token and records which platforms' |
| 5 | +# fingerprints changed. This one runs from main with a write token and syncs |
| 6 | +# the "📱 Native Change" label. It never checks out or runs PR code. |
| 7 | +# |
| 8 | +# A PR can edit the check workflow it runs under, so the recorded result is |
| 9 | +# untrusted data: it is validated, and at worst a PR mislabels itself. |
| 10 | + |
| 11 | +on: |
| 12 | + workflow_run: |
| 13 | + workflows: [Mobile Fingerprint Check] |
| 14 | + types: [completed] |
| 15 | + |
| 16 | +permissions: |
| 17 | + contents: read |
| 18 | + |
| 19 | +jobs: |
| 20 | + label: |
| 21 | + name: Sync native change label |
| 22 | + # A cancelled run was superseded by a newer push, whose run labels instead. |
| 23 | + if: >- |
| 24 | + github.event.workflow_run.event == 'pull_request' && |
| 25 | + github.event.workflow_run.conclusion == 'success' |
| 26 | + runs-on: ubuntu-24.04 |
| 27 | + timeout-minutes: 5 |
| 28 | + permissions: |
| 29 | + actions: read |
| 30 | + contents: read |
| 31 | + issues: write |
| 32 | + pull-requests: write |
| 33 | + steps: |
| 34 | + - id: pr |
| 35 | + name: Resolve the pull request behind the check |
| 36 | + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 |
| 37 | + with: |
| 38 | + script: | |
| 39 | + const run = context.payload.workflow_run; |
| 40 | + const { owner, repo } = context.repo; |
| 41 | +
|
| 42 | + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { |
| 43 | + owner, |
| 44 | + repo, |
| 45 | + run_id: run.id, |
| 46 | + per_page: 100, |
| 47 | + }); |
| 48 | + const results = artifacts.filter( |
| 49 | + (artifact) => artifact.name === "fingerprint-result" && !artifact.expired, |
| 50 | + ); |
| 51 | + if (results.length !== 1) { |
| 52 | + core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`); |
| 53 | + return; |
| 54 | + } |
| 55 | +
|
| 56 | + // workflow_run.pull_requests is empty for fork PRs, so resolve the |
| 57 | + // PR from the checked commit. Requiring the PR's current head to be |
| 58 | + // that commit keeps a slow run for an older push from overwriting a |
| 59 | + // newer result. |
| 60 | + const associated = await github.paginate( |
| 61 | + github.rest.repos.listPullRequestsAssociatedWithCommit, |
| 62 | + { owner, repo, commit_sha: run.head_sha, per_page: 100 }, |
| 63 | + ); |
| 64 | + const matching = associated.filter( |
| 65 | + (candidate) => |
| 66 | + candidate.state === "open" && |
| 67 | + candidate.head.sha === run.head_sha && |
| 68 | + candidate.head.ref === run.head_branch && |
| 69 | + candidate.head.repo?.full_name === run.head_repository?.full_name, |
| 70 | + ); |
| 71 | + if (matching.length !== 1) { |
| 72 | + core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`); |
| 73 | + return; |
| 74 | + } |
| 75 | +
|
| 76 | + core.setOutput("artifact_id", String(results[0].id)); |
| 77 | + core.setOutput("pr_number", String(matching[0].number)); |
| 78 | +
|
| 79 | + # Read the archive as bytes; never extract PR-produced files into a |
| 80 | + # workspace this job runs code from. |
| 81 | + - id: result |
| 82 | + name: Read changed platforms |
| 83 | + if: steps.pr.outputs.pr_number != '' |
| 84 | + shell: bash |
| 85 | + env: |
| 86 | + GH_TOKEN: ${{ github.token }} |
| 87 | + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} |
| 88 | + run: | |
| 89 | + set -euo pipefail |
| 90 | + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip" |
| 91 | + changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)" |
| 92 | + if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then |
| 93 | + echo "Unexpected changed-platforms value '$changed'." >&2 |
| 94 | + exit 1 |
| 95 | + fi |
| 96 | + echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT" |
| 97 | +
|
| 98 | + - name: Sync native change label |
| 99 | + if: steps.pr.outputs.pr_number != '' |
| 100 | + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 |
| 101 | + env: |
| 102 | + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} |
| 103 | + CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} |
| 104 | + with: |
| 105 | + script: | |
| 106 | + const managedLabel = { |
| 107 | + name: "📱 Native Change", |
| 108 | + color: "d93f0b", |
| 109 | + description: |
| 110 | + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", |
| 111 | + }; |
| 112 | + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; |
| 113 | + const issueNumber = Number(process.env.PR_NUMBER); |
| 114 | +
|
| 115 | + try { |
| 116 | + const { data: existing } = await github.rest.issues.getLabel({ |
| 117 | + owner: context.repo.owner, |
| 118 | + repo: context.repo.repo, |
| 119 | + name: managedLabel.name, |
| 120 | + }); |
| 121 | +
|
| 122 | + if ( |
| 123 | + existing.color !== managedLabel.color || |
| 124 | + (existing.description ?? "") !== managedLabel.description |
| 125 | + ) { |
| 126 | + await github.rest.issues.updateLabel({ |
| 127 | + owner: context.repo.owner, |
| 128 | + repo: context.repo.repo, |
| 129 | + name: managedLabel.name, |
| 130 | + color: managedLabel.color, |
| 131 | + description: managedLabel.description, |
| 132 | + }); |
| 133 | + } |
| 134 | + } catch (error) { |
| 135 | + if (error.status !== 404) { |
| 136 | + throw error; |
| 137 | + } |
| 138 | +
|
| 139 | + try { |
| 140 | + await github.rest.issues.createLabel({ |
| 141 | + owner: context.repo.owner, |
| 142 | + repo: context.repo.repo, |
| 143 | + name: managedLabel.name, |
| 144 | + color: managedLabel.color, |
| 145 | + description: managedLabel.description, |
| 146 | + }); |
| 147 | + } catch (createError) { |
| 148 | + if (createError.status !== 422) { |
| 149 | + throw createError; |
| 150 | + } |
| 151 | + } |
| 152 | + } |
| 153 | +
|
| 154 | + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ |
| 155 | + owner: context.repo.owner, |
| 156 | + repo: context.repo.repo, |
| 157 | + issue_number: issueNumber, |
| 158 | + per_page: 100, |
| 159 | + }); |
| 160 | + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); |
| 161 | +
|
| 162 | + if (nativeChanged && !hasLabel) { |
| 163 | + await github.rest.issues.addLabels({ |
| 164 | + owner: context.repo.owner, |
| 165 | + repo: context.repo.repo, |
| 166 | + issue_number: issueNumber, |
| 167 | + labels: [managedLabel.name], |
| 168 | + }); |
| 169 | + } else if (!nativeChanged && hasLabel) { |
| 170 | + try { |
| 171 | + await github.rest.issues.removeLabel({ |
| 172 | + owner: context.repo.owner, |
| 173 | + repo: context.repo.repo, |
| 174 | + issue_number: issueNumber, |
| 175 | + name: managedLabel.name, |
| 176 | + }); |
| 177 | + } catch (removeError) { |
| 178 | + if (removeError.status !== 404) { |
| 179 | + throw removeError; |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | +
|
| 184 | + core.info( |
| 185 | + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, |
| 186 | + ); |
0 commit comments