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
16 changes: 6 additions & 10 deletions .github/codecov.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
codecov:
require_ci_to_pass: true
require_ci_to_pass: false
notify:
after_n_builds: 1 # Only Linux Debug generates coverage
wait_for_ci: true
wait_for_ci: false

coverage:
precision: 2
Expand All @@ -15,20 +15,20 @@ coverage:
target: 30%
threshold: 2%
base: auto
if_ci_failed: error
if_ci_failed: success
patch:
default:
target: 40%
target: 30%
threshold: 5%
base: auto
if_ci_failed: error
if_ci_failed: success

comment:
layout: "reach,diff,flags,tree,footer"
behavior: default
require_changes: false
require_base: false
require_head: true
require_head: false

ignore:
- "test/**/*"
Expand All @@ -45,7 +45,3 @@ flags:
paths:
- src/
carryforward: true
release:
paths:
- src/
carryforward: true
53 changes: 53 additions & 0 deletions .github/scripts/find_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Find a build artifact by glob pattern in a directory.
Usage:
find_artifact.py --build-dir DIR --pattern PATTERN
Outputs (GITHUB_OUTPUT):
path - Full path to the artifact
found - 'true' or 'false'
"""

from __future__ import annotations

import argparse
import os
from pathlib import Path


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--build-dir", type=Path, required=True)
parser.add_argument("--pattern", required=True, help="Glob pattern (e.g. '*.apk')")
args = parser.parse_args()

github_output = os.environ.get("GITHUB_OUTPUT")

def set_output(key: str, value: str) -> None:
if github_output:
with open(github_output, "a", encoding="utf-8") as f:
f.write(f"{key}={value}\n")

if not args.build_dir.is_dir():
print(f"::warning::Build directory does not exist: {args.build_dir}")
set_output("found", "false")
return 0

matches = list(args.build_dir.rglob(args.pattern))
files = [m for m in matches if m.is_file()]

if not files:
print(f"::warning::No artifact matching {args.pattern} found")
set_output("found", "false")
return 0

artifact = files[0]
print(f"Found artifact: {artifact}")
set_output("path", str(artifact))
set_output("found", "true")
return 0


if __name__ == "__main__":
raise SystemExit(main())
32 changes: 32 additions & 0 deletions .github/scripts/tests/test_find_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Tests for find_artifact.py."""

from __future__ import annotations

from pathlib import Path

from find_artifact import main


def test_missing_dir(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr("sys.argv", ["prog", "--build-dir", str(tmp_path / "nope"), "--pattern", "*.apk"])
assert main() == 0


def test_no_match(tmp_path: Path, monkeypatch) -> None:
(tmp_path / "other.txt").write_text("x")
monkeypatch.setattr("sys.argv", ["prog", "--build-dir", str(tmp_path), "--pattern", "*.apk"])
assert main() == 0


def test_finds_artifact(tmp_path: Path, monkeypatch) -> None:
apk = tmp_path / "sub" / "app.apk"
apk.parent.mkdir()
apk.write_text("x")
output_file = tmp_path / "gh_output"
output_file.write_text("")
monkeypatch.setenv("GITHUB_OUTPUT", str(output_file))
monkeypatch.setattr("sys.argv", ["prog", "--build-dir", str(tmp_path), "--pattern", "*.apk"])
assert main() == 0
output = output_file.read_text()
assert "found=true" in output
assert str(apk) in output
59 changes: 59 additions & 0 deletions .github/scripts/tests/test_verify_coverage_thresholds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Tests for verify_coverage_thresholds.py."""

from __future__ import annotations

from pathlib import Path

from verify_coverage_thresholds import main


def _write_xml(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")


def test_missing_file_returns_zero(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(
"sys.argv",
["prog", "--coverage-xml", str(tmp_path / "missing.xml")],
)
assert main() == 0


def test_above_thresholds(tmp_path: Path, monkeypatch) -> None:
xml = tmp_path / "coverage.xml"
_write_xml(xml, '<coverage lines-valid="100" lines-covered="80" line-rate="0.80" branch-rate="0.60"/>')
monkeypatch.setattr(
"sys.argv",
["prog", "--coverage-xml", str(xml), "--line-threshold", "30", "--branch-threshold", "20"],
)
assert main() == 0


def test_below_line_threshold(tmp_path: Path, monkeypatch) -> None:
xml = tmp_path / "coverage.xml"
_write_xml(xml, '<coverage lines-valid="100" lines-covered="10" line-rate="0.10" branch-rate="0.50"/>')
monkeypatch.setattr(
"sys.argv",
["prog", "--coverage-xml", str(xml), "--line-threshold", "30", "--branch-threshold", "20"],
)
assert main() == 1


def test_below_branch_threshold(tmp_path: Path, monkeypatch) -> None:
xml = tmp_path / "coverage.xml"
_write_xml(xml, '<coverage lines-valid="100" lines-covered="80" line-rate="0.80" branch-rate="0.10"/>')
monkeypatch.setattr(
"sys.argv",
["prog", "--coverage-xml", str(xml), "--line-threshold", "30", "--branch-threshold", "20"],
)
assert main() == 1


def test_zero_lines_valid(tmp_path: Path, monkeypatch) -> None:
xml = tmp_path / "coverage.xml"
_write_xml(xml, '<coverage lines-valid="0" lines-covered="0" line-rate="0" branch-rate="0"/>')
monkeypatch.setattr(
"sys.argv",
["prog", "--coverage-xml", str(xml)],
)
assert main() == 1
62 changes: 62 additions & 0 deletions .github/scripts/verify_coverage_thresholds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Verify coverage.xml meets line and branch coverage thresholds."""

import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from xml_utils import xml_parse


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--coverage-xml", default="coverage.xml", help="Path to coverage.xml")
parser.add_argument("--line-threshold", type=float, default=30.0)
parser.add_argument("--branch-threshold", type=float, default=20.0)
args = parser.parse_args()

path = Path(args.coverage_xml)
if not path.exists():
print("::warning::coverage.xml not found, skipping threshold check")
return 0

root = xml_parse(str(path)).getroot()
if root is None:
print("::error::coverage.xml has no root element")
return 1
cov = root
lines_valid = int(cov.get("lines-valid", 0))
lines_covered = int(cov.get("lines-covered", 0))
line_rate = float(cov.get("line-rate", 0)) * 100
branch_rate = float(cov.get("branch-rate", 0)) * 100

if lines_valid == 0:
print(
f"::error::Coverage report contains 0 lines — "
f"lines-covered={lines_covered}, line-rate={line_rate:.2f}%, branch-rate={branch_rate:.2f}%"
)
output = path.parent / "coverage-output.txt"
if output.exists():
print("::group::coverage-output tail")
for line in output.read_text(encoding="utf-8", errors="replace").splitlines()[-40:]:
print(line)
print("::endgroup::")
return 1

print(f"Line coverage: {line_rate:.1f}% (threshold: {args.line_threshold}%)")
print(f"Branch coverage: {branch_rate:.1f}% (threshold: {args.branch_threshold}%)")

failed = False
if line_rate < args.line_threshold:
print(f"::error::Line coverage {line_rate:.1f}% is below {args.line_threshold}% threshold")
failed = True
if branch_rate < args.branch_threshold:
print(f"::error::Branch coverage {branch_rate:.1f}% is below {args.branch_threshold}% threshold")
failed = True

return 1 if failed else 0


if __name__ == "__main__":
raise SystemExit(main())
35 changes: 5 additions & 30 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
name: Docker ${{ matrix.platform }}
runs-on: ubuntu-latest
needs: [changes, plan-builds]
if: always() && !cancelled() && needs.plan-builds.outputs.has_jobs == 'true' && vars.DOCKER_BUILD_ENABLED == 'true'
if: always() && !cancelled() && needs.plan-builds.outputs.has_jobs == 'true'
timeout-minutes: 120

strategy:
Expand Down Expand Up @@ -124,35 +124,10 @@ jobs:

- name: Find build artifact
id: artifact
env:
BUILD_DIR: ${{ github.workspace }}/build
ARTIFACT_PATTERN: ${{ matrix.artifact_pattern }}
run: |
set +o pipefail # Disable pipefail to handle find | head gracefully
echo "Searching for ${ARTIFACT_PATTERN} in ${BUILD_DIR}"

# Check if build directory exists
if [ ! -d "${BUILD_DIR}" ]; then
echo "::warning::Build directory does not exist: ${BUILD_DIR}"
echo "found=false" >> "${GITHUB_OUTPUT}"
exit 0
fi

# Show build directory structure for debugging (ignore permission errors from Docker-created dirs)
echo "Build directory contents:"
find "${BUILD_DIR}" -maxdepth 4 \( -name "*.apk" -o -name "*.AppImage" \) -type f 2>/dev/null || true

# Find the produced artifact (APK or AppImage)
# Use -quit for efficiency and to avoid broken pipe with head
ARTIFACT=$(find "${BUILD_DIR}" -name "${ARTIFACT_PATTERN}" -type f -print -quit 2>/dev/null)
if [ -z "$ARTIFACT" ]; then
echo "::warning::No artifact matching ${ARTIFACT_PATTERN} found"
echo "found=false" >> "${GITHUB_OUTPUT}"
else
echo "Found artifact: $ARTIFACT"
echo "path=$ARTIFACT" >> "${GITHUB_OUTPUT}"
echo "found=true" >> "${GITHUB_OUTPUT}"
fi
run: >-
python3 "${GITHUB_WORKSPACE}/.github/scripts/find_artifact.py"
--build-dir "${{ github.workspace }}/build"
--pattern "${{ matrix.artifact_pattern }}"

- name: Compute Trivy cache key
if: steps.artifact.outputs.found == 'true'
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/docs_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ jobs:
with:
egress-policy: audit

- name: Checkout
uses: actions/checkout@v6
with:
sparse-checkout: |
.github/actions
.github/scripts

- name: Deploy docs
uses: ./.github/actions/deploy-docs
with:
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/doxygen_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ jobs:
with:
egress-policy: audit

- name: Checkout
uses: actions/checkout@v6
with:
sparse-checkout: |
.github/actions
.github/scripts

- name: Deploy docs
uses: ./.github/actions/deploy-docs
with:
Expand Down
Loading
Loading