diff --git a/.github/workflows/build-upload-tvos.yml b/.github/workflows/build-upload-tvos.yml index 0ac99a9..2383d93 100644 --- a/.github/workflows/build-upload-tvos.yml +++ b/.github/workflows/build-upload-tvos.yml @@ -7,6 +7,11 @@ on: required: true type: string description: 'Git ref to checkout and build' + version: + required: false + type: string + default: '' + description: 'Marketing version override (e.g. from git tag)' build-number: required: false type: string @@ -133,6 +138,13 @@ jobs: fi echo "build-number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT" + VERSION="${{ inputs.version }}" + if [ -n "$VERSION" ]; then + echo "version-args=MARKETING_VERSION=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "version-args=" >> "$GITHUB_OUTPUT" + fi + - name: Install provisioning profiles env: PROFILE_TV_APP_BASE64: ${{ secrets.PROVISIONING_PROFILE_TV_APP_BASE64 }} @@ -217,10 +229,11 @@ jobs: -authenticationKeyPath ~/".appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8" \ -authenticationKeyID "$API_KEY_ID" \ -authenticationKeyIssuerID "$API_ISSUER_ID" \ - CURRENT_PROJECT_VERSION=${{ steps.settings.outputs.build-number }} + CURRENT_PROJECT_VERSION=${{ steps.settings.outputs.build-number }} \ + ${{ steps.settings.outputs.version-args }} - name: Export and Upload to App Store Connect - if: inputs.upload + if: inputs.upload == true working-directory: ios-client env: API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} diff --git a/.github/workflows/build-upload.yml b/.github/workflows/build-upload.yml index 09588e6..feba41f 100644 --- a/.github/workflows/build-upload.yml +++ b/.github/workflows/build-upload.yml @@ -288,7 +288,7 @@ jobs: ${{ steps.settings.outputs.version-args }} - name: Export and Upload to App Store Connect - if: inputs.upload + if: inputs.upload == true working-directory: ios-client env: API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97dabff..19a1ffa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,11 +21,83 @@ jobs: TAG="${GITHUB_REF_NAME}" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + fetch-build-number: + name: Fetch build number from App Store Connect + needs: prepare + runs-on: ubuntu-latest + outputs: + build-number: ${{ steps.asc.outputs.build-number }} + build-number-tvos: ${{ steps.asc.outputs.build-number-tvos }} + steps: + - name: Get latest build and increment + id: asc + env: + ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + APP_ID_IOS: ${{ secrets.APP_STORE_APP_ID_IOS }} + APP_ID_TVOS: ${{ secrets.APP_STORE_APP_ID_TVOS }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + pip install cryptography --quiet + + echo "$PRIVATE_KEY_BASE64" | base64 --decode > /tmp/AuthKey.p8 + trap 'rm -f /tmp/AuthKey.p8' EXIT + + JWT=$(python3 -c "import base64,json,time,os;from cryptography.hazmat.primitives import hashes,serialization;from cryptography.hazmat.primitives.asymmetric import ec;from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature;key=serialization.load_pem_private_key(open('/tmp/AuthKey.p8','rb').read(),password=None);b64url=lambda d:base64.urlsafe_b64encode(d if isinstance(d,bytes) else d.encode()).rstrip(b'=').decode();now=int(time.time());h=b64url(json.dumps({'alg':'ES256','kid':os.environ['KEY_ID'],'typ':'JWT'},separators=(',',':')));p=b64url(json.dumps({'iss':os.environ['ISSUER_ID'],'exp':now+1200,'aud':'appstoreconnect-v1'},separators=(',',':')));msg=f'{h}.{p}'.encode();sig_der=key.sign(msg,ec.ECDSA(hashes.SHA256()));r,s=decode_dss_signature(sig_der);sig=b64url(r.to_bytes(32,'big')+s.to_bytes(32,'big'));print(f'{h}.{p}.{sig}')") + + fetch_latest() { + local APP_ID=$1 + local RESP STATUS + STATUS=$(curl -sg \ + -o /tmp/asc_response.json \ + -w "%{http_code}" \ + "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=$APP_ID&filter[preReleaseVersion.version]=$VERSION&sort=-uploadedDate&limit=1" \ + -H "Authorization: Bearer $JWT") + RESP=$(cat /tmp/asc_response.json) + if [ "$STATUS" != "200" ]; then + echo "API error (app=$APP_ID):" && echo "$RESP" | jq . || echo "$RESP" + exit 1 + fi + echo "$RESP" | jq -r 'if (.data | length) > 0 and (.data[0].attributes.version != null) then .data[0].attributes.version else "none" end' + } + + next_build() { + local LATEST=$1 + if [ "$LATEST" = "none" ]; then echo "1" + else python3 -c "v='$LATEST'; print(int(v)+1) if v.isdigit() else '1'" + fi + } + + LATEST_IOS=$(fetch_latest "$APP_ID_IOS") + LATEST_TVOS=$(fetch_latest "$APP_ID_TVOS") + NEXT_IOS=$(next_build "$LATEST_IOS") + NEXT_TVOS=$(next_build "$LATEST_TVOS") + + echo "=========================================" + echo " iOS latest=$LATEST_IOS next=$NEXT_IOS" + echo " tvOS latest=$LATEST_TVOS next=$NEXT_TVOS" + echo "=========================================" + + echo "build-number=$NEXT_IOS" >> "$GITHUB_OUTPUT" + echo "build-number-tvos=$NEXT_TVOS" >> "$GITHUB_OUTPUT" + build: name: Build and Upload Release - needs: prepare + needs: [prepare, fetch-build-number] uses: ./.github/workflows/build-upload.yml with: ref: ${{ github.ref }} version: ${{ needs.prepare.outputs.version }} + build-number: ${{ needs.fetch-build-number.outputs.build-number }} + secrets: inherit + + build-tvos: + name: Build and Upload tvOS Release + needs: [prepare, fetch-build-number] + uses: ./.github/workflows/build-upload-tvos.yml + with: + ref: ${{ github.ref }} + version: ${{ needs.prepare.outputs.version }} + build-number: ${{ needs.fetch-build-number.outputs.build-number-tvos }} secrets: inherit diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 23d512d..ef69557 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -34,6 +34,7 @@ jobs: netbird-ref: ${{ steps.finalize.outputs.netbird-ref }} version: ${{ steps.finalize.outputs.version }} build-number: ${{ steps.finalize.outputs.build-number }} + build-number-tvos: ${{ steps.finalize.outputs.build-number-tvos }} steps: - name: Resolve ref and check permissions id: pre @@ -150,7 +151,8 @@ jobs: ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - APP_ID: ${{ secrets.APP_STORE_APP_ID_IOS }} + APP_ID_IOS: ${{ secrets.APP_STORE_APP_ID_IOS }} + APP_ID_TVOS: ${{ secrets.APP_STORE_APP_ID_TVOS }} run: | VERSION="${{ steps.pre.outputs.version-override || steps.derive.outputs.version }}" @@ -161,29 +163,33 @@ jobs: JWT=$(python3 -c "import base64,json,time,os;from cryptography.hazmat.primitives import hashes,serialization;from cryptography.hazmat.primitives.asymmetric import ec;from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature;key=serialization.load_pem_private_key(open('/tmp/AuthKey.p8','rb').read(),password=None);b64url=lambda d:base64.urlsafe_b64encode(d if isinstance(d,bytes) else d.encode()).rstrip(b'=').decode();now=int(time.time());h=b64url(json.dumps({'alg':'ES256','kid':os.environ['KEY_ID'],'typ':'JWT'},separators=(',',':')));p=b64url(json.dumps({'iss':os.environ['ISSUER_ID'],'exp':now+1200,'aud':'appstoreconnect-v1'},separators=(',',':')));msg=f'{h}.{p}'.encode();sig_der=key.sign(msg,ec.ECDSA(hashes.SHA256()));r,s=decode_dss_signature(sig_der);sig=b64url(r.to_bytes(32,'big')+s.to_bytes(32,'big'));print(f'{h}.{p}.{sig}')") - HTTP_STATUS=$(curl -sg \ - -o /tmp/asc_response.json \ - -w "%{http_code}" \ - "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=$APP_ID&filter[preReleaseVersion.version]=$VERSION&sort=-uploadedDate&limit=1" \ - -H "Authorization: Bearer $JWT") - - RESPONSE=$(cat /tmp/asc_response.json) - echo "HTTP status: $HTTP_STATUS" - - if [ "$HTTP_STATUS" != "200" ]; then - echo "API error response:" - echo "$RESPONSE" | jq . || echo "$RESPONSE" - exit 1 - fi - - LATEST_BUILD=$(echo "$RESPONSE" | jq -r 'if (.data | length) > 0 and (.data[0].attributes.version != null) then .data[0].attributes.version else "none" end') - - echo "latest-build=$LATEST_BUILD" >> "$GITHUB_OUTPUT" + fetch_latest() { + local APP_ID=$1 + local RESP STATUS LATEST + STATUS=$(curl -sg \ + -o /tmp/asc_response.json \ + -w "%{http_code}" \ + "https://api.appstoreconnect.apple.com/v1/builds?filter[app]=$APP_ID&filter[preReleaseVersion.version]=$VERSION&sort=-uploadedDate&limit=1" \ + -H "Authorization: Bearer $JWT") + RESP=$(cat /tmp/asc_response.json) + if [ "$STATUS" != "200" ]; then + echo "API error (app=$APP_ID):" && echo "$RESP" | jq . || echo "$RESP" + exit 1 + fi + echo "$RESP" | jq -r 'if (.data | length) > 0 and (.data[0].attributes.version != null) then .data[0].attributes.version else "none" end' + } + + LATEST_BUILD=$(fetch_latest "$APP_ID_IOS") + LATEST_BUILD_TVOS=$(fetch_latest "$APP_ID_TVOS") + + echo "latest-build=$LATEST_BUILD" >> "$GITHUB_OUTPUT" + echo "latest-build-tvos=$LATEST_BUILD_TVOS" >> "$GITHUB_OUTPUT" echo "=========================================" echo " App Store Connect — latest build info" - echo " Version: $VERSION" - echo " Latest uploaded build: $LATEST_BUILD" + echo " Version: $VERSION" + echo " iOS latest: $LATEST_BUILD" + echo " tvOS latest: $LATEST_BUILD_TVOS" echo "=========================================" @@ -198,8 +204,9 @@ jobs: PRE_BUILD_NUMBER: ${{ steps.pre.outputs.build-number }} PRE_VERSION_OVERRIDE: ${{ steps.pre.outputs.version-override }} DERIVE_VERSION: ${{ steps.derive.outputs.version }} - ASC_LATEST_BUILD: ${{ steps.asc-build.outputs.latest-build }} - GH_RUN_NUMBER: ${{ github.run_number }} + ASC_LATEST_BUILD: ${{ steps.asc-build.outputs.latest-build }} + ASC_LATEST_BUILD_TVOS: ${{ steps.asc-build.outputs.latest-build-tvos }} + GH_RUN_NUMBER: ${{ github.run_number }} with: script: | core.setOutput('ref', process.env.PRE_REF); @@ -222,6 +229,19 @@ jobs: core.info(`build-number: ${buildNumber} (latest=${latestBuild}, override=${overrideBuild})`); core.setOutput('build-number', buildNumber); + const latestBuildTvos = process.env.ASC_LATEST_BUILD_TVOS || ''; + let buildNumberTvos; + if (overrideBuild && overrideBuild !== runNumber) { + buildNumberTvos = overrideBuild; + } else if (latestBuildTvos && latestBuildTvos !== 'none') { + const parsed = parseInt(latestBuildTvos, 10); + buildNumberTvos = !isNaN(parsed) ? String(parsed + 1) : '1'; + } else { + buildNumberTvos = '1'; + } + core.info(`build-number-tvos: ${buildNumberTvos} (latest=${latestBuildTvos})`); + core.setOutput('build-number-tvos', buildNumberTvos); + const override = process.env.PRE_VERSION_OVERRIDE || ''; const derived = process.env.DERIVE_VERSION || ''; core.setOutput('version', override || derived); @@ -247,7 +267,8 @@ jobs: uses: ./.github/workflows/build-upload-tvos.yml with: ref: ${{ needs.gate.outputs.ref }} - build-number: ${{ needs.gate.outputs.build-number }} + version: ${{ needs.gate.outputs.version }} + build-number: ${{ needs.gate.outputs.build-number-tvos }} upload: ${{ needs.gate.outputs.upload == 'true' }} secrets: inherit