Skip to content

ci(release): 修复 Windows native 与镜像扫描 #13

ci(release): 修复 Windows native 与镜像扫描

ci(release): 修复 Windows native 与镜像扫描 #13

Workflow file for this run

name: TaskBridge Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Release version, for example v0.1.6"
required: true
type: string
publish_latest:
description: "Also publish the Docker latest tag"
required: true
default: true
type: boolean
permissions:
contents: write
packages: write
security-events: write
attestations: write
id-token: write
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
cancel-in-progress: false
env:
RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || '27xk/taskbridge' }}
TASKBRIDGE_BASE_URL: ${{ vars.TASKBRIDGE_BASE_URL }}
TASKBRIDGE_WS_URL: ${{ vars.TASKBRIDGE_WS_URL }}
jobs:
prepare:
name: Prepare Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate release version
shell: bash
run: |
if [[ ! "${RELEASE_VERSION}" =~ ^v[0-9]+(\.[0-9]+){0,2}([-.][0-9A-Za-z.]+)?$ ]]; then
echo "::error::Release version must start with v, for example v0.1.6."
exit 1
fi
expected_version="$(tr -d '[:space:]' < VERSION)"
if [ "${RELEASE_VERSION#v}" != "$expected_version" ]; then
echo "::error::Release version must match VERSION (${expected_version})."
exit 1
fi
- name: Set up Node.js for repository guards
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Verify version source
run: node scripts/check-version-source.mjs
- name: Set up Python for dependency audit
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: |
backend/requirements.txt
backend/requirements-dev.txt
- name: Install backend audit dependencies
working-directory: backend
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Audit Python dependencies
working-directory: backend
run: python -m pip_audit -r requirements-dev.txt
- name: Run backend tests
working-directory: backend
run: python -m pytest tests -q
- name: Verify database migrations
working-directory: backend
run: python -m pytest tests/test_migrations.py -q
- name: Compile backend and tools
working-directory: backend
run: python -m compileall -q app tests tools
- name: Verify OpenAPI contract
working-directory: backend
run: python -m tools.openapi_contract --check
- name: Lint backend
working-directory: backend
run: python -m ruff check app tests tools
- name: Validate release endpoint configuration
shell: bash
run: |
validate_endpoint() {
local name="$1"
local value="$2"
local required_path="$3"
shift 3
local allowed_schemes=("$@")
if [ -z "$value" ]; then
echo "::error::${name} must be configured in repository variables before release."
exit 1
fi
local scheme_ok=false
for scheme in "${allowed_schemes[@]}"; do
if [[ "$value" == ${scheme}* ]]; then
scheme_ok=true
break
fi
done
if [ "$scheme_ok" != true ]; then
echo "::error::${name} must use one of: ${allowed_schemes[*]} for release artifacts."
exit 1
fi
if [[ "$value" != *"${required_path}"* ]]; then
echo "::error::${name} must include ${required_path}."
exit 1
fi
host="$(python -c 'from urllib.parse import urlparse; import sys; print((urlparse(sys.argv[1]).hostname or "").lower())' "$value")"
if [[ "$host" =~ ^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.) ]]; then
echo "::error::${name} must not use a private or local network address for public release artifacts."
exit 1
fi
}
validate_endpoint "TASKBRIDGE_BASE_URL" "$TASKBRIDGE_BASE_URL" "/api/v1/" "http://" "https://"
validate_endpoint "TASKBRIDGE_WS_URL" "$TASKBRIDGE_WS_URL" "/ws/sync" "ws://" "wss://"
- name: Ensure release tag exists
if: ${{ github.event_name == 'workflow_dispatch' }}
shell: bash
run: |
git fetch --tags --force
if git rev-parse -q --verify "refs/tags/${RELEASE_VERSION}" >/dev/null; then
echo "Tag ${RELEASE_VERSION} already exists."
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "${RELEASE_VERSION}" "${GITHUB_SHA}"
git push origin "refs/tags/${RELEASE_VERSION}"
fi
android:
name: Build Android APK
runs-on: ubuntu-latest
needs: prepare
defaults:
run:
working-directory: android
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
gradle-home-cache-cleanup: true
- name: Prepare Android signing key
shell: bash
run: |
signing_values=(
"$ANDROID_KEYSTORE_BASE64"
"$ANDROID_KEYSTORE_PASSWORD"
"$ANDROID_KEY_ALIAS"
"$ANDROID_KEY_PASSWORD"
)
configured=0
for value in "${signing_values[@]}"; do
if [ -n "$value" ]; then
configured=1
fi
done
missing=()
for name in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
missing+=("$name")
fi
done
if [ "$configured" -eq 0 ]; then
echo "::notice::Android signing secrets are not configured; building an unsigned release APK."
echo "TASKBRIDGE_ANDROID_SIGNED_RELEASE=false" >> "$GITHUB_ENV"
exit 0
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::Incomplete Android signing secrets: ${missing[*]}."
echo "Configure all Android signing secrets or leave all of them empty for an unsigned APK." >&2
exit 1
fi
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > release.keystore
if [ ! -s release.keystore ]; then
echo "Decoded Android signing keystore is empty or missing." >&2
exit 1
fi
echo "TASKBRIDGE_ANDROID_SIGNED_RELEASE=true" >> "$GITHUB_ENV"
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: Make Gradle wrapper executable
run: chmod +x gradlew
- name: Run unit tests
run: ./gradlew testReleaseUnitTest -PTASKBRIDGE_USE_CHINA_MIRRORS=false -PTASKBRIDGE_BASE_URL="${TASKBRIDGE_BASE_URL}" -PTASKBRIDGE_WS_URL="${TASKBRIDGE_WS_URL}" --stacktrace
- name: Build release APK
run: ./gradlew :app:assembleRelease -PTASKBRIDGE_USE_CHINA_MIRRORS=false -PTASKBRIDGE_BASE_URL="${TASKBRIDGE_BASE_URL}" -PTASKBRIDGE_WS_URL="${TASKBRIDGE_WS_URL}" --stacktrace
env:
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android/release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
- name: Verify Android APK signature
shell: bash
run: |
if [ "$TASKBRIDGE_ANDROID_SIGNED_RELEASE" != "true" ]; then
echo "::notice::Skipping Android APK signature verification because this release is unsigned."
exit 0
fi
apk_path="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release.apk' | head -n 1)"
if [ -z "$apk_path" ]; then
echo "No release APK was produced." >&2
exit 1
fi
android_sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [ -z "$android_sdk" ]; then
echo "ANDROID_HOME or ANDROID_SDK_ROOT is required to locate apksigner." >&2
exit 1
fi
apksigner="$(find "$android_sdk/build-tools" -maxdepth 2 -type f -name apksigner | sort -V | tail -n 1)"
if [ -z "$apksigner" ]; then
echo "apksigner was not found in Android SDK build-tools." >&2
exit 1
fi
"$apksigner" verify --verbose --print-certs "$apk_path"
- name: Prepare Android artifact
shell: bash
run: |
mkdir -p ../artifacts
find app/build/outputs/apk -maxdepth 3 -type f -print
if [ "$TASKBRIDGE_ANDROID_SIGNED_RELEASE" = "true" ]; then
apk_path="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release.apk' | head -n 1)"
artifact_name="TaskBridge-${RELEASE_VERSION}-android.apk"
else
apk_path="$(find app/build/outputs/apk/release -maxdepth 1 -type f -name 'app-release-unsigned.apk' | head -n 1)"
artifact_name="TaskBridge-${RELEASE_VERSION}-android-unsigned.apk"
fi
if [ -z "$apk_path" ]; then
echo "No release APK was produced." >&2
exit 1
fi
cp "$apk_path" "../artifacts/${artifact_name}"
- name: Upload Android artifact
uses: actions/upload-artifact@v4
with:
name: taskbridge-android
path: artifacts/*.apk
if-no-files-found: error
desktop:
name: Build Windows installer
runs-on: windows-latest
needs: prepare
defaults:
run:
working-directory: desktop
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: desktop/package-lock.json
- name: Verify version source
run: node ..\scripts\check-version-source.mjs
- name: Verify desktop lockfile registry metadata
run: npm run check:lockfile-registry
- name: Install dependencies
run: npm ci
env:
TASKBRIDGE_SKIP_NATIVE_REBUILD: "1"
- name: Audit npm dependencies
run: npm audit --audit-level=high
- name: Verify desktop endpoint config
run: npm run check:desktop-endpoint-config
- name: Verify security-sensitive config
run: npm run check:security-config
- name: Verify auth session governance
run: npm run check:auth-session-config
- name: Verify backend observability config
run: npm run check:backend-observability
- name: Verify desktop package size config
run: npm run check:package-size-config
- name: Run desktop unit tests
run: npm run test:unit
- name: Verify quick-add parser behavior
run: npm run check:quick-add-parser
- name: Verify task ordering behavior
run: npm run check:task-order
- name: Verify sync push handling
run: npm run check:sync-push
- name: Verify sync diagnostics
run: npm run check:sync-diagnostics
- name: Verify sync recovery center
run: npm run check:sync-recovery-center
- name: Verify desktop backup handling
run: npm run check:desktop-backup
- name: Verify desktop theme config
run: npm run check:desktop-theme
- name: Verify desktop docs consistency
run: npm run check:desktop-docs
- name: Verify local bootstrap guidance
run: npm run check:local-bootstrap
- name: Verify release readiness config
run: npm run check:release-readiness
- name: Verify release artifact config
run: npm run check:release-artifacts
- name: Verify production hardening config
run: npm run check:production-hardening
- name: Verify desktop task list completeness
run: npm run check:desktop-task-list-completeness
- name: Verify Android localization config
run: npm run check:android-localization
- name: Verify Android task delete confirmation
run: npm run check:android-task-delete-confirmation
- name: Verify refresh token singleflight config
run: npm run check:refresh-singleflight
- name: Verify registration governance config
run: npm run check:registration-governance
- name: Verify sync retry preservation
run: npm run check:sync-retry-preservation
- name: Verify Android list and realtime config
run: npm run check:android-list-realtime
- name: Verify release endpoint defaults
run: npm run check:release-endpoint-defaults
- name: Verify Android sync status message config
run: npm run check:android-sync-status-message
- name: Verify Android sync recovery config
run: npm run check:android-sync-recovery
- name: Verify CI workflow desktop coverage
run: npm run check:ci-workflows
- name: Verify cross-client contract drift
run: npm run check:contract-drift
- name: Verify source test visibility
run: npm run check:source-tests-visible
- name: Verify supply chain config
run: npm run check:supply-chain
- name: Verify desktop auto-update config
run: npm run check:desktop-auto-update
- name: Verify Android data extraction config
run: npm run check:android-data-extraction
- name: Verify security governance config
run: npm run check:security-governance
- name: Verify Web client shell
run: node ..\scripts\check-web-client.mjs
- name: Run Web unit tests
run: npm --prefix .. run test:web
- name: Verify Web offline-first behavior
run: node ..\scripts\check-web-offline-first.mjs
- name: Verify Web offline core behavior
run: node ..\scripts\check-web-offline-core.mjs
- name: Smoke test Web client shell
run: node ..\scripts\smoke-web-client.mjs
- name: Prepare Windows signing certificate
shell: pwsh
run: |
$hasCertificate = -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_BASE64)
$hasPassword = -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD)
if (-not $hasCertificate -and -not $hasPassword) {
"TASKBRIDGE_WINDOWS_SIGNED_RELEASE=false" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CSC_IDENTITY_AUTO_DISCOVERY=false" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "::notice::Windows signing certificate is not configured; building an unsigned installer."
exit 0
}
if (-not $hasCertificate -or -not $hasPassword) {
throw "Incomplete Windows signing secrets. Configure both WINDOWS_CERTIFICATE_BASE64 and WINDOWS_CERTIFICATE_PASSWORD, or leave both empty for an unsigned installer."
}
$certificatePath = Join-Path $env:RUNNER_TEMP "taskbridge-codesign.pfx"
[IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64))
if ((Get-Item -LiteralPath $certificatePath).Length -le 0) {
throw "Decoded Windows signing certificate is empty."
}
"CSC_LINK=$certificatePath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"CSC_KEY_PASSWORD=$env:WINDOWS_CERTIFICATE_PASSWORD" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"TASKBRIDGE_WINDOWS_SIGNED_RELEASE=true" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
env:
WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
- name: Build Windows installer
run: npm run dist
env:
TASKBRIDGE_BASE_URL: ${{ env.TASKBRIDGE_BASE_URL }}
TASKBRIDGE_WS_URL: ${{ env.TASKBRIDGE_WS_URL }}
ELECTRON_CACHE: ${{ github.workspace }}\\.cache\\electron
ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\\.cache\\electron-builder
- name: Verify Windows installer signatures
shell: pwsh
run: |
if ($env:TASKBRIDGE_WINDOWS_SIGNED_RELEASE -ne "true") {
Write-Host "::notice::Skipping Windows installer signature verification because this release is unsigned."
exit 0
}
$installers = @(Get-ChildItem -Path release -File | Where-Object { $_.Extension -eq '.exe' })
if ($installers.Count -eq 0) {
throw "No Windows installer .exe was produced."
}
foreach ($installer in $installers) {
$signature = Get-AuthenticodeSignature -LiteralPath $installer.FullName
if ($signature.Status -ne 'Valid') {
throw "Windows installer is not signed with a valid Authenticode signature: $($installer.FullName) [$($signature.Status)]"
}
}
- name: Prepare desktop artifacts
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path ..\artifacts | Out-Null
Get-ChildItem -Path release -Recurse -File | Select-Object FullName, Length
Get-ChildItem -Path release -File | Where-Object {
$_.Extension -in @('.exe', '.yml', '.blockmap') -or $_.Name -eq 'latest.yml'
} | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination ..\artifacts\$($_.Name)
}
- name: Validate desktop update artifacts
shell: pwsh
run: |
# Desktop auto-update publishes to the stable latest channel.
$artifacts = @(Get-ChildItem -Path ..\artifacts -File)
if (-not ($artifacts | Where-Object { $_.Name -eq 'latest.yml' })) {
throw "No desktop auto-update latest.yml was produced."
}
if (-not ($artifacts | Where-Object { $_.Extension -eq '.blockmap' })) {
throw "No desktop installer blockmap was produced."
}
if (-not ($artifacts | Where-Object { $_.Extension -eq '.exe' })) {
throw "No Windows installer .exe was copied for update delivery."
}
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: taskbridge-desktop
path: artifacts/*
if-no-files-found: error
docker:
name: Build and publish backend image
runs-on: ubuntu-latest
needs: prepare
outputs:
ghcr_image: ${{ steps.image.outputs.ghcr }}
dockerhub_image: ${{ steps.image.outputs.dockerhub }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Validate Docker Hub secrets
shell: bash
run: |
missing=()
for name in DOCKERHUB_USERNAME DOCKERHUB_TOKEN; do
if [ -z "${!name}" ]; then
missing+=("$name")
fi
done
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::Missing Docker Hub secrets: ${missing[*]}."
exit 1
fi
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Resolve image name
id: image
shell: bash
run: |
ghcr_image="ghcr.io/${GITHUB_REPOSITORY_OWNER}/taskbridge"
dockerhub_image="${DOCKERHUB_IMAGE}"
echo "ghcr=${ghcr_image,,}" >> "$GITHUB_OUTPUT"
echo "dockerhub=${dockerhub_image,,}" >> "$GITHUB_OUTPUT"
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ steps.image.outputs.ghcr }}
${{ steps.image.outputs.dockerhub }}
tags: |
type=raw,value=${{ env.RELEASE_VERSION }}
type=raw,value=latest,enable=${{ github.event_name == 'push' || inputs.publish_latest == true }}
- name: Build backend image for vulnerability scan
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile
push: false
load: true
tags: taskbridge:release-scan
labels: ${{ steps.meta.outputs.labels }}
- name: Scan backend image before publish
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: taskbridge:release-scan
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '1'
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
sbom: true
provenance: mode=max
release:
name: Update GitHub Release
runs-on: ubuntu-latest
needs:
- android
- desktop
- docker
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: release-artifacts
pattern: taskbridge-*
merge-multiple: true
- name: List release artifacts
shell: bash
run: find release-artifacts -maxdepth 1 -type f -print
- name: Generate release artifact checksums
shell: bash
run: |
cd release-artifacts
sha256sum * > SHA256SUMS.txt
- name: Attest release artifacts
uses: actions/attest-build-provenance@v2
with:
subject-path: release-artifacts/*
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_VERSION }}
name: TaskBridge ${{ env.RELEASE_VERSION }}
target_commitish: ${{ github.sha }}
generate_release_notes: true
files: release-artifacts/*
fail_on_unmatched_files: true
body: |
Backend Docker images:
- `${{ needs.docker.outputs.ghcr_image }}:${{ env.RELEASE_VERSION }}`
- `${{ needs.docker.outputs.dockerhub_image }}:${{ env.RELEASE_VERSION }}`
When `latest` is enabled, these tags are also published:
- `${{ needs.docker.outputs.ghcr_image }}:latest`
- `${{ needs.docker.outputs.dockerhub_image }}:latest`
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}