Skip to content

Production Release Pipeline #139

Production Release Pipeline

Production Release Pipeline #139

Workflow file for this run

name: "Production Release Pipeline"
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version_name:
description: 'Version Name – semver X.Y.Z (e.g. 1.2.3)'
required: true
version_code:
description: 'Version Code override (integer). Leave blank to auto‑calculate.'
required: false
default: ''
release_notes:
description: 'Release notes (Markdown)'
required: false
default: ''
draft:
description: 'Create release as a draft?'
required: false
default: false
type: boolean
prerelease:
description: 'Mark as a pre‑release?'
required: false
default: false
type: boolean
build_android:
description: 'Build Android APK + Root Module ZIP'
required: false
default: true
type: boolean
build_windows:
description: 'Build PC Tool – Windows format'
required: false
default: 'both'
type: choice
options:
- both
- exe
- msi
- none
build_linux:
description: 'Build PC Tool – Linux (.deb)'
required: false
default: true
type: boolean
build_macos:
description: 'Build PC Tool – macOS (.dmg)'
required: false
default: true
type: boolean
sign_apk:
description: 'Sign the APK (requires keystore secrets)'
required: false
default: true
type: boolean
force_version:
description: 'Override – skip version code regression check (use to re‑deploy)'
required: false
default: false
type: boolean
permissions:
contents: write
pages: write
issues: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# ============================================================
# 1. VALIDATE VERSION & GENERATE NOTES
# ============================================================
validate:
name: "Validate Version & Generate Notes"
runs-on: ubuntu-latest
outputs:
version_name: ${{ steps.resolve-version.outputs.VERSION_NAME }}
version_code: ${{ steps.resolve-version.outputs.VERSION_CODE }}
tag_name: ${{ steps.resolve-version.outputs.TAG_NAME }}
release_notes: ${{ steps.generate-notes.outputs.BODY }}
started_at: ${{ steps.resolve-version.outputs.STARTED_AT }}
steps:
- name: "Checkout repository (full history for changelog)"
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: "Resolve & Validate Version Number"
id: resolve-version
shell: pwsh
run: |
$version_name = "${{ github.event.inputs.version_name }}"
$version_code_raw = "${{ github.event.inputs.version_code }}"
# Tag‑triggered run: extract version from the tag itself
if ("${{ github.event_name }}" -eq "push") {
$version_name = "${{ github.ref_name }}".TrimStart('v')
}
if ([string]::IsNullOrWhiteSpace($version_name)) {
throw "FATAL: version_name is required."
}
if ($version_name -notmatch '^\d+\.\d+\.\d+$') {
throw "FATAL: '$version_name' is not a valid semver (X.Y.Z)."
}
$parts = $version_name -split '\.'
$major = [int]$parts[0]
$minor = [int]$parts[1]
$patch = [int]$parts[2]
if (-not [string]::IsNullOrWhiteSpace($version_code_raw)) {
$version_code = [int]$version_code_raw
} else {
$version_code = ($major * 10000) + ($minor * 100) + $patch
}
$started_at = (Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC")
echo "VERSION_NAME=$version_name" >> $env:GITHUB_OUTPUT
echo "VERSION_CODE=$version_code" >> $env:GITHUB_OUTPUT
echo "TAG_NAME=v$version_name" >> $env:GITHUB_OUTPUT
echo "STARTED_AT=$started_at" >> $env:GITHUB_OUTPUT
- name: "Guard Against Duplicate Releases"
shell: bash
run: |
FORCE="${{ github.event.inputs.force_version }}"
if [ "$FORCE" = "true" ]; then
echo "force_version=true — skipping duplicate release guard."
exit 0
fi
TAG="${{ steps.resolve-version.outputs.TAG_NAME }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG")
if [ "$STATUS" = "200" ]; then
echo "::error::A release for $TAG already exists. Aborting."
exit 1
fi
- name: "Check Version Code Regression"
if: github.event.inputs.force_version != 'true'
shell: bash
run: |
NEW_CODE=${{ steps.resolve-version.outputs.VERSION_CODE }}
PAGES_URL="https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/update.json"
if curl -sf "$PAGES_URL" -o live_update.json; then
LIVE_CODE=$(jq -r '.versionCode' live_update.json)
if [ "$NEW_CODE" -le "$LIVE_CODE" ]; then
echo "::error::Version code regression: new=$NEW_CODE <= live=$LIVE_CODE"
exit 1
fi
else
echo "No existing update.json found – regression check skipped."
fi
- name: "Generate Release Notes"
id: generate-notes
shell: pwsh
run: |
$v = "${{ steps.resolve-version.outputs.VERSION_NAME }}"
$c = "${{ steps.resolve-version.outputs.VERSION_CODE }}"
$manual = "${{ github.event.inputs.release_notes }}"
$body = ""
if (-not [string]::IsNullOrWhiteSpace($manual)) {
$body = $manual
} else {
$prev = git describe --tags --abbrev=0 HEAD^ 2>$null
if ($prev) {
# Group commits by conventional-commit type for readability
$lines = @()
$currentType = ""
git log "$prev..HEAD" --pretty=format:"%s" --no-merges | ForEach-Object {
$msg = $_
if ($msg -match '^(feat|fix|docs|refactor|chore|ci|test|style|perf|build|revert)(\([^)]+\))?:\s*(.*)') {
$type = $matches[1]
$scope = $matches[2]
$desc = $matches[3]
if ($type -ne $currentType) {
$currentType = $type
$typeLabel = @{
'feat' = '🚀 Features'
'fix' = '🐛 Bug Fixes'
'docs' = '📚 Documentation'
'refactor' = '♻️ Refactoring'
'chore' = '🧹 Chores'
'ci' = '⚙️ CI/CD'
'test' = '🧪 Tests'
'style' = '💄 Styling'
'perf' = '⚡ Performance'
'build' = '🏗️ Build'
'revert' = '⏪ Reverts'
}
$lines += "`n### $($typeLabel[$type])"
}
$lines += "- $desc $scope"
} else {
$lines += "- $msg"
}
}
if ($lines.Count -gt 0) {
$body += "## What's Changed (since $prev)`n"
$body += ($lines -join "`n")
} else {
$body += "## What's Changed`n"
$body += "- Maintenance and stability improvements."
}
} else {
$log = git log --pretty=format:"- %s" --no-merges -30
$body += "## Initial Release`n$log"
}
}
$body += "`n`n___`n### 📥 Requirements`n| | |`n|---|---|`n| **Root** | Magisk / KernelSU / APatch / SuperSU |`n| **Framework** | LSPosed (recommended) – or Overlay Mode (no framework) |`n| **Android** | 6.0+ (API 23) |`n| **Version Code** | $c |`n`n### 🆘 Support`n- **Issues**: https://github.com/${{ github.repository }}/issues`n- **Security**: https://github.com/${{ github.repository }}/security/advisories`n- **Documentation**: https://github.com/${{ github.repository }}#readme"
echo "BODY<<EOF" >> $env:GITHUB_OUTPUT
echo $body >> $env:GITHUB_OUTPUT
echo "EOF" >> $env:GITHUB_OUTPUT
# ============================================================
# 2. RUN UNIT TESTS
# ============================================================
test:
name: "Unit Tests"
needs: validate
runs-on: ubuntu-latest
steps:
- name: "Checkout Code"
uses: actions/checkout@v7
- name: "Set up Java 17"
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle'
- name: "Make Gradlew Executable"
run: chmod +x gradlew
- name: "Execute Unit Tests"
run: ./gradlew test -PVERSION_NAME="${{ needs.validate.outputs.version_name }}" -PVERSION_CODE=${{ needs.validate.outputs.version_code }}
- name: "Upload Test Reports"
if: always()
uses: actions/upload-artifact@v7
with:
name: test-reports
path: '**/build/reports/tests/'
retention-days: 7
# ============================================================
# 3. BUILD PC (Windows / Linux / macOS matrix)
# ============================================================
build-pc:
name: "Build PC – ${{ matrix.label }}"
needs: [validate, test]
if: |
always() &&
needs.validate.result == 'success' &&
needs.test.result == 'success'
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
label: Windows
artifact_name: pc-tool-windows
file_glob: "*.exe"
java_version: '17'
- os: ubuntu-latest
label: Linux
artifact_name: pc-tool-linux
file_glob: "*.deb"
java_version: '17'
- os: macos-latest
label: macOS
artifact_name: pc-tool-macos
file_glob: "*.dmg"
java_version: '21'
runs-on: ${{ matrix.os }}
steps:
- name: "Checkout Code"
uses: actions/checkout@v7
- name: "Set up Java ${{ matrix.java_version }}"
uses: actions/setup-java@v5
with:
java-version: ${{ matrix.java_version }}
distribution: 'temurin'
cache: 'gradle'
- name: "Make Gradlew Executable"
run: chmod +x gradlew
- name: "Export winFormat for Gradle"
id: win-format
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "WIN_FORMAT=both" >> $GITHUB_OUTPUT
elif [ "${{ matrix.label }}" = "Windows" ]; then
echo "WIN_FORMAT=${{ github.event.inputs.build_windows }}" >> $GITHUB_OUTPUT
else
echo "WIN_FORMAT=both" >> $GITHUB_OUTPUT
fi
- name: "Build ${{ matrix.label }} Distribution"
id: build-pc
if: |
github.event_name == 'push' ||
(matrix.label == 'Windows' && github.event.inputs.build_windows != 'none') ||
(matrix.label == 'Linux' && github.event.inputs.build_linux == 'true') ||
(matrix.label == 'macOS' && github.event.inputs.build_macos == 'true')
shell: bash
run: |
./gradlew buildPC \
-PVERSION_NAME="${{ needs.validate.outputs.version_name }}" \
-PVERSION_CODE=${{ needs.validate.outputs.version_code }} \
-PwinFormat=${{ steps.win-format.outputs.WIN_FORMAT }}
- name: "Stage ${{ matrix.label }} Artifact"
if: steps.build-pc.outcome == 'success'
shell: pwsh
run: |
New-Item -ItemType Directory -Path staging -Force
$winFormat = "${{ steps.win-format.outputs.WIN_FORMAT }}"
$fileGlob = "${{ matrix.file_glob }}"
# Skip *.exe glob when building msi-only (no .exe produced)
if (($fileGlob -eq "*.exe") -and ($winFormat -eq "msi")) {
Write-Host "Skipping *.exe glob (msi-only Windows build)" -ForegroundColor Yellow
} else {
$files = @(Get-ChildItem -Path "pc-tool-kotlin/build/compose/binaries" -Recurse -Filter $fileGlob)
if ($files.Count -eq 0) { throw "No file matching $fileGlob found in pc-tool-kotlin/build/compose/binaries" }
foreach ($f in $files) { Copy-Item -Path $f.FullName -Destination staging/ }
}
# On Windows we may also have a .msi (needed for exe-only or dual-format)
if ("${{ matrix.label }}" -eq "Windows" -and $winFormat -ne "exe") {
$msiFiles = @(Get-ChildItem -Path "pc-tool-kotlin/build/compose/binaries" -Recurse -Filter "*.msi" -ErrorAction SilentlyContinue)
foreach ($f in $msiFiles) { Copy-Item -Path $f.FullName -Destination staging/ }
}
- name: "Upload ${{ matrix.label }} Artifact"
if: steps.build-pc.outcome == 'success'
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact_name }}
path: staging/*
# ============================================================
# 4. BUILD ANDROID APK & MODULE
# ============================================================
build-android:
name: "Build Android APK & Module"
needs: [validate, test]
if: |
always() &&
needs.validate.result == 'success' &&
needs.test.result == 'success' &&
(github.event_name == 'push' || github.event.inputs.build_android == 'true')
runs-on: ubuntu-latest
steps:
- name: "Checkout Code"
uses: actions/checkout@v7
- name: "Set up Java 17"
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
cache: 'gradle'
- name: "Make Gradlew Executable"
run: chmod +x gradlew
- name: "Resolve signing config"
id: signing-config
shell: bash
run: |
DO_SIGN="false"
if [ "${{ github.event_name }}" = "push" ] || [ "${{ github.event.inputs.sign_apk }}" = "true" ]; then
DO_SIGN="true"
fi
if [ "$DO_SIGN" = "true" ]; then
if [ -z "${{ secrets.SIGNING_KEY }}" ]; then
echo "::error::APK signing enabled but SIGNING_KEY secret is missing"
exit 1
fi
echo "${{ secrets.SIGNING_KEY }}" | base64 -d > release.keystore
echo "SIGN_APK=true" >> $GITHUB_ENV
echo "KEYSTORE_FILE=release.keystore" >> $GITHUB_ENV
else
echo "SIGN_APK=false" >> $GITHUB_ENV
fi
- name: "Build Android APK & Root Module (unsigned)"
shell: bash
run: |
./gradlew buildAndroid buildModule \
-PVERSION_NAME="${{ needs.validate.outputs.version_name }}" \
-PVERSION_CODE=${{ needs.validate.outputs.version_code }} \
-PSIGN_APK=false
- name: "Sign APK with apksigner"
if: env.SIGN_APK == 'true'
shell: bash
run: |
BUILD_TOOLS=$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)
UNSIGNED="android-app/app/build/outputs/apk/release/app-release-unsigned.apk"
SIGNED="android-app/app/build/outputs/apk/release/app-release.apk"
"$ANDROID_HOME/build-tools/$BUILD_TOOLS/apksigner" sign \
--ks release.keystore \
--ks-pass "pass:${{ secrets.KEY_STORE_PASSWORD }}" \
--ks-key-alias "${{ secrets.ALIAS }}" \
--key-pass "pass:${{ secrets.KEY_PASSWORD }}" \
--out "$SIGNED" \
"$UNSIGNED"
- name: "Stage Android Artifacts"
shell: bash
run: |
mkdir -p android-staging
APK="android-app/app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then
APK="android-app/app/build/outputs/apk/release/app-release-unsigned.apk"
fi
if [ ! -f "$APK" ]; then
echo "::error::No APK found in android-app/app/build/outputs/apk/release/"
ls -la android-app/app/build/outputs/apk/release/ 2>/dev/null || echo "(directory may be empty)"
exit 1
fi
cp "$APK" android-staging/app-release.apk
cp build/distributions/InputBlockerModule.zip android-staging/
- name: "Upload Android Artifacts"
uses: actions/upload-artifact@v7
with:
name: android-artifacts
path: android-staging/*
# ============================================================
# 5. PUBLISH RELEASE & DEPLOY update.json
# ============================================================
release:
name: "Publish Release & Deploy update.json"
needs: [validate, build-pc, build-android]
if: |
always() &&
needs.validate.result == 'success' &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
runs-on: ubuntu-latest
steps:
- name: "Checkout Code"
uses: actions/checkout@v7
- name: "Download All Build Artifacts"
uses: actions/download-artifact@v8
with:
path: raw-artifacts
- name: "Organise & Rename Artifacts for Release"
shell: pwsh
run: |
$v = "${{ needs.validate.outputs.version_name }}"
$do_android = "${{ github.event.inputs.build_android }}" -ne 'false'
$do_windows = "${{ github.event.inputs.build_windows }}" -ne 'none'
$do_linux = "${{ github.event.inputs.build_linux }}" -ne 'false'
$do_macos = "${{ github.event.inputs.build_macos }}" -ne 'false'
if ("${{ github.event_name }}" -eq "push") {
$do_android = $true; $do_windows = $true; $do_linux = $true; $do_macos = $true
}
New-Item -ItemType Directory -Path "release-assets" -Force
if ($do_android) {
Copy-Item "raw-artifacts/android-artifacts/app-release.apk" "release-assets/InputBlocker-v$v.apk"
Copy-Item "raw-artifacts/android-artifacts/InputBlockerModule.zip" "release-assets/InputBlocker-v$v.zip"
}
if ($do_windows) {
$win_format = "${{ github.event.inputs.build_windows }}"
if ("${{ github.event_name }}" -eq "push") { $win_format = "both" }
if ($win_format -eq 'both' -or $win_format -eq 'exe') {
$exe = Get-ChildItem "raw-artifacts/pc-tool-windows/*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($exe) { Copy-Item $exe.FullName "release-assets/InputBlockerSetup-v$v.exe" }
}
if ($win_format -eq 'both' -or $win_format -eq 'msi') {
$msi = Get-ChildItem "raw-artifacts/pc-tool-windows/*.msi" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($msi) { Copy-Item $msi.FullName "release-assets/InputBlockerSetup-v$v.msi" }
}
}
if ($do_linux) {
$app = Get-ChildItem "raw-artifacts/pc-tool-linux/*.deb" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($app) { Copy-Item $app.FullName "release-assets/InputBlockerSetup-v$v.deb" }
}
if ($do_macos) {
$dmg = Get-ChildItem "raw-artifacts/pc-tool-macos/*.dmg" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($dmg) { Copy-Item $dmg.FullName "release-assets/InputBlockerSetup-v$v.dmg" }
}
- name: "Verify Mandatory Artifacts Present"
shell: pwsh
run: |
$v = "${{ needs.validate.outputs.version_name }}"
$do_android = "${{ github.event.inputs.build_android }}" -ne 'false'
$do_windows = "${{ github.event.inputs.build_windows }}" -ne 'none'
$is_push = "${{ github.event_name }}" -eq "push"
if ($is_push) { $do_windows = $true }
$required = @()
if ($do_android -or $is_push) {
$required += "release-assets/InputBlocker-v$v.apk"
$required += "release-assets/InputBlocker-v$v.zip"
}
if ($do_windows) {
$win_format = "${{ github.event.inputs.build_windows }}"
if ($is_push) { $win_format = "both" }
if ($win_format -eq 'both' -or $win_format -eq 'exe') { $required += "release-assets/InputBlockerSetup-v$v.exe" }
if ($win_format -eq 'both' -or $win_format -eq 'msi') { $required += "release-assets/InputBlockerSetup-v$v.msi" }
}
if ($required.Count -eq 0) {
throw "No artifacts selected for build – nothing to release."
}
foreach ($file in $required) {
if (-not (Test-Path $file)) {
throw "Missing required artifact: $file"
}
}
- name: "Verify APK Signature"
if: |
(github.event_name == 'push' || github.event.inputs.sign_apk == 'true') &&
(github.event_name == 'push' || github.event.inputs.build_android == 'true')
shell: bash
run: |
APK="release-assets/InputBlocker-v${{ needs.validate.outputs.version_name }}.apk"
if [ ! -f "$APK" ]; then echo "No APK to verify, skipping."; exit 0; fi
BUILD_TOOLS=$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)
"$ANDROID_HOME/build-tools/$BUILD_TOOLS/apksigner" verify "$APK"
- name: "Verify ZIP Integrity"
if: github.event_name == 'push' || github.event.inputs.build_android == 'true'
shell: bash
run: |
unzip -t "release-assets/InputBlocker-v${{ needs.validate.outputs.version_name }}.zip"
- name: "Generate SHA-256 Checksums"
shell: bash
run: |
cd release-assets
sha256sum * > "checksums-v${{ needs.validate.outputs.version_name }}.txt"
- name: "Upload Final Release Assets (for inspection)"
uses: actions/upload-artifact@v7
with:
name: release-assets-v${{ needs.validate.outputs.version_name }}
path: release-assets/*
- name: "Create GitHub Release"
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.validate.outputs.tag_name }}
name: "Release ${{ needs.validate.outputs.tag_name }}"
body: ${{ needs.validate.outputs.release_notes }}
draft: ${{ github.event.inputs.draft || 'false' }}
prerelease: ${{ github.event.inputs.prerelease || 'false' }}
files: release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Deploy update.json for in‑app updater"
if: github.event_name == 'push' || github.event.inputs.build_android == 'true'
shell: bash
run: |
V="${{ needs.validate.outputs.version_name }}"
C="${{ needs.validate.outputs.version_code }}"
REPO="${{ github.repository }}"
APK_SHA=$(sha256sum "release-assets/InputBlocker-v$V.apk" | awk '{print $1}')
ZIP_SHA=$(sha256sum "release-assets/InputBlocker-v$V.zip" | awk '{print $1}')
mkdir -p pages-deploy
# Write changelog to a temp file so jq can read it verbatim
echo "${{ needs.validate.outputs.release_notes }}" > /tmp/changelog.md
jq -n \
--arg version "$V" \
--argjson versionCode "$C" \
--arg apkUrl "https://github.com/$REPO/releases/download/v$V/InputBlocker-v$V.apk" \
--arg zipUrl "https://github.com/$REPO/releases/download/v$V/InputBlocker-v$V.zip" \
--rawfile changelog /tmp/changelog.md \
--arg apkSha256 "$APK_SHA" \
--arg zipSha256 "$ZIP_SHA" \
'{
"version": $version,
"versionCode": $versionCode,
"apkUrl": $apkUrl,
"zipUrl": $zipUrl,
"changelog": $changelog,
"apkSha256": $apkSha256,
"zipSha256": $zipSha256
}' > pages-deploy/update.json
- name: "Publish update.json via GitHub Pages"
if: github.event_name == 'push' || github.event.inputs.build_android == 'true'
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./pages-deploy
keep_files: true
commit_message: "Deploy update.json for v${{ needs.validate.outputs.version_name }}"
- name: "Create Bug Report on Pipeline Failure"
if: failure()
uses: actions/github-script@v9
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: "Release pipeline failed – v${{ needs.validate.outputs.version_name }}",
body: `Pipeline run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}`,
labels: ["bug", "release"]
})