From 92db20129bd3c939bbdb5f9626a9c56f586a8cb6 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 23 Sep 2026 15:12:52 +0100 Subject: [PATCH 1/6] fix: parse dcd status --json with node so the status outputs are set `dcd status --json` prints pretty JSON (JSON.stringify(obj, null, 2)), so the compact-JSON greps ('"status":"...') never matched and DEVICE_CLOUD_UPLOAD_STATUS, DEVICE_CLOUD_FLOW_RESULTS and DEVICE_CLOUD_APP_BINARY_ID were always empty. Read the document with node, which is always present because the step runs the CLI through npx. - DEVICE_CLOUD_FLOW_RESULTS is now a JSON array of {name, status, failReason?}. - A status call that returns no JSON reports ERROR; the verdict then rests on the CLI exit code, as before. - The verdict logic is unchanged, but a FAILED status now actually fails the step. That matters with json_file: `dcd cloud --json-file` exits 0 on a failed run, so until now those runs passed the step. For the same reason, json_file runs whose status cannot be read now fail instead of passing (async submissions excepted). Unskips the two known-bug tests and adds fixture tests for passing, failing, json_file, compact and noisy status output. --- step.sh | 73 +++++++++++++--- test/fixtures/status-failed.json | 23 +++++ test/fixtures/status-passed.json | 22 +++++ test/test.bats | 143 ++++++++++++++++++++++++++++--- 4 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 test/fixtures/status-failed.json create mode 100644 test/fixtures/status-passed.json diff --git a/step.sh b/step.sh index 3c2fa24..6ab06dd 100644 --- a/step.sh +++ b/step.sh @@ -1,5 +1,42 @@ #!/bin/sh +# Print one field of the `dcd status --json` document read from stdin: a string +# as-is, anything else as compact JSON, nothing when the field is absent or +# null. "flowResults" is the tests array cut down to name, status and failReason. +# Exits 1 when stdin holds no JSON object. +# +# The CLI pretty-prints that document (JSON.stringify(obj, null, 2)), so the +# compact-JSON greps this replaces ('"status":"...') never matched and every +# status-derived output came out empty. node is always available here: the +# step runs the CLI through npx. +status_field() { + node -e ' +const text = require("fs").readFileSync(0, "utf8"); +const start = text.search(/^[ \t]*\{/m); +let doc = null; +if (start !== -1) { + try { + doc = JSON.parse(text.slice(start, text.lastIndexOf("}") + 1)); + } catch (e) { + doc = null; + } +} +if (!doc || typeof doc !== "object" || Array.isArray(doc)) process.exit(1); +const field = process.argv[1]; +const value = + field === "flowResults" + ? (Array.isArray(doc.tests) ? doc.tests : []).map((t) => { + const r = { name: t && t.name, status: t && t.status }; + if (t && t.failReason) r.failReason = t.failReason; + return r; + }) + : doc[field]; +if (value !== undefined && value !== null) { + process.stdout.write(typeof value === "string" ? value : JSON.stringify(value)); +} +' "$1" +} + # Parse env variables env_list_parsed="" if [ -n "$env_list" ]; then @@ -209,26 +246,31 @@ UPLOAD_ID=$(echo "$OUTPUT" | grep -o 'upload=[a-zA-Z0-9-]*' | cut -d= -f2 | head if [ -n "$UPLOAD_ID" ]; then # Get test status using the status command STATUS_OUTPUT=$(npx --yes "$DCD_VERSION" status --json --upload-id "$UPLOAD_ID" --api-key "$api_key" ${api_url:+--api-url "$api_url"}) - - # Extract values from status JSON using grep and sed + # Console URL CONSOLE_URL=$(echo "$OUTPUT" | grep -o 'https://console\.devicecloud\.dev/results?upload=[a-zA-Z0-9-]*') envman add --key DEVICE_CLOUD_CONSOLE_URL --value "$CONSOLE_URL" - - # Status - TEST_STATUS=$(echo "$STATUS_OUTPUT" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) + + # Status, flow results and binary id, read from the status JSON. ERROR marks + # a status call that returned no JSON at all; the verdict below then rests + # on the CLI's exit code alone, as it did while these outputs were empty. + if TEST_STATUS=$(printf '%s' "$STATUS_OUTPUT" | status_field status); then + FLOW_RESULTS=$(printf '%s' "$STATUS_OUTPUT" | status_field flowResults) + APP_BINARY_ID=$(printf '%s' "$STATUS_OUTPUT" | status_field appBinaryId) + else + echo "Could not read the upload status from 'dcd status --json'; reporting ERROR. Output was:" + echo "$STATUS_OUTPUT" + TEST_STATUS="ERROR" + FLOW_RESULTS="" + APP_BINARY_ID="" + fi + envman add --key DEVICE_CLOUD_UPLOAD_STATUS --value "$TEST_STATUS" - - # Flow Results - FLOW_RESULTS=$(echo "$STATUS_OUTPUT" | grep -o '"tests":\[[^]]*\]') - envman add --key DEVICE_CLOUD_FLOW_RESULTS --value "$FLOW_RESULTS" - - # App Binary ID - APP_BINARY_ID=$(echo "$STATUS_OUTPUT" | grep -o '"appBinaryId":"[^"]*"' | cut -d'"' -f4) + envman add --key DEVICE_CLOUD_FLOW_RESULTS --value "${FLOW_RESULTS:-[]}" if [ -n "$APP_BINARY_ID" ]; then envman add --key DEVICE_CLOUD_APP_BINARY_ID --value "$APP_BINARY_ID" fi - + # Set exit code based on status. A bad status fails the step; a good one # only clears the step if the CLI agreed. Clearing it unconditionally is # what let a cancelled run (CLI exit 2) report green when the status @@ -240,6 +282,11 @@ if [ -n "$UPLOAD_ID" ]; then elif [ "$CLI_EXIT_CODE" -ne 0 ]; then echo "dcd exited $CLI_EXIT_CODE; failing the step despite upload status '$TEST_STATUS'." EXIT_CODE=1 + elif [ "$TEST_STATUS" = "ERROR" ] && [ "$is_json_file" = "true" ] && [ "$is_async" != "true" ]; then + # json_file keeps dcd's exit code at 0 on a failed run, so without a + # status nothing is left to tell a pass from a failure. + echo "The upload status is unknown and json_file keeps dcd's exit code at 0; failing the step." + EXIT_CODE=1 fi fi diff --git a/test/fixtures/status-failed.json b/test/fixtures/status-failed.json new file mode 100644 index 0000000..cc3bfa1 --- /dev/null +++ b/test/fixtures/status-failed.json @@ -0,0 +1,23 @@ +{ + "status": "FAILED", + "appBinaryId": "abi", + "uploadId": "fake-upload-id", + "consoleUrl": "https://console.devicecloud.dev/results?upload=fake-upload-id&result=101", + "name": "My Bitrise run", + "createdAt": "2026-09-23T10:00:00.000Z", + "tests": [ + { + "name": "./flows/login.yaml", + "status": "PASSED", + "durationSeconds": 42, + "createdAt": "2026-09-23T10:00:05.000Z" + }, + { + "name": "./flows/search.yaml", + "status": "FAILED", + "durationSeconds": 51, + "failReason": "Assertion is false: \"Results\" is visible", + "createdAt": "2026-09-23T10:00:05.000Z" + } + ] +} diff --git a/test/fixtures/status-passed.json b/test/fixtures/status-passed.json new file mode 100644 index 0000000..c2351d0 --- /dev/null +++ b/test/fixtures/status-passed.json @@ -0,0 +1,22 @@ +{ + "status": "PASSED", + "appBinaryId": "abi", + "uploadId": "fake-upload-id", + "consoleUrl": "https://console.devicecloud.dev/results?upload=fake-upload-id&result=101", + "name": "My Bitrise run", + "createdAt": "2026-09-23T10:00:00.000Z", + "tests": [ + { + "name": "./flows/login.yaml", + "status": "PASSED", + "durationSeconds": 42, + "createdAt": "2026-09-23T10:00:05.000Z" + }, + { + "name": "./flows/search.yaml", + "status": "PASSED", + "durationSeconds": 37, + "createdAt": "2026-09-23T10:00:05.000Z" + } + ] +} diff --git a/test/test.bats b/test/test.bats index f9c8a41..7f9c1c7 100644 --- a/test/test.bats +++ b/test/test.bats @@ -6,11 +6,9 @@ # entry_file: step.sh) — the leading `#!/bin/sh` shebang is not used by Bitrise. # # The `status` stub emits PRETTY-printed JSON because that is what the real CLI -# produces (`dcd status --json` => JSON.stringify(obj, null, 2)). Tests whose -# names start with "[known bug]" are skipped: step.sh greps COMPACT-JSON -# patterns ('"status":"..."') that never match the pretty output, so the -# status-derived outputs are currently empty. Unskip them once step.sh parses -# the real output (e.g. via jq or space-tolerant patterns). +# produces (`dcd status --json` => JSON.stringify(obj, null, 2)). Fixture-based +# tests replay whole documents from test/fixtures/ (STUB_STATUS_FIXTURE), and +# STUB_STATUS_RAW replays arbitrary text (npm noise, non-JSON errors). setup() { TEST_DIR="$(mktemp -d)" @@ -34,9 +32,22 @@ case "$sub" in # words, from an unquoted expansion) from "-m" plus "a=b c" (two args). for a in "$@"; do echo "STUB_CLOUD_ARG: $a"; done echo "View results: https://console.devicecloud.dev/results?upload=fake-upload-id" - exit "${STUB_CLOUD_EXIT:-0}" + code="${STUB_CLOUD_EXIT:-0}" + # Like the real CLI: --json-file keeps the exit code at 0 on a failed run. + for a in "$@"; do + if [ "$a" = "--json-file" ] && [ "$code" = "2" ]; then code=0; fi + done + exit "$code" ;; status) + if [ -n "${STUB_STATUS_RAW:-}" ]; then + printf '%s\n' "${STUB_STATUS_RAW}" + exit 0 + fi + if [ -n "${STUB_STATUS_FIXTURE:-}" ]; then + cat "${STUB_STATUS_FIXTURE}" + exit 0 + fi st="${STUB_STATUS:-PASSED}" cat < "${ENVMAN_LOG}" + export async="true" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] +} + +@test "a PASSED status does not clear a failing CLI exit code" { + export api_key="k" + export STUB_STATUS_FIXTURE="${FIXTURES}/status-passed.json" + export STUB_CLOUD_EXIT=2 + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"dcd exited 2"* ]] +} + +@test "still reads compact status JSON" { + export api_key="k" + export STUB_STATUS_RAW='{"status":"FAILED","appBinaryId":"abc","tests":[{"name":"t1","status":"FAILED"}]}' + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 1 ] + [ "$(envman_value DEVICE_CLOUD_UPLOAD_STATUS)" = "FAILED" ] + [ "$(envman_value DEVICE_CLOUD_APP_BINARY_ID)" = "abc" ] +} + +@test "skips npm noise printed ahead of the status JSON" { + export api_key="k" + STUB_STATUS_RAW="npm warn exec The following package was not found and will be installed: @devicecloud.dev/dcd@5.6.0 +$(cat "${FIXTURES}/status-passed.json")" + export STUB_STATUS_RAW + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [ "$(envman_value DEVICE_CLOUD_UPLOAD_STATUS)" = "PASSED" ] +} + +@test "status output with no JSON reports ERROR and leaves the verdict to the CLI" { + export api_key="k" + export STUB_STATUS_RAW="npm error code ETIMEDOUT" + + export STUB_CLOUD_EXIT=0 + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [ "$(envman_value DEVICE_CLOUD_UPLOAD_STATUS)" = "ERROR" ] + [ "$(envman_value DEVICE_CLOUD_FLOW_RESULTS)" = "[]" ] + + : > "${ENVMAN_LOG}" + export STUB_CLOUD_EXIT=2 + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 1 ] + [ "$(envman_value DEVICE_CLOUD_UPLOAD_STATUS)" = "ERROR" ] +} From 9a23d747cf5894bf332f4d12d064a962f1460e4a Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 23 Sep 2026 15:16:02 +0100 Subject: [PATCH 2/6] fix: pass the step when a newer run superseded this one With cancel_previous, a newer run from the same CI context cancels the older run's queued tests. /uploads/status rolls those cancelled tests up to FAILED, so the older build went red for work nobody is waiting on. The status now carries `supersededBy: ` for such a run: log "Superseded by " with the newer run's console link, set DEVICE_CLOUD_UPLOAD_STATUS to SUPERSEDED and exit 0, whatever dcd exited with (an older CLI exits 2 for it). This mirrors `dcd cloud` 5.6.0, which exits 0 for a superseded run. Nothing changes when the field is absent, as it is on every other run and on APIs that predate it. --- step.sh | 25 +++++++++++++++-- step.yml | 7 +++-- test/fixtures/status-superseded.json | 22 +++++++++++++++ test/test.bats | 41 ++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/status-superseded.json diff --git a/step.sh b/step.sh index 6ab06dd..142dbdd 100644 --- a/step.sh +++ b/step.sh @@ -257,12 +257,27 @@ if [ -n "$UPLOAD_ID" ]; then if TEST_STATUS=$(printf '%s' "$STATUS_OUTPUT" | status_field status); then FLOW_RESULTS=$(printf '%s' "$STATUS_OUTPUT" | status_field flowResults) APP_BINARY_ID=$(printf '%s' "$STATUS_OUTPUT" | status_field appBinaryId) + SUPERSEDED_BY=$(printf '%s' "$STATUS_OUTPUT" | status_field supersededBy) else echo "Could not read the upload status from 'dcd status --json'; reporting ERROR. Output was:" echo "$STATUS_OUTPUT" TEST_STATUS="ERROR" FLOW_RESULTS="" APP_BINARY_ID="" + SUPERSEDED_BY="" + fi + + # supersededBy: a newer run from the same CI context replaced this one + # (cancel_previous) and cancelled its queued tests. The API rolls those up + # to FAILED, but the run no longer speaks for the commit, so, like + # `dcd cloud` itself, the step does not fail for it. Absent on every other + # run, and on APIs that predate the field. + if [ -n "$SUPERSEDED_BY" ]; then + echo "Superseded by $SUPERSEDED_BY: a newer run from the same CI context replaced this one, so this step passes." + if [ -n "$CONSOLE_URL" ]; then + echo "Newer run: ${CONSOLE_URL/$UPLOAD_ID/$SUPERSEDED_BY}" + fi + TEST_STATUS="SUPERSEDED" fi envman add --key DEVICE_CLOUD_UPLOAD_STATUS --value "$TEST_STATUS" @@ -274,8 +289,14 @@ if [ -n "$UPLOAD_ID" ]; then # Set exit code based on status. A bad status fails the step; a good one # only clears the step if the CLI agreed. Clearing it unconditionally is # what let a cancelled run (CLI exit 2) report green when the status - # rollup wrongly said PASSED. - if [ "$TEST_STATUS" = "FAILED" ] || [ "$TEST_STATUS" = "CANCELLED" ]; then + # rollup wrongly said PASSED. A superseded run passes whatever dcd exited + # with: an older CLI that doesn't know the state exits 2 for it. + if [ "$TEST_STATUS" = "SUPERSEDED" ]; then + if [ "$CLI_EXIT_CODE" -ne 0 ]; then + echo "dcd exited $CLI_EXIT_CODE, but the run was superseded; not failing the step." + fi + EXIT_CODE=0 + elif [ "$TEST_STATUS" = "FAILED" ] || [ "$TEST_STATUS" = "CANCELLED" ]; then EXIT_CODE=1 elif [ "$TEST_STATUS" = "PASSED" ] && [ "$CLI_EXIT_CODE" -eq 0 ]; then EXIT_CODE=0 diff --git a/step.yml b/step.yml index 03e933b..f2bcdbb 100644 --- a/step.yml +++ b/step.yml @@ -124,6 +124,9 @@ inputs: Bitrise reports no build id to DeviceCloud, so if one build runs this step more than once give each invocation its own `check_name` — otherwise the second would cancel the first. + + The superseded run does not fail its build: its step passes, with + `DEVICE_CLOUD_UPLOAD_STATUS` set to `SUPERSEDED`. is_expand: true is_required: false default_value: "false" @@ -425,11 +428,11 @@ outputs: - DEVICE_CLOUD_FLOW_RESULTS: opts: title: "Flow Results" - summary: "JSON array containing results for each flow, including name, status, and any errors" + summary: "JSON array with one entry per flow: its name, status and, for a failed flow, failReason" - DEVICE_CLOUD_UPLOAD_STATUS: opts: title: "Test Run Status" - summary: "Status of the test run (PENDING, RUNNING, PASSED, FAILED, CANCELLED)" + summary: "Status of the test run: PASSED, FAILED, PENDING, QUEUED or RUNNING (an async run reports whatever it had reached at submission); SUPERSEDED when a newer run replaced it through cancel_previous; ERROR when the status could not be read" - DEVICE_CLOUD_APP_BINARY_ID: opts: title: "App Binary ID" diff --git a/test/fixtures/status-superseded.json b/test/fixtures/status-superseded.json new file mode 100644 index 0000000..4786261 --- /dev/null +++ b/test/fixtures/status-superseded.json @@ -0,0 +1,22 @@ +{ + "status": "FAILED", + "appBinaryId": "abi", + "uploadId": "fake-upload-id", + "consoleUrl": "https://console.devicecloud.dev/results?upload=fake-upload-id&result=101", + "name": "My Bitrise run", + "createdAt": "2026-09-23T10:00:00.000Z", + "supersededBy": "newer-upload-id", + "tests": [ + { + "name": "./flows/login.yaml", + "status": "PASSED", + "durationSeconds": 42, + "createdAt": "2026-09-23T10:00:05.000Z" + }, + { + "name": "./flows/search.yaml", + "status": "CANCELLED", + "createdAt": "2026-09-23T10:00:05.000Z" + } + ] +} diff --git a/test/test.bats b/test/test.bats index 7f9c1c7..13ef6df 100644 --- a/test/test.bats +++ b/test/test.bats @@ -321,6 +321,47 @@ teardown() { [ "$status" -eq 0 ] } +@test "fixture: a superseded run reports SUPERSEDED and passes the step" { + # The API rolls the superseded run's cancelled tests up to FAILED; the + # supersededBy field is what says a newer run replaced it. + export api_key="k" + export STUB_STATUS_FIXTURE="${FIXTURES}/status-superseded.json" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [ "$(envman_value DEVICE_CLOUD_UPLOAD_STATUS)" = "SUPERSEDED" ] + [[ "$output" == *"Superseded by newer-upload-id"* ]] + [[ "$output" == *"Newer run: https://console.devicecloud.dev/results?upload=newer-upload-id"* ]] + [ "$(envman_value DEVICE_CLOUD_FLOW_RESULTS)" = '[{"name":"./flows/login.yaml","status":"PASSED"},{"name":"./flows/search.yaml","status":"CANCELLED"}]' ] +} + +@test "fixture: a superseded run passes even when an older CLI exits 2 for it" { + export api_key="k" + export STUB_STATUS_FIXTURE="${FIXTURES}/status-superseded.json" + export STUB_CLOUD_EXIT=2 + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [ "$(envman_value DEVICE_CLOUD_UPLOAD_STATUS)" = "SUPERSEDED" ] +} + +@test "json_file: a superseded run passes the step" { + export api_key="k" + export json_file="true" + export cancel_previous="true" + export STUB_STATUS_FIXTURE="${FIXTURES}/status-superseded.json" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [ "$(envman_value DEVICE_CLOUD_UPLOAD_STATUS)" = "SUPERSEDED" ] +} + +@test "without supersededBy (older APIs, every other run) nothing is superseded" { + export api_key="k" + export STUB_STATUS_FIXTURE="${FIXTURES}/status-failed.json" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 1 ] + [[ "$output" != *"Superseded"* ]] + [ "$(envman_value DEVICE_CLOUD_UPLOAD_STATUS)" = "FAILED" ] +} + @test "a PASSED status does not clear a failing CLI exit code" { export api_key="k" export STUB_STATUS_FIXTURE="${FIXTURES}/status-passed.json" From 139571acbf2f57a6b544814190a6cef18b51ac9c Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 23 Sep 2026 15:17:02 +0100 Subject: [PATCH 3/6] fix: stop printing the API key The variables dump and the echoed command line both printed api_key in full. Print [REDACTED] instead; the key is still passed to the CLI. --- step.sh | 7 ++++--- test/test.bats | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/step.sh b/step.sh index 142dbdd..27c0752 100644 --- a/step.sh +++ b/step.sh @@ -85,13 +85,14 @@ cd $BITRISE_SOURCE_DIR EXIT_CODE=0 -# Log all variables for debugging +# Log all variables for debugging, except the API key itself: don't rely on +# Bitrise's log redaction to catch a secret the step prints on purpose. echo "DCD variables:" echo "allure_path: $allure_path" echo "android_api_level: $android_api_level" echo "android_device: $android_device" echo "android_no_snapshot: $android_no_snapshot" -echo "api_key: $api_key" +echo "api_key: ${api_key:+[REDACTED]}" echo "api_url: $api_url" echo "app_binary_id: $app_binary_id" echo "app_file: $app_file" @@ -137,7 +138,7 @@ echo "check_name: $check_name" # split into two argv entries and the stray word would land as a positional (app # file / workspace). echo "Running command: npx --yes \"$DCD_VERSION\" cloud --quiet \ ---apiKey \"$api_key\" \ +--apiKey \"${api_key:+[REDACTED]}\" \ ${allure_path:+--allure-path \"$allure_path\"} \ ${is_android_no_snapshot:+--android-no-snapshot} \ ${android_api_level:+--android-api-level \"$android_api_level\"} \ diff --git a/test/test.bats b/test/test.bats index 13ef6df..122b4c6 100644 --- a/test/test.bats +++ b/test/test.bats @@ -113,6 +113,19 @@ teardown() { [[ "$output" == *".maestro"* ]] } +@test "never prints the API key itself" { + export api_key="sk-live-do-not-print" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + # The key still reaches the CLI... + [[ "$output" == *"STUB_CLOUD_ARG: sk-live-do-not-print"* ]] + # ...but none of the step's own lines (everything the stub didn't print) shows it. + own_output="$(printf '%s\n' "$output" | grep -v '^STUB_CLOUD_')" + [[ "$own_output" != *"sk-live-do-not-print"* ]] + [[ "$own_output" == *"api_key: [REDACTED]"* ]] + [[ "$own_output" == *'--apiKey "[REDACTED]"'* ]] +} + @test "default package is the >=4.4.0 version range" { export api_key="k" run bash "${TEST_DIR}/step.sh" From 8e2705605c1371897899697a393a5e72734979d5 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 23 Sep 2026 15:22:11 +0100 Subject: [PATCH 4/6] feat: derive the GitHub context from Bitrise env vars GitHub checks need gh_repo + gh_sha on the run, and cancel_previous groups runs by gh_repo + gh_pr_number/gh_branch (+ gh_check_name). The step only ever sent gh_check_name, so on Bitrise neither worked unless users hand-wrote the keys into `metadata`. For a github.com repository the step now attaches: - gh_repo from GIT_REPOSITORY_URL (https, ssh:// and scp-style remotes; other hosts, including GitHub Enterprise, get no gh_repo) - gh_sha from BITRISE_GIT_COMMIT, else GIT_CLONE_COMMIT_HASH - gh_branch from BITRISE_GIT_BRANCH - gh_pr_number and gh_pr_url from BITRISE_PULL_REQUEST and, for any repository, gh_run_id from BITRISEIO_PIPELINE_ID (the pipeline build, shared by every workflow in it), else BITRISE_BUILD_SLUG. The server treats uploads with the same run id as siblings, so parallel iOS/Android workflows of one pipeline no longer cancel each other. A key already set in the metadata input wins; gh_pr_url is only built alongside a derived gh_repo. A new include_github_context input (default true) turns all of it off. --- step.sh | 54 ++++++++++++++++++++++ step.yml | 50 ++++++++++++++++++--- test/test.bats | 119 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 5 deletions(-) diff --git a/step.sh b/step.sh index 27c0752..72043c7 100644 --- a/step.sh +++ b/step.sh @@ -66,6 +66,57 @@ if [ -n "$metadata" ]; then done <<< "$metadata" fi +# owner/repo for a github.com remote (https, ssh:// or scp-style git@...), else +# nothing. Other hosts get no gh_repo: GitHub checks come from the DeviceCloud +# GitHub App on github.com, and the console links gh_repo to github.com. +github_repo_from_url() { + local url="${1%/}" + local re='^(https?://([^/@]*@)?|ssh://([^/@]*@)?|[^/@:]+@)(www\.)?github\.com[:/]([^/]+)/([^/]+)$' + local restore_case + restore_case=$(shopt -p nocasematch) + shopt -s nocasematch + if [[ "$url" =~ $re ]]; then + printf '%s/%s' "${BASH_REMATCH[5]}" "${BASH_REMATCH[6]%.git}" + fi + eval "$restore_case" +} + +# True when the metadata input already sets key $1. +metadata_has() { + printf '%s\n' "$metadata" | grep -Eq "^[[:space:]]*$1=" +} + +# GitHub context. DeviceCloud posts a GitHub check for a run carrying gh_repo + +# gh_sha, and cancel_previous groups runs by gh_repo + gh_pr_number or +# gh_branch (+ gh_check_name). The GitHub Action attaches these itself; here +# they come from Bitrise's env vars, and a key set in the metadata input wins. +# gh_run_id is the pipeline build (or, outside a pipeline, the build), so runs +# from the same one are siblings that cancel_previous never cancels. +gh_context_args=() +add_gh_context() { + if [ -n "$2" ] && ! metadata_has "$1"; then + gh_context_args+=(-m "$1=$2") + fi +} +if [ "$include_github_context" != "false" ]; then + gh_repo_from_env=$(github_repo_from_url "$GIT_REPOSITORY_URL") + if [ -n "$gh_repo_from_env" ]; then + # The commit Bitrise reports build status on (for a PR, its head commit, + # not the pre-merged state Git Clone may build); the cloned commit for + # a build that no commit triggered. + add_gh_context gh_sha "${BITRISE_GIT_COMMIT:-$GIT_CLONE_COMMIT_HASH}" + add_gh_context gh_branch "$BITRISE_GIT_BRANCH" + add_gh_context gh_pr_number "$BITRISE_PULL_REQUEST" + if ! metadata_has gh_repo; then + add_gh_context gh_repo "$gh_repo_from_env" + if [ -n "$BITRISE_PULL_REQUEST" ]; then + add_gh_context gh_pr_url "https://github.com/$gh_repo_from_env/pull/$BITRISE_PULL_REQUEST" + fi + fi + fi + add_gh_context gh_run_id "${BITRISEIO_PIPELINE_ID:-$BITRISE_BUILD_SLUG}" +fi + # Refine variables [[ "$async" == "true" ]] && is_async="true" [[ "$google_play" == "true" ]] && is_google_play="true" @@ -132,6 +183,7 @@ echo "disable_animations: $disable_animations" echo "quiet: $quiet" echo "use_beta: $use_beta" echo "check_name: $check_name" +echo "include_github_context: $include_github_context" # check_name is passed as its own quoted `-m` pair rather than folded into # metadata_parsed, which expands unquoted: a check name containing a space would @@ -180,6 +232,7 @@ ${is_disable_animations:+--disable-animations} \ ${is_quiet:+--quiet} \ ${env_list_parsed} \ ${metadata_parsed} \ +${gh_context_args[*]} \ \"$app_file\" \"$workspace\"" # Capture the command output and display it @@ -232,6 +285,7 @@ ${is_disable_animations:+--disable-animations} \ ${is_quiet:+--quiet} \ ${env_list_parsed} \ ${metadata_parsed} \ +"${gh_context_args[@]}" \ "$app_file" "$workspace" 2>&1) || EXIT_CODE=$? echo "$OUTPUT" diff --git a/step.yml b/step.yml index f2bcdbb..d41b48a 100644 --- a/step.yml +++ b/step.yml @@ -97,9 +97,44 @@ inputs: opts: title: "Metadata" summary: "Arbitrary key-value metadata to include with your test run, separated by newlines (format: key=value)" + description: | + Arbitrary `key=value` pairs to attach to the run, one per line. + + For a GitHub repository the step also attaches the build's GitHub + context (see `include_github_context`). A key you set here wins over + the derived value, e.g. `gh_repo=owner/repo` when this Bitrise app + builds a mirror of a GitHub repository. is_expand: true is_required: false + - include_github_context: "true" + opts: + title: "Include GitHub Context" + summary: "Attach this build's GitHub repo, commit, branch and PR as gh_* metadata, which GitHub checks and cancel_previous rely on. Set to false to opt out." + description: | + When the app's repository is on github.com, attaches the build's + GitHub context to the run as metadata, read from Bitrise's env vars: + + - `gh_repo` from `GIT_REPOSITORY_URL` + - `gh_sha` from `BITRISE_GIT_COMMIT` (else `GIT_CLONE_COMMIT_HASH`) + - `gh_branch` from `BITRISE_GIT_BRANCH` + - `gh_pr_number` and `gh_pr_url` from `BITRISE_PULL_REQUEST` + + For any repository it also attaches `gh_run_id`, from + `BITRISEIO_PIPELINE_ID` (else `BITRISE_BUILD_SLUG`), so runs from the + same pipeline build never cancel each other under `cancel_previous`. + + With the DeviceCloud GitHub App connected, `gh_repo` + `gh_sha` make + DeviceCloud post a GitHub check for the run; `gh_repo` + the branch + or PR are what `cancel_previous` matches runs on. Keys set in the + `metadata` input win. Set to `false` to attach none of these. + is_expand: true + is_required: false + default_value: "true" + value_options: + - "true" + - "false" + - async: "false" opts: title: "Async Execution" @@ -120,10 +155,15 @@ inputs: context when this run is submitted. Tests already running are left to finish; cancelled tests are refunded at 75%. - The context is matched on repository + branch (or PR) + `check_name`. - Bitrise reports no build id to DeviceCloud, so if one build runs this - step more than once give each invocation its own `check_name` — - otherwise the second would cancel the first. + The context is matched on repository + branch (or PR) + `check_name`, + taken from the GitHub context the step attaches (see + `include_github_context`) or from `gh_repo` and `gh_branch` or + `gh_pr_number` in `metadata`. Without a repository and a branch or + PR nothing is cancelled. Runs from the same pipeline build never + cancel each other. If one build runs this step more than once (iOS + and Android, say), give each invocation its own `check_name`: + otherwise one platform's new run cancels the other platform's + previous run. The superseded run does not fail its build: its step passes, with `DEVICE_CLOUD_UPLOAD_STATUS` set to `SUPERSEDED`. @@ -212,7 +252,7 @@ inputs: - check_name: "" opts: title: "GitHub Check Name" - summary: "Names the GitHub check this run posts, e.g. \"iOS\" gives \"DeviceCloud / iOS\". Only applies when the run carries GitHub commit metadata (gh_sha/gh_repo) and your org has the DeviceCloud GitHub App installed. Use it when a commit is tested more than once so each run gets a check that can be required separately in branch protection, and keep the value fixed for a given workflow." + summary: "Names the GitHub check this run posts, e.g. \"iOS\" gives \"DeviceCloud / iOS\". Only applies when the run carries GitHub commit metadata (gh_sha/gh_repo, attached automatically for GitHub repositories, see include_github_context) and your org has the DeviceCloud GitHub App installed. Use it when a commit is tested more than once so each run gets a check that can be required separately in branch protection, and keep the value fixed for a given workflow." is_expand: true is_required: false diff --git a/test/test.bats b/test/test.bats index 122b4c6..9362061 100644 --- a/test/test.bats +++ b/test/test.bats @@ -86,7 +86,13 @@ STUB unset api_key app_file workspace android_device android_api_level ios_device \ name check_name async google_play debug disable_animations use_beta \ env_list metadata download_artifacts json_file cancel_previous \ + include_github_context \ STUB_STATUS STUB_CLOUD_EXIT STUB_STATUS_FIXTURE STUB_STATUS_RAW + # ...and the Bitrise env vars the GitHub context is derived from, in case + # the suite itself runs on Bitrise. + unset GIT_REPOSITORY_URL BITRISE_GIT_COMMIT GIT_CLONE_COMMIT_HASH \ + BITRISE_GIT_BRANCH BITRISE_PULL_REQUEST BITRISEIO_PIPELINE_ID \ + BITRISE_BUILD_SLUG FIXTURES="${BATS_TEST_DIRNAME}/fixtures" } @@ -237,6 +243,119 @@ teardown() { [[ "$output" == *"-m sha=abc"* ]] } +# --- GitHub context from Bitrise env vars ------------------------------------ + +@test "derives the gh_* context for a GitHub PR build" { + export api_key="k" + export GIT_REPOSITORY_URL="https://github.com/acme/widgets.git" + export BITRISE_GIT_COMMIT="deadbeef" + export GIT_CLONE_COMMIT_HASH="mergecommit" + export BITRISE_GIT_BRANCH="feature/login" + export BITRISE_PULL_REQUEST="7" + export BITRISEIO_PIPELINE_ID="pipeline-123" + export BITRISE_BUILD_SLUG="build-9" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"STUB_CLOUD_ARG: gh_repo=acme/widgets"* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_sha=deadbeef"* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_branch=feature/login"* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_pr_number=7"* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_pr_url=https://github.com/acme/widgets/pull/7"* ]] + # The pipeline build, shared by every workflow in it, not this build. + [[ "$output" == *"STUB_CLOUD_ARG: gh_run_id=pipeline-123"* ]] + [[ "$output" != *"build-9"* ]] + [[ "$output" != *"mergecommit"* ]] +} + +@test "outside a pipeline gh_run_id is the build, and a branch build has no PR keys" { + export api_key="k" + export GIT_REPOSITORY_URL="https://github.com/acme/widgets" + export BITRISE_GIT_COMMIT="deadbeef" + export BITRISE_GIT_BRANCH="main" + export BITRISE_BUILD_SLUG="build-9" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"STUB_CLOUD_ARG: gh_repo=acme/widgets"* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_branch=main"* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_run_id=build-9"* ]] + [[ "$output" != *"gh_pr_number"* ]] + [[ "$output" != *"gh_pr_url"* ]] +} + +@test "falls back to the cloned commit when no commit triggered the build" { + export api_key="k" + export GIT_REPOSITORY_URL="https://github.com/acme/widgets.git" + export GIT_CLONE_COMMIT_HASH="cafef00d" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"STUB_CLOUD_ARG: gh_sha=cafef00d"* ]] +} + +@test "reads ssh, scp-style, credentialed and mixed-case GitHub remotes" { + export api_key="k" + for url in "git@github.com:acme/widgets.git" \ + "ssh://git@github.com/acme/widgets.git" \ + "https://x-access-token:secret@github.com/acme/widgets.git" \ + "https://GitHub.com/acme/widgets/"; do + export GIT_REPOSITORY_URL="$url" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"STUB_CLOUD_ARG: gh_repo=acme/widgets"* ]] + [[ "$output" != *"secret@"* ]] + done +} + +@test "attaches no GitHub repo context for other hosts, only the run id" { + export api_key="k" + export BITRISE_GIT_COMMIT="deadbeef" + export BITRISE_GIT_BRANCH="main" + export BITRISE_PULL_REQUEST="7" + export BITRISE_BUILD_SLUG="build-9" + for url in "https://gitlab.com/acme/widgets.git" \ + "git@bitbucket.org:acme/widgets.git" \ + "https://github.example.com/acme/widgets.git" \ + "https://notgithub.com/acme/widgets.git"; do + export GIT_REPOSITORY_URL="$url" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [[ "$output" != *"gh_repo="* ]] + [[ "$output" != *"gh_sha="* ]] + [[ "$output" != *"gh_branch="* ]] + [[ "$output" != *"gh_pr_number="* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_run_id=build-9"* ]] + done +} + +@test "a key set in the metadata input wins over the derived one" { + export api_key="k" + export metadata=$'gh_repo=acme/mirror\ngh_branch=release' + export GIT_REPOSITORY_URL="https://github.com/acme/widgets.git" + export BITRISE_GIT_COMMIT="deadbeef" + export BITRISE_GIT_BRANCH="feature/login" + export BITRISE_PULL_REQUEST="7" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"STUB_CLOUD_ARG: gh_repo=acme/mirror"* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_branch=release"* ]] + [[ "$output" != *"gh_repo=acme/widgets"* ]] + [[ "$output" != *"gh_branch=feature/login"* ]] + # A PR URL built from the Bitrise repo would point at the wrong repository. + [[ "$output" != *"gh_pr_url"* ]] + [[ "$output" == *"STUB_CLOUD_ARG: gh_sha=deadbeef"* ]] +} + +@test "include_github_context=false attaches none of it" { + export api_key="k" + export include_github_context="false" + export GIT_REPOSITORY_URL="https://github.com/acme/widgets.git" + export BITRISE_GIT_COMMIT="deadbeef" + export BITRISE_GIT_BRANCH="main" + export BITRISEIO_PIPELINE_ID="pipeline-123" + run bash "${TEST_DIR}/step.sh" + [ "$status" -eq 0 ] + [[ "$output" != *"gh_"* ]] +} + # --- Outputs & exit code ----------------------------------------------------- @test "emits DEVICE_CLOUD_CONSOLE_URL via envman (parsed from cloud output)" { From 3e0f7f6695b7dab350e78a0536e6ba12fb34efa2 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Wed, 23 Sep 2026 15:23:13 +0100 Subject: [PATCH 5/6] docs: document the outputs and GitHub context; correct stale input text - README: the outputs and their values (including SUPERSEDED and ERROR), the gh_* context derived from Bitrise env vars, and how to run the tests (bash 4.1+, since bash 3.2 lets a mid-test [[ ]] assertion pass). - step.yml: android_no_snapshot is automatic from API 34, not 35; json no longer claims to force exit code 0; json_file writes _dcd.json and still fails a failed run. --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- step.yml | 6 +++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bd609ec..8c488a9 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ -# bitrise-integration \ No newline at end of file +# bitrise-integration + +The **Device Cloud for Maestro** Bitrise step: runs your Maestro flows on +[devicecloud.dev](https://devicecloud.dev) from a Bitrise workflow. Every input +is described in [`step.yml`](step.yml); the user guide is at +[docs.devicecloud.dev](https://docs.devicecloud.dev/ci-cd-integration/bitrise-steps). + +## Outputs + +| Output | Value | +|---|---| +| `DEVICE_CLOUD_CONSOLE_URL` | The run in the DeviceCloud console. | +| `DEVICE_CLOUD_UPLOAD_STATUS` | `PASSED`, `FAILED`, `PENDING`, `QUEUED` or `RUNNING`; `SUPERSEDED` when a newer run replaced this one through `cancel_previous`; `ERROR` when the status could not be read. | +| `DEVICE_CLOUD_FLOW_RESULTS` | JSON array, one entry per flow: `[{"name": "...", "status": "PASSED"}]`, plus `failReason` for a failed flow. | +| `DEVICE_CLOUD_APP_BINARY_ID` | The uploaded binary, to reuse through `app_binary_id`. | + +The step fails when the run fails, whether or not `json` or `json_file` is set. +A run that a newer one superseded (`cancel_previous`) passes. + +## GitHub context + +For a repository on github.com, the step attaches the build's GitHub context to +the run as metadata, read from Bitrise's env vars: + +| Metadata key | From | +|---|---| +| `gh_repo` | `GIT_REPOSITORY_URL` | +| `gh_sha` | `BITRISE_GIT_COMMIT`, else `GIT_CLONE_COMMIT_HASH` | +| `gh_branch` | `BITRISE_GIT_BRANCH` | +| `gh_pr_number`, `gh_pr_url` | `BITRISE_PULL_REQUEST` (PR builds) | +| `gh_run_id` | `BITRISEIO_PIPELINE_ID`, else `BITRISE_BUILD_SLUG` (any repository) | + +With the DeviceCloud GitHub App connected, `gh_repo` + `gh_sha` make DeviceCloud +post a GitHub check for the run, and `cancel_previous` matches runs on `gh_repo` +plus the PR or branch (and `check_name`). Runs sharing a `gh_run_id` (the +workflows of one pipeline build) never cancel each other. A key you set in the +`metadata` input wins over the derived one; set `include_github_context` to +`false` to attach none of them. + +## Tests + +```bash +npm install -g bats +bats test/test.bats +``` + +Run the suite under bash 4.1 or later: bash 3.2 (macOS's `/bin/bash`) does not +fail a test on a `[[ ]]` assertion that isn't its last command. diff --git a/step.yml b/step.yml index d41b48a..cc209d6 100644 --- a/step.yml +++ b/step.yml @@ -195,7 +195,7 @@ inputs: - android_no_snapshot: "false" opts: title: "Android No Snapshot" - summary: "[Android only] Force cold boot instead of using snapshot boot. Automatically enabled for API 35+ but can be used to force cold boot on older API levels." + summary: "[Android only] Force cold boot instead of using snapshot boot. Automatically enabled for API 34+ but can be used to force cold boot on older API levels." is_expand: true is_required: false default_value: "false" @@ -420,7 +420,7 @@ inputs: - json: "false" opts: title: "JSON Output" - summary: "Output results in JSON format - note: will always provide exit code 0" + summary: "Print the CLI's results as JSON. The step still fails a failed run." is_expand: true is_required: false default_value: "false" @@ -431,7 +431,7 @@ inputs: - json_file: "false" opts: title: "JSON File Output" - summary: "Write JSON output to a file with name _dcd.json or _dcd.json if no name is provided" + summary: "Write the results as JSON to _dcd.json (or json_file_name). dcd exits 0 in this mode even when tests fail, so the step decides from the upload status: a failed run still fails the step." is_expand: true is_required: false default_value: "false" From 1e150c6e01fd79fb13b5408d3030a37aa2ebdae3 Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Thu, 24 Sep 2026 16:06:31 +0100 Subject: [PATCH 6/6] chore: prepare the 1.4.0 release Share config for bitrise run share-this-step: the step version was never bumped from 1.0.0, and the fork URL pointed at this repo instead of the devicecloud-dev/bitrise-steplib fork the 1.3.0 steplib PR came from. --- bitrise.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bitrise.yml b/bitrise.yml index beb4f4d..dea63ea 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -5,9 +5,9 @@ app: envs: # If you want to share this step into a StepLib - BITRISE_STEP_ID: device-cloud-for-maestro - - BITRISE_STEP_VERSION: "1.0.0" + - BITRISE_STEP_VERSION: "1.4.0" - BITRISE_STEP_GIT_CLONE_URL: https://github.com/devicecloud-dev/bitrise-integration.git - - MY_STEPLIB_REPO_FORK_GIT_URL: git@github.com:devicecloud-dev/bitrise-integration.git + - MY_STEPLIB_REPO_FORK_GIT_URL: https://github.com/devicecloud-dev/bitrise-steplib.git workflows: test: