Skip to content
Open
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
6 changes: 4 additions & 2 deletions .ci/changed-file-filters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,14 @@ ciFilters:
# decide which macOS jobs run, so a change to one has to rerun them all, while a Maestro
# flow edit under e2e/ must not.
iosCiConfig:
- 'scripts/report_native_coverage'
- 'scripts/lib/native_coverage_report.rb'
- 'scripts/report_coverage'
- 'scripts/lib/coverage_report.rb'
- 'e2e/bitrise.yml'
- 'e2e/config/ios_ci.yml'
- 'e2e/lib/ios_ci_run_plan.rb'
- 'e2e/lib/ios_ci_reporter.rb'
- 'e2e/lib/swift_coverage_status.rb'
- 'e2e/scripts/ios_ci_run_plan'
- 'e2e/scripts/report_ios_ci_results'
- 'e2e/scripts/report_swift_coverage_status'
- 'e2e/scripts/bitrise_ci_helpers'
28 changes: 21 additions & 7 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,29 @@ Swift and Android generate coverage reports without enforcing a minimum yet:

| Platform | Local command | Reports | CI artifact |
| --- | --- | --- | --- |
| Android | `dev android test coverage` | `platforms/android/lib/build/reports/coverage/test/debug/` (HTML and XML) | `android-coverage` in the GitHub Actions Android test job |
| Android | `dev android test coverage` | `platforms/android/lib/build/reports/coverage/test/debug/` (HTML and XML) | `android-coverage-<attempt>` in the GitHub Actions Android test job |
| Swift | `dev swift test coverage` | `.xcresults/coverage/` (JSON and text) | Coverage reports in the Bitrise Swift package test job |

CI updates a coverage comment per native platform on same-repository pull requests,
using the same headings, line-coverage badges, and metric formatting as the
JavaScript reports, plus a link to the full reports. Android also writes its
coverage table to the GitHub Actions job summary.
Fork builds and builds without a write token keep the artifacts and log summaries
without posting a comment. These summaries report coverage without enforcing 85%.
CI updates one **Coverage Report** comment on same-repository pull requests, with
sections for Web, React Native JavaScript, TypeScript Protocol, Android, and Swift.
Each section uses the same line-coverage badges and metric formatting, with links
to the full reports. Jobs that are pending, skipped, failed, or missing a report
show their status instead of coverage from an older run. GitHub Actions jobs also
write their coverage tables to the job summary. Fork builds and builds without a
write token keep their artifacts and log summaries without posting a comment.

Platform jobs store numeric results in GitHub checks. The `Coverage Report`
workflow is the only comment writer and serializes updates per PR. It refreshes
on CI workflow events and Bitrise's `coverage-updated` repository dispatch,
reading all current results so simultaneous completions do not lose data.
Bitrise uses its existing GitHub App token (checks and contents write permissions)
and records skipped/failed Swift jobs when the iOS pipeline finishes. A manual
run of `Coverage Report` with the PR number refreshes interrupted reporting.

The publisher executes trusted default-branch code and validates the numeric
results; it never executes PR-provided reporting code. This workflow begins
publishing once it exists on `main`. Coverage thresholds remain in the test
runners; combining the comments does not change their enforcement.

Android reports cover the library's debug unit tests, including Robolectric
tests and the library's telemetry code. Swift reports contain per-target and
Expand Down
38 changes: 38 additions & 0 deletions .github/actions/report-coverage/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Record coverage
description: Save a platform result for the shared coverage comment publisher
inputs:
platform:
required: true
description: Coverage platform key
report-path:
required: true
description: Coverage summary JSON or JaCoCo XML, relative to the repository root
report-directory:
required: true
description: Directory containing the downloadable reports
outcome:
required: true
description: Outcome of the coverage test step
runs:
using: composite
steps:
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
id: artifact
if: ${{ hashFiles(inputs.report-path) != '' }}
with:
name: ${{ inputs.platform }}-coverage-${{ github.run_attempt }}
path: ${{ inputs.report-directory }}
retention-days: 7
- name: Record coverage result
shell: bash
working-directory: ${{ github.workspace }}
env:
COVERAGE_PLATFORM: ${{ inputs.platform }}
COVERAGE_PATH: ${{ inputs.report-path }}
COVERAGE_TEST_OUTCOME: ${{ inputs.outcome }}
COVERAGE_REPOSITORY: ${{ github.repository }}
COVERAGE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
COVERAGE_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}
COVERAGE_TOKEN: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.token || '' }}
COVERAGE_REPORT_URL: ${{ steps.artifact.outputs.artifact-url }}
run: ruby scripts/report_coverage "$COVERAGE_PLATFORM" "$COVERAGE_PATH"
169 changes: 169 additions & 0 deletions .github/scripts/publish-coverage.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
const marker = "<!-- checkout-kit-coverage -->";
const jsMetrics = ["Lines", "Statements", "Branches", "Functions"];
const platforms = [
{id: "web", title: "Web", job: "Web / Lint, test, build, verify", metrics: jsMetrics},
{id: "react-native", title: "React Native", job: "React Native / Run jest tests", metrics: jsMetrics},
{id: "android", title: "Android", job: "Android / test", metrics: ["Lines", "Instructions", "Branches", "Methods"]},
{id: "swift", title: "Swift", metrics: ["ShopifyCheckoutKit", "ShopifyAcceleratedCheckouts"]},
{id: "protocol", title: "Embedded Checkout Protocol (TypeScript)", displayTitle: "Embedded Checkout Protocol (TS)", job: "Protocol / Test", skippedJob: "Protocol", metrics: jsMetrics},
{id: "protocol-kotlin", title: "Embedded Checkout Protocol (Kotlin)", job: "Android / test", skippedJob: "Android", metrics: ["Lines", "Instructions", "Branches", "Methods"]},
{id: "protocol-swift", title: "Embedded Checkout Protocol (Swift)", metrics: ["Lines", "Functions"]},
];

const latest = (items) => [...items].sort((a, b) => b.id - a.id)[0];
const isComment = (comment) => comment.user?.type === "Bot" &&
comment.user.login === "github-actions[bot]" && comment.body?.startsWith(marker);
const pipelineCheck = (checks) => latest(checks.filter((check) =>
check.app?.slug === "bitrise" && check.name === "ci/bitrise/ci-ios/pr"));

async function resolvePR({github, context}) {
if (context.eventName === "repository_dispatch" || context.eventName === "workflow_dispatch") {
const pr = Number(context.payload.client_payload?.pr ?? context.payload.inputs?.pr);
return Number.isSafeInteger(pr) && pr > 0 ? pr : null;
}
const run = context.payload.workflow_run;
if (run?.event !== "pull_request" || run.path !== ".github/workflows/ci.yml") return null;
const prs = run.pull_requests?.length ? run.pull_requests :
await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
...context.repo, commit_sha: run.head_sha, per_page: 100,
});
return prs.find((pr) => pr.head.sha === run.head_sha && pr.head.repo?.id === run.head_repository?.id)?.number ?? null;
}

async function sources(github, repo, pr) {
const [runs, checks] = await Promise.all([
github.paginate(github.rest.actions.listWorkflowRuns, {
...repo, workflow_id: "ci.yml", event: "pull_request", head_sha: pr.head.sha, per_page: 100,
}),
github.paginate(github.rest.checks.listForRef, {
...repo, ref: pr.head.sha, filter: "all", per_page: 100,
}),
]);
const run = latest(runs.filter((item) => item.head_sha === pr.head.sha && item.head_repository?.id === pr.head.repo.id && item.head_branch === pr.head.ref));
return {run, checks, pipeline: pipelineCheck(checks.filter((check) => check.head_sha === pr.head.sha))};
}

function reportURL(value, repo, platform, run) {
if (!value) return null;
const url = new URL(value);
const artifactPrefix = `/${repo.owner}/${repo.repo}/actions/runs/${run?.id}/artifacts/`;
const valid = !platform.job
? url.hostname === "app.bitrise.io" && /^\/app\/[\w-]+\/build\/[\w-]+$/.test(url.pathname)
: url.hostname === "github.com" && url.pathname.startsWith(artifactPrefix) && /^[0-9]+$/.test(url.pathname.slice(artifactPrefix.length));
if (!valid || url.protocol !== "https:" || url.username || url.password || url.search || url.hash)
throw new Error("Invalid coverage report link");
return url.href;
}

function readResult(check, platform, expected, pr, repo, run) {
if (check.head_sha !== pr.head.sha || !check.output?.text || check.output.text.length > 32768)
throw new Error("Invalid coverage check");
const result = JSON.parse(check.output.text);
if (result.version !== 1 || result.platform !== platform.id || result.pr !== pr.number ||
result.headSha !== pr.head.sha || result.source?.provider !== expected.provider ||
result.source.runId !== expected.runId || (result.source.runAttempt ?? 1) !== expected.runAttempt ||
!["success", "failed", "skipped", "unavailable"].includes(result.state) || !Array.isArray(result.rows))
throw new Error("Coverage result does not match this run");
if (["skipped", "unavailable"].includes(result.state) && result.rows.length)
throw new Error("Unexpected measurements for an unmeasured platform");
if (result.rows.length || result.state === "success") {
if (result.rows.length !== platform.metrics.length) throw new Error("Missing coverage metrics");
result.rows.forEach((row, index) => {
if (!Array.isArray(row) || row.length !== 3 || row[0] !== platform.metrics[index] ||
!Number.isSafeInteger(row[1]) || !Number.isSafeInteger(row[2]) || row[1] < 0 || row[2] < row[1])
throw new Error("Invalid coverage counts");
});
}
return {...result, reportUrl: reportURL(result.reportUrl, repo, platform, run)};
}

function platformResult(platform, snapshot, jobs, pr, repo, core) {
const {run, pipeline, checks} = snapshot;
let expected, state = "pending";
if (!platform.job) {
if (!pipeline) return {state};
expected = {provider: "bitrise", runId: pipeline.id, runAttempt: pipeline.external_id ?? 1};
if (pipeline.status === "completed") state = pipeline.conclusion === "success" ? "unavailable" : "failed";
} else {
if (!run) return {state};
// A failed-jobs rerun reuses successful jobs from an earlier attempt. Match
// each result to the attempt that actually ran that platform's test job.
const job = latest(jobs.filter((item) => item.name === platform.job)) ||
latest(jobs.filter((item) => item.name === (platform.skippedJob || platform.title)));
expected = {provider: "github-actions", runId: run.id, runAttempt: job?.run_attempt ?? run.run_attempt};
const plan = latest(jobs.filter((item) => item.name === "Detect Changed Areas"));
if (plan?.status === "completed" && plan.conclusion !== "success") return {state: "unavailable"};
if (job?.conclusion === "skipped") return {state: "skipped"};
if (job?.status === "completed") state = job.conclusion === "success" ? "unavailable" : "failed";
else if (!job && run.status === "completed") state = "unavailable";
}
const externalId = `coverage:${platform.id}:${expected.runId}:${expected.runAttempt}`;
const check = latest(checks.filter((item) => item.app?.slug === expected.provider &&
item.external_id === externalId && item.name === `Coverage — ${platform.title}`));
if (!check) return {state};
try {
return readResult(check, platform, expected, pr, repo, run);
} catch (error) {
core.warning(`${platform.title}: ${error.message}`);
return {state: "unavailable"};
}
}

function metric(row) {
if (!row) return "—";
const [, covered, total] = row;
if (!total) return "N/A";
const percentage = Number((100 * covered / total).toFixed(2));
return `${percentage}%`;
}

function render(results) {
const lines = [marker, "# Coverage Report", "",
"| Status | Platform / target | Lines | Branches | Functions / Methods | Report |",
"| :---: | --- | ---: | ---: | ---: | --- |"];
const statuses = {
success: ["✅", "—"], pending: ["⏳", "Waiting for coverage"],
skipped: ["⏭️", "Not run for this change"], failed: ["❌", "Tests or coverage collection failed"],
unavailable: ["⚠️", "Coverage report unavailable"],
};
for (const platform of platforms) {
const result = results[platform.id];
const rows = result.rows || [];
const [emoji, status] = statuses[result.state];
const report = result.reportUrl ? `[Full report](${result.reportUrl})` : status;
if (platform.id === "swift") {
for (const target of platform.metrics)
lines.push(`| ${emoji} | Swift · ${target} | ${metric(rows.find(([name]) => name === target))} | — | — | ${report} |`);
} else {
const find = (name) => rows.find((row) => row[0] === name);
lines.push(`| ${emoji} | ${platform.displayTitle || platform.title} | ${metric(find("Lines"))} | ${metric(find("Branches"))} | ${metric(find("Functions") || find("Methods"))} | ${report} |`);
}
}
return lines.join("\n") + "\n";
}

const sourceKey = ({run, pipeline}) => `${run?.id}:${run?.run_attempt}:${pipeline?.id}:${pipeline?.external_id}`;

async function publish({github, context, core, prNumber}) {
const repo = context.repo;
const {data: pr} = await github.rest.pulls.get({...repo, pull_number: prNumber});
// Match the producer policy: forks keep artifacts/logs without comment writes.
if (pr.state !== "open" || pr.draft || pr.head.repo?.full_name !== `${repo.owner}/${repo.repo}`) return;
const snapshot = await sources(github, repo, pr);
const jobs = snapshot.run ? await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
...repo, run_id: snapshot.run.id, filter: "all", per_page: 100,
}) : [];
const results = Object.fromEntries(platforms.map((platform) =>
[platform.id, platformResult(platform, snapshot, jobs, pr, repo, core)]));
const body = render(results);
const comments = await github.paginate(github.rest.issues.listComments, {...repo, issue_number: pr.number, per_page: 100});
const existing = comments.find(isComment);
const {data: current} = await github.rest.pulls.get({...repo, pull_number: pr.number});
if (current.state !== "open" || current.draft || current.head.sha !== pr.head.sha || current.base.sha !== pr.base.sha) return;
if (sourceKey(await sources(github, repo, current)) !== sourceKey(snapshot)) return;
if (existing?.body === body) return;
if (existing) await github.rest.issues.updateComment({...repo, comment_id: existing.id, body});
else await github.rest.issues.createComment({...repo, issue_number: pr.number, body});
}

module.exports = {resolvePR, publish, readResult, platformResult, render, platforms, marker, isComment};
Loading
Loading