diff --git a/.gitea/workflows/android.yaml b/.gitea/workflows/android.yaml new file mode 100644 index 0000000..f63f343 --- /dev/null +++ b/.gitea/workflows/android.yaml @@ -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" diff --git a/.gitea/workflows/macos.yaml b/.gitea/workflows/macos.yaml new file mode 100644 index 0000000..d3cc05b --- /dev/null +++ b/.gitea/workflows/macos.yaml @@ -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_.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 diff --git a/.gitea/workflows/windows.yaml b/.gitea/workflows/windows.yaml new file mode 100644 index 0000000..6f14fd5 --- /dev/null +++ b/.gitea/workflows/windows.yaml @@ -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 diff --git a/README.md b/README.md index c0ab5a1..1369cde 100644 --- a/README.md +++ b/README.md @@ -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_.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 |