Skip to content
Open
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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,23 @@ env:
FLUTTER_CHANNEL: stable

jobs:
# Supply-chain coverage on this repo:
# 1. Dependabot (.github/dependabot.yml) — weekly grouped pub +
# github-actions update PRs; available on private repos without GHAS.
# 2. Dependabot security alerts — auto-enabled on private repos; opens
# PRs for known CVEs in pubspec.lock.
# 3. gitleaks (.github/workflows/secret-scan.yml) — credential leakage
# scan on every push, PR, and weekly cron.
# 4. CODEOWNERS — Tyler-only review on /.github/, /android/app/build.gradle*,
# /ios/Runner/, and signing-config paths.
# actions/dependency-review-action would add PR-time license + CVE diff
# review but requires GitHub Advanced Security on private repos. Re-add
# it (or swap in google/osv-scanner-action) when this repo goes public or
# GHAS is enabled.
analyze:
name: Analyze + Format
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
Expand All @@ -41,6 +55,7 @@ jobs:
test:
name: Unit + Widget tests
runs-on: ubuntu-latest
timeout-minutes: 15
needs: analyze
steps:
- uses: actions/checkout@v4
Expand All @@ -64,6 +79,7 @@ jobs:
build-android:
name: Build Android (debug APK)
runs-on: ubuntu-latest
timeout-minutes: 30
needs: test
steps:
- uses: actions/checkout@v4
Expand All @@ -90,6 +106,7 @@ jobs:
build-ios:
name: Build iOS (no codesign)
runs-on: macos-latest
timeout-minutes: 45
needs: test
steps:
- uses: actions/checkout@v4
Expand All @@ -106,6 +123,7 @@ jobs:
build-web:
name: Build Web
runs-on: ubuntu-latest
timeout-minutes: 15
needs: test
steps:
- uses: actions/checkout@v4
Expand Down
299 changes: 299 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
# Release pipeline.
#
# Trigger: push of a tag matching v*.*.* (e.g. v1.0.0, v1.0.0-rc.1).
# Manual trigger via workflow_dispatch is also supported (provide a tag input).
#
# Builds a signed Android App Bundle (Play Store) + APK (sideload) and creates
# a GitHub Release with the artifacts attached. Release notes are pulled from
# the matching section of CHANGELOG.md (or [Unreleased] as a fallback).
#
# Signing degrades gracefully:
# - If ANDROID_KEYSTORE_BASE64 + companion secrets are configured, builds
# are signed with the upload keystore.
# - If they are absent, builds are debug-signed and the GitHub Release is
# created as a draft so it cannot be accidentally published.
#
# Required repository secrets for production-signed builds:
# - ANDROID_KEYSTORE_BASE64 base64-encoded upload keystore (.jks)
# - ANDROID_KEYSTORE_PASSWORD store password
# - ANDROID_KEY_ALIAS key alias
# - ANDROID_KEY_PASSWORD key password
#
# Optional repository secrets (improve the build but not required):
# - ADMOB_APP_ID_ANDROID production AdMob app ID (defaults to test ID)
# - ADMOB_BANNER_ID_ANDROID production banner ad unit ID
# - RC_PUBLIC_API_KEY_ANDROID RevenueCat Android public SDK key
# - RC_LIFETIME_PRODUCT_ID RevenueCat lifetime product identifier
#
# iOS builds are deliberately omitted from this workflow:
# - ci.yml already verifies iOS builds compile on every PR (--no-codesign).
# - TestFlight upload requires Apple Developer Program signing certs and an
# App Store Connect API key, which are not yet provisioned. Add a separate
# ios-release job once those are in place.

name: Release

on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: "Version tag to release (e.g. v1.0.0). Must already exist as a git tag or the release will fail."
required: true
type: string

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

env:
FLUTTER_VERSION: "3.41.5"
FLUTTER_CHANNEL: stable

jobs:
build-android:
name: Build Android (signed AAB + APK)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
outputs:
version: ${{ steps.tag.outputs.version }}
version_no_v: ${{ steps.tag.outputs.version_no_v }}
signing_status: ${{ steps.signing.outputs.status }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Resolve version tag
id: tag
env:
INPUT_TAG: ${{ github.event.inputs.tag }}
GITHUB_REF: ${{ github.ref }}
run: |
if [ -n "$INPUT_TAG" ]; then
version="$INPUT_TAG"
else
version="${GITHUB_REF#refs/tags/}"
fi
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
echo "::error::Tag '$version' does not match v<MAJOR>.<MINOR>.<PATCH>[-prerelease]."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_no_v=${version#v}" >> "$GITHUB_OUTPUT"

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: ${{ env.FLUTTER_CHANNEL }}
cache: true

- run: flutter pub get

- run: dart run build_runner build --delete-conflicting-outputs

- name: Decode keystore (when secret present)
id: signing
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 }}
run: |
if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not configured — release will be debug-signed and the GitHub Release will be marked as draft."
echo "status=debug-signed" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$ANDROID_KEYSTORE_PASSWORD" ] || [ -z "$ANDROID_KEY_ALIAS" ] || [ -z "$ANDROID_KEY_PASSWORD" ]; then
echo "::error::ANDROID_KEYSTORE_BASE64 is set but companion secrets are missing (ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD). Aborting to avoid a silently-debug-signed production release."
exit 1
fi
keystore_path="$RUNNER_TEMP/upload-keystore.jks"
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$keystore_path"
if [ ! -s "$keystore_path" ]; then
echo "::error::Decoded keystore is empty. Check ANDROID_KEYSTORE_BASE64 contents."
exit 1
fi
umask 077
cat > android/key.properties <<EOF
storeFile=$keystore_path
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOF
echo "status=release-signed" >> "$GITHUB_OUTPUT"

- name: Resolve AdMob app ID
id: admob
env:
ADMOB_APP_ID_ANDROID: ${{ secrets.ADMOB_APP_ID_ANDROID }}
run: |
if [ -n "$ADMOB_APP_ID_ANDROID" ]; then
echo "id=$ADMOB_APP_ID_ANDROID" >> "$GITHUB_OUTPUT"
else
echo "::warning::ADMOB_APP_ID_ANDROID secret not set — falling back to AdMob test app ID. The store listing will need a real ID before public release."
echo "id=ca-app-pub-3940256099942544~3347511713" >> "$GITHUB_OUTPUT"
fi

- name: Compute --dart-define flags
id: defines
env:
RC_API_KEY_ANDROID: ${{ secrets.RC_PUBLIC_API_KEY_ANDROID }}
RC_LIFETIME_PRODUCT_ID: ${{ secrets.RC_LIFETIME_PRODUCT_ID }}
ADMOB_BANNER_ID_ANDROID: ${{ secrets.ADMOB_BANNER_ID_ANDROID }}
run: |
flags=""
if [ -n "$RC_API_KEY_ANDROID" ]; then
flags="$flags --dart-define=RC_PUBLIC_API_KEY_ANDROID=$RC_API_KEY_ANDROID"
fi
if [ -n "$RC_LIFETIME_PRODUCT_ID" ]; then
flags="$flags --dart-define=RC_LIFETIME_PRODUCT_ID=$RC_LIFETIME_PRODUCT_ID"
fi
if [ -n "$ADMOB_BANNER_ID_ANDROID" ]; then
flags="$flags --dart-define=ADMOB_BANNER_ID_ANDROID=$ADMOB_BANNER_ID_ANDROID"
fi
echo "flags=$flags" >> "$GITHUB_OUTPUT"

- name: Build AAB (release)
run: |
flutter build appbundle --release \
--build-name="${{ steps.tag.outputs.version_no_v }}" \
-PadmobAppId="${{ steps.admob.outputs.id }}" \
${{ steps.defines.outputs.flags }}

- name: Build APK (release, sideload)
run: |
flutter build apk --release \
--build-name="${{ steps.tag.outputs.version_no_v }}" \
-PadmobAppId="${{ steps.admob.outputs.id }}" \
${{ steps.defines.outputs.flags }}
Comment on lines +170 to +182

- name: Stage versioned artifacts
run: |
mkdir -p dist
cp build/app/outputs/bundle/release/app-release.aab \
"dist/InvisibleHabitBuilder-${{ steps.tag.outputs.version }}.aab"
cp build/app/outputs/flutter-apk/app-release.apk \
"dist/InvisibleHabitBuilder-${{ steps.tag.outputs.version }}.apk"
(cd dist && sha256sum *.aab *.apk > SHA256SUMS.txt)

- name: Upload release artifacts
uses: actions/upload-artifact@v4
with:
name: android-release
path: dist/
if-no-files-found: error
retention-days: 30

- name: Cleanup signing material
if: always()
run: |
rm -f android/key.properties
rm -f "$RUNNER_TEMP/upload-keystore.jks"

create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: build-android
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Download Android artifacts
uses: actions/download-artifact@v4
with:
name: android-release
path: dist/

- name: Extract release notes from CHANGELOG.md
id: notes
env:
VERSION: ${{ needs.build-android.outputs.version }}
VERSION_NO_V: ${{ needs.build-android.outputs.version_no_v }}
SIGNING_STATUS: ${{ needs.build-android.outputs.signing_status }}
run: |
notes="release_notes.md"
if grep -q "^## \[$VERSION_NO_V\]" CHANGELOG.md; then
awk -v ver="$VERSION_NO_V" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
flag && /^## \[/ { exit }
flag { print }
' CHANGELOG.md > "$notes"
elif grep -q "^## \[Unreleased\]" CHANGELOG.md; then
{
echo "_Notes derived from CHANGELOG.md \`[Unreleased]\` section._"
echo "_Rename the section to \`[$VERSION_NO_V] - $(date -u +%Y-%m-%d)\` in a follow-up commit and re-run this workflow with workflow_dispatch to refresh the release body._"
echo ""
awk '
/^## \[Unreleased\]/ { flag = 1; next }
flag && /^## \[/ { exit }
flag { print }
' CHANGELOG.md
} > "$notes"
else
echo "_See commit log for changes in $VERSION._" > "$notes"
fi
{
echo ""
echo "---"
echo ""
echo "**Build signing**: \`$SIGNING_STATUS\`"
if [ "$SIGNING_STATUS" = "debug-signed" ]; then
echo ""
echo "> :warning: This release is debug-signed because production signing secrets are not configured. The GitHub Release is published as a **draft** and the AAB cannot be uploaded to Google Play. Configure \`ANDROID_KEYSTORE_BASE64\`, \`ANDROID_KEYSTORE_PASSWORD\`, \`ANDROID_KEY_ALIAS\`, \`ANDROID_KEY_PASSWORD\` in repository secrets, then re-run the workflow."
fi
} >> "$notes"
echo "path=$notes" >> "$GITHUB_OUTPUT"

- name: Determine prerelease flag
id: prerelease
env:
VERSION: ${{ needs.build-android.outputs.version }}
run: |
if [[ "$VERSION" == *-* ]]; then
echo "value=true" >> "$GITHUB_OUTPUT"
else
echo "value=false" >> "$GITHUB_OUTPUT"
fi

- name: Determine draft flag
id: draft
env:
SIGNING_STATUS: ${{ needs.build-android.outputs.signing_status }}
run: |
if [ "$SIGNING_STATUS" = "debug-signed" ]; then
echo "value=true" >> "$GITHUB_OUTPUT"
else
echo "value=false" >> "$GITHUB_OUTPUT"
fi

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.build-android.outputs.version }}
name: ${{ needs.build-android.outputs.version }}
body_path: ${{ steps.notes.outputs.path }}
draft: ${{ steps.draft.outputs.value }}
prerelease: ${{ steps.prerelease.outputs.value }}
files: |
dist/*.aab
dist/*.apk
dist/SHA256SUMS.txt
fail_on_unmatched_files: true
Loading
Loading