diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index c054629..47b539f 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,6 +1,7 @@ -# CI runs only on release tags. +# CI runs first; build only proceeds if all checks pass. # -# Tag v*: analyze + test → build APK → attach to Forgejo Release +# Push to dev or main: flutter analyze + flutter test +# Tag v* (release): gates + signed APK build + attach to Forgejo Release # # To cut a release: # Create a release via the Forgejo UI on main with a v* tag name. @@ -11,19 +12,39 @@ # commands directly instead. # # Required secrets (repo → Settings → Secrets → Actions): -# RELEASE_TOKEN — Forgejo PAT with write:repository scope +# RELEASE_TOKEN — Forgejo PAT with write:repository scope +# RELEASE_KEYSTORE_BASE64 — base64 of the signing keystore +# RELEASE_KEYSTORE_PASSWORD — keystore + key password +# RELEASE_KEY_ALIAS — key alias within the keystore name: CI & Build on: push: + branches: [dev, main] tags: ["v*"] +# Cancel older runs on the same branch when a newer push lands. Tag runs +# are never cancelled so a release build can't kill itself mid-flight. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +# Least-privilege default. The build job upgrades to contents: write +# so it can attach the APK to a Forgejo release. +permissions: + contents: read + jobs: analyze: name: Analyze & test runs-on: py3.12-node22 container: - image: ghcr.io/cirruslabs/flutter:stable + # Pinned to a specific Flutter version for reproducible builds. + # Floating :stable means a random Flutter minor bump could change + # analyzer output or break the build without any commit landing. + # Bump this line (and verify locally with `flutter --version`) + # when you intentionally want a newer Flutter. + image: ghcr.io/cirruslabs/flutter:3.41.6 steps: - name: Checkout run: | @@ -42,9 +63,14 @@ jobs: build: name: Build release APK needs: [analyze] + # Only tag pushes produce a signed release build. dev/main pushes + # run the gates above and stop there. + if: startsWith(github.ref, 'refs/tags/') runs-on: py3.12-node22 container: - image: ghcr.io/cirruslabs/flutter:stable + image: ghcr.io/cirruslabs/flutter:3.41.6 + permissions: + contents: write steps: - name: Checkout run: | @@ -74,57 +100,12 @@ jobs: --build-name="$BUILD_NAME" \ --build-number="$BUILD_NUMBER" - - name: Set artifact name - id: artifact - run: | - echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT - - - name: Upload artifact to Forgejo - env: - RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} - APK: build/app/outputs/flutter-apk/app-release.apk - API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp - ARTIFACT_NAME: ${{ steps.artifact.outputs.name }} - run: | - # Upload the APK as a workflow artifact via the Forgejo API. - curl -s -X POST "$API/actions/artifacts" \ - -H "Authorization: token $RELEASE_TOKEN" \ - -F "name=$ARTIFACT_NAME" \ - -F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)." - - name: Publish Forgejo release + # Release-publish logic lives in a shell script so it's + # testable locally (bash -x scripts/publish_apk_release.sh + # with env vars set) instead of trapped in YAML. env: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} TAG: ${{ github.ref_name }} - API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp - run: | - # Look for an existing release (created via the UI or a prior run). - EXISTING=$(curl -s \ - "$API/releases/tags/$TAG" \ - -H "Authorization: token $RELEASE_TOKEN") - - RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') - - if [ -n "$RELEASE_ID" ]; then - echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..." - else - echo "No existing release found, creating $TAG..." - RESPONSE=$(curl -s -X POST "$API/releases" \ - -H "Authorization: token $RELEASE_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}") - RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+') - if [ -z "$RELEASE_ID" ]; then - echo "Failed to create release. API response:" - echo "$RESPONSE" - exit 1 - fi - echo "Release created with id $RELEASE_ID." - fi - - curl -s -X POST "$API/releases/$RELEASE_ID/assets" \ - -H "Authorization: token $RELEASE_TOKEN" \ - -F "attachment=@build/app/outputs/flutter-apk/app-release.apk" - - echo "Done — $TAG is live at:" - echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG" + APK: build/app/outputs/flutter-apk/app-release.apk + run: bash scripts/publish_apk_release.sh diff --git a/scripts/publish_apk_release.sh b/scripts/publish_apk_release.sh new file mode 100755 index 0000000..aebef6a --- /dev/null +++ b/scripts/publish_apk_release.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# Publish a signed release APK to Forgejo. +# +# Finds the release matching $TAG (creating it if the CI job got here +# before the UI did), then attaches the APK as a release asset. +# +# Required env: +# RELEASE_TOKEN — Forgejo PAT with write:repository scope +# TAG — release tag (e.g. v26.04.11) +# APK — path to the built APK +# +# Optional env: +# FORGEJO_API — defaults to the FabledApp repo API root +# +# Exits non-zero if the release can't be created or the asset upload +# fails. Designed to be testable locally: +# RELEASE_TOKEN=... TAG=v0.0.1 APK=/tmp/test.apk bash -x scripts/publish_apk_release.sh +set -euo pipefail + +: "${RELEASE_TOKEN:?RELEASE_TOKEN not set}" +: "${TAG:?TAG not set}" +: "${APK:?APK not set}" + +if [ ! -f "$APK" ]; then + echo "APK not found at $APK" >&2 + exit 1 +fi + +API="${FORGEJO_API:-https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp}" + +# jq isn't in the cirruslabs Flutter image, so we parse JSON with grep. +# Fragile but bounded: we only care about the first "id": field, +# which is always the release id in Forgejo's responses for these +# endpoints. If Forgejo ever adds a preceding id field, revisit. +extract_id() { + echo "$1" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' +} + +# Look for an existing release (created via the UI or a prior run). +# curl -f turns 4xx/5xx into non-zero so we can distinguish "not found" +# (no release yet) from "auth broken" (real failure). +existing=$(curl -fsS -H "Authorization: token $RELEASE_TOKEN" \ + "$API/releases/tags/$TAG" 2>/dev/null || true) + +release_id=$(extract_id "$existing") + +if [ -z "$release_id" ]; then + echo "No existing release for $TAG — creating one..." + response=$(curl -fsS -X POST \ + -H "Authorization: token $RELEASE_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}" \ + "$API/releases") + release_id=$(extract_id "$response") + if [ -z "$release_id" ]; then + echo "Failed to create release. API response:" >&2 + echo "$response" >&2 + exit 1 + fi + echo "Created release $TAG (id=$release_id)." +else + echo "Found existing release $TAG (id=$release_id). Attaching APK..." +fi + +curl -fsS -X POST \ + -H "Authorization: token $RELEASE_TOKEN" \ + -F "attachment=@$APK" \ + "$API/releases/$release_id/assets" > /dev/null + +echo "Done — $TAG is live at:" +echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"