diff --git a/README.md b/README.md index 4fbc331..f11e92d 100644 --- a/README.md +++ b/README.md @@ -6,28 +6,42 @@ A reusable GitHub Action that triggers a [Unity Build Automation](https://docs.u The action does not check out or read your repository — it only calls the Unity Build Automation REST API — so it is safe to run before, after, or without `actions/checkout`. +It speaks **API v2**. Unity removes API v1 on **2026-12-21**; if you are upgrading from an earlier release of this action, see [Migrating from API v1](#migrating-from-api-v1) — the credentials change. + +## Authentication + +API v2 authenticates with HTTP Basic using a Unity **service account** key ID and secret. The legacy Build Automation API key does not work on v2. + +1. In the Unity Cloud Dashboard, go to **Administration → Service Accounts** and create a service account. +2. Give it a role that allows Build Automation builds in the target project. +3. Create an API key on that service account and store the **key ID** and **secret key** as GitHub secrets. + +Pass them as `unity_service_account_key_id` and `unity_service_account_secret_key`; the action base64-encodes `keyId:secretKey` and sends the `Authorization: Basic …` header for you. Both values are masked in the logs. + ## Inputs -| Input | Required | Description | Default | -| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -| `unity_org_id` | Yes | The Unity Organization ID (used in the endpoint `orgs/{unity_org_id}`). | — | -| `unity_project_id` | Yes | The Unity Build Automation Project ID. | — | -| `build_target_id` | Yes | The Build Target ID within the Unity Build Automation project. | — | -| `authorization_header` | Yes | Authorization header for the API, e.g. `Authorization: Basic `. A bare token is also accepted and gets the `Authorization: ` prefix added. | — | -| `branch` | No | Git branch to build. When empty, the branch configured on the build target is used. | `''` | -| `clean` | No | Whether to perform a clean build. Accepts `true`/`false` (also `1`/`0`, `yes`/`no`, any casing). | `false` | -| `platform` | No | Build platform override (e.g. `standalonewindows64`). Sent only when set. | `''` | -| `machine_type_label` | No | Machine type label (e.g. `win_premium_v1`). Sent as `machineTypeLabel`, only when set. | `''` | -| `api_url` | No | Base URL of the API, including the version segment. Override to target a newer API version. | `https://build-api.cloud.unity3d.com/api/v1` | +| Input | Required | Description | Default | +| ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `unity_org_id` | Yes | The Unity Organization ID (used in the endpoint `orgs/{unity_org_id}`). | — | +| `unity_project_id` | Yes | The Unity Build Automation Project ID. | — | +| `build_target_id` | Yes | The Build Target ID within the Unity Build Automation project. | — | +| `unity_service_account_key_id` | Yes | Key ID of a Unity service account with a Build Automation role. | — | +| `unity_service_account_secret_key` | Yes | Secret key of that service account. Pass as a secret. | — | +| `branch` | No | Git branch to build. When empty, the branch configured on the build target is used. | `''` | +| `clean` | No | Whether to perform a clean build. Accepts `true`/`false` (also `1`/`0`, `yes`/`no`, any casing). | `false` | +| `platform` | No | Build platform override (e.g. `standalonewindows64`). Sent only when set. | `''` | +| `machine_type_label` | No | Machine type label (e.g. `win_premium_v1`). Sent as `machineTypeLabel`, only when set. | `''` | +| `api_url` | No | Base URL of the API, including the version segment. v2 only — a v1 base URL is rejected. | `https://build-automation.services.api.unity.com/v2` | `platform` and `machine_type_label` are omitted from the request when left empty, so the build target's own configuration applies. Set them only if you want to override the target. ## Outputs -| Output | Description | -| ---------- | -------------------------------------------------------- | -| `build_id` | The ID (build number) of the triggered Unity build. | -| `platform` | The platform of the triggered build, as reported by the API. | +| Output | Description | +| -------------- | ------------------------------------------------------------------------ | +| `build_id` | The ID (build number) of the triggered Unity build. | +| `platform` | The platform of the triggered build, as reported by the API. | +| `build_status` | The status the build was created with (e.g. `queued`). Empty if the API did not report one. | ## Usage @@ -57,7 +71,8 @@ jobs: unity_org_id: ${{ secrets.UNITY_ORG_ID }} unity_project_id: ${{ secrets.UNITY_PROJECT_ID }} build_target_id: ${{ secrets.UNITY_BUILD_TARGET_ID }} - authorization_header: ${{ secrets.UNITY_AUTH_HEADER }} + unity_service_account_key_id: ${{ secrets.UNITY_SA_KEY_ID }} + unity_service_account_secret_key: ${{ secrets.UNITY_SA_SECRET_KEY }} branch: main # Optional: override the branch to build ``` @@ -92,7 +107,8 @@ jobs: unity_org_id: ${{ secrets.UNITY_ORG_ID }} unity_project_id: ${{ secrets.UNITY_PROJECT_ID }} build_target_id: ${{ secrets.UNITY_BUILD_TARGET_ID }} - authorization_header: ${{ secrets.UNITY_AUTH_HEADER }} + unity_service_account_key_id: ${{ secrets.UNITY_SA_KEY_ID }} + unity_service_account_secret_key: ${{ secrets.UNITY_SA_SECRET_KEY }} branch: ${{ inputs.branch }} clean: ${{ inputs.clean }} @@ -100,29 +116,48 @@ jobs: run: | echo "Build ${{ steps.unity.outputs.build_id }} started" echo "Platform: ${{ steps.unity.outputs.platform }}" + echo "Status: ${{ steps.unity.outputs.build_status }}" ``` -## API version +## Migrating from API v1 -By default the action calls API **v1** at `https://build-api.cloud.unity3d.com/api/v1`, which is the version Unity's published Build Automation API reference documents. +Unity deprecated Build Automation API v1 and **removes it on 2026-12-21**. This action calls v2 and no longer supports v1, so upgrading from an earlier release is a breaking change. -If you are moving to a newer API version, point `api_url` at it — no change to the action is needed as long as the request/response shapes stay compatible: +| | v1 (before) | v2 (now) | +| --- | --- | --- | +| Base URL | `https://build-api.cloud.unity3d.com/api/v1` | `https://build-automation.services.api.unity.com/v2` | +| Credentials | Build Automation API key | Service account key ID + secret | +| Success status | `200`/`201` | `202 Accepted` | +| Error body | `{ "error": … }` | [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) `{ "detail": …, "requestId": … }` | -```yaml - with: - api_url: https://build-api.cloud.unity3d.com/api/v2 - # ... +The endpoint path (`/orgs/{org}/projects/{project}/buildtargets/{target}/builds`) and the request fields this action sends (`clean`, `delay`, `branch`, `platform`, `machineTypeLabel`) are unchanged. + +### What you need to change + +The `authorization_header` input is **removed**. Create a [service account](#authentication) and replace it with the key pair: + +```diff +- authorization_header: ${{ secrets.UNITY_AUTH_HEADER }} ++ unity_service_account_key_id: ${{ secrets.UNITY_SA_KEY_ID }} ++ unity_service_account_secret_key: ${{ secrets.UNITY_SA_SECRET_KEY }} ``` -The response parser accepts both a single-element array (`[{ "build": 123, ... }]`, the v1 shape) and a bare object (`{ "build": 123, ... }`). If a newer version renames the `build` field, the action fails with the raw response body rather than silently reporting an empty build ID. +Your old API key cannot be reused in any form — v2 only accepts service account credentials. A `403` with otherwise valid credentials usually means the service account has no Build Automation role on the project. The action calls both cases out in its error message and includes the API's `requestId`, which Unity support asks for. + +Pointing `api_url` at a v1 URL fails immediately with a message telling you to pin an older release of this action, rather than sending a request that could only return `401`. + +If you are not ready to create a service account, stay on the previous release of this action until you are; v1 keeps working until Unity removes it. + +Reference: [Build Automation API v2](https://docs.unity.com/en-us/oas-build-automation-client/2.0.0) · [Unity's v1 → v2 migration guide](https://support.unity.com/hc/en-us/articles/51178448254356-How-do-I-migrate-my-Unity-Build-Automation-UBA-REST-API-calls-from-V1-to-V2) ## Behaviour notes - **Non-idempotent by design.** Triggering a build is not idempotent, so a request that reached Unity is never retried. Only DNS and connection failures — where nothing reached the server — are retried (up to 3 attempts). This avoids double-charging build minutes. -- **Secrets stay in one step.** The authorization header is passed to a single step's environment and masked in the logs. It is not written to `$GITHUB_ENV`, so it does not leak into other steps of the calling job. +- **Secrets stay in one step.** Credentials are passed to a single step's environment and masked in the logs — the secret key, the base64 credential derived from it, and the assembled header are all masked. Nothing is written to `$GITHUB_ENV`, so they do not leak into other steps of the calling job. - **The response body is not logged** by default. Re-run the workflow with debug logging enabled to see it. -- **Required inputs are validated before the request** — an empty org/project/target ID or an invalid `clean` value fails immediately instead of producing a confusing API error. -- **`curl` and `jq`** are used, and are preinstalled on GitHub-hosted runners. On runners that lack them, the action installs them via `apt-get` or `brew`, and fails with a clear message if neither is available. +- **Required inputs are validated before the request** — an empty org/project/target ID, a missing credential, or an invalid `clean` value fails immediately instead of producing a confusing API error. +- **A 2xx without a build ID is still a failure.** v2 can return `202` carrying only an `error` — typically because a build is already running for that target. The action reports the API's own explanation instead of an empty `build_id`. The parser accepts both the documented single-element array and a bare object. +- **`curl` and `jq`** are used, and are preinstalled on GitHub-hosted runners. On runners that lack them, the action installs them via `apt-get` or `brew`, and fails with a clear message if neither is available. `base64` (from coreutils) is also required and is present on any runner that has a shell. ## Development diff --git a/action.yml b/action.yml index 591ae4f..a87d0d9 100644 --- a/action.yml +++ b/action.yml @@ -14,11 +14,16 @@ inputs: build_target_id: description: 'Your Build Target ID' required: true - authorization_header: + unity_service_account_key_id: description: >- - Authorization header for the Unity Cloud Build API, e.g. - "Authorization: Basic ". A bare token is also accepted and gets the - "Authorization: " prefix added automatically. + Key ID of a Unity service account with a Build Automation role. Combined + with `unity_service_account_secret_key` into the HTTP Basic credential + required by API v2. The legacy Build Automation API key is not accepted. + required: true + unity_service_account_secret_key: + description: >- + Secret key of the Unity service account. Pass this as a secret; it is + masked in the logs. required: true branch: description: >- @@ -46,10 +51,9 @@ inputs: api_url: description: >- Base URL of the Unity Build Automation API, including the version segment. - Override this to move to a newer API version (e.g. ".../api/v2") without - changing the action. + This action speaks API v2 only; the v1 base URL is rejected. required: false - default: 'https://build-api.cloud.unity3d.com/api/v1' + default: 'https://build-automation.services.api.unity.com/v2' outputs: build_id: description: 'The ID (build number) of the triggered build.' @@ -57,6 +61,11 @@ outputs: platform: description: 'The platform of the build, as reported by the API.' value: '${{ steps.trigger-build.outputs.platform }}' + build_status: + description: >- + The status the build was created with, as reported by the API (e.g. + queued). Empty if the API did not report one. + value: '${{ steps.trigger-build.outputs.build_status }}' runs: using: 'composite' steps: @@ -111,7 +120,8 @@ runs: # the script, so that values containing shell metacharacters cannot be # executed as code. env: - INPUT_AUTHORIZATION_HEADER: ${{ inputs.authorization_header }} + INPUT_KEY_ID: ${{ inputs.unity_service_account_key_id }} + INPUT_SECRET_KEY: ${{ inputs.unity_service_account_secret_key }} INPUT_UNITY_ORG_ID: ${{ inputs.unity_org_id }} INPUT_UNITY_PROJECT_ID: ${{ inputs.unity_project_id }} INPUT_BUILD_TARGET_ID: ${{ inputs.build_target_id }} @@ -128,7 +138,8 @@ runs: printf '%s' "${1:-}" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' } - AUTH_HEADER="$(trim "${INPUT_AUTHORIZATION_HEADER:-}")" + KEY_ID="$(trim "${INPUT_KEY_ID:-}")" + SECRET_KEY="$(trim "${INPUT_SECRET_KEY:-}")" ORG_ID="$(trim "${INPUT_UNITY_ORG_ID:-}")" PROJ_ID="$(trim "${INPUT_UNITY_PROJECT_ID:-}")" TARGET_ID="$(trim "${INPUT_BUILD_TARGET_ID:-}")" @@ -139,8 +150,8 @@ runs: API_URL="${API_URL%/}" # Keep the credential out of the logs even if it was not passed as a secret. - if [ -n "$AUTH_HEADER" ]; then - echo "::add-mask::${AUTH_HEADER}" + if [ -n "$SECRET_KEY" ]; then + echo "::add-mask::${SECRET_KEY}" fi # Fail fast on missing required inputs instead of sending a doomed request. @@ -148,7 +159,8 @@ runs: "unity_org_id:${ORG_ID}" \ "unity_project_id:${PROJ_ID}" \ "build_target_id:${TARGET_ID}" \ - "authorization_header:${AUTH_HEADER}" \ + "unity_service_account_key_id:${KEY_ID}" \ + "unity_service_account_secret_key:${SECRET_KEY}" \ "api_url:${API_URL}" do name="${pair%%:*}" @@ -159,13 +171,19 @@ runs: fi done - # Accept a bare credential as well as a full header line. - case "$AUTH_HEADER" in - *:*) ;; - *) - echo "::warning ::'authorization_header' has no header name; assuming 'Authorization: '." - AUTH_HEADER="Authorization: ${AUTH_HEADER}" - echo "::add-mask::${AUTH_HEADER}" + # API v2 authenticates with HTTP Basic over base64(":"). + # `base64` wraps long lines on GNU coreutils, so strip the newlines. + BASIC_CREDENTIAL="$(printf '%s:%s' "$KEY_ID" "$SECRET_KEY" | base64 | tr -d '\r\n')" + echo "::add-mask::${BASIC_CREDENTIAL}" + AUTH_HEADER="Authorization: Basic ${BASIC_CREDENTIAL}" + echo "::add-mask::${AUTH_HEADER}" + + # This action speaks v2 only, and v1 does not accept service account + # credentials, so a v1 base URL can only ever produce a confusing 401. + case "$API_URL" in + *build-api.cloud.unity3d.com*) + echo "::error ::'api_url' points at Unity Build Automation API v1, which this action no longer supports (Unity removes it on 2026-12-21). Drop the 'api_url' override to use v2, or pin an older release of this action." + exit 1 ;; esac @@ -222,13 +240,13 @@ runs: break fi if [ "$attempt" -lt 3 ]; then - echo "::warning ::Could not connect to the Unity Cloud Build API (curl exit ${CURL_RC}); retrying (attempt ${attempt}/3)." + echo "::warning ::Could not connect to the Unity Build Automation API (curl exit ${CURL_RC}); retrying (attempt ${attempt}/3)." sleep $((attempt * 2)) fi done if [ "$CURL_RC" -ne 0 ]; then - echo "::error ::Request to the Unity Cloud Build API failed (curl exit ${CURL_RC}). No build was triggered." + echo "::error ::Request to the Unity Build Automation API failed (curl exit ${CURL_RC}). No build was triggered." exit 1 fi @@ -236,34 +254,55 @@ runs: echo "::debug ::HTTP ${HTTP_CODE} $(tr -d '\r\n' < "$RESPONSE_FILE")" fi - # The API returns a single-element array; tolerate a bare object too. + # A successful trigger returns HTTP 202 with a single-element array; + # tolerate a bare object too. Errors are always bare objects. FIRST_ENTRY='if type == "array" then (.[0] // {}) else . end' if [[ ! "$HTTP_CODE" =~ ^2 ]]; then - ERROR_MSG="$(jq -r "${FIRST_ENTRY} | (.error // .message // empty)" "$RESPONSE_FILE" 2>/dev/null || true)" + # v2 reports errors in the RFC 7807 shape: 'detail' carries the + # explanation, 'title' the status text, 'requestId' the trace ID. + ERROR_MSG="$(jq -r "${FIRST_ENTRY} | (.detail // .title // empty)" "$RESPONSE_FILE" 2>/dev/null || true)" + REQUEST_ID="$(jq -r "${FIRST_ENTRY} | (.requestId // empty)" "$RESPONSE_FILE" 2>/dev/null || true)" if [ -z "$ERROR_MSG" ]; then # Not JSON (or no message field) — fall back to a truncated raw body. ERROR_MSG="$(head -c 500 "$RESPONSE_FILE" | tr -d '\r\n')" fi - echo "::error ::Failed to trigger build: HTTP ${HTTP_CODE} – ${ERROR_MSG:-}" + if [ -n "$REQUEST_ID" ]; then + # Unity support asks for this when investigating a failed request. + ERROR_MSG="${ERROR_MSG} (requestId: ${REQUEST_ID})" + fi + if [ "$HTTP_CODE" = '401' ] || [ "$HTTP_CODE" = '403' ]; then + echo "::error ::Failed to trigger build: HTTP ${HTTP_CODE} – ${ERROR_MSG:-}. Check that the service account key and secret are correct and that the account has a Build Automation role on this project; the legacy Build Automation API key is not accepted by API v2." + else + echo "::error ::Failed to trigger build: HTTP ${HTTP_CODE} – ${ERROR_MSG:-}" + fi exit 1 fi BUILD_ID="$(jq -r "${FIRST_ENTRY} | (.build // empty)" "$RESPONSE_FILE" 2>/dev/null || true)" PLATFORM_OUT="$(jq -r "${FIRST_ENTRY} | (.platform // empty)" "$RESPONSE_FILE" 2>/dev/null || true)" + BUILD_STATUS="$(jq -r "${FIRST_ENTRY} | (.buildStatus // empty)" "$RESPONSE_FILE" 2>/dev/null || true)" if [ -z "$BUILD_ID" ]; then - echo "::error ::Unity Cloud Build returned HTTP ${HTTP_CODE} but no build ID: $(head -c 500 "$RESPONSE_FILE" | tr -d '\r\n')" + # A 2xx can still carry a per-target 'error' — e.g. a build for this + # target is already running — in which case no build was started. + ENTRY_ERROR="$(jq -r "${FIRST_ENTRY} | (.error // empty)" "$RESPONSE_FILE" 2>/dev/null || true)" + if [ -n "$ENTRY_ERROR" ]; then + echo "::error ::Unity Build Automation did not start a build: ${ENTRY_ERROR}" + else + echo "::error ::Unity Build Automation returned HTTP ${HTTP_CODE} but no build ID: $(head -c 500 "$RESPONSE_FILE" | tr -d '\r\n')" + fi exit 1 fi { echo "build_id=${BUILD_ID}" echo "platform=${PLATFORM_OUT}" + echo "build_status=${BUILD_STATUS}" } >> "$GITHUB_OUTPUT" { - echo "### Unity Cloud Build triggered" + echo "### Unity Build Automation build triggered" echo "" echo "| Field | Value |" echo "| --- | --- |" @@ -271,6 +310,7 @@ runs: echo "| Build target | \`${TARGET_ID}\` |" echo "| Branch | \`${BRANCH:-}\` |" echo "| Platform | \`${PLATFORM_OUT:-unknown}\` |" + echo "| Status | \`${BUILD_STATUS:-unknown}\` |" echo "| Clean | \`${CLEAN_FLAG}\` |" } >> "${GITHUB_STEP_SUMMARY:-/dev/null}"