native: shared Android, Windows and macOS workflows #7

Merged
james.maes merged 5 commits from native-workflows-pr into main 2026-11-08 13:44:54 +00:00
4 changed files with 522 additions and 0 deletions

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
# Shared Android CI: run the repository's Gradle gates in the pinned Android image (cimg/android plus the
# emulator, JDK 8/17/21/25) on the Lore android-build runners, which pass /dev/kvm through for the emulator.
#
# any push -> commands, then each Gradle line in tasks; emulator-tasks with a booted emulator
# tag vX.Y.Z -> + publish-tasks with -Pversion=X.Y.Z to the owner's Gitea Maven registry
#
# tasks: one ./gradlew invocation per line, for example
# testDebugUnitTest lintDebug assembleDebug
# -PscannerTestBuildType=qa :scanner-host:assembleQa
name: android
on:
workflow_call:
inputs:
tasks:
description: Gradle invocations, one per line (arguments to ./gradlew); every line must pass
type: string
default: build
java-version:
description: JDK major version from the image (8, 17, 21 or 25)
type: string
default: '21'
runner:
type: string
default: android-build
commands:
description: Shell commands run before Gradle, one per line (for example source checks)
type: string
default: ''
node-version:
description: Install this Node.js version (official tarball, checksum-verified) for commands; empty = none
type: string
default: ''
emulator-tasks:
description: Gradle invocations, one per line, run with a booted emulator (connected tests); empty = no emulator
type: string
default: ''
emulator-image:
description: sdkmanager system image for the emulator
type: string
default: system-images;android-30;google_apis;x86
publish-tasks:
description: Gradle tasks run on vX.Y.Z tags to publish to the Gitea registry (repository named gitea); empty = none
type: string
default: ''
secrets:
PACKAGES_TOKEN:
required: false
permissions:
contents: read
packages: write
jobs:
android:
runs-on: ${{ inputs.runner }}
timeout-minutes: 90
container:
# Praetor ops/gitea/fleet-ci/platform-ci/android-image/Dockerfile: cimg/android 2026.10.1 + emulator, uid 999.
image: git.kof22.com/koftwentytwo/ci-android-runtime@sha256:838a1a494794a7c0705930e9a13d744498c6e01dc481a29bdda5ecae85f1a466
volumes:
- /var/lib/gitea-job-workspace:${{ github.workspace }}
env:
HOME: /tmp
CI: 'true'
GRADLE_USER_HOME: /tmp/.gradle
GRADLE_OPTS: -Dorg.gradle.daemon=false
steps:
- uses: builtin:checkout
- name: Set up
shell: bash
env:
JAVA_VERSION: ${{ inputs.java-version }}
run: |
set -euo pipefail
git config --global --add safe.directory "$GITHUB_WORKSPACE"
[[ -x ./gradlew ]] || { echo "./gradlew is missing or not executable"; exit 1; }
jdk=$(ls -d /usr/lib/jvm/*-"$JAVA_VERSION"-* /usr/lib/jvm/*-"$JAVA_VERSION" /usr/lib/jvm/*"$JAVA_VERSION"* 2>/dev/null | head -1 || true)
[[ -n $jdk && -x $jdk/bin/java ]] || { echo "No JDK $JAVA_VERSION in the image:"; ls /usr/lib/jvm; exit 1; }
echo "JAVA_HOME=$jdk" >> "$GITHUB_ENV"
echo "$jdk/bin" >> "$GITHUB_PATH"
"$jdk/bin/java" -version
- name: Install Node.js
if: inputs.node-version != ''
shell: bash
env:
NODE_VERSION: ${{ inputs.node-version }}
run: |
set -euo pipefail
base=https://nodejs.org/dist/v$NODE_VERSION
file=node-v$NODE_VERSION-linux-x64.tar.gz
mkdir -p /tmp/node && cd /tmp/node
curl -fsSLO "$base/$file" && curl -fsSLO "$base/SHASUMS256.txt"
grep " $file\$" SHASUMS256.txt | sha256sum -c -
tar -xzf "$file" --strip-components=1 && rm "$file"
echo /tmp/node/bin >> "$GITHUB_PATH"
- name: Run commands
if: inputs.commands != ''
shell: bash
env:
COMMANDS: ${{ inputs.commands }}
run: |
printf '%s\n' "$COMMANDS" > /tmp/commands.sh
bash -exo pipefail /tmp/commands.sh
- name: Gradle
shell: bash
env:
TASKS: ${{ inputs.tasks }}
run: |
set -euo pipefail
while IFS= read -r line; do
[[ -z ${line// } ]] && continue
echo "::group::./gradlew $line"
# Word splitting is intended: each line is a list of Gradle arguments.
# shellcheck disable=SC2086
./gradlew --console=plain $line
echo "::endgroup::"
done <<< "$TASKS"
- name: Emulator tests
if: inputs.emulator-tasks != ''
shell: bash
env:
TASKS: ${{ inputs.emulator-tasks }}
IMAGE: ${{ inputs.emulator-image }}
run: |
set -euo pipefail
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "/dev/kvm is not usable by uid $(id -u): this runner cannot host the emulator"; exit 1; }
sdkmanager --install "$IMAGE" > /dev/null
echo no | avdmanager create avd --force -n ci -k "$IMAGE"
emulator -avd ci -no-window -no-audio -no-boot-anim -no-snapshot -gpu swiftshader_indirect > /tmp/emulator.log 2>&1 &
trap 'adb emu kill > /dev/null 2>&1 || true' EXIT
adb wait-for-device
deadline=$((SECONDS + 600))
until [[ $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') == 1 ]]; do
(( SECONDS < deadline )) || { echo "emulator did not boot in 10 minutes"; tail -50 /tmp/emulator.log; exit 1; }
sleep 2
done
for s in window_animation_scale transition_animation_scale animator_duration_scale; do
adb shell settings put global "$s" 0
done
while IFS= read -r line; do
[[ -z ${line// } ]] && continue
echo "::group::./gradlew $line"
# shellcheck disable=SC2086
./gradlew --console=plain $line
echo "::endgroup::"
done <<< "$TASKS"
- name: Publish
if: inputs.publish-tasks != '' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
shell: bash
env:
TASKS: ${{ inputs.publish-tasks }}
REF_NAME: ${{ github.ref_name }}
ORG_GRADLE_PROJECT_giteaUsername: ${{ github.actor }}
ORG_GRADLE_PROJECT_giteaPassword: ${{ secrets.PACKAGES_TOKEN || github.token }}
run: |
set -euo pipefail
[[ $REF_NAME =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?)$ ]] || { echo "Tag $REF_NAME is not vX.Y.Z[-suffix]"; exit 1; }
version=${BASH_REMATCH[1]}
export ORG_GRADLE_PROJECT_giteaMavenUrl="https://git.kof22.com/api/packages/$GITHUB_REPOSITORY_OWNER/maven"
# shellcheck disable=SC2086
./gradlew --console=plain -Pversion="$version" -PVERSION_NAME="$version" $TASKS
echo "published $version to $ORG_GRADLE_PROJECT_giteaMavenUrl"
+134
View File
@@ -0,0 +1,134 @@
# Shared macOS CI: run the repository's own gates with bash directly on the macOS host runner
# (grogu, no container). Xcode and other tools come from the host; nothing is installed here.
#
# any push -> commands (bash -eo pipefail)
# tag v* -> + upload the artifacts files and a SHA256SUMS to the tag's Gitea release
#
# Host runners execute repository code on the machine: callers trigger on push only (never on
# pull_request), so only branches of the repository itself, never forks, reach the host.
name: macos
on:
workflow_call:
inputs:
commands:
description: Shell commands, one per line, run with bash -exo pipefail; the first failure fails the build
type: string
required: true
runner:
description: macOS runner label (KofTwentyTwo kof-macos-arm64, QRun-IO qqq-macos-arm64)
type: string
default: kof-macos-arm64
xcode:
description: Xcode to select through DEVELOPER_DIR, a version (/Applications/Xcode_<v>.app) or an app path; empty = host default
type: string
default: ''
lfs:
description: Fetch Git LFS objects in the checkout (needs git-lfs on the host)
type: boolean
default: false
artifacts:
description: Files or globs, one per line, attached to the Gitea release on v* tags; empty = no release
type: string
default: ''
checksums:
description: Name of the checksum asset (use a per-platform name when several jobs release the same tag)
type: string
default: SHA256SUMS
secrets:
PACKAGES_TOKEN:
required: false
permissions:
contents: write
jobs:
macos:
runs-on: ${{ inputs.runner }}
timeout-minutes: 60
env:
CI: 'true'
steps:
- uses: builtin:checkout
with:
lfs: ${{ inputs.lfs }}
- name: Toolchain
shell: bash
env:
XCODE: ${{ inputs.xcode }}
run: |
set -euo pipefail
if [[ -n $XCODE ]]; then
case $XCODE in
/*) app=$XCODE ;;
*) app=/Applications/Xcode_$XCODE.app; [[ -d $app ]] || app=/Applications/Xcode-$XCODE.app ;;
esac
[[ -d $app/Contents/Developer ]] || { echo "Xcode $XCODE not found; installed:"; ls -d /Applications/Xcode*.app; exit 1; }
echo "DEVELOPER_DIR=$app/Contents/Developer" >> "$GITHUB_ENV"
export DEVELOPER_DIR=$app/Contents/Developer
fi
sw_vers
xcodebuild -version
swift --version 2>&1 | head -1
- name: Run commands
shell: bash
env:
COMMANDS: ${{ inputs.commands }}
run: |
script=$(mktemp)
printf '%s\n' "$COMMANDS" > "$script"
status=0
bash -exo pipefail "$script" || status=$?
rm -f "$script"
exit $status
- name: Release
if: inputs.artifacts != '' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
shell: bash
env:
ARTIFACTS: ${{ inputs.artifacts }}
TOKEN: ${{ secrets.PACKAGES_TOKEN || github.token }}
TAG: ${{ github.ref_name }}
CHECKSUMS: ${{ inputs.checksums }}
run: |
set -euo pipefail
files=()
while IFS= read -r pattern; do
[[ -z ${pattern// } ]] && continue
# Glob expansion is intended.
# shellcheck disable=SC2206
matches=($pattern)
[[ -f ${matches[0]} ]] || { echo "No file matches $pattern"; exit 1; }
files+=("${matches[@]}")
done <<< "$ARTIFACTS"
sums=$(mktemp)
for f in "${files[@]}"; do (cd "$(dirname "$f")" && shasum -a 256 "$(basename "$f")"); done > "$sums"
cat "$sums"
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
auth=(-H "Authorization: token $TOKEN")
code=$(curl -sS -o /tmp/release-$$.json -w '%{http_code}' "${auth[@]}" "$api/releases/tags/$TAG")
if [[ $code == 404 ]]; then
prerelease=false; [[ $TAG == *-* ]] && prerelease=true
# Another job may create the same release concurrently: fall back to reading it.
curl -fsS "${auth[@]}" -H 'Content-Type: application/json' -o /tmp/release-$$.json \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"prerelease\":$prerelease}" "$api/releases" \
|| curl -fsS "${auth[@]}" -o /tmp/release-$$.json "$api/releases/tags/$TAG"
elif [[ $code != 200 ]]; then
echo "release lookup for $TAG returned HTTP $code"; exit 1
fi
release=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' /tmp/release-$$.json)
upload() {
# Re-runs replace an asset of the same name instead of failing.
python3 -c 'import json,sys; [print(a["id"]) for a in json.load(open(sys.argv[1])).get("assets") or [] if a["name"] == sys.argv[2]]' \
/tmp/release-$$.json "$2" | while read -r old; do
curl -fsS -X DELETE "${auth[@]}" "$api/releases/$release/assets/$old"
done
curl -fsS -o /dev/null "${auth[@]}" -F "attachment=@$1" "$api/releases/$release/assets?name=$2"
echo "attached $2 to release $TAG"
}
for f in "${files[@]}"; do upload "$f" "$(basename "$f")"; done
upload "$sums" "$CHECKSUMS"
rm -f "$sums" /tmp/release-$$.json
+129
View File
@@ -0,0 +1,129 @@
# Shared Windows CI: run the repository's own gates with PowerShell 7 directly on a Windows host runner
# (no container). Tools come from the host; nothing is installed here.
#
# any push -> commands (fail fast)
# tag v* -> + upload the artifacts files and a SHA256SUMS (checksums) to the tag's Gitea release
#
# A .ps1 called in-process with & that ends in `exit 1` does not stop the script: PowerShell only
# turns native exit codes into errors. Call such scripts as native processes: pwsh -NoProfile -File x.ps1.
#
# Host runners execute repository code on the machine: callers trigger on push only (never on
# pull_request), so only branches of the repository itself, never forks, reach the host.
name: windows
on:
workflow_call:
inputs:
commands:
description: PowerShell commands run as one script; any error or non-zero native exit code fails the build (run .ps1 files that report failure with exit codes as pwsh -File)
type: string
required: true
runner:
description: Windows runner label (KofTwentyTwo kof-windows-amd64, QRun-IO qqq-windows-vm6218)
type: string
default: kof-windows-amd64
dotnet-version:
description: Require a host .NET SDK whose version starts with this (for example 10.0.4); empty = no check
type: string
default: ''
artifacts:
description: Files or wildcards, one per line, attached to the Gitea release on v* tags; empty = no release
type: string
default: ''
checksums:
description: Name of the checksum asset (use a per-platform name when several jobs release the same tag)
type: string
default: SHA256SUMS
secrets:
PACKAGES_TOKEN:
required: false
permissions:
contents: write
jobs:
windows:
runs-on: ${{ inputs.runner }}
timeout-minutes: 60
env:
CI: 'true'
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
steps:
- uses: builtin:checkout
- name: Toolchain
shell: pwsh
env:
DOTNET_VERSION: ${{ inputs.dotnet-version }}
run: |
$ErrorActionPreference = 'Stop'
Write-Output "PowerShell $($PSVersionTable.PSVersion) on $([Environment]::OSVersion.VersionString)"
if (Get-Command dotnet -ErrorAction SilentlyContinue) {
$sdks = @(dotnet --list-sdks)
Write-Output ".NET SDKs:"; $sdks | ForEach-Object { " $_" }
if ($env:DOTNET_VERSION -and -not ($sdks | Where-Object { $_.StartsWith($env:DOTNET_VERSION) })) {
throw ".NET SDK $env:DOTNET_VERSION is not installed on this runner"
}
} elseif ($env:DOTNET_VERSION) {
throw 'dotnet is not installed on this runner'
}
- name: Run commands
shell: pwsh
env:
COMMANDS: ${{ inputs.commands }}
run: |
$temp = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() }
$script = Join-Path $temp "commands-$env:GITHUB_RUN_ID.ps1"
# Stop on the first error, including a non-zero exit code from a native command.
"`$ErrorActionPreference = 'Stop'`n`$PSNativeCommandUseErrorActionPreference = `$true`n$env:COMMANDS" |
Set-Content -LiteralPath $script -Encoding utf8
Write-Output $env:COMMANDS
try { & $script } finally { Remove-Item -LiteralPath $script -ErrorAction SilentlyContinue }
if ($LASTEXITCODE) { exit $LASTEXITCODE }
- name: Release
if: inputs.artifacts != '' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v')
shell: pwsh
env:
ARTIFACTS: ${{ inputs.artifacts }}
TOKEN: ${{ secrets.PACKAGES_TOKEN || github.token }}
TAG: ${{ github.ref_name }}
CHECKSUMS: ${{ inputs.checksums }}
run: |
$ErrorActionPreference = 'Stop'
$files = foreach ($pattern in ($env:ARTIFACTS -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
$found = @(Get-ChildItem -Path $pattern -File)
if ($found.Count -eq 0) { throw "No file matches $pattern" }
$found
}
$sums = Join-Path ([IO.Path]::GetTempPath()) "SHA256SUMS-$env:GITHUB_RUN_ID"
$files | ForEach-Object { '{0} {1}' -f (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name } |
Set-Content -LiteralPath $sums -Encoding ascii
Get-Content -LiteralPath $sums
$api = "$env:GITHUB_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY"
$headers = @{ Authorization = "token $env:TOKEN" }
function Get-Release {
try { Invoke-RestMethod -Headers $headers "$api/releases/tags/$env:TAG" }
catch { if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }; $null }
}
$release = Get-Release
if (-not $release) {
$body = @{ tag_name = $env:TAG; name = $env:TAG; prerelease = $env:TAG.Contains('-') } | ConvertTo-Json
# Another job may create the same release concurrently: fall back to reading it.
try { $release = Invoke-RestMethod -Method Post -Headers $headers -ContentType 'application/json' -Body $body "$api/releases" }
catch { $release = Get-Release; if (-not $release) { throw } }
}
$uploads = @($files | ForEach-Object { @{ Path = $_.FullName; Name = $_.Name } }) + @{ Path = $sums; Name = $env:CHECKSUMS }
foreach ($upload in $uploads) {
# Re-runs replace an asset of the same name instead of failing.
foreach ($old in @($release.assets | Where-Object name -eq $upload.Name)) {
Invoke-RestMethod -Method Delete -Headers $headers "$api/releases/$($release.id)/assets/$($old.id)" | Out-Null
}
$name = [Uri]::EscapeDataString($upload.Name)
Invoke-RestMethod -Method Post -Headers $headers -Form @{ attachment = Get-Item -LiteralPath $upload.Path } "$api/releases/$($release.id)/assets?name=$name" | Out-Null
Write-Output "attached $($upload.Name) to release $env:TAG"
}
Remove-Item -LiteralPath $sums
+90
View File
@@ -120,6 +120,96 @@ To consume a package:
Binaries for macOS or Windows need native runners; repositories that ship
them (for example `QRun-IO/qqq-cli`) keep their own release workflow.
## Android
`.gitea/workflows/build.yaml` (see `QRun-IO/qqq-android`):
```yaml
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
android:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/android.yaml@v1
secrets: inherit
with:
tasks: |
testDebugUnitTest lintDebug assembleDebug
emulator-tasks: connectedDebugAndroidTest
```
| Input | Default | Meaning |
| --- | --- | --- |
| `tasks` | `build` | Gradle invocations, one per line (arguments to `./gradlew`) |
| `java-version` | `'21'` | JDK from the image (8, 17, 21, 25) |
| `commands` | `''` | Shell commands run before Gradle |
| `node-version` | `''` | Install this Node.js (official tarball, checksum-verified) for `commands` |
| `emulator-tasks` | `''` | Gradle invocations run with a booted emulator (needs `/dev/kvm`) |
| `emulator-image` | `system-images;android-30;google_apis;x86` | AVD system image |
| `publish-tasks` | `''` | Run on `vX.Y.Z` tags with `-Pversion=X.Y.Z` |
| `runner` | `android-build` | Runner label (Lore builders) |
Runs in the pinned `ci-android-runtime` image (cimg/android plus the emulator).
To publish an AAR, declare a `maven { name = "gitea" ... credentials(PasswordCredentials::class) }`
repository whose URL comes from the `giteaMavenUrl` Gradle property; the workflow
sets it to the owner's Gitea Maven registry with `PACKAGES_TOKEN` (or the job token).
## Windows and macOS
Host jobs (no container) on the native runners. The repository's own commands
run with the tools the host provides; nothing is installed.
```yaml
name: build
on:
push:
branches: ['**']
tags: ['v*']
jobs:
windows:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
secrets: inherit
with:
commands: |
dotnet restore App.slnx --locked-mode
dotnet build App.slnx -warnaserror --no-restore
dotnet test tests/App.Tests --no-build
artifacts: artifacts/release/*.msi
macos:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/macos.yaml@v1
secrets: inherit
with:
commands: |
swift build
swift test
```
| Input | Windows | macOS | Meaning |
| --- | --- | --- | --- |
| `commands` | PowerShell 7, one script | bash `-exo pipefail` | Required. The first failure fails the build |
| `runner` | `kof-windows-amd64` | `kof-macos-arm64` | QRun-IO repositories use `qqq-windows-vm6218` / `qqq-macos-arm64` |
| `dotnet-version` | `''` | | Fail unless a host SDK starts with this version |
| `xcode` | | `''` | Select Xcode by version (`/Applications/Xcode_<v>.app`) or path via `DEVELOPER_DIR` |
| `lfs` | | `false` | Fetch Git LFS objects (needs `git-lfs` on the host) |
| `artifacts` | `''` | `''` | Files or globs, one per line; on `v*` tags they and a `SHA256SUMS` are attached to the tag's Gitea release |
| `checksums` | `SHA256SUMS` | `SHA256SUMS` | Checksum asset name; give each platform its own when several jobs release one tag |
- Trigger on `push` only. Host runners run repository code on the machine, and
push events come only from branches of the repository itself, never forks.
- Windows: native commands with a non-zero exit fail the build. A `.ps1` that
reports failure with `exit 1` must run as `pwsh -NoProfile -File x.ps1`; called
with `&` it would not stop the script. Test tools that assert on child exit
codes themselves (Pester suites) need `$PSNativeCommandUseErrorActionPreference = $false` first.
- Releases use `PACKAGES_TOKEN` (or the job token). Re-running a tag replaces
assets of the same name. A Homebrew or Scoop update can read the release's
`SHA256SUMS`.
- Host limits today: the Windows runner is a batch-logon task with no interactive
desktop (no FlaUI UI tests); the macOS runner is a launchd daemon with no GUI
session, so `xcodebuild test` cannot reach `testmanagerd` (SwiftPM `swift test`
works), and the host has no `git-lfs`.
## Releasing
| To publish | Do this |