set shell := ["bash", "-eo", "pipefail", "-c"] default_destination := "platform=iOS Simulator,name=iPhone 17 Pro" default_destination_b := "platform=iOS Simulator,name=iPhone Air" default_destination_c := "platform=iOS Simulator,name=iPhone 17 Pro Max" compose_file := "integration/docker-compose.yml" base_url_1 := "http://localhost:13001" base_url_2 := "http://localhost:13002" base_url_3 := "http://localhost:13003" readonly_classes := "LoginUITests CommitHistoryUITests PaginationUITests RepositoryUITests IssueUITests PullRequestUITests OverviewCreateUITests HomeScreenUITests PermissionUITests MergedInstanceReadOnlyUITests ActionsUITests" mutating_a_classes := "MergedInstanceUITests IssueMutatingUITests NotificationsUITests OverviewCreateMutatingUITests" mutating_b_classes := "PullRequestMutatingUITests RepositoryMutatingUITests" # List all available tasks default: @just --list # Build the app build destination=default_destination: xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -destination '{{destination}}' build 2>&1 | xcbeautify # Run app unit tests test destination=default_destination: xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -destination '{{destination}}' test -only-testing:ForjiTests 2>&1 | xcbeautify # Lint Swift code lint: swiftlint lint Forji/Forji # Format Swift code format: swiftformat Forji/Forji pbxproj := "Forji/Forji.xcodeproj/project.pbxproj" # Show current app version version: @grep -m1 'MARKETING_VERSION' {{pbxproj}} | sed 's/.*= *//;s/;.*//' changelog := "CHANGELOG.md" # Preview the changelog entries for unreleased commits (since the latest tag) changelog: @git cliff --config cliff.toml --unreleased --strip header # Release a new version: bump version, generate the changelog, tag, commit, and push release new_version: #!/usr/bin/env bash set -eo pipefail NEW="{{new_version}}" TAG="v$NEW" # Refuse to release from a dirty tree so the release commit stays clean. if [ -n "$(git status --porcelain)" ]; then echo "Working tree is not clean. Commit or stash your changes before releasing." exit 1 fi # Refuse to clobber an existing tag. if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Tag $TAG already exists." exit 1 fi PREV=$(grep -m1 'MARKETING_VERSION' {{pbxproj}} | sed 's/.*= *//;s/;.*//') echo "Releasing $PREV -> $NEW" # 1. Bump the version in the Xcode project. Use a backup suffix so the -i # syntax works on both BSD sed (macOS) and GNU sed (nix dev shell). sed -i.bak "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = $NEW/" {{pbxproj}} sed -i.bak "s/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = $NEW/" {{pbxproj}} rm -f {{pbxproj}}.bak # 2. Generate the changelog section for the new version from the conventional # commits since the last tag, prepending it under the header. git cliff --config cliff.toml --unreleased --tag "$TAG" --prepend {{changelog}} # 3. Commit, tag, and push. git add {{pbxproj}} {{changelog}} git commit -m "chore: release $NEW" git tag -a "$TAG" -m "Release $NEW" git push origin HEAD git push origin "$TAG" echo "Released $NEW and pushed $TAG." # Clean build artifacts clean: xcodebuild -project Forji/Forji.xcodeproj -scheme Forji clean 2>&1 | xcbeautify # Create simulators for justfile destinations on the latest installed iOS runtime sim-update: #!/usr/bin/env bash set -eo pipefail DEVICES=("iPhone 17 Pro" "iPhone Air" "iPhone 17 Pro Max") LATEST=$(xcrun simctl list runtimes -j | jq '[.runtimes[] | select(.platform == "iOS" and .isAvailable)] | sort_by(.version | split(".") | map(tonumber)) | last') if [ -z "$LATEST" ] || [ "$LATEST" = "null" ]; then echo "No iOS simulator runtime found. Install one in Xcode → Settings → Platforms." exit 1 fi LATEST_VERSION=$(echo "$LATEST" | jq -r '.version') LATEST_IDENTIFIER=$(echo "$LATEST" | jq -r '.identifier') echo "Latest iOS runtime: $LATEST_VERSION ($LATEST_IDENTIFIER)" EXISTING=$(xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -showdestinations 2>&1 || true) CREATED=0 for DEVICE in "${DEVICES[@]}"; do if echo "$EXISTING" | grep -E "OS:${LATEST_VERSION//./\\.},.*name:${DEVICE} \}" >/dev/null; then echo " ✓ $DEVICE already exists on $LATEST_VERSION" else DEVICE_TYPE_ID=$(xcrun simctl list devicetypes -j | jq -r --arg n "$DEVICE" '.devicetypes[] | select(.name == $n) | .identifier') if [ -z "$DEVICE_TYPE_ID" ]; then echo " ✗ Device type not found: $DEVICE (skipping)" continue fi echo " → Creating $DEVICE on $LATEST_VERSION..." xcrun simctl create "$DEVICE" "$DEVICE_TYPE_ID" "$LATEST_IDENTIFIER" >/dev/null CREATED=$((CREATED + 1)) fi done if [ "$CREATED" -gt 0 ]; then echo "Created $CREATED simulator(s)." else echo "All simulators are up to date." fi # Build, install, and launch in simulator run destination=default_destination: #!/usr/bin/env bash set -eo pipefail SIM_NAME="$(echo '{{destination}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" xcrun simctl boot "$SIM_NAME" 2>/dev/null || true open -a Simulator xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -destination '{{destination}}' build 2>&1 | xcbeautify BUILT_APP="$(xcodebuild -project Forji/Forji.xcodeproj -scheme Forji -destination '{{destination}}' -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/Forji.app" xcrun simctl install "$SIM_NAME" "$BUILT_APP" xcrun simctl launch "$SIM_NAME" "$(defaults read "$BUILT_APP/Info.plist" CFBundleIdentifier)" # List all UI integration tests test-list: @grep -rh 'func test.*()' Forji/ForjiUITests/*.swift \ | grep -v 'override\|private\|ForgejoUITestBase' \ | sed 's/.*func //' | sed 's/().*//' \ | while read -r method; do \ file=$(grep -rl "func $method()" Forji/ForjiUITests/*.swift | head -1); \ class=$(grep 'class.*:' "$file" | head -1 | sed 's/.*class //' | sed 's/[: ].*//' ); \ printf " %s/%s\n" "$class" "$method"; \ done | sort # Start Forgejo Docker containers docker-up: docker compose -f {{compose_file}} up -d --wait --wait-timeout 120 # Stop Forgejo Docker containers and clean up docker-down: docker compose -f {{compose_file}} down -v 2>/dev/null || true rm -f /tmp/forgejo_test_url.txt /tmp/forgejo_test_url_*.txt rm -f /tmp/forgejo_test_url2.txt /tmp/forgejo_test_url2_*.txt rm -f /tmp/forgejo_test_token.txt /tmp/forgejo_test_token_*.txt # Clear integration test caches (seed snapshots, temp files) clean-integration: rm -f integration/.forgejo-seed-snapshot.tar.gz integration/.forgejo-seed-hash rm -f /tmp/forgejo_test_url.txt /tmp/forgejo_test_url_*.txt rm -f /tmp/forgejo_test_url2.txt /tmp/forgejo_test_url2_*.txt rm -f /tmp/forgejo_test_token.txt /tmp/forgejo_test_token_*.txt rm -rf integration/forgejo-seed/.build @echo "Integration test caches cleared." # Seed test data into Forgejo instances (with snapshot caching) seed: #!/usr/bin/env bash set -eo pipefail COMPOSE_FILE="{{compose_file}}" BASE_URLS=("{{base_url_1}}" "{{base_url_2}}" "{{base_url_3}}") SERVICES=(forgejo-1 forgejo-2 forgejo-3) SNAPSHOT_FILE="integration/.forgejo-seed-snapshot.tar.gz" HASH_FILE="integration/.forgejo-seed-hash" SETUP_HASH=$(find integration/forgejo-seed/Sources -name '*.swift' -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | awk '{print $1}') wait_for_instance() { local url="$1" elapsed=0 until curl -sf "$url/api/v1/version" > /dev/null 2>&1; do if [ "$elapsed" -ge 60 ]; then echo "Timed out waiting for $url after 60s" exit 1 fi sleep 1 elapsed=$((elapsed + 1)) done echo " $url is ready (${elapsed}s)" } if [ -f "$SNAPSHOT_FILE" ] && [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE")" = "$SETUP_HASH" ]; then echo "Restoring from seed snapshot..." for svc in "${SERVICES[@]}"; do CID=$(docker compose -f "$COMPOSE_FILE" ps -q "$svc") docker cp "$SNAPSHOT_FILE" "$CID:/tmp/snapshot.tar.gz" & done wait for svc in "${SERVICES[@]}"; do CID=$(docker compose -f "$COMPOSE_FILE" ps -q "$svc") docker exec "$CID" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" & done wait docker compose -f "$COMPOSE_FILE" restart "${SERVICES[@]}" for url in "${BASE_URLS[@]}"; do wait_for_instance "$url" & done wait echo "Snapshot restored to all instances." else echo "Seeding test data..." swift run --package-path integration/forgejo-seed forgejo-seed "${BASE_URLS[0]}" forgejo-1 echo "Snapshotting seed data..." CID_1=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-1) docker exec "$CID_1" sh -c "cd / && tar czf /tmp/snapshot.tar.gz data" docker cp "$CID_1:/tmp/snapshot.tar.gz" "$SNAPSHOT_FILE" echo "$SETUP_HASH" > "$HASH_FILE" echo "Restoring snapshot to other instances..." for svc in "${SERVICES[@]:1}"; do CID=$(docker compose -f "$COMPOSE_FILE" ps -q "$svc") docker cp "$SNAPSHOT_FILE" "$CID:/tmp/snapshot.tar.gz" docker exec "$CID" sh -c "cd / && tar xzf /tmp/snapshot.tar.gz" & done wait docker compose -f "$COMPOSE_FILE" restart "${SERVICES[@]:1}" for url in "${BASE_URLS[@]:1}"; do wait_for_instance "$url" & done wait echo "All instances seeded." fi # Run read-only UI tests (assumes build + seed + URL files are set up) test-readonly destination=default_destination: #!/usr/bin/env bash set -eo pipefail ARGS="" for cls in {{readonly_classes}}; do ARGS="$ARGS -only-testing:ForjiUITests/$cls" done xcodebuild test-without-building \ -project Forji/Forji.xcodeproj \ -scheme Forji \ -destination '{{destination}}' \ -parallel-testing-enabled YES \ $ARGS 2>&1 | xcbeautify # Run mutating group A: MergedInstanceUITests (needs 2 instances) test-mutating-a destination=default_destination_b: #!/usr/bin/env bash set -eo pipefail ARGS="" for cls in {{mutating_a_classes}}; do ARGS="$ARGS -only-testing:ForjiUITests/$cls" done xcodebuild test-without-building \ -project Forji/Forji.xcodeproj \ -scheme Forji \ -destination '{{destination}}' \ -parallel-testing-enabled NO \ $ARGS 2>&1 | xcbeautify # Run mutating group B: all non-merged mutating tests test-mutating-b destination=default_destination_c: #!/usr/bin/env bash set -eo pipefail ARGS="" for cls in {{mutating_b_classes}}; do ARGS="$ARGS -only-testing:ForjiUITests/$cls" done xcodebuild test-without-building \ -project Forji/Forji.xcodeproj \ -scheme Forji \ -destination '{{destination}}' \ -parallel-testing-enabled NO \ $ARGS 2>&1 | xcbeautify # Run full UI integration test suite (requires Docker) test-ui destination_a=default_destination destination_b=default_destination_b destination_c=default_destination_c: #!/usr/bin/env bash set -eo pipefail cleanup() { just docker-down } trap cleanup EXIT just sim-boot '{{destination_a}}' '{{destination_b}}' '{{destination_c}}' & just docker-up & just build-for-testing '{{destination_a}}' '{{destination_b}}' '{{destination_c}}' & wait just seed just _write-url-files '{{destination_a}}' '{{destination_b}}' '{{destination_c}}' LOG_RO="/tmp/forji_test_readonly.log" LOG_MA="/tmp/forji_test_mutating_a.log" LOG_MB="/tmp/forji_test_mutating_b.log" EXIT_RO=0 EXIT_MA=0 EXIT_MB=0 echo "Starting read-only tests on {{destination_a}}..." just test-readonly '{{destination_a}}' > "$LOG_RO" 2>&1 & PID_RO=$! echo "Starting mutating-a (merged) tests on {{destination_b}}..." just test-mutating-a '{{destination_b}}' > "$LOG_MA" 2>&1 & PID_MA=$! echo "Starting mutating-b (rest) tests on {{destination_c}}..." just test-mutating-b '{{destination_c}}' > "$LOG_MB" 2>&1 & PID_MB=$! wait $PID_RO || EXIT_RO=$? wait $PID_MA || EXIT_MA=$? wait $PID_MB || EXIT_MB=$? echo "=== Read-only tests output ===" cat "$LOG_RO" echo "" echo "=== Mutating-A (merged) tests output ===" cat "$LOG_MA" echo "" echo "=== Mutating-B (rest) tests output ===" cat "$LOG_MB" rm -f "$LOG_RO" "$LOG_MA" "$LOG_MB" FAILED=0 if [ "$EXIT_RO" -ne 0 ]; then echo "Read-only tests failed (exit $EXIT_RO)."; FAILED=1; fi if [ "$EXIT_MA" -ne 0 ]; then echo "Mutating-A tests failed (exit $EXIT_MA)."; FAILED=1; fi if [ "$EXIT_MB" -ne 0 ]; then echo "Mutating-B tests failed (exit $EXIT_MB)."; FAILED=1; fi if [ "$FAILED" -ne 0 ]; then echo "Integration tests FAILED." exit 1 fi echo "" echo "All integration tests passed." # Run a single UI test (requires Docker). Use `just test-list` to see available tests. test-one filter="" destination=default_destination: #!/usr/bin/env bash set -eo pipefail FILTER="{{filter}}" if [ -z "$FILTER" ]; then echo "Usage: just test-one " echo "" echo "Run 'just test-list' to see available tests." exit 1 fi cleanup() { just docker-down } trap cleanup EXIT just sim-boot '{{destination}}' '{{destination}}' '{{destination}}' & just docker-up & just build-for-testing '{{destination}}' '{{destination}}' '{{destination}}' & wait just seed just _write-url-file '{{destination}}' '{{base_url_1}}' true just _write-url2-file '{{destination}}' '{{base_url_2}}' true xcodebuild test-without-building \ -project Forji/Forji.xcodeproj \ -scheme Forji \ -destination '{{destination}}' \ -parallel-testing-enabled NO \ -only-testing:"ForjiUITests/$FILTER" 2>&1 | xcbeautify [private] sim-boot destination_a=default_destination destination_b=default_destination_b destination_c=default_destination_c: #!/usr/bin/env bash set -eo pipefail for dest in '{{destination_a}}' '{{destination_b}}' '{{destination_c}}'; do SIM="$(echo "$dest" | sed -n 's/.*name=\([^,]*\).*/\1/p')" xcrun simctl boot "$SIM" 2>/dev/null || true done [private] build-for-testing destination_a=default_destination destination_b=default_destination_b destination_c=default_destination_c: xcodebuild build-for-testing \ -project Forji/Forji.xcodeproj \ -scheme Forji \ -destination '{{destination_a}}' \ -destination '{{destination_b}}' \ -destination '{{destination_c}}' 2>&1 | xcbeautify [private] _write-url-file destination url fallback="false": #!/usr/bin/env bash set -eo pipefail SIM="$(echo '{{destination}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" SIM_SAFE="${SIM// /_}" echo "{{url}}" > "/tmp/forgejo_test_url_${SIM_SAFE}.txt" if [ "{{fallback}}" = "true" ]; then echo "{{url}}" > /tmp/forgejo_test_url.txt fi [private] _write-url2-file destination url fallback="false": #!/usr/bin/env bash set -eo pipefail SIM="$(echo '{{destination}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" SIM_SAFE="${SIM// /_}" echo "{{url}}" > "/tmp/forgejo_test_url2_${SIM_SAFE}.txt" if [ "{{fallback}}" = "true" ]; then echo "{{url}}" > /tmp/forgejo_test_url2.txt fi [private] _write-url-files destination_a=default_destination destination_b=default_destination_b destination_c=default_destination_c: just _write-url-file '{{destination_a}}' '{{base_url_1}}' true just _write-url-file '{{destination_b}}' '{{base_url_2}}' just _write-url-file '{{destination_c}}' '{{base_url_3}}' just _write-url2-file '{{destination_a}}' '{{base_url_2}}' true just _write-url2-file '{{destination_b}}' '{{base_url_1}}'