Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 15 additions & 91 deletions .github/workflows/mobile-fingerprint-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ name: Mobile Fingerprint Check

# Detects whether a PR changes the native fingerprint — i.e. whether merging
# it would leave main un-OTA-able until a new store build ships. Native-change
# PRs get the "📱 Native Change" label so they can be held and merged as a
# PRs get the "📱 Native Change" label (applied by mobile-fingerprint-label.yml,
# which runs from main so fork PRs get it too) so they can be held and merged as a
# batch right before the next store submission, keeping main OTA-able for
# everything else in between. (Once one native PR merges, every later merge
# inherits the drifted fingerprint and loses OTA reach too — that is why the
Expand All @@ -27,8 +28,6 @@ jobs:
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
contents: read
issues: write
pull-requests: write
env:
APP_VARIANT: production
NODE_OPTIONS: --max-old-space-size=8192
Expand Down Expand Up @@ -127,94 +126,19 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT"
- name: Sync native change label
# Fork PRs get a read-only token under pull_request; the check stays
# advisory there (summary only). This workflow must not move to
# pull_request_target — it installs and runs PR code.
if: github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v8
# mobile-fingerprint-label.yml reads this from main with a write token
# and syncs the label; this job's token is read-only for fork PRs.
- name: Record changed platforms
env:
CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }}
with:
script: |
const managedLabel = {
name: "📱 Native Change",
color: "d93f0b",
description:
"Changes the native fingerprint; merging blocks production OTAs until a new store build ships.",
};
const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== "";
const issueNumber = context.payload.pull_request.number;
try {
const { data: existing } = await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: managedLabel.name,
});
if (
existing.color !== managedLabel.color ||
(existing.description ?? "") !== managedLabel.description
) {
await github.rest.issues.updateLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: managedLabel.name,
color: managedLabel.color,
description: managedLabel.description,
});
}
} catch (error) {
if (error.status !== 404) {
throw error;
}
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: managedLabel.name,
color: managedLabel.color,
description: managedLabel.description,
});
} catch (createError) {
if (createError.status !== 422) {
throw createError;
}
}
}
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
const hasLabel = currentLabels.some((label) => label.name === managedLabel.name);
if (nativeChanged && !hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: [managedLabel.name],
});
} else if (!nativeChanged && hasLabel) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: managedLabel.name,
});
} catch (removeError) {
if (removeError.status !== 404) {
throw removeError;
}
}
}
run: |
mkdir -p "$RUNNER_TEMP/fingerprint-result"
printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms"
core.info(
`PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`,
);
- name: Upload changed platforms
uses: actions/upload-artifact@v7
with:
name: fingerprint-result
path: ${{ runner.temp }}/fingerprint-result
if-no-files-found: error
retention-days: 1
186 changes: 186 additions & 0 deletions .github/workflows/mobile-fingerprint-label.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
name: Mobile Fingerprint Label

# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code
# (fork PRs included) with a read-only token and records which platforms'
# fingerprints changed. This one runs from main with a write token and syncs
# the "📱 Native Change" label. It never checks out or runs PR code.
#
# A PR can edit the check workflow it runs under, so the recorded result is
# untrusted data: it is validated, and at worst a PR mislabels itself.

on:
workflow_run:
workflows: [Mobile Fingerprint Check]
types: [completed]

permissions:
contents: read

jobs:
label:
name: Sync native change label
# A cancelled run was superseded by a newer push, whose run labels instead.
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- id: pr
name: Resolve the pull request behind the check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const run = context.payload.workflow_run;
const { owner, repo } = context.repo;

const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: run.id,
per_page: 100,
});
const results = artifacts.filter(
(artifact) => artifact.name === "fingerprint-result" && !artifact.expired,
);
if (results.length !== 1) {
core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`);
return;
}

// workflow_run.pull_requests is empty for fork PRs, so resolve the
// PR from the checked commit. Requiring the PR's current head to be
// that commit keeps a slow run for an older push from overwriting a
// newer result.
const associated = await github.paginate(
github.rest.repos.listPullRequestsAssociatedWithCommit,
{ owner, repo, commit_sha: run.head_sha, per_page: 100 },
);
const matching = associated.filter(
(candidate) =>
candidate.state === "open" &&
candidate.head.sha === run.head_sha &&
candidate.head.ref === run.head_branch &&
candidate.head.repo?.full_name === run.head_repository?.full_name,
);
if (matching.length !== 1) {
core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`);
return;
}

core.setOutput("artifact_id", String(results[0].id));
core.setOutput("pr_number", String(matching[0].number));

# Read the archive as bytes; never extract PR-produced files into a
# workspace this job runs code from.
- id: result
name: Read changed platforms
if: steps.pr.outputs.pr_number != ''
shell: bash
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip"
changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)"
if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then
echo "Unexpected changed-platforms value '$changed'." >&2
exit 1
fi
echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT"

- name: Sync native change label
if: steps.pr.outputs.pr_number != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }}
with:
script: |
const managedLabel = {
name: "📱 Native Change",
color: "d93f0b",
description:
"Changes the native fingerprint; merging blocks production OTAs until a new store build ships.",
};
const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== "";
const issueNumber = Number(process.env.PR_NUMBER);

try {
const { data: existing } = await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: managedLabel.name,
});

if (
existing.color !== managedLabel.color ||
(existing.description ?? "") !== managedLabel.description
) {
await github.rest.issues.updateLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: managedLabel.name,
color: managedLabel.color,
description: managedLabel.description,
});
}
} catch (error) {
if (error.status !== 404) {
throw error;
}

try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: managedLabel.name,
color: managedLabel.color,
description: managedLabel.description,
});
} catch (createError) {
if (createError.status !== 422) {
throw createError;
}
}
}

const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
const hasLabel = currentLabels.some((label) => label.name === managedLabel.name);

if (nativeChanged && !hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: [managedLabel.name],
});
} else if (!nativeChanged && hasLabel) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: managedLabel.name,
});
} catch (removeError) {
if (removeError.status !== 404) {
throw removeError;
}
}
}

core.info(
`PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`,
);
Loading