diff --git a/.github/workflows/ios-ship-contract.yml b/.github/workflows/ios-ship-contract.yml index c4ae25ee..1a4e6e65 100644 --- a/.github/workflows/ios-ship-contract.yml +++ b/.github/workflows/ios-ship-contract.yml @@ -56,3 +56,4 @@ jobs: python3 scripts/ios-fleet/test_pick_simulator.py python3 scripts/ios-fleet/test_screenshot_ink.py bash scripts/ios-fleet/test-capture-script.sh + bash scripts/ios-fleet/test-publish-ios-versions.sh diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 69d7b2b6..f21d69e8 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -293,6 +293,13 @@ jobs: env: IOS_TF_RELEASE_NOTES: "1" IOS_MANUAL_SIGN: "1" + # The version-manifest publisher writes site/ios-versions.json on + # Simple-With-Us/ai-fleet-coordinator through a branch and pull + # request. This job's GITHUB_TOKEN is contents:read here and cannot + # write that repository, so the publisher gets the org GH_PAT secret + # scoped to this step only. The value is never printed, and a publish + # miss stays non-fatal for the TestFlight upload (CC-202). + IOS_VERSIONS_PUBLISH_TOKEN: ${{ secrets.GH_PAT }} run: | source scripts/ios-load-asc-key.sh bash scripts/ios-ship-testflight.sh diff --git a/scripts/ios-fleet/publish-ios-versions.sh b/scripts/ios-fleet/publish-ios-versions.sh index d1b15273..3deef304 100755 --- a/scripts/ios-fleet/publish-ios-versions.sh +++ b/scripts/ios-fleet/publish-ios-versions.sh @@ -1,30 +1,43 @@ #!/usr/bin/env bash -# publish-ios-versions.sh — update the public fleet iOS version manifest. +# publish-ios-versions.sh - update the public fleet iOS version manifest. # # Called by ship-testflight.sh after a successful upload. Also safe to run -# by hand. Never prints secret values. Failure is non-fatal for ships. +# by hand. Never prints secret values. Failure is non-fatal for ships: +# the caller keeps this step best-effort and prints whatever this script +# prints, including the HTTP status of a refused remote write (CC-202). # # Usage: -# bash /Users/jay/apps/ios-fleet/publish-ios-versions.sh \ -# --bundle-id trade.socratic.app \ -# --version 1.0.68 \ -# [--build 2360] \ -# [--apple-id 6799238379] \ -# [--display-name "Socratic.Trade"] +# bash publish-ios-versions.sh \ +# --bundle-id com.simplewithus.codecaps.ios \ +# --version 1.0.264 \ +# [--build 283] \ +# [--apple-id 6814623846] \ +# [--display-name "CodeCaps Companion"] # # The marketing version and the build number are independent (AFC-517), so # both are passed separately and neither is derived from the other. # # Writes: -# /Users/jay/apps/ios-fleet/ios-app-versions.json -# https://github.com/Simple-With-Us/ai-fleet-coordinator (site/ios-versions.json) +# /ios-app-versions.json (local record) +# Simple-With-Us/ai-fleet-coordinator site/ios-versions.json, through a +# pull request. That repository's main branch requires a pull request +# (org and repo rulesets, no bypass actors), so a direct Contents PUT to +# main is refused even for a credential that can write the repository. +# The ship job's own GITHUB_TOKEN is also contents:read on the shipping +# repository and cannot write the other repository at all. The remote +# leg therefore needs IOS_VERSIONS_PUBLISH_TOKEN (in CI: the org GH_PAT +# secret, wired in ios-ship.yml) and opens or reuses a branch and pull +# request instead of PUTting to main. Without that token the remote leg +# is skipped out loud and this script exits 1; the ship stays non-fatal +# because ship-testflight.sh only warns on a non-zero exit here. set -euo pipefail FLEET_DIR="$(cd "$(dirname "$0")" && pwd)" LOCAL_JSON="${FLEET_DIR}/ios-app-versions.json" -REPO="Simple-With-Us/ai-fleet-coordinator" -REMOTE_PATH="site/ios-versions.json" +REPO="${IOS_VERSIONS_REPO:-Simple-With-Us/ai-fleet-coordinator}" +REMOTE_PATH="${IOS_VERSIONS_REMOTE_PATH:-site/ios-versions.json}" +API_BASE="${IOS_VERSIONS_API_BASE:-https://api.github.com}" BUNDLE_ID="" VERSION="" @@ -50,6 +63,10 @@ while [[ $# -gt 0 ]]; do done [[ -n "$BUNDLE_ID" && -n "$VERSION" ]] || { echo "need --bundle-id and --version" >&2; exit 2; } +if [[ -n "$APPLE_ID" && ! "$APPLE_ID" =~ ^[0-9]+$ ]]; then + echo "remote-manifest-publish-failed: --apple-id must be a number (got a non-numeric value)" >&2 + exit 2 +fi python3 - "$LOCAL_JSON" "$BUNDLE_ID" "$VERSION" "$BUILD" "$APPLE_ID" "$DISPLAY_NAME" <<'PY' import json, sys, datetime @@ -77,27 +94,229 @@ with open(path, "w", encoding="utf-8") as fh: print("local-manifest-updated", bundle, version) PY -if ! command -v gh >/dev/null 2>&1; then - echo "gh not installed; local manifest only" >&2 +# The dedicated publishing credential. GH_TOKEN is honoured as a fallback +# for a hand run; GITHUB_TOKEN is deliberately not used, because in CI it +# is the shipping repository's contents:read token and a remote write +# attempted with it only ever produced a swallowed 403. +TOKEN="${IOS_VERSIONS_PUBLISH_TOKEN:-${GH_TOKEN:-}}" +if [[ -z "$TOKEN" ]] && command -v gh >/dev/null 2>&1; then + TOKEN="$(gh auth token 2>/dev/null || true)" +fi +if [[ -z "$TOKEN" ]]; then + echo "remote-manifest-publish-skipped: no IOS_VERSIONS_PUBLISH_TOKEN credential; the ship job GITHUB_TOKEN is contents:read on this repository and cannot write ${REPO}, so ${REMOTE_PATH} was not updated" >&2 + exit 1 +fi +if ! command -v curl >/dev/null 2>&1; then + echo "remote-manifest-publish-failed: curl not installed; cannot call the GitHub API for ${REPO}/${REMOTE_PATH}" >&2 + exit 1 +fi + +WORK_DIR="$(umask 077; mktemp -d "${TMPDIR:-/tmp}/publish-ios-versions.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT +API_BODY="${WORK_DIR}/api-body.json" +API_STATUS="" +# The credential travels in a private curl config inside the work directory, +# never in a command argument or a log line. +CURL_CONFIG="${WORK_DIR}/curl-config" +printf 'header = "Authorization: %s %s"\n' "Bearer" "$TOKEN" > "$CURL_CONFIG" +chmod 600 "$CURL_CONFIG" + +# api_request METHOD PATH [JSON_FILE] +# Body lands in $API_BODY and the HTTP status in $API_STATUS. +api_request() { + local method="$1" path="$2" data_file="${3:-}" + local -a args + args=(-sS -K "$CURL_CONFIG" -o "$API_BODY" -w '%{http_code}' -X "$method" + -H "Accept: application/vnd.github+json" + -H "X-GitHub-Api-Version: 2022-11-28") + if [[ -n "$data_file" ]]; then + args+=(-H "Content-Type: application/json" --data "@${data_file}") + fi + API_STATUS="$(curl "${args[@]}" "${API_BASE}/${path}" || true)" + [[ -n "$API_STATUS" ]] || { echo "remote-manifest-publish-failed: ${method} ${path} could not reach ${API_BASE}" >&2; exit 1; } +} + +api_message() { + python3 - "$API_BODY" <<'PY' 2>/dev/null || echo "no response message" +import json, sys +try: + data = json.load(open(sys.argv[1], encoding="utf-8")) +except Exception: + print("no response message"); raise SystemExit +msg = data.get("message") if isinstance(data, dict) else None +print(msg if msg else "no response message") +PY +} + +api_fail() { + local what="$1" + echo "remote-manifest-publish-failed: ${what} HTTP ${API_STATUS}: $(api_message)" >&2 + exit 1 +} + +json_field() { + python3 - "$1" "$2" <<'PY' +import json, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +cur = data +for part in sys.argv[2].split("."): + if isinstance(cur, dict): + cur = cur.get(part, "") + elif isinstance(cur, list) and part.isdigit() and int(part) < len(cur): + cur = cur[int(part)] + else: + cur = "" + break +print("" if cur is None else cur) +PY +} + +# 1. Read the live manifest and merge this app's row into it. Merging into +# the remote file matters: the local record only knows this app, so +# uploading it whole would delete every other app's row. +api_request GET "repos/${REPO}/contents/${REMOTE_PATH}?ref=main" +[[ "$API_STATUS" == "200" ]] || api_fail "GET repos/${REPO}/contents/${REMOTE_PATH}?ref=main" +json_field "$API_BODY" "content" | tr -d '\n' | python3 -c 'import base64,sys; sys.stdout.buffer.write(base64.b64decode(sys.stdin.read()))' > "${WORK_DIR}/remote.json" 2>/dev/null \ + || { echo "remote-manifest-publish-failed: could not decode the live ${REMOTE_PATH} (HTTP 200 with an unreadable body)" >&2; exit 1; } + +ALREADY_CURRENT="$(python3 - "${WORK_DIR}/remote.json" "$BUNDLE_ID" "$VERSION" "$BUILD" "$APPLE_ID" "$DISPLAY_NAME" <<'PY' +import json, sys +path, bundle, version, build, apple_id, display = sys.argv[1:7] +data = json.load(open(path, encoding="utf-8")) +entry = data.get("apps", {}).get(bundle, {}) +ok = entry.get("marketingVersion") == version +if build: + ok = ok and str(entry.get("build", "")) == build +if apple_id: + ok = ok and entry.get("appleId") == int(apple_id) +if display: + ok = ok and entry.get("displayName") == display +print("1" if ok else "0") +PY +)" +if [[ "$ALREADY_CURRENT" == "1" ]]; then + echo "remote-manifest-already-current ${REPO}/${REMOTE_PATH} ${BUNDLE_ID} ${VERSION}" exit 0 fi -CONTENT_B64="$(python3 -c 'import base64,sys; print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "$LOCAL_JSON")" -SHA="$(gh api "repos/${REPO}/contents/${REMOTE_PATH}" --jq .sha 2>/dev/null || true)" +python3 - "${WORK_DIR}/remote.json" "${WORK_DIR}/merged.json" "$BUNDLE_ID" "$VERSION" "$BUILD" "$APPLE_ID" "$DISPLAY_NAME" <<'PY' +import json, sys, datetime +src, dst, bundle, version, build, apple_id, display = sys.argv[1:8] +data = json.load(open(src, encoding="utf-8")) +apps = data.setdefault("apps", {}) +entry = apps.get(bundle, {}) +entry["marketingVersion"] = version +if build: + entry["build"] = build +if apple_id: + entry["appleId"] = int(apple_id) +if display: + entry["displayName"] = display +apps[bundle] = entry +data["schemaVersion"] = 1 +data["updatedAt"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +with open(dst, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") +PY + +# 2. Branch off main. The branch name is stable for one app version, so a +# re-run reuses the same branch and pull request instead of opening a +# duplicate. +BRANCH="ios-versions/$(printf '%s-%s' "$BUNDLE_ID" "$VERSION" | tr -c 'A-Za-z0-9._-' '-')" +api_request GET "repos/${REPO}/git/ref/heads/main" +[[ "$API_STATUS" == "200" ]] || api_fail "GET repos/${REPO}/git/ref/heads/main" +MAIN_SHA="$(json_field "$API_BODY" "object.sha")" +[[ -n "$MAIN_SHA" ]] || { echo "remote-manifest-publish-failed: GET main ref returned no sha (HTTP ${API_STATUS})" >&2; exit 1; } + +python3 - "${WORK_DIR}/create-ref.json" "refs/heads/${BRANCH}" "$MAIN_SHA" <<'PY' +import json, sys +json.dump({"ref": sys.argv[2], "sha": sys.argv[3]}, open(sys.argv[1], "w", encoding="utf-8")) +PY +api_request POST "repos/${REPO}/git/refs" "${WORK_DIR}/create-ref.json" +if [[ "$API_STATUS" == "422" ]]; then + api_request GET "repos/${REPO}/git/ref/heads/${BRANCH}" + [[ "$API_STATUS" == "200" ]] || api_fail "POST repos/${REPO}/git/refs (branch ${BRANCH} may already exist)" +elif [[ "$API_STATUS" != "201" ]]; then + api_fail "POST repos/${REPO}/git/refs" +fi + +# 3. Put the merged manifest on the branch, never on main. +api_request GET "repos/${REPO}/contents/${REMOTE_PATH}?ref=${BRANCH}" +BRANCH_FILE_SHA="" +if [[ "$API_STATUS" == "200" ]]; then + BRANCH_FILE_SHA="$(json_field "$API_BODY" "sha")" +elif [[ "$API_STATUS" != "404" ]]; then + api_fail "GET repos/${REPO}/contents/${REMOTE_PATH}?ref=${BRANCH}" +fi + +python3 - "${WORK_DIR}/merged.json" "${WORK_DIR}/put.json" "$BUNDLE_ID" "$VERSION" "$BRANCH" "$BRANCH_FILE_SHA" <<'PY' +import base64, json, sys +src, dst, bundle, version, branch, sha = sys.argv[1:7] +payload = { + "message": f"chore: {bundle} {version}", + "content": base64.b64encode(open(src, "rb").read()).decode(), + "branch": branch, +} +if sha: + payload["sha"] = sha +json.dump(payload, open(dst, "w", encoding="utf-8")) +PY +api_request PUT "repos/${REPO}/contents/${REMOTE_PATH}" "${WORK_DIR}/put.json" +[[ "$API_STATUS" == "200" || "$API_STATUS" == "201" ]] || api_fail "PUT repos/${REPO}/contents/${REMOTE_PATH} (branch ${BRANCH})" -ARGS=( - --method PUT - "repos/${REPO}/contents/${REMOTE_PATH}" - -f message="chore: ${BUNDLE_ID} ${VERSION}" - -f content="${CONTENT_B64}" +# 4. Open the pull request, or reuse the one a previous run opened. +python3 - "${WORK_DIR}/pr.json" "$BUNDLE_ID" "$VERSION" "$BRANCH" <<'PY' +import json, sys +dst, bundle, version, branch = sys.argv[1:5] +body = ( + f"Records {bundle} {version} in the public iOS version manifest. " + "This branch was written by publish-ios-versions.sh after a TestFlight upload. " + "Main requires a pull request, so the publisher opens one instead of PUTting to main." ) -if [[ -n "$SHA" ]]; then - ARGS+=(-f sha="$SHA") +json.dump({"title": f"chore: {bundle} {version}", "head": branch, "base": "main", "body": body}, + open(dst, "w", encoding="utf-8")) +PY +api_request POST "repos/${REPO}/pulls" "${WORK_DIR}/pr.json" +PR_URL="" +PR_NUMBER="" +if [[ "$API_STATUS" == "201" ]]; then + PR_URL="$(json_field "$API_BODY" "html_url")" + PR_NUMBER="$(json_field "$API_BODY" "number")" +elif [[ "$API_STATUS" == "422" ]]; then + OWNER="${REPO%%/*}" + api_request GET "repos/${REPO}/pulls?state=open&head=${OWNER}:${BRANCH}" + if [[ "$API_STATUS" == "200" ]]; then + PR_URL="$(json_field "$API_BODY" "0.html_url")" + PR_NUMBER="$(json_field "$API_BODY" "0.number")" + fi + [[ -n "$PR_URL" ]] || api_fail "POST repos/${REPO}/pulls" +else + api_fail "POST repos/${REPO}/pulls" fi -if gh api "${ARGS[@]}" >/dev/null; then - echo "remote-manifest-updated ${REPO}/${REMOTE_PATH}" -else - echo "remote-manifest-publish-failed" >&2 - exit 1 +echo "remote-manifest-pr-opened ${REPO}/${REMOTE_PATH} ${BUNDLE_ID} ${VERSION} PR #${PR_NUMBER} ${PR_URL}" + +# 5. Best effort: arm auto-merge so the row lands once the required checks +# pass. A refusal here is printed, not hidden, and the pull request above +# is still the written row, so this leg alone does not fail the publish. +if [[ -n "$PR_NUMBER" ]]; then + api_request GET "repos/${REPO}/pulls/${PR_NUMBER}" + PR_NODE_ID="" + if [[ "$API_STATUS" == "200" ]]; then + PR_NODE_ID="$(json_field "$API_BODY" "node_id")" + fi + if [[ -n "$PR_NODE_ID" ]]; then + python3 - "${WORK_DIR}/graphql.json" "$PR_NODE_ID" <<'PY' +import json, sys +query = "mutation($id:ID!){enablePullRequestAutoMerge(input:{pullRequestId:$id,mergeMethod:SQUASH}){pullRequest{number}}}" +json.dump({"query": query, "variables": {"id": sys.argv[2]}}, open(sys.argv[1], "w", encoding="utf-8")) +PY + api_request POST "graphql" "${WORK_DIR}/graphql.json" + if [[ "$API_STATUS" == "200" ]] && ! grep -q '"errors"' "$API_BODY"; then + echo "remote-manifest-pr-auto-merge-armed PR #${PR_NUMBER}" + else + echo "remote-manifest-pr-auto-merge-not-armed: POST graphql HTTP ${API_STATUS}: $(api_message); PR #${PR_NUMBER} is open and lands once its required checks pass and it is merged" >&2 + fi + fi fi diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 48615167..7c784d29 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -887,6 +887,13 @@ record_successful_ship() { printf '%s %s\n' "$now" "$head_sha" >"$path" chmod 600 "$path" 2>/dev/null || true log "ship-gate: recorded success ts=${now} sha=${head_sha:0:10} -> ${path}" + # --upload-only resolves no version (MARKETING/BUILD_NUM stay the "(from + # IPA)" placeholder): forwarding it would write the placeholder into the + # manifest, so only the ship-gate state above is recorded. + if [[ "${MARKETING:-}" == "(from IPA)" || "${BUILD_NUM:-}" == "(from IPA)" ]]; then + log "version-manifest: skipped (upload-only; the IPA already carries its version)" + return 0 + fi # Public version manifest for the in-app update prompt. Best-effort — # a publish miss must never fail a ship that already uploaded. if [[ -n "${BUNDLE_ID:-}" && -n "${MARKETING:-}" ]]; then @@ -898,10 +905,20 @@ record_successful_ship() { [[ -n "${BUILD_NUM:-}" ]] && pub_args+=(--build "$BUILD_NUM") [[ -n "${apple_id:-}" ]] && pub_args+=(--apple-id "$apple_id") [[ -n "${display:-}" ]] && pub_args+=(--display-name "$display") - if bash "${FLEET_DIR}/publish-ios-versions.sh" "${pub_args[@]}" >/dev/null 2>&1; then + # Never discard the publisher's streams: a missed publish must name its + # cause (including the HTTP status) in the ship log, while staying + # non-fatal for a TestFlight upload that already succeeded (CC-202). + local publish_output publish_rc=0 + publish_output="$(bash "${FLEET_DIR}/publish-ios-versions.sh" "${pub_args[@]}" 2>&1)" || publish_rc=$? + if [[ -n "$publish_output" ]]; then + while IFS= read -r publish_line; do + log "version-manifest: ${publish_line}" + done <<< "$publish_output" + fi + if [[ "$publish_rc" -eq 0 ]]; then log "version-manifest: published ${BUNDLE_ID} ${MARKETING}" else - log "warning: version-manifest publish failed (non-fatal)" + log "warning: version-manifest publish failed (non-fatal, rc=${publish_rc}); the TestFlight upload above still counts as shipped" fi fi } diff --git a/scripts/ios-fleet/test-publish-ios-versions.sh b/scripts/ios-fleet/test-publish-ios-versions.sh new file mode 100755 index 00000000..9a83d6c4 --- /dev/null +++ b/scripts/ios-fleet/test-publish-ios-versions.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Tests for publish-ios-versions.sh (CC-202) with a stubbed GitHub API. +# Run: bash scripts/ios-fleet/test-publish-ios-versions.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SCRIPT_SRC="${ROOT}/scripts/ios-fleet/publish-ios-versions.sh" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/test-publish-ios.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "${TMP}/fleet" "${TMP}/bin" "${TMP}/state" +cp "$SCRIPT_SRC" "${TMP}/fleet/publish-ios-versions.sh" + +# Fixture remote manifest with another app's row that must survive the merge. +python3 - "${TMP}/state/remote.json" <<'PY' +import base64, json, sys +data = {"schemaVersion": 1, "updatedAt": "2026-10-18T02:20:00Z", + "apps": {"trade.congress.ios": {"marketingVersion": "1.0.236", "build": "202610180036", "appleId": 6798076688, "displayName": "Congress.Trade"}}} +open(sys.argv[1], "w").write(json.dumps(data)) +PY + +cat > "${TMP}/bin/curl" <<'STUB' +#!/usr/bin/env bash +# Stub curl: understands -o FILE -w FMT -X METHOD --data @FILE URL and -K FILE. +out=""; method="GET"; data=""; url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -w) shift 2 ;; + -X) method="$2"; shift 2 ;; + --data) data="$2"; shift 2 ;; + -K|-H) shift 2 ;; + -sS) shift ;; + *) url="$1"; shift ;; + esac +done +state="${STUB_STATE_DIR:?}" +printf '%s %s\n' "$method" "$url" >> "${state}/calls.log" +if [[ -n "$data" && "$data" == @* ]]; then + cp "${data#@}" "${state}/last-${method}.json" 2>/dev/null || true +fi +body='{}'; status=200 +case "$method $url" in + "GET "*"/contents/"*"?ref=main") + content="$(base64 < "${state}/remote.json" | tr -d '\n')" + body="{\"sha\":\"remote-sha\",\"content\":\"${content}\"}" + ;; + "GET "*"/git/ref/heads/main") + body='{"object":{"sha":"main-sha"}}' + ;; + "POST "*"/git/refs") + status=201; body='{"ref":"refs/heads/stub"}' + ;; + "GET "*"/contents/"*"?ref=ios-versions/"*) + status=404; body='{"message":"Not Found"}' + ;; + "PUT "*"/contents/"*) + if [[ "${STUB_FAIL_PUT:-}" == "1" ]]; then + status=403; body='{"message":"Resource not accessible by integration"}' + else + status=201; body='{"content":{"sha":"new-sha"}}' + cp "${state}/last-PUT.json" "${state}/put.json" 2>/dev/null || true + fi + ;; + "POST "*"/pulls") + status=201; body='{"html_url":"https://github.example/pull/7","number":7}' + ;; + "GET "*"/pulls/7") + body='{"node_id":"PR_node_7","number":7}' + ;; + "POST "*"/graphql") + body='{"data":{}}' + ;; + *) + status=500; body='{"message":"unstubbed call"}' + ;; +esac +printf '%s' "$body" > "$out" +printf '%s' "$status" +STUB +chmod +x "${TMP}/bin/curl" + +run_publish() { + STUB_STATE_DIR="${TMP}/state" PATH="${TMP}/bin:/usr/bin:/bin" \ + IOS_VERSIONS_API_BASE="https://api.github.example" \ + IOS_VERSIONS_PUBLISH_TOKEN="stub-token-for-tests" \ + bash "${TMP}/fleet/publish-ios-versions.sh" \ + --bundle-id com.simplewithus.codecaps.ios --version 1.0.264 \ + --build 283 --apple-id 6814623846 --display-name "CodeCaps Companion" +} + +# Happy path: branch + PR, other apps preserved, row written. +out="$(run_publish 2>"${TMP}/happy.err")" +grep -q "remote-manifest-pr-opened" <<<"$out" +grep -q "PR #7" <<<"$out" +python3 - "${TMP}/state/put.json" <<'PY' +import base64, json, sys +put = json.load(open(sys.argv[1])) +assert put["branch"].startswith("ios-versions/"), put["branch"] +assert put["branch"] != "main" +merged = json.loads(base64.b64decode(put["content"])) +row = merged["apps"]["com.simplewithus.codecaps.ios"] +assert row == {"marketingVersion": "1.0.264", "build": "283", "appleId": 6814623846, "displayName": "CodeCaps Companion"}, row +assert merged["apps"]["trade.congress.ios"]["marketingVersion"] == "1.0.236", "other app row must survive" +PY +if grep -q "PUT .*contents.*ref=main" "${TMP}/state/calls.log"; then + echo "FAIL: publisher PUT to main" >&2; exit 1 +fi + +# A refused write names its HTTP status and fails (the ship stays non-fatal +# because ship-testflight.sh only warns on this exit). +: > "${TMP}/state/calls.log" +if STUB_FAIL_PUT=1 run_publish >"${TMP}/fail.out" 2>"${TMP}/fail.err"; then + echo "FAIL: a 403 PUT must make the publisher exit non-zero" >&2; exit 1 +fi +grep -q "HTTP 403" "${TMP}/fail.err" +grep -q "Resource not accessible" "${TMP}/fail.err" + +# No credential: the cause is printed, including why the ship token cannot work. +if PATH="/usr/bin:/bin" HOME="${TMP}" bash "${TMP}/fleet/publish-ios-versions.sh" \ + --bundle-id com.simplewithus.codecaps.ios --version 1.0.264 >"${TMP}/notoken.out" 2>"${TMP}/notoken.err"; then + echo "FAIL: no-credential publish must exit non-zero so the ship log warns" >&2; exit 1 +fi +grep -q "no IOS_VERSIONS_PUBLISH_TOKEN" "${TMP}/notoken.err" +grep -q "contents:read" "${TMP}/notoken.err" + +# A non-numeric apple id is a named failure, not a bare traceback. +if bash "${TMP}/fleet/publish-ios-versions.sh" --bundle-id com.simplewithus.codecaps.ios \ + --version 1.0.264 --apple-id notanumber >"${TMP}/badid.out" 2>"${TMP}/badid.err"; then + echo "FAIL: a non-numeric --apple-id must exit non-zero" >&2; exit 1 +fi +grep -q "remote-manifest-publish-failed: --apple-id must be a number" "${TMP}/badid.err" +if grep -q Traceback "${TMP}/badid.err"; then echo "FAIL: traceback leaked" >&2; exit 1; fi + +echo "test-publish-ios-versions: all tests passed" diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index cac43a60..e2029033 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -297,6 +297,40 @@ test("the ASC credential reaches every client through the environment, never a p assert.ok(pushPaths().includes("scripts/ios-load-asc-key.sh"), "editing the loader must re-ship"); }); +test("the version manifest publish names its cause and can write the CodeCaps row (CC-202)", () => { + const ship = read("scripts/ios-fleet/ship-testflight.sh"); + const publish = read("scripts/ios-fleet/publish-ios-versions.sh"); + + // The publisher's streams are never discarded, and its failure stays a + // warning: the TestFlight upload that already succeeded must not fail. + assert.doesNotMatch(ship, /publish-ios-versions\.sh[^|\n]*>\/dev\/null/); + assert.match(ship, /publish_output="\$\(bash "\$\{FLEET_DIR\}\/publish-ios-versions\.sh"/); + assert.match(ship, /version-manifest publish failed \(non-fatal, rc=\$\{publish_rc\}\)/); + assert.match(publish, /HTTP \$\{API_STATUS\}/); + assert.match(publish, /no IOS_VERSIONS_PUBLISH_TOKEN credential/); + assert.match(publish, /contents:read on this repository and cannot write/); + + // The remote leg merges into the live manifest and writes through a + // branch and pull request, because ai-fleet-coordinator main requires a + // pull request and this job's token is contents:read here. + assert.match(publish, /repos\/\$\{REPO\}\/git\/refs/); + assert.match(publish, /repos\/\$\{REPO\}\/pulls/); + assert.match(publish, /"branch": branch/); + assert.doesNotMatch(publish, /gh api "\$\{ARGS\[@\]\}"/); + assert.match(publish, /other app|every other app/); + + // The credential is the org GH_PAT secret, scoped to the ship step only, + // and this job's own permissions stay read-only. + assert.match(code, /IOS_VERSIONS_PUBLISH_TOKEN:\s*\$\{\{ secrets\.GH_PAT \}\}/); + assert.match(code, /permissions:\s*\n\s+contents: read/); + assert.doesNotMatch(code, /contents: write/); + assert.doesNotMatch(code, /(echo|printf)[^\n]*IOS_VERSIONS_PUBLISH_TOKEN/); + assert.match(publish, /CURL_CONFIG="\$\{WORK_DIR\}\/curl-config"/); + assert.match(publish, /chmod 600 "\$CURL_CONFIG"/); + assert.doesNotMatch(publish, /echo[^\n]*\$\{TOKEN\}/); + assert.doesNotMatch(publish, /Authorization: Bearer \$\{TOKEN\}/); +}); + test("the ship carries no standing gate override and always publishes What to Test", () => { assert.doesNotMatch(code, /IOS_TF_MIN_INTERVAL_SEC/); assert.doesNotMatch(code, /--force-ship/); @@ -323,6 +357,7 @@ test("the marketing train is 1.0.N and release notes use the owner clock", () => test("scheduled gate and key handoff shell tests pass", () => { for (const script of [ "scripts/ios-fleet/test-scheduled-ship-gate.sh", + "scripts/ios-fleet/test-publish-ios-versions.sh", "scripts/test-ios-stage-asc-key.sh", "scripts/test-ios-load-asc-key.sh", ]) { @@ -338,3 +373,10 @@ test("scheduled gate and key handoff shell tests pass", () => { assert.match(out, /all tests passed/, `${script} did not report success`); } }); + +test("upload-only never forwards the (from IPA) placeholder to the manifest publisher", () => { + const ship = read("scripts/ios-fleet/ship-testflight.sh"); + const guard = ship.indexOf('"${MARKETING:-}" == "(from IPA)" || "${BUILD_NUM:-}" == "(from IPA)"'); + const publish = ship.indexOf('bash "${FLEET_DIR}/publish-ios-versions.sh"'); + assert.ok(guard > 0 && publish > guard, "placeholder guard must come before the publisher call"); +});