From a495b86a06fcdede2d9893c1019d39abf4bdb1a3 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 29 Apr 2026 22:39:56 -0700 Subject: [PATCH 1/3] test(e2e): poll-mode + since_id cursor round-trip (#2339 PR 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end coverage for the canvas-chat unblocker. Exercises every moving part of the #2339 stack against a real platform instance: Phase 1 — register a workspace as delivery_mode=poll WITHOUT a URL; verify the response carries delivery_mode=poll. Phase 2 — invalid delivery_mode rejected with 400 (typo defense). Phase 3 — POST A2A to the poll-mode workspace; verify proxyA2ARequest short-circuits and returns 200 {status:queued, delivery_mode:poll, method:message/send} without ever resolving an agent URL. Phase 4 — verify the queued message appears in /activity?type=a2a_receive with the right method + payload (the polling agent reads from here). Phase 5 — since_id cursor returns ASC-ordered rows STRICTLY AFTER the cursor; the cursor row itself must NOT be replayed. Sends two follow-up messages and asserts ordering: rows[0] is the older new event, rows[-1] is the newer. Phase 6 — unknown / pruned cursor returns 410 Gone with an explanation. Phase 7 — cross-workspace cursor isolation: a UUID belonging to one workspace cannot be used to peek at another workspace's feed (returns 410, same as pruned, no info leak). Idempotent: per-run unique workspace ids (date+pid). Trap-based cleanup deletes the test rows on exit; no e2e_cleanup_all_workspaces call (see feedback_never_run_cluster_cleanup_tests_on_live_platform.md). Wired into .github/workflows/e2e-api.yml so it runs on every PR that touches workspace-server/, tests/e2e/, or the workflow file itself — same gate as the existing test_a2a_e2e + test_notify_attachments suites. Stacked on #2354 (PR 3: since_id cursor). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/e2e-api.yml | 3 + tests/e2e/test_poll_mode_e2e.sh | 297 ++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100755 tests/e2e/test_poll_mode_e2e.sh diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml index 2023c0ef..9acc570f 100644 --- a/.github/workflows/e2e-api.yml +++ b/.github/workflows/e2e-api.yml @@ -169,6 +169,9 @@ jobs: - name: Run priority-runtimes E2E (claude-code + hermes — skips when keys absent) if: needs.detect-changes.outputs.api == 'true' run: bash tests/e2e/test_priority_runtimes_e2e.sh + - name: Run poll-mode + since_id cursor E2E (#2339) + if: needs.detect-changes.outputs.api == 'true' + run: bash tests/e2e/test_poll_mode_e2e.sh - name: Dump platform log on failure if: failure() && needs.detect-changes.outputs.api == 'true' run: cat workspace-server/platform.log || true diff --git a/tests/e2e/test_poll_mode_e2e.sh b/tests/e2e/test_poll_mode_e2e.sh new file mode 100755 index 00000000..844849c8 --- /dev/null +++ b/tests/e2e/test_poll_mode_e2e.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# E2E for delivery_mode=poll + since_id cursor (#2339). +# +# Round-trip: register a workspace as poll-mode (no URL) → POST A2A to it → +# verify the proxy short-circuits to {status:"queued"} → verify the message +# appears in /activity → verify the since_id cursor returns ONLY new events +# in ASC order → verify a stale cursor returns 410. +# +# Requires: platform running on localhost:8080 with migrations applied. +# bash workspace-server/scripts/dev-start.sh +# bash workspace-server/scripts/run-migrations.sh +# +# Idempotent: each run uses fresh per-script workspace ids so reruns don't +# collide. Does NOT call e2e_cleanup_all_workspaces — see +# `feedback_never_run_cluster_cleanup_tests_on_live_platform.md`. + +set -euo pipefail + +source "$(dirname "$0")/_lib.sh" + +PASS=0 +FAIL=0 +TIMEOUT="${A2A_TIMEOUT:-30}" + +# Per-run unique ids — same shape as test_2307_peer_visibility_staging.sh. +# Date+pid keeps reruns isolated; trap cleans up the row at the end so +# /workspaces lists don't accumulate test garbage on a long-lived dev DB. +RUN_TAG="poll-e2e-$(date +%s)-$$" +POLL_WS_ID="ws-${RUN_TAG}" +CALLER_WS_ID="ws-caller-${RUN_TAG}" + +cleanup() { + local rc=$? + # Best-effort delete; non-fatal if the row was never created. + curl -s -X DELETE "$BASE/workspaces/$POLL_WS_ID" >/dev/null || true + curl -s -X DELETE "$BASE/workspaces/$CALLER_WS_ID" >/dev/null || true + exit $rc +} +trap cleanup EXIT + +check() { + local desc="$1" + local expected="$2" + local actual="$3" + if echo "$actual" | grep -qF -- "$expected"; then + echo "PASS: $desc" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc" + echo " expected to contain: $expected" + echo " got: $(echo "$actual" | head -10)" + FAIL=$((FAIL + 1)) + fi +} + +check_eq() { + local desc="$1" + local expected="$2" + local actual="$3" + if [ "$actual" = "$expected" ]; then + echo "PASS: $desc" + PASS=$((PASS + 1)) + else + echo "FAIL: $desc" + echo " expected: $expected" + echo " got: $actual" + FAIL=$((FAIL + 1)) + fi +} + +check_not_contains() { + local desc="$1" + local unexpected="$2" + local actual="$3" + if echo "$actual" | grep -qF -- "$unexpected"; then + echo "FAIL: $desc" + echo " should NOT contain: $unexpected" + FAIL=$((FAIL + 1)) + else + echo "PASS: $desc" + PASS=$((PASS + 1)) + fi +} + +echo "=== Poll-Mode + since_id Cursor E2E (#2339) ===" +echo " base: $BASE" +echo " poll workspace: $POLL_WS_ID" +echo " caller workspace: $CALLER_WS_ID" +echo "" + +# ---------- Phase 1: register as poll-mode ---------- +echo "--- Phase 1: Register poll-mode workspace (no URL) ---" + +# A poll-mode workspace registers WITHOUT a URL — that's the contract from +# PR 1 (#2348). The agent_card is required; everything else is optional. +REG_RESP=$(curl -s -X POST "$BASE/registry/register" \ + -H "Content-Type: application/json" \ + -d "{ + \"id\": \"$POLL_WS_ID\", + \"delivery_mode\": \"poll\", + \"agent_card\": {\"name\": \"poll-mode-test\"} + }") + +check "register accepts poll mode without URL" '"status":"registered"' "$REG_RESP" +check "register response echoes delivery_mode=poll" '"delivery_mode":"poll"' "$REG_RESP" + +# Capture the auth token for subsequent /activity reads (Phase 30.1). +POLL_TOKEN=$(echo "$REG_RESP" | e2e_extract_token || true) +if [ -z "$POLL_TOKEN" ]; then + echo "WARN: no auth_token in register response — token-required reads will fail" +fi + +# ---------- Phase 2: invalid mode rejected ---------- +echo "" +echo "--- Phase 2: Invalid delivery_mode rejected ---" + +INVALID_RESP=$(curl -s -w '\n%{http_code}' -X POST "$BASE/registry/register" \ + -H "Content-Type: application/json" \ + -d "{ + \"id\": \"${POLL_WS_ID}-bad\", + \"delivery_mode\": \"webhook\", + \"agent_card\": {\"name\": \"bad\"} + }") +INVALID_CODE=$(printf '%s' "$INVALID_RESP" | tail -n1) +INVALID_BODY=$(printf '%s' "$INVALID_RESP" | sed '$d') + +check_eq "register rejects unknown delivery_mode (HTTP 400)" "400" "$INVALID_CODE" +check "error mentions delivery_mode" "delivery_mode" "$INVALID_BODY" + +# ---------- Phase 3: A2A short-circuits to {status:"queued"} ---------- +echo "" +echo "--- Phase 3: A2A to poll-mode workspace short-circuits ---" + +A2A_RESP=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$POLL_WS_ID/a2a" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "msg-1", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"type": "text", "text": "hello-from-e2e-1"}] + } + } + }') + +check "poll-mode A2A returns queued status" '"status":"queued"' "$A2A_RESP" +check "queued response echoes delivery_mode=poll" '"delivery_mode":"poll"' "$A2A_RESP" +check "queued response echoes the JSON-RPC method" '"method":"message/send"' "$A2A_RESP" + +# ---------- Phase 4: queued message appears in /activity ---------- +echo "" +echo "--- Phase 4: Queued message visible via /activity ---" + +# The activity_logs INSERT runs in a goroutine — give it a moment. +sleep 1 + +# Use bearer token if we got one; some platforms require it on /activity. +ACTIVITY_AUTH=() +[ -n "${POLL_TOKEN:-}" ] && ACTIVITY_AUTH=(-H "Authorization: Bearer $POLL_TOKEN") + +ACT_RESP=$(curl -s --max-time "$TIMEOUT" "${ACTIVITY_AUTH[@]}" \ + "$BASE/workspaces/$POLL_WS_ID/activity?type=a2a_receive&limit=10") + +check "activity feed has the queued message text" "hello-from-e2e-1" "$ACT_RESP" +check "activity_type is a2a_receive" '"activity_type":"a2a_receive"' "$ACT_RESP" +check "method preserved on the activity row" '"method":"message/send"' "$ACT_RESP" + +# Pull the most-recent activity_id for use as a cursor. +FIRST_ACTIVITY_ID=$(echo "$ACT_RESP" | python3 -c " +import json, sys +rows = json.load(sys.stdin) +if not rows: + print('') +else: + # Default ordering is DESC (newest-first) when no since_id is set. + print(rows[0]['id']) +") + +if [ -z "$FIRST_ACTIVITY_ID" ]; then + echo "FAIL: could not extract activity_id from /activity response" + FAIL=$((FAIL + 1)) + exit 1 +fi +echo " cursor candidate: $FIRST_ACTIVITY_ID" + +# ---------- Phase 5: since_id returns only events strictly after ---------- +echo "" +echo "--- Phase 5: since_id cursor returns ASC, strictly-after ---" + +# Send a SECOND A2A message; it must appear in the cursor-filtered feed, +# the FIRST message must NOT (cursor is strictly-after). +A2A_RESP2=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$POLL_WS_ID/a2a" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "msg-2", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"type": "text", "text": "hello-from-e2e-2"}] + } + } + }') +check "second A2A also queues" '"status":"queued"' "$A2A_RESP2" + +sleep 1 + +CURSOR_RESP=$(curl -s --max-time "$TIMEOUT" "${ACTIVITY_AUTH[@]}" \ + "$BASE/workspaces/$POLL_WS_ID/activity?type=a2a_receive&since_id=$FIRST_ACTIVITY_ID&limit=10") + +check "since_id feed includes the new message" "hello-from-e2e-2" "$CURSOR_RESP" +check_not_contains "since_id feed excludes the cursor row itself" "hello-from-e2e-1" "$CURSOR_RESP" + +# Verify ASC ordering: in a fresh cursor window with two new events the +# array's first element must be the OLDER one (the test only sends one +# event after the cursor, so this case is trivially "exactly one row"; +# the next sub-phase strengthens this with a second event). +A2A_RESP3=$(curl -s --max-time "$TIMEOUT" -X POST "$BASE/workspaces/$POLL_WS_ID/a2a" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "msg-3", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"type": "text", "text": "hello-from-e2e-3"}] + } + } + }') +check "third A2A queues" '"status":"queued"' "$A2A_RESP3" + +sleep 1 + +ASC_RESP=$(curl -s --max-time "$TIMEOUT" "${ACTIVITY_AUTH[@]}" \ + "$BASE/workspaces/$POLL_WS_ID/activity?type=a2a_receive&since_id=$FIRST_ACTIVITY_ID&limit=10") + +# rows[0] should be msg-2 (older), rows[-1] should be msg-3 (newer) — that's +# ASC. If the server still defaulted to DESC, rows[0] would be msg-3. +ASC_FIRST=$(echo "$ASC_RESP" | python3 -c " +import json, sys +rows = json.load(sys.stdin) +def text_of(r): + body = r.get('request_body') or {} + parts = (body.get('params') or {}).get('message', {}).get('parts') or [] + return ''.join(p.get('text','') for p in parts if p.get('type')=='text') +if len(rows) < 2: + print('NEED2_GOT_'+str(len(rows))) +else: + print(text_of(rows[0]) + '|' + text_of(rows[-1])) +") +check_eq "since_id feed orders ASC (oldest-new first, newest-new last)" \ + "hello-from-e2e-2|hello-from-e2e-3" "$ASC_FIRST" + +# ---------- Phase 6: stale cursor returns 410 ---------- +echo "" +echo "--- Phase 6: Stale / unknown cursor returns 410 ---" + +GONE_RESP=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" "${ACTIVITY_AUTH[@]}" \ + "$BASE/workspaces/$POLL_WS_ID/activity?since_id=00000000-0000-0000-0000-000000000000") +GONE_CODE=$(printf '%s' "$GONE_RESP" | tail -n1) +GONE_BODY=$(printf '%s' "$GONE_RESP" | sed '$d') + +check_eq "unknown since_id returns HTTP 410 Gone" "410" "$GONE_CODE" +check "410 body explains how to recover" "since_id" "$GONE_BODY" + +# ---------- Phase 7: cross-workspace cursor isolation ---------- +echo "" +echo "--- Phase 7: Cross-workspace cursor isolation ---" + +# Register a SECOND poll-mode workspace and try to read its activity +# feed using a cursor from the FIRST workspace. Must 410 — the cursor +# is workspace-scoped to prevent UUID-guessing peeks. +REG2=$(curl -s -X POST "$BASE/registry/register" \ + -H "Content-Type: application/json" \ + -d "{ + \"id\": \"$CALLER_WS_ID\", + \"delivery_mode\": \"poll\", + \"agent_card\": {\"name\": \"poll-cross-test\"} + }") +check "second poll-mode workspace registers" '"status":"registered"' "$REG2" +CALLER_TOKEN=$(echo "$REG2" | e2e_extract_token || true) +CROSS_AUTH=() +[ -n "${CALLER_TOKEN:-}" ] && CROSS_AUTH=(-H "Authorization: Bearer $CALLER_TOKEN") + +CROSS_RESP=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" "${CROSS_AUTH[@]}" \ + "$BASE/workspaces/$CALLER_WS_ID/activity?since_id=$FIRST_ACTIVITY_ID") +CROSS_CODE=$(printf '%s' "$CROSS_RESP" | tail -n1) +check_eq "cross-workspace cursor blocked with 410 (no info leak)" "410" "$CROSS_CODE" + +# ---------- Results ---------- +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] From 08252b3cd760a426115723a5bc47c5d784a7dfff Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 29 Apr 2026 23:10:36 -0700 Subject: [PATCH 2/3] fix(e2e): use real UUIDs for poll-mode test workspace ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI run on PR #2355 surfaced `pq: invalid input syntax for type uuid: ws-poll-e2e-1777529293-3363` — workspaces.id is UUID-typed and the hand-rolled "ws-" shape fails the cast. Phase 1 returned generic 'registration failed' which cascaded into Phase 3 'lookup failed' (resolveAgentURL on a non-existent row) and Phase 4 'missing workspace auth token' (no token extracted because Phase 1 didn't run the bootstrap path). Generate v4 UUIDs via uuidgen (with a python3 fallback), one each for the poll workspace, the caller workspace, and the Phase 2 invalid-mode probe. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/e2e/test_poll_mode_e2e.sh | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/e2e/test_poll_mode_e2e.sh b/tests/e2e/test_poll_mode_e2e.sh index 844849c8..e4dd22bc 100755 --- a/tests/e2e/test_poll_mode_e2e.sh +++ b/tests/e2e/test_poll_mode_e2e.sh @@ -22,12 +22,23 @@ PASS=0 FAIL=0 TIMEOUT="${A2A_TIMEOUT:-30}" -# Per-run unique ids — same shape as test_2307_peer_visibility_staging.sh. -# Date+pid keeps reruns isolated; trap cleans up the row at the end so -# /workspaces lists don't accumulate test garbage on a long-lived dev DB. -RUN_TAG="poll-e2e-$(date +%s)-$$" -POLL_WS_ID="ws-${RUN_TAG}" -CALLER_WS_ID="ws-caller-${RUN_TAG}" +# Per-run unique ids — workspaces.id is a UUID column, so we generate +# real v4 UUIDs. A "ws-" string fails the pq UUID cast and surfaces +# as opaque "registration failed" (caught against this very test in CI +# before merge — the failure mode that motivates the helper). +gen_uuid() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen | tr '[:upper:]' '[:lower:]' + else + python3 -c 'import uuid; print(uuid.uuid4())' + fi +} +POLL_WS_ID="$(gen_uuid)" +CALLER_WS_ID="$(gen_uuid)" +# Phase 2 uses a separate UUID for its invalid-mode probe so a rerun +# can't poison POLL_WS_ID's row with a bad upsert (the 400 path doesn't +# touch DB, but defense in depth). +INVALID_PROBE_ID="$(gen_uuid)" cleanup() { local rc=$? @@ -117,7 +128,7 @@ echo "--- Phase 2: Invalid delivery_mode rejected ---" INVALID_RESP=$(curl -s -w '\n%{http_code}' -X POST "$BASE/registry/register" \ -H "Content-Type: application/json" \ -d "{ - \"id\": \"${POLL_WS_ID}-bad\", + \"id\": \"$INVALID_PROBE_ID\", \"delivery_mode\": \"webhook\", \"agent_card\": {\"name\": \"bad\"} }") From 9a7f61661b40097f2a1a65e3aa8c455c6120795f Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 29 Apr 2026 23:31:13 -0700 Subject: [PATCH 3/3] fix(ci): dispatch publish chain after auto-promote merge (#2357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-promote staging → main flow uses `gh pr merge --auto` with GITHUB_TOKEN, which means GitHub suppresses downstream `push` events on the resulting main commit. This is documented behavior — events created by GITHUB_TOKEN do not trigger new workflow runs, with workflow_dispatch and repository_dispatch as the only exceptions. Effect: when the merge queue lands the auto-promote PR, the main push DOES NOT fire publish-workspace-server-image. canary-verify + the :staging- → :latest retag never run, so redeploy-tenants-on-main also never fires. Tenants stay on stale code until someone manually dispatches the chain (which is what just happened for issue #2339). Fix here: after enqueuing auto-merge, poll for the PR to land, then explicitly `gh workflow run publish-workspace-server-image.yml --ref main`. workflow_dispatch is the documented exception, so the dispatch event itself DOES create a new run. canary-verify and redeploy-tenants-on-main chain via workflow_run as before. Long-term (tracked in #2357): switch the auto-merge call above to a GitHub App token (actions/create-github-app-token) so the merge event itself can trigger the downstream chain naturally; the polling tail becomes deletable. Why a 30-min poll cap: merge queue typically lands a green promote PR within 5-10 min. 30 min covers a slow CI run without hanging the workflow indefinitely. If the merge times out, the step warns and exits 0 — operator can manually dispatch as a fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/auto-promote-staging.yml | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/.github/workflows/auto-promote-staging.yml b/.github/workflows/auto-promote-staging.yml index b4e56cd6..8304398c 100644 --- a/.github/workflows/auto-promote-staging.yml +++ b/.github/workflows/auto-promote-staging.yml @@ -240,3 +240,67 @@ jobs: echo echo "Merge queue lands the PR once required gates are green; no human action needed unless gates fail." } >> "$GITHUB_STEP_SUMMARY" + + # Hand the PR number to the next step so we can dispatch the + # tenant-redeploy chain after the merge queue lands the merge. + echo "promote_pr_num=${PR_NUM}" >> "$GITHUB_OUTPUT" + id: promote_pr + + - name: Wait for promote merge, then dispatch publish + redeploy (#2357) + # GITHUB_TOKEN-initiated merges suppress downstream `push` events + # (https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow). + # Result: when the merge queue lands the promote PR, the resulting + # main-branch push DOES NOT fire publish-workspace-server-image, + # so canary-verify and redeploy-tenants-on-main never run and + # tenants stay on stale code (issue #2357). + # + # Workaround: poll for the merge to land, then explicitly + # `gh workflow run` publish-workspace-server-image. workflow_dispatch + # is the documented exception to the GITHUB_TOKEN suppression rule — + # dispatch DOES create a new workflow run. canary-verify chains via + # workflow_run (no branch filter) and redeploys to fleet via the + # existing chain. + # + # Long-term fix: switch the auto-merge call above to a GitHub App + # token (actions/create-github-app-token) and remove this polling + # tail step. Tracked in #2357. + if: steps.promote_pr.outputs.promote_pr_num != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUM: ${{ steps.promote_pr.outputs.promote_pr_num }} + run: | + # Poll for merge — max 30 min (60 × 30s). The merge queue + # typically lands within 5-10 min when gates are green. + MERGED="" + for _ in $(seq 1 60); do + MERGED=$(gh pr view "$PR_NUM" --repo "$REPO" --json mergedAt --jq '.mergedAt // ""') + if [ -n "$MERGED" ] && [ "$MERGED" != "null" ]; then + echo "::notice::Promote PR #${PR_NUM} merged at ${MERGED}" + break + fi + sleep 30 + done + + if [ -z "$MERGED" ] || [ "$MERGED" = "null" ]; then + echo "::warning::Promote PR #${PR_NUM} didn't merge within 30min — skipping deploy dispatch (manually run \`gh workflow run redeploy-tenants-on-main.yml\` once it lands)." + exit 0 + fi + + # Dispatch publish on main. workflow_dispatch via GITHUB_TOKEN + # IS allowed to create new workflow runs (per the linked docs). + # publish completes → canary-verify chains via workflow_run → + # redeploy-tenants-on-main chains via workflow_run + branches:[main]. + if gh workflow run publish-workspace-server-image.yml \ + --repo "$REPO" --ref main 2>&1; then + echo "::notice::Dispatched publish-workspace-server-image on ref=main — canary-verify and redeploy-tenants-on-main will chain via workflow_run." + { + echo "## 🚀 Tenant redeploy chain dispatched" + echo + echo "- publish-workspace-server-image (workflow_dispatch on \`main\`)" + echo "- canary-verify will chain on completion" + echo "- redeploy-tenants-on-main will chain on canary green" + } >> "$GITHUB_STEP_SUMMARY" + else + echo "::error::Failed to dispatch publish-workspace-server-image. Run manually: gh workflow run publish-workspace-server-image.yml --ref main" + fi