diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a4a699fcb0..3c57bbccc1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -141,6 +141,7 @@ shell-unit-tests: image: ${PREPARE_IMAGE} script: - bash .gitlab/scripts/tests/includes_test.sh + - bash .gitlab/scripts/tests/test_check_stack_top.sh - bash .gitlab/dd-trace-integration/tests/post_pr_comment_test.sh # Shared version detection used by benchmarks and reliability pipelines diff --git a/.gitlab/benchmarks/.gitlab-ci.yml b/.gitlab/benchmarks/.gitlab-ci.yml index eee8d533a1..3e9ffe0fa2 100644 --- a/.gitlab/benchmarks/.gitlab-ci.yml +++ b/.gitlab/benchmarks/.gitlab-ci.yml @@ -1,19 +1,23 @@ variables: DD_OCTO_STS_IMAGE: registry.ddbuild.io/images/dd-octo-sts-ci-base:2025.06-1 -# Bridge job: triggers the BP pipeline and blocks until it completes. -# Bridge jobs cannot appear in other jobs' needs: — downstream jobs use -# stage ordering (post-benchmarks stage runs after benchmarks stage). -benchmarks-trigger: - stage: benchmarks - # Bridge jobs cannot have before_script, so CANCELLED is checked via rules. - # interruptible: false prevents orphaning the BP downstream pipeline on push. - interruptible: false - needs: - - job: get-versions - artifacts: true - - job: deploy-artifact - artifacts: false +# Benchmarks run only for the top of a PR stack. Stacked PRs chain +# head -> base (PR_n's base is PR_{n-1}'s head branch), so a branch with an +# open PR using it as base is below another PR in the stack. A bridge job +# cannot run the check itself (trigger jobs have no script) and dotenv +# variables cannot drive `rules`, so the decision is baked into which child +# pipeline YAML generate-benchmarks-child-pipeline emits: +# - top of stack (or check failed open): real child pipeline that triggers +# the BP pipeline (.gitlab/benchmarks/child.gitlab-ci.yml) +# - below another PR: noop child pipeline that only logs the skip +# This mirrors the generate-reliability-child-pipeline / run-reliability-tests +# pattern in .gitlab-ci.yml. + +# Shared skip conditions for the benchmarks jobs below. Both jobs must +# always coexist (run-benchmarks hard-needs the generate job's artifact), so +# the never-conditions MUST stay identical for both — keep them only here. +# This mirrors the .skip-on-release pattern in .gitlab/common.yml. +.benchmarks-skip-rules: rules: - if: '$CANCELLED == "true"' when: never @@ -24,26 +28,114 @@ benchmarks-trigger: when: never - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' when: never + +# Decide whether benchmarks run for this branch and generate the child +# pipeline YAML accordingly. +generate-benchmarks-child-pipeline: + stage: benchmarks + tags: ["arch:amd64"] + image: $DD_OCTO_STS_IMAGE + id_tokens: + DDOCTOSTS_ID_TOKEN: + aud: dd-octo-sts + needs: [] + rules: + - !reference [.benchmarks-skip-rules, rules] + # Runs automatically in web pipelines too: run-benchmarks (manual on web) + # hard-needs this job's artifact, so a manual-only generate job would + # break the manual trigger path with an unmet-needs artifact error. + - when: on_success + script: + # Single canonical parse of the script's contract output: stdout is + # exactly one line "RUN_BENCHMARKS="; stderr (diagnostics) passes + # through to the job log. Fail open unless the value is unambiguously + # "false" — an empty, missing, or corrupted parse must never skip + # benchmarks. + - | + RUN_BENCHMARKS=$(bash .gitlab/benchmarks/check-stack-top.sh | tail -n 1 | sed -n 's/^RUN_BENCHMARKS=//p' | tr -d '[:space:]') + case "$RUN_BENCHMARKS" in + false) RUN_BENCHMARKS=false ;; + *) RUN_BENCHMARKS=true ;; + esac + echo "RUN_BENCHMARKS=$RUN_BENCHMARKS" + if [ "$RUN_BENCHMARKS" = "true" ]; then + echo "Branch is the top of its PR stack — benchmarks enabled" + cp .gitlab/benchmarks/child.gitlab-ci.yml generated-benchmarks.yml + else + echo "Branch is below another PR in its stack — benchmarks skipped" + cat > generated-benchmarks.yml << 'NOOP' + skip-benchmarks: + image: registry.ddbuild.io/images/benchmarking-platform-tools-ubuntu:newest + tags: ["arch:amd64"] + script: + - echo "Branch is not the top of its PR stack — skipping benchmarks" + rules: + - when: always + NOOP + fi + artifacts: + paths: + - generated-benchmarks.yml + expire_in: 1 day + +# Bridge job: triggers the benchmarks child pipeline and blocks until it +# completes. +# +# Variable forwarding to the child pipeline: +# - CANDIDATE_VERSION / BASELINE_VERSION: get-versions produces them as a +# dotenv artifact; listing get-versions in needs injects them into THIS +# job's variable context, and redeclaring them here makes them +# trigger-job variables, which are forwarded by default (yaml_variables). +# - BENCHMARK_ITERATIONS / BENCHMARK_MODES: manual pipeline variables, +# forwarded via trigger:forward: pipeline_variables: true. +# - PARENT_PIPELINE_ID / PARENT_COMMIT_SHA / PARENT_COMMIT_BRANCH: the +# child pipeline's own CI_PIPELINE_ID/CI_COMMIT_* refer to the child, +# so the parent values must be passed explicitly. +run-benchmarks: + stage: benchmarks + # Bridge jobs cannot have before_script, so CANCELLED is checked via rules. + # interruptible: false prevents orphaning the BP downstream pipeline on push. + interruptible: false + needs: + - job: generate-benchmarks-child-pipeline + artifacts: true + - job: get-versions + artifacts: true + # Benchmarks benchmark the artifact published by deploy-artifact; without + # this gate the BP pipeline could start before the artifact exists. + # optional: true keeps the need satisfiable on branches where + # deploy-artifact is legitimately skipped (e.g. release branches), same + # as run-reliability-tests in .gitlab-ci.yml. + - job: deploy-artifact + artifacts: false + optional: true + variables: + PARENT_PIPELINE_ID: "$CI_PIPELINE_ID" + PARENT_COMMIT_SHA: "$CI_COMMIT_SHA" + # GitLab trigger variables do not evaluate Bash ${VAR:-default} + # expansions, so a fallback must be chosen as the single variable that is + # always populated: CI_COMMIT_REF_NAME (unlike CI_COMMIT_BRANCH) is set + # for detached-HEAD trigger/api pipelines too. + PARENT_COMMIT_BRANCH: "$CI_COMMIT_REF_NAME" + # Redeclared from the get-versions dotenv artifact (via needs) so they + # reach the child pipeline as trigger-job variables — see the comment + # above the job. + CANDIDATE_VERSION: "$CURRENT_VERSION" + BASELINE_VERSION: "$PREVIOUS_VERSION" + rules: + - !reference [.benchmarks-skip-rules, rules] - if: '$CI_PIPELINE_SOURCE == "web"' when: manual allow_failure: true - # Run automatically and non-blocking on any other source (push/trigger/api/etc.) - when: on_success allow_failure: true - variables: - CANDIDATE_VERSION: "${CURRENT_VERSION}" - BASELINE_VERSION: "${PREVIOUS_VERSION}" - BENCHMARK_ITERATIONS: "${BENCHMARK_ITERATIONS:-5}" - BENCHMARK_MODES: "${BENCHMARK_MODES:-cpu,wall,alloc,memleak}" - DDPROF_COMMIT_SHA: "${CI_COMMIT_SHA}" - DDPROF_COMMIT_BRANCH: "${CI_COMMIT_BRANCH}" - UPSTREAM_PROJECT_NAME: "java-profiler" - UPSTREAM_BRANCH: "${CI_COMMIT_BRANCH}" - UPSTREAM_PIPELINE_ID: "${CI_PIPELINE_ID}" trigger: - project: DataDog/apm-reliability/benchmarking-platform - branch: java-profiler + include: + - artifact: generated-benchmarks.yml + job: generate-benchmarks-child-pipeline strategy: depend + forward: + pipeline_variables: true publish-benchmark-gh-pages: diff --git a/.gitlab/benchmarks/check-stack-top.sh b/.gitlab/benchmarks/check-stack-top.sh new file mode 100755 index 0000000000..20b756f4c8 --- /dev/null +++ b/.gitlab/benchmarks/check-stack-top.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# check-stack-top.sh - Decide whether benchmarks should run for this branch. +# +# A branch is the top of its PR stack when no other open PR uses it as base +# branch. Stacked PRs chain head -> base (PR_n's base is PR_{n-1}'s head +# branch), so an open PR with base == means this branch sits +# below another PR in the stack. Benchmarks only run for the top of a stack: +# intermediate commits get no BP pipeline. +# +# Usage: check-stack-top.sh [branch] +# branch defaults to $CI_COMMIT_BRANCH. +# Output: exactly one line "RUN_BENCHMARKS=true" or "RUN_BENCHMARKS=false" on +# stdout (diagnostics go to stderr). +# +# Fail-open: on any API/auth/parsing error the decision is RUN_BENCHMARKS=true +# so a GitHub outage can never silently disable benchmark coverage. +# +# Authentication mirrors .gitlab/common/lookup-pr.sh: Octo-STS token when +# available, GITHUB_TOKEN fallback, anonymous as last resort. The token is +# passed to curl via a header file, never as a command-line argument (argv is +# world-readable on shared runners via /proc/*/cmdline). + +set -uo pipefail + +BRANCH="${1:-${CI_COMMIT_BRANCH:-}}" +REPO="DataDog/java-profiler" + +debug() { echo "[DEBUG] $*" >&2; } + +result() { + echo "RUN_BENCHMARKS=$1" + debug "decision: RUN_BENCHMARKS=$1 ($2)" + exit 0 +} + +# main/master are the stack root: open PRs target them as base by +# definition, so the base-branch query below would always report "not top". +if [ -z "${BRANCH}" ] || [ "${BRANCH}" = "main" ] || [ "${BRANCH}" = "master" ]; then + result "true" "branch is ${BRANCH:-}" +fi + +# Authentication: pre-existing GITHUB_TOKEN env var, refreshed via dd-octo-sts +# when available (same scheme as lookup-pr.sh) +GITHUB_TOKEN="${GITHUB_TOKEN:-}" +if command -v dd-octo-sts >/dev/null 2>&1 && [ -n "${DDOCTOSTS_ID_TOKEN:-}" ]; then + debug "Attempting to get token via Octo-STS..." + if TOKEN_OUTPUT=$(dd-octo-sts token --scope "${REPO}" --policy async-profiler-build.ci 2>/tmp/dd-octo-sts-stack-top-error.log) && [ -n "${TOKEN_OUTPUT}" ]; then + GITHUB_TOKEN="${TOKEN_OUTPUT}" + debug "Got GitHub token via Octo-STS" + else + debug "Failed to get token via Octo-STS, falling back" + fi +fi +# Normalize: strip CR/LF/outer whitespace and reject values that are not a +# bare token — a multi-line or decorated value would be interpolated into an +# HTTP header (injected headers or guaranteed auth failure). +GITHUB_TOKEN=$(printf '%s' "${GITHUB_TOKEN}" | tr -d '\r\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') +if [ -n "${GITHUB_TOKEN}" ] && ! printf '%s' "${GITHUB_TOKEN}" | grep -qE '^[A-Za-z0-9_.=-]+$'; then + debug "Token has unexpected shape — ignoring it" + GITHUB_TOKEN="" +fi + +# URL-encode the branch name (/ -> %2F, etc.) - same approach as lookup-pr.sh +url_encode() { + local string="$1" + if command -v jq >/dev/null 2>&1; then + printf '%s' "$string" | jq -sRr @uri + else + # % must be encoded first so the encodings added below are not re-encoded + printf '%s' "$string" | sed 's/%/%25/g; s|/|%2F|g; s/ /%20/g; s/#/%23/g; s/+/%2B/g; s/&/%26/g; s/?/%3F/g; s/=/%3D/g; s/:/%3A/g; s/;/%3B/g; s/@/%40/g' + fi +} + +ENCODED_BRANCH=$(url_encode "${BRANCH}") +API_URL="https://api.github.com/repos/${REPO}/pulls?state=open&base=${ENCODED_BRANCH}&per_page=1" +debug "API URL: ${API_URL}" + +# Pass the credential out-of-band: curl reads the header from a 0600 temp file +# instead of receiving it as a world-readable argv element. +AUTH_HEADER_FILE="" +cleanup() { [ -n "${AUTH_HEADER_FILE}" ] && rm -f "${AUTH_HEADER_FILE}"; } +trap cleanup EXIT +if [ -n "${GITHUB_TOKEN}" ]; then + AUTH_HEADER_FILE=$(mktemp "${TMPDIR:-/tmp}/sphinx-stack-top-hdr.XXXXXX") + chmod 600 "${AUTH_HEADER_FILE}" + printf 'Authorization: token %s\n' "${GITHUB_TOKEN}" > "${AUTH_HEADER_FILE}" + debug "Using authenticated request (header via file)" +else + debug "Using anonymous request (may be rate limited)" +fi + +BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/sphinx-stack-top-body.XXXXXX") +HTTP_CODE=$(curl -s -o "${BODY_FILE}" -w '%{http_code}' --max-time 10 \ + ${AUTH_HEADER_FILE:+-H "@${AUTH_HEADER_FILE}"} \ + -H "Accept: application/vnd.github+json" \ + "${API_URL}" 2>/dev/null) +CURL_EXIT=$? +response=$(cat "${BODY_FILE}" 2>/dev/null) +rm -f "${BODY_FILE}" + +if [ "${CURL_EXIT}" -ne 0 ]; then + result "true" "curl failed with exit ${CURL_EXIT} (fail-open)" +fi +if [ "${HTTP_CODE}" != "200" ]; then + # HTTP 403 with a JSON error object is the anonymous rate-limit response; + # surfacing the code here makes that failure observable instead of silent. + result "true" "GitHub API returned HTTP ${HTTP_CODE} (fail-open)" +fi + +debug "API response length: ${#response} chars" +debug "API response preview: ${response:0:200}" + +if ! command -v jq >/dev/null 2>&1; then + result "true" "jq not available (fail-open)" +fi + +if ! echo "${response}" | jq -e 'type == "array"' >/dev/null 2>&1; then + result "true" "response is not a JSON array (API error, fail-open)" +fi + +STACKED_ON_TOP=$(echo "${response}" | jq 'length') +if [ "${STACKED_ON_TOP}" -gt 0 ]; then + result "false" "${STACKED_ON_TOP} open PR(s) use ${BRANCH} as base branch" +fi + +result "true" "no open PR stacks on ${BRANCH}" diff --git a/.gitlab/benchmarks/child.gitlab-ci.yml b/.gitlab/benchmarks/child.gitlab-ci.yml new file mode 100644 index 0000000000..d87065892f --- /dev/null +++ b/.gitlab/benchmarks/child.gitlab-ci.yml @@ -0,0 +1,64 @@ +# Benchmarks child pipeline. +# +# Triggered by run-benchmarks in the parent pipeline (generated copy of this +# file is passed via `trigger: include: artifact`). Created only when +# generate-benchmarks-child-pipeline decided benchmarks should run — either +# because the branch is the top of its PR stack (check-stack-top.sh) or the +# check failed open. +# +# Variables forwarded from the parent pipeline via run-benchmarks: +# - CANDIDATE_VERSION / BASELINE_VERSION: declared in run-benchmarks' +# `variables:` block (values from the get-versions dotenv artifact), so +# they arrive as trigger-job variables — the documented forwarding path. +# - BENCHMARK_ITERATIONS / BENCHMARK_MODES (manual pipeline variables, via +# trigger:forward: pipeline_variables: true) +# - PARENT_PIPELINE_ID / PARENT_COMMIT_SHA / PARENT_COMMIT_BRANCH: set +# explicitly by run-benchmarks because CI_PIPELINE_ID/CI_COMMIT_* inside +# this child pipeline refer to the child, not the parent. +--- +stages: + - benchmarks + +# Fail fast when the version variables did not survive the parent -> child +# hop: without this guard the BP pipeline would consume empty versions. +benchmarks-inputs-guard: + stage: benchmarks + image: registry.ddbuild.io/images/benchmarking-platform-tools-ubuntu:newest + tags: ["arch:amd64"] + needs: [] + rules: + # The child pipeline is independently retryable in the UI; a manual retry + # must not bypass the parent-level CANCELLED gate and start a BP run. + - if: '$CANCELLED == "true"' + when: never + - when: on_success + script: + - '[ -n "${CANDIDATE_VERSION:-}" ] || { echo "CANDIDATE_VERSION is empty — version forwarding from the parent pipeline failed"; exit 1; }' + - '[ -n "${BASELINE_VERSION:-}" ] || { echo "BASELINE_VERSION is empty — version forwarding from the parent pipeline failed"; exit 1; }' + - echo "CANDIDATE_VERSION=${CANDIDATE_VERSION} BASELINE_VERSION=${BASELINE_VERSION}" + +# Bridge job: triggers the BP pipeline and blocks until it completes. +# Bridge jobs cannot appear in other jobs' needs: — downstream jobs use +# stage ordering (post-benchmarks stage in the parent pipeline runs after +# the benchmarks stage). +benchmarks-trigger: + stage: benchmarks + # Bridge jobs cannot have before_script, so CANCELLED is checked via the + # guard job's rules above. + # interruptible: false prevents orphaning the BP downstream pipeline on push. + interruptible: false + needs: [benchmarks-inputs-guard] + variables: + CANDIDATE_VERSION: "${CANDIDATE_VERSION}" + BASELINE_VERSION: "${BASELINE_VERSION}" + BENCHMARK_ITERATIONS: "${BENCHMARK_ITERATIONS:-5}" + BENCHMARK_MODES: "${BENCHMARK_MODES:-cpu,wall,alloc,memleak}" + DDPROF_COMMIT_SHA: "${PARENT_COMMIT_SHA}" + DDPROF_COMMIT_BRANCH: "${PARENT_COMMIT_BRANCH}" + UPSTREAM_PROJECT_NAME: "java-profiler" + UPSTREAM_BRANCH: "${PARENT_COMMIT_BRANCH}" + UPSTREAM_PIPELINE_ID: "${PARENT_PIPELINE_ID}" + trigger: + project: DataDog/apm-reliability/benchmarking-platform + branch: java-profiler + strategy: depend diff --git a/.gitlab/benchmarks/download-bp-reports.sh b/.gitlab/benchmarks/download-bp-reports.sh index 9c323dfc47..a5327e64fe 100755 --- a/.gitlab/benchmarks/download-bp-reports.sh +++ b/.gitlab/benchmarks/download-bp-reports.sh @@ -3,7 +3,10 @@ # # Requires only curl and python3 (stdlib) — no aws CLI, pip, or boto3 needed. # BP jobs already store artifacts in GitLab; this fetches them directly from -# the downstream pipeline triggered by benchmarks-trigger. +# the BP downstream pipeline. The BP pipeline hangs off the benchmarks child +# pipeline: parent `run-benchmarks` bridge -> benchmarks child pipeline -> +# `benchmarks-trigger` bridge -> BP pipeline, so the bridge chain is traversed +# before the BP pipeline id can be resolved. set -uo pipefail # intentionally no -e: we handle errors explicitly DEST="${1:-reports}" @@ -27,7 +30,33 @@ api_get() { return 0 } -# ── 1. find the benchmarks-trigger bridge ──────────────────────────────────── +# ── helper: find a bridge by name in a bridges.json ──────────────────── +# Usage: find_bridge +# Prints " " when the named bridge has a downstream +# pipeline (status success required iff require_success=yes); prints nothing +# otherwise. +find_bridge() { + python3 - "$1" "$2" "$3" <<'PYEOF' +import json, sys +with open(sys.argv[1]) as f: + bridges = json.load(f) +require_success = sys.argv[3] == "yes" +for b in bridges: + if b.get("name") == sys.argv[2]: + dp = b.get("downstream_pipeline") or {} + if dp.get("id") and dp.get("project_id") and (not require_success or dp.get("status") == "success"): + print(dp["project_id"], dp["id"]) + break +PYEOF +} + +# ── 1. find the BP pipeline by traversing the bridge chain ────────────── +# The BP pipeline sits two bridge levels below this pipeline: +# run-benchmarks (parent) -> benchmarks child pipeline -> benchmarks-trigger +# -> BP pipeline +# Traversal bridges are accepted in any state (a failed child pipeline must +# still be inspected to report why), but the BP bridge itself must be +# `success` — only then are all BP artifacts complete. BRIDGES_FILE="${TMPDIR_LOCAL}/bridges.json" echo "Querying bridges for pipeline ${CI_PIPELINE_ID}…" if ! api_get \ @@ -37,19 +66,24 @@ if ! api_get \ exit 0 fi -read -r BP_PROJECT_ID DOWNSTREAM_PIPELINE_ID < <(python3 - "${BRIDGES_FILE}" <<'PYEOF' -import json, sys -with open(sys.argv[1]) as f: - bridges = json.load(f) -for b in bridges: - if b.get("name") == "benchmarks-trigger": - dp = b.get("downstream_pipeline") or {} - if dp.get("id") and dp.get("project_id") and dp.get("status") == "success": - print(dp["project_id"], dp["id"]) - sys.exit(0) -print("", "") -PYEOF -) +BP_PROJECT_ID="" +DOWNSTREAM_PIPELINE_ID="" +read -r CHILD_PROJECT_ID CHILD_PIPELINE_ID < <(find_bridge "${BRIDGES_FILE}" "run-benchmarks" no) +if [ -n "${CHILD_PIPELINE_ID:-}" ]; then + echo "Benchmarks child pipeline: project=${CHILD_PROJECT_ID} pipeline=${CHILD_PIPELINE_ID}" + CHILD_BRIDGES_FILE="${TMPDIR_LOCAL}/child_bridges.json" + if api_get \ + "${CI_API_V4_URL}/projects/${CHILD_PROJECT_ID}/pipelines/${CHILD_PIPELINE_ID}/bridges" \ + "${CHILD_BRIDGES_FILE}"; then + read -r BP_PROJECT_ID DOWNSTREAM_PIPELINE_ID < <(find_bridge "${CHILD_BRIDGES_FILE}" "benchmarks-trigger" yes) + else + echo "Cannot read benchmarks child pipeline bridges — skipping download" + fi +else + # Fallback: pipelines where the benchmarks-trigger bridge still lives in + # this pipeline directly (no run-benchmarks child-pipeline indirection). + read -r BP_PROJECT_ID DOWNSTREAM_PIPELINE_ID < <(find_bridge "${BRIDGES_FILE}" "benchmarks-trigger" yes) +fi if [ -z "${DOWNSTREAM_PIPELINE_ID:-}" ]; then echo "benchmarks-trigger bridge not found or did not run — skipping download" diff --git a/.gitlab/scripts/tests/test_check_stack_top.sh b/.gitlab/scripts/tests/test_check_stack_top.sh new file mode 100755 index 0000000000..5cf59d066c --- /dev/null +++ b/.gitlab/scripts/tests/test_check_stack_top.sh @@ -0,0 +1,167 @@ +#! /bin/bash +# Minimal, dependency-free unit tests for .gitlab/benchmarks/check-stack-top.sh. +# Run with: bash .gitlab/scripts/tests/test_check_stack_top.sh +# +# The decision script is exercised through a fake `curl` (and, for the +# jq-absent fallback, a PATH without `jq`), so no network access happens. + +set -uo pipefail + +HERE=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +SCRIPT_UNDER_TEST="${HERE}/../../benchmarks/check-stack-top.sh" + +FAILED=0 +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +# resolved up front: the test runs the script with a PATH restricted to the +# fake-tool bin dir, which does not contain bash +BASH_BIN=$(command -v bash) + +fail() { echo "FAIL: $*"; FAILED=1; } +pass() { echo "PASS: $*"; } + +# Build an isolated bin dir: fake curl, optional real jq, and symlinks for +# every external tool the script needs — PATH is restricted to this dir so +# the host's jq cannot leak into the jq-absent case. +setup_bin() { + local with_jq="$1" + local bin="$WORK/bin" + rm -rf "$bin" + mkdir -p "$bin" + local tool + for tool in sed tr grep mktemp chmod cat rm cp stat; do + ln -s "$(command -v "$tool")" "$bin/$tool" + done + cat > "$bin/curl" << 'FAKE' +#! /bin/bash +# fake curl: records argv, snapshots any -H @file header (the script under +# test deletes that file on exit, so the snapshot must be taken during the +# request), writes $FAKE_BODY to the -o target, prints the HTTP code +printf '%s\n' "$@" >> "$FAKE_CURL_ARGS" +out="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -w) shift 2 ;; + -H) if [ -f "${2#@}" ]; then + cp "${2#@}" "$FAKE_HDR_SNAPSHOT" 2>/dev/null + stat -f '%Lp' "${2#@}" > "$FAKE_HDR_MODE" 2>/dev/null || \ + stat -c '%a' "${2#@}" > "$FAKE_HDR_MODE" 2>/dev/null + fi + shift 2 ;; + *) shift ;; + esac +done +if [ -n "$out" ]; then + printf '%s' "${FAKE_BODY:-[]}" > "$out" +fi +printf '%s' "${FAKE_HTTP_CODE:-200}" +exit "${FAKE_CURL_EXIT:-0}" +FAKE + chmod +x "$bin/curl" + if [ "$with_jq" = "yes" ]; then + ln -s "$(command -v jq)" "$bin/jq" + fi +} + +# run_check [KEY=VALUE env assignments...] +# Sets RESULT (decision line), URL_LINE (API URL the fake curl saw), +# HDR_CONTENT and HDR_MODE (snapshot of the Authorization header file). +run_check() { + local branch="$1"; shift + setup_bin "${WITH_JQ:-yes}" + : > "$WORK/curl_args" + rm -f "$WORK/hdr_snapshot" "$WORK/hdr_mode" + RESULT=$(env "$@" PATH="$WORK/bin" CI_COMMIT_BRANCH="$branch" \ + FAKE_CURL_ARGS="$WORK/curl_args" FAKE_HDR_SNAPSHOT="$WORK/hdr_snapshot" \ + FAKE_HDR_MODE="$WORK/hdr_mode" \ + "$BASH_BIN" "$SCRIPT_UNDER_TEST" 2>"$WORK/stderr") + URL_LINE=$(grep -F 'base=' "$WORK/curl_args" 2>/dev/null | head -1 || true) + HDR_CONTENT=$(cat "$WORK/hdr_snapshot" 2>/dev/null || true) + HDR_MODE=$(cat "$WORK/hdr_mode" 2>/dev/null || true) +} + +expect_decision() { + local desc="$1" expected="$2" + if [ "$RESULT" = "RUN_BENCHMARKS=$expected" ]; then + pass "$desc" + else + fail "$desc — expected RUN_BENCHMARKS=$expected, got '${RESULT}'" + fi +} + +# main/master and empty branches never query the API +run_check "main" +expect_decision "main branch runs benchmarks" true +if [ -n "$URL_LINE" ]; then fail "main branch must not query the API"; else pass "main branch skips the API"; fi + +run_check "" +expect_decision "empty branch runs benchmarks" true + +# a stacked branch (open PR uses it as base) is skipped +FAKE_BODY='[{"number":1}]' run_check "jb/feature" +expect_decision "branch with a stacked PR is skipped" false + +# top of the stack / independent branch runs +FAKE_BODY='[]' run_check "jb/feature" +expect_decision "branch without a stacked PR runs benchmarks" true + +# API error bodies are JSON objects, not arrays — fail open +FAKE_BODY='{"message":"API rate limit exceeded"}' run_check "jb/feature" +expect_decision "non-array rate-limit response fails open" true + +# transport failures fail open +FAKE_CURL_EXIT=7 run_check "jb/feature" +expect_decision "curl transport failure fails open" true + +# non-200 HTTP status (e.g. 403 rate limit) fails open +FAKE_HTTP_CODE=403 FAKE_BODY='{"message":"API rate limit exceeded"}' run_check "jb/feature" +expect_decision "HTTP 403 fails open" true + +# branch names with reserved characters must be percent-encoded +FAKE_BODY='[]' run_check "fix-50%off&a b" +case "$URL_LINE" in + *base=fix-50%25off%26a%20b*) pass "reserved characters are percent-encoded" ;; + *) fail "reserved characters are percent-encoded — got URL: $URL_LINE" ;; +esac + +# the sed fallback path (jq absent) must encode % first +WITH_JQ=no FAKE_BODY='[]' run_check "fix-50%off" +expect_decision "jq-absent fallback still decides" true +case "$URL_LINE" in + *base=fix-50%25off*) pass "sed fallback encodes % before other characters" ;; + *) fail "sed fallback encodes % first — got URL: $URL_LINE" ;; +esac + +# a token with trailing newline is normalized, not injected raw into headers +FAKE_BODY='[]' run_check "jb/feature" "GITHUB_TOKEN=$(printf 'tok123\n')" +expect_decision "normalized token still authenticates" true +if [ -n "$HDR_CONTENT" ]; then + if [ "$HDR_CONTENT" = "Authorization: token tok123" ]; then + pass "token passed via header file with single-line value" + else + fail "token header file content — got '$HDR_CONTENT'" + fi + if [ "$HDR_MODE" = "600" ]; then + pass "header file is mode 0600" + else + fail "header file must be mode 0600 — got '$HDR_MODE'" + fi +else + fail "token was not passed via a header file" +fi + +# a multi-line token value is rejected instead of becoming injected headers +FAKE_BODY='[]' run_check "jb/feature" "GITHUB_TOKEN=$(printf 'tok\nX-Injected: yes')" +if [ -z "$HDR_CONTENT" ]; then + pass "malformed token is dropped (anonymous request)" +else + fail "malformed token must be dropped — header content: $HDR_CONTENT" +fi + +if [ "$FAILED" -eq 0 ]; then + echo "test_check_stack_top: all tests passed" +else + echo "test_check_stack_top: FAILURES detected" +fi +exit "$FAILED"