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
4 changes: 4 additions & 0 deletions .ci/changed-file-filters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +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_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'
33 changes: 26 additions & 7 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,19 @@ pnpm --dir protocol test --coverage
A failing threshold fails the test command and its CI job. Add tests for the
uncovered behavior instead of lowering the threshold. The thresholds live in
`platforms/web/vite.config.ts`, `platforms/react-native/jest.config.js`, and
`protocol/vitest.config.ts`. Swift, Android, and standalone telemetry tests do
not yet enforce coverage thresholds.
`protocol/vitest.config.ts`.

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-<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 one **Coverage Report** comment on same-repository pull requests for
Web, React Native JavaScript, and Embedded Checkout Protocol (TS). The table
shows status emojis, line, branch, and function coverage percentages, and links
Web, React Native JavaScript, Android, both Swift SDK targets, and Embedded
Checkout Protocol (TS, Kotlin, and Swift). The table shows status emojis, line,
branch, and function/method coverage percentages where available, and 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
Expand All @@ -115,15 +122,27 @@ without a delta. Baseline jobs do not gate the PR or replace its test results.

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, reading all current results so simultaneous completions
do not lose data. A manual run of `Coverage Report` with the PR number refreshes
interrupted reporting.
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
per-file line coverage from the package tests; inspect `ShopifyCheckoutKit` and
`ShopifyAcceleratedCheckouts` for the SDK coverage. Xcode also lists test and
dependency targets, sometimes with no executable lines; compare the named
production targets rather than the overall total. The full Swift result bundle
is available locally at `.xcresults/ShopifyCheckoutKit-Package-coverage.xcresult`.
Neither report includes sample app or end-to-end tests. Standalone TypeScript
telemetry tests do not yet collect coverage.

### Testing PR builds with Tophat

[Tophat](https://github.com/Shopify/tophat) is a macOS menu-bar app that
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/report-coverage/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ inputs:
description: Coverage platform key
report-path:
required: true
description: Coverage summary JSON, relative to the repository root
description: Coverage summary JSON or JaCoCo XML, relative to the repository root
report-directory:
required: true
description: Directory containing the downloadable reports
Expand Down
67 changes: 44 additions & 23 deletions .github/scripts/publish-coverage.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ 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 === "workflow_dispatch") {
const pr = Number(context.payload.inputs?.pr);
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;
Expand All @@ -34,14 +40,16 @@ async function sources(github, repo, pr) {
}),
]);
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};
return {run, checks, pipeline: pipelineCheck(checks.filter((check) => check.head_sha === pr.head.sha))};
}

function reportURL(value, repo, run) {
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 = url.hostname === "github.com" && url.pathname.startsWith(artifactPrefix) && /^[0-9]+$/.test(url.pathname.slice(artifactPrefix.length));
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;
Expand Down Expand Up @@ -69,26 +77,34 @@ function readResult(check, platform, expected, pr, repo, run) {
throw new Error("Invalid coverage counts");
});
}
return {...result, reportUrl: reportURL(result.reportUrl, repo, run)};
return {...result, reportUrl: reportURL(result.reportUrl, repo, platform, run)};
}

function platformResult(platform, snapshot, jobs, pr, repo, core, revision = "head") {
const {run, checks} = snapshot;
let state = "pending";
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 {run, pipeline, checks} = snapshot;
const baseline = revision === "base";
const jobName = baseline ? `Coverage baseline (${platform.id}) / Test` : platform.job;
const skippedJob = baseline ? `Coverage baseline (${platform.id})` : platform.skippedJob || platform.title;
const job = latest(jobs.filter((item) => item.name === jobName)) ||
latest(jobs.filter((item) => item.name === skippedJob));
const expected = {provider: "github-actions", runId: run.id, runAttempt: job?.run_attempt ?? run.run_attempt, revision};
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";
if (baseline && !platform.job) return {state: "unavailable"};
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 jobName = baseline ? `Coverage baseline (${platform.id}) / Test` : platform.job;
const skippedJob = baseline ? `Coverage baseline (${platform.id})` : platform.skippedJob || platform.title;
const job = latest(jobs.filter((item) => item.name === jobName)) ||
latest(jobs.filter((item) => item.name === skippedJob));
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";
}
expected.revision = revision;
const externalId = `coverage${baseline ? "-base" : ""}:${platform.id}:${expected.runId}:${expected.runAttempt}`;
const check = latest(checks.filter((item) => item.app?.slug === expected.provider &&
item.external_id === externalId && item.name === `Coverage${baseline ? " base" : ""} — ${platform.title}`));
Expand Down Expand Up @@ -130,13 +146,18 @@ function render(results, baselines = {}) {
const comparable = result.state === "success" && baseline?.state === "success" &&
result.baseSha && result.baseSha === baseline.baseSha;
const value = (name) => metric(find(name), comparable ? baseline.rows.find((row) => row[0] === name) : undefined);
lines.push(`| ${emoji} | ${platform.displayTitle || platform.title} | ${value("Lines")} | ${value("Branches")} | ${value("Functions")} | ${report} |`);
if (platform.id === "swift") {
for (const target of platform.metrics)
lines.push(`| ${emoji} | Swift · ${target} | ${value(target)} | — | — | ${report} |`);
} else {
lines.push(`| ${emoji} | ${platform.displayTitle || platform.title} | ${value("Lines")} | ${value("Branches")} | ${value(find("Functions") ? "Functions" : "Methods")} | ${report} |`);
}
}
lines.push("", "Changes in parentheses are **percentage points versus the PR’s base commit**. No delta appears when matching base coverage is unavailable.");
return lines.join("\n") + "\n";
}

const sourceKey = ({run}) => `${run?.id}:${run?.run_attempt}`;
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;
Expand Down
Loading
Loading