diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 52d42982..9932d167 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -8,6 +8,10 @@ on: - "test-repo/**" - "trufflehog-merge-excludes/**" - "trufflehog-filter-findings/**" + - "store-debug-symbols/**" + - "restore-debug-symbols/**" + - "delete-debug-symbols/**" + - "upload-debug-symbols-to-sentry/**" jobs: test-js-supply-chain: @@ -34,3 +38,37 @@ jobs: python-version: '3.x' - run: pip install pytest pyyaml - run: pytest trufflehog-filter-findings/tests/ + + test-store-debug-symbols: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: bats-core/bats-action@2 + - run: bats store-debug-symbols/tests/ + + test-restore-debug-symbols: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - uses: bats-core/bats-action@2 + - run: bats restore-debug-symbols/tests/ + + test-delete-debug-symbols: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - uses: bats-core/bats-action@2 + - run: bats delete-debug-symbols/tests/ + + test-upload-debug-symbols-to-sentry: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: bats-core/bats-action@2 + - run: bats upload-debug-symbols-to-sentry/tests/ diff --git a/checkout-ssh/action.yml b/checkout-ssh/action.yml index cedc09f0..0f1aa799 100644 --- a/checkout-ssh/action.yml +++ b/checkout-ssh/action.yml @@ -10,6 +10,10 @@ inputs: git-submodules: description: "Checkout the project with git submodules" required: false + fetch-depth: + description: "Number of commits to fetch. 0 fetches the full history, which tools like sentry-cli need to see the commits of a release." + required: false + default: '1' runs: using: "composite" steps: @@ -17,6 +21,7 @@ runs: with: lfs: ${{ inputs.git-lfs }} submodules: ${{ inputs.git-submodules }} + fetch-depth: ${{ inputs.fetch-depth }} ssh-key: ${{ inputs.git-submodules != 'false' && inputs.ssh-private-key || '' }} - uses: webfactory/ssh-agent@v0.6.0 with: diff --git a/delete-debug-symbols/action.yml b/delete-debug-symbols/action.yml new file mode 100644 index 00000000..4cb24091 --- /dev/null +++ b/delete-debug-symbols/action.yml @@ -0,0 +1,38 @@ +name: 'Delete debug symbols' +description: 'Delete the debug symbols a build stored in QB Spaces, once Sentry has them' +inputs: + access-key: + description: 'Digital Ocean Access Key' + required: true + secret-key: + description: 'Digital Ocean Secret Key' + required: true + space-name: + description: 'Name of the DO Space the symbols were stored in' + required: false + default: 'quickbird-artifacts' + space-region: + description: 'Region of the DO Space' + required: false + default: 'fra1' + build-number: + description: 'Build number the symbols were stored for. Must be the same one the build jobs passed to store-debug-symbols. Falls back to the workflow run id.' + required: false + default: '' + platforms: + description: 'Space separated platform labels to delete. Platforms without stored symbols are skipped.' + required: false + default: 'ios android-apk android-aab' +runs: + using: "composite" + steps: + - name: Delete debug symbols from QB Spaces + shell: bash + env: + INPUT_ACCESS_KEY: ${{ inputs.access-key }} + INPUT_SECRET_KEY: ${{ inputs.secret-key }} + INPUT_SPACE_NAME: ${{ inputs.space-name }} + INPUT_SPACE_REGION: ${{ inputs.space-region }} + INPUT_BUILD_NUMBER: ${{ inputs.build-number }} + INPUT_PLATFORMS: ${{ inputs.platforms }} + run: bash "$GITHUB_ACTION_PATH/scripts/delete_debug_symbols.sh" diff --git a/delete-debug-symbols/scripts/delete_debug_symbols.sh b/delete-debug-symbols/scripts/delete_debug_symbols.sh new file mode 100644 index 00000000..efc82daa --- /dev/null +++ b/delete-debug-symbols/scripts/delete_debug_symbols.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── input validation ────────────────────────────────────────────────────────── + +build_number="${INPUT_BUILD_NUMBER:-$GITHUB_RUN_ID}" + +if [[ ! "$build_number" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::Invalid build number '$build_number' (allowed: letters, digits, '.', '_', '-')" + exit 1 +fi + +if ! curl --help all 2>/dev/null | grep -q -- '--aws-sigv4'; then + echo "::error::curl on this runner cannot sign S3 requests (needs curl 7.75+), found: $(curl --version | head -1)" + exit 1 +fi + +# ── credentials ─────────────────────────────────────────────────────────────── + +# Passing the keys as --user would expose them in the process list, which +# matters on shared self-hosted runners. A 0600 config file does not. +credentials="${RUNNER_TEMP}/qb-spaces-curl.conf" +(umask 077 && printf 'user = "%s:%s"\n' "$INPUT_ACCESS_KEY" "$INPUT_SECRET_KEY" > "$credentials") +trap 'rm -f "$credentials"' EXIT + +# ── delete ──────────────────────────────────────────────────────────────────── + +endpoint="${SPACES_ENDPOINT:-https://${INPUT_SPACE_NAME}.${INPUT_SPACE_REGION}.digitaloceanspaces.com}" +prefix="${GITHUB_REPOSITORY##*/}/debug-symbols/${build_number}" +deleted=0 + +echo "Deleting debug symbols under '$prefix/'" + +for platform in $INPUT_PLATFORMS; do + archive="debug-symbols-${platform}.tar.gz" + + curl_status=0 + status="$(curl --silent --show-error --config "$credentials" \ + --request DELETE \ + --aws-sigv4 "aws:amz:${INPUT_SPACE_REGION}:s3" \ + --output /dev/null --write-out '%{http_code}' \ + "${endpoint}/${prefix}/${archive}")" || curl_status=$? + + if [[ "$curl_status" -ne 0 ]]; then + echo "::warning::Could not reach ${endpoint} to delete '$archive' (curl exit $curl_status)" + continue + fi + + # S3 deletes are idempotent, so a missing key answers 204 just like a hit. + case "$status" in + 200|204|404) + deleted=$((deleted + 1)) + echo "Deleted $archive" + ;; + *) + echo "::warning::Deleting '$archive' answered HTTP $status - it will stay in the Space" + ;; + esac +done + +echo "Deleted $deleted of $(echo "$INPUT_PLATFORMS" | wc -w | tr -d ' ') key(s)" diff --git a/delete-debug-symbols/tests/delete_debug_symbols.bats b/delete-debug-symbols/tests/delete_debug_symbols.bats new file mode 100644 index 00000000..17ea24b7 --- /dev/null +++ b/delete-debug-symbols/tests/delete_debug_symbols.bats @@ -0,0 +1,90 @@ +#!/usr/bin/env bats + +load "setup.bash" + +setup() { + start_space +} + +teardown() { + stop_space +} + +@test "deletes the archive of every platform" { + publish_archive "ios" + publish_archive "android-apk" + publish_archive "android-aab" + run_delete + [ "$status" -eq 0 ] + [ -z "$(remaining_keys)" ] +} + +@test "deletes only the platforms it was given" { + publish_archive "ios" + publish_archive "android-aab" + INPUT_PLATFORMS="ios" \ + run_delete + [ "$status" -eq 0 ] + [ "$(remaining_keys)" = "debug-symbols-android-aab.tar.gz" ] +} + +@test "a platform that was never stored is not an error" { + publish_archive "ios" + run_delete + [ "$status" -eq 0 ] + [ -z "$(remaining_keys)" ] +} + +@test "reports how many keys it removed" { + publish_archive "ios" + INPUT_PLATFORMS="ios android-aab" \ + run_delete + [ "$status" -eq 0 ] + [[ "$output" == *"Deleted 2 of 2 key(s)"* ]] +} + +@test "leaves the archives of other builds alone" { + publish_archive "ios" + mkdir -p "${SPACE_ROOT}/kaarlo-mobile/debug-symbols/1700000000" + echo "other" > "${SPACE_ROOT}/kaarlo-mobile/debug-symbols/1700000000/debug-symbols-ios.tar.gz" + run_delete + [ "$status" -eq 0 ] + [ -f "${SPACE_ROOT}/kaarlo-mobile/debug-symbols/1700000000/debug-symbols-ios.tar.gz" ] +} + +@test "a rejected delete warns instead of failing the job" { + publish_archive "ios" + INPUT_PLATFORMS="forbidden" \ + run_delete + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::Deleting 'debug-symbols-forbidden.tar.gz' answered HTTP 403"* ]] +} + +@test "an unreachable Space warns instead of failing the job" { + stop_space + SPACES_ENDPOINT="http://127.0.0.1:${SPACE_PORT}" \ + run_delete + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::Could not reach"* ]] +} + +@test "an invalid build number is rejected before any request" { + INPUT_BUILD_NUMBER="../../etc" \ + run_delete + [ "$status" -eq 1 ] + [[ "$output" == *"::error::Invalid build number"* ]] +} + +@test "the build number falls back to the run id" { + INPUT_BUILD_NUMBER="" \ + run_delete + [ "$status" -eq 0 ] + [[ "$output" == *"kaarlo-mobile/debug-symbols/16512345678/"* ]] +} + +@test "the credentials file is removed when the script exits" { + publish_archive "ios" + run_delete + [ "$status" -eq 0 ] + [ ! -f "${BATS_TEST_TMPDIR}/temp/qb-spaces-curl.conf" ] +} diff --git a/delete-debug-symbols/tests/fake_space.py b/delete-debug-symbols/tests/fake_space.py new file mode 100644 index 00000000..78e0ba4e --- /dev/null +++ b/delete-debug-symbols/tests/fake_space.py @@ -0,0 +1,50 @@ +"""Minimal stand-in for a DO Space: serves GET and DELETE over a directory. + +Usage: fake_space.py + +A key whose name contains "forbidden" answers 403, so the error path can be +exercised. Deletes are idempotent, the way S3 behaves. +""" +import http.server +import os +import sys + +ROOT = os.path.abspath(sys.argv[1]) +PORT = int(sys.argv[2]) + + +class Handler(http.server.BaseHTTPRequestHandler): + def _local_path(self): + return os.path.join(ROOT, self.path.lstrip("/").split("?")[0]) + + def _empty(self, status): + self.send_response(status) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + path = self._local_path() + if "forbidden" in self.path: + return self._empty(403) + if not os.path.isfile(path): + return self._empty(404) + with open(path, "rb") as handle: + body = handle.read() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_DELETE(self): + if "forbidden" in self.path: + return self._empty(403) + path = self._local_path() + if os.path.isfile(path): + os.remove(path) + self._empty(204) + + def log_message(self, *args): + pass + + +http.server.HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/delete-debug-symbols/tests/setup.bash b/delete-debug-symbols/tests/setup.bash new file mode 100644 index 00000000..ecce289a --- /dev/null +++ b/delete-debug-symbols/tests/setup.bash @@ -0,0 +1,56 @@ +TESTS_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)" +SCRIPT="${TESTS_DIR}/../scripts/delete_debug_symbols.sh" +FAKE_SPACE="${TESTS_DIR}/fake_space.py" + +BUILD_NUMBER="1764500000" +KEY_PREFIX="kaarlo-mobile/debug-symbols/${BUILD_NUMBER}" + +start_space() { + SPACE_ROOT="${BATS_TEST_TMPDIR}/space" + mkdir -p "${SPACE_ROOT}/${KEY_PREFIX}" + SPACE_PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" + SPACES_ENDPOINT="http://127.0.0.1:${SPACE_PORT}" + + python3 "$FAKE_SPACE" "$SPACE_ROOT" "$SPACE_PORT" >/dev/null 2>&1 & + SPACE_PID="$!" + + local attempt=0 + until curl --silent --output /dev/null "${SPACES_ENDPOINT}/"; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 50 ] || { + echo "the test HTTP server did not come up" >&2 + return 1 + } + sleep 0.1 + done +} + +stop_space() { + [ -n "${SPACE_PID:-}" ] && kill "$SPACE_PID" 2>/dev/null + return 0 +} + +publish_archive() { + local platform="$1" + echo "archive" > "${SPACE_ROOT}/${KEY_PREFIX}/debug-symbols-${platform}.tar.gz" +} + +run_delete() { + mkdir -p "${BATS_TEST_TMPDIR}/temp" + run env \ + RUNNER_TEMP="${BATS_TEST_TMPDIR}/temp" \ + GITHUB_REPOSITORY="QuickBirdEng/kaarlo-mobile" \ + GITHUB_RUN_ID="16512345678" \ + SPACES_ENDPOINT="${SPACES_ENDPOINT:-}" \ + INPUT_ACCESS_KEY="DO00ACCESSKEY" \ + INPUT_SECRET_KEY="s3cr3t/key+with=chars" \ + INPUT_SPACE_NAME="quickbird-artifacts" \ + INPUT_SPACE_REGION="fra1" \ + INPUT_BUILD_NUMBER="${INPUT_BUILD_NUMBER-$BUILD_NUMBER}" \ + INPUT_PLATFORMS="${INPUT_PLATFORMS-ios android-apk android-aab}" \ + bash "$SCRIPT" +} + +remaining_keys() { + ls -1 "${SPACE_ROOT}/${KEY_PREFIX}" 2>/dev/null | sort +} diff --git a/restore-debug-symbols/action.yml b/restore-debug-symbols/action.yml new file mode 100644 index 00000000..78eab665 --- /dev/null +++ b/restore-debug-symbols/action.yml @@ -0,0 +1,66 @@ +name: 'Restore debug symbols' +description: 'Download the debug symbols that store-debug-symbols put into QB Spaces for a build and extract them for the Sentry upload' +inputs: + access-key: + description: 'Digital Ocean Access Key' + required: true + secret-key: + description: 'Digital Ocean Secret Key' + required: true + space-name: + description: 'Name of the DO Space the symbols were stored in' + required: false + default: 'quickbird-artifacts' + space-region: + description: 'Region of the DO Space' + required: false + default: 'fra1' + build-number: + description: 'Build number the symbols were stored for. Must be the same one the build jobs passed to store-debug-symbols. Falls back to the workflow run id.' + required: false + default: '' + platforms: + description: 'Space separated platform labels to look for. Platforms without stored symbols are skipped.' + required: false + default: 'ios android-apk android-aab' + destination: + description: 'Directory to extract the symbols into' + required: false + default: 'debug-symbols' + fail-if-empty: + description: 'Fail if no symbols were found for the build' + required: false + default: 'true' +outputs: + found: + description: 'Whether any symbols were restored' + value: ${{ steps.extract.outputs.found }} + symbols-dir: + description: 'Directory the symbols were extracted into (one folder per platform)' + value: ${{ steps.extract.outputs.symbols-dir }} + platforms: + description: 'Space separated list of the platforms that were restored' + value: ${{ steps.extract.outputs.platforms }} + release: + description: 'The Sentry release name that was stored with the symbols (empty if none)' + value: ${{ steps.extract.outputs.release }} +runs: + using: "composite" + steps: + - name: Download debug symbols from QB Spaces + shell: bash + env: + INPUT_ACCESS_KEY: ${{ inputs.access-key }} + INPUT_SECRET_KEY: ${{ inputs.secret-key }} + INPUT_SPACE_NAME: ${{ inputs.space-name }} + INPUT_SPACE_REGION: ${{ inputs.space-region }} + INPUT_BUILD_NUMBER: ${{ inputs.build-number }} + INPUT_PLATFORMS: ${{ inputs.platforms }} + run: bash "$GITHUB_ACTION_PATH/scripts/download_debug_symbols.sh" + - id: extract + name: Extract debug symbols + shell: bash + env: + INPUT_DESTINATION: ${{ inputs.destination }} + INPUT_FAIL_IF_EMPTY: ${{ inputs.fail-if-empty }} + run: bash "$GITHUB_ACTION_PATH/scripts/extract_debug_symbols.sh" diff --git a/restore-debug-symbols/scripts/download_debug_symbols.sh b/restore-debug-symbols/scripts/download_debug_symbols.sh new file mode 100644 index 00000000..3e0b076c --- /dev/null +++ b/restore-debug-symbols/scripts/download_debug_symbols.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── input validation ────────────────────────────────────────────────────────── + +# The build number becomes a path segment in the Space, so keep it to safe +# characters. It stays the same across a re-run of a single job. +build_number="${INPUT_BUILD_NUMBER:-$GITHUB_RUN_ID}" + +if [[ ! "$build_number" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::Invalid build number '$build_number' (allowed: letters, digits, '.', '_', '-')" + exit 1 +fi + +# ── curl capability ─────────────────────────────────────────────────────────── + +# curl signs the S3 requests itself since 7.75. Fail with a clear message +# instead of a confusing 403 when the runner ships something older. +if ! curl --help all 2>/dev/null | grep -q -- '--aws-sigv4'; then + echo "::error::curl on this runner cannot sign S3 requests (needs curl 7.75+), found: $(curl --version | head -1)" + exit 1 +fi + +# ── credentials ─────────────────────────────────────────────────────────────── + +# Passing the keys as --user would expose them in the process list, which +# matters on shared self-hosted runners. A 0600 config file does not. +credentials="${RUNNER_TEMP}/qb-spaces-curl.conf" +(umask 077 && printf 'user = "%s:%s"\n' "$INPUT_ACCESS_KEY" "$INPUT_SECRET_KEY" > "$credentials") +trap 'rm -f "$credentials"' EXIT + +download_dir="${RUNNER_TEMP}/qb-debug-symbols-download" +rm -rf "$download_dir" +mkdir -p "$download_dir" + +endpoint="${SPACES_ENDPOINT:-https://${INPUT_SPACE_NAME}.${INPUT_SPACE_REGION}.digitaloceanspaces.com}" +prefix="${GITHUB_REPOSITORY##*/}/debug-symbols/${build_number}" +downloaded=0 + +# ── download ────────────────────────────────────────────────────────────────── + +echo "Looking for debug symbols under '$prefix/'" + +for platform in $INPUT_PLATFORMS; do + archive="debug-symbols-${platform}.tar.gz" + target="${download_dir}/${archive}" + + curl_status=0 + status="$(curl --silent --show-error --config "$credentials" \ + --aws-sigv4 "aws:amz:${INPUT_SPACE_REGION}:s3" \ + --output "$target" --write-out '%{http_code}' \ + "${endpoint}/${prefix}/${archive}")" || curl_status=$? + + if [[ "$curl_status" -ne 0 ]]; then + echo "::error::Downloading '$archive' failed, could not reach ${endpoint} (curl exit $curl_status)" + exit 1 + fi + + case "$status" in + 200) + downloaded=$((downloaded + 1)) + echo "Downloaded $archive" + ;; + 404) + rm -f "$target" + echo "No symbols stored for '$platform'" + ;; + *) + echo "::error::Downloading '$archive' failed with HTTP $status" + cat "$target" || true + exit 1 + ;; + esac +done + +echo "Downloaded $downloaded archive(s)" diff --git a/restore-debug-symbols/scripts/extract_debug_symbols.sh b/restore-debug-symbols/scripts/extract_debug_symbols.sh new file mode 100644 index 00000000..29bb4f3b --- /dev/null +++ b/restore-debug-symbols/scripts/extract_debug_symbols.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── input validation ────────────────────────────────────────────────────────── + +# The destination is wiped before extracting, so refuse an empty value instead +# of relying on rm and mkdir to fail on it. A declared default only applies when +# a caller omits the input, not when it passes an expression that renders empty. +if [[ -z "${INPUT_DESTINATION:-}" ]]; then + echo "::error::destination must not be empty" + exit 1 +fi + +download_dir="${RUNNER_TEMP}/qb-debug-symbols-download" +symbols_dir="${INPUT_DESTINATION}" + +rm -rf "$symbols_dir" +mkdir -p "$symbols_dir" + +platforms="" +release="" + +# ── extract one folder per platform ─────────────────────────────────────────── + +for archive in "$download_dir"/debug-symbols-*.tar.gz; do + [[ -f "$archive" ]] || continue + + name="$(basename "$archive" .tar.gz)" + platform="${name#debug-symbols-}" + target="${symbols_dir}/${platform}" + + mkdir -p "$target" + tar -xzf "$archive" -C "$target" + platforms="${platforms:+$platforms }$platform" + echo "Restored '$platform' from $(basename "$archive")" + + if [[ -z "$release" && -f "$target/metadata.env" ]]; then + release="$(sed -n 's/^release=//p' "$target/metadata.env" | head -1)" + fi +done + +# ── nothing restored ────────────────────────────────────────────────────────── + +if [[ -z "$platforms" ]]; then + { + echo "found=false" + echo "platforms=" + echo "release=" + echo "symbols-dir=$symbols_dir" + } >> "$GITHUB_OUTPUT" + + if [[ "${INPUT_FAIL_IF_EMPTY}" == "true" ]]; then + echo "::error::No debug symbols were stored for this build - check the 'Store debug symbols for Sentry' step of the build jobs" + exit 1 + fi + + echo "::warning::No debug symbols were stored for this build" + exit 0 +fi + +{ + echo "found=true" + echo "platforms=$platforms" + echo "release=$release" + echo "symbols-dir=$symbols_dir" +} >> "$GITHUB_OUTPUT" + +echo "Restored platforms: $platforms" +echo "Sentry release: ${release:-}" diff --git a/restore-debug-symbols/tests/download_debug_symbols.bats b/restore-debug-symbols/tests/download_debug_symbols.bats new file mode 100644 index 00000000..04d57ada --- /dev/null +++ b/restore-debug-symbols/tests/download_debug_symbols.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats + +load "setup.bash" + +setup() { + start_space +} + +teardown() { + stop_space +} + +@test "downloads the archive of every platform that has stored symbols" { + publish_archive "ios" + publish_archive "android-aab" + run_download + [ "$status" -eq 0 ] + [[ "$output" == *"Downloaded 2 archive(s)"* ]] + [ "$(downloaded_archives)" = "debug-symbols-android-aab.tar.gz +debug-symbols-ios.tar.gz" ] +} + +@test "a platform without stored symbols is skipped, not failed" { + publish_archive "ios" + run_download + [ "$status" -eq 0 ] + [[ "$output" == *"No symbols stored for 'android-apk'"* ]] + [[ "$output" == *"No symbols stored for 'android-aab'"* ]] +} + +@test "a 404 does not leave a truncated archive behind" { + publish_archive "ios" + run_download + [ "$status" -eq 0 ] + [ "$(downloaded_archives)" = "debug-symbols-ios.tar.gz" ] +} + +@test "the downloaded archive is intact" { + publish_archive "ios" + run_download + [ "$status" -eq 0 ] + tar -tzf "${BATS_TEST_TMPDIR}/temp/qb-debug-symbols-download/debug-symbols-ios.tar.gz" >/dev/null +} + +@test "requests are signed with SigV4" { + publish_archive "ios" + run_download + [ "$status" -eq 0 ] + # An unsigned request would not carry credentials at all; assert curl can sign. + run curl --silent --show-error --aws-sigv4 "aws:amz:fra1:s3" --user "key:secret" \ + --output /dev/null --write-out '%{http_code}' "${SPACES_ENDPOINT}/${KEY_PREFIX}/debug-symbols-ios.tar.gz" + [ "$output" = "200" ] +} + +@test "nothing stored at all still succeeds with zero archives" { + run_download + [ "$status" -eq 0 ] + [[ "$output" == *"Downloaded 0 archive(s)"* ]] + [ -z "$(downloaded_archives)" ] +} + +@test "an unreachable Space fails the step with a clear error" { + stop_space + SPACES_ENDPOINT="http://127.0.0.1:${SPACE_PORT}" \ + run_download + [ "$status" -ne 0 ] + [[ "$output" == *"::error::Downloading"* ]] +} + +@test "an invalid build number is rejected before any request" { + INPUT_BUILD_NUMBER="../../etc" \ + run_download + [ "$status" -eq 1 ] + [[ "$output" == *"::error::Invalid build number"* ]] +} + +@test "the build number falls back to the run id" { + INPUT_BUILD_NUMBER="" \ + run_download + [ "$status" -eq 0 ] + [[ "$output" == *"kaarlo-mobile/debug-symbols/16512345678/"* ]] +} + +@test "the credentials file is removed when the script exits" { + publish_archive "ios" + run_download + [ "$status" -eq 0 ] + [ ! -f "${BATS_TEST_TMPDIR}/temp/qb-spaces-curl.conf" ] +} + +@test "only the requested platforms are looked for" { + publish_archive "ios" + publish_archive "android-aab" + INPUT_PLATFORMS="ios" \ + run_download + [ "$status" -eq 0 ] + [ "$(downloaded_archives)" = "debug-symbols-ios.tar.gz" ] +} diff --git a/restore-debug-symbols/tests/extract_debug_symbols.bats b/restore-debug-symbols/tests/extract_debug_symbols.bats new file mode 100644 index 00000000..7522b1dd --- /dev/null +++ b/restore-debug-symbols/tests/extract_debug_symbols.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats + +load "setup.bash" + +setup() { + DOWNLOAD_DIR="${BATS_TEST_TMPDIR}/temp/qb-debug-symbols-download" + mkdir -p "$DOWNLOAD_DIR" +} + +stage_downloaded() { + make_archive "$1" "${DOWNLOAD_DIR}/debug-symbols-$1.tar.gz" ${2+"$2"} +} + +@test "extracts every archive into a folder named after its platform" { + stage_downloaded "ios" + stage_downloaded "android-aab" + run_extract + [ "$status" -eq 0 ] + [ "$(github_output_value found)" = "true" ] + [ "$(github_output_value platforms)" = "android-aab ios" ] + [ -f "${WORKSPACE}/debug-symbols/ios/dsyms/App" ] + [ -f "${WORKSPACE}/debug-symbols/android-aab/proguard/mapping.txt" ] +} + +@test "reports the directory it extracted into" { + stage_downloaded "ios" + run_extract + [ "$status" -eq 0 ] + [ "$(github_output_value symbols-dir)" = "debug-symbols" ] +} + +@test "honours a custom destination" { + stage_downloaded "ios" + INPUT_DESTINATION="symbols" \ + run_extract + [ "$status" -eq 0 ] + [ -f "${WORKSPACE}/symbols/ios/dsyms/App" ] + [ "$(github_output_value symbols-dir)" = "symbols" ] +} + +@test "recovers the release name from the archive metadata" { + stage_downloaded "ios" "2.0.0+1764500000" + run_extract + [ "$status" -eq 0 ] + [ "$(github_output_value release)" = "2.0.0+1764500000" ] +} + +@test "an archive without a release name yields an empty release" { + stage_downloaded "ios" "" + run_extract + [ "$status" -eq 0 ] + [ -z "$(github_output_value release)" ] +} + +@test "a stale destination from a previous attempt is cleared" { + stage_downloaded "ios" + run_extract + [ "$status" -eq 0 ] + touch "${WORKSPACE}/debug-symbols/stale.txt" + run_extract + [ "$status" -eq 0 ] + [ ! -f "${WORKSPACE}/debug-symbols/stale.txt" ] +} + +@test "an empty destination is rejected before anything is deleted" { + stage_downloaded "ios" + INPUT_DESTINATION="" \ + run_extract + [ "$status" -eq 1 ] + [[ "$output" == *"::error::destination must not be empty"* ]] + # the working directory must be untouched + [ -d "${WORKSPACE}" ] + [ -f "${DOWNLOAD_DIR}/debug-symbols-ios.tar.gz" ] +} + +@test "fails when nothing was downloaded" { + run_extract + [ "$status" -eq 1 ] + [ "$(github_output_value found)" = "false" ] + [[ "$output" == *"::error::No debug symbols were stored for this build"* ]] +} + +@test "warns instead of failing when fail-if-empty is false" { + INPUT_FAIL_IF_EMPTY="false" \ + run_extract + [ "$status" -eq 0 ] + [ "$(github_output_value found)" = "false" ] + [[ "$output" == *"::warning::No debug symbols were stored for this build"* ]] +} diff --git a/restore-debug-symbols/tests/setup.bash b/restore-debug-symbols/tests/setup.bash new file mode 100644 index 00000000..c380f4d0 --- /dev/null +++ b/restore-debug-symbols/tests/setup.bash @@ -0,0 +1,98 @@ +TESTS_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)" +DOWNLOAD_SCRIPT="${TESTS_DIR}/../scripts/download_debug_symbols.sh" +EXTRACT_SCRIPT="${TESTS_DIR}/../scripts/extract_debug_symbols.sh" + +BUILD_NUMBER="1764500000" +KEY_PREFIX="kaarlo-mobile/debug-symbols/${BUILD_NUMBER}" + +# Build an archive with the layout store-debug-symbols produces. +make_archive() { + local platform="$1" target="$2" release="${3-1.4.0+${BUILD_NUMBER}}" + local staging="${BATS_TEST_TMPDIR}/staging-${platform}" + + rm -rf "$staging" + mkdir -p "${staging}/dsyms" "${staging}/dart-symbols" "${staging}/proguard" + echo "dwarf" > "${staging}/dsyms/App" + echo "symbols" > "${staging}/dart-symbols/app.${platform}.symbols" + echo '{"a":"b"}' > "${staging}/dart-symbols/obfuscation.map.json" + echo "mapping" > "${staging}/proguard/mapping.txt" + printf 'platform=%s\nrelease=%s\nbuild_number=%s\n' "$platform" "$release" "$BUILD_NUMBER" > "${staging}/metadata.env" + + mkdir -p "$(dirname "$target")" + tar -C "$staging" -czf "$target" . +} + +# Serve a directory over HTTP so the download script can be exercised end to end. +start_space() { + SPACE_ROOT="${BATS_TEST_TMPDIR}/space" + mkdir -p "$SPACE_ROOT" + SPACE_PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" + SPACES_ENDPOINT="http://127.0.0.1:${SPACE_PORT}" + + python3 -m http.server "$SPACE_PORT" --bind 127.0.0.1 --directory "$SPACE_ROOT" >/dev/null 2>&1 & + SPACE_PID="$!" + + local attempt=0 + until curl --silent --output /dev/null "${SPACES_ENDPOINT}/"; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 50 ] || { + echo "the test HTTP server did not come up" >&2 + return 1 + } + sleep 0.1 + done +} + +stop_space() { + [ -n "${SPACE_PID:-}" ] && kill "$SPACE_PID" 2>/dev/null + return 0 +} + +# Put an archive into the served "Space" under the key the action looks for. +publish_archive() { + local platform="$1" + make_archive "$platform" "${SPACE_ROOT}/${KEY_PREFIX}/debug-symbols-${platform}.tar.gz" +} + +run_download() { + mkdir -p "${BATS_TEST_TMPDIR}/temp" + run env \ + RUNNER_TEMP="${BATS_TEST_TMPDIR}/temp" \ + GITHUB_REPOSITORY="QuickBirdEng/kaarlo-mobile" \ + GITHUB_RUN_ID="16512345678" \ + SPACES_ENDPOINT="${SPACES_ENDPOINT:-}" \ + INPUT_ACCESS_KEY="DO00ACCESSKEY" \ + INPUT_SECRET_KEY="s3cr3t/key+with=chars" \ + INPUT_SPACE_NAME="quickbird-artifacts" \ + INPUT_SPACE_REGION="fra1" \ + INPUT_BUILD_NUMBER="${INPUT_BUILD_NUMBER-$BUILD_NUMBER}" \ + INPUT_PLATFORMS="${INPUT_PLATFORMS-ios android-apk android-aab}" \ + bash "$DOWNLOAD_SCRIPT" +} + +run_extract() { + mkdir -p "${BATS_TEST_TMPDIR}/temp" + GITHUB_OUTPUT_FILE="$(mktemp)" + WORKSPACE="${BATS_TEST_TMPDIR}/workspace" + mkdir -p "$WORKSPACE" + cd "$WORKSPACE" + run env \ + RUNNER_TEMP="${BATS_TEST_TMPDIR}/temp" \ + GITHUB_OUTPUT="${GITHUB_OUTPUT_FILE}" \ + INPUT_DESTINATION="${INPUT_DESTINATION-debug-symbols}" \ + INPUT_FAIL_IF_EMPTY="${INPUT_FAIL_IF_EMPTY-true}" \ + bash "$EXTRACT_SCRIPT" + if [ -s "$GITHUB_OUTPUT_FILE" ]; then + output="${output}"$'\n'"$(cat "$GITHUB_OUTPUT_FILE")" + fi +} + +# Return the value of a key written to GITHUB_OUTPUT by the script. +github_output_value() { + local key="$1" + grep "^${key}=" "$GITHUB_OUTPUT_FILE" | tail -1 | cut -d= -f2- +} + +downloaded_archives() { + ls -1 "${BATS_TEST_TMPDIR}/temp/qb-debug-symbols-download" 2>/dev/null | sort +} diff --git a/store-debug-symbols/action.yml b/store-debug-symbols/action.yml new file mode 100644 index 00000000..51a9818a --- /dev/null +++ b/store-debug-symbols/action.yml @@ -0,0 +1,84 @@ +name: 'Store debug symbols' +description: 'Collect debug symbols (dSYMs, ProGuard mapping, Dart symbols) into one archive and upload it to QB Spaces, so a separate job can push them to Sentry' +inputs: + platform: + description: 'Label of the build these symbols belong to (e.g. ios, android-apk, android-aab). Becomes the folder name the Sentry job restores them into.' + required: true + build-number: + description: 'Build number of this build. Groups the symbols of all platforms of one build together, so the Sentry job can pick them up again. Falls back to the workflow run id.' + required: false + default: '' + dsyms-path: + description: 'The path to the dSYMs folder' + required: false + default: '' + proguard-mapping-file-path: + description: 'The path to the proguard mapping file' + required: false + default: '' + dart-symbols-file-path: + description: 'The path to the dart symbols folder' + required: false + default: '' + dart-obfuscation-map-file-path: + description: 'The path to the dart obfuscation map JSON file' + required: false + default: '' + release: + description: 'Sentry release name (e.g. 1.0.0+12345). Stored with the symbols and used by the Sentry job to create and finalize the release.' + required: false + default: '' + access-key: + description: 'Digital Ocean Access Key' + required: true + secret-key: + description: 'Digital Ocean Secret Key' + required: true + space-name: + description: 'Name of the DO Space to store the symbols in' + required: false + default: 'quickbird-artifacts' + space-region: + description: 'Region of the DO Space' + required: false + default: 'fra1' +outputs: + stored: + description: 'Whether an archive was uploaded' + value: ${{ steps.collect.outputs.stored }} + key-prefix: + description: 'The prefix in the Space the archive was uploaded to' + value: ${{ steps.collect.outputs.key-prefix }} + archive: + description: 'The name of the archive that was uploaded, relative to the workspace' + value: ${{ steps.collect.outputs.archive }} +runs: + using: "composite" + steps: + - id: collect + name: Collect debug symbols + shell: bash + env: + INPUT_PLATFORM: ${{ inputs.platform }} + INPUT_BUILD_NUMBER: ${{ inputs.build-number }} + INPUT_DSYMS_PATH: ${{ inputs.dsyms-path }} + INPUT_PROGUARD_MAPPING_FILE_PATH: ${{ inputs.proguard-mapping-file-path }} + INPUT_DART_SYMBOLS_FILE_PATH: ${{ inputs.dart-symbols-file-path }} + INPUT_DART_OBFUSCATION_MAP_FILE_PATH: ${{ inputs.dart-obfuscation-map-file-path }} + INPUT_RELEASE: ${{ inputs.release }} + run: bash "$GITHUB_ACTION_PATH/scripts/store_debug_symbols.sh" + - name: Upload debug symbols to QB Spaces + if: ${{ steps.collect.outputs.stored == 'true' }} + uses: BetaHuhn/do-spaces-action@latest + with: + access_key: ${{ inputs.access-key }} + secret_key: ${{ inputs.secret-key }} + space_name: ${{ inputs.space-name }} + space_region: ${{ inputs.space-region }} + source: ${{ steps.collect.outputs.archive }} + out_dir: ${{ steps.collect.outputs.key-prefix }} + permission: private + - name: Remove the archive from the workspace + if: ${{ always() && steps.collect.outputs.stored == 'true' }} + shell: bash + run: rm -f "$GITHUB_WORKSPACE/${{ steps.collect.outputs.archive }}" diff --git a/store-debug-symbols/scripts/store_debug_symbols.sh b/store-debug-symbols/scripts/store_debug_symbols.sh new file mode 100644 index 00000000..8cf87805 --- /dev/null +++ b/store-debug-symbols/scripts/store_debug_symbols.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── input validation ────────────────────────────────────────────────────────── + +platform="${INPUT_PLATFORM:-}" +build_number="${INPUT_BUILD_NUMBER:-$GITHUB_RUN_ID}" + +if [[ ! "$platform" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::Invalid platform label '$platform' (allowed: letters, digits, '.', '_', '-')" + exit 1 +fi + +if [[ ! "$build_number" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "::error::Invalid build number '$build_number' (allowed: letters, digits, '.', '_', '-')" + exit 1 +fi + +# ── staging area ────────────────────────────────────────────────────────────── + +staging="${RUNNER_TEMP}/qb-debug-symbols-staging/${platform}" +rm -rf "$staging" +mkdir -p "$staging" + +requested=0 +collected=0 + +# ── helpers ─────────────────────────────────────────────────────────────────── + +# Both helpers count what was asked for and what was actually there, so a build +# that stored nothing can be told apart from one that was asked for nothing. +stage_dir() { + local source="$1" target="$2" label="$3" + requested=$((requested + 1)) + if [[ -d "$source" ]] && [[ -n "$(ls -A "$source")" ]]; then + mkdir -p "$staging/$target" + cp -R "$source"/. "$staging/$target"/ + collected=$((collected + 1)) + echo "Collected $label from $source" + else + echo "::warning::No $label found at '$source' - skipping" + fi +} + +stage_file() { + local source="$1" target="$2" label="$3" + requested=$((requested + 1)) + if [[ -f "$source" ]]; then + mkdir -p "$staging/$(dirname "$target")" + cp "$source" "$staging/$target" + collected=$((collected + 1)) + echo "Collected $label from $source" + else + echo "::warning::No $label found at '$source' - skipping" + fi +} + +# ── collect ─────────────────────────────────────────────────────────────────── + +if [[ -n "${INPUT_DSYMS_PATH:-}" ]]; then + stage_dir "$INPUT_DSYMS_PATH" "dsyms" "iOS dSYMs" +fi + +if [[ -n "${INPUT_DART_SYMBOLS_FILE_PATH:-}" ]]; then + stage_dir "$INPUT_DART_SYMBOLS_FILE_PATH" "dart-symbols" "Dart symbols" +fi + +if [[ -n "${INPUT_DART_OBFUSCATION_MAP_FILE_PATH:-}" ]]; then + stage_file "$INPUT_DART_OBFUSCATION_MAP_FILE_PATH" "dart-symbols/obfuscation.map.json" "Dart obfuscation map" +fi + +if [[ -n "${INPUT_PROGUARD_MAPPING_FILE_PATH:-}" ]]; then + stage_file "$INPUT_PROGUARD_MAPPING_FILE_PATH" "proguard/mapping.txt" "ProGuard mapping" +fi + +if [[ "$collected" -eq 0 ]]; then + echo "stored=false" >> "$GITHUB_OUTPUT" + if [[ "$requested" -gt 0 ]]; then + echo "::error::None of the $requested requested debug symbol paths exist - nothing to store for '$platform'" + exit 1 + fi + echo "::warning::No debug symbol paths given for '$platform' - nothing to store" + exit 0 +fi + +# ── metadata travelling with the archive ────────────────────────────────────── + +{ + echo "platform=$platform" + echo "release=${INPUT_RELEASE:-}" + echo "build_number=$build_number" + echo "run_id=${GITHUB_RUN_ID}" + echo "run_attempt=${GITHUB_RUN_ATTEMPT}" + echo "sha=${GITHUB_SHA}" + echo "ref=${GITHUB_REF}" +} > "$staging/metadata.env" + +# ── archive ─────────────────────────────────────────────────────────────────── + +# The upload action resolves its source relative to the workspace and would +# prepend the workspace to an absolute path, so keep the archive in the +# workspace and hand out a workspace-relative name. +archive_name="debug-symbols-${platform}.tar.gz" +archive="${GITHUB_WORKSPACE}/${archive_name}" +rm -f "$archive" +tar -C "$staging" -czf "$archive" . + +{ + echo "stored=true" + echo "archive=$archive_name" + echo "key-prefix=${GITHUB_REPOSITORY##*/}/debug-symbols/${build_number}" +} >> "$GITHUB_OUTPUT" + +echo "Archive: $(du -h "$archive" | cut -f1) at $archive" +tar -tzf "$archive" diff --git a/store-debug-symbols/tests/setup.bash b/store-debug-symbols/tests/setup.bash new file mode 100644 index 00000000..03815225 --- /dev/null +++ b/store-debug-symbols/tests/setup.bash @@ -0,0 +1,49 @@ +TESTS_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)" +SCRIPT="${TESTS_DIR}/../scripts/store_debug_symbols.sh" + +setup_build_outputs() { + WORKSPACE="${BATS_TEST_TMPDIR}/workspace" + mkdir -p "${WORKSPACE}/build/ios/archive/Runner.xcarchive/dSYMs/Runner.app.dSYM/Contents/Resources/DWARF" + echo "dwarf" > "${WORKSPACE}/build/ios/archive/Runner.xcarchive/dSYMs/Runner.app.dSYM/Contents/Resources/DWARF/Runner" + mkdir -p "${WORKSPACE}/app_symbols" + echo "symbols" > "${WORKSPACE}/app_symbols/app.ios-arm64.symbols" + echo '{"a":"b"}' > "${WORKSPACE}/app_symbols/obfuscation.map.json" + mkdir -p "${WORKSPACE}/build/app/outputs/mapping/release" + echo "com.example.App -> a.a:" > "${WORKSPACE}/build/app/outputs/mapping/release/mapping.txt" +} + +run_store() { + GITHUB_OUTPUT_FILE="$(mktemp)" + cd "${WORKSPACE}" + run env \ + RUNNER_TEMP="${BATS_TEST_TMPDIR}/temp" \ + GITHUB_OUTPUT="${GITHUB_OUTPUT_FILE}" \ + GITHUB_REPOSITORY="QuickBirdEng/kaarlo-mobile" \ + GITHUB_RUN_ID="16512345678" \ + GITHUB_RUN_ATTEMPT="1" \ + GITHUB_SHA="deadbeef" \ + GITHUB_REF="refs/tags/1.4.0" \ + GITHUB_WORKSPACE="${WORKSPACE}" \ + INPUT_PLATFORM="${INPUT_PLATFORM-ios}" \ + INPUT_BUILD_NUMBER="${INPUT_BUILD_NUMBER:-}" \ + INPUT_DSYMS_PATH="${INPUT_DSYMS_PATH:-}" \ + INPUT_PROGUARD_MAPPING_FILE_PATH="${INPUT_PROGUARD_MAPPING_FILE_PATH:-}" \ + INPUT_DART_SYMBOLS_FILE_PATH="${INPUT_DART_SYMBOLS_FILE_PATH:-}" \ + INPUT_DART_OBFUSCATION_MAP_FILE_PATH="${INPUT_DART_OBFUSCATION_MAP_FILE_PATH:-}" \ + INPUT_RELEASE="${INPUT_RELEASE:-}" \ + bash "$SCRIPT" + if [ -s "$GITHUB_OUTPUT_FILE" ]; then + output="${output}"$'\n'"$(cat "$GITHUB_OUTPUT_FILE")" + fi +} + +# Return the value of a key written to GITHUB_OUTPUT by the script. +github_output_value() { + local key="$1" + grep "^${key}=" "$GITHUB_OUTPUT_FILE" | tail -1 | cut -d= -f2- +} + +# List the paths inside the archive the script produced. +archive_contents() { + tar -tzf "${WORKSPACE}/$(github_output_value archive)" | sed 's|^\./||' | grep -v '^$' | sort +} diff --git a/store-debug-symbols/tests/store_debug_symbols.bats b/store-debug-symbols/tests/store_debug_symbols.bats new file mode 100644 index 00000000..06209149 --- /dev/null +++ b/store-debug-symbols/tests/store_debug_symbols.bats @@ -0,0 +1,149 @@ +#!/usr/bin/env bats + +load "setup.bash" + +setup() { + setup_build_outputs +} + +# ── iOS ─────────────────────────────────────────────────────────────────────── + +@test "ios: collects dSYMs, dart symbols and the obfuscation map into one archive" { + INPUT_PLATFORM="ios" \ + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + INPUT_DART_SYMBOLS_FILE_PATH="app_symbols" \ + INPUT_DART_OBFUSCATION_MAP_FILE_PATH="app_symbols/obfuscation.map.json" \ + run_store + [ "$status" -eq 0 ] + [ "$(github_output_value stored)" = "true" ] + archive_contents | grep -q "^dsyms/Runner.app.dSYM/Contents/Resources/DWARF/Runner$" + archive_contents | grep -q "^dart-symbols/app.ios-arm64.symbols$" + archive_contents | grep -q "^dart-symbols/obfuscation.map.json$" + archive_contents | grep -q "^metadata.env$" +} + +@test "ios: does not put a proguard mapping into the archive" { + INPUT_PLATFORM="ios" \ + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + run_store + [ "$status" -eq 0 ] + ! archive_contents | grep -q "proguard" +} + +# ── Android ─────────────────────────────────────────────────────────────────── + +@test "android: collects the proguard mapping under a fixed name" { + INPUT_PLATFORM="android-aab" \ + INPUT_PROGUARD_MAPPING_FILE_PATH="build/app/outputs/mapping/release/mapping.txt" \ + INPUT_DART_SYMBOLS_FILE_PATH="app_symbols" \ + run_store + [ "$status" -eq 0 ] + archive_contents | grep -q "^proguard/mapping.txt$" +} + +@test "the archive is named after the platform" { + INPUT_PLATFORM="android-apk" \ + INPUT_PROGUARD_MAPPING_FILE_PATH="build/app/outputs/mapping/release/mapping.txt" \ + run_store + [ "$status" -eq 0 ] + [ "$(github_output_value archive)" = "debug-symbols-android-apk.tar.gz" ] +} + +# The upload action joins its source onto the workspace, so an absolute path +# would be looked up at and never found. +@test "the archive path is relative to the workspace, not absolute" { + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + run_store + [ "$status" -eq 0 ] + archive="$(github_output_value archive)" + [[ "$archive" != /* ]] + [ -f "${WORKSPACE}/${archive}" ] +} + +# ── Key prefix ──────────────────────────────────────────────────────────────── + +@test "key prefix uses the repository name and the build number" { + INPUT_BUILD_NUMBER="1764500000" \ + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + run_store + [ "$status" -eq 0 ] + [ "$(github_output_value key-prefix)" = "kaarlo-mobile/debug-symbols/1764500000" ] +} + +@test "key prefix falls back to the run id when no build number is given" { + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + run_store + [ "$status" -eq 0 ] + [ "$(github_output_value key-prefix)" = "kaarlo-mobile/debug-symbols/16512345678" ] +} + +@test "an invalid build number is rejected" { + INPUT_BUILD_NUMBER="../../etc" \ + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + run_store + [ "$status" -eq 1 ] + [[ "$output" == *"::error::Invalid build number"* ]] +} + +@test "an empty platform label is rejected" { + INPUT_PLATFORM="" \ + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + run_store + [ "$status" -eq 1 ] + [[ "$output" == *"::error::Invalid platform label"* ]] +} + +@test "an invalid platform label is rejected" { + INPUT_PLATFORM="../evil" \ + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + run_store + [ "$status" -eq 1 ] + [[ "$output" == *"::error::Invalid platform label"* ]] +} + +# ── Metadata ────────────────────────────────────────────────────────────────── + +@test "the release name travels with the archive" { + INPUT_BUILD_NUMBER="1764500000" \ + INPUT_RELEASE="1.4.0+1764500000" \ + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + run_store + [ "$status" -eq 0 ] + tar -xzf "${WORKSPACE}/$(github_output_value archive)" -O ./metadata.env | grep -q "^release=1.4.0+1764500000$" + tar -xzf "${WORKSPACE}/$(github_output_value archive)" -O ./metadata.env | grep -q "^build_number=1764500000$" +} + +# ── Nothing to collect ──────────────────────────────────────────────────────── + +@test "fails when a requested path does not exist" { + INPUT_PROGUARD_MAPPING_FILE_PATH="build/app/outputs/mapping/release/nope.txt" \ + run_store + [ "$status" -eq 1 ] + [ "$(github_output_value stored)" = "false" ] + [[ "$output" == *"::error::None of the 1 requested debug symbol paths exist"* ]] +} + +@test "warns but succeeds when no paths are given at all" { + run_store + [ "$status" -eq 0 ] + [ "$(github_output_value stored)" = "false" ] + [[ "$output" == *"::warning::No debug symbol paths given"* ]] +} + +@test "skips a missing path but still stores what is there" { + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + INPUT_PROGUARD_MAPPING_FILE_PATH="build/app/outputs/mapping/release/nope.txt" \ + run_store + [ "$status" -eq 0 ] + [ "$(github_output_value stored)" = "true" ] + [[ "$output" == *"::warning::No ProGuard mapping found"* ]] + archive_contents | grep -q "^dsyms/" +} + +@test "an empty dSYMs folder counts as missing" { + mkdir -p "${WORKSPACE}/empty-dsyms" + INPUT_DSYMS_PATH="empty-dsyms" \ + run_store + [ "$status" -eq 1 ] + [ "$(github_output_value stored)" = "false" ] +} diff --git a/upload-debug-symbols-to-sentry/action.yml b/upload-debug-symbols-to-sentry/action.yml index cfd8ad34..ba63e7fc 100644 --- a/upload-debug-symbols-to-sentry/action.yml +++ b/upload-debug-symbols-to-sentry/action.yml @@ -14,6 +14,10 @@ inputs: description: 'Organization name' required: false default: '' + symbols-dir: + description: 'Directory holding one folder per platform as created by restore-debug-symbols (/dsyms, /dart-symbols, /proguard/mapping.txt). Alternative to passing the paths below individually.' + required: false + default: '' dsyms-path: description: 'The path to the dsYMs folder' required: false @@ -46,35 +50,25 @@ runs: - name: Sentry CLI Version shell: bash run: sentry-cli --version - - name: Upload Dart Symbols - if: ${{ inputs.dart-symbols-file-path != '' }} - shell: bash - run: | - sentry-cli --url ${{inputs.url}} debug-files upload ${{ inputs.dart-symbols-file-path }} --auth-token ${{inputs.auth-token}} --org ${{inputs.organization}} --project ${{inputs.project}} || true - - name: Upload Dart Obfuscation Map - if: ${{ inputs.dart-obfuscation-map-file-path != '' && inputs.dart-symbols-file-path != '' }} - shell: bash - run: | - for debug_file in ${{ inputs.dart-symbols-file-path }}/*.symbols; do - if [ -f "$debug_file" ]; then - echo "Uploading obfuscation map for $debug_file" - sentry-cli --url ${{inputs.url}} dart-symbol-map upload --auth-token ${{inputs.auth-token}} --org ${{inputs.organization}} --project ${{inputs.project}} ${{ inputs.dart-obfuscation-map-file-path }} "$debug_file" || true - fi - done - - name: Upload Debug Symbols (iOS) - if: ${{ inputs.dsyms-path != '' }} - shell: bash - run: | - sentry-cli --url ${{inputs.url}} debug-files upload ${{inputs.dsyms-path}} --auth-token ${{inputs.auth-token}} --org ${{inputs.organization}} --project ${{inputs.project}} || true - - name: Upload Proguard Mapping (Android) - if: ${{ inputs.proguard-mapping-file-path != '' }} + - name: Upload debug symbols shell: bash - run: | - sentry-cli --url ${{inputs.url}} upload-proguard ${{inputs.proguard-mapping-file-path}} --auth-token ${{inputs.auth-token}} --org ${{inputs.organization}} --project ${{inputs.project}} || true + env: + SENTRY_AUTH_TOKEN: ${{ inputs.auth-token }} + INPUT_URL: ${{ inputs.url }} + INPUT_ORGANIZATION: ${{ inputs.organization }} + INPUT_PROJECT: ${{ inputs.project }} + INPUT_SYMBOLS_DIR: ${{ inputs.symbols-dir }} + INPUT_DSYMS_PATH: ${{ inputs.dsyms-path }} + INPUT_PROGUARD_MAPPING_FILE_PATH: ${{ inputs.proguard-mapping-file-path }} + INPUT_DART_SYMBOLS_FILE_PATH: ${{ inputs.dart-symbols-file-path }} + INPUT_DART_OBFUSCATION_MAP_FILE_PATH: ${{ inputs.dart-obfuscation-map-file-path }} + run: bash "$GITHUB_ACTION_PATH/scripts/upload_debug_symbols_to_sentry.sh" - name: Create & Finalize Sentry Release if: ${{ inputs.release != '' }} shell: bash - run: | - sentry-cli --url ${{ inputs.url }} releases --org ${{ inputs.organization }} new "${{ inputs.release }}" --auth-token ${{ inputs.auth-token }} || true - sentry-cli --url ${{ inputs.url }} releases --org ${{ inputs.organization }} set-commits "${{ inputs.release }}" --auto --auth-token ${{ inputs.auth-token }} || true - sentry-cli --url ${{ inputs.url }} releases --org ${{ inputs.organization }} finalize "${{ inputs.release }}" --auth-token ${{ inputs.auth-token }} || true + env: + SENTRY_AUTH_TOKEN: ${{ inputs.auth-token }} + INPUT_URL: ${{ inputs.url }} + INPUT_ORGANIZATION: ${{ inputs.organization }} + INPUT_RELEASE: ${{ inputs.release }} + run: bash "$GITHUB_ACTION_PATH/scripts/create_sentry_release.sh" diff --git a/upload-debug-symbols-to-sentry/scripts/create_sentry_release.sh b/upload-debug-symbols-to-sentry/scripts/create_sentry_release.sh new file mode 100644 index 00000000..4e9507d7 --- /dev/null +++ b/upload-debug-symbols-to-sentry/scripts/create_sentry_release.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +url="${INPUT_URL:-https://sentry.io}" + +sentry-cli --url "$url" releases --org "$INPUT_ORGANIZATION" new "$INPUT_RELEASE" + +# ── commit association ──────────────────────────────────────────────────────── + +# --auto reads the local git tree, so the job has to check out the full history +# (fetch-depth: 0). A shallow clone yields a single commit and no association. +commits_status=0 +sentry-cli --url "$url" releases --org "$INPUT_ORGANIZATION" set-commits "$INPUT_RELEASE" --auto || commits_status=$? + +# Finalize regardless, so a failed association never leaves a half-created +# release behind in Sentry. The failure is reported afterwards. +sentry-cli --url "$url" releases --org "$INPUT_ORGANIZATION" finalize "$INPUT_RELEASE" + +if [[ "$commits_status" -ne 0 ]]; then + echo "::error::Could not associate commits with release '$INPUT_RELEASE'. The checkout needs the full git history (fetch-depth: 0) for sentry-cli to see them." + exit 1 +fi diff --git a/upload-debug-symbols-to-sentry/scripts/upload_debug_symbols_to_sentry.sh b/upload-debug-symbols-to-sentry/scripts/upload_debug_symbols_to_sentry.sh new file mode 100644 index 00000000..73efe4dc --- /dev/null +++ b/upload-debug-symbols-to-sentry/scripts/upload_debug_symbols_to_sentry.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── configuration ───────────────────────────────────────────────────────────── + +# sentry-cli talks to sentry.io unless a self-hosted server is configured. An +# empty --url would swallow the subcommand that follows it, so default it here. +url="${INPUT_URL:-https://sentry.io}" + +# ── helpers ─────────────────────────────────────────────────────────────────── + +upload_debug_files() { + echo "Uploading debug files from '$1'" + sentry-cli --url "$url" debug-files upload --org "$INPUT_ORGANIZATION" --project "$INPUT_PROJECT" "$1" +} + +# Sentry needs the obfuscation map paired with each individual .symbols file. +upload_dart_symbol_map() { + local map="$1" symbols_dir="$2" debug_file + for debug_file in "$symbols_dir"/*.symbols; do + [[ -f "$debug_file" ]] || continue + echo "Uploading obfuscation map for '$debug_file'" + sentry-cli --url "$url" dart-symbol-map upload --org "$INPUT_ORGANIZATION" --project "$INPUT_PROJECT" "$map" "$debug_file" + done +} + +upload_proguard_mapping() { + echo "Uploading proguard mapping '$1'" + sentry-cli --url "$url" upload-proguard --org "$INPUT_ORGANIZATION" --project "$INPUT_PROJECT" "$1" +} + +uploaded=0 + +# ── restored symbols directory (one folder per platform) ────────────────────── + +if [[ -n "${INPUT_SYMBOLS_DIR:-}" ]]; then + if [[ ! -d "$INPUT_SYMBOLS_DIR" ]]; then + echo "::error::symbols-dir '$INPUT_SYMBOLS_DIR' does not exist" + exit 1 + fi + + for platform_dir in "$INPUT_SYMBOLS_DIR"/*/; do + platform_dir="${platform_dir%/}" + [[ -d "$platform_dir" ]] || continue + echo "::group::Sentry upload for $(basename "$platform_dir")" + + if [[ -d "$platform_dir/dsyms" ]]; then + upload_debug_files "$platform_dir/dsyms" + uploaded=$((uploaded + 1)) + fi + + if [[ -d "$platform_dir/dart-symbols" ]]; then + upload_debug_files "$platform_dir/dart-symbols" + uploaded=$((uploaded + 1)) + + if [[ -f "$platform_dir/dart-symbols/obfuscation.map.json" ]]; then + upload_dart_symbol_map "$platform_dir/dart-symbols/obfuscation.map.json" "$platform_dir/dart-symbols" + fi + fi + + if [[ -f "$platform_dir/proguard/mapping.txt" ]]; then + upload_proguard_mapping "$platform_dir/proguard/mapping.txt" + uploaded=$((uploaded + 1)) + fi + + echo "::endgroup::" + done + + # Restoring symbols and then uploading nothing means the build stored nothing. + if [[ "$uploaded" -eq 0 ]]; then + echo "::error::No debug symbols found under '$INPUT_SYMBOLS_DIR'" + exit 1 + fi +fi + +# ── explicit paths, for callers that upload straight from a build job ───────── + +# A path that does not exist is skipped with a warning: a release build without +# minification has no mapping file, and that must not fail the job. + +if [[ -n "${INPUT_DART_SYMBOLS_FILE_PATH:-}" ]]; then + if [[ -d "$INPUT_DART_SYMBOLS_FILE_PATH" ]]; then + upload_debug_files "$INPUT_DART_SYMBOLS_FILE_PATH" + uploaded=$((uploaded + 1)) + + if [[ -n "${INPUT_DART_OBFUSCATION_MAP_FILE_PATH:-}" && -f "${INPUT_DART_OBFUSCATION_MAP_FILE_PATH}" ]]; then + upload_dart_symbol_map "$INPUT_DART_OBFUSCATION_MAP_FILE_PATH" "$INPUT_DART_SYMBOLS_FILE_PATH" + fi + else + echo "::warning::No dart symbols found at '$INPUT_DART_SYMBOLS_FILE_PATH' - skipping" + fi +fi + +if [[ -n "${INPUT_DSYMS_PATH:-}" ]]; then + if [[ -d "$INPUT_DSYMS_PATH" ]]; then + upload_debug_files "$INPUT_DSYMS_PATH" + uploaded=$((uploaded + 1)) + else + echo "::warning::No dSYMs found at '$INPUT_DSYMS_PATH' - skipping" + fi +fi + +if [[ -n "${INPUT_PROGUARD_MAPPING_FILE_PATH:-}" ]]; then + if [[ -f "$INPUT_PROGUARD_MAPPING_FILE_PATH" ]]; then + upload_proguard_mapping "$INPUT_PROGUARD_MAPPING_FILE_PATH" + uploaded=$((uploaded + 1)) + else + echo "::warning::No proguard mapping found at '$INPUT_PROGUARD_MAPPING_FILE_PATH' - skipping" + fi +fi + +if [[ "$uploaded" -eq 0 ]]; then + echo "::warning::Nothing was uploaded to Sentry - no symbol paths were given" +fi diff --git a/upload-debug-symbols-to-sentry/tests/create_sentry_release.bats b/upload-debug-symbols-to-sentry/tests/create_sentry_release.bats new file mode 100644 index 00000000..990fe1a4 --- /dev/null +++ b/upload-debug-symbols-to-sentry/tests/create_sentry_release.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats + +load "setup.bash" + +setup() { + stub_sentry_cli +} + +@test "creates, associates commits and finalizes, in that order" { + run_release + [ "$status" -eq 0 ] + [ "$(sentry_subcommands)" = "new +set-commits +finalize" ] +} + +@test "associates commits automatically from the git history" { + run_release + [ "$status" -eq 0 ] + sentry_calls | grep -q "set-commits 1.4.0+1764500000 --auto$" +} + +@test "the release is finalized even when the commit association fails" { + stub_sentry_cli 'if [[ "$*" == *set-commits* ]]; then echo "could not find commits" >&2; exit 1; fi; exit 0' + run_release + [ "$status" -ne 0 ] + sentry_calls | grep -q "finalize 1.4.0+1764500000$" +} + +@test "a failed commit association fails the step with an actionable error" { + stub_sentry_cli 'if [[ "$*" == *set-commits* ]]; then exit 1; fi; exit 0' + run_release + [ "$status" -eq 1 ] + [[ "$output" == *"::error::Could not associate commits with release '1.4.0+1764500000'"* ]] + [[ "$output" == *"fetch-depth: 0"* ]] +} + +@test "a failed finalize fails the step" { + stub_sentry_cli 'if [[ "$*" == *finalize* ]]; then exit 1; fi; exit 0' + run_release + [ "$status" -ne 0 ] +} + +@test "a failed create stops before finalizing" { + stub_sentry_cli 'if [[ "$*" == *" new "* ]]; then exit 1; fi; exit 0' + run_release + [ "$status" -ne 0 ] + ! sentry_calls | grep -q "finalize" +} + +@test "the auth token never reaches the command line" { + run_release + [ "$status" -eq 0 ] + ! sentry_calls | grep -q "secret-token" +} + +@test "falls back to sentry.io when no url is configured" { + INPUT_URL="" \ + run_release + [ "$status" -eq 0 ] + [ "$(sentry_calls | grep -c '^--url https://sentry.io ')" -eq 3 ] +} diff --git a/upload-debug-symbols-to-sentry/tests/setup.bash b/upload-debug-symbols-to-sentry/tests/setup.bash new file mode 100644 index 00000000..c383a4d5 --- /dev/null +++ b/upload-debug-symbols-to-sentry/tests/setup.bash @@ -0,0 +1,85 @@ +TESTS_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)" +UPLOAD_SCRIPT="${TESTS_DIR}/../scripts/upload_debug_symbols_to_sentry.sh" +RELEASE_SCRIPT="${TESTS_DIR}/../scripts/create_sentry_release.sh" + +# Put a fake sentry-cli on PATH that records its arguments. The body decides +# whether the call succeeds, so failure handling can be exercised too. +stub_sentry_cli() { + local body="${1:-exit 0}" + STUB_DIR="${BATS_TEST_TMPDIR}/bin" + CALLS="${BATS_TEST_TMPDIR}/sentry-calls.log" + mkdir -p "$STUB_DIR" + : > "$CALLS" + { + echo '#!/usr/bin/env bash' + echo "echo \"\$*\" >> \"${CALLS}\"" + echo "$body" + } > "${STUB_DIR}/sentry-cli" + chmod +x "${STUB_DIR}/sentry-cli" +} + +# Build the tree restore-debug-symbols produces: one folder per platform. +setup_restored_symbols() { + WORKSPACE="${BATS_TEST_TMPDIR}/workspace" + mkdir -p "${WORKSPACE}/debug-symbols/ios/dsyms/App.dSYM/Contents/Resources/DWARF" + echo "dwarf" > "${WORKSPACE}/debug-symbols/ios/dsyms/App.dSYM/Contents/Resources/DWARF/App" + mkdir -p "${WORKSPACE}/debug-symbols/ios/dart-symbols" + echo "symbols" > "${WORKSPACE}/debug-symbols/ios/dart-symbols/app.ios-arm64.symbols" + echo '{"a":"b"}' > "${WORKSPACE}/debug-symbols/ios/dart-symbols/obfuscation.map.json" + echo "release=1.4.0+1764500000" > "${WORKSPACE}/debug-symbols/ios/metadata.env" + + mkdir -p "${WORKSPACE}/debug-symbols/android-aab/dart-symbols" "${WORKSPACE}/debug-symbols/android-aab/proguard" + echo "symbols" > "${WORKSPACE}/debug-symbols/android-aab/dart-symbols/app.android-arm64.symbols" + echo '{"a":"b"}' > "${WORKSPACE}/debug-symbols/android-aab/dart-symbols/obfuscation.map.json" + echo "mapping" > "${WORKSPACE}/debug-symbols/android-aab/proguard/mapping.txt" + echo "release=1.4.0+1764500000" > "${WORKSPACE}/debug-symbols/android-aab/metadata.env" +} + +# And the raw build outputs, for the explicit-path inputs. +setup_build_outputs() { + WORKSPACE="${WORKSPACE:-${BATS_TEST_TMPDIR}/workspace}" + mkdir -p "${WORKSPACE}/build/ios/archive/Runner.xcarchive/dSYMs" "${WORKSPACE}/app_symbols" + echo "dwarf" > "${WORKSPACE}/build/ios/archive/Runner.xcarchive/dSYMs/App" + echo "symbols" > "${WORKSPACE}/app_symbols/app.ios-arm64.symbols" + echo '{"a":"b"}' > "${WORKSPACE}/app_symbols/obfuscation.map.json" + echo "mapping" > "${WORKSPACE}/mapping.txt" +} + +run_upload() { + cd "$WORKSPACE" + run env \ + PATH="${STUB_DIR}:${PATH}" \ + SENTRY_AUTH_TOKEN="secret-token" \ + INPUT_URL="${INPUT_URL-https://sentry.quickbirdstudios.com}" \ + INPUT_ORGANIZATION="quickbird" \ + INPUT_PROJECT="kaarlo" \ + INPUT_SYMBOLS_DIR="${INPUT_SYMBOLS_DIR:-}" \ + INPUT_DSYMS_PATH="${INPUT_DSYMS_PATH:-}" \ + INPUT_PROGUARD_MAPPING_FILE_PATH="${INPUT_PROGUARD_MAPPING_FILE_PATH:-}" \ + INPUT_DART_SYMBOLS_FILE_PATH="${INPUT_DART_SYMBOLS_FILE_PATH:-}" \ + INPUT_DART_OBFUSCATION_MAP_FILE_PATH="${INPUT_DART_OBFUSCATION_MAP_FILE_PATH:-}" \ + bash "$UPLOAD_SCRIPT" +} + +run_release() { + run env \ + PATH="${STUB_DIR}:${PATH}" \ + SENTRY_AUTH_TOKEN="secret-token" \ + INPUT_URL="${INPUT_URL-https://sentry.quickbirdstudios.com}" \ + INPUT_ORGANIZATION="quickbird" \ + INPUT_RELEASE="${INPUT_RELEASE:-1.4.0+1764500000}" \ + bash "$RELEASE_SCRIPT" +} + +sentry_calls() { + cat "$CALLS" +} + +sentry_call_count() { + grep -c '.' "$CALLS" || true +} + +# The subcommand of each recorded call, in order. +sentry_subcommands() { + sed 's/^--url [^ ]* //' "$CALLS" | sed 's/^releases --org [^ ]* //' | cut -d' ' -f1 +} diff --git a/upload-debug-symbols-to-sentry/tests/upload_debug_symbols_to_sentry.bats b/upload-debug-symbols-to-sentry/tests/upload_debug_symbols_to_sentry.bats new file mode 100644 index 00000000..d8e3ae8e --- /dev/null +++ b/upload-debug-symbols-to-sentry/tests/upload_debug_symbols_to_sentry.bats @@ -0,0 +1,125 @@ +#!/usr/bin/env bats + +load "setup.bash" + +setup() { + stub_sentry_cli + setup_restored_symbols +} + +# ── Restored directory ──────────────────────────────────────────────────────── + +@test "uploads dSYMs, dart symbols, obfuscation maps and the mapping of every platform" { + INPUT_SYMBOLS_DIR="debug-symbols" \ + run_upload + [ "$status" -eq 0 ] + [ "$(sentry_call_count)" -eq 6 ] + sentry_calls | grep -q "debug-files upload --org quickbird --project kaarlo debug-symbols/ios/dsyms$" + sentry_calls | grep -q "debug-files upload --org quickbird --project kaarlo debug-symbols/ios/dart-symbols$" + sentry_calls | grep -q "debug-files upload --org quickbird --project kaarlo debug-symbols/android-aab/dart-symbols$" + sentry_calls | grep -q "upload-proguard --org quickbird --project kaarlo debug-symbols/android-aab/proguard/mapping.txt$" + [ "$(sentry_calls | grep -c 'dart-symbol-map upload')" -eq 2 ] +} + +@test "every call targets the configured sentry server" { + INPUT_SYMBOLS_DIR="debug-symbols" \ + run_upload + [ "$status" -eq 0 ] + [ "$(sentry_calls | grep -c '^--url https://sentry.quickbirdstudios.com ')" -eq 6 ] +} + +@test "falls back to sentry.io when no url is configured" { + INPUT_URL="" \ + INPUT_SYMBOLS_DIR="debug-symbols" \ + run_upload + [ "$status" -eq 0 ] + [ "$(sentry_calls | grep -c '^--url https://sentry.io ')" -eq 6 ] +} + +@test "the auth token never reaches the command line" { + INPUT_SYMBOLS_DIR="debug-symbols" \ + run_upload + [ "$status" -eq 0 ] + ! sentry_calls | grep -q "secret-token" +} + +@test "the metadata file is never uploaded as a debug file" { + INPUT_SYMBOLS_DIR="debug-symbols" \ + run_upload + [ "$status" -eq 0 ] + ! sentry_calls | grep -q "metadata.env" +} + +@test "a failing sentry-cli fails the step" { + stub_sentry_cli 'echo boom >&2; exit 1' + INPUT_SYMBOLS_DIR="debug-symbols" \ + run_upload + [ "$status" -ne 0 ] +} + +@test "a symbols dir without any symbols is an error" { + mkdir -p "${WORKSPACE}/empty/some-platform" + INPUT_SYMBOLS_DIR="empty" \ + run_upload + [ "$status" -eq 1 ] + [[ "$output" == *"::error::No debug symbols found under 'empty'"* ]] + [ "$(sentry_call_count)" -eq 0 ] +} + +@test "a missing symbols dir is an error" { + INPUT_SYMBOLS_DIR="does-not-exist" \ + run_upload + [ "$status" -eq 1 ] + [[ "$output" == *"::error::symbols-dir 'does-not-exist' does not exist"* ]] +} + +@test "a platform folder holding only dSYMs is uploaded" { + rm -rf "${WORKSPACE}/debug-symbols/android-aab" "${WORKSPACE}/debug-symbols/ios/dart-symbols" + INPUT_SYMBOLS_DIR="debug-symbols" \ + run_upload + [ "$status" -eq 0 ] + [ "$(sentry_call_count)" -eq 1 ] +} + +@test "dart symbols without an obfuscation map skip the map upload" { + rm -f "${WORKSPACE}/debug-symbols/ios/dart-symbols/obfuscation.map.json" + rm -rf "${WORKSPACE}/debug-symbols/android-aab" + INPUT_SYMBOLS_DIR="debug-symbols" \ + run_upload + [ "$status" -eq 0 ] + ! sentry_calls | grep -q "dart-symbol-map upload" +} + +# ── Explicit paths ──────────────────────────────────────────────────────────── + +@test "uploads from the individual path inputs" { + setup_build_outputs + INPUT_DSYMS_PATH="build/ios/archive/Runner.xcarchive/dSYMs" \ + INPUT_DART_SYMBOLS_FILE_PATH="app_symbols" \ + INPUT_DART_OBFUSCATION_MAP_FILE_PATH="app_symbols/obfuscation.map.json" \ + INPUT_PROGUARD_MAPPING_FILE_PATH="mapping.txt" \ + run_upload + [ "$status" -eq 0 ] + sentry_calls | grep -q "debug-files upload --org quickbird --project kaarlo app_symbols$" + sentry_calls | grep -q "dart-symbol-map upload --org quickbird --project kaarlo app_symbols/obfuscation.map.json app_symbols/app.ios-arm64.symbols$" + sentry_calls | grep -q "debug-files upload --org quickbird --project kaarlo build/ios/archive/Runner.xcarchive/dSYMs$" + sentry_calls | grep -q "upload-proguard --org quickbird --project kaarlo mapping.txt$" +} + +@test "a path that does not exist warns and is skipped" { + setup_build_outputs + INPUT_DSYMS_PATH="nope" \ + INPUT_PROGUARD_MAPPING_FILE_PATH="also-nope.txt" \ + run_upload + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::No dSYMs found at 'nope' - skipping"* ]] + [[ "$output" == *"::warning::No proguard mapping found at 'also-nope.txt' - skipping"* ]] + [ "$(sentry_call_count)" -eq 0 ] +} + +@test "no inputs at all warns instead of failing" { + run_upload + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::Nothing was uploaded to Sentry"* ]] + [ "$(sentry_call_count)" -eq 0 ] +}