diff --git a/ci.sh b/ci.sh index b79b5091ea7..d7b2544c136 100755 --- a/ci.sh +++ b/ci.sh @@ -124,11 +124,11 @@ export RUN_ID=${RUN_ID:-$(date +%s%3N)} function multi_job_run { if [[ -z "${CI_DASHBOARD:-}" ]]; then - if [[ "${REF_NAME:-}" == "main" ]]; then - export CI_DASHBOARD="main" - else - export CI_DASHBOARD="prs" - fi + # Section = a mainline branch's own name (the default branch, v5, ...), "tags" for + # any tag, else "prs" (see ci_dashboard_section in source_refname). log_ci_run + # prefixes this with the repo. The trigger sets CI_DASHBOARD from .ci3.yml's + # push_branches; this is the fallback for direct/local ci.sh runs. + export CI_DASHBOARD="$(ci_dashboard_section)" fi export AWS_SHUTDOWN_TIME=${AWS_SHUTDOWN_TIME:-75} export AWS_SHUTDOWN_TIME_ARM=${AWS_SHUTDOWN_TIME_ARM:-90} @@ -230,7 +230,9 @@ case "$cmd" in # Uses same hash as run_test_cmd's test_hash for consistency test_cmd="${full_cmd#* }" test_hash=$(hash_str_orig "$test_cmd") - export CI_DASHBOARD="deflake" + # Grind is a dev tool; its runs go to the repo's "local" section (the deflake + # section was retired along with the /grind web endpoint). + export CI_DASHBOARD="local" export JOB_ID="grind-test-$test_hash" export INSTANCE_POSTFIX=$JOB_ID export CPUS=${CPUS:-192} @@ -383,7 +385,7 @@ case "$cmd" in release) # Spin up ec2 instances (amd64 + arm64) and run the full release flow: backwards-compat e2e # checks, build, and publish. Set DRY_RUN=1 to exercise the whole flow without publishing. - export CI_DASHBOARD="releases" + export CI_DASHBOARD="tags" # Roomier instance lifetime than a standard run: the amd64 job builds, runs the backwards-compat # e2e suite, and then publishes, which together exceed the default 75 min shutdown. export AWS_SHUTDOWN_TIME=${AWS_SHUTDOWN_TIME:-180} @@ -483,7 +485,9 @@ case "$cmd" in echo "No redis available and CI_PASSWORD not set for http fallback." exit 1 fi - curl -sf "http://aztec:$CI_PASSWORD@ci.aztec-labs.com/$key.txt" | $pager + # https, not http: the dashboard now redirects 80->443, and curl won't resend + # inline basic-auth across an http->https redirect (would need --location-trusted). + curl -sf "https://aztec:$CI_PASSWORD@ci.aztec-labs.com/$key.txt" | $pager if [ ${PIPESTATUS[0]} -ne 0 ]; then echo "Failed to fetch log via http." exit 1 diff --git a/ci3/dashboard/Caddyfile b/ci3/dashboard/Caddyfile new file mode 100644 index 00000000000..44d06b0198c --- /dev/null +++ b/ci3/dashboard/Caddyfile @@ -0,0 +1,11 @@ +ci.aztec-labs.com { + encode zstd gzip + # Long timeouts: the dashboard streams/long-polls live logs. Port 80 stays open + # (ACME HTTP-01 + redirect); TLS terminates here, app is plaintext on loopback. + reverse_proxy 127.0.0.1:8080 { + transport http { + read_timeout 3600s + write_timeout 3600s + } + } +} diff --git a/ci3/dashboard/ci-metrics/metrics.py b/ci3/dashboard/ci-metrics/metrics.py index f9cb3d8bb52..804832bd6f9 100644 --- a/ci3/dashboard/ci-metrics/metrics.py +++ b/ci3/dashboard/ci-metrics/metrics.py @@ -431,7 +431,6 @@ def get_phases(date_from: str, date_to: str, dashboard: str = '', # ---- Sync failed_tests_{section} lists from Redis into SQLite ---- _ANSI_STRIP = re.compile(r'\x1b\[[^m]*m|\x1b\]8;;[^\x07]*\x07') -_GRIND_CMD_RE = re.compile(r'/grind\?cmd=([^&\x07"]+)') _LOG_KEY_RE = re.compile(r'ci\.aztec-labs\.com/([a-f0-9]{16})') _INLINE_CMD_RE = re.compile(r'(?:grind\)|[0-9a-f]{16}\)):?\s+(.+?)\s+\(\d+s\)') _DURATION_RE = re.compile(r'\((\d+)s\)') @@ -444,7 +443,6 @@ def get_phases(date_from: str, date_to: str, dashboard: str = '', def _parse_failed_test_entry(raw: str, section: str) -> dict | None: """Parse an ANSI-formatted failed_tests_{section} entry into structured data.""" - from urllib.parse import unquote clean = _ANSI_STRIP.sub('', raw) # Status @@ -478,23 +476,12 @@ def _parse_failed_test_entry(raw: str, section: str) -> dict | None: if m: log_key = m.group(1) - # Test command: try grind link first, then inline text + # Test command: extract from the inline text after the log key. + # (Historically a /grind?cmd= link was parsed first; that link was removed.) test_cmd = '' - m = _GRIND_CMD_RE.search(raw) + m = _INLINE_CMD_RE.search(clean) if m: - cmd_raw = unquote(m.group(1)) - # Format: "hash:KEY=VAL:KEY=VAL actual_command" - # Strip the hash:KEY=VAL prefix to get the actual test command - parts = cmd_raw.split(' ', 1) - if len(parts) == 2 and ':' in parts[0]: - test_cmd = parts[1].strip() - else: - test_cmd = cmd_raw - else: - # Fallback: extract from inline text after log key - m = _INLINE_CMD_RE.search(clean) - if m: - test_cmd = m.group(1).strip() + test_cmd = m.group(1).strip() # Duration duration = None diff --git a/ci3/dashboard/deploy.sh b/ci3/dashboard/deploy.sh index 27119bc9f84..3a90ed9423a 100755 --- a/ci3/dashboard/deploy.sh +++ b/ci3/dashboard/deploy.sh @@ -1,13 +1,52 @@ #!/bin/bash +# Deploy the CI dashboard (rkapp + Caddy TLS) to the ci host. +# +# Safe to run repeatedly. The first run also performs the one-time cutover from the +# legacy systemd `rkapp` unit (which bound :80 directly) to the compose stack (rkapp on +# loopback, Caddy terminating TLS on 443 and redirecting 80). Ordering is chosen so a +# failure leaves the current service up: the new image is built BEFORE the old unit is +# retired. +# +# Prerequisite: /etc/rkapp.env (mode 600) on the host — the app secrets. deploy.sh +# refuses to proceed without it rather than bring the app up unconfigured. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOST=${1:-ubuntu@ci.aztec-labs.com} +KEY=~/.ssh/build_instance_key -# Sync dashboard (rkapp) files, including ci-metrics subdirectory -rsync -avz --exclude='deploy.sh' -e "ssh -i ~/.ssh/build_instance_key" "$SCRIPT_DIR"/* ubuntu@ci.aztec-labs.com:rk +rsync -avz --exclude='deploy.sh' -e "ssh -i $KEY" "$SCRIPT_DIR"/ "$HOST":rk -ssh -i ~/.ssh/build_instance_key ubuntu@ci.aztec-labs.com " +ssh -i "$KEY" "$HOST" ' + set -euo pipefail + if [ ! -f /etc/rkapp.env ]; then + echo "ERROR: /etc/rkapp.env missing. Create it (mode 600) before deploying." >&2 + exit 1 + fi + mkdir -p /home/ubuntu/rk/caddy/data /home/ubuntu/rk/caddy/config cd rk - docker build -t rkapp . - sudo systemctl restart rkapp -" + + # Build the new image first — nothing running is disturbed if this fails. + docker compose build + + # Retire the legacy systemd rkapp so Caddy can bind 80/443. Idempotent: a no-op + # once it is already gone, so steady-state redeploys skip it. + if systemctl list-unit-files rkapp.service >/dev/null 2>&1; then + echo "Retiring legacy systemd rkapp unit..." + sudo systemctl disable --now rkapp 2>/dev/null || true + fi + + docker compose up -d + + # Liveness: the app answers on loopback (401 = up-and-auth-gated, which is fine; + # 000 = not listening). Caddy issues its cert on first boot, so https may lag a few + # seconds — check it in a browser. + sleep 3 + code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/ || echo 000) + if [ "$code" = "000" ]; then + echo "ERROR: app not responding on 127.0.0.1:8080" >&2 + docker compose logs --tail=30 rkapp >&2 + exit 1 + fi + echo "Dashboard app up (http $code on loopback). Caddy fronting 443; verify https in a browser." +' diff --git a/ci3/dashboard/docker-compose.yml b/ci3/dashboard/docker-compose.yml new file mode 100644 index 00000000000..5565a5049d5 --- /dev/null +++ b/ci3/dashboard/docker-compose.yml @@ -0,0 +1,41 @@ +# Runs the CI dashboard behind Caddy (TLS) in one stack, replacing the hand-written +# `docker run` systemd unit that lived only on the ci host. Secrets are NOT inline: +# they come from /etc/rkapp.env (mode 600, off-repo) — the same pattern as ci3-trigger. +# +# Both services use host networking, deliberately unchanged from the old unit: the app +# reaches ElastiCache and IMDS through the host's network identity, and IMDSv2's default +# hop limit rejects bridged containers. Caddy therefore reaches the app on loopback. +# +# deploy.sh does the whole rollout (build, retire the old rkapp unit, compose up). +# It requires /etc/rkapp.env (mode 600) to already exist on the host — the app secrets +# (REDIS_HOST, CI_REDIS, DASHBOARD_PASSWORD, the GCP creds path, AWS region). GH_TOKEN +# and REPO_PATH from the old unit are deliberately dropped: GH_TOKEN is dead and fed a +# now-misdirected GitHub poller; REPO_PATH only served the removed /grind route. +# 443 must be open on bastion_sg (iac change, already applied). +services: + rkapp: + build: . + image: rkapp + network_mode: host + restart: always + env_file: /etc/rkapp.env + volumes: + - /home/ubuntu/rk/ci-metrics-gcp-credentials.json:/app/ci-metrics-gcp-credentials.json:ro + - /logs-disk:/logs-disk + - /home/ubuntu/rk/data:/data + - /home/ubuntu/.aws:/root/.aws:ro + # Bind loopback only: the app is no longer publicly reachable; Caddy fronts it. + command: gunicorn -w 50 -b 127.0.0.1:8080 rk:app + logging: + driver: json-file + options: {max-size: "10m", max-file: "3"} + + caddy: + image: caddy:2-alpine + network_mode: host + restart: always + depends_on: [rkapp] + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - /home/ubuntu/rk/caddy/data:/data + - /home/ubuntu/rk/caddy/config:/config diff --git a/ci3/dashboard/rk.py b/ci3/dashboard/rk.py index 6907b70a6d0..abed51ec904 100644 --- a/ci3/dashboard/rk.py +++ b/ci3/dashboard/rk.py @@ -181,35 +181,58 @@ def get_github_actions_status(): _github_status_cache["ts"] = now return result +def list_dashboards() -> list[str]: + """Live dashboards from the self-registering `ci-run-sections` index. + + Each member is an "//
" string written by log_ci_run. A + section ZSET carries a sliding 90-day TTL, so one that has expired (no runs in + 90 days) is retired: we drop it from the index on read. O(sections), no scan. + """ + live = [] + for raw in r.smembers('ci-run-sections'): + name = raw.decode() if isinstance(raw, bytes) else raw + if r.exists('ci-run-' + name): + live.append(name) + else: + r.srem('ci-run-sections', raw) + return sorted(live) + + +def _split_dashboard(d: str) -> tuple[str, str]: + """'org/repo/section' -> ('org/repo', 'section'); legacy unscoped -> ('', d).""" + parts = d.split('/') + if len(parts) >= 3: + return '/'.join(parts[:-1]), parts[-1] + return '', d + + def root() -> str: - # Show the default (no section) view with updated links - return ( - update_status(0, '', '') + - f"\n" - f"Select a filter:\n" - f"\n{YELLOW}" - f"{hyperlink('/section/main', 'main queue')}\n" - f"{hyperlink('/section/prs', 'prs')}\n" - f"{hyperlink('/section/releases', 'releases')}\n" - f"{hyperlink('/section/nightly', 'nightly')}\n" - f"{hyperlink('/section/network', 'network')}\n" - f"{hyperlink('/section/deflake', 'deflake')}\n" - f"{RESET}" - f"\n" - f"Benchmarks:\n" - f"\n{YELLOW}" - f"{hyperlink('https://aztecprotocol.github.io/benchmark-page-data/bench?branch=main', 'main')}\n" - f"{hyperlink('https://aztecprotocol.github.io/benchmark-page-data/bench?branch=prs', 'prs')}\n" - f"{hyperlink('/chonk-breakdowns', 'chonk breakdowns')}\n" - f"{RESET}" - f"\n" + out = update_status(0, '', '') + "\nCI runs:\n\n" + + grouped: dict[str, list[tuple[str, str]]] = {} + for d in list_dashboards(): + repo, section = _split_dashboard(d) + grouped.setdefault(repo, []).append((section, d)) + + if not grouped: + out += f"{YELLOW}(no active dashboards yet){RESET}\n" + for repo in sorted(grouped): + if repo: + out += f"{BOLD}{repo}{RESET}\n" + for section, d in sorted(grouped[repo]): + out += f" {YELLOW}{hyperlink('/section/' + d, section)}{RESET}\n" + out += "\n" + + out += ( f"CI Metrics:\n" f"\n{YELLOW}" f"{hyperlink('/cost-overview', 'cost overview (AWS + GCP)')}\n" f"{hyperlink('/namespace-billing', 'namespace billing')}\n" f"{hyperlink('/ci-insights', 'ci insights')}\n" + f"{hyperlink('/chonk-breakdowns', 'chonk breakdowns')}\n" f"{RESET}" ) + return out def section_view(section: str) -> str: offset = int(request.args.get('offset', 0)) @@ -391,7 +414,9 @@ def show_root(): filter_prop='' ) -@app.route('/section/
') +# so org/repo/section dashboards (with slashes) route correctly — +# same reason as /list/ below (WSGI decodes %2F to / before routing). +@app.route('/section/') @optional_auth def show_section(section): return render_template_string( @@ -470,122 +495,6 @@ def get_breakdown(runtime, flow_name, sha): return Response('{"error": "Breakdown not found"}', mimetype='application/json', status=404) - -@app.route('/grind') -@optional_auth -def trigger_grind(): - """Trigger a grind job for a flaky test.""" - from urllib.parse import urlencode as url_encode - - full_cmd = request.args.get('cmd') - commit = request.args.get('commit', 'HEAD') - run_id = request.args.get('run') # Pre-generated run_id from selection page - start = request.args.get('start') # If set, start the grind - - # Configurable options with defaults - grind_time = request.args.get('time', '20m') - cpus = request.args.get('cpus', '192') - jobs_pct = request.args.get('jobs', '200') - memsuspend_pct = request.args.get('memsuspend', '50') - - if not full_cmd: - return "Missing cmd parameter", 400 - - # If run_id is provided and already has a log, redirect to it (back-button protection) - if run_id and r.exists(run_id): - return redirect(f'/{run_id}') - - # If start not requested, show configuration page - if not start: - # Generate one run_id for all links on this page load - page_run_id = uuid.uuid4().hex[:16] - - # Helper to build option links - def make_options(param_name, options, current_value, suffix=''): - links = [] - for opt in options: - is_selected = str(opt) == str(current_value) - if is_selected: - links.append(f"{BOLD}{BLUE}{opt}{suffix}{RESET}") - else: - params = { - 'cmd': full_cmd, 'commit': commit, 'run': page_run_id, - 'time': grind_time, 'cpus': cpus, 'jobs': jobs_pct, 'memsuspend': memsuspend_pct - } - params[param_name] = opt - url = f"/grind?{url_encode(params)}" - links.append(f"{YELLOW}{hyperlink(url, f'{opt}{suffix}')}{RESET}") - return ' | '.join(links) - - time_options = make_options('time', ['5m', '10m', '20m', '30m', '1h'], grind_time) - cpus_options = make_options('cpus', ['16', '32', '64', '128', '192'], cpus) - jobs_options = make_options('jobs', ['10', '25', '50', '75', '100', '200', '400'], jobs_pct, '%') - memsuspend_options = make_options('memsuspend', ['25', '50', '75'], memsuspend_pct, '%') - - # Start grind button - start_params = { - 'cmd': full_cmd, 'commit': commit, 'run': page_run_id, - 'time': grind_time, 'cpus': cpus, 'jobs': jobs_pct, 'memsuspend': memsuspend_pct, - 'start': '1' - } - start_url = f"/grind?{url_encode(start_params)}" - start_button = f"{BOLD}{GREEN}{hyperlink(start_url, '[ Start Grind ]')}{RESET}" - - page = ( - f"{BOLD}Grind Test{RESET}\n\n" - f"Command: {full_cmd}\n" - f"Commit: {commit}\n\n" - f"Duration: {time_options}\n" - f"CPUs: {cpus_options}\n" - f"Jobs: {jobs_options}\n" - f"Memsuspend: {memsuspend_options}\n\n" - f"{start_button}\n" - ) - return render_template_string(TEMPLATE, value=ansi_to_html(page), filter_str='grind', follow='top') - - # Start requested - run the grind - # Use run_id from URL, or generate new one if not provided - if not run_id: - run_id = uuid.uuid4().hex[:16] - - # Initialize the log key so redirect doesn't show "Key not found" - r.setex(run_id, 86400, b'Starting grind...\n') - - # Start grind job in background - # Dashboard server needs local repo checkout at REPO_PATH - repo_path = os.environ.get('REPO_PATH') - if repo_path: - # Refresh the launcher checkout to current origin/main before launching. - # REPO_PATH only supplies the orchestration scripts (ci.sh/bootstrap_ec2); - # the grind target commit is checked out on the remote box. The launcher - # must stay current so grind uses the same transport (SSM) as the rest of - # CI -- a drifted checkout silently falls back to the retired SSH path and - # every instance times out waiting for SSH. - refresh = subprocess.run( - ['git', '-C', repo_path, 'fetch', '--quiet', 'origin', 'main'], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True - ) - if refresh.returncode == 0: - refresh = subprocess.run( - ['git', '-C', repo_path, 'checkout', '--quiet', '--force', 'origin/main'], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True - ) - if refresh.returncode != 0: - r.setex(run_id, 86400, - f'Failed to refresh launcher checkout at {repo_path}:\n{refresh.stdout}\n'.encode()) - return redirect(f'/{run_id}') - - subprocess.Popen( - ['bash', '-c', f'cd {repo_path} && RUN_ID={run_id} CPUS={cpus} ./ci.sh grind-test {shlex.quote(full_cmd)} {grind_time} {jobs_pct} {memsuspend_pct} {commit}'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True - ) - - # Redirect to log view. - return redirect(f'/{run_id}') - - # ---- Reverse proxy to ci-metrics server ---- _proxy_session = requests.Session() diff --git a/ci3/log_ci_run b/ci3/log_ci_run index fbeaaac6c80..6d158369f5c 100755 --- a/ci3/log_ci_run +++ b/ci3/log_ci_run @@ -9,19 +9,27 @@ if [ -z "${RUN_ID:-}" ]; then exit fi -if [ -z "${CI_DASHBOARD:-}" ]; then - # This path is deprecated. Set CI_DASHBOARD explicitly in ci.sh. - # CI runs are grouped by: - # - The 'release' group if tagged with a semver. - # - Or in the 'prs' group. - if semver check "$REF_NAME"; then - range_key="ci-run-releases" - else - range_key="ci-run-prs" - fi +# Sliding TTL on the section ZSET: refreshed on every write below, so a section +# lives as long as it sees runs and is dropped 90 days after its last one. This is +# what lets the landing page prune retired org/repo/section combinations by a simple +# EXISTS check rather than scanning timestamps. +SECTION_TTL=$((60 * 60 * 24 * 90)) + +# The section is the CI_DASHBOARD dimension (prs, a mainline branch name, tags, ...). +# ci.sh / the trigger runner normally set CI_DASHBOARD explicitly; ci_dashboard_section +# (source_refname) is the shared fallback for direct callers that don't. +section="${CI_DASHBOARD:-$(ci_dashboard_section)}" + +# Scope the section by repository so multiple orgs/repos sharing this dashboard don't +# collide (and one repo's runs can't evict another's under the 1000-entry cap). The +# dashboard string — "//
" — is the landing page's unit of listing. +repo="${GITHUB_REPOSITORY:-}" +if [ -n "$repo" ]; then + dashboard="$repo/$section" else - range_key="ci-run-$CI_DASHBOARD" + dashboard="$section" # pre-multi-repo callers with no GITHUB_REPOSITORY fi +range_key="ci-run-$dashboard" if [ "$status" = "RUNNING" ]; then msg=$(git log -1 --pretty=format:"%s") @@ -38,7 +46,6 @@ if [ "$status" = "RUNNING" ]; then fi msg=$(pr_link "$msg") - dashboard="${range_key#ci-run-}" json=$(jq -c -j -n \ --argjson timestamp "$key" \ @@ -54,8 +61,9 @@ if [ "$status" = "RUNNING" ]; then --argjson instance_vcpus "$instance_vcpus" \ --arg pr_number "$pr_number" \ --arg dashboard "$dashboard" \ + --arg repo "$repo" \ --arg github_actor "${GITHUB_ACTOR:-}" \ - '{timestamp: $timestamp, run_id: $run_id, job_id: $job_id, status: $status, msg: $msg, name: $name, author: $author, github_actor: $github_actor, arch: $arch, spot: $spot, instance_type: $instance_type, instance_vcpus: $instance_vcpus, pr_number: $pr_number, dashboard: $dashboard}') + '{timestamp: $timestamp, run_id: $run_id, job_id: $job_id, status: $status, msg: $msg, name: $name, author: $author, github_actor: $github_actor, arch: $arch, spot: $spot, instance_type: $instance_type, instance_vcpus: $instance_vcpus, pr_number: $pr_number, dashboard: $dashboard, repo: $repo}') # echo "$json" >&2 redis_cli ZADD $range_key $key "$json" &>/dev/null redis_cli SETEX hb-$key 60 1 &>/dev/null @@ -77,3 +85,8 @@ else # Add the updated entry with the same key. redis_cli ZADD $range_key $key "$json" &>/dev/null fi + +# Refresh the section's sliding TTL and register it in the discovery index. Done on +# both create and update so the window tracks last activity, not first. +redis_cli EXPIRE $range_key $SECTION_TTL &>/dev/null +redis_cli SADD ci-run-sections "$dashboard" &>/dev/null diff --git a/ci3/run_test_cmd b/ci3/run_test_cmd index 2a9c9d9ac5f..8ec689357cf 100755 --- a/ci3/run_test_cmd +++ b/ci3/run_test_cmd @@ -161,8 +161,6 @@ function live_publish_log { # Create a new log key and ci link. log_key=$(uuid) log_info=" ($(ci_term_link $log_key))" -grind_link=$(term_link "/grind?cmd=$(urlencode "$cmd")" "grind") -fail_links="($grind_link)" # Create a temporary file for the test log. tmp_file=/tmp/$key @@ -273,7 +271,7 @@ function pass { [ "$publish" -eq 1 ] && publish_redis "passed" if [ "$track_test_history" -eq 1 ]; then - local track_line="${green}PASSED${reset}${log_info:-} ${fail_links}: $test_cmd (${SECONDS}s) (${purple}$COMMIT_AUTHOR${reset}: $COMMIT_MSG)" + local track_line="${green}PASSED${reset}${log_info:-}: $test_cmd (${SECONDS}s) (${purple}$COMMIT_AUTHOR${reset}: $COMMIT_MSG)" track_test_history "$track_line" fi @@ -322,7 +320,7 @@ function fail { echo -e "$line" fi - local track_line="${red}FAILED${reset}${log_info:-} ${fail_links}: $test_cmd (${SECONDS}s) (code: $code) (${purple}$COMMIT_AUTHOR${reset}: $COMMIT_MSG)" + local track_line="${red}FAILED${reset}${log_info:-}: $test_cmd (${SECONDS}s) (code: $code) (${purple}$COMMIT_AUTHOR${reset}: $COMMIT_MSG)" [ "$track_test_history" -eq 1 ] && track_test_history "$track_line" [ "$track_test_fail" -eq 1 ] && track_test_failed "$track_line" [ "$publish" -eq 1 ] && publish_redis "failed" @@ -344,7 +342,7 @@ function flake { local line="${purple}FLAKED${reset}${log_info:-}: $test_cmd (${SECONDS}s) (code: $code)${group_suffix}" echo -e "$line" - local track_line="${purple}FLAKED${reset}${log_info:-} ${fail_links}: $test_cmd (${SECONDS}s) (code: $code)${group_suffix} (${purple}$COMMIT_AUTHOR${reset}: $COMMIT_MSG)" + local track_line="${purple}FLAKED${reset}${log_info:-}: $test_cmd (${SECONDS}s) (code: $code)${group_suffix} (${purple}$COMMIT_AUTHOR${reset}: $COMMIT_MSG)" [ "$track_test_history" -eq 1 ] && track_test_history "$track_line" [ "$track_test_fail" -eq 1 ] && track_test_failed "$track_line" [ "$publish" -eq 1 ] && publish_redis "flaked" diff --git a/ci3/source_refname b/ci3/source_refname index 797f94fef85..51b3c3a0205 100644 --- a/ci3/source_refname +++ b/ci3/source_refname @@ -30,9 +30,47 @@ if [ -z "${REF_NAME:-}" ]; then export REF_NAME fi +# Resolve the repository's trunk (default branch), cached in DEFAULT_BRANCH. +# Order: an explicit env override (cheapest — CI can pass the known value), then the +# local origin/HEAD, then ask the remote. The remote query is what makes this work on +# the shallow `git fetch --depth 1` clones in the build container, where origin/HEAD is +# never set; it is also host-agnostic (GitHub, Forgejo). "main" is the last-resort +# fallback for a repo with no reachable remote (e.g. some local runs). +function ci_default_branch { + if [ -z "${DEFAULT_BRANCH:-}" ]; then + local d + d=$(git symbolic-ref --short -q refs/remotes/origin/HEAD 2>/dev/null || true) + d="${d#origin/}" + if [ -z "$d" ]; then + d=$(git ls-remote --symref origin HEAD 2>/dev/null \ + | awk '/^ref:/ { sub("refs/heads/", "", $2); print $2; exit }') + fi + export DEFAULT_BRANCH="${d:-main}" + fi + echo "$DEFAULT_BRANCH" +} + +# Derive the CI dashboard section from a ref (defaults to REF_NAME): +# - "tags" for any semver tag (a release is a semver-tag push) +# - the ref's own name if it is the default branch or a configured mainline branch +# (CI_MAINLINE_BRANCHES, an extended-regex; defaults to release lines like v5) +# - "prs" for everything else (feature branches, PR heads) +# The single source of truth for the section, shared by ci.sh and log_ci_run. +function ci_dashboard_section { + local ref="${1:-$REF_NAME}" + if semver check "$ref"; then + echo "tags" + elif [ "$ref" == "$(ci_default_branch)" ] || [[ "$ref" =~ ${CI_MAINLINE_BRANCHES:-^v[0-9]+$} ]]; then + echo "$ref" + else + echo "prs" + fi +} +export -f ci_default_branch ci_dashboard_section + if [ -z "${CI_FULL:-}" ]; then export CI_FULL=0 - if [ "$CI" -eq 1 ] && { semver check "$REF_NAME" || [ "$REF_NAME" == "master" ]; }; then + if [ "$CI" -eq 1 ] && { semver check "$REF_NAME" || [ "$REF_NAME" == "$(ci_default_branch)" ]; }; then export CI_FULL=1 fi fi