diff --git a/.github/workflows/appstore-submit.yml b/.github/workflows/appstore-submit.yml new file mode 100644 index 0000000..5f72ad8 --- /dev/null +++ b/.github/workflows/appstore-submit.yml @@ -0,0 +1,292 @@ +name: App Store Submit + +on: + repository_dispatch: + types: [appstore-submit-ready] + workflow_dispatch: + inputs: + tag: + description: "Release tag (optional, e.g. v1.2.3)" + required: false + type: string + version: + description: "Marketing version override (x.y.z)" + required: false + type: string + build_number: + description: "Build number override (CFBundleVersion)" + required: false + type: string + build_id: + description: "ASC build ID override (optional)" + required: false + type: string + sync_assets: + description: "Sync appstore-assets screenshots/previews before submission" + required: false + type: boolean + default: false + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: appstore-submit-${{ github.event_name == 'repository_dispatch' && github.event.client_payload.tag || github.ref }} + cancel-in-progress: false + +jobs: + preflight: + name: Preflight Checks + if: github.event_name == 'workflow_dispatch' || github.event.client_payload.prerelease != true + runs-on: [self-hosted, macOS, ARM64, bgpro-charstack] + timeout-minutes: 45 + + outputs: + tag: ${{ steps.ctx.outputs.tag }} + version: ${{ steps.ctx.outputs.version }} + build_number: ${{ steps.build.outputs.build_number }} + build_id: ${{ steps.build.outputs.build_id }} + app_store_version_id: ${{ steps.appstore.outputs.app_store_version_id }} + sync_assets: ${{ steps.ctx.outputs.sync_assets }} + + env: + ASC_APP_ID: ${{ vars.ASC_APP_ID }} + ASC_PRIMARY_LOCALE: ${{ vars.ASC_PRIMARY_LOCALE || 'en-US' }} + ASC_SUPPORT_URL: ${{ vars.ASC_SUPPORT_URL }} + ASC_PRIVACY_POLICY_URL: ${{ vars.ASC_PRIVACY_POLICY_URL }} + APPSTORE_API_PRIVATE_KEY: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} + APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + + steps: + - name: Checkout dispatch tag + if: github.event_name == 'repository_dispatch' && github.event.client_payload.tag != '' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.client_payload.tag }} + + - name: Checkout workflow_dispatch tag + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag }} + + - name: Checkout default branch + if: (github.event_name == 'workflow_dispatch' && inputs.tag == '') || (github.event_name == 'repository_dispatch' && github.event.client_payload.tag == '') + uses: actions/checkout@v6 + + - name: Validate required configuration + run: | + set -euo pipefail + required=( + ASC_APP_ID + APPSTORE_API_PRIVATE_KEY + APPSTORE_API_KEY_ID + APPSTORE_API_ISSUER_ID + ) + for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "error: missing required secret/var: $name" >&2 + exit 1 + fi + done + + - name: Build submission context + id: ctx + env: + DISPATCH_TAG: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.tag || '' }} + DISPATCH_VERSION: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version || '' }} + DISPATCH_BUILD_NUMBER: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.build_number || '' }} + DISPATCH_BUILD_ID: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.build_id || '' }} + DISPATCH_SYNC_ASSETS: false + INPUT_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }} + INPUT_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }} + INPUT_BUILD_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.build_number || '' }} + INPUT_BUILD_ID: ${{ github.event_name == 'workflow_dispatch' && inputs.build_id || '' }} + INPUT_SYNC_ASSETS: ${{ github.event_name == 'workflow_dispatch' && inputs.sync_assets || false }} + run: | + set -euo pipefail + + tag="${DISPATCH_TAG:-${INPUT_TAG:-}}" + version="${DISPATCH_VERSION:-${INPUT_VERSION:-}}" + requested_build_number="${DISPATCH_BUILD_NUMBER:-${INPUT_BUILD_NUMBER:-}}" + requested_build_id="${DISPATCH_BUILD_ID:-${INPUT_BUILD_ID:-}}" + + if [[ -z "$version" ]]; then + if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + version="${BASH_REMATCH[1]}" + else + version="$(tr -d '[:space:]' < VERSION)" + fi + fi + + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: invalid marketing version '$version'" >&2 + exit 1 + fi + + if [[ -n "$requested_build_number" ]]; then + if ! [[ "$requested_build_number" =~ ^[0-9]+$ ]]; then + echo "error: build_number must be numeric" >&2 + exit 1 + fi + fi + + sync_assets="false" + if [[ "${INPUT_SYNC_ASSETS}" == "true" ]]; then + sync_assets="true" + fi + + { + echo "tag=$tag" + echo "version=$version" + echo "requested_build_number=$requested_build_number" + echo "requested_build_id=$requested_build_id" + echo "sync_assets=$sync_assets" + } >> "$GITHUB_OUTPUT" + + - name: Validate submission checklist + run: | + set -euo pipefail + python3 scripts/appstore/validate_submission_checklist.py + env: + VERSION: ${{ steps.ctx.outputs.version }} + + - name: Resolve App Store version + id: appstore + run: | + set -euo pipefail + + lookup="$(scripts/appstore/asc_api.sh GET "/v1/appStoreVersions?filter[app]=${ASC_APP_ID}&filter[versionString]=${{ steps.ctx.outputs.version }}&filter[platform]=IOS&limit=1")" + app_store_version_id="$(printf '%s' "$lookup" | python3 -c 'import json,sys; items=(json.load(sys.stdin).get("data") or []); print(items[0].get("id", "") if items else "")')" + + if [[ -z "$app_store_version_id" ]]; then + VERSION_STRING="${{ steps.ctx.outputs.version }}" APP_ID="${ASC_APP_ID}" python3 -c 'import json,os; print(json.dumps({"data":{"type":"appStoreVersions","attributes":{"platform":"IOS","versionString":os.environ["VERSION_STRING"]},"relationships":{"app":{"data":{"type":"apps","id":os.environ["APP_ID"]}}}}}))' > build/create-app-store-version.json + created="$(scripts/appstore/asc_api.sh POST "/v1/appStoreVersions" build/create-app-store-version.json)" + app_store_version_id="$(printf '%s' "$created" | python3 -c 'import json,sys; payload=json.load(sys.stdin); print((payload.get("data") or {}).get("id", ""))')" + fi + + if [[ -z "$app_store_version_id" ]]; then + echo "error: failed to resolve appStoreVersion ID" >&2 + exit 1 + fi + + echo "app_store_version_id=$app_store_version_id" >> "$GITHUB_OUTPUT" + + - name: Resolve VALID build + id: build + run: | + set -euo pipefail + + requested_build_id="${{ steps.ctx.outputs.requested_build_id }}" + requested_build_number="${{ steps.ctx.outputs.requested_build_number }}" + + if [[ -n "$requested_build_id" ]]; then + response="$(scripts/appstore/asc_api.sh GET "/v1/builds/${requested_build_id}")" + parsed="$(printf '%s' "$response" | python3 -c 'import json,sys; payload=json.load(sys.stdin); data=(payload.get("data") or {}); attrs=(data.get("attributes") or {}); print("{}|{}|{}".format(data.get("id", ""), attrs.get("processingState", ""), attrs.get("version", "")))')" + build_id="${parsed%%|*}" + rest="${parsed#*|}" + state="${rest%%|*}" + build_number="${rest#*|}" + else + query="/v1/builds?filter[app]=${ASC_APP_ID}&include=preReleaseVersion&sort=-uploadedDate&limit=50" + response="$(scripts/appstore/asc_api.sh GET "$query")" + selector_args=( + scripts/appstore/select_build.py + --marketing-version "${{ steps.ctx.outputs.version }}" + ) + if [[ -n "$requested_build_number" ]]; then + selector_args+=( --build-number "$requested_build_number" ) + fi + parsed="$(printf '%s' "$response" | python3 "${selector_args[@]}")" + build_id="${parsed%%|*}" + rest="${parsed#*|}" + state="${rest%%|*}" + build_number="${rest#*|}" + fi + + if [[ -z "$build_id" ]]; then + echo "error: no matching build found for app=${ASC_APP_ID} version=${{ steps.ctx.outputs.version }} build_number=${requested_build_number:-latest}" >&2 + exit 1 + fi + + if [[ "$state" != "VALID" ]]; then + echo "error: selected build is not VALID (state=$state, id=$build_id)" >&2 + exit 1 + fi + + echo "build_id=$build_id" >> "$GITHUB_OUTPUT" + echo "build_number=$build_number" >> "$GITHUB_OUTPUT" + + - name: Optional asset sync + if: steps.ctx.outputs.sync_assets == 'true' + run: | + set -euo pipefail + scripts/appstore/sync_assets.sh \ + --app-store-version-id "${{ steps.appstore.outputs.app_store_version_id }}" \ + --locale "${ASC_PRIMARY_LOCALE}" \ + --root "appstore-assets" + + - name: Upload preflight artifact + uses: actions/upload-artifact@v4 + with: + name: appstore-submit-preflight + path: build/submission-preflight.json + + submit: + name: Submit for App Review + needs: preflight + if: needs.preflight.result == 'success' + runs-on: [self-hosted, macOS, ARM64, bgpro-charstack] + timeout-minutes: 30 + environment: appstore-production + + env: + APPSTORE_API_PRIVATE_KEY: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} + APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Prepare workspace + run: mkdir -p build + + - name: Attach build to App Store version + run: | + set -euo pipefail + + python3 -c 'import json; print(json.dumps({"data":{"type":"builds","id":"${{ needs.preflight.outputs.build_id }}"}}))' > build/attach-build.json + scripts/appstore/asc_api.sh PATCH "/v1/appStoreVersions/${{ needs.preflight.outputs.app_store_version_id }}/relationships/build" build/attach-build.json >/dev/null + + - name: Set manual release control + run: | + set -euo pipefail + + python3 -c 'import json; print(json.dumps({"data":{"type":"appStoreVersions","id":"${{ needs.preflight.outputs.app_store_version_id }}","attributes":{"releaseType":"MANUAL"}}}))' > build/set-release-type.json + scripts/appstore/asc_api.sh PATCH "/v1/appStoreVersions/${{ needs.preflight.outputs.app_store_version_id }}" build/set-release-type.json >/dev/null + + - name: Submit App Store version for review + run: | + set -euo pipefail + + existing="$(scripts/appstore/asc_api.sh GET "/v1/appStoreVersionSubmissions?filter[appStoreVersion]=${{ needs.preflight.outputs.app_store_version_id }}&limit=1")" + existing_id="$(printf '%s' "$existing" | python3 -c 'import json,sys; items=(json.load(sys.stdin).get("data") or []); print(items[0].get("id", "") if items else "")')" + + if [[ -n "$existing_id" ]]; then + echo "Submission already exists: $existing_id" + exit 0 + fi + + python3 -c 'import json; print(json.dumps({"data":{"type":"appStoreVersionSubmissions","relationships":{"appStoreVersion":{"data":{"type":"appStoreVersions","id":"${{ needs.preflight.outputs.app_store_version_id }}"}}}}}))' > build/create-submission.json + scripts/appstore/asc_api.sh POST "/v1/appStoreVersionSubmissions" build/create-submission.json >/dev/null + + - name: Log submission summary + run: | + set -euo pipefail + echo "Submitted version=${{ needs.preflight.outputs.version }} build_number=${{ needs.preflight.outputs.build_number }} build_id=${{ needs.preflight.outputs.build_id }}" diff --git a/.github/workflows/pr-docs.yml b/.github/workflows/pr-docs.yml index c2b5ec8..ac63537 100644 --- a/.github/workflows/pr-docs.yml +++ b/.github/workflows/pr-docs.yml @@ -17,7 +17,7 @@ jobs: update-pr-docs: name: Update PR docs runs-on: [self-hosted, macOS, ARM64, bgpro-charstack] - if: github.event.pull_request.head.repo.full_name == github.repository + if: github.event.pull_request.head.repo.full_name == github.repository && !startsWith(github.event.pull_request.head.ref, 'dependabot/') env: PR_DOCS_DIR: /tmp/pr-docs-${{ github.run_id }}-${{ github.run_attempt }} PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58e49f9..9de0675 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,8 @@ name: Release on: push: branches: [master, develop] + paths-ignore: + - VERSION workflow_dispatch: inputs: bump: @@ -54,6 +56,7 @@ jobs: id: ver run: | REF_NAME="${{ github.ref_name }}" + EVENT_NAME="${{ github.event_name }}" INPUT_BUMP="${{ github.event.inputs.bump || 'auto' }}" INPUT_OVERRIDE="${{ github.event.inputs.version_override || '' }}" @@ -83,18 +86,18 @@ jobs: exit 0 fi - # Find the latest tag for THIS branch type + # Find the latest tag in this branch release stream if [[ "$REF_NAME" == "develop" ]]; then - # develop tags: v1.2.3-dev - LATEST_TAG="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*-dev' --sort=-v:refname | head -1 || true)" + # develop tags: v1.2.3 + LATEST_TAG="$(git tag --merged HEAD --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1 || true)" else - # master tags: v1.2.0 (no suffix) - LATEST_TAG="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -v '\-' | head -1 || true)" + # master tags: v1.2.0 (no suffix), reachable from master + LATEST_TAG="$(git tag --merged HEAD --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -v '\-' | head -1 || true)" fi # Strip prefix and suffix to get bare version LATEST_VERSION="${LATEST_TAG#v}" - LATEST_VERSION="${LATEST_VERSION%-dev}" + LATEST_VERSION="${LATEST_VERSION%}" # Also read VERSION file as fallback BASE="$(tr -d '[:space:]' < VERSION)" @@ -142,13 +145,23 @@ jobs: )" fi - # Build the tag name (develop gets -dev suffix) if [[ "$REF_NAME" == "develop" ]]; then - TAG="v${FINAL_VERSION}-dev" + TAG="v${FINAL_VERSION}" else TAG="v${FINAL_VERSION}" fi + # Skip push-triggered releases when there are no content changes + # since the previous tag in this stream (for example empty sync merges). + if [[ "$EVENT_NAME" == "push" && -n "$LATEST_TAG" ]]; then + if git diff --quiet "${LATEST_TAG}..HEAD" -- . ":(exclude)VERSION"; then + echo "No content changes since ${LATEST_TAG}; skipping release." + echo "prev_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + # Check tag doesn't already exist if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then echo "::error::Tag $TAG already exists on remote." @@ -157,6 +170,7 @@ jobs: echo "version=$FINAL_VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "prev_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT" # Determine if this is a prerelease (develop) or full release (master) @@ -171,34 +185,6 @@ jobs: if: steps.ver.outputs.skip == 'true' run: echo "Bump is 'none' — skipping release." - # ── Create & push tag ── - - name: Create tag - if: steps.ver.outputs.skip != 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "${{ steps.ver.outputs.tag }}" -m "Release ${{ steps.ver.outputs.tag }}" - git push origin "${{ steps.ver.outputs.tag }}" - - # ── Update VERSION file ── - # Uses RELEASE_TOKEN (PAT) to push through branch protection. - # If this step fails, check that the RELEASE_TOKEN secret is set and not expired. - # RELEASE_TOKEN must be a fine-grained PAT with Contents: read+write scope. - - name: Update VERSION file - if: steps.ver.outputs.skip != 'true' - run: | - echo "${{ steps.ver.outputs.version }}" > VERSION - if git diff --quiet VERSION; then - echo "VERSION file already up to date." - exit 0 - fi - - git add VERSION - git commit -m "chore: bump VERSION to ${{ steps.ver.outputs.version }} [skip ci]" - echo "Pushing VERSION bump to ${{ github.ref_name }}..." - echo "If this fails, check that the RELEASE_TOKEN secret is set and has not expired." - git push origin HEAD:${{ github.ref_name }} - # ── Build release archive ── - name: Resolve Xcode project if: steps.ver.outputs.skip != 'true' @@ -237,16 +223,7 @@ jobs: if: steps.ver.outputs.skip != 'true' run: | TAG="${{ steps.ver.outputs.tag }}" - REF_NAME="${{ github.ref_name }}" - - # Find previous tag for the same branch type - if [[ "$REF_NAME" == "develop" ]]; then - PREV_TAG="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*-dev' --sort=-v:refname \ - | grep -vx "$TAG" | head -1 || true)" - else - PREV_TAG="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \ - | grep -v '\-' | grep -vx "$TAG" | head -1 || true)" - fi + PREV_TAG="${{ steps.ver.outputs.prev_tag }}" { echo "# Changelog — $TAG" @@ -298,18 +275,72 @@ jobs: exit $status + # ── Create & push tag ── + - name: Create tag + if: steps.ver.outputs.skip != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${{ steps.ver.outputs.tag }}" -m "Release ${{ steps.ver.outputs.tag }}" + git push origin "${{ steps.ver.outputs.tag }}" + # ── Create GitHub Release ── + # `gh release create` handles immutable-release repositories by creating + # a draft, uploading assets, then publishing. Prefer RELEASE_TOKEN here + # so the published release can fan out to downstream release workflows. - name: Create GitHub Release if: steps.ver.outputs.skip != 'true' - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.ver.outputs.tag }} - name: ${{ steps.ver.outputs.tag }} - body_path: ${{ steps.ai_notes.outputs.has_notes == 'true' && 'build/RELEASE_NOTES.md' || 'build/CHANGELOG.md' }} - prerelease: ${{ steps.ver.outputs.prerelease == 'true' }} - generate_release_notes: false - files: | + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ steps.ver.outputs.tag }} + RELEASE_NAME: ${{ steps.ver.outputs.tag }} + RELEASE_IS_PRERELEASE: ${{ steps.ver.outputs.prerelease }} + NOTES_FILE: ${{ steps.ai_notes.outputs.has_notes == 'true' && 'build/RELEASE_NOTES.md' || 'build/CHANGELOG.md' }} + run: | + set -euo pipefail + + args=( + "$TAG_NAME" + --verify-tag + --title "$RELEASE_NAME" + --notes-file "$NOTES_FILE" + ) + + if [[ "$RELEASE_IS_PRERELEASE" == "true" ]]; then + args+=(--prerelease) + fi + + args+=( build/App.xcarchive.zip build/CHANGELOG.md - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ) + + gh release create "${args[@]}" + + # ── Update VERSION file (best-effort) ── + # Uses RELEASE_TOKEN (PAT) to push through branch protection. + # If the branch moved while this run was in progress, this step is skipped. + - name: Update VERSION file + if: steps.ver.outputs.skip != 'true' + run: | + echo "${{ steps.ver.outputs.version }}" > VERSION + if git diff --quiet VERSION; then + echo "VERSION file already up to date." + exit 0 + fi + + BRANCH="${{ github.ref_name }}" + REMOTE_HEAD="$(git ls-remote --heads origin "$BRANCH" | awk '{print $1}')" + if [[ -n "$REMOTE_HEAD" && "$REMOTE_HEAD" != "$GITHUB_SHA" ]]; then + echo "::warning::${BRANCH} moved to ${REMOTE_HEAD} while this run targets ${GITHUB_SHA}; skipping VERSION bump push." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add VERSION + git commit -m "chore: bump VERSION to ${{ steps.ver.outputs.version }} [skip ci]" + echo "Pushing VERSION bump to ${BRANCH}..." + if ! git push origin "HEAD:${BRANCH}"; then + echo "::warning::Failed to push VERSION bump commit. Release is still published." + fi diff --git a/.github/workflows/testflight-auto.yml b/.github/workflows/testflight-auto.yml new file mode 100644 index 0000000..37cf36d --- /dev/null +++ b/.github/workflows/testflight-auto.yml @@ -0,0 +1,505 @@ +name: TestFlight Auto Upload + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to upload (optional, e.g. v1.2.3)" + required: false + type: string + build_number: + description: "Override CFBundleVersion (optional positive integer)" + required: false + type: string + whats_new: + description: "Override TestFlight What to Test text" + required: false + type: string + dry_run: + description: "Skip upload/API mutations but run signing/build/export" + required: false + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: testflight-upload-${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + cancel-in-progress: false + +jobs: + upload: + name: Upload to TestFlight + runs-on: [self-hosted, macOS, ARM64, bgpro-charstack] + timeout-minutes: 90 + + env: + ASC_APP_ID: ${{ vars.ASC_APP_ID }} + ASC_PRIMARY_LOCALE: ${{ vars.ASC_PRIMARY_LOCALE || 'en-US' }} + ASC_BUNDLE_ID: ${{ vars.ASC_BUNDLE_ID || 'com.bgzxr.Charstack' }} + ASC_TEAM_ID: ${{ vars.ASC_TEAM_ID || '54CDMF6B5L' }} + APPSTORE_API_PRIVATE_KEY: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} + APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + + steps: + - name: Checkout release tag + if: github.event_name == 'release' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Checkout dispatch tag + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag }} + + - name: Checkout default branch + if: github.event_name == 'workflow_dispatch' && inputs.tag == '' + uses: actions/checkout@v6 + + - name: Select Xcode + if: vars.XCODE_APP_PATH != '' + run: | + sudo xcode-select -s "${{ vars.XCODE_APP_PATH }}" + xcodebuild -version + + - name: Resolve Xcode project + id: xcode + run: .github/scripts/resolve-xcode-project.sh + env: + INPUT_WORKSPACE: ${{ vars.XCODE_WORKSPACE }} + INPUT_PROJECT: ${{ vars.XCODE_PROJECT }} + INPUT_SCHEME: ${{ vars.XCODE_SCHEME }} + + - name: Prepare build metadata + id: meta + env: + RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || '' }} + RELEASE_BODY: ${{ github.event_name == 'release' && github.event.release.body || '' }} + RELEASE_PRERELEASE: ${{ github.event_name == 'release' && github.event.release.prerelease || false }} + INPUT_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || '' }} + INPUT_BUILD_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.build_number || '' }} + INPUT_WHATS_NEW: ${{ github.event_name == 'workflow_dispatch' && inputs.whats_new || '' }} + INPUT_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + run: | + set -euo pipefail + mkdir -p build + + tag="${RELEASE_TAG:-${INPUT_TAG:-}}" + if [[ -z "$tag" ]]; then + tag="$(git describe --tags --exact-match 2>/dev/null || true)" + fi + + if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + marketing_version="${BASH_REMATCH[1]}" + else + marketing_version="$(tr -d '[:space:]' < VERSION)" + fi + + if ! [[ "$marketing_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: unable to determine MARKETING_VERSION" >&2 + exit 1 + fi + + build_number="${INPUT_BUILD_NUMBER:-}" + if [[ -z "$build_number" ]]; then + build_number="${GITHUB_RUN_NUMBER}" + fi + if ! [[ "$build_number" =~ ^[0-9]+$ ]] || [[ "$build_number" -le 0 ]]; then + echo "error: build number must be a positive integer (got '$build_number')" >&2 + exit 1 + fi + + prerelease="false" + if [[ "${RELEASE_PRERELEASE}" == "true" ]]; then + prerelease="true" + fi + + dry_run="false" + if [[ "${INPUT_DRY_RUN}" == "true" ]]; then + dry_run="true" + fi + + if [[ -n "${INPUT_WHATS_NEW}" ]]; then + what_to_test="${INPUT_WHATS_NEW}" + elif [[ -n "${RELEASE_BODY}" ]]; then + what_to_test="${RELEASE_BODY}" + else + what_to_test="$(git log --no-merges --pretty=format:'- %s' -n 20)" + fi + + python3 - "$what_to_test" -c 'import sys; text=sys.argv[1].strip() or "Internal beta build for validation."; print(text[:4000])' > build/what_to_test.txt + + { + echo "tag=$tag" + echo "marketing_version=$marketing_version" + echo "build_number=$build_number" + echo "prerelease=$prerelease" + echo "dry_run=$dry_run" + echo "what_to_test_file=build/what_to_test.txt" + } >> "$GITHUB_OUTPUT" + + - name: Validate required configuration + run: | + set -euo pipefail + required=( + ASC_APP_ID + APPSTORE_API_PRIVATE_KEY + APPSTORE_API_KEY_ID + APPSTORE_API_ISSUER_ID + APPLE_DIST_CERT_P12_BASE64 + APPLE_DIST_CERT_PASSWORD + APPLE_KEYCHAIN_PASSWORD + ) + for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "error: missing required secret/var: $name" >&2 + exit 1 + fi + done + env: + APPLE_DIST_CERT_P12_BASE64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }} + APPLE_DIST_CERT_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + + - name: Compute temporary keychain name + id: keychain + run: | + set -euo pipefail + echo "name=signing_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + + - name: Import code-signing certificate + uses: apple-actions/import-codesign-certs@v6 + with: + p12-file-base64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }} + p12-password: ${{ secrets.APPLE_DIST_CERT_PASSWORD }} + keychain-password: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + keychain: ${{ steps.keychain.outputs.name }} + create-keychain: true + + - name: Clean local provisioning profile cache + run: | + set -euo pipefail + profile_dir="$HOME/Library/MobileDevice/Provisioning Profiles" + mkdir -p "$profile_dir" + rm -f "$profile_dir"/*.mobileprovision + + - name: Download provisioning profiles + uses: apple-actions/download-provisioning-profiles@v5 + with: + bundle-id: ${{ env.ASC_BUNDLE_ID }} + profile-type: IOS_APP_STORE + issuer-id: ${{ secrets.APPSTORE_API_ISSUER_ID }} + api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} + api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} + + - name: Resolve matching profile for imported certificate + id: signing + env: + P12_BASE64: ${{ secrets.APPLE_DIST_CERT_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }} + TEAM_ID: ${{ env.ASC_TEAM_ID }} + BUNDLE_ID: ${{ env.ASC_BUNDLE_ID }} + run: | + set -euo pipefail + mkdir -p build + profile_dir="$HOME/Library/MobileDevice/Provisioning Profiles" + + printf '%s' "$P12_BASE64" | { base64 --decode 2>/dev/null || base64 -D; } > build/dist-cert.p12 + openssl pkcs12 -legacy -in build/dist-cert.p12 -clcerts -nokeys -passin "pass:${P12_PASSWORD}" > build/dist-cert.pem + p12_serial="$(openssl x509 -in build/dist-cert.pem -noout -serial | cut -d= -f2)" + p12_sha1="$(openssl x509 -in build/dist-cert.pem -noout -fingerprint | cut -d= -f2 | tr -d ':')" + + echo "Imported distribution cert serial: ${p12_serial}" + echo "Imported distribution cert sha1: ${p12_sha1}" + + selection="$(python3 - "$profile_dir" "$TEAM_ID" "$BUNDLE_ID" "$p12_serial" <<'PY' + import datetime as dt + import glob + import os + import plistlib + import subprocess + import sys + import tempfile + + profile_dir, team_id, bundle_id, wanted_serial = sys.argv[1:5] + target_app_id = f"{team_id}.{bundle_id}" + + profiles = sorted(glob.glob(os.path.join(profile_dir, "*.mobileprovision"))) + if not profiles: + print("No provisioning profiles were installed after download step.", file=sys.stderr) + sys.exit(1) + + matches = [] + for path in profiles: + try: + plist_bytes = subprocess.check_output( + ["openssl", "smime", "-inform", "der", "-verify", "-noverify", "-in", path, "-outform", "DER"], + stderr=subprocess.DEVNULL, + ) + profile = plistlib.loads(plist_bytes) + except Exception: + print(f"Skipping unreadable profile: {path}", file=sys.stderr) + continue + + name = profile.get("Name", "") + uuid = profile.get("UUID", "") + team = (profile.get("TeamIdentifier") or [""])[0] + app_id = ((profile.get("Entitlements") or {}).get("application-identifier") or "") + expires = profile.get("ExpirationDate") + + cert_serials = [] + for cert_der in profile.get("DeveloperCertificates") or []: + with tempfile.NamedTemporaryFile(suffix=".cer") as f: + f.write(cert_der) + f.flush() + out = subprocess.check_output( + ["openssl", "x509", "-inform", "DER", "-in", f.name, "-noout", "-serial", "-fingerprint", "-sha256"], + text=True, + ) + serial = "" + fingerprint = "" + for line in out.splitlines(): + if line.startswith("serial="): + serial = line.split("=", 1)[1].strip() + if "Fingerprint=" in line: + fingerprint = line.split("=", 1)[1].strip() + if serial: + cert_serials.append(serial) + print(f"Profile '{name}' cert serial={serial} sha256={fingerprint}", file=sys.stderr) + + print(f"Profile '{name}' uuid={uuid} team={team} appId={app_id}", file=sys.stderr) + if team == team_id and app_id == target_app_id and wanted_serial in cert_serials: + expiry_key = expires or dt.datetime.min + matches.append((expiry_key, uuid, name)) + + if not matches: + print( + f"No downloaded profile includes distribution cert serial={wanted_serial} for {target_app_id}.", + file=sys.stderr, + ) + sys.exit(1) + + matches.sort(key=lambda t: t[0], reverse=True) + _expiry, uuid, name = matches[0] + print(f"{uuid}\t{name}") + PY + )" + + IFS=$'\t' read -r profile_uuid profile_name <<< "$selection" + + { + echo "profile_uuid=$profile_uuid" + echo "cert_sha1=$p12_sha1" + echo "profile_name<> "$GITHUB_OUTPUT" + + - name: Build signed archive + run: | + set -euo pipefail + mkdir -p build + xcodebuild \ + "${{ steps.xcode.outputs.flag }}" "${{ steps.xcode.outputs.path }}" \ + -scheme "${{ steps.xcode.outputs.scheme }}" \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath build/Charstack.xcarchive \ + MARKETING_VERSION="${{ steps.meta.outputs.marketing_version }}" \ + CURRENT_PROJECT_VERSION="${{ steps.meta.outputs.build_number }}" \ + clean archive + + - name: Export IPA + run: | + set -euo pipefail + mkdir -p build + ASC_TEAM_ID="$ASC_TEAM_ID" ASC_BUNDLE_ID="$ASC_BUNDLE_ID" PROFILE_NAME="${{ steps.signing.outputs.profile_name }}" CERT_SHA1="${{ steps.signing.outputs.cert_sha1 }}" python3 - <<'PY' > build/ExportOptions.plist + import os + import plistlib + import sys + + team = os.environ["ASC_TEAM_ID"].strip() + bundle = os.environ["ASC_BUNDLE_ID"].strip() + profile_name = os.environ["PROFILE_NAME"].strip() + cert_sha1 = os.environ["CERT_SHA1"].strip() + + if not team: + raise SystemExit("error: ASC_TEAM_ID is required for export options generation") + if not bundle: + raise SystemExit("error: ASC_BUNDLE_ID is required for export options generation") + if not profile_name: + raise SystemExit("error: matching provisioning profile name is required for export options generation") + if not cert_sha1: + raise SystemExit("error: signing certificate sha1 is required for export options generation") + + payload = { + "method": "app-store-connect", + "signingStyle": "manual", + "teamID": team, + "signingCertificate": cert_sha1, + "provisioningProfiles": {bundle: profile_name}, + "stripSwiftSymbols": True, + "uploadSymbols": True, + } + plistlib.dump(payload, sys.stdout.buffer, fmt=plistlib.FMT_XML, sort_keys=False) + PY + xcodebuild -exportArchive \ + -archivePath build/Charstack.xcarchive \ + -exportPath build \ + -exportOptionsPlist build/ExportOptions.plist + + ipa_path="$(find build -maxdepth 1 -name '*.ipa' | head -n 1 || true)" + if [[ -z "$ipa_path" ]]; then + echo "error: export did not produce an .ipa" >&2 + exit 1 + fi + echo "ipa_path=$ipa_path" >> "$GITHUB_OUTPUT" + id: export + + - name: Validate App Store Connect API auth + run: | + set -euo pipefail + token="$(scripts/appstore/jwt.sh)" + if [[ -z "$token" ]]; then + echo "error: empty JWT" >&2 + exit 1 + fi + ASC_JWT_TOKEN="$token" scripts/appstore/asc_api.sh GET "/v1/apps?limit=1" >/dev/null + echo "JWT generation and ASC API auth succeeded." + + - name: Install App Store Connect private key for Transporter + if: steps.meta.outputs.dry_run != 'true' + run: | + set -euo pipefail + key_dir="$HOME/.appstoreconnect/private_keys" + key_file="$key_dir/AuthKey_${APPSTORE_API_KEY_ID}.p8" + mkdir -p "$key_dir" + chmod 700 "$HOME/.appstoreconnect" "$key_dir" + printf '%s\n' "$APPSTORE_API_PRIVATE_KEY" > "$key_file" + chmod 600 "$key_file" + + - name: Upload IPA via iTMSTransporter + if: steps.meta.outputs.dry_run != 'true' + run: | + set -euo pipefail + cmd=( + xcrun iTMSTransporter + -m upload + -assetFile "${{ steps.export.outputs.ipa_path }}" + -apiKey "$APPSTORE_API_KEY_ID" + -apiIssuer "$APPSTORE_API_ISSUER_ID" + -v informational + ) + if [[ -n "${APPSTORE_PROVIDER_SHORT_NAME:-}" ]]; then + cmd+=( -itc_provider "$APPSTORE_PROVIDER_SHORT_NAME" ) + fi + "${cmd[@]}" + + - name: Poll build processing state + if: steps.meta.outputs.dry_run != 'true' + id: poll + run: | + set -euo pipefail + + max_attempts=80 + sleep_seconds=30 + build_id="" + build_state="" + + for ((attempt=1; attempt<=max_attempts; attempt+=1)); do + response="$(scripts/appstore/asc_api.sh GET "/v1/builds?filter[app]=${ASC_APP_ID}&include=preReleaseVersion&sort=-uploadedDate&limit=50")" + + parsed="$(printf '%s' "$response" | python3 scripts/appstore/select_build.py --marketing-version "${{ steps.meta.outputs.marketing_version }}" --build-number "${{ steps.meta.outputs.build_number }}")" + + build_id="${parsed%%|*}" + rest="${parsed#*|}" + build_state="${rest%%|*}" + + if [[ -z "$build_id" ]]; then + echo "Build not visible yet (attempt ${attempt}/${max_attempts})." + sleep "$sleep_seconds" + continue + fi + + echo "Build ${build_id} state: ${build_state}" + + if [[ "$build_state" == "VALID" ]]; then + echo "build_id=$build_id" >> "$GITHUB_OUTPUT" + echo "build_state=$build_state" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "$build_state" == "FAILED" || "$build_state" == "INVALID" ]]; then + echo "::error::Build processing failed for app=${ASC_APP_ID} version=${{ steps.meta.outputs.marketing_version }} build=${{ steps.meta.outputs.build_number }} state=${build_state}" + exit 1 + fi + + sleep "$sleep_seconds" + done + + echo "::error::Timed out waiting for TestFlight processing for app=${ASC_APP_ID} version=${{ steps.meta.outputs.marketing_version }} build=${{ steps.meta.outputs.build_number }}" + exit 1 + + - name: Upsert TestFlight What to Test + if: steps.meta.outputs.dry_run != 'true' + env: + BUILD_ID: ${{ steps.poll.outputs.build_id }} + LOCALE: ${{ env.ASC_PRIMARY_LOCALE }} + WHATS_NEW_FILE: ${{ steps.meta.outputs.what_to_test_file }} + run: | + set -euo pipefail + + lookup="$(scripts/appstore/asc_api.sh GET "/v1/betaBuildLocalizations?filter[build]=${BUILD_ID}&filter[locale]=${LOCALE}&limit=1")" + localization_id="$(printf '%s' "$lookup" | python3 -c 'import json,sys; items=(json.load(sys.stdin).get("data") or []); print(items[0].get("id", "") if items else "")')" + + python3 -c 'import json,os,pathlib; text=pathlib.Path(os.environ["WHATS_NEW_FILE"]).read_text(encoding="utf-8", errors="replace").strip() or "Internal beta build for validation."; text=text[:4000]; print(json.dumps({"data":{"type":"betaBuildLocalizations","attributes":{"locale":os.environ["LOCALE"],"whatsNew":text},"relationships":{"build":{"data":{"type":"builds","id":os.environ["BUILD_ID"]}}}}}))' > build/beta-localization.json + + if [[ -n "$localization_id" ]]; then + scripts/appstore/asc_api.sh PATCH "/v1/betaBuildLocalizations/${localization_id}" build/beta-localization.json >/dev/null + else + scripts/appstore/asc_api.sh POST "/v1/betaBuildLocalizations" build/beta-localization.json >/dev/null + fi + + - name: Write TestFlight context artifact + if: always() + run: | + set -euo pipefail + mkdir -p build + python3 -c 'import json; print(json.dumps({"tag":"${{ steps.meta.outputs.tag }}","marketing_version":"${{ steps.meta.outputs.marketing_version }}","build_number":"${{ steps.meta.outputs.build_number }}","build_id":"${{ steps.poll.outputs.build_id || '' }}","prerelease":"${{ steps.meta.outputs.prerelease }}","dry_run":"${{ steps.meta.outputs.dry_run }}"}))' > build/testflight-context.json + + - name: Upload TestFlight context artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: testflight-context + path: build/testflight-context.json + + - name: Trigger App Store submission workflow + if: success() && steps.meta.outputs.dry_run != 'true' && github.event_name == 'release' && github.event.release.prerelease == false + uses: actions/github-script@v8 + with: + script: | + await github.request('POST /repos/{owner}/{repo}/dispatches', { + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'appstore-submit-ready', + client_payload: { + tag: context.payload.release.tag_name, + version: '${{ steps.meta.outputs.marketing_version }}', + build_number: '${{ steps.meta.outputs.build_number }}', + build_id: '${{ steps.poll.outputs.build_id }}', + prerelease: false + } + }) + + - name: Cleanup private key material + if: always() + run: | + set -euo pipefail + rm -f "$HOME/.appstoreconnect/private_keys/AuthKey_${APPSTORE_API_KEY_ID}.p8" diff --git a/.gitignore b/.gitignore index 0ad062a..0394504 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,6 @@ DerivedData/ .build/ Packages/ Package.pins -Package.resolved -*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ .swiftpm/ # CocoaPods (not used, but just in case) @@ -54,6 +52,8 @@ fastlane/test_output *~ .idea/ .vscode/ +__pycache__/ +*.pyc # Playgrounds timeline.xctimeline @@ -68,6 +68,10 @@ playground.xcworkspace # Custom docs/scratch/ .gemini/ +.logs/ +secrets/ +*.p8 +AuthKey_*.p8 # Claude local files .claude/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 0909490..cde3f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **CI/CD & Release Process** - - Rewrite `release.yml` — branch-aware versioning (`-dev` suffix for develop, clean tags for master), GitHub Environments (`development`/`production`), auto-commit VERSION file, prerelease flag for develop releases + - Rewrite `release.yml` — branch-aware versioning, GitHub Environments (`development`/`production`), auto-commit VERSION file, prerelease flag for develop releases - Move `fmt-lint.sh` from `.github/scripts/` to `scripts/` (local dev tools separated from CI scripts) - Add `.github/pull_request_template.md` with checklist - Add `CODEOWNERS` file - Update `ROADMAP.md` tag format references (`vX.Y.Z` instead of `release/vX.Y.Z`) + - Commit `Package.resolved` (was gitignored; required by Xcode Cloud) + - Rename app display name from "Charstack" to "Charflow" (`CFBundleDisplayName`) + - Update README and docs to use "Charflow" as the product name ### Added - **Backlog & Day Rollover (Phase 1 Week 3)** diff --git a/Charstack.xcodeproj/project.pbxproj b/Charstack.xcodeproj/project.pbxproj index 15cafdd..141c308 100644 --- a/Charstack.xcodeproj/project.pbxproj +++ b/Charstack.xcodeproj/project.pbxproj @@ -172,7 +172,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2620; - LastUpgradeCheck = 2620; + LastUpgradeCheck = 2630; TargetAttributes = { 3FAC33342F1D1F7F00E3D3F4 = { CreatedOnToolsVersion = 26.2; @@ -312,6 +312,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; @@ -370,6 +371,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; @@ -387,6 +389,9 @@ DEVELOPMENT_TEAM = 54CDMF6B5L; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Charflow; + INFOPLIST_KEY_CFBundleIconName = AppIcon; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -397,7 +402,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.10; PRODUCT_BUNDLE_IDENTIFIER = com.bgzxr.Charstack; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -421,6 +426,9 @@ DEVELOPMENT_TEAM = 54CDMF6B5L; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Charflow; + INFOPLIST_KEY_CFBundleIconName = AppIcon; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -431,7 +439,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.10; PRODUCT_BUNDLE_IDENTIFIER = com.bgzxr.Charstack; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -453,7 +461,7 @@ DEVELOPMENT_TEAM = 54CDMF6B5L; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.10; PRODUCT_BUNDLE_IDENTIFIER = com.bgzxr.CharstackTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -474,7 +482,7 @@ DEVELOPMENT_TEAM = 54CDMF6B5L; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.1.10; PRODUCT_BUNDLE_IDENTIFIER = com.bgzxr.CharstackTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; diff --git a/Charstack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Charstack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..a4f8826 --- /dev/null +++ b/Charstack.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "c3373633343d643cf4275c42d63c44d7be89c54076117a694350d515b60c2b83", + "pins" : [ + { + "identity" : "collectionconcurrencykit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", + "state" : { + "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", + "version" : "0.2.0" + } + }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f", + "version" : "1.9.0" + } + }, + { + "identity" : "sourcekitten", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/SourceKitten.git", + "state" : { + "revision" : "731ffe6a35344a19bab00cdca1c952d5b4fee4d8", + "version" : "0.37.2" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-filename-matcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ileitch/swift-filename-matcher", + "state" : { + "revision" : "eef5ac0b6b3cdc64b3039b037bed2def8a1edaeb", + "version" : "2.0.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "65b02a90ad2cc213e09309faeb7f6909e0a8577a", + "version" : "604.0.0-prerelease-2026-01-20" + } + }, + { + "identity" : "swiftlint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/realm/SwiftLint", + "state" : { + "revision" : "88952528a590ed366c6f76f6bfb980b5ebdcefc1", + "version" : "0.63.2" + } + }, + { + "identity" : "swiftytexttable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", + "state" : { + "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", + "version" : "0.9.0" + } + }, + { + "identity" : "swxmlhash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/drmohundro/SWXMLHash.git", + "state" : { + "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", + "version" : "7.0.2" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", + "version" : "6.2.1" + } + } + ], + "version" : 3 +} diff --git a/Charstack.xcodeproj/xcshareddata/xcschemes/Charstack.xcscheme b/Charstack.xcodeproj/xcshareddata/xcschemes/Charstack.xcscheme index 5a245b9..65f405b 100644 --- a/Charstack.xcodeproj/xcshareddata/xcschemes/Charstack.xcscheme +++ b/Charstack.xcodeproj/xcshareddata/xcschemes/Charstack.xcscheme @@ -1,6 +1,6 @@ "Char" (چهار) = Persian for **4**. Four regions, one focused day. +> "Char" (چهار) = Persian for **4**. Four regions, one focused flow. ## Tech @@ -31,6 +31,22 @@ open charstack/Charstack.xcodeproj Requires Xcode 16+ and macOS 14+. +## Versioning + +- `VERSION` is the source of truth for semantic app version (`MARKETING_VERSION` / `CFBundleShortVersionString`). +- `CURRENT_PROJECT_VERSION` (`CFBundleVersion`) should be a monotonically increasing build number. +- Sync project settings from `VERSION`: + +```bash +./scripts/sync-xcode-version.sh +``` + +- Sync version and set a local build number: + +```bash +./scripts/sync-xcode-version.sh --build-number 42 +``` + ## Status MVP in progress. Not yet on the App Store. @@ -41,6 +57,7 @@ MVP in progress. Not yet on the App Store. - [Requirements](docs/REQUIREMENTS.md) — functional specs - [Architecture](docs/ARCHITECTURE.md) — technical decisions - [Roadmap](docs/ROADMAP.md) — what's next +- [App Store Connect Pipeline](docs/appstore-connect/pipeline.md) — TestFlight and App Review automation - [Changelog](CHANGELOG.md) — version history ## License diff --git a/VERSION b/VERSION index 6e8bf73..71d6a66 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.14 diff --git a/appstore-assets/README.md b/appstore-assets/README.md new file mode 100644 index 0000000..fa21a2d --- /dev/null +++ b/appstore-assets/README.md @@ -0,0 +1,59 @@ +# App Store Asset Contracts + +This folder holds source-controlled assets and review metadata used by automated App Store Connect workflows. + +## Structure + +- `screenshots///...` +- `previews///...` +- `review/review-notes.md` +- `review/submission-checklist.json` + +Tracked placeholders: + +- `screenshots/.gitkeep` +- `previews/.gitkeep` + +The `.gitkeep` files exist only to keep empty directories in git until you add real assets. + +## Display Family Examples + +Use App Store Connect display family identifiers: + +- `APP_IPHONE_67` +- `APP_IPHONE_65` +- `APP_IPHONE_61` +- `APP_IPHONE_55` +- `APP_IPAD_PRO_3GEN_129` + +## Current Automation Scope + +- Screenshot and preview upload support is gated behind `sync_assets=true` input in `appstore-submit` workflow dispatch. +- Review assets under `review/` are validated by pre-submit checks. +- In-App Event and In-App Purchase assets are intentionally out of scope for now. + +## Generation Model + +- This repo does not auto-generate screenshots or previews. +- You produce media manually, then place them in the expected folders. +- The workflow uploads what is present; it does not create content. + +## Example Layout + +```text +appstore-assets/ + screenshots/ + .gitkeep + en-US/ + APP_IPHONE_67/ + 01-home.png + 02-backlog.png + previews/ + .gitkeep + en-US/ + APP_IPHONE_67/ + preview.mov + review/ + review-notes.md + submission-checklist.json +``` diff --git a/appstore-assets/previews/.gitkeep b/appstore-assets/previews/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/appstore-assets/review/review-notes.md b/appstore-assets/review/review-notes.md new file mode 100644 index 0000000..d3e13c3 --- /dev/null +++ b/appstore-assets/review/review-notes.md @@ -0,0 +1,16 @@ +# App Review Notes Template + +## Test Instructions + +- Launch app and create tasks in Morning/Afternoon/Evening regions. +- Mark tasks complete and verify rollover behavior to Backlog. + +## Feature Flags / Environment + +- No server backend required for MVP. +- No user login required in current release scope. + +## Contact + +- Support URL: https://example.com/support +- Privacy Policy: https://example.com/privacy diff --git a/appstore-assets/review/submission-checklist.json b/appstore-assets/review/submission-checklist.json new file mode 100644 index 0000000..9441a2e --- /dev/null +++ b/appstore-assets/review/submission-checklist.json @@ -0,0 +1,10 @@ +{ + "privacyPolicyUrl": "https://example.com/privacy", + "supportUrl": "https://example.com/support", + "description": "A minimal daily task manager for iOS.", + "subtitle": "Daily focus with 1-3-5", + "keywords": "tasks,productivity,daily,focus", + "ageRatingConfirmed": true, + "exportComplianceConfirmed": true, + "reviewNotesPath": "appstore-assets/review/review-notes.md" +} diff --git a/appstore-assets/screenshots/.gitkeep b/appstore-assets/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh new file mode 100755 index 0000000..1f79d5a --- /dev/null +++ b/ci_scripts/ci_post_clone.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +# Enable unattended Swift package plugin/macro execution in Xcode Cloud. +# SwiftLint documents the misspelled key below; set both spellings for compatibility. +defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES +defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidation -bool YES +defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + +# Keep Xcode project versions aligned with repo VERSION metadata. +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SYNC_SCRIPT="$REPO_ROOT/scripts/sync-xcode-version.sh" + +if [ ! -x "$SYNC_SCRIPT" ]; then + echo "error: missing executable sync script at $SYNC_SCRIPT" >&2 + exit 1 +fi + +if [ -n "${CI_BUILD_NUMBER:-}" ]; then + "$SYNC_SCRIPT" --build-number "$CI_BUILD_NUMBER" +else + "$SYNC_SCRIPT" +fi diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0735a11..b5d830f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,10 +1,10 @@ -# Charstack Architecture Document +# Charflow Architecture Document ## Overview -Charstack is a minimal daily task manager for iOS designed with a focus on simplicity, productivity, and intentional work. Users organize tasks into 4 daily regions (Morning, Afternoon, Evening, Backlog) and apply the 1-3-5 rule per region: 1 must task, 3 complementary tasks, and 5 miscellaneous tasks. +Charflow is a minimal daily task manager for iOS designed with a focus on simplicity, productivity, and intentional work. Users organize tasks into 4 daily regions (Morning, Afternoon, Evening, Backlog) and apply the 1-3-5 rule per region: 1 must task, 3 complementary tasks, and 5 miscellaneous tasks. -This document outlines the complete technical architecture, design patterns, and implementation strategy for Charstack. +This document outlines the complete technical architecture, design patterns, and implementation strategy for Charflow. --- diff --git a/docs/PROJECT_BRIEF.md b/docs/PROJECT_BRIEF.md index 4433682..16cd332 100644 --- a/docs/PROJECT_BRIEF.md +++ b/docs/PROJECT_BRIEF.md @@ -1,14 +1,14 @@ -# Charstack — Project Brief (iOS-first) +# Charflow — Project Brief (iOS-first) ## One-liner -**Charstack** is a clean, minimal daily task manager built around **four day regions**—**Morning**, **Afternoon**, **Evening**, and **Backlog**—where each active region uses a **1–3–5** structure to keep focus and prevent list bloat. +**Charflow** is a clean, minimal daily task manager built around **four day regions**—**Morning**, **Afternoon**, **Evening**, and **Backlog**—where each active region uses a **1–3–5** structure to keep focus and prevent list bloat. -> Char = Persian root for **4** → four regions that define the day. +> Char = Persian root for **4** → four regions, one focused flow. --- -## The problem Charstack solves -Most task apps become endless lists. People over-plan, lose focus, and tasks silently rot. Charstack replaces the infinite backlog mindset with a *daily, time-of-day* plan that stays small, intentional, and doable—while still preserving anything unfinished. +## The problem Charflow solves +Most task apps become endless lists. People over-plan, lose focus, and tasks silently rot. Charflow replaces the infinite backlog mindset with a *daily, time-of-day* plan that stays small, intentional, and doable—while still preserving anything unfinished. --- diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index ca6b7d4..a10a2d4 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -1,7 +1,7 @@ -# Charstack Requirements Document +# Charflow Requirements Document ## Document Information -- **Product**: Charstack iOS App (MVP) +- **Product**: Charflow iOS App (MVP) - **Version**: 1.0 - **Last Updated**: February 8, 2026 - **Status**: Ready for Development @@ -12,7 +12,7 @@ ## 1. Executive Summary -**Charstack** is a minimal daily task manager designed to combat task list bloat by organizing work into four time-of-day regions—Morning, Afternoon, Evening, and Backlog—using a 1–3–5 constraint structure. The app emphasizes focus, intentionality, and consistent task completion. +**Charflow** is a minimal daily task manager designed to combat task list bloat by organizing work into four time-of-day regions—Morning, Afternoon, Evening, and Backlog—using a 1–3–5 constraint structure. The app emphasizes focus, intentionality, and consistent task completion. ### Core Value Proposition - Replace infinite backlogs with time-of-day planning diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9889335..f5141ac 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,4 +1,4 @@ -# Charstack iOS Development Roadmap +# Charflow iOS Development Roadmap A minimal daily task manager built with SwiftUI and SwiftData on iOS 26+. diff --git a/docs/appstore-connect/api-keys.md b/docs/appstore-connect/api-keys.md new file mode 100644 index 0000000..0584852 --- /dev/null +++ b/docs/appstore-connect/api-keys.md @@ -0,0 +1,87 @@ +# App Store Connect API Keys + +This project uses App Store Connect API keys for CI/CD actions (TestFlight and App Store submission). + +## Key Lifecycle + +1. Create a key in App Store Connect: `Users and Access` -> `Integrations` -> `App Store Connect API` -> `Generate API Key`. +2. Save the `.p8` file immediately. Apple only allows one download. +3. Store key material in GitHub Actions secrets. +4. Rotate keys periodically or immediately after any suspected exposure. +5. Revoke old keys in App Store Connect once replacement is validated. + +## Required GitHub Secrets and Variables by Stage + +Legend: + +- Stage 1 = `testflight-auto.yml` (internal TestFlight automation) +- Stage 2 = `appstore-submit.yml` (App Review submission with approval gate) + +### Secrets + +| Name | Stage 1 | Stage 2 | Notes | +|---|---|---|---| +| `APPSTORE_API_PRIVATE_KEY` | Required | Required | Full `.p8` contents. | +| `APPSTORE_API_KEY_ID` | Required | Required | ASC key ID. | +| `APPSTORE_API_ISSUER_ID` | Required | Required | ASC issuer ID. | +| `APPLE_DIST_CERT_P12_BASE64` | Required | Not used | Needed only for signed IPA build/upload in Stage 1. | +| `APPLE_DIST_CERT_PASSWORD` | Required | Not used | Needed only for Stage 1 certificate import. | +| `APPLE_KEYCHAIN_PASSWORD` | Required | Not used | Needed only for Stage 1 certificate import. | +| `APPSTORE_EXPORT_OPTIONS_BASE64` | Optional | Not used | Legacy override only; workflow now auto-generates ExportOptions with `app-store-connect`. | + +### Variables + +| Name | Stage 1 | Stage 2 | Notes | +|---|---|---|---| +| `ASC_APP_ID` | Required | Required | App Store Connect app identifier. | +| `ASC_PRIMARY_LOCALE` | Optional | Optional | Defaults to `en-US`. | +| `ASC_BUNDLE_ID` | Optional | Not used | Defaults to `com.bgzxr.Charstack` in workflow. | +| `ASC_TEAM_ID` | Optional | Not used | Defaults to `54CDMF6B5L`; used for auto-generated ExportOptions. | +| `ASC_SUPPORT_URL` | Not used | Optional | If set, must match checklist URL during submission preflight. | +| `ASC_PRIVACY_POLICY_URL` | Not used | Optional | If set, must match checklist URL during submission preflight. | +| `APPSTORE_PROVIDER_SHORT_NAME` | Optional | Not used | Optional Transporter provider hint for Stage 1. | + +Important: + +- For internal TestFlight (Stage 1), `ASC_SUPPORT_URL` and `ASC_PRIVACY_POLICY_URL` are not required. +- For App Review submission (Stage 2), support/privacy URLs are required in `appstore-assets/review/submission-checklist.json` even if the two vars are unset. + +## Uploading `.p8` to GitHub + +### GitHub UI + +1. Open repository -> `Settings` -> `Secrets and variables` -> `Actions`. +2. Choose `New repository secret`. +3. Name: `APPSTORE_API_PRIVATE_KEY`. +4. Paste the full `.p8` content, including begin/end lines. +5. Save. + +### GitHub CLI + +```bash +gh secret set APPSTORE_API_PRIVATE_KEY < /absolute/path/to/AuthKey_XXXXXXX.p8 +``` + +Add IDs as separate secrets: + +```bash +gh secret set APPSTORE_API_KEY_ID --body "" +gh secret set APPSTORE_API_ISSUER_ID --body "" +``` + +## Revoking Keys + +Use App Store Connect `Users and Access` -> `Integrations` -> `App Store Connect API` to revoke compromised or obsolete keys. + +Recommended sequence: + +1. Create new key. +2. Update GitHub secrets. +3. Validate a TestFlight upload. +4. Revoke old key. + +## Security Rules + +- Never commit `.p8` files. +- Never print key contents or JWTs in CI logs. +- If key material leaks, revoke and replace immediately. diff --git a/docs/appstore-connect/jwt.md b/docs/appstore-connect/jwt.md new file mode 100644 index 0000000..da5ed2f --- /dev/null +++ b/docs/appstore-connect/jwt.md @@ -0,0 +1,56 @@ +# App Store Connect JWT + +`scripts/appstore/jwt.sh` generates a short-lived JWT for App Store Connect API calls. + +## Header + +```json +{ + "alg": "ES256", + "kid": "", + "typ": "JWT" +} +``` + +## Claims + +```json +{ + "iss": "", + "iat": , + "exp": , + "aud": "appstoreconnect-v1" +} +``` + +## Constraints + +- Algorithm must be `ES256`. +- `aud` must be `appstoreconnect-v1`. +- Token lifetime must be short; this project defaults to 10 minutes (`JWT_TTL_SECONDS=600`). +- Do not cache tokens across long-running jobs. + +## Environment Variables + +- `APPSTORE_API_PRIVATE_KEY`: private key content. +- `APPSTORE_API_PRIVATE_KEY_FILE`: optional path alternative to private key content. +- `APPSTORE_API_KEY_ID`: key ID. +- `APPSTORE_API_ISSUER_ID`: issuer ID. +- `JWT_TTL_SECONDS`: optional TTL override, max 1200. + +## Rotation Guidance + +- Rotate keys on a regular cadence (for example every 90 days). +- Rotate immediately on suspected exposure. +- Validate with a manual `workflow_dispatch` run before deleting previous key. + +## Clock Skew Guidance + +- CI runner time must be accurate (NTP-synced). +- If you get authentication failures around `iat`/`exp`, check system clock drift first. + +## Troubleshooting + +- `401 NOT_AUTHORIZED`: check key ID, issuer ID, and key pair. +- `INVALID_AUTHENTICATION_CREDENTIALS`: verify `aud=appstoreconnect-v1` and token expiry. +- `INVALID_SIGNATURE`: verify exact `.p8` content and no whitespace corruption in secret. diff --git a/docs/appstore-connect/pipeline.md b/docs/appstore-connect/pipeline.md new file mode 100644 index 0000000..a08a1d6 --- /dev/null +++ b/docs/appstore-connect/pipeline.md @@ -0,0 +1,94 @@ +# App Store Connect Release Pipeline + +This repository now has a two-stage pipeline: + +1. `testflight-auto.yml` automatically uploads signed iOS builds to TestFlight after each published GitHub release. +2. `appstore-submit.yml` runs preflight checks and submits production versions to App Review after a manual environment approval gate. + +## Stage 1: TestFlight Auto Upload + +Workflow: `.github/workflows/testflight-auto.yml` + +Triggers: + +- `release.published` +- `workflow_dispatch` + +Behavior: + +- Checks out release tag. +- Builds signed archive for `generic/platform=iOS`. +- Exports `.ipa` using auto-generated `ExportOptions.plist` (`method=app-store-connect`). +- Uploads with `xcrun iTMSTransporter` using API key auth. +- Polls App Store Connect build processing state until `VALID`. +- Upserts TestFlight `What to Test` from release body or dispatch input. +- Dispatches `appstore-submit-ready` event for non-prerelease releases. + +Dry-run mode: + +- `workflow_dispatch` supports `dry_run=true` to validate signing, export, and JWT generation without upload/API mutations. + +Stage 1 requirements (internal testing): + +- Does not require `ASC_SUPPORT_URL` or `ASC_PRIVACY_POLICY_URL`. +- Does not require App Review checklist fields. + +## Stage 2: App Store Submit (Manual Gate) + +Workflow: `.github/workflows/appstore-submit.yml` + +Triggers: + +- `repository_dispatch` type `appstore-submit-ready` (from stage 1) +- `workflow_dispatch` + +Behavior: + +- Validates `appstore-assets/review/submission-checklist.json` and `review-notes.md`. +- Ensures target `appStoreVersion` exists. +- Resolves a `VALID` build for the selected version/build number. +- Optional asset sync (`sync_assets=true`) for screenshots/previews. +- Requires environment approval on `appstore-production` before final submission. +- Attaches build to app store version, sets release type to `MANUAL`, and submits for review. + +Stage 2 requirements (submission): + +- Requires valid `appstore-assets/review/submission-checklist.json`. +- Requires non-empty `supportUrl` and `privacyPolicyUrl` in that checklist. +- `ASC_SUPPORT_URL` and `ASC_PRIVACY_POLICY_URL` vars are optional, but if set they must match checklist values. + +## Required App Review Checklist Artifact + +`appstore-assets/review/submission-checklist.json` must include: + +- `privacyPolicyUrl` +- `supportUrl` +- `description` +- `subtitle` +- `keywords` +- `ageRatingConfirmed: true` +- `exportComplianceConfirmed: true` +- `reviewNotesPath` + +## Asset Automation Scope + +| Asset type | Can automate | Should automate now | Status | +|---|---|---|---| +| App Store screenshots | Yes | Yes (deterministic folder naming) | Implemented (opt-in) | +| App previews | Yes | Partial | Implemented (opt-in) | +| In-App Event media | Yes | Not now | Deferred | +| In-App Purchase media | Yes | Not now | Deferred | +| Review attachments | Yes | Partial/manual | Manual template only | + +## Asset Generation vs Upload + +- Screenshots and previews are not auto-generated by this pipeline. +- You create screenshots/previews manually (or with your own capture tooling). +- The pipeline only uploads files that already exist under `appstore-assets/` when `sync_assets=true` in `appstore-submit.yml`. + +## Folder Contracts + +- `appstore-assets/screenshots///...` +- `appstore-assets/previews///...` +- `appstore-assets/review/review-notes.md` +- `appstore-assets/review/submission-checklist.json` diff --git a/scripts/appstore/asc_api.sh b/scripts/appstore/asc_api.sh new file mode 100755 index 0000000..5c94e41 --- /dev/null +++ b/scripts/appstore/asc_api.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/appstore/asc_api.sh [JSON_FILE] + +Examples: + scripts/appstore/asc_api.sh GET /v1/apps + scripts/appstore/asc_api.sh POST /v1/betaGroups//relationships/builds payload.json + +Environment: + ASC_BASE_URL Optional (default: https://api.appstoreconnect.apple.com) + ASC_JWT_TOKEN Optional pre-generated JWT + ASC_API_MAX_RETRIES Optional retry count for 429/5xx (default: 5) + ASC_API_BACKOFF_BASE_SECONDS Optional base backoff seconds (default: 2) +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ $# -lt 2 || $# -gt 3 ]]; then + usage >&2 + exit 1 +fi + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: missing required command: $1" >&2 + exit 1 + fi +} + +normalize_error() { + local status="$1" + local body_file="$2" + python3 - "$status" "$body_file" <<'PY' +import json +import pathlib +import sys + +status = sys.argv[1] +body_path = pathlib.Path(sys.argv[2]) +body = body_path.read_text(encoding="utf-8", errors="replace") if body_path.exists() else "" + +print(f"ASC API error: HTTP {status}", file=sys.stderr) + +if not body.strip(): + print("- detail: empty response body", file=sys.stderr) + sys.exit(0) + +try: + parsed = json.loads(body) +except json.JSONDecodeError: + print("- detail: non-JSON response body", file=sys.stderr) + print(body[:600], file=sys.stderr) + sys.exit(0) + +errors = parsed.get("errors") +if not isinstance(errors, list) or not errors: + detail = parsed.get("detail") or parsed.get("message") or "Unknown API error" + print(f"- detail: {detail}", file=sys.stderr) + sys.exit(0) + +for err in errors: + code = err.get("code", "UNKNOWN") + title = err.get("title", "") + detail = err.get("detail", "") + src = err.get("source") or {} + pointer = src.get("pointer") or src.get("parameter") or "n/a" + suffix = f"{title}: {detail}" if title and detail else (detail or title or "") + if suffix: + print(f"- code={code} pointer={pointer} detail={suffix}", file=sys.stderr) + else: + print(f"- code={code} pointer={pointer}", file=sys.stderr) +PY +} + +should_retry() { + local status="$1" + if [[ "$status" == "429" ]]; then + return 0 + fi + if [[ "$status" =~ ^5[0-9][0-9]$ ]]; then + return 0 + fi + return 1 +} + +require_cmd curl +require_cmd python3 + +METHOD="$(echo "$1" | tr '[:lower:]' '[:upper:]')" +PATH_PART="$2" +BODY_FILE="${3:-}" + +if [[ -n "$BODY_FILE" && ! -f "$BODY_FILE" ]]; then + echo "error: JSON file not found: $BODY_FILE" >&2 + exit 1 +fi + +if [[ "$PATH_PART" != /* ]]; then + PATH_PART="/$PATH_PART" +fi + +ASC_BASE_URL="${ASC_BASE_URL:-https://api.appstoreconnect.apple.com}" +MAX_RETRIES="${ASC_API_MAX_RETRIES:-5}" +BACKOFF_BASE_SECONDS="${ASC_API_BACKOFF_BASE_SECONDS:-2}" + +if ! [[ "$MAX_RETRIES" =~ ^[0-9]+$ ]]; then + echo "error: ASC_API_MAX_RETRIES must be an integer" >&2 + exit 1 +fi +if ! [[ "$BACKOFF_BASE_SECONDS" =~ ^[0-9]+$ ]] || [[ "$BACKOFF_BASE_SECONDS" -le 0 ]]; then + echo "error: ASC_API_BACKOFF_BASE_SECONDS must be a positive integer" >&2 + exit 1 +fi + +JWT_TOKEN="${ASC_JWT_TOKEN:-}" +if [[ -z "$JWT_TOKEN" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + JWT_TOKEN="$($SCRIPT_DIR/jwt.sh)" +fi + +URL="${ASC_BASE_URL}${PATH_PART}" +ATTEMPT=0 +LAST_STATUS="" + +while (( ATTEMPT <= MAX_RETRIES )); do + ATTEMPT=$((ATTEMPT + 1)) + + body_tmp="$(mktemp)" + header_tmp="$(mktemp)" + + curl_args=( + -sS + --globoff + -X "$METHOD" + "$URL" + -H "Authorization: Bearer $JWT_TOKEN" + -H "Accept: application/json" + -D "$header_tmp" + -o "$body_tmp" + -w "%{http_code}" + ) + + if [[ -n "$BODY_FILE" ]]; then + curl_args+=( + -H "Content-Type: application/json" + --data-binary "@$BODY_FILE" + ) + fi + + set +e + http_code="$(curl "${curl_args[@]}")" + curl_exit=$? + set -e + + if [[ $curl_exit -eq 0 && "$http_code" =~ ^2[0-9][0-9]$ ]]; then + cat "$body_tmp" + rm -f "$body_tmp" "$header_tmp" + exit 0 + fi + + if [[ $curl_exit -ne 0 ]]; then + LAST_STATUS="curl_exit_${curl_exit}" + if (( ATTEMPT <= MAX_RETRIES )); then + sleep_seconds=$((BACKOFF_BASE_SECONDS * ATTEMPT)) + sleep "$sleep_seconds" + rm -f "$body_tmp" "$header_tmp" + continue + fi + + echo "error: curl request failed after ${ATTEMPT} attempt(s)" >&2 + rm -f "$body_tmp" "$header_tmp" + exit 1 + fi + + LAST_STATUS="$http_code" + + if should_retry "$http_code" && (( ATTEMPT <= MAX_RETRIES )); then + sleep_seconds=$((BACKOFF_BASE_SECONDS * ATTEMPT)) + sleep "$sleep_seconds" + rm -f "$body_tmp" "$header_tmp" + continue + fi + + normalize_error "$http_code" "$body_tmp" + rm -f "$body_tmp" "$header_tmp" + exit 1 +done + +echo "error: request failed after retries (last status: ${LAST_STATUS:-unknown})" >&2 +exit 1 diff --git a/scripts/appstore/jwt.sh b/scripts/appstore/jwt.sh new file mode 100755 index 0000000..079160f --- /dev/null +++ b/scripts/appstore/jwt.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/appstore/jwt.sh + +Generates a short-lived App Store Connect JWT and writes it to stdout. + +Required environment variables: + APPSTORE_API_KEY_ID + APPSTORE_API_ISSUER_ID + APPSTORE_API_PRIVATE_KEY or APPSTORE_API_PRIVATE_KEY_FILE + +Optional environment variables: + JWT_TTL_SECONDS Token lifetime in seconds (default: 600, max: 1200) +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "error: missing required env var: ${name}" >&2 + exit 1 + fi +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: missing required command: $1" >&2 + exit 1 + fi +} + +b64url() { + openssl base64 -e -A | tr '+/' '-_' | tr -d '=' +} + +require_cmd openssl +require_cmd python3 + +require_env APPSTORE_API_KEY_ID +require_env APPSTORE_API_ISSUER_ID + +JWT_TTL_SECONDS="${JWT_TTL_SECONDS:-600}" +if ! [[ "$JWT_TTL_SECONDS" =~ ^[0-9]+$ ]]; then + echo "error: JWT_TTL_SECONDS must be a positive integer" >&2 + exit 1 +fi +if [[ "$JWT_TTL_SECONDS" -le 0 || "$JWT_TTL_SECONDS" -gt 1200 ]]; then + echo "error: JWT_TTL_SECONDS must be between 1 and 1200" >&2 + exit 1 +fi + +key_file="" +temp_key_file="false" +if [[ -n "${APPSTORE_API_PRIVATE_KEY_FILE:-}" ]]; then + key_file="$APPSTORE_API_PRIVATE_KEY_FILE" + if [[ ! -f "$key_file" ]]; then + echo "error: APPSTORE_API_PRIVATE_KEY_FILE does not exist: $key_file" >&2 + exit 1 + fi +elif [[ -n "${APPSTORE_API_PRIVATE_KEY:-}" ]]; then + key_file="$(mktemp)" + temp_key_file="true" + chmod 600 "$key_file" + printf '%s\n' "$APPSTORE_API_PRIVATE_KEY" > "$key_file" +else + echo "error: set APPSTORE_API_PRIVATE_KEY or APPSTORE_API_PRIVATE_KEY_FILE" >&2 + exit 1 +fi + +cleanup() { + if [[ "$temp_key_file" == "true" ]]; then + rm -f "$key_file" + fi +} +trap cleanup EXIT + +now="$(date +%s)" +exp="$((now + JWT_TTL_SECONDS))" + +header_json="$(python3 - "$APPSTORE_API_KEY_ID" <<'PY' +import json +import sys +kid = sys.argv[1] +print(json.dumps({"alg": "ES256", "kid": kid, "typ": "JWT"}, separators=(",", ":"))) +PY +)" + +claims_json="$(python3 - "$APPSTORE_API_ISSUER_ID" "$now" "$exp" <<'PY' +import json +import sys +iss = sys.argv[1] +iat = int(sys.argv[2]) +exp = int(sys.argv[3]) +print(json.dumps({"iss": iss, "iat": iat, "exp": exp, "aud": "appstoreconnect-v1"}, separators=(",", ":"))) +PY +)" + +header_b64="$(printf '%s' "$header_json" | b64url)" +claims_b64="$(printf '%s' "$claims_json" | b64url)" +unsigned_token="${header_b64}.${claims_b64}" + +signature_b64="$( + printf '%s' "$unsigned_token" \ + | openssl dgst -binary -sha256 -sign "$key_file" \ + | python3 -c ' +import base64 +import sys + + +def parse_length(data: bytes, index: int) -> tuple[int, int]: + if index >= len(data): + raise ValueError("unexpected end of DER input") + first = data[index] + index += 1 + if first < 0x80: + return first, index + count = first & 0x7F + if count == 0 or index + count > len(data): + raise ValueError("invalid DER length") + length = int.from_bytes(data[index:index + count], "big") + return length, index + count + + +def parse_integer(data: bytes, index: int) -> tuple[bytes, int]: + if index >= len(data) or data[index] != 0x02: + raise ValueError("expected DER INTEGER") + index += 1 + length, index = parse_length(data, index) + value = data[index:index + length] + if len(value) != length: + raise ValueError("truncated DER INTEGER") + value = value.lstrip(b"\x00") + if len(value) > 32: + raise ValueError("DER INTEGER too large for ES256") + return value.rjust(32, b"\x00"), index + length + + +der = sys.stdin.buffer.read() +if not der or der[0] != 0x30: + raise SystemExit("error: expected DER SEQUENCE for ECDSA signature") + +seq_length, offset = parse_length(der, 1) +if offset + seq_length != len(der): + raise SystemExit("error: malformed DER ECDSA signature") + +r, offset = parse_integer(der, offset) +s, offset = parse_integer(der, offset) +if offset != len(der): + raise SystemExit("error: trailing bytes in DER ECDSA signature") + +jose = r + s +print(base64.urlsafe_b64encode(jose).decode().rstrip("=")) +' +)" + +printf '%s\n' "${unsigned_token}.${signature_b64}" diff --git a/scripts/appstore/select_build.py b/scripts/appstore/select_build.py new file mode 100644 index 0000000..65e4876 --- /dev/null +++ b/scripts/appstore/select_build.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Select an App Store Connect build from a builds list payload." + ) + parser.add_argument("--marketing-version", required=True) + parser.add_argument("--build-number") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + payload = json.load(sys.stdin) + items = payload.get("data") or [] + included = payload.get("included") or [] + + pre_release_versions = { + item.get("id", ""): (item.get("attributes") or {}).get("version", "") + for item in included + if item.get("type") == "preReleaseVersions" + } + + selected = None + for item in items: + attrs = item.get("attributes") or {} + if args.build_number and attrs.get("version") != args.build_number: + continue + + rel = ((item.get("relationships") or {}).get("preReleaseVersion") or {}).get("data") or {} + pre_release_id = rel.get("id", "") + marketing_version = pre_release_versions.get(pre_release_id, "") + if marketing_version != args.marketing_version: + continue + + selected = item + break + + if selected is None: + print("||") + return 0 + + attrs = selected.get("attributes") or {} + print( + "{}|{}|{}".format( + selected.get("id", ""), + attrs.get("processingState", ""), + attrs.get("version", ""), + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/appstore/sync_assets.sh b/scripts/appstore/sync_assets.sh new file mode 100755 index 0000000..9947a7b --- /dev/null +++ b/scripts/appstore/sync_assets.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/appstore/sync_assets.sh --app-store-version-id --locale [--root appstore-assets] + +Synchronizes App Store screenshots and previews from source-controlled folders. + +Folder layout: + appstore-assets/screenshots///*.{png,jpg,jpeg} + appstore-assets/previews///*.{mov,mp4,m4v} + +Notes: + - This script is intentionally opt-in from workflow input sync_assets. + - In-App Events and In-App Purchases are intentionally out of scope. +USAGE +} + +APP_STORE_VERSION_ID="" +LOCALE="" +ROOT_DIR="appstore-assets" + +while [[ $# -gt 0 ]]; do + case "$1" in + --app-store-version-id) + APP_STORE_VERSION_ID="$2" + shift 2 + ;; + --locale) + LOCALE="$2" + shift 2 + ;; + --root) + ROOT_DIR="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$APP_STORE_VERSION_ID" || -z "$LOCALE" ]]; then + usage >&2 + exit 1 +fi + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: missing required command: $1" >&2 + exit 1 + fi +} + +require_cmd python3 +require_cmd curl +require_cmd dd + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ASC_API="$SCRIPT_DIR/asc_api.sh" + +if [[ ! -x "$ASC_API" ]]; then + echo "error: missing ASC API helper at $ASC_API" >&2 + exit 1 +fi + +ensure_tmpdir() { + mkdir -p build +} + +api_get_first_id() { + local path="$1" + "$ASC_API" GET "$path" | python3 - <<'PY' +import json +import sys +payload = json.load(sys.stdin) +items = payload.get("data") or [] +print(items[0].get("id", "") if items else "") +PY +} + +ensure_version_localization() { + local version_id="$1" + local locale="$2" + + local loc_id + loc_id="$(api_get_first_id "/v1/appStoreVersionLocalizations?filter[appStoreVersion]=${version_id}&filter[locale]=${locale}&limit=1")" + if [[ -n "$loc_id" ]]; then + printf '%s\n' "$loc_id" + return 0 + fi + + cat > build/create-localization.json < build/create-screenshot-set.json < build/create-preview-set.json < "$ops_tsv" <<'PY' +import base64 +import json +import pathlib +import sys + +payload = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) +data = payload.get("data") or {} +attrs = data.get("attributes") or {} +ops = attrs.get("uploadOperations") or [] + +for op in ops: + method = op.get("method", "PUT") + url = op.get("url", "") + offset = op.get("offset", 0) + length = op.get("length", 0) + headers = op.get("requestHeaders") or [] + b64_headers = base64.b64encode(json.dumps(headers).encode("utf-8")).decode("ascii") + print(f"{method}\t{url}\t{offset}\t{length}\t{b64_headers}") +PY + + if [[ ! -s "$ops_tsv" ]]; then + rm -f "$ops_tsv" + return 0 + fi + + while IFS=$'\t' read -r method url offset length headers_b64; do + if [[ -z "$url" ]]; then + continue + fi + + mapfile -t headers < <(python3 - "$headers_b64" <<'PY' +import base64 +import json +import sys +headers = json.loads(base64.b64decode(sys.argv[1]).decode("utf-8")) +for h in headers: + name = h.get("name") + value = h.get("value") + if name and value is not None: + print(f"{name}: {value}") +PY +) + + curl_cmd=(curl -sS -X "$method" "$url") + for h in "${headers[@]}"; do + curl_cmd+=( -H "$h" ) + done + + if [[ "$length" =~ ^[0-9]+$ ]] && [[ "$length" -gt 0 ]]; then + chunk_file="$(mktemp)" + dd if="$source_file" of="$chunk_file" bs=1 skip="$offset" count="$length" status=none + curl_cmd+=( --data-binary "@$chunk_file" ) + "${curl_cmd[@]}" >/dev/null + rm -f "$chunk_file" + else + curl_cmd+=( --data-binary "@$source_file" ) + "${curl_cmd[@]}" >/dev/null + fi + done < "$ops_tsv" + + rm -f "$ops_tsv" +} + +create_upload_resource() { + local kind="$1" # screenshot | preview + local set_id="$2" + local file_path="$3" + + local file_name + file_name="$(basename "$file_path")" + local file_size + file_size="$(wc -c < "$file_path" | tr -d ' ')" + local mime_type + mime_type="$(content_type_for_file "$file_name")" + + if [[ "$kind" == "screenshot" ]]; then + FILE_NAME="$file_name" FILE_SIZE="$file_size" SET_ID="$set_id" python3 - <<'PY' > build/create-resource.json +import json +import os +print(json.dumps({ + "data": { + "type": "appScreenshots", + "attributes": { + "fileName": os.environ["FILE_NAME"], + "fileSize": int(os.environ["FILE_SIZE"]) + }, + "relationships": { + "appScreenshotSet": { + "data": {"type": "appScreenshotSets", "id": os.environ["SET_ID"]} + } + } + } +})) +PY + "$ASC_API" POST "/v1/appScreenshots" build/create-resource.json > build/create-resource-response.json + else + FILE_NAME="$file_name" FILE_SIZE="$file_size" MIME_TYPE="$mime_type" SET_ID="$set_id" python3 - <<'PY' > build/create-resource.json +import json +import os +print(json.dumps({ + "data": { + "type": "appPreviews", + "attributes": { + "fileName": os.environ["FILE_NAME"], + "fileSize": int(os.environ["FILE_SIZE"]), + "mimeType": os.environ["MIME_TYPE"] + }, + "relationships": { + "appPreviewSet": { + "data": {"type": "appPreviewSets", "id": os.environ["SET_ID"]} + } + } + } +})) +PY + "$ASC_API" POST "/v1/appPreviews" build/create-resource.json > build/create-resource-response.json + fi + + upload_from_operations build/create-resource-response.json "$file_path" + + resource_id="$(python3 - <<'PY' +import json +from pathlib import Path +payload = json.loads(Path("build/create-resource-response.json").read_text(encoding="utf-8")) +print((payload.get("data") or {}).get("id", "")) +PY +)" + + if [[ -z "$resource_id" ]]; then + echo "error: missing resource ID for $file_name" >&2 + exit 1 + fi + + if [[ "$kind" == "screenshot" ]]; then + cat > build/finalize-resource.json </dev/null || true + else + cat > build/finalize-resource.json </dev/null || true + fi + + echo "Uploaded ${kind}: ${file_name}" +} + +sync_screenshots() { + local localization_id="$1" + local screenshots_root="$2" + + if [[ ! -d "$screenshots_root" ]]; then + echo "No screenshots directory at $screenshots_root" + return 0 + fi + + local found="false" + + while IFS= read -r display_dir; do + [[ -d "$display_dir" ]] || continue + display_type="$(basename "$display_dir")" + set_id="$(ensure_screenshot_set "$localization_id" "$display_type")" + + while IFS= read -r file_path; do + [[ -f "$file_path" ]] || continue + found="true" + create_upload_resource screenshot "$set_id" "$file_path" + done < <(find "$display_dir" -maxdepth 1 -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \) | sort) + done < <(find "$screenshots_root" -mindepth 1 -maxdepth 1 -type d | sort) + + if [[ "$found" == "false" ]]; then + echo "No screenshot files found under $screenshots_root" + fi +} + +sync_previews() { + local localization_id="$1" + local previews_root="$2" + + if [[ ! -d "$previews_root" ]]; then + echo "No previews directory at $previews_root" + return 0 + fi + + local found="false" + + while IFS= read -r display_dir; do + [[ -d "$display_dir" ]] || continue + preview_type="$(basename "$display_dir")" + set_id="$(ensure_preview_set "$localization_id" "$preview_type")" + + while IFS= read -r file_path; do + [[ -f "$file_path" ]] || continue + found="true" + create_upload_resource preview "$set_id" "$file_path" + done < <(find "$display_dir" -maxdepth 1 -type f \( -name '*.mov' -o -name '*.mp4' -o -name '*.m4v' \) | sort) + done < <(find "$previews_root" -mindepth 1 -maxdepth 1 -type d | sort) + + if [[ "$found" == "false" ]]; then + echo "No preview files found under $previews_root" + fi +} + +ensure_tmpdir +localization_id="$(ensure_version_localization "$APP_STORE_VERSION_ID" "$LOCALE")" +if [[ -z "$localization_id" ]]; then + echo "error: failed to resolve appStoreVersionLocalization" >&2 + exit 1 +fi + +echo "Using appStoreVersionLocalization: $localization_id" + +sync_screenshots "$localization_id" "$ROOT_DIR/screenshots/$LOCALE" +sync_previews "$localization_id" "$ROOT_DIR/previews/$LOCALE" diff --git a/scripts/appstore/validate_submission_checklist.py b/scripts/appstore/validate_submission_checklist.py new file mode 100755 index 0000000..e1f7fe1 --- /dev/null +++ b/scripts/appstore/validate_submission_checklist.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import json +import os +from pathlib import Path + +checklist_path = Path("appstore-assets/review/submission-checklist.json") +if not checklist_path.exists(): + raise SystemExit("error: missing appstore-assets/review/submission-checklist.json") + +payload = json.loads(checklist_path.read_text(encoding="utf-8")) + +required_string_fields = [ + "privacyPolicyUrl", + "supportUrl", + "description", + "subtitle", + "keywords", + "reviewNotesPath", +] + +missing = [field for field in required_string_fields if not str(payload.get(field, "")).strip()] +if missing: + raise SystemExit(f"error: missing checklist fields: {', '.join(missing)}") + +for bool_field in ["ageRatingConfirmed", "exportComplianceConfirmed"]: + if payload.get(bool_field) is not True: + raise SystemExit(f"error: checklist requires {bool_field}=true") + +notes_path = Path(payload["reviewNotesPath"]) +if not notes_path.exists(): + raise SystemExit(f"error: review notes file not found: {notes_path}") + +support_url = os.environ.get("ASC_SUPPORT_URL", "").strip() +if support_url and payload["supportUrl"] != support_url: + raise SystemExit("error: checklist supportUrl does not match vars.ASC_SUPPORT_URL") + +privacy_policy_url = os.environ.get("ASC_PRIVACY_POLICY_URL", "").strip() +if privacy_policy_url and payload["privacyPolicyUrl"] != privacy_policy_url: + raise SystemExit("error: checklist privacyPolicyUrl does not match vars.ASC_PRIVACY_POLICY_URL") + +version = os.environ.get("VERSION", "").strip() +if not version: + raise SystemExit("error: VERSION env var is required") + +Path("build").mkdir(parents=True, exist_ok=True) +artifact = {"version": version, "checklist": payload} +Path("build/submission-preflight.json").write_text(json.dumps(artifact, indent=2), encoding="utf-8") diff --git a/scripts/fmt-lint.sh b/scripts/fmt-lint.sh index fcfc204..cb26a29 100644 --- a/scripts/fmt-lint.sh +++ b/scripts/fmt-lint.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash -# scripts/fmt-lint.sh # # Runs auto-fixers first (swift-format + swiftlint fix), then runs the exact # checks your GitHub workflow runs (swift-format lint --strict + swiftlint lint --strict). diff --git a/scripts/sync-xcode-version.sh b/scripts/sync-xcode-version.sh index 2fa9991..95762f4 100755 --- a/scripts/sync-xcode-version.sh +++ b/scripts/sync-xcode-version.sh @@ -1,48 +1,162 @@ #!/usr/bin/env bash set -euo pipefail -VERSION_FILE="${VERSION_FILE:-VERSION}" -PBXPROJ_FILE="${PBXPROJ_FILE:-Charstack.xcodeproj/project.pbxproj}" +usage() { + cat <<'EOF' +Usage: scripts/sync-xcode-version.sh [options] -if [[ ! -f "$VERSION_FILE" ]]; then - echo "VERSION file not found: $VERSION_FILE" >&2 - exit 1 +Sync Xcode project version settings from repo metadata. + +Options: + --version-file Path to VERSION file (default: VERSION at repo root) + --project Path to .xcodeproj (default: auto-detect single project) + --marketing-version Override semantic marketing version + --build-number Set CURRENT_PROJECT_VERSION to a numeric build number + -h, --help Show this help + +Examples: + scripts/sync-xcode-version.sh + scripts/sync-xcode-version.sh --build-number 42 + scripts/sync-xcode-version.sh --project Charstack.xcodeproj --version-file VERSION +EOF +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VERSION_FILE="$REPO_ROOT/VERSION" +PROJECT_PATH="" +MARKETING_VERSION="" +BUILD_NUMBER="${BUILD_NUMBER:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version-file) + [[ $# -ge 2 ]] || { echo "error: --version-file requires a value" >&2; exit 1; } + VERSION_FILE="$2" + shift 2 + ;; + --project) + [[ $# -ge 2 ]] || { echo "error: --project requires a value" >&2; exit 1; } + PROJECT_PATH="$2" + shift 2 + ;; + --marketing-version) + [[ $# -ge 2 ]] || { echo "error: --marketing-version requires a value" >&2; exit 1; } + MARKETING_VERSION="$2" + shift 2 + ;; + --build-number) + [[ $# -ge 2 ]] || { echo "error: --build-number requires a value" >&2; exit 1; } + BUILD_NUMBER="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument '$1'" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "$VERSION_FILE" != /* ]]; then + VERSION_FILE="$REPO_ROOT/$VERSION_FILE" fi -if [[ ! -f "$PBXPROJ_FILE" ]]; then - echo "Xcode project file not found: $PBXPROJ_FILE" >&2 - exit 1 +if [[ -z "$MARKETING_VERSION" ]]; then + if [[ ! -f "$VERSION_FILE" ]]; then + echo "error: VERSION file not found at '$VERSION_FILE'" >&2 + exit 1 + fi + MARKETING_VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")" fi -VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")" -if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "VERSION must be x.y.z — got '$VERSION'" >&2 +if [[ ! "$MARKETING_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: MARKETING_VERSION must be x.y.z (got '$MARKETING_VERSION')" >&2 exit 1 fi -BUILD_NUMBER="${BUILD_NUMBER:-}" -if [[ -z "$BUILD_NUMBER" ]]; then - BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)" +if [[ -n "$BUILD_NUMBER" ]]; then + if [[ ! "$BUILD_NUMBER" =~ ^[0-9]+$ ]] || [[ "$BUILD_NUMBER" -le 0 ]]; then + echo "error: build number must be a positive integer (got '$BUILD_NUMBER')" >&2 + exit 1 + fi fi -if [[ -z "$BUILD_NUMBER" ]]; then - BUILD_NUMBER="1" + +if [[ -z "$PROJECT_PATH" ]]; then + PROJECT_LIST="$(find "$REPO_ROOT" -maxdepth 1 -type d -name "*.xcodeproj" | sort)" + PROJECT_COUNT="$(printf '%s\n' "$PROJECT_LIST" | sed '/^$/d' | wc -l | tr -d ' ')" + if [[ "$PROJECT_COUNT" -eq 0 ]]; then + echo "error: no .xcodeproj found at repo root '$REPO_ROOT'" >&2 + exit 1 + fi + if [[ "$PROJECT_COUNT" -gt 1 ]]; then + echo "error: multiple .xcodeproj files found; pass --project explicitly" >&2 + printf '%s\n' "$PROJECT_LIST" >&2 + exit 1 + fi + PROJECT_PATH="$(printf '%s\n' "$PROJECT_LIST" | head -n 1)" +elif [[ "$PROJECT_PATH" != /* ]]; then + PROJECT_PATH="$REPO_ROOT/$PROJECT_PATH" fi -python3 - <<'PY' -import os, re, sys +PBXPROJ="$PROJECT_PATH/project.pbxproj" +if [[ ! -f "$PBXPROJ" ]]; then + echo "error: project file not found at '$PBXPROJ'" >&2 + exit 1 +fi + +export MARKETING_VERSION +export BUILD_NUMBER +python3 - "$PBXPROJ" <<'PY' +import os +import re +import sys from pathlib import Path -pbxproj = Path(os.environ["PBXPROJ_FILE"]) -version = os.environ["VERSION"] -build = os.environ["BUILD_NUMBER"] +pbxproj = Path(sys.argv[1]) +contents = pbxproj.read_text(encoding="utf-8") + +marketing_version = os.environ["MARKETING_VERSION"] +build_number = os.environ.get("BUILD_NUMBER", "") + +marketing_count = 0 +build_count = 0 + +def replace_marketing(match): + global marketing_count + marketing_count += 1 + return f"{match.group(1)}{marketing_version};" -text = pbxproj.read_text() -text2 = re.sub(r"MARKETING_VERSION = [^;]+;", f"MARKETING_VERSION = {version};", text) -text2 = re.sub(r"CURRENT_PROJECT_VERSION = [^;]+;", f"CURRENT_PROJECT_VERSION = {build};", text2) +def replace_build(match): + global build_count + build_count += 1 + return f"{match.group(1)}{build_number};" -if text2 != text: - pbxproj.write_text(text2) - print(f"Updated MARKETING_VERSION={version} CURRENT_PROJECT_VERSION={build}") +updated = re.sub(r"(?m)^(\s*MARKETING_VERSION = )[^;]+;", replace_marketing, contents) +if marketing_count == 0: + raise SystemExit("error: no MARKETING_VERSION settings found in project.pbxproj") + +if build_number: + updated = re.sub(r"(?m)^(\s*CURRENT_PROJECT_VERSION = )[^;]+;", replace_build, updated) + if build_count == 0: + raise SystemExit("error: no CURRENT_PROJECT_VERSION settings found in project.pbxproj") + +if updated != contents: + pbxproj.write_text(updated, encoding="utf-8") + +print(f"Updated MARKETING_VERSION in {marketing_count} setting(s).") +if build_number: + print(f"Updated CURRENT_PROJECT_VERSION in {build_count} setting(s).") else: - print("Project version already up to date.") + print("Skipped CURRENT_PROJECT_VERSION update (no --build-number provided).") PY + +echo "Synced project: $PROJECT_PATH" +echo "MARKETING_VERSION=$MARKETING_VERSION" +if [[ -n "$BUILD_NUMBER" ]]; then + echo "CURRENT_PROJECT_VERSION=$BUILD_NUMBER" +fi