From 0b56414067bd2a1b82fe2992512d081cd3cab8f8 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 14:54:45 -0300 Subject: [PATCH 01/13] test(code-repo): add offline script harness and CI --- .github/workflows/ci.yaml | 37 +++++++++++ tests/lib.sh | 136 ++++++++++++++++++++++++++++++++++++++ tests/run | 38 +++++++++++ tests/stubs/curl | 72 ++++++++++++++++++++ tests/stubs/git | 18 +++++ tests/stubs/np | 6 ++ 6 files changed, 307 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 tests/lib.sh create mode 100755 tests/run create mode 100755 tests/stubs/curl create mode 100755 tests/stubs/git create mode 100755 tests/stubs/np diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..60b78f6 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,37 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Lint + # Scoped to the files this plan adds or touches. The pre-existing + # gitlab/ and asset-repo/ scripts are NOT clean under shellcheck; fixing + # them is out of scope here and would bury this change in noise. + # + # SC1091: shellcheck cannot follow `source "$CURRENT_DIR/_api"`. + # SC2154: the workflow engine supplies CODE_REPOSITORY_STRATEGY, + # REPOSITORY_NAME, APPLICATION_ID, ... from the environment, so + # they are legitimately "referenced but not assigned" here. + run: | + shellcheck --shell=bash -e SC1091 -e SC2154 \ + scripts/code-repo/bitbucket/* \ + scripts/code-repo/generate_secrets \ + tests/run \ + tests/lib.sh \ + tests/stubs/* + + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run script tests + run: tests/run diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..3f18eb8 --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Shared helpers for the code-repo script tests. +# +# The scripts under test are SOURCED, not executed (see the top of +# scripts/code-repo/bitbucket/_api). run_step therefore sources them inside a +# command substitution: a bare `exit 1` in a step then ends that subshell -- it +# reports a failing step without killing the test runner. + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +setup() { + TEST_TMP=$(mktemp -d) + + BB_FIXTURES="$TEST_TMP/fixtures" + BB_CALLS="$TEST_TMP/calls.tsv" + + mkdir -p "$BB_FIXTURES" + : > "$BB_CALLS" + + export BB_FIXTURES BB_CALLS + export PATH="$REPO_ROOT/tests/stubs:$PATH" + + # The context that build_context exports for every later step. + export BITBUCKET_TOKEN="test-token" + export BITBUCKET_WORKSPACE="acme" + export BITBUCKET_PROJECT_KEY="APP" + export BITBUCKET_INSTALLATION_URL="https://bitbucket.org" + export BITBUCKET_API_BASE="https://api.bitbucket.org/2.0" + export REPOSITORY_SLUG="my-service" + export REPOSITORY_NAME="my-service" + export APPLICATION_ID="42" +} + +teardown() { + rm -rf "$TEST_TMP" +} + +# clear_context -- drop everything build_context is meant to derive for itself. +# setup() pre-exports that context because it is what the steps AFTER +# build_context consume; a build_context test must start from a clean slate. +clear_context() { + unset BITBUCKET_TOKEN BITBUCKET_WORKSPACE BITBUCKET_PROJECT_KEY \ + BITBUCKET_INSTALLATION_URL BITBUCKET_API_BASE BITBUCKET_AUTH_METHOD \ + BITBUCKET_OAUTH_KEY BITBUCKET_OAUTH_SECRET REPOSITORY_SLUG +} + +# fixture METHOD PATH CODE [BODY] +fixture() { + local method="$1" path="$2" code="$3" body="${4:-}" + local key + + key="${method}_$(printf '%s' "$path" | sed -e 's|[^A-Za-z0-9]|_|g')" + + printf '%s' "$code" > "$BB_FIXTURES/${key}.code" + printf '%s' "$body" > "$BB_FIXTURES/${key}.body" +} + +# fixture_seq METHOD PATH N CODE [BODY] -- response for the N-th call. +fixture_seq() { + local method="$1" path="$2" n="$3" code="$4" body="${5:-}" + local key + + key="${method}_$(printf '%s' "$path" | sed -e 's|[^A-Za-z0-9]|_|g')" + + printf '%s' "$code" > "$BB_FIXTURES/${key}.${n}.code" + printf '%s' "$body" > "$BB_FIXTURES/${key}.${n}.body" +} + +# run_step NAME -> STEP_OUTPUT, STEP_STATUS +run_step() { + local step="$1" + + STEP_OUTPUT=$(cd "$REPO_ROOT" && source "scripts/code-repo/bitbucket/$step" 2>&1) + STEP_STATUS=$? +} + +# capture_export NAME VAR -> the value the step exported into VAR +capture_export() { + local step="$1" var="$2" + + ( + cd "$REPO_ROOT" || exit 1 + # shellcheck disable=SC1090 + source "scripts/code-repo/bitbucket/$step" >/dev/null 2>&1 + printf '%s' "${!var}" + ) +} + +# request_body METHOD PATH -> the JSON body sent with that request +request_body() { + local method="$1" path="$2" + + grep -F "$(printf '%s\t%s\t' "$method" "$path")" "$BB_CALLS" | head -1 | cut -f3 +} + +assert_status() { + local expected="$1" + + if [[ "$STEP_STATUS" != "$expected" ]]; then + echo " FAIL: expected exit status $expected, got $STEP_STATUS" + echo " step output:" + printf '%s\n' "$STEP_OUTPUT" | sed 's/^/ /' + return 1 + fi +} + +assert_contains() { + local needle="$1" + local haystack="${2:-$STEP_OUTPUT}" + + if [[ "$haystack" != *"$needle"* ]]; then + echo " FAIL: expected to find '$needle' in:" + printf '%s\n' "$haystack" | sed 's/^/ /' + return 1 + fi +} + +assert_called() { + local method="$1" path="$2" + + if ! grep -qF "$(printf '%s\t%s\t' "$method" "$path")" "$BB_CALLS"; then + echo " FAIL: expected a '$method $path' call. Calls made:" + sed 's/^/ /' "$BB_CALLS" + return 1 + fi +} + +assert_called_git() { + local subcommand="$1" + + if ! grep -qE "^git .*(^|[[:space:]])${subcommand}([[:space:]]|$)" "$BB_CALLS"; then + echo " FAIL: expected a 'git $subcommand'. Calls made:" + sed 's/^/ /' "$BB_CALLS" + return 1 + fi +} diff --git a/tests/run b/tests/run new file mode 100755 index 0000000..4ff2a8d --- /dev/null +++ b/tests/run @@ -0,0 +1,38 @@ +#!/bin/bash +# Test runner for the code-repo provider scripts. +# +# tests/run # everything +# tests/run # one case +# +# Each file in tests/cases is an independent bash script that exits non-zero on +# failure. Deliberately no bats: this repo ships nothing but bash, curl and jq, +# and its tests should need nothing more either. + +cd "$(dirname "$0")/.." || exit 1 + +pass=0 +fail=0 + +for case_file in tests/cases/*; do + [[ -f "$case_file" ]] || continue + + name=$(basename "$case_file") + + if [[ -n "$1" && "$name" != "$1" ]]; then + continue + fi + + if output=$(bash "$case_file" 2>&1); then + echo "ok $name" + pass=$((pass + 1)) + else + echo "FAIL $name" + printf '%s\n' "$output" | sed 's/^/ /' + fail=$((fail + 1)) + fi +done + +echo "" +echo "$pass passed, $fail failed" + +[[ "$fail" -eq 0 ]] diff --git a/tests/stubs/curl b/tests/stubs/curl new file mode 100755 index 0000000..a5fff1b --- /dev/null +++ b/tests/stubs/curl @@ -0,0 +1,72 @@ +#!/bin/bash +# Fixture-driven `curl` stub used by tests/run. +# +# Resolves a canned response by METHOD + request path from $BB_FIXTURES, and +# appends every request (method, path, body) to $BB_CALLS so tests can assert on +# what was actually sent. +# +# Honours the exact flag shapes the scripts use: +# bb_api: -s -o FILE -w '%{http_code}' -X METHOD -H ... [--data BODY] URL +# OAuth mint: -s -u KEY:SECRET -X POST -d grant_type=client_credentials URL +# +# As with real curl, the status code is echoed to stdout ONLY when -w asks for +# it; the OAuth mint pipes raw stdout into jq and must not see a code appended. + +method="GET" +out="" +data="" +url="" +want_code=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -X) method="$2"; shift 2 ;; + -o) out="$2"; shift 2 ;; + -w) want_code=1; shift 2 ;; + -H|-u) shift 2 ;; + -d) data="$2"; shift 2 ;; + --data) data="$2"; shift 2 ;; + -s|-S|-f) shift ;; + *) url="$1"; shift ;; + esac +done + +path="${url#https://api.bitbucket.org/2.0}" + +printf '%s\t%s\t%s\n' "$method" "$path" "$data" >> "$BB_CALLS" + +key="${method}_$(printf '%s' "$path" | sed -e 's|[^A-Za-z0-9]|_|g')" + +# Per-key call counter, so a test can queue a different response for the 2nd +# call to the same endpoint (the 409 -> re-list -> PUT flow needs this). +count_file="$BB_FIXTURES/.count_${key}" +n=1 +if [[ -f "$count_file" ]]; then + n=$(( $(cat "$count_file") + 1 )) +fi +printf '%s' "$n" > "$count_file" + +code_file="$BB_FIXTURES/${key}.${n}.code" +body_file="$BB_FIXTURES/${key}.${n}.body" + +if [[ ! -f "$code_file" ]]; then + code_file="$BB_FIXTURES/${key}.code" + body_file="$BB_FIXTURES/${key}.body" +fi + +# 599 = "the script made a call the test never stubbed". Fail loudly. +code="599" +body="" + +[[ -f "$code_file" ]] && code=$(cat "$code_file") +[[ -f "$body_file" ]] && body=$(cat "$body_file") + +if [[ -n "$out" ]]; then + printf '%s' "$body" > "$out" +else + printf '%s' "$body" +fi + +if [[ "$want_code" -eq 1 ]]; then + printf '%s' "$code" +fi diff --git a/tests/stubs/git b/tests/stubs/git new file mode 100755 index 0000000..9e81ab8 --- /dev/null +++ b/tests/stubs/git @@ -0,0 +1,18 @@ +#!/bin/bash +# `git` stub: records the invocation and fakes a clone. + +printf 'git\t%s\n' "$*" >> "$BB_CALLS" + +# Emulate `git clone --depth 1 `: create_repository expects the +# target directory to exist and hold the template's files afterwards. +if [[ "$1" == "clone" ]]; then + target="" + for arg in "$@"; do + target="$arg" + done + + mkdir -p "$target/.git" + echo "template" > "$target/README.md" +fi + +exit 0 diff --git a/tests/stubs/np b/tests/stubs/np new file mode 100755 index 0000000..1684f45 --- /dev/null +++ b/tests/stubs/np @@ -0,0 +1,6 @@ +#!/bin/bash +# `np` CLI stub: records the invocation, returns empty JSON. + +printf 'np\t%s\n' "$*" >> "$BB_CALLS" + +echo '{}' From d1910ceb1d670a12250bc7bb953f0f4fd945247b Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 14:56:38 -0300 Subject: [PATCH 02/13] feat(code-repo): add bitbucket build_context with dual auth --- scripts/code-repo/bitbucket/_api | 109 +++++++++++ scripts/code-repo/bitbucket/build_context | 178 ++++++++++++++++++ tests/cases/build_context_mints_oauth_token | 36 ++++ tests/cases/build_context_normalises_slug | 24 +++ .../cases/build_context_requires_project_key | 20 ++ 5 files changed, 367 insertions(+) create mode 100644 scripts/code-repo/bitbucket/_api create mode 100644 scripts/code-repo/bitbucket/build_context create mode 100755 tests/cases/build_context_mints_oauth_token create mode 100755 tests/cases/build_context_normalises_slug create mode 100755 tests/cases/build_context_requires_project_key diff --git a/scripts/code-repo/bitbucket/_api b/scripts/code-repo/bitbucket/_api new file mode 100644 index 0000000..2d9c59e --- /dev/null +++ b/scripts/code-repo/bitbucket/_api @@ -0,0 +1,109 @@ +#!/bin/bash +# +# Shared Bitbucket Cloud REST helpers. Sourced by the step scripts in this +# directory; the leading underscore marks it as "not a workflow step". +# +# IMPORTANT -- every script in this repository is SOURCED by +# `np service workflow exec`, not executed. (Proof: gitlab/create_repository +# uses a bare top-level `return`, and none of these files carry an executable +# bit.) Consequences: +# +# * NEVER `set -euo pipefail` here. It would leak into the workflow shell and +# silently change the behaviour of every later step. +# * `exit 1` aborts the sourcing shell -- that IS how a step fails the +# workflow, and entrypoint then reports "failed" back to nullplatform. +# * `return` skips the rest of a step without failing the workflow. +# +# Auth is resolved once, in build_context, which exports a single bearer token +# as BITBUCKET_TOKEN (from either a Workspace Access Token or a minted OAuth 2LO +# token). Nothing else in this directory knows or cares which. + +# bb_api METHOD PATH [BODY] +# +# Calls the Bitbucket REST API and sets BB_HTTP_CODE and BB_BODY. It never +# fails: every caller branches on the status code, because for Bitbucket the +# code is the meaning (404 = "free" on one endpoint, "never enabled" on +# another; 409 = "exists"; 401 != "missing"). +bb_api() { + local method="$1" + local path="$2" + local body="${3:-}" + + local tmp + tmp=$(mktemp) + + local args=( + -s + -o "$tmp" + -w '%{http_code}' + -X "$method" + -H "Authorization: Bearer ${BITBUCKET_TOKEN}" + -H "Content-Type: application/json" + ) + + if [[ -n "$body" ]]; then + args+=(--data "$body") + fi + + BB_HTTP_CODE=$(curl "${args[@]}" "${BITBUCKET_API_BASE}${path}") + BB_BODY=$(cat "$tmp") + + rm -f "$tmp" +} + +# bb_encode_uuid VALUE +# +# Bitbucket returns UUIDs with LITERAL braces -- {9484702e-...} -- and they must +# be percent-encoded before use as a path parameter. +bb_encode_uuid() { + local value="$1" + + value="${value//\{/%7B}" + value="${value//\}/%7D}" + + printf '%s' "$value" +} + +# bb_scrub +# +# stdin filter that redacts the bearer token. git prints credentialed remote +# URLs verbatim on failure, so every byte of git output goes through this. +bb_scrub() { + if [[ -z "${BITBUCKET_TOKEN:-}" ]]; then + cat + return + fi + + local escaped + # Escape regex metacharacters (including /) so any token value is literal. + escaped=$(printf '%s' "$BITBUCKET_TOKEN" | sed -e 's/[]\/$*.^[]/\\&/g') + + sed -e "s/${escaped}/***/g" +} + +# bb_git ARGS... +# +# Runs git with the terminal prompt disabled, scrubbing the token out of its +# output, and returns GIT's exit status -- not sed's. (A naive +# `git ... | bb_scrub` would always look successful, because a pipeline's status +# is its LAST command. We cannot use `set -o pipefail` to fix that: see the +# header.) +bb_git() { + local output + local status + + output=$(GIT_TERMINAL_PROMPT=0 git "$@" 2>&1) + status=$? + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" | bb_scrub + fi + + return $status +} + +# bb_fail MESSAGE... +bb_fail() { + echo "Error: $*" >&2 + exit 1 +} diff --git a/scripts/code-repo/bitbucket/build_context b/scripts/code-repo/bitbucket/build_context new file mode 100644 index 0000000..87d987b --- /dev/null +++ b/scripts/code-repo/bitbucket/build_context @@ -0,0 +1,178 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +# --- workspace ------------------------------------------------------------- + +if [[ -n "${BITBUCKET_WORKSPACE:-}" ]]; then + echo "Using BITBUCKET_WORKSPACE from environment: $BITBUCKET_WORKSPACE" +else + BITBUCKET_WORKSPACE=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.workspace // empty') + echo "No BITBUCKET_WORKSPACE in environment, using value from platform: $BITBUCKET_WORKSPACE" +fi + +if [[ -z "$BITBUCKET_WORKSPACE" ]]; then + bb_fail "BITBUCKET_WORKSPACE is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.workspace) or in the environment." +fi + +# --- installation URL and API base ----------------------------------------- + +if [[ -n "${BITBUCKET_INSTALLATION_URL:-}" ]]; then + echo "Using BITBUCKET_INSTALLATION_URL from environment: $BITBUCKET_INSTALLATION_URL" +else + BITBUCKET_INSTALLATION_URL=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.installation_url // empty') + echo "No BITBUCKET_INSTALLATION_URL in environment, using value from platform: $BITBUCKET_INSTALLATION_URL" +fi + +if [[ -z "$BITBUCKET_INSTALLATION_URL" ]]; then + BITBUCKET_INSTALLATION_URL="https://bitbucket.org" + echo "No installation_url configured, defaulting to: $BITBUCKET_INSTALLATION_URL" +fi + +# Strip any trailing slash so the URLs we build never double up. +BITBUCKET_INSTALLATION_URL="${BITBUCKET_INSTALLATION_URL%/}" + +# Bitbucket Cloud serves its REST API from a DIFFERENT host than the web UI. +# Keeping this a separate variable is the seam a future Data Center flavor would +# use (DC serves REST 1.0 from /rest/api/1.0). +if [[ -z "${BITBUCKET_API_BASE:-}" ]]; then + BITBUCKET_API_BASE="https://api.bitbucket.org/2.0" +fi + +BITBUCKET_API_BASE="${BITBUCKET_API_BASE%/}" + +# --- auth ------------------------------------------------------------------ +# +# Two first-class mechanisms (ADR-2). Bitbucket app passwords are REMOVED as of +# 2026-07-28 and are deliberately not supported. This block is the only place in +# the provider that knows which mechanism is in use: it resolves both down to a +# single bearer token, BITBUCKET_TOKEN. + +if [[ -n "${BITBUCKET_AUTH_METHOD:-}" ]]; then + echo "Using BITBUCKET_AUTH_METHOD from environment: $BITBUCKET_AUTH_METHOD" +else + BITBUCKET_AUTH_METHOD=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.auth_method // empty') + echo "No BITBUCKET_AUTH_METHOD in environment, using value from platform: $BITBUCKET_AUTH_METHOD" +fi + +if [[ -z "$BITBUCKET_AUTH_METHOD" ]]; then + BITBUCKET_AUTH_METHOD="workspace_access_token" + echo "No auth_method configured, defaulting to: $BITBUCKET_AUTH_METHOD" +fi + +case "$BITBUCKET_AUTH_METHOD" in + workspace_access_token) + # The exact analogue of a GitLab group access token: workspace-scoped, not + # tied to a human, long-lived. REQUIRES BITBUCKET PREMIUM. + if [[ -n "${BITBUCKET_TOKEN:-}" ]]; then + echo "Using BITBUCKET_TOKEN from environment (hidden)" + else + BITBUCKET_TOKEN=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.access_token // empty') + echo "No BITBUCKET_TOKEN in environment, using the workspace access token from platform (hidden)" + fi + + if [[ -z "$BITBUCKET_TOKEN" ]]; then + bb_fail "auth_method is 'workspace_access_token' but no token is configured on the bitbucket-configuration provider (.attributes.setup.access_token). Note that workspace access tokens require Bitbucket Premium -- if this workspace is not on Premium, set auth_method to 'oauth2'." + fi + ;; + + oauth2) + # OAuth 2.0 consumer, client_credentials (2LO). Works on ALL Bitbucket + # plans. The token represents the consumer's owner -- the workspace -- not + # an employee, so it survives staff turnover. + # + # LIFETIME: the token lives 2 hours and this grant returns NO refresh token + # (you re-mint from the long-lived key/secret). An ALM run is a single + # application-creation hook -- seconds to low minutes -- so we mint exactly + # ONCE, here, and never refresh. That is deliberate: a cache or a refresh + # loop would be YAGNI. Accepted consequence: a run lasting >2h would fail + # with 401 partway through. If that ever becomes real, re-mint inside bb_api + # on a 401 rather than building a background refresher. + OAUTH_KEY="${BITBUCKET_OAUTH_KEY:-}" + OAUTH_SECRET="${BITBUCKET_OAUTH_SECRET:-}" + + if [[ -z "$OAUTH_KEY" ]]; then + OAUTH_KEY=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.oauth_key // empty') + fi + + if [[ -z "$OAUTH_SECRET" ]]; then + OAUTH_SECRET=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.oauth_secret // empty') + fi + + if [[ -z "$OAUTH_KEY" || -z "$OAUTH_SECRET" ]]; then + bb_fail "auth_method is 'oauth2' but oauth_key/oauth_secret are not configured on the bitbucket-configuration provider (.attributes.setup.oauth_key, .attributes.setup.oauth_secret)." + fi + + echo "Minting a Bitbucket OAuth 2.0 access token (client_credentials) for workspace: $BITBUCKET_WORKSPACE" + + OAUTH_RESPONSE=$(curl -s -u "${OAUTH_KEY}:${OAUTH_SECRET}" \ + -X POST "${BITBUCKET_INSTALLATION_URL}/site/oauth2/access_token" \ + -d grant_type=client_credentials) + + BITBUCKET_TOKEN=$(echo "$OAUTH_RESPONSE" | jq -r '.access_token // empty' 2>/dev/null) + + if [[ -z "$BITBUCKET_TOKEN" ]]; then + # Never echo OAUTH_RESPONSE wholesale -- on success it contains the token. + bb_fail "Could not mint an OAuth token for workspace '$BITBUCKET_WORKSPACE'. Bitbucket said: $(echo "$OAUTH_RESPONSE" | jq -r '.error_description // .error // "unknown error"' 2>/dev/null)" + fi + + echo "Minted an OAuth access token (hidden), expires in $(echo "$OAUTH_RESPONSE" | jq -r '.expires_in // "?"')s" + ;; + + *) + bb_fail "Unsupported auth_method '$BITBUCKET_AUTH_METHOD'. Expected 'workspace_access_token' or 'oauth2'. (Bitbucket app passwords were removed on 2026-07-28 and are not supported.)" + ;; +esac + +# --- project key ----------------------------------------------------------- +# +# Effectively MANDATORY. Omit project.key when creating a repository and +# Bitbucket does not error -- it SILENTLY files the repo under the workspace's +# OLDEST project. Refuse to run rather than mis-file the customer's repository. + +if [[ -n "${BITBUCKET_PROJECT_KEY:-}" ]]; then + echo "Using BITBUCKET_PROJECT_KEY from environment: $BITBUCKET_PROJECT_KEY" +else + BITBUCKET_PROJECT_KEY=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.project_key // empty') + echo "No BITBUCKET_PROJECT_KEY in environment, using value from platform: $BITBUCKET_PROJECT_KEY" +fi + +if [[ -z "$BITBUCKET_PROJECT_KEY" ]]; then + bb_fail "BITBUCKET_PROJECT_KEY is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.project_key) or in the environment. It cannot be omitted: Bitbucket would silently assign the new repository to the oldest project in the workspace." +fi + +# --- repository slug ------------------------------------------------------- +# +# REPOSITORY_NAME is `basename "$REPOSITORY_URL"` (see +# scripts/code-repo/create_code_repository), so it may carry a .git suffix and +# arbitrary case. Bitbucket slugifies the name it is given: send "My-Service" +# and the repository is created at .../my-service, so links.html.href would come +# back different from what we asked for -- and Contract A (core-entities matches +# repository_url by EXACT string) would break. Normalise here, once. + +REPOSITORY_SLUG="${REPOSITORY_NAME%.git}" +REPOSITORY_SLUG=$(echo "$REPOSITORY_SLUG" | tr '[:upper:]' '[:lower:]') + +if [[ -z "$REPOSITORY_SLUG" ]]; then + bb_fail "Could not derive a repository slug from REPOSITORY_NAME='$REPOSITORY_NAME'" +fi + +if [[ "$REPOSITORY_SLUG" != "$REPOSITORY_NAME" ]]; then + echo "Normalised repository name '$REPOSITORY_NAME' to Bitbucket slug '$REPOSITORY_SLUG'" +fi + +echo "Bitbucket workspace: $BITBUCKET_WORKSPACE" +echo "Bitbucket project: $BITBUCKET_PROJECT_KEY" +echo "Bitbucket repository: $REPOSITORY_SLUG" +echo "Bitbucket auth: $BITBUCKET_AUTH_METHOD" + +export BITBUCKET_TOKEN +export BITBUCKET_AUTH_METHOD +export BITBUCKET_WORKSPACE +export BITBUCKET_PROJECT_KEY +export BITBUCKET_INSTALLATION_URL +export BITBUCKET_API_BASE +export REPOSITORY_SLUG diff --git a/tests/cases/build_context_mints_oauth_token b/tests/cases/build_context_mints_oauth_token new file mode 100755 index 0000000..e4bfcc3 --- /dev/null +++ b/tests/cases/build_context_mints_oauth_token @@ -0,0 +1,36 @@ +#!/bin/bash +# 2LO works on every Bitbucket plan, unlike a workspace access token (Premium +# only), so it must produce a BITBUCKET_TOKEN exactly like the WAT path does. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="my-service" +export CODE_REPOSITORY='{"attributes":{"setup":{ + "auth_method":"oauth2", + "workspace":"acme", + "project_key":"APP", + "oauth_key":"KEY123", + "oauth_secret":"SECRET456" +}}}' + +fixture POST "https://bitbucket.org/site/oauth2/access_token" 200 \ + '{"access_token":"minted-token","expires_in":7200,"token_type":"bearer"}' + +token=$(capture_export build_context BITBUCKET_TOKEN) + +if [[ "$token" != "minted-token" ]]; then + echo " FAIL: expected the minted OAuth token, got '$token'" + exit 1 +fi + +run_step build_context +assert_status 0 || exit 1 + +# The secret must never reach the logs. +if [[ "$STEP_OUTPUT" == *"SECRET456"* || "$STEP_OUTPUT" == *"minted-token"* ]]; then + echo " FAIL: credentials leaked into the step output:" + printf '%s\n' "$STEP_OUTPUT" | sed 's/^/ /' + exit 1 +fi diff --git a/tests/cases/build_context_normalises_slug b/tests/cases/build_context_normalises_slug new file mode 100755 index 0000000..b8df25a --- /dev/null +++ b/tests/cases/build_context_normalises_slug @@ -0,0 +1,24 @@ +#!/bin/bash +# REPOSITORY_NAME comes from `basename "$REPOSITORY_URL"` and is neither +# lower-cased nor .git-stripped. Bitbucket slugifies, so an un-normalised name +# would create the repo at a path we did not ask for -- and links.html.href +# would then disagree with the application's repository_url (Contract A). +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="My-Service.git" +export CODE_REPOSITORY='{"attributes":{"setup":{ + "auth_method":"workspace_access_token", + "workspace":"acme", + "project_key":"APP", + "access_token":"wat-token" +}}}' + +slug=$(capture_export build_context REPOSITORY_SLUG) + +if [[ "$slug" != "my-service" ]]; then + echo " FAIL: expected slug 'my-service', got '$slug'" + exit 1 +fi diff --git a/tests/cases/build_context_requires_project_key b/tests/cases/build_context_requires_project_key new file mode 100755 index 0000000..463d680 --- /dev/null +++ b/tests/cases/build_context_requires_project_key @@ -0,0 +1,20 @@ +#!/bin/bash +# project.key is effectively mandatory: omit it on create and Bitbucket SILENTLY +# files the repo under the workspace's OLDEST project. Fail early and loudly +# rather than mis-file the customer's repository. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="my-service" +export CODE_REPOSITORY='{"attributes":{"setup":{ + "auth_method":"workspace_access_token", + "workspace":"acme", + "access_token":"wat-token" +}}}' + +run_step build_context + +assert_status 1 || exit 1 +assert_contains "BITBUCKET_PROJECT_KEY is not set" || exit 1 From 5c037e01fcc65c3d7795be177d81fdd7de4dfa77 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 14:57:36 -0300 Subject: [PATCH 03/13] feat(code-repo): validate bitbucket repository existence --- .../validate_repository_does_not_exist | 51 +++++++++++++++++++ tests/cases/validate_401_is_not_free | 17 +++++++ tests/cases/validate_404_means_free | 14 +++++ .../validate_existing_repo_on_create_fails | 14 +++++ 4 files changed, 96 insertions(+) create mode 100644 scripts/code-repo/bitbucket/validate_repository_does_not_exist create mode 100755 tests/cases/validate_401_is_not_free create mode 100755 tests/cases/validate_404_means_free create mode 100755 tests/cases/validate_existing_repo_on_create_fails diff --git a/scripts/code-repo/bitbucket/validate_repository_does_not_exist b/scripts/code-repo/bitbucket/validate_repository_does_not_exist new file mode 100644 index 0000000..9ff8d43 --- /dev/null +++ b/scripts/code-repo/bitbucket/validate_repository_does_not_exist @@ -0,0 +1,51 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +echo "Checking if repository exists: ${BITBUCKET_INSTALLATION_URL}/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" + +bb_api GET "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" + +case "$BB_HTTP_CODE" in + 200) + REPOSITORY_EXISTS="true" + ;; + 404) + # 404 is the ONLY response that means "this slug is free". + REPOSITORY_EXISTS="false" + ;; + 401 | 403) + bb_fail "Bitbucket rejected the credentials (HTTP $BB_HTTP_CODE) while checking ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}. This is NOT 'repository does not exist'. Check the ${BITBUCKET_AUTH_METHOD} credentials and that they carry the repository:admin scope. Response: $BB_BODY" + ;; + *) + bb_fail "Unexpected response from Bitbucket (HTTP $BB_HTTP_CODE) while checking ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}. Response: $BB_BODY" + ;; +esac + +if [[ "$REPOSITORY_EXISTS" == "true" ]]; then + if [[ "$CODE_REPOSITORY_STRATEGY" == "create" ]]; then + bb_fail "Repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} already exists but strategy is set to 'create'. Please use a different repository name or change strategy to 'import'." + fi + + # Contract A: take the canonical URL from links.html.href. Never synthesise + # it, and never read links.clone[] -- that array's https entry carries a .git + # suffix and has historically embedded userinfo. + BITBUCKET_REPOSITORY_URL=$(echo "$BB_BODY" | jq -r '.links.html.href // empty') + BITBUCKET_MAIN_BRANCH=$(echo "$BB_BODY" | jq -r '.mainbranch.name // "main"') + + if [[ -z "$BITBUCKET_REPOSITORY_URL" ]]; then + bb_fail "Bitbucket did not return links.html.href for ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}: $BB_BODY" + fi + + export BITBUCKET_REPOSITORY_URL + export BITBUCKET_MAIN_BRANCH + + echo "Repository found ($BITBUCKET_REPOSITORY_URL, default branch '$BITBUCKET_MAIN_BRANCH') and strategy is set to 'import'. Proceeding with import configuration..." +elif [[ "$CODE_REPOSITORY_STRATEGY" == "import" ]]; then + bb_fail "Repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} does not exist but strategy is set to 'import'. Please create the repository first or change strategy to 'create'." +else + echo "Repository does not exist and strategy is set to 'create'. Proceeding with repository creation..." +fi diff --git a/tests/cases/validate_401_is_not_free b/tests/cases/validate_401_is_not_free new file mode 100755 index 0000000..df29a5e --- /dev/null +++ b/tests/cases/validate_401_is_not_free @@ -0,0 +1,17 @@ +#!/bin/bash +# The dangerous one. A 401/403 is NOT "the slug is free": treating it as such +# would send us on to create a repository we cannot see, and the failure would +# surface much later as something unrelated. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" + +fixture GET "/repositories/acme/my-service" 401 \ + '{"type":"error","error":{"message":"Unauthorized"}}' + +run_step validate_repository_does_not_exist + +assert_status 1 || exit 1 +assert_contains "This is NOT 'repository does not exist'" || exit 1 diff --git a/tests/cases/validate_404_means_free b/tests/cases/validate_404_means_free new file mode 100755 index 0000000..0016fcc --- /dev/null +++ b/tests/cases/validate_404_means_free @@ -0,0 +1,14 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" + +fixture GET "/repositories/acme/my-service" 404 '{"type":"error"}' + +run_step validate_repository_does_not_exist + +assert_status 0 || exit 1 +assert_called GET "/repositories/acme/my-service" || exit 1 +assert_contains "Repository does not exist and strategy is set to 'create'" || exit 1 diff --git a/tests/cases/validate_existing_repo_on_create_fails b/tests/cases/validate_existing_repo_on_create_fails new file mode 100755 index 0000000..76c44ac --- /dev/null +++ b/tests/cases/validate_existing_repo_on_create_fails @@ -0,0 +1,14 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" + +fixture GET "/repositories/acme/my-service" 200 \ + '{"links":{"html":{"href":"https://bitbucket.org/acme/my-service"}},"mainbranch":{"name":"main"}}' + +run_step validate_repository_does_not_exist + +assert_status 1 || exit 1 +assert_contains "already exists but strategy is set to 'create'" || exit 1 From 4a184b8e3cf0672cbdfc879fa28d73d709a4f7da Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 15:04:02 -0300 Subject: [PATCH 04/13] fix(tests): match git subcommands after the leading tab in assert_called_git --- tests/lib.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/lib.sh b/tests/lib.sh index 3f18eb8..c900a80 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -128,7 +128,12 @@ assert_called() { assert_called_git() { local subcommand="$1" - if ! grep -qE "^git .*(^|[[:space:]])${subcommand}([[:space:]]|$)" "$BB_CALLS"; then + # Records are "git". Match the subcommand as a whitespace- + # delimited token anywhere in the args. The leading TAB is itself a boundary, + # so the first alternative matches a subcommand that comes immediately after + # it (clone, init, add), while `.*[[:space:]]` matches one that follows other + # args (push after `-C `). + if ! grep -qE "^git([[:space:]]|.*[[:space:]])${subcommand}([[:space:]]|$)" "$BB_CALLS"; then echo " FAIL: expected a 'git $subcommand'. Calls made:" sed 's/^/ /' "$BB_CALLS" return 1 From c87652698e4a5ec11956f298fe2a4d3d4b3db768 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 15:04:02 -0300 Subject: [PATCH 05/13] feat(code-repo): create and seed bitbucket repositories --- scripts/code-repo/bitbucket/create_repository | 155 ++++++++++++++++++ ...reate_repository_reconciles_repository_url | 24 +++ tests/cases/create_repository_skips_on_import | 18 ++ tests/cases/create_repository_uses_html_href | 45 +++++ 4 files changed, 242 insertions(+) create mode 100644 scripts/code-repo/bitbucket/create_repository create mode 100755 tests/cases/create_repository_reconciles_repository_url create mode 100755 tests/cases/create_repository_skips_on_import create mode 100755 tests/cases/create_repository_uses_html_href diff --git a/scripts/code-repo/bitbucket/create_repository b/scripts/code-repo/bitbucket/create_repository new file mode 100644 index 0000000..3aca782 --- /dev/null +++ b/scripts/code-repo/bitbucket/create_repository @@ -0,0 +1,155 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +if [[ "$CODE_REPOSITORY_STRATEGY" == "import" ]]; then + echo "Strategy is set to 'import'. Skipping repo creation." + return +fi + +if ! command -v git >/dev/null 2>&1; then + bb_fail "git is required to seed a Bitbucket repository from a template (Bitbucket Cloud has no import API) but it was not found on PATH." +fi + +# --------------------------------------------------------------------------- +# 1. Create the (empty) repository. +# +# project.key is not optional in practice: omit it and Bitbucket does not error, +# it silently files the repository under the workspace's OLDEST project. +# --------------------------------------------------------------------------- + +CREATE_REPO_BODY=$(jq -nc \ + --arg project_key "$BITBUCKET_PROJECT_KEY" \ + '{scm: "git", is_private: true, fork_policy: "no_forks", project: {key: $project_key}}') + +echo "Creating repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} with body: " +echo "$CREATE_REPO_BODY" | jq . + +bb_api POST "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" "$CREATE_REPO_BODY" + +# Bitbucket answers repository creation with 200, not the 201 you would expect. +# 201 is accepted defensively in case that is ever corrected. +if [[ "$BB_HTTP_CODE" != "200" && "$BB_HTTP_CODE" != "201" ]]; then + bb_fail "Error creating repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} (HTTP $BB_HTTP_CODE): $BB_BODY" +fi + +# Contract A: the canonical repository_url is exactly what the API returned in +# links.html.href -- https://bitbucket.org/{workspace}/{slug}, no .git, no +# trailing slash. NEVER synthesise it, and NEVER read links.clone[]: that +# array's https entry carries .git and may embed userinfo, and its order is not +# contractual. +BITBUCKET_REPOSITORY_URL=$(echo "$BB_BODY" | jq -r '.links.html.href // empty') + +if [[ -z "$BITBUCKET_REPOSITORY_URL" ]]; then + bb_fail "Bitbucket did not return links.html.href for the new repository: $BB_BODY" +fi + +# We push the seed commit to 'main' below, which makes it the default branch. +BITBUCKET_MAIN_BRANCH="main" + +echo "Repository created: $BITBUCKET_REPOSITORY_URL" + +# --------------------------------------------------------------------------- +# 2. Seed from the template (ADR-1). +# +# Bitbucket Cloud has NO import-from-URL API, and forking is dead (cross- +# workspace forks were removed on 2026-07-01; same-workspace forks carry an +# undetachable parent link). A real git clone + push is the only full-fidelity +# option -- and here in ALM we are already in bash with git and a working tree, +# so it is also the natural one. +# +# We squash to a single root commit so the customer's repository does not +# inherit nullplatform's template history: clone --depth 1, drop .git, re-init. +# --------------------------------------------------------------------------- + +if [[ -z "${TEMPLATE_URL:-}" || "$TEMPLATE_URL" == "null" ]]; then + echo "No TEMPLATE_URL set (the application has no template_id); leaving $BITBUCKET_REPOSITORY_URL empty." +else + # The literal string 'x-token-auth' is required by Bitbucket. It works for + # both a workspace access token and a minted OAuth 2LO token. + PUSH_URL="https://x-token-auth:${BITBUCKET_TOKEN}@bitbucket.org/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}.git" + + CLONE_URL="$TEMPLATE_URL" + + # A template hosted in Bitbucket is private, so cloning it needs the same + # token. A template on GitHub (the usual case) is public and needs none. + if [[ "$TEMPLATE_URL" == "https://bitbucket.org/"* ]]; then + TEMPLATE_PATH="${TEMPLATE_URL#https://bitbucket.org/}" + TEMPLATE_PATH="${TEMPLATE_PATH%.git}" + CLONE_URL="https://x-token-auth:${BITBUCKET_TOKEN}@bitbucket.org/${TEMPLATE_PATH}.git" + fi + + SEED_DIR=$(mktemp -d) + + echo "Seeding $BITBUCKET_REPOSITORY_URL from template $TEMPLATE_URL" + + if ! bb_git clone --depth 1 "$CLONE_URL" "$SEED_DIR"; then + rm -rf "$SEED_DIR" + bb_fail "Failed to clone the template $TEMPLATE_URL" + fi + + # Drop the template's history entirely. + rm -rf "${SEED_DIR:?}/.git" + + if ! bb_git -C "$SEED_DIR" init; then + rm -rf "$SEED_DIR" + bb_fail "Failed to initialise the seed repository" + fi + + if ! bb_git -C "$SEED_DIR" add -A; then + rm -rf "$SEED_DIR" + bb_fail "Failed to stage the template files" + fi + + # The agent container has no git identity configured, so pass one inline + # rather than mutating global git config. + if ! bb_git -C "$SEED_DIR" \ + -c user.email="ci@nullplatform.com" \ + -c user.name="nullplatform" \ + commit -m "Initial commit from template"; then + rm -rf "$SEED_DIR" + bb_fail "Failed to create the initial commit" + fi + + # Push HEAD to an explicit ref, so the local branch name (which varies with + # the git version's init.defaultBranch) does not matter. This is the first ref + # in the repo, so Bitbucket makes it the default branch. + if ! bb_git -C "$SEED_DIR" push "$PUSH_URL" "HEAD:refs/heads/${BITBUCKET_MAIN_BRANCH}"; then + rm -rf "$SEED_DIR" + bb_fail "Failed to push the template to $BITBUCKET_REPOSITORY_URL" + fi + + rm -rf "$SEED_DIR" + + echo "Template pushed to $BITBUCKET_REPOSITORY_URL ($BITBUCKET_MAIN_BRANCH)" +fi + +# --------------------------------------------------------------------------- +# 3. Contract A reconciliation. +# +# core-entities stores repository_url verbatim and resolves an application by an +# EXACT string match -- no normalisation, no .git stripping, no lower-casing. If +# the URL the application was created with is not byte-identical to the one +# Bitbucket just gave us (Bitbucket slugifies and lower-cases), then a later +# `np build start` fails with "could not get the app". Fix it now, while we +# still know both values. +# --------------------------------------------------------------------------- + +if [[ "$REPOSITORY_URL" != "$BITBUCKET_REPOSITORY_URL" ]]; then + echo "Application repository_url ('$REPOSITORY_URL') differs from the canonical Bitbucket URL ('$BITBUCKET_REPOSITORY_URL'). Updating application $APPLICATION_ID." + + UPDATE_BODY=$(jq -nc --arg url "$BITBUCKET_REPOSITORY_URL" '{repository_url: $url}') + + if ! np application update --id "$APPLICATION_ID" --body "$UPDATE_BODY" --format json >/dev/null; then + bb_fail "Failed to update application $APPLICATION_ID with the canonical repository_url $BITBUCKET_REPOSITORY_URL. Builds would fail to resolve the application." + fi + + REPOSITORY_URL="$BITBUCKET_REPOSITORY_URL" + export REPOSITORY_URL +fi + +export BITBUCKET_REPOSITORY_URL +export BITBUCKET_MAIN_BRANCH diff --git a/tests/cases/create_repository_reconciles_repository_url b/tests/cases/create_repository_reconciles_repository_url new file mode 100755 index 0000000..a821b05 --- /dev/null +++ b/tests/cases/create_repository_reconciles_repository_url @@ -0,0 +1,24 @@ +#!/bin/bash +# Contract A: core-entities resolves an application by an EXACT string match on +# repository_url. Bitbucket lower-cases slugs, so if the application was created +# with a mixed-case URL the canonical URL differs -- and `np build start` would +# later fail with "could not get the app". Reconcile at provisioning time. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" +export TEMPLATE_URL="" +export REPOSITORY_URL="https://bitbucket.org/acme/My-Service" + +fixture POST "/repositories/acme/my-service" 200 \ + '{"links":{"html":{"href":"https://bitbucket.org/acme/my-service"}}}' + +run_step create_repository +assert_status 0 || exit 1 + +if ! grep -q 'application update' "$BB_CALLS"; then + echo " FAIL: expected the application's repository_url to be reconciled. Calls:" + sed 's/^/ /' "$BB_CALLS" + exit 1 +fi diff --git a/tests/cases/create_repository_skips_on_import b/tests/cases/create_repository_skips_on_import new file mode 100755 index 0000000..2686c35 --- /dev/null +++ b/tests/cases/create_repository_skips_on_import @@ -0,0 +1,18 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="import" +export REPOSITORY_URL="https://bitbucket.org/acme/my-service" + +run_step create_repository + +assert_status 0 || exit 1 +assert_contains "Skipping repo creation" || exit 1 + +if [[ -s "$BB_CALLS" ]]; then + echo " FAIL: import strategy must not create anything. Calls:" + sed 's/^/ /' "$BB_CALLS" + exit 1 +fi diff --git a/tests/cases/create_repository_uses_html_href b/tests/cases/create_repository_uses_html_href new file mode 100755 index 0000000..6f5c477 --- /dev/null +++ b/tests/cases/create_repository_uses_html_href @@ -0,0 +1,45 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_STRATEGY="create" +export TEMPLATE_URL="https://github.com/nullplatform/technology-templates-nodejs" +export REPOSITORY_URL="https://bitbucket.org/acme/my-service" + +# Bitbucket answers repository creation with 200, NOT 201. links.clone[] carries +# a .git suffix and may embed userinfo; links.html.href is the canonical URL. +fixture POST "/repositories/acme/my-service" 200 '{ + "links": { + "html": {"href": "https://bitbucket.org/acme/my-service"}, + "clone": [{"name": "https", "href": "https://acme@bitbucket.org/acme/my-service.git"}] + } +}' + +url=$(capture_export create_repository BITBUCKET_REPOSITORY_URL) + +if [[ "$url" != "https://bitbucket.org/acme/my-service" ]]; then + echo " FAIL: expected the canonical links.html.href, got '$url'" + exit 1 +fi + +run_step create_repository +assert_status 0 || exit 1 + +# project.key must be sent, or Bitbucket silently files the repo under the +# workspace's OLDEST project. +body=$(request_body POST "/repositories/acme/my-service") + +if [[ "$(echo "$body" | jq -r '.project.key')" != "APP" ]]; then + echo " FAIL: create body did not carry project.key=APP -- got: $body" + exit 1 +fi + +if [[ "$(echo "$body" | jq -r '.is_private')" != "true" ]]; then + echo " FAIL: repository was not created private -- got: $body" + exit 1 +fi + +# ADR-1: there is no import API, so the template is seeded by git clone + push. +assert_called_git "clone" || exit 1 +assert_called_git "push" || exit 1 From 7195f2f3ee1c2f79b7e1960a2c6c217a1dc1eed7 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 15:04:45 -0300 Subject: [PATCH 06/13] feat(code-repo): grant bitbucket repository permissions --- scripts/code-repo/bitbucket/add_collaborators | 88 +++++++++++++++++++ tests/cases/add_collaborators_grants_write | 25 ++++++ .../add_collaborators_reports_non_members | 17 ++++ 3 files changed, 130 insertions(+) create mode 100644 scripts/code-repo/bitbucket/add_collaborators create mode 100755 tests/cases/add_collaborators_grants_write create mode 100755 tests/cases/add_collaborators_reports_non_members diff --git a/scripts/code-repo/bitbucket/add_collaborators b/scripts/code-repo/bitbucket/add_collaborators new file mode 100644 index 0000000..dd9d027 --- /dev/null +++ b/scripts/code-repo/bitbucket/add_collaborators @@ -0,0 +1,88 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +# Bitbucket repository permissions are read | write | admin. Accept those +# directly, and map the GitLab role names the platform already stores. +get_permission() { + local role="$1" + + case "$role" in + read | guest | reporter) echo "read" ;; + write | developer | maintainer) echo "write" ;; + admin | owner) echo "admin" ;; + *) echo "write" ;; # mirrors the GitLab provider's default of DEVELOPER + esac +} + +MISSING_MEMBERS=() +FAILED=0 + +# Process substitution, NOT a pipe. `... | while read` would run the loop body in +# a SUBSHELL, where FAILED and MISSING_MEMBERS would be discarded and an `exit 1` +# would kill only the subshell -- which is exactly why the GitLab equivalents of +# this script and of create_secrets can never actually fail the workflow. +while read -r collaborator; do + [[ -z "$collaborator" ]] && continue + + id=$(echo "$collaborator" | jq -r '.id') + role=$(echo "$collaborator" | jq -r '.role') + type=$(echo "$collaborator" | jq -r '.type') + + permission=$(get_permission "$role") + body=$(jq -nc --arg permission "$permission" '{permission: $permission}') + + if [[ "$type" == "user" ]]; then + # Users are addressed by account_id or UUID (usernames were removed for + # GDPR). UUIDs arrive with literal braces and must be percent-encoded. + encoded=$(bb_encode_uuid "$id") + + echo "Granting '$permission' on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} to user '$id'" + + bb_api PUT "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/permissions-config/users/${encoded}" "$body" + elif [[ "$type" == "group" ]]; then + echo "Granting '$permission' on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} to group '$id'" + + bb_api PUT "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/permissions-config/groups/${id}" "$body" + else + echo "Error: unknown collaborator type '$type' for '$id'" + FAILED=1 + continue + fi + + case "$BB_HTTP_CODE" in + 200 | 201 | 204) + echo "Granted '$permission' to '$id'" + ;; + 404) + # ADR-3. repository:admin can only grant permissions to principals that + # are ALREADY workspace members, and Bitbucket has no API to invite one. + # Collect these and report them together, by name, below. + MISSING_MEMBERS+=("$type '$id'") + FAILED=1 + ;; + *) + echo "Error granting '$permission' to '$id' (HTTP $BB_HTTP_CODE): $BB_BODY" + FAILED=1 + ;; + esac +done < <(echo "$CODE_REPOSITORY_COLLABORATORS" | jq -c '.[]?' 2>/dev/null) + +if [[ ${#MISSING_MEMBERS[@]} -gt 0 ]]; then + echo "Error: the following collaborators are not members of the '$BITBUCKET_WORKSPACE' Bitbucket workspace:" >&2 + + for member in "${MISSING_MEMBERS[@]}"; do + echo " - $member" >&2 + done + + echo "Bitbucket has no API to invite a user to a workspace, so nullplatform cannot add them for you. A workspace administrator must invite them at ${BITBUCKET_INSTALLATION_URL}/${BITBUCKET_WORKSPACE}/workspace/settings/members first. The repository ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} has already been created: once they are members, re-run this application creation with the 'import' strategy." >&2 +fi + +if [[ "$FAILED" -ne 0 ]]; then + exit 1 +fi + +echo "Finished adding collaborators" diff --git a/tests/cases/add_collaborators_grants_write b/tests/cases/add_collaborators_grants_write new file mode 100755 index 0000000..6d516a7 --- /dev/null +++ b/tests/cases/add_collaborators_grants_write @@ -0,0 +1,25 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_COLLABORATORS='[ + {"id":"557058:abc","role":"developer","type":"user"}, + {"id":"platform-team","role":"maintainer","type":"group"} +]' + +fixture PUT "/repositories/acme/my-service/permissions-config/users/557058:abc" 200 '{}' +fixture PUT "/repositories/acme/my-service/permissions-config/groups/platform-team" 200 '{}' + +run_step add_collaborators +assert_status 0 || exit 1 + +# GitLab role names must map onto Bitbucket's read|write|admin enum. +body=$(request_body PUT "/repositories/acme/my-service/permissions-config/users/557058:abc") + +if [[ "$(echo "$body" | jq -r '.permission')" != "write" ]]; then + echo " FAIL: expected permission 'write' for a developer, got: $body" + exit 1 +fi + +assert_contains "Finished adding collaborators" || exit 1 diff --git a/tests/cases/add_collaborators_reports_non_members b/tests/cases/add_collaborators_reports_non_members new file mode 100755 index 0000000..4c04b2b --- /dev/null +++ b/tests/cases/add_collaborators_reports_non_members @@ -0,0 +1,17 @@ +#!/bin/bash +# ADR-3: we cannot invite anyone to the workspace. Fail loudly and name them. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_COLLABORATORS='[{"id":"{aaaa-bbbb}","role":"developer","type":"user"}]' + +fixture PUT "/repositories/acme/my-service/permissions-config/users/%7Baaaa-bbbb%7D" 404 \ + '{"type":"error","error":{"message":"Not found"}}' + +run_step add_collaborators + +assert_status 1 || exit 1 +assert_contains "not members of the 'acme' Bitbucket workspace" || exit 1 +assert_contains "{aaaa-bbbb}" || exit 1 +assert_contains "workspace/settings/members" || exit 1 From c52fa742985ec853705ab1b5b41849a73fc1f6b6 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 15:05:37 -0300 Subject: [PATCH 07/13] feat(code-repo): set secured bitbucket pipeline variables --- scripts/code-repo/bitbucket/create_secrets | 86 +++++++++++++++++++ tests/cases/create_secrets_creates_new | 23 +++++ .../cases/create_secrets_secures_and_updates | 32 +++++++ 3 files changed, 141 insertions(+) create mode 100644 scripts/code-repo/bitbucket/create_secrets create mode 100755 tests/cases/create_secrets_creates_new create mode 100755 tests/cases/create_secrets_secures_and_updates diff --git a/scripts/code-repo/bitbucket/create_secrets b/scripts/code-repo/bitbucket/create_secrets new file mode 100644 index 0000000..a7c8e4e --- /dev/null +++ b/scripts/code-repo/bitbucket/create_secrets @@ -0,0 +1,86 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +# NOTE the spelling: 'pipelines_config' with an UNDERSCORE at repository level. +# (The workspace-level endpoint is hyphenated. That really is how Atlassian +# ships it.) +VARIABLES_PATH="/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/pipelines_config/variables" + +# Bitbucket pipeline variables have NO upsert: POSTing an existing key returns +# 409, and to change one you must PUT to its uuid. So list them once up front. +# +# (Secured values are write-only -- a GET returns "value": "" -- but we only +# need the key and the uuid here.) +bb_api GET "${VARIABLES_PATH}?pagelen=100" + +if [[ "$BB_HTTP_CODE" != "200" ]]; then + bb_fail "Could not list the pipeline variables of ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} (HTTP $BB_HTTP_CODE): $BB_BODY" +fi + +EXISTING_VARIABLES="$BB_BODY" + +FAILED=0 + +# Process substitution, NOT a pipe -- see the note in add_collaborators. +while IFS=$'\t' read -r key value; do + [[ -z "$key" ]] && continue + + echo "Creating secret: $key" + + # Global constraint: every variable carrying a credential is secured. + body=$(jq -nc --arg key "$key" --arg value "$value" \ + '{key: $key, value: $value, secured: true}') + + uuid=$(echo "$EXISTING_VARIABLES" | jq -r --arg key "$key" \ + '.values[]? | select(.key == $key) | .uuid // empty') + + if [[ -n "$uuid" ]]; then + encoded=$(bb_encode_uuid "$uuid") + + bb_api PUT "${VARIABLES_PATH}/${encoded}" "$body" + else + bb_api POST "$VARIABLES_PATH" "$body" + + if [[ "$BB_HTTP_CODE" == "409" ]]; then + # The key exists after all (a race, or it was past the first page). There + # is no upsert, so recover by finding the uuid and updating. + echo "Secret $key already exists; updating it instead" + + bb_api GET "${VARIABLES_PATH}?pagelen=100" + + uuid=$(echo "$BB_BODY" | jq -r --arg key "$key" \ + '.values[]? | select(.key == $key) | .uuid // empty') + + if [[ -z "$uuid" ]]; then + echo "Error: Bitbucket reported that secret $key already exists, but it is not in the variable list" + FAILED=1 + continue + fi + + encoded=$(bb_encode_uuid "$uuid") + + bb_api PUT "${VARIABLES_PATH}/${encoded}" "$body" + fi + fi + + case "$BB_HTTP_CODE" in + 200 | 201) + echo "Secret $key created successfully" + ;; + *) + echo "Error creating $key secret (HTTP $BB_HTTP_CODE): $BB_BODY" + FAILED=1 + ;; + esac +done < <(echo "$CODE_REPOSITORY_SECRETS" | jq -r 'to_entries[] | [.key, (.value | tostring)] | @tsv') + +if [[ "$FAILED" -ne 0 ]]; then + # Diverges deliberately from the GitLab script, which only prints. Without the + # nullplatform API key the pipeline cannot create builds or push assets, and + # the failure would surface much later as a confusing CI error. + exit 1 +fi diff --git a/tests/cases/create_secrets_creates_new b/tests/cases/create_secrets_creates_new new file mode 100755 index 0000000..2245d91 --- /dev/null +++ b/tests/cases/create_secrets_creates_new @@ -0,0 +1,23 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_SECRETS='{"NULLPLATFORM_API_KEY":"abc123"}' + +VARS="/repositories/acme/my-service/pipelines_config/variables" + +fixture GET "${VARS}?pagelen=100" 200 '{"values":[]}' +fixture POST "$VARS" 201 '{"key":"NULLPLATFORM_API_KEY"}' + +run_step create_secrets + +assert_status 0 || exit 1 +assert_contains "Secret NULLPLATFORM_API_KEY created successfully" || exit 1 + +body=$(request_body POST "$VARS") + +if [[ "$(echo "$body" | jq -r '.secured')" != "true" ]]; then + echo " FAIL: the credential was not created with secured:true -- got: $body" + exit 1 +fi diff --git a/tests/cases/create_secrets_secures_and_updates b/tests/cases/create_secrets_secures_and_updates new file mode 100755 index 0000000..7134e17 --- /dev/null +++ b/tests/cases/create_secrets_secures_and_updates @@ -0,0 +1,32 @@ +#!/bin/bash +# There is no upsert. Exercise the full 409 recovery: list -> POST -> 409 -> +# re-list to find the uuid -> PUT. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export CODE_REPOSITORY_SECRETS='{"NP_API_KEY":"abc123"}' + +VARS="/repositories/acme/my-service/pipelines_config/variables" + +# 1st list: the key is not there yet... +fixture_seq GET "${VARS}?pagelen=100" 1 200 '{"values":[]}' +# ...so we POST -- and Bitbucket says 409 (it was created in between). +fixture POST "$VARS" 409 '{"type":"error","error":{"message":"already exists"}}' +# 2nd list: now we can see its uuid. +fixture_seq GET "${VARS}?pagelen=100" 2 200 \ + '{"values":[{"key":"NP_API_KEY","uuid":"{1234-abcd}"}]}' +# Update by uuid, with the literal braces percent-encoded. +fixture PUT "${VARS}/%7B1234-abcd%7D" 200 '{"key":"NP_API_KEY"}' + +run_step create_secrets + +assert_status 0 || exit 1 +assert_called PUT "${VARS}/%7B1234-abcd%7D" || exit 1 + +body=$(request_body PUT "${VARS}/%7B1234-abcd%7D") + +if [[ "$(echo "$body" | jq -r '.secured')" != "true" ]]; then + echo " FAIL: the credential was not stored with secured:true -- got: $body" + exit 1 +fi From 4627a844c9f7097145005bb922fa70d02e86ce43 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 15:06:20 -0300 Subject: [PATCH 08/13] feat(code-repo): enable bitbucket pipelines and trigger first build --- scripts/code-repo/bitbucket/run_first_build | 82 +++++++++++++++++++ tests/cases/run_first_build_enables_pipelines | 28 +++++++ tests/cases/run_first_build_warns_without_yml | 18 ++++ 3 files changed, 128 insertions(+) create mode 100644 scripts/code-repo/bitbucket/run_first_build create mode 100755 tests/cases/run_first_build_enables_pipelines create mode 100755 tests/cases/run_first_build_warns_without_yml diff --git a/scripts/code-repo/bitbucket/run_first_build b/scripts/code-repo/bitbucket/run_first_build new file mode 100644 index 0000000..5b8845c --- /dev/null +++ b/scripts/code-repo/bitbucket/run_first_build @@ -0,0 +1,82 @@ +#!/bin/bash + +CURRENT_DIR=$(dirname "${BASH_SOURCE[0]}") + +# shellcheck source=scripts/code-repo/bitbucket/_api +source "$CURRENT_DIR/_api" + +PIPELINES_CONFIG_PATH="/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/pipelines_config" + +BRANCH="${BITBUCKET_MAIN_BRANCH:-main}" + +# --------------------------------------------------------------------------- +# 1. Enable Pipelines. +# +# Pipelines is OFF by default, even when bitbucket-pipelines.yml is present, so +# nothing can be triggered until it is enabled. Enabling does NOT require the +# yml to exist. The order is: enable -> (yml already pushed) -> trigger. +# +# GOTCHA: GET pipelines_config returns 404 when Pipelines was NEVER enabled on +# the repository. That 404 does NOT mean the repository is missing. +# --------------------------------------------------------------------------- + +bb_api GET "$PIPELINES_CONFIG_PATH" + +case "$BB_HTTP_CODE" in + 200) + if [[ "$(echo "$BB_BODY" | jq -r '.enabled')" == "true" ]]; then + echo "Bitbucket Pipelines is already enabled" + PIPELINES_ENABLED="true" + else + PIPELINES_ENABLED="false" + fi + ;; + 404) + echo "Bitbucket Pipelines has never been enabled on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" + PIPELINES_ENABLED="false" + ;; + *) + bb_fail "Unexpected response reading the pipelines config of ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} (HTTP $BB_HTTP_CODE): $BB_BODY" + ;; +esac + +if [[ "$PIPELINES_ENABLED" != "true" ]]; then + echo "Enabling Bitbucket Pipelines on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}" + + bb_api PUT "$PIPELINES_CONFIG_PATH" '{"enabled": true}' + + if [[ "$BB_HTTP_CODE" != "200" ]]; then + bb_fail "Could not enable Bitbucket Pipelines (HTTP $BB_HTTP_CODE): $BB_BODY" + fi + + echo "Bitbucket Pipelines enabled" +fi + +# --------------------------------------------------------------------------- +# 2. Trigger the first build. +# +# Note the TRAILING SLASH on /pipelines/ -- the endpoint is spelled that way. +# --------------------------------------------------------------------------- + +TRIGGER_BODY=$(jq -nc --arg branch "$BRANCH" \ + '{target: {type: "pipeline_ref_target", ref_type: "branch", ref_name: $branch}}') + +echo "Triggering the first build on branch '$BRANCH'" + +bb_api POST "/repositories/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}/pipelines/" "$TRIGGER_BODY" + +case "$BB_HTTP_CODE" in + 200 | 201) + echo "Pipeline triggered successfully: build #$(echo "$BB_BODY" | jq -r '.build_number // "?"')" + ;; + 400) + # Triggering when the branch has no bitbucket-pipelines.yml returns 400. An + # imported repository may legitimately not have one yet, and the application + # was created successfully either way -- so warn rather than fail the whole + # workflow. This matches the GitLab step's posture. + echo "Warning: Could not trigger the first build. Bitbucket rejected the request, which usually means branch '$BRANCH' has no bitbucket-pipelines.yml. Response: $BB_BODY" + ;; + *) + echo "Warning: Could not trigger the first build (HTTP $BB_HTTP_CODE): $BB_BODY" + ;; +esac diff --git a/tests/cases/run_first_build_enables_pipelines b/tests/cases/run_first_build_enables_pipelines new file mode 100755 index 0000000..c76db41 --- /dev/null +++ b/tests/cases/run_first_build_enables_pipelines @@ -0,0 +1,28 @@ +#!/bin/bash +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +CONFIG="/repositories/acme/my-service/pipelines_config" + +# GET pipelines_config 404s when Pipelines has NEVER been enabled. That does NOT +# mean the repository is missing -- it means "not enabled yet, go PUT it". +fixture GET "$CONFIG" 404 '{"type":"error"}' +fixture PUT "$CONFIG" 200 '{"enabled":true}' +fixture POST "/repositories/acme/my-service/pipelines/" 201 '{"build_number":1}' + +run_step run_first_build + +assert_status 0 || exit 1 +assert_called PUT "$CONFIG" || exit 1 +assert_called POST "/repositories/acme/my-service/pipelines/" || exit 1 +assert_contains "build #1" || exit 1 + +# Enabling must come BEFORE triggering. +enable_line=$(grep -nF "$(printf 'PUT\t%s\t' "$CONFIG")" "$BB_CALLS" | cut -d: -f1 | head -1) +trigger_line=$(grep -nF "$(printf 'POST\t/repositories/acme/my-service/pipelines/\t')" "$BB_CALLS" | cut -d: -f1 | head -1) + +if [[ "$enable_line" -ge "$trigger_line" ]]; then + echo " FAIL: Pipelines must be enabled before a build is triggered" + exit 1 +fi diff --git a/tests/cases/run_first_build_warns_without_yml b/tests/cases/run_first_build_warns_without_yml new file mode 100755 index 0000000..2e27e95 --- /dev/null +++ b/tests/cases/run_first_build_warns_without_yml @@ -0,0 +1,18 @@ +#!/bin/bash +# Triggering with no bitbucket-pipelines.yml on the branch returns 400. The app +# was still created successfully, so warn -- do not fail the workflow. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +CONFIG="/repositories/acme/my-service/pipelines_config" + +fixture GET "$CONFIG" 200 '{"enabled":true}' +fixture POST "/repositories/acme/my-service/pipelines/" 400 \ + '{"type":"error","error":{"message":"No pipelines configuration"}}' + +run_step run_first_build + +assert_status 0 || exit 1 +assert_contains "Warning: Could not trigger the first build" || exit 1 +assert_contains "bitbucket-pipelines.yml" || exit 1 From 54b48a2c6edcec4035ca7f9304efada3780057cf Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 15:07:27 -0300 Subject: [PATCH 09/13] feat(code-repo): emit NULLPLATFORM_API_KEY and document bitbucket --- CHANGELOG.md | 10 +++++ README.md | 27 ++++++++++++- scripts/code-repo/generate_secrets | 13 +++++- .../generate_secrets_emits_canonical_key | 40 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100755 tests/cases/generate_secrets_emits_canonical_key diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd6465..8463bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to `application-lifecycle-manager` will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once it reaches a stable API. +## [0.3.0] - 2026-07-13 + +### Added +- Bitbucket Cloud code repository support (`scripts/code-repo/bitbucket`), with both + Workspace Access Token and OAuth 2.0 `client_credentials` authentication. +- A `NULLPLATFORM_API_KEY` pipeline secret, alongside the existing `NP_API_KEY`, so the shared + technology templates work under either provisioning path. +- An offline test harness (`tests/run`) and CI running `shellcheck` plus those tests. + + ## [0.2.0] - 2025-11-13 ### Added diff --git a/README.md b/README.md index 997fde3..7af234d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,32 @@ When an application is created, this service coordinates two main tasks: You must configure your code repository provider through **nullplatform platform settings** or the **nullplatform Terraform provider**. -> **Note:** At the moment, this repository supports **GitLab** repositories only. +> **Note:** This repository supports **GitLab** and **Bitbucket Cloud** repositories. + +#### Bitbucket Cloud + +Configure a `bitbucket-configuration` code-repository provider with: + +| Attribute | Required | Notes | +|---|---|---| +| `setup.workspace` | yes | The Bitbucket workspace slug. | +| `setup.project_key` | yes | The key of the project new repositories are filed under. **Not optional**: omit it and Bitbucket silently assigns the repository to the workspace's oldest project. | +| `setup.auth_method` | yes | `workspace_access_token` or `oauth2`. | +| `setup.access_token` | when `auth_method` is `workspace_access_token` | A Workspace Access Token. **Requires Bitbucket Premium.** It cannot be minted via API — a workspace admin creates it in the Bitbucket UI and pastes it here. Scopes: `repository:admin`, `repository:write`, `pipeline:write`, `pipeline:variable`, `webhook`. | +| `setup.oauth_key` / `setup.oauth_secret` | when `auth_method` is `oauth2` | An OAuth 2.0 consumer using the `client_credentials` grant. Works on **all** Bitbucket plans. The token represents the workspace, not an individual. | +| `setup.installation_url` | no | Defaults to `https://bitbucket.org`. | +| `access.default_collaborators` | no | See the limitation below. | + +> **Bitbucket app passwords are not supported.** Atlassian removed them on 2026-07-28. + +**Limitation — collaborators must already be workspace members.** Bitbucket has no API to +invite a user to a workspace, so `add_collaborators` can only *grant repository permissions* to +principals who are already members. If a configured collaborator is not a member, repository +creation fails with an error naming them; a workspace administrator must invite them first. + +**Limitation — no template import API.** Bitbucket Cloud cannot import a repository from a URL, +and forking is no longer usable. Templates are therefore seeded with a real `git clone` + `git +push`, squashed to a single initial commit. **`git` must be available on the agent host.** #### Workflow diff --git a/scripts/code-repo/generate_secrets b/scripts/code-repo/generate_secrets index 98c855a..868358f 100644 --- a/scripts/code-repo/generate_secrets +++ b/scripts/code-repo/generate_secrets @@ -28,6 +28,17 @@ else exit 1 fi -CODE_REPOSITORY_SECRETS="{\"NP_API_KEY\": \"$API_KEY\"}" +# Both names carry the same CI api key. +# +# NP_API_KEY is what this repository has always emitted and what the existing +# GitLab-provisioned templates read. NULLPLATFORM_API_KEY is the platform-wide +# canonical name -- it is what main-application-workflow-manager injects, and +# therefore what the shared technology templates read. A template is used by +# BOTH provisioning paths, so both names must be present. +# +# jq (rather than string interpolation) so an api key containing characters that +# are significant in JSON cannot corrupt the payload. +CODE_REPOSITORY_SECRETS=$(jq -nc --arg api_key "$API_KEY" \ + '{NP_API_KEY: $api_key, NULLPLATFORM_API_KEY: $api_key}') export CODE_REPOSITORY_SECRETS \ No newline at end of file diff --git a/tests/cases/generate_secrets_emits_canonical_key b/tests/cases/generate_secrets_emits_canonical_key new file mode 100755 index 0000000..0f6cb53 --- /dev/null +++ b/tests/cases/generate_secrets_emits_canonical_key @@ -0,0 +1,40 @@ +#!/bin/bash +# The templates are SHARED by both provisioning paths, so both must inject the +# CI credential under the same canonical name. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT + +export NAMESPACE_SLUG="ns" +export NAMESPACE_ID="1" +export APPLICATION_SLUG="app" +export APPLICATION_ID="2" +export NRN="organization=1:account=2" + +# The np stub returns '{}', so give api-key create a real answer. +cat > "$TEST_TMP/np" <<'EOF' +#!/bin/bash +printf 'np\t%s\n' "$*" >> "$BB_CALLS" +if [[ "$1" == "api-key" ]]; then + echo '{"id": 99, "api_key": "np-secret-value"}' +else + echo '{}' +fi +EOF +chmod +x "$TEST_TMP/np" +export PATH="$TEST_TMP:$PATH" + +secrets=$( + cd "$REPO_ROOT" || exit 1 + source scripts/code-repo/generate_secrets >/dev/null 2>&1 + printf '%s' "$CODE_REPOSITORY_SECRETS" +) + +for key in NP_API_KEY NULLPLATFORM_API_KEY; do + value=$(echo "$secrets" | jq -r --arg k "$key" '.[$k] // empty') + + if [[ "$value" != "np-secret-value" ]]; then + echo " FAIL: expected $key to carry the CI api key, got '$value' from: $secrets" + exit 1 + fi +done From 6fd03ced5eae0de710ee14f704ec4259726a44a0 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 15:07:57 -0300 Subject: [PATCH 10/13] test(code-repo): add live bitbucket smoke verification --- tests/smoke_bitbucket_live | 115 +++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100755 tests/smoke_bitbucket_live diff --git a/tests/smoke_bitbucket_live b/tests/smoke_bitbucket_live new file mode 100755 index 0000000..08573c5 --- /dev/null +++ b/tests/smoke_bitbucket_live @@ -0,0 +1,115 @@ +#!/bin/bash +# +# Live end-to-end verification against a REAL, throwaway Bitbucket workspace. +# +# This is NOT part of `tests/run` and does NOT run in CI: it needs real +# credentials and it creates real repositories. It is the only thing that proves +# the API contracts this provider is built on (repo-create returns 200 not 201; +# GET pipelines_config 404s before Pipelines is enabled; pipeline variables have +# no upsert; and -- unvalidated until you run this -- that a 2LO token is +# allowed to create a repository at all). +# +# Run it once per auth_method. +# +# # Workspace Access Token (Bitbucket Premium): +# BITBUCKET_AUTH_METHOD=workspace_access_token \ +# BITBUCKET_TOKEN= \ +# BITBUCKET_WORKSPACE=np-scratch \ +# BITBUCKET_PROJECT_KEY=SCRATCH \ +# TEMPLATE_URL=https://github.com/nullplatform/technology-templates-nodejs \ +# tests/smoke_bitbucket_live +# +# # OAuth 2LO (all plans): +# BITBUCKET_AUTH_METHOD=oauth2 \ +# BITBUCKET_OAUTH_KEY= BITBUCKET_OAUTH_SECRET= \ +# BITBUCKET_WORKSPACE=np-scratch \ +# BITBUCKET_PROJECT_KEY=SCRATCH \ +# TEMPLATE_URL=https://github.com/nullplatform/technology-templates-nodejs \ +# tests/smoke_bitbucket_live +# +# Add KEEP=1 to skip the cleanup and inspect the repository afterwards. + +cd "$(dirname "$0")/.." || exit 1 + +SLUG="${SMOKE_SLUG:-np-smoke-$(date +%s)}" + +# Stand in for the parts of the workflow context that create_code_repository and +# generate_secrets would normally provide. We do NOT call generate_secrets here: +# it would mint a real nullplatform CI api key. +export CODE_REPOSITORY_STRATEGY="create" +export REPOSITORY_NAME="$SLUG" +export REPOSITORY_URL="https://bitbucket.org/${BITBUCKET_WORKSPACE}/${SLUG}" +export CODE_REPOSITORY='{"attributes":{"setup":{},"access":{}}}' +export CODE_REPOSITORY_COLLABORATORS='[]' +export CODE_REPOSITORY_SECRETS='{"NULLPLATFORM_API_KEY":"smoke-test-value"}' +export APPLICATION_ID="0" + +echo "=== smoke: ${BITBUCKET_WORKSPACE}/${SLUG} (auth: ${BITBUCKET_AUTH_METHOD}) ===" + +# Each step is sourced, exactly as the workflow engine does it. A step that +# fails calls `exit 1`, which ends this script too -- which is the point. +for step in build_context validate_repository_does_not_exist create_repository \ + add_collaborators create_secrets run_first_build; do + echo "" + echo "--- $step ---" + + # shellcheck disable=SC1090 + source "scripts/code-repo/bitbucket/$step" +done + +echo "" +echo "--- assertions ---" + +# Contract A: the persisted URL must be exactly https://bitbucket.org/{ws}/{slug} +# -- no .git, no trailing slash, no userinfo. +EXPECTED="https://bitbucket.org/${BITBUCKET_WORKSPACE}/${SLUG}" + +if [[ "$BITBUCKET_REPOSITORY_URL" != "$EXPECTED" ]]; then + echo "FAIL: Contract A violated." + echo " expected: $EXPECTED" + echo " got: $BITBUCKET_REPOSITORY_URL" + exit 1 +fi + +echo "ok Contract A: $BITBUCKET_REPOSITORY_URL" + +# The template must actually be in the repo, as ONE root commit. +COMMITS=$(curl -s -H "Authorization: Bearer $BITBUCKET_TOKEN" \ + "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}/commits/main" \ + | jq '.values | length') + +if [[ "$COMMITS" != "1" ]]; then + echo "FAIL: expected exactly 1 commit on main (a squashed template), found: $COMMITS" + exit 1 +fi + +echo "ok template seeded as a single root commit" + +# The credential must be stored secured (its value is then write-only). +SECURED=$(curl -s -H "Authorization: Bearer $BITBUCKET_TOKEN" \ + "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}/pipelines_config/variables?pagelen=100" \ + | jq -r '.values[] | select(.key == "NULLPLATFORM_API_KEY") | .secured') + +if [[ "$SECURED" != "true" ]]; then + echo "FAIL: NULLPLATFORM_API_KEY is not secured (got: '$SECURED')" + exit 1 +fi + +echo "ok NULLPLATFORM_API_KEY stored with secured:true" + +if [[ -n "${KEEP:-}" ]]; then + echo "" + echo "KEEP set -- leaving $BITBUCKET_REPOSITORY_URL in place." + exit 0 +fi + +echo "" +echo "--- cleanup ---" + +CLEANUP_CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \ + -H "Authorization: Bearer $BITBUCKET_TOKEN" \ + "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}") + +echo "deleted ${BITBUCKET_WORKSPACE}/${SLUG} (HTTP $CLEANUP_CODE)" +echo "" +echo "=== smoke passed (auth: ${BITBUCKET_AUTH_METHOD}) ===" From c0e8b126f020a70d5ff4addcce3b2983e2f6df6e Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Sun, 19 Jul 2026 15:10:04 -0300 Subject: [PATCH 11/13] style(code-repo): annotate shellcheck output-var and dynamic-source lines --- scripts/code-repo/bitbucket/_api | 4 ++++ tests/lib.sh | 1 + 2 files changed, 5 insertions(+) diff --git a/scripts/code-repo/bitbucket/_api b/scripts/code-repo/bitbucket/_api index 2d9c59e..3a53df3 100644 --- a/scripts/code-repo/bitbucket/_api +++ b/scripts/code-repo/bitbucket/_api @@ -45,7 +45,11 @@ bb_api() { args+=(--data "$body") fi + # BB_HTTP_CODE and BB_BODY are the outputs of bb_api: they are read by the + # step scripts that source this file, so they are not unused here. + # shellcheck disable=SC2034 BB_HTTP_CODE=$(curl "${args[@]}" "${BITBUCKET_API_BASE}${path}") + # shellcheck disable=SC2034 BB_BODY=$(cat "$tmp") rm -f "$tmp" diff --git a/tests/lib.sh b/tests/lib.sh index c900a80..125c68f 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -70,6 +70,7 @@ fixture_seq() { run_step() { local step="$1" + # shellcheck disable=SC1090 STEP_OUTPUT=$(cd "$REPO_ROOT" && source "scripts/code-repo/bitbucket/$step" 2>&1) STEP_STATUS=$? } From a933006d9bf1dbb51fde36d5d6f0ee26982a9979 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Mon, 20 Jul 2026 11:55:27 -0300 Subject: [PATCH 12/13] refactor(code-repo): use bot-user API token (HTTP Basic) for bitbucket auth Replace the workspace-access-token / OAuth-2LO dual auth model (which never matched the seeded NRN keys and could not enable Pipelines) with a single dedicated bot-user Atlassian API token: - build_context resolves bitbucket.email + bitbucket.apiToken and exports BITBUCKET_EMAIL + BITBUCKET_API_TOKEN; no auth_method branch, no OAuth mint. - _api sends HTTP Basic (email:api_token) on every REST call. - create_repository seeds git over HTTPS with the username x-bitbucket-api-token-auth and the token as password. - run_first_build surfaces a 403 on pipelines enable as the bot-user 2SV requirement, not a phantom scopes bug. - Tests, smoke runner, README and CHANGELOG updated to the API-token model. --- CHANGELOG.md | 5 +- README.md | 18 ++- scripts/code-repo/bitbucket/_api | 22 ++-- scripts/code-repo/bitbucket/build_context | 119 ++++++------------ scripts/code-repo/bitbucket/create_repository | 9 +- scripts/code-repo/bitbucket/run_first_build | 8 ++ .../validate_repository_does_not_exist | 2 +- tests/cases/build_context_mints_oauth_token | 36 ------ tests/cases/build_context_normalises_slug | 6 +- tests/cases/build_context_reads_api_token | 40 ++++++ .../cases/build_context_requires_credentials | 21 ++++ .../cases/build_context_requires_project_key | 4 +- tests/lib.sh | 9 +- tests/smoke_bitbucket_live | 31 ++--- 14 files changed, 163 insertions(+), 167 deletions(-) delete mode 100755 tests/cases/build_context_mints_oauth_token create mode 100755 tests/cases/build_context_reads_api_token create mode 100755 tests/cases/build_context_requires_credentials diff --git a/CHANGELOG.md b/CHANGELOG.md index 8463bec..c2decef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [0.3.0] - 2026-07-13 ### Added -- Bitbucket Cloud code repository support (`scripts/code-repo/bitbucket`), with both - Workspace Access Token and OAuth 2.0 `client_credentials` authentication. +- Bitbucket Cloud code repository support (`scripts/code-repo/bitbucket`), authenticated with a + dedicated bot user's Atlassian API token over HTTP Basic (the only credential that can enable + Bitbucket Pipelines). - A `NULLPLATFORM_API_KEY` pipeline secret, alongside the existing `NP_API_KEY`, so the shared technology templates work under either provisioning path. - An offline test harness (`tests/run`) and CI running `shellcheck` plus those tests. diff --git a/README.md b/README.md index 7af234d..526bb21 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,20 @@ Configure a `bitbucket-configuration` code-repository provider with: | Attribute | Required | Notes | |---|---|---| | `setup.workspace` | yes | The Bitbucket workspace slug. | -| `setup.project_key` | yes | The key of the project new repositories are filed under. **Not optional**: omit it and Bitbucket silently assigns the repository to the workspace's oldest project. | -| `setup.auth_method` | yes | `workspace_access_token` or `oauth2`. | -| `setup.access_token` | when `auth_method` is `workspace_access_token` | A Workspace Access Token. **Requires Bitbucket Premium.** It cannot be minted via API — a workspace admin creates it in the Bitbucket UI and pastes it here. Scopes: `repository:admin`, `repository:write`, `pipeline:write`, `pipeline:variable`, `webhook`. | -| `setup.oauth_key` / `setup.oauth_secret` | when `auth_method` is `oauth2` | An OAuth 2.0 consumer using the `client_credentials` grant. Works on **all** Bitbucket plans. The token represents the workspace, not an individual. | -| `setup.installation_url` | no | Defaults to `https://bitbucket.org`. | +| `setup.projectKey` | yes | The key of the project new repositories are filed under. **Not optional**: omit it and Bitbucket silently assigns the repository to the workspace's oldest project. | +| `setup.email` | yes | The Atlassian account email of the dedicated Bitbucket **bot user**. This is the HTTP Basic username for the API token. | +| `setup.apiToken` | yes | The bot user's **Atlassian API token**. Used as the HTTP Basic password for the REST API and, with the git username `x-bitbucket-api-token-auth`, for git-over-HTTPS. | +| `setup.installationUrl` | no | Defaults to `https://bitbucket.org`. | | `access.default_collaborators` | no | See the limitation below. | -> **Bitbucket app passwords are not supported.** Atlassian removed them on 2026-07-28. +> **Authentication is a dedicated bot user's Atlassian API token.** nullplatform must hold a +> **user-scoped** credential because enabling Bitbucket Pipelines requires a two-step-verification +> (2SV) enabled *user* principal — an OAuth app (2LO) is refused there with a permanent `403`, and a +> workspace access token fails the same check. So: create a dedicated Bitbucket bot user, **enable +> Bitbucket 2SV on it** (this is Bitbucket 2SV, *not* Atlassian-account 2FA), grant it repository / +> pipeline / project admin on the workspace, and issue it an Atlassian API token. This works on +> **every** Bitbucket plan (no Premium required). Atlassian API tokens expire in ≤365 days, so rotate +> it before then. **Bitbucket app passwords are not supported** (Atlassian removed them on 2026-07-28). **Limitation — collaborators must already be workspace members.** Bitbucket has no API to invite a user to a workspace, so `add_collaborators` can only *grant repository permissions* to diff --git a/scripts/code-repo/bitbucket/_api b/scripts/code-repo/bitbucket/_api index 3a53df3..7cadd01 100644 --- a/scripts/code-repo/bitbucket/_api +++ b/scripts/code-repo/bitbucket/_api @@ -14,9 +14,13 @@ # workflow, and entrypoint then reports "failed" back to nullplatform. # * `return` skips the rest of a step without failing the workflow. # -# Auth is resolved once, in build_context, which exports a single bearer token -# as BITBUCKET_TOKEN (from either a Workspace Access Token or a minted OAuth 2LO -# token). Nothing else in this directory knows or cares which. +# AUTH: nullplatform authenticates to Bitbucket Cloud with a single dedicated +# bot user's Atlassian API token, using HTTP Basic (email:api_token). This is the +# ONLY credential that can enable Bitbucket Pipelines -- an OAuth app (2LO) is +# refused there with a permanent 403, and a workspace access token fails the same +# 2SV check (both validated live). build_context resolves the email + token and +# exports them; every REST call here sends them as Basic auth, and git transport +# uses the username `x-bitbucket-api-token-auth` with the token as the password. # bb_api METHOD PATH [BODY] # @@ -37,7 +41,7 @@ bb_api() { -o "$tmp" -w '%{http_code}' -X "$method" - -H "Authorization: Bearer ${BITBUCKET_TOKEN}" + -u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}" -H "Content-Type: application/json" ) @@ -70,17 +74,19 @@ bb_encode_uuid() { # bb_scrub # -# stdin filter that redacts the bearer token. git prints credentialed remote -# URLs verbatim on failure, so every byte of git output goes through this. +# stdin filter that redacts the API token. git prints credentialed remote URLs +# verbatim on failure, so every byte of git output goes through this. (The bot +# email is the Basic-auth username and the git URL's userinfo is the fixed +# literal `x-bitbucket-api-token-auth`; the token is the only secret.) bb_scrub() { - if [[ -z "${BITBUCKET_TOKEN:-}" ]]; then + if [[ -z "${BITBUCKET_API_TOKEN:-}" ]]; then cat return fi local escaped # Escape regex metacharacters (including /) so any token value is literal. - escaped=$(printf '%s' "$BITBUCKET_TOKEN" | sed -e 's/[]\/$*.^[]/\\&/g') + escaped=$(printf '%s' "$BITBUCKET_API_TOKEN" | sed -e 's/[]\/$*.^[]/\\&/g') sed -e "s/${escaped}/***/g" } diff --git a/scripts/code-repo/bitbucket/build_context b/scripts/code-repo/bitbucket/build_context index 87d987b..e579913 100644 --- a/scripts/code-repo/bitbucket/build_context +++ b/scripts/code-repo/bitbucket/build_context @@ -15,7 +15,7 @@ else fi if [[ -z "$BITBUCKET_WORKSPACE" ]]; then - bb_fail "BITBUCKET_WORKSPACE is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.workspace) or in the environment." + bb_fail "BITBUCKET_WORKSPACE is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.workspace / bitbucket.workspace) or in the environment." fi # --- installation URL and API base ----------------------------------------- @@ -23,13 +23,13 @@ fi if [[ -n "${BITBUCKET_INSTALLATION_URL:-}" ]]; then echo "Using BITBUCKET_INSTALLATION_URL from environment: $BITBUCKET_INSTALLATION_URL" else - BITBUCKET_INSTALLATION_URL=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.installation_url // empty') + BITBUCKET_INSTALLATION_URL=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.installationUrl // empty') echo "No BITBUCKET_INSTALLATION_URL in environment, using value from platform: $BITBUCKET_INSTALLATION_URL" fi if [[ -z "$BITBUCKET_INSTALLATION_URL" ]]; then BITBUCKET_INSTALLATION_URL="https://bitbucket.org" - echo "No installation_url configured, defaulting to: $BITBUCKET_INSTALLATION_URL" + echo "No installationUrl configured, defaulting to: $BITBUCKET_INSTALLATION_URL" fi # Strip any trailing slash so the URLs we build never double up. @@ -46,86 +46,41 @@ BITBUCKET_API_BASE="${BITBUCKET_API_BASE%/}" # --- auth ------------------------------------------------------------------ # -# Two first-class mechanisms (ADR-2). Bitbucket app passwords are REMOVED as of -# 2026-07-28 and are deliberately not supported. This block is the only place in -# the provider that knows which mechanism is in use: it resolves both down to a -# single bearer token, BITBUCKET_TOKEN. +# nullplatform holds ONE credential for Bitbucket: a dedicated bot user's +# Atlassian API token, plus that user's email (the HTTP Basic username). This is +# a deliberate, validated-live decision, NOT app passwords (removed 2026-07-28), +# NOT OAuth 2LO, and NOT a workspace access token: +# +# * Enabling Bitbucket Pipelines requires a 2SV'd USER principal. An OAuth app +# (client_credentials) is refused with a PERMANENT 403 there, and a workspace +# access token -- explicitly "not tied to a user" -- fails the same check. +# Only a user-scoped API token works, on every plan (no Premium needed). +# * The token is long-lived (<=365 days), so there is no mint/refresh dance. +# +# Every REST call uses HTTP Basic (email:api_token); git transport uses the +# username `x-bitbucket-api-token-auth` with the token as the password. -if [[ -n "${BITBUCKET_AUTH_METHOD:-}" ]]; then - echo "Using BITBUCKET_AUTH_METHOD from environment: $BITBUCKET_AUTH_METHOD" +if [[ -n "${BITBUCKET_EMAIL:-}" ]]; then + echo "Using BITBUCKET_EMAIL from environment: $BITBUCKET_EMAIL" else - BITBUCKET_AUTH_METHOD=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.auth_method // empty') - echo "No BITBUCKET_AUTH_METHOD in environment, using value from platform: $BITBUCKET_AUTH_METHOD" + BITBUCKET_EMAIL=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.email // empty') + echo "No BITBUCKET_EMAIL in environment, using value from platform: $BITBUCKET_EMAIL" +fi + +if [[ -z "$BITBUCKET_EMAIL" ]]; then + bb_fail "BITBUCKET_EMAIL is not set. Configure the bot user's Atlassian account email on the bitbucket-configuration provider (.attributes.setup.email / bitbucket.email) or in the environment. It is the HTTP Basic username for the API token." fi -if [[ -z "$BITBUCKET_AUTH_METHOD" ]]; then - BITBUCKET_AUTH_METHOD="workspace_access_token" - echo "No auth_method configured, defaulting to: $BITBUCKET_AUTH_METHOD" +if [[ -n "${BITBUCKET_API_TOKEN:-}" ]]; then + echo "Using BITBUCKET_API_TOKEN from environment (hidden)" +else + BITBUCKET_API_TOKEN=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.apiToken // empty') + echo "No BITBUCKET_API_TOKEN in environment, using the API token from platform (hidden)" fi -case "$BITBUCKET_AUTH_METHOD" in - workspace_access_token) - # The exact analogue of a GitLab group access token: workspace-scoped, not - # tied to a human, long-lived. REQUIRES BITBUCKET PREMIUM. - if [[ -n "${BITBUCKET_TOKEN:-}" ]]; then - echo "Using BITBUCKET_TOKEN from environment (hidden)" - else - BITBUCKET_TOKEN=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.access_token // empty') - echo "No BITBUCKET_TOKEN in environment, using the workspace access token from platform (hidden)" - fi - - if [[ -z "$BITBUCKET_TOKEN" ]]; then - bb_fail "auth_method is 'workspace_access_token' but no token is configured on the bitbucket-configuration provider (.attributes.setup.access_token). Note that workspace access tokens require Bitbucket Premium -- if this workspace is not on Premium, set auth_method to 'oauth2'." - fi - ;; - - oauth2) - # OAuth 2.0 consumer, client_credentials (2LO). Works on ALL Bitbucket - # plans. The token represents the consumer's owner -- the workspace -- not - # an employee, so it survives staff turnover. - # - # LIFETIME: the token lives 2 hours and this grant returns NO refresh token - # (you re-mint from the long-lived key/secret). An ALM run is a single - # application-creation hook -- seconds to low minutes -- so we mint exactly - # ONCE, here, and never refresh. That is deliberate: a cache or a refresh - # loop would be YAGNI. Accepted consequence: a run lasting >2h would fail - # with 401 partway through. If that ever becomes real, re-mint inside bb_api - # on a 401 rather than building a background refresher. - OAUTH_KEY="${BITBUCKET_OAUTH_KEY:-}" - OAUTH_SECRET="${BITBUCKET_OAUTH_SECRET:-}" - - if [[ -z "$OAUTH_KEY" ]]; then - OAUTH_KEY=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.oauth_key // empty') - fi - - if [[ -z "$OAUTH_SECRET" ]]; then - OAUTH_SECRET=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.oauth_secret // empty') - fi - - if [[ -z "$OAUTH_KEY" || -z "$OAUTH_SECRET" ]]; then - bb_fail "auth_method is 'oauth2' but oauth_key/oauth_secret are not configured on the bitbucket-configuration provider (.attributes.setup.oauth_key, .attributes.setup.oauth_secret)." - fi - - echo "Minting a Bitbucket OAuth 2.0 access token (client_credentials) for workspace: $BITBUCKET_WORKSPACE" - - OAUTH_RESPONSE=$(curl -s -u "${OAUTH_KEY}:${OAUTH_SECRET}" \ - -X POST "${BITBUCKET_INSTALLATION_URL}/site/oauth2/access_token" \ - -d grant_type=client_credentials) - - BITBUCKET_TOKEN=$(echo "$OAUTH_RESPONSE" | jq -r '.access_token // empty' 2>/dev/null) - - if [[ -z "$BITBUCKET_TOKEN" ]]; then - # Never echo OAUTH_RESPONSE wholesale -- on success it contains the token. - bb_fail "Could not mint an OAuth token for workspace '$BITBUCKET_WORKSPACE'. Bitbucket said: $(echo "$OAUTH_RESPONSE" | jq -r '.error_description // .error // "unknown error"' 2>/dev/null)" - fi - - echo "Minted an OAuth access token (hidden), expires in $(echo "$OAUTH_RESPONSE" | jq -r '.expires_in // "?"')s" - ;; - - *) - bb_fail "Unsupported auth_method '$BITBUCKET_AUTH_METHOD'. Expected 'workspace_access_token' or 'oauth2'. (Bitbucket app passwords were removed on 2026-07-28 and are not supported.)" - ;; -esac +if [[ -z "$BITBUCKET_API_TOKEN" ]]; then + bb_fail "BITBUCKET_API_TOKEN is not set. Configure the bot user's Atlassian API token on the bitbucket-configuration provider (.attributes.setup.apiToken / bitbucket.apiToken) or in the environment. It must belong to a 2SV-enabled Bitbucket user -- that is the only principal Bitbucket lets enable Pipelines. (Bitbucket app passwords were removed on 2026-07-28 and are not supported.)" +fi # --- project key ----------------------------------------------------------- # @@ -136,12 +91,12 @@ esac if [[ -n "${BITBUCKET_PROJECT_KEY:-}" ]]; then echo "Using BITBUCKET_PROJECT_KEY from environment: $BITBUCKET_PROJECT_KEY" else - BITBUCKET_PROJECT_KEY=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.project_key // empty') + BITBUCKET_PROJECT_KEY=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.projectKey // empty') echo "No BITBUCKET_PROJECT_KEY in environment, using value from platform: $BITBUCKET_PROJECT_KEY" fi if [[ -z "$BITBUCKET_PROJECT_KEY" ]]; then - bb_fail "BITBUCKET_PROJECT_KEY is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.project_key) or in the environment. It cannot be omitted: Bitbucket would silently assign the new repository to the oldest project in the workspace." + bb_fail "BITBUCKET_PROJECT_KEY is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.projectKey / bitbucket.projectKey) or in the environment. It cannot be omitted: Bitbucket would silently assign the new repository to the oldest project in the workspace." fi # --- repository slug ------------------------------------------------------- @@ -167,10 +122,10 @@ fi echo "Bitbucket workspace: $BITBUCKET_WORKSPACE" echo "Bitbucket project: $BITBUCKET_PROJECT_KEY" echo "Bitbucket repository: $REPOSITORY_SLUG" -echo "Bitbucket auth: $BITBUCKET_AUTH_METHOD" +echo "Bitbucket bot user: $BITBUCKET_EMAIL" -export BITBUCKET_TOKEN -export BITBUCKET_AUTH_METHOD +export BITBUCKET_EMAIL +export BITBUCKET_API_TOKEN export BITBUCKET_WORKSPACE export BITBUCKET_PROJECT_KEY export BITBUCKET_INSTALLATION_URL diff --git a/scripts/code-repo/bitbucket/create_repository b/scripts/code-repo/bitbucket/create_repository index 3aca782..18a3b57 100644 --- a/scripts/code-repo/bitbucket/create_repository +++ b/scripts/code-repo/bitbucket/create_repository @@ -68,9 +68,10 @@ echo "Repository created: $BITBUCKET_REPOSITORY_URL" if [[ -z "${TEMPLATE_URL:-}" || "$TEMPLATE_URL" == "null" ]]; then echo "No TEMPLATE_URL set (the application has no template_id); leaving $BITBUCKET_REPOSITORY_URL empty." else - # The literal string 'x-token-auth' is required by Bitbucket. It works for - # both a workspace access token and a minted OAuth 2LO token. - PUSH_URL="https://x-token-auth:${BITBUCKET_TOKEN}@bitbucket.org/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}.git" + # For an Atlassian API token the git username MUST be the literal string + # 'x-bitbucket-api-token-auth' and the password is the token itself (the + # 'x-token-auth' form is for OAuth/access tokens and would fail auth here). + PUSH_URL="https://x-bitbucket-api-token-auth:${BITBUCKET_API_TOKEN}@bitbucket.org/${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}.git" CLONE_URL="$TEMPLATE_URL" @@ -79,7 +80,7 @@ else if [[ "$TEMPLATE_URL" == "https://bitbucket.org/"* ]]; then TEMPLATE_PATH="${TEMPLATE_URL#https://bitbucket.org/}" TEMPLATE_PATH="${TEMPLATE_PATH%.git}" - CLONE_URL="https://x-token-auth:${BITBUCKET_TOKEN}@bitbucket.org/${TEMPLATE_PATH}.git" + CLONE_URL="https://x-bitbucket-api-token-auth:${BITBUCKET_API_TOKEN}@bitbucket.org/${TEMPLATE_PATH}.git" fi SEED_DIR=$(mktemp -d) diff --git a/scripts/code-repo/bitbucket/run_first_build b/scripts/code-repo/bitbucket/run_first_build index 5b8845c..fa11532 100644 --- a/scripts/code-repo/bitbucket/run_first_build +++ b/scripts/code-repo/bitbucket/run_first_build @@ -45,6 +45,14 @@ if [[ "$PIPELINES_ENABLED" != "true" ]]; then bb_api PUT "$PIPELINES_CONFIG_PATH" '{"enabled": true}' + if [[ "$BB_HTTP_CODE" == "403" ]]; then + # This is NOT a missing scope. Enabling Pipelines requires a 2SV-enabled USER + # principal; the API refuses every other credential here (an OAuth app is + # refused permanently, a workspace access token fails the same check). Name + # the cause so nobody hunts a scopes bug that does not exist. + bb_fail "Bitbucket refused to enable Pipelines on ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG} (HTTP 403). This means the bot user (${BITBUCKET_EMAIL}) does not have two-step verification enabled on its BITBUCKET account -- 2SV is a prerequisite Bitbucket enforces before Pipelines can be turned on, through the API or the UI. Note this is Bitbucket 2SV, NOT Atlassian-account 2FA. Enable it on the bot user and re-run. Response: $BB_BODY" + fi + if [[ "$BB_HTTP_CODE" != "200" ]]; then bb_fail "Could not enable Bitbucket Pipelines (HTTP $BB_HTTP_CODE): $BB_BODY" fi diff --git a/scripts/code-repo/bitbucket/validate_repository_does_not_exist b/scripts/code-repo/bitbucket/validate_repository_does_not_exist index 9ff8d43..f9ec877 100644 --- a/scripts/code-repo/bitbucket/validate_repository_does_not_exist +++ b/scripts/code-repo/bitbucket/validate_repository_does_not_exist @@ -18,7 +18,7 @@ case "$BB_HTTP_CODE" in REPOSITORY_EXISTS="false" ;; 401 | 403) - bb_fail "Bitbucket rejected the credentials (HTTP $BB_HTTP_CODE) while checking ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}. This is NOT 'repository does not exist'. Check the ${BITBUCKET_AUTH_METHOD} credentials and that they carry the repository:admin scope. Response: $BB_BODY" + bb_fail "Bitbucket rejected the credentials (HTTP $BB_HTTP_CODE) while checking ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}. This is NOT 'repository does not exist'. Check the bot user's API token (bitbucket.email / bitbucket.apiToken) and that it carries the repository admin scope. Response: $BB_BODY" ;; *) bb_fail "Unexpected response from Bitbucket (HTTP $BB_HTTP_CODE) while checking ${BITBUCKET_WORKSPACE}/${REPOSITORY_SLUG}. Response: $BB_BODY" diff --git a/tests/cases/build_context_mints_oauth_token b/tests/cases/build_context_mints_oauth_token deleted file mode 100755 index e4bfcc3..0000000 --- a/tests/cases/build_context_mints_oauth_token +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -# 2LO works on every Bitbucket plan, unlike a workspace access token (Premium -# only), so it must produce a BITBUCKET_TOKEN exactly like the WAT path does. -source "$(dirname "$0")/../lib.sh" -setup -trap teardown EXIT -clear_context - -export REPOSITORY_NAME="my-service" -export CODE_REPOSITORY='{"attributes":{"setup":{ - "auth_method":"oauth2", - "workspace":"acme", - "project_key":"APP", - "oauth_key":"KEY123", - "oauth_secret":"SECRET456" -}}}' - -fixture POST "https://bitbucket.org/site/oauth2/access_token" 200 \ - '{"access_token":"minted-token","expires_in":7200,"token_type":"bearer"}' - -token=$(capture_export build_context BITBUCKET_TOKEN) - -if [[ "$token" != "minted-token" ]]; then - echo " FAIL: expected the minted OAuth token, got '$token'" - exit 1 -fi - -run_step build_context -assert_status 0 || exit 1 - -# The secret must never reach the logs. -if [[ "$STEP_OUTPUT" == *"SECRET456"* || "$STEP_OUTPUT" == *"minted-token"* ]]; then - echo " FAIL: credentials leaked into the step output:" - printf '%s\n' "$STEP_OUTPUT" | sed 's/^/ /' - exit 1 -fi diff --git a/tests/cases/build_context_normalises_slug b/tests/cases/build_context_normalises_slug index b8df25a..f610c57 100755 --- a/tests/cases/build_context_normalises_slug +++ b/tests/cases/build_context_normalises_slug @@ -10,10 +10,10 @@ clear_context export REPOSITORY_NAME="My-Service.git" export CODE_REPOSITORY='{"attributes":{"setup":{ - "auth_method":"workspace_access_token", "workspace":"acme", - "project_key":"APP", - "access_token":"wat-token" + "projectKey":"APP", + "email":"bot@nullplatform.com", + "apiToken":"secret-token-123" }}}' slug=$(capture_export build_context REPOSITORY_SLUG) diff --git a/tests/cases/build_context_reads_api_token b/tests/cases/build_context_reads_api_token new file mode 100755 index 0000000..b3e3cca --- /dev/null +++ b/tests/cases/build_context_reads_api_token @@ -0,0 +1,40 @@ +#!/bin/bash +# nullplatform holds a single credential for Bitbucket: a bot user's Atlassian +# API token (+ its email, the HTTP Basic username). build_context reads both from +# the provider record and exports them for Basic auth. It is the ONLY credential +# that can enable Pipelines, so there is no auth_method branching any more. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="my-service" +export CODE_REPOSITORY='{"attributes":{"setup":{ + "workspace":"acme", + "projectKey":"APP", + "email":"bot@nullplatform.com", + "apiToken":"secret-token-123" +}}}' + +email=$(capture_export build_context BITBUCKET_EMAIL) +token=$(capture_export build_context BITBUCKET_API_TOKEN) + +if [[ "$email" != "bot@nullplatform.com" ]]; then + echo " FAIL: expected the bot email, got '$email'" + exit 1 +fi + +if [[ "$token" != "secret-token-123" ]]; then + echo " FAIL: expected the API token, got '$token'" + exit 1 +fi + +run_step build_context +assert_status 0 || exit 1 + +# The token must never reach the logs. +if [[ "$STEP_OUTPUT" == *"secret-token-123"* ]]; then + echo " FAIL: the API token leaked into the step output:" + printf '%s\n' "$STEP_OUTPUT" | sed 's/^/ /' + exit 1 +fi diff --git a/tests/cases/build_context_requires_credentials b/tests/cases/build_context_requires_credentials new file mode 100755 index 0000000..a890a90 --- /dev/null +++ b/tests/cases/build_context_requires_credentials @@ -0,0 +1,21 @@ +#!/bin/bash +# App passwords are gone and OAuth/workspace tokens cannot enable Pipelines, so +# the bot user's API token is the one credential. If it is not configured, fail +# loudly and name it rather than proceeding to a confusing 401 later. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="my-service" +export CODE_REPOSITORY='{"attributes":{"setup":{ + "workspace":"acme", + "projectKey":"APP", + "email":"bot@nullplatform.com" +}}}' + +run_step build_context + +assert_status 1 || exit 1 +assert_contains "BITBUCKET_API_TOKEN is not set" || exit 1 +assert_contains "apiToken" || exit 1 diff --git a/tests/cases/build_context_requires_project_key b/tests/cases/build_context_requires_project_key index 463d680..463250a 100755 --- a/tests/cases/build_context_requires_project_key +++ b/tests/cases/build_context_requires_project_key @@ -9,9 +9,9 @@ clear_context export REPOSITORY_NAME="my-service" export CODE_REPOSITORY='{"attributes":{"setup":{ - "auth_method":"workspace_access_token", "workspace":"acme", - "access_token":"wat-token" + "email":"bot@nullplatform.com", + "apiToken":"secret-token-123" }}}' run_step build_context diff --git a/tests/lib.sh b/tests/lib.sh index 125c68f..3604a11 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -21,7 +21,8 @@ setup() { export PATH="$REPO_ROOT/tests/stubs:$PATH" # The context that build_context exports for every later step. - export BITBUCKET_TOKEN="test-token" + export BITBUCKET_EMAIL="bot@nullplatform.com" + export BITBUCKET_API_TOKEN="test-token" export BITBUCKET_WORKSPACE="acme" export BITBUCKET_PROJECT_KEY="APP" export BITBUCKET_INSTALLATION_URL="https://bitbucket.org" @@ -39,9 +40,9 @@ teardown() { # setup() pre-exports that context because it is what the steps AFTER # build_context consume; a build_context test must start from a clean slate. clear_context() { - unset BITBUCKET_TOKEN BITBUCKET_WORKSPACE BITBUCKET_PROJECT_KEY \ - BITBUCKET_INSTALLATION_URL BITBUCKET_API_BASE BITBUCKET_AUTH_METHOD \ - BITBUCKET_OAUTH_KEY BITBUCKET_OAUTH_SECRET REPOSITORY_SLUG + unset BITBUCKET_EMAIL BITBUCKET_API_TOKEN BITBUCKET_WORKSPACE \ + BITBUCKET_PROJECT_KEY BITBUCKET_INSTALLATION_URL BITBUCKET_API_BASE \ + REPOSITORY_SLUG } # fixture METHOD PATH CODE [BODY] diff --git a/tests/smoke_bitbucket_live b/tests/smoke_bitbucket_live index 08573c5..9e7c6ba 100755 --- a/tests/smoke_bitbucket_live +++ b/tests/smoke_bitbucket_live @@ -6,22 +6,15 @@ # credentials and it creates real repositories. It is the only thing that proves # the API contracts this provider is built on (repo-create returns 200 not 201; # GET pipelines_config 404s before Pipelines is enabled; pipeline variables have -# no upsert; and -- unvalidated until you run this -- that a 2LO token is -# allowed to create a repository at all). +# no upsert; and that the bot user's API token can enable Pipelines -- the one +# thing an OAuth app or workspace token cannot do). # -# Run it once per auth_method. +# nullplatform authenticates with a single dedicated bot user's Atlassian API +# token, over HTTP Basic (email:api_token). The bot user MUST have Bitbucket +# two-step verification enabled or the Pipelines-enable step returns 403. # -# # Workspace Access Token (Bitbucket Premium): -# BITBUCKET_AUTH_METHOD=workspace_access_token \ -# BITBUCKET_TOKEN= \ -# BITBUCKET_WORKSPACE=np-scratch \ -# BITBUCKET_PROJECT_KEY=SCRATCH \ -# TEMPLATE_URL=https://github.com/nullplatform/technology-templates-nodejs \ -# tests/smoke_bitbucket_live -# -# # OAuth 2LO (all plans): -# BITBUCKET_AUTH_METHOD=oauth2 \ -# BITBUCKET_OAUTH_KEY= BITBUCKET_OAUTH_SECRET= \ +# BITBUCKET_EMAIL=bot@nullplatform.com \ +# BITBUCKET_API_TOKEN= \ # BITBUCKET_WORKSPACE=np-scratch \ # BITBUCKET_PROJECT_KEY=SCRATCH \ # TEMPLATE_URL=https://github.com/nullplatform/technology-templates-nodejs \ @@ -44,7 +37,7 @@ export CODE_REPOSITORY_COLLABORATORS='[]' export CODE_REPOSITORY_SECRETS='{"NULLPLATFORM_API_KEY":"smoke-test-value"}' export APPLICATION_ID="0" -echo "=== smoke: ${BITBUCKET_WORKSPACE}/${SLUG} (auth: ${BITBUCKET_AUTH_METHOD}) ===" +echo "=== smoke: ${BITBUCKET_WORKSPACE}/${SLUG} (bot user: ${BITBUCKET_EMAIL}) ===" # Each step is sourced, exactly as the workflow engine does it. A step that # fails calls `exit 1`, which ends this script too -- which is the point. @@ -74,7 +67,7 @@ fi echo "ok Contract A: $BITBUCKET_REPOSITORY_URL" # The template must actually be in the repo, as ONE root commit. -COMMITS=$(curl -s -H "Authorization: Bearer $BITBUCKET_TOKEN" \ +COMMITS=$(curl -s -u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}" \ "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}/commits/main" \ | jq '.values | length') @@ -86,7 +79,7 @@ fi echo "ok template seeded as a single root commit" # The credential must be stored secured (its value is then write-only). -SECURED=$(curl -s -H "Authorization: Bearer $BITBUCKET_TOKEN" \ +SECURED=$(curl -s -u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}" \ "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}/pipelines_config/variables?pagelen=100" \ | jq -r '.values[] | select(.key == "NULLPLATFORM_API_KEY") | .secured') @@ -107,9 +100,9 @@ echo "" echo "--- cleanup ---" CLEANUP_CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \ - -H "Authorization: Bearer $BITBUCKET_TOKEN" \ + -u "${BITBUCKET_EMAIL}:${BITBUCKET_API_TOKEN}" \ "https://api.bitbucket.org/2.0/repositories/${BITBUCKET_WORKSPACE}/${SLUG}") echo "deleted ${BITBUCKET_WORKSPACE}/${SLUG} (HTTP $CLEANUP_CODE)" echo "" -echo "=== smoke passed (auth: ${BITBUCKET_AUTH_METHOD}) ===" +echo "=== smoke passed (bot user: ${BITBUCKET_EMAIL}) ===" From 6e66f46ef5876f5b7ba14e58ea4a5baee21b52a9 Mon Sep 17 00:00:00 2001 From: Pablo Vilas Date: Mon, 20 Jul 2026 12:05:00 -0300 Subject: [PATCH 13/13] fix(code-repo): read snake_case provider keys and source bitbucket token from env Two production-path bugs in build_context: - The provider record from `np provider list` uses the schema (snake_case) attribute shape, like the GitLab provider reads .attributes.setup.access_token. The camelCase paths (projectKey/installationUrl/apiToken) resolved to null; read project_key/installation_url/api_token instead. The camelCase forms are the NRN key names only, kept in the error-message hints. - The api token is marked secret, and providers-api nullifies secret attribute values on authenticated reads, so `np provider list` returns api_token: null -- the platform read cannot supply the credential (GitLab only works because its access_token was never marked secret). Make the BITBUCKET_API_TOKEN env var the primary, documented source; fail loudly explaining the secret-nullification and how to configure it when neither env nor record yields a token. Offline fixtures now mirror real `np provider list` output (snake_case, api_token null) plus an env-provided-token happy path. README documents the credential-sourcing difference vs GitLab and its rationale. --- README.md | 29 +++++++--- scripts/code-repo/bitbucket/build_context | 38 ++++++++++--- tests/cases/build_context_normalises_slug | 5 +- tests/cases/build_context_reads_api_token | 40 -------------- .../cases/build_context_requires_credentials | 17 +++--- .../cases/build_context_requires_project_key | 6 ++- tests/cases/build_context_token_from_env | 54 +++++++++++++++++++ 7 files changed, 123 insertions(+), 66 deletions(-) delete mode 100755 tests/cases/build_context_reads_api_token create mode 100755 tests/cases/build_context_token_from_env diff --git a/README.md b/README.md index 526bb21..9ef8a01 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,14 @@ You must configure your code repository provider through **nullplatform platform Configure a `bitbucket-configuration` code-repository provider with: -| Attribute | Required | Notes | -|---|---|---| -| `setup.workspace` | yes | The Bitbucket workspace slug. | -| `setup.projectKey` | yes | The key of the project new repositories are filed under. **Not optional**: omit it and Bitbucket silently assigns the repository to the workspace's oldest project. | -| `setup.email` | yes | The Atlassian account email of the dedicated Bitbucket **bot user**. This is the HTTP Basic username for the API token. | -| `setup.apiToken` | yes | The bot user's **Atlassian API token**. Used as the HTTP Basic password for the REST API and, with the git username `x-bitbucket-api-token-auth`, for git-over-HTTPS. | -| `setup.installationUrl` | no | Defaults to `https://bitbucket.org`. | -| `access.default_collaborators` | no | See the limitation below. | +| Attribute (provider `setup`) | NRN key | Required | Source | Notes | +|---|---|---|---|---| +| `workspace` | `bitbucket.workspace` | yes | provider or env | The Bitbucket workspace slug. | +| `project_key` | `bitbucket.projectKey` | yes | provider or env | The key of the project new repositories are filed under. **Not optional**: omit it and Bitbucket silently assigns the repository to the workspace's oldest project. | +| `email` | `bitbucket.email` | yes | provider or env | The Atlassian account email of the dedicated Bitbucket **bot user**. HTTP Basic username for the API token. | +| `api_token` | `bitbucket.apiToken` | yes | **`BITBUCKET_API_TOKEN` env only** | The bot user's **Atlassian API token** — HTTP Basic password for the REST API and, with the git username `x-bitbucket-api-token-auth`, for git-over-HTTPS. See "credential sourcing" below. | +| `installation_url` | `bitbucket.installationUrl` | no | provider or env | Defaults to `https://bitbucket.org`. | +| `access.default_collaborators` | — | no | provider | See the limitation below. | > **Authentication is a dedicated bot user's Atlassian API token.** nullplatform must hold a > **user-scoped** credential because enabling Bitbucket Pipelines requires a two-step-verification @@ -59,6 +59,19 @@ Configure a `bitbucket-configuration` code-repository provider with: > **every** Bitbucket plan (no Premium required). Atlassian API tokens expire in ≤365 days, so rotate > it before then. **Bitbucket app passwords are not supported** (Atlassian removed them on 2026-07-28). +> **Credential sourcing — this is where Bitbucket differs from GitLab.** The API token is stored as a +> proper **secret** (the `bitbucket-configuration` spec marks `api_token` `secret: true`), and +> nullplatform **does not return secret attribute values on authenticated provider reads**. So `np +> provider list` — which ALM uses to load provider config — returns `api_token: null`; the platform +> read *cannot* hand ALM the token. (The GitLab provider only works from the platform read because its +> spec never marked `access_token` secret — a laxity not repeated here.) **Therefore the token's +> primary, documented source is the `BITBUCKET_API_TOKEN` environment variable set on the ALM +> deployment** (`build_context` also honours `BITBUCKET_EMAIL`, `BITBUCKET_WORKSPACE`, +> `BITBUCKET_PROJECT_KEY`, `BITBUCKET_INSTALLATION_URL`). The non-secret fields above are still read +> from the provider record. If the token is absent from both env and record, provisioning fails +> immediately with a message explaining exactly this. *(`np nrn read` can address raw NRN keys but is +> deprecated and is not relied upon here.)* + **Limitation — collaborators must already be workspace members.** Bitbucket has no API to invite a user to a workspace, so `add_collaborators` can only *grant repository permissions* to principals who are already members. If a configured collaborator is not a member, repository diff --git a/scripts/code-repo/bitbucket/build_context b/scripts/code-repo/bitbucket/build_context index e579913..f6e3b26 100644 --- a/scripts/code-repo/bitbucket/build_context +++ b/scripts/code-repo/bitbucket/build_context @@ -23,13 +23,17 @@ fi if [[ -n "${BITBUCKET_INSTALLATION_URL:-}" ]]; then echo "Using BITBUCKET_INSTALLATION_URL from environment: $BITBUCKET_INSTALLATION_URL" else - BITBUCKET_INSTALLATION_URL=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.installationUrl // empty') + # $CODE_REPOSITORY is `np provider list` output, whose attributes use the + # SCHEMA (snake_case) shape -- exactly like the GitLab provider reads + # .attributes.setup.installation_url. (The camelCase forms bitbucket.* are the + # NRN key names only.) + BITBUCKET_INSTALLATION_URL=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.installation_url // empty') echo "No BITBUCKET_INSTALLATION_URL in environment, using value from platform: $BITBUCKET_INSTALLATION_URL" fi if [[ -z "$BITBUCKET_INSTALLATION_URL" ]]; then BITBUCKET_INSTALLATION_URL="https://bitbucket.org" - echo "No installationUrl configured, defaulting to: $BITBUCKET_INSTALLATION_URL" + echo "No installation_url configured, defaulting to: $BITBUCKET_INSTALLATION_URL" fi # Strip any trailing slash so the URLs we build never double up. @@ -59,6 +63,19 @@ BITBUCKET_API_BASE="${BITBUCKET_API_BASE%/}" # # Every REST call uses HTTP Basic (email:api_token); git transport uses the # username `x-bitbucket-api-token-auth` with the token as the password. +# +# CREDENTIAL SOURCING -- this is where Bitbucket DIFFERS from GitLab. +# The token is a real SECRET: the bitbucket-configuration spec marks api_token +# `secret: true`, and providers-api NULLIFIES secret attribute values on +# authenticated reads (nullifySecrets in ProviderController). So `np provider +# list` returns .attributes.setup.api_token = null -- the platform read CANNOT +# supply the token. (GitLab's flow only works because its spec never marked +# access_token secret, a laxity we deliberately did not copy.) Therefore the +# token's primary, documented source is the BITBUCKET_API_TOKEN environment +# variable set on the ALM deployment. The record path is still read (harmless +# null in production; forward-compatible if a privileged read is ever wired in), +# but env wins. The non-secret fields (workspace, email, project_key, +# installation_url) DO come back from the provider read as normal. if [[ -n "${BITBUCKET_EMAIL:-}" ]]; then echo "Using BITBUCKET_EMAIL from environment: $BITBUCKET_EMAIL" @@ -68,18 +85,23 @@ else fi if [[ -z "$BITBUCKET_EMAIL" ]]; then - bb_fail "BITBUCKET_EMAIL is not set. Configure the bot user's Atlassian account email on the bitbucket-configuration provider (.attributes.setup.email / bitbucket.email) or in the environment. It is the HTTP Basic username for the API token." + bb_fail "BITBUCKET_EMAIL is not set. Configure the bot user's Atlassian account email on the bitbucket-configuration provider (.attributes.setup.email / NRN bitbucket.email) or in the environment. It is the HTTP Basic username for the API token." fi if [[ -n "${BITBUCKET_API_TOKEN:-}" ]]; then echo "Using BITBUCKET_API_TOKEN from environment (hidden)" else - BITBUCKET_API_TOKEN=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.apiToken // empty') - echo "No BITBUCKET_API_TOKEN in environment, using the API token from platform (hidden)" + # This is null in production (secret attributes are nullified on provider + # reads); kept for forward-compatibility and so the failure below is the + # single decision point. + BITBUCKET_API_TOKEN=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.api_token // empty') + if [[ -n "$BITBUCKET_API_TOKEN" ]]; then + echo "Using BITBUCKET_API_TOKEN from the provider record (hidden)" + fi fi if [[ -z "$BITBUCKET_API_TOKEN" ]]; then - bb_fail "BITBUCKET_API_TOKEN is not set. Configure the bot user's Atlassian API token on the bitbucket-configuration provider (.attributes.setup.apiToken / bitbucket.apiToken) or in the environment. It must belong to a 2SV-enabled Bitbucket user -- that is the only principal Bitbucket lets enable Pipelines. (Bitbucket app passwords were removed on 2026-07-28 and are not supported.)" + bb_fail "BITBUCKET_API_TOKEN is not set. The bot user's Atlassian API token is stored as a SECRET (setup.api_token / NRN bitbucket.apiToken), and nullplatform deliberately does NOT return secret attribute values on authenticated provider reads (np provider list) -- so, unlike the GitLab provider, ALM cannot read the Bitbucket token from the platform. Set it directly on the ALM deployment as the BITBUCKET_API_TOKEN environment variable. It must belong to a 2SV-enabled Bitbucket user -- that is the only principal Bitbucket lets enable Pipelines. (Bitbucket app passwords were removed on 2026-07-28 and are not supported.)" fi # --- project key ----------------------------------------------------------- @@ -91,12 +113,12 @@ fi if [[ -n "${BITBUCKET_PROJECT_KEY:-}" ]]; then echo "Using BITBUCKET_PROJECT_KEY from environment: $BITBUCKET_PROJECT_KEY" else - BITBUCKET_PROJECT_KEY=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.projectKey // empty') + BITBUCKET_PROJECT_KEY=$(echo "$CODE_REPOSITORY" | jq -r '.attributes.setup.project_key // empty') echo "No BITBUCKET_PROJECT_KEY in environment, using value from platform: $BITBUCKET_PROJECT_KEY" fi if [[ -z "$BITBUCKET_PROJECT_KEY" ]]; then - bb_fail "BITBUCKET_PROJECT_KEY is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.projectKey / bitbucket.projectKey) or in the environment. It cannot be omitted: Bitbucket would silently assign the new repository to the oldest project in the workspace." + bb_fail "BITBUCKET_PROJECT_KEY is not set. Configure it on the bitbucket-configuration provider (.attributes.setup.project_key / NRN bitbucket.projectKey) or in the environment. It cannot be omitted: Bitbucket would silently assign the new repository to the oldest project in the workspace." fi # --- repository slug ------------------------------------------------------- diff --git a/tests/cases/build_context_normalises_slug b/tests/cases/build_context_normalises_slug index f610c57..e26eb02 100755 --- a/tests/cases/build_context_normalises_slug +++ b/tests/cases/build_context_normalises_slug @@ -9,11 +9,12 @@ trap teardown EXIT clear_context export REPOSITORY_NAME="My-Service.git" +export BITBUCKET_API_TOKEN="env-token-xyz" export CODE_REPOSITORY='{"attributes":{"setup":{ "workspace":"acme", - "projectKey":"APP", + "project_key":"APP", "email":"bot@nullplatform.com", - "apiToken":"secret-token-123" + "api_token":null }}}' slug=$(capture_export build_context REPOSITORY_SLUG) diff --git a/tests/cases/build_context_reads_api_token b/tests/cases/build_context_reads_api_token deleted file mode 100755 index b3e3cca..0000000 --- a/tests/cases/build_context_reads_api_token +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash -# nullplatform holds a single credential for Bitbucket: a bot user's Atlassian -# API token (+ its email, the HTTP Basic username). build_context reads both from -# the provider record and exports them for Basic auth. It is the ONLY credential -# that can enable Pipelines, so there is no auth_method branching any more. -source "$(dirname "$0")/../lib.sh" -setup -trap teardown EXIT -clear_context - -export REPOSITORY_NAME="my-service" -export CODE_REPOSITORY='{"attributes":{"setup":{ - "workspace":"acme", - "projectKey":"APP", - "email":"bot@nullplatform.com", - "apiToken":"secret-token-123" -}}}' - -email=$(capture_export build_context BITBUCKET_EMAIL) -token=$(capture_export build_context BITBUCKET_API_TOKEN) - -if [[ "$email" != "bot@nullplatform.com" ]]; then - echo " FAIL: expected the bot email, got '$email'" - exit 1 -fi - -if [[ "$token" != "secret-token-123" ]]; then - echo " FAIL: expected the API token, got '$token'" - exit 1 -fi - -run_step build_context -assert_status 0 || exit 1 - -# The token must never reach the logs. -if [[ "$STEP_OUTPUT" == *"secret-token-123"* ]]; then - echo " FAIL: the API token leaked into the step output:" - printf '%s\n' "$STEP_OUTPUT" | sed 's/^/ /' - exit 1 -fi diff --git a/tests/cases/build_context_requires_credentials b/tests/cases/build_context_requires_credentials index a890a90..dd8eecf 100755 --- a/tests/cases/build_context_requires_credentials +++ b/tests/cases/build_context_requires_credentials @@ -1,21 +1,26 @@ #!/bin/bash -# App passwords are gone and OAuth/workspace tokens cannot enable Pipelines, so -# the bot user's API token is the one credential. If it is not configured, fail -# loudly and name it rather than proceeding to a confusing 401 later. +# The api token is a SECRET: `np provider list` returns api_token: null, so the +# platform read cannot supply it. With no BITBUCKET_API_TOKEN in the environment +# either, build_context must fail loudly, EXPLAIN why (secret not returned by +# provider reads) and say HOW to configure it. source "$(dirname "$0")/../lib.sh" setup trap teardown EXIT clear_context export REPOSITORY_NAME="my-service" + +# Production-shaped provider record: snake_case, secret value nulled, no env token. export CODE_REPOSITORY='{"attributes":{"setup":{ "workspace":"acme", - "projectKey":"APP", - "email":"bot@nullplatform.com" + "project_key":"APP", + "email":"bot@nullplatform.com", + "api_token":null }}}' run_step build_context assert_status 1 || exit 1 assert_contains "BITBUCKET_API_TOKEN is not set" || exit 1 -assert_contains "apiToken" || exit 1 +assert_contains "secret" || exit 1 +assert_contains "api_token" || exit 1 diff --git a/tests/cases/build_context_requires_project_key b/tests/cases/build_context_requires_project_key index 463250a..06d5c51 100755 --- a/tests/cases/build_context_requires_project_key +++ b/tests/cases/build_context_requires_project_key @@ -1,17 +1,19 @@ #!/bin/bash # project.key is effectively mandatory: omit it on create and Bitbucket SILENTLY # files the repo under the workspace's OLDEST project. Fail early and loudly -# rather than mis-file the customer's repository. +# rather than mis-file the customer's repository. (Token comes from the env, as +# in production; the record is the snake_case provider-list shape.) source "$(dirname "$0")/../lib.sh" setup trap teardown EXIT clear_context export REPOSITORY_NAME="my-service" +export BITBUCKET_API_TOKEN="env-token-xyz" export CODE_REPOSITORY='{"attributes":{"setup":{ "workspace":"acme", "email":"bot@nullplatform.com", - "apiToken":"secret-token-123" + "api_token":null }}}' run_step build_context diff --git a/tests/cases/build_context_token_from_env b/tests/cases/build_context_token_from_env new file mode 100755 index 0000000..a27fdb7 --- /dev/null +++ b/tests/cases/build_context_token_from_env @@ -0,0 +1,54 @@ +#!/bin/bash +# The Bitbucket api token is stored as a SECRET, so `np provider list` returns +# api_token: null (secret attributes are nullified on authenticated reads). The +# token is therefore supplied to the ALM deployment via the BITBUCKET_API_TOKEN +# env var, while the NON-secret fields (email, workspace, project_key) still come +# from the provider record -- in the schema's snake_case shape. +source "$(dirname "$0")/../lib.sh" +setup +trap teardown EXIT +clear_context + +export REPOSITORY_NAME="my-service" + +# The credential the ALM deployment holds (what the platform cannot return). +export BITBUCKET_API_TOKEN="env-token-xyz" + +# Exactly what `np provider list` returns: snake_case attributes, secret nulled. +export CODE_REPOSITORY='{"attributes":{"setup":{ + "workspace":"acme", + "project_key":"APP", + "installation_url":"https://bitbucket.org", + "email":"bot@nullplatform.com", + "api_token":null +}}}' + +token=$(capture_export build_context BITBUCKET_API_TOKEN) +email=$(capture_export build_context BITBUCKET_EMAIL) +proj=$(capture_export build_context BITBUCKET_PROJECT_KEY) + +if [[ "$token" != "env-token-xyz" ]]; then + echo " FAIL: expected the env-provided token, got '$token'" + exit 1 +fi + +# Non-secret fields resolve from the record via the snake_case paths. +if [[ "$email" != "bot@nullplatform.com" ]]; then + echo " FAIL: expected the bot email from the record, got '$email'" + exit 1 +fi + +if [[ "$proj" != "APP" ]]; then + echo " FAIL: expected project key 'APP' from the record (.attributes.setup.project_key), got '$proj'" + exit 1 +fi + +run_step build_context +assert_status 0 || exit 1 + +# The token must never reach the logs. +if [[ "$STEP_OUTPUT" == *"env-token-xyz"* ]]; then + echo " FAIL: the API token leaked into the step output:" + printf '%s\n' "$STEP_OUTPUT" | sed 's/^/ /' + exit 1 +fi