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
15 changes: 15 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.bat -text
*.jar binary
*.dex binary
*.apk binary
*.zip binary
tools/busybox binary
tools/cmd binary
tools/find binary
tools/jq binary
tools/keycheck binary
tools/smbclient binary
tools/speednative binary
tools/tar binary
tools/zstd binary
83 changes: 83 additions & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Android checks and build

on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: android-ci-${{ github.event.pull_request.head.ref || github.ref_name }}
cancel-in-progress: true

jobs:
source-checks:
name: Source, shell syntax and runtime SHA
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.13'
- name: Install mksh
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y mksh
- name: Check source and committed tools
run: python ci/check_repository.py check --shells

android-build:
name: Android arm64 Dex and Rust
needs: source-checks
runs-on: windows-2022
timeout-minutes: 45
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.13'
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5
with:
distribution: temurin
java-version: '17'
- uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-windows-jdk17-${{ hashFiles('dex/**/*.gradle.kts', 'dex/**/*.toml', 'dex/**/gradle-wrapper.properties') }}
restore-keys: gradle-windows-jdk17-
- name: Build and package checked Android artifacts
run: |
New-Item -ItemType Directory -Path ci-output -Force | Out-Null
& pwsh -NoProfile -File ./ci/build_android.ps1 *>&1 | Tee-Object -FilePath ci-output/build.log
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload matching runtime and source snapshot
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: speedbackup-android-${{ github.sha }}
path: |
ci-output/SpeedBackup_*_CI_RUNTIME.zip
ci-output/SpeedBackup_*_SOURCE_SNAPSHOT.zip
ci-output/SHA256SUMS.txt
ci-output/BUILD_INFO.json
if-no-files-found: error
retention-days: 14
- name: Upload build diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: android-build-log-${{ github.sha }}
path: ci-output/build.log
if-no-files-found: warn
retention-days: 14
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1 @@

/ci-output/
50 changes: 50 additions & 0 deletions ci/build_android.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$rootDir = Split-Path -Parent $PSScriptRoot
$outputDir = Join-Path $rootDir 'ci-output'
$null = New-Item -ItemType Directory -Path $outputDir -Force
Push-Location -LiteralPath $rootDir
try {
$config = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'toolchain.json') | ConvertFrom-Json
& python (Join-Path $PSScriptRoot 'check_repository.py') check
if ($LASTEXITCODE -ne 0) { throw 'Repository checks failed.' }

$sdkRoot = $env:ANDROID_HOME
if (-not $sdkRoot) { $sdkRoot = $env:ANDROID_SDK_ROOT }
if (-not $sdkRoot -or -not (Test-Path -LiteralPath $sdkRoot)) { throw 'Android SDK is missing on the runner.' }
$sdkManager = Join-Path $sdkRoot 'cmdline-tools/latest/bin/sdkmanager.bat'
if (-not (Test-Path -LiteralPath $sdkManager)) { throw "sdkmanager not found: $sdkManager" }
$env:ANDROID_HOME = $sdkRoot
$env:ANDROID_SDK_ROOT = $sdkRoot
& $sdkManager --version
if ($LASTEXITCODE -ne 0) { throw 'sdkmanager failed.' }
1..100 | ForEach-Object { 'y' } | & $sdkManager --licenses
if ($LASTEXITCODE -ne 0) { throw 'Android SDK license acceptance failed.' }
& $sdkManager "platforms;android-$($config.compile_sdk)" "build-tools;$($config.build_tools)" "ndk;$($config.ndk)"
if ($LASTEXITCODE -ne 0) { throw 'Android SDK/NDK installation failed.' }

& rustup toolchain install $config.rust --profile minimal --target $config.rust_target
if ($LASTEXITCODE -ne 0) { throw 'Pinned Rust installation failed.' }
$env:RUSTUP_TOOLCHAIN = $config.rust
& rustc --version
if ($LASTEXITCODE -ne 0) { throw 'rustc failed.' }

# New source layouts may provide generated component-version metadata.
$syncVersion = Join-Path $rootDir 'sync_version.ps1'
if (Test-Path -LiteralPath $syncVersion) {
& pwsh -NoProfile -File $syncVersion -Check
if ($LASTEXITCODE -ne 0) { throw 'Generated version metadata is out of sync.' }
}

& pwsh -NoProfile -File (Join-Path $rootDir 'dex/build_dex.ps1') -JavaHome $env:JAVA_HOME -SdkRoot $sdkRoot -NoDaemon
if ($LASTEXITCODE -ne 0) { throw 'Dex build failed.' }
& pwsh -NoProfile -File (Join-Path $rootDir 'rust/build.ps1') -Sdk $sdkRoot -Ndk (Join-Path $sdkRoot "ndk/$($config.ndk)")
if ($LASTEXITCODE -ne 0) { throw 'Rust build failed.' }

& python (Join-Path $PSScriptRoot 'check_repository.py') package
if ($LASTEXITCODE -ne 0) { throw 'Artifact validation/packaging failed.' }
} finally {
Pop-Location
}
203 changes: 203 additions & 0 deletions ci/check_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""CI source/runtime integrity and packaging checks; never runs Android tools."""
import argparse
import hashlib
import json
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import tomllib
import zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
REQUIRED_TOOLS = {
'busybox', 'classes.dex', 'cmd', 'dex_check.sh', 'find', 'jq',
'keycheck', 'smbclient', 'speednative', 'tar', 'zstd',
}
TEXT_SUFFIXES = {'.sh', '.rs', '.java', '.kt', '.kts', '.ps1', '.toml', '.yml', '.yaml'}


def require(condition, message):
if not condition:
raise ValueError(message)


def sha256(data):
return hashlib.sha256(data).hexdigest()


def git(*args):
return subprocess.check_output(['git', *args], cwd=ROOT)


def runtime_table(script):
blocks = list(re.finditer(r"(?m)^\s*cat <<'SB_TOOL_SHA_TABLE'\n(.*?)^SB_TOOL_SHA_TABLE$", script, re.S))
require(len(blocks) == 1, 'Expected exactly one runtime SHA table')
entries = {}
for line in blocks[0].group(1).splitlines():
match = re.fullmatch(r'([A-Za-z0-9_.-]+) ([0-9a-f]{64})', line)
require(match is not None, f'Malformed runtime SHA row: {line!r}')
name, digest = match.groups()
require(name not in entries, f'Duplicate runtime SHA row: {name}')
entries[name] = digest
require(REQUIRED_TOOLS <= entries.keys(), 'Missing required runtime SHA rows')
return entries


def verify_runtime(directory):
script = (directory / 'tools.sh').read_bytes().decode('utf-8')
require('\r' not in script, 'tools.sh must use LF')
entries = runtime_table(script)
for name, expected in entries.items():
path = directory / name
require(path.is_file() and not path.is_symlink(), f'Missing regular runtime file: {name}')
actual = sha256(path.read_bytes())
require(actual == expected, f'{name}: runtime SHA mismatch; expected {expected}, got {actual}')
print(f'Runtime SHA: {len(entries)} files match')


def check_versions():
config = ROOT / 'versions.properties'
if not config.exists():
print('Versions: existing legacy scheme (no versions.properties)')
return
values = {}
for line in config.read_text(encoding='utf-8').splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
key, value = line.split('=', 1)
require(key not in values, f'Duplicate version key: {key}')
require(re.fullmatch(r'v[1-9][0-9]{2}', value), f'Invalid version: {key}={value}')
values[key] = value
required = {'build', 'script', 'dex', 'cgfreezer', 'eventwait', 'filewatch',
'netwatch', 'procwait', 'speedscan', 'uidexec', 'unixsock'}
require(required == values.keys(), 'Missing or unknown component version keys')
require((ROOT / 'VERSION').read_text().strip() == values['build'], 'VERSION differs from build')
script = (ROOT / 'tools/tools.sh').read_text(encoding='utf-8')
require(f'speedbackup_script_version="{values["script"]}"' in script, 'Script function version differs')
if re.search(r'(?m)^speedbackup_patch_build=', script):
require(f'speedbackup_patch_build="{values["build"]}"' in script, 'Script build version differs')
for name in ('rust/build.rs', 'read_versions.ps1', 'sync_version.ps1', 'sync_artifacts.ps1', 'dex/release-version.properties'):
require((ROOT / name).is_file(), f'Missing version build input: {name}')
dex_versions = dict(line.split('=', 1) for line in
(ROOT / 'dex/release-version.properties').read_text(encoding='utf-8').splitlines()
if line.strip() and not line.lstrip().startswith('#'))
require(dex_versions.get('version') == values['dex'] and
dex_versions.get('build') == values['build'], 'Dex component/build version differs')
self_check = (ROOT / 'tools/dex_check.sh').read_text(encoding='utf-8')
require(f'DEX_CHECK_VERSION="{values["script"]}"' in self_check and
f'DEX_CHECK_BUILD="{values["build"]}"' in self_check, 'Self-check component/build version differs')
cargo = tomllib.loads((ROOT / 'rust/Cargo.toml').read_text(encoding='utf-8'))
require(cargo['package']['version'] == f'{int(values["build"][1:])}.0.0', 'Cargo build version differs')
print('Versions: centralized component metadata checked')


def check(shells=False):
names = git('ls-files', '-z').decode('utf-8').rstrip('\0').split('\0')
for name in ['start.sh', 'tools/tools.sh', 'tools/dex_check.sh', 'tools/soc.json',
'rust/Cargo.toml', 'rust/Cargo.lock', 'rust/src/main.rs', 'rust/build.ps1',
'dex/build_dex.ps1', 'dex/gradlew', 'dex/gradlew.bat',
'dex/gradle/wrapper/gradle-wrapper.jar', 'dex/gradle/wrapper/gradle-wrapper.properties']:
require((ROOT / name).is_file(), f'Missing required file: {name}')
for name in names:
path = ROOT / name
if path.suffix not in TEXT_SUFFIXES and name != 'dex/gradlew':
continue
data = path.read_bytes()
require(not data.startswith(b'\xef\xbb\xbf'), f'Unexpected UTF-8 BOM: {name}')
require(b'\r' not in data, f'Expected LF line endings: {name}')
data.decode('utf-8')
json.loads((ROOT / 'tools/soc.json').read_text(encoding='utf-8'))
cargo = tomllib.loads((ROOT / 'rust/Cargo.toml').read_text(encoding='utf-8'))
lock = tomllib.loads((ROOT / 'rust/Cargo.lock').read_text(encoding='utf-8'))
packages = [p for p in lock['package'] if p['name'] == cargo['package']['name']]
require(len(packages) == 1 and packages[0]['version'] == cargo['package']['version'], 'Cargo.lock package version differs')
with zipfile.ZipFile(ROOT / 'dex/gradle/wrapper/gradle-wrapper.jar') as wrapper:
require(wrapper.testzip() is None, 'Corrupt Gradle wrapper JAR')
require('org/gradle/wrapper/GradleWrapperMain.class' in wrapper.namelist(), 'Missing wrapper entry point')
wrapper_props = (ROOT / 'dex/gradle/wrapper/gradle-wrapper.properties').read_text(encoding='utf-8')
require(re.search(r'(?m)^distributionSha256Sum=[0-9a-f]{64}$', wrapper_props),
'Gradle distribution SHA256 must be pinned')
verify_runtime(ROOT / 'tools')
check_versions()
if shells:
for shell in ('bash', 'mksh'):
require(shutil.which(shell), f'{shell} is not installed')
for name in names:
if name.endswith('.sh') or name == 'dex/gradlew':
subprocess.run([shell, '-n', str(ROOT / name)], cwd=ROOT, check=True)
print('Shell syntax: Bash and mksh passed (scripts not executed)')
print('Repository checks passed')


def package():
dex = ROOT / 'dex/classes.dex'
native = ROOT / 'rust/out/speednative'
require(dex.is_file() and native.is_file(), 'Missing fresh build outputs')
require(dex.read_bytes()[:4] == b'dex\n', 'Invalid Dex magic')
elf = native.read_bytes()
require(elf[:6] == b'\x7fELF\x02\x01' and struct.unpack_from('<H', elf, 18)[0] == 183,
'Expected Android arm64 ELF64')
# The Rust builder additionally checks PIE, 16 KiB segments and API28/r30 notes.
native_info = json.loads((ROOT / 'rust/out/BUILD_INFO.json').read_text(encoding='utf-8-sig'))
config = json.loads((ROOT / 'ci/toolchain.json').read_text(encoding='utf-8'))
require(native_info['ndk'] == config['ndk'] and native_info['api'] == config['android_api'], 'Unexpected NDK/API build metadata')
commit = git('rev-parse', 'HEAD').decode().strip()
output = ROOT / 'ci-output'
output.mkdir(exist_ok=True)
prefix = f'SpeedBackup_{commit[:12]}'
runtime_zip = output / f'{prefix}_CI_RUNTIME.zip'
source_zip = output / f'{prefix}_SOURCE_SNAPSHOT.zip'
with tempfile.TemporaryDirectory(prefix='speedbackup-ci-') as staging:
stage = Path(staging)
# Export tracked files only: exclude logs, machine configuration and build caches.
tracked = git('ls-files', '-z').decode().rstrip('\0').split('\0')
for name in tracked:
if name.startswith('tools/') or name in ('start.sh', 'README.md', 'LICENSE'):
dest = stage / name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / name, dest)
shutil.copyfile(dex, stage / 'tools/classes.dex')
shutil.copyfile(native, stage / 'tools/speednative')
script_path = stage / 'tools/tools.sh'
script = script_path.read_text(encoding='utf-8')
runtime_table(script) # Reject malformed tables before changing generated entries.
for name in ('classes.dex', 'speednative', 'dex_check.sh'):
digest = sha256((stage / 'tools' / name).read_bytes())
script, count = re.subn(rf'(?m)^{re.escape(name)} [0-9a-f]{{64}}$', f'{name} {digest}', script)
require(count == 1, f'Missing/duplicate SHA row for {name}')
script_path.write_text(script, encoding='utf-8', newline='\n')
verify_runtime(stage / 'tools')
artifact_hashes = {p.relative_to(stage).as_posix(): sha256(p.read_bytes()) for p in sorted(stage.rglob('*')) if p.is_file()}
with zipfile.ZipFile(runtime_zip, 'w', zipfile.ZIP_DEFLATED) as archive:
for name in artifact_hashes:
archive.write(stage / name, name)
with zipfile.ZipFile(runtime_zip) as archive:
require(archive.testzip() is None, 'Runtime ZIP CRC failure')
for name, digest in artifact_hashes.items():
require(sha256(archive.read(name)) == digest, f'ZIP bytes differ: {name}')
subprocess.run(['git', 'archive', '--format=zip', f'--output={source_zip}', commit], cwd=ROOT, check=True)
info = {'commit': commit, 'toolchain': config, 'runtime_sha256': artifact_hashes,
'android_runtime_tests': 'not executed; builds and static checks only',
'source_snapshot': 'exact Git commit; prebuilt third-party tools retained; not a FULL_SOURCE package for all dependencies'}
(output / 'BUILD_INFO.json').write_text(json.dumps(info, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n')
sums = ''.join(f'{sha256(p.read_bytes())} {p.name}\n' for p in (runtime_zip, source_zip, output / 'BUILD_INFO.json'))
(output / 'SHA256SUMS.txt').write_text(sums, encoding='ascii', newline='\n')
print(f'Packaged {runtime_zip.name} and {source_zip.name}')


if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['check', 'package'])
parser.add_argument('--shells', action='store_true')
args = parser.parse_args()
try:
check(args.shells) if args.command == 'check' else package()
except (ValueError, OSError, KeyError, subprocess.CalledProcessError, zipfile.BadZipFile) as exc:
print(f'CI ERROR: {exc}', file=sys.stderr)
sys.exit(1)
9 changes: 9 additions & 0 deletions ci/toolchain.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"rust": "1.96.0",
"rust_target": "aarch64-linux-android",
"ndk": "30.0.16248370",
"android_api": 28,
"compile_sdk": "34",
"build_tools": "34.0.0",
"java_major": "17"
}
Loading