From d6603d7cd2bb3869ed29eeba8bc0ce04528fb311 Mon Sep 17 00:00:00 2001 From: core-devops Date: Sun, 21 Jun 2026 17:14:50 -0700 Subject: [PATCH 1/2] fix(canvas/plugins): explicit loading state for Plugins tab async sections + CI regression guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace Plugins tab (SkillsTab) rendered its EMPTY state ("0 installed", "Registry returned 0 plugins") while the installed-plugins and registry fetches were still in flight, so the tab looked broken until data arrived a second later (user-reported). Bug 1 (loading state): add an `installedLoading` flag + a `PluginSkeletonRows` animated skeleton (motion-safe, aria-busy / role=status) so each async section shows an explicit loading affordance while pending, and only falls back to the empty state after the fetch resolves with zero results. The installed section had no loading state at all (it painted the "0 installed" header during load); the registry section's plain "Loading registry…" text is upgraded to the same skeleton. Bug 2 (registry not shown in the Install dialog): the registry data path (GET /plugins → render) and loading/error/empty states already exist on main (the "no registry available" string the user saw is from an older deployed image). Confirmed the dialog lists the registry; the remaining "empty-during-load" flash is resolved by Bug 1's fix. Added test coverage pinning that the dialog shows the registry list when /plugins returns entries. CI regression guards: - canvas vitest (SkillsTab.loadingState.test.tsx): asserts skeleton while pending (not empty), rows on resolve-with-data, empty/compact pill on resolve-empty, and the install dialog lists registry entries when /plugins returns data. - workspace-server staging e2e (TestPluginInstallLifecycle_Staging): registry non-empty → install plugin on a SaaS workspace → ListInstalled returns it (guards CP #3125 EIC readback) → workspace back online+routable + serves A2A after the install restart (guards the #159 mgmt-MCP self-heal). Wired as a fail-loud (advisory) job in e2e-staging-saas.yml; to gate it, add the context to required-contexts.txt + branch protection (owner action, noted in the job comment). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/e2e-staging-saas.yml | 61 ++++++ canvas/src/components/tabs/SkillsTab.tsx | 57 +++++- .../__tests__/SkillsTab.loadingState.test.tsx | 168 +++++++++++++++++ .../staginge2e/plugin_lifecycle_e2e_test.go | 177 ++++++++++++++++++ 4 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 canvas/src/components/tabs/__tests__/SkillsTab.loadingState.test.tsx create mode 100644 workspace-server/internal/staginge2e/plugin_lifecycle_e2e_test.go diff --git a/.gitea/workflows/e2e-staging-saas.yml b/.gitea/workflows/e2e-staging-saas.yml index c0c15a80..35950f6f 100644 --- a/.gitea/workflows/e2e-staging-saas.yml +++ b/.gitea/workflows/e2e-staging-saas.yml @@ -961,3 +961,64 @@ jobs: # (e2e-cncrg-* slug), running even on a t.Fatal. The age-guarded # sweep-stale-e2e-orgs workflow (30-min floor, e2e- prefix) is the final # net for a tenant orphaned by a hard runner cancel. + + # Tenant plugin-install lifecycle guard (canvas Plugins tab regression class): + # boots a real staging workspace, asserts the registry is non-empty (the + # "no registry available" report), installs a registry plugin via + # POST /workspaces/:id/plugins, then asserts (a) GET /workspaces/:id/plugins + # returns it — guards the SaaS EIC readback (CP #3125), (b) the workspace + # comes back online+routable and serves A2A after the install-triggered + # restart — guards the #159 mgmt-MCP self-heal online-gate. Drives + # TestPluginInstallLifecycle_Staging (workspace-server/internal/staginge2e). + # + # GATING: fail-loud (no continue-on-error) on push/dispatch/cron — a real + # red here blocks the deploy path. NOT yet a branch-protection REQUIRED + # context: to make it merge-blocking, add + # "E2E Staging SaaS (full lifecycle) / E2E Staging Plugin Install Lifecycle" + # to .gitea/required-contexts.txt AND to branch_protections/main + # status_check_contexts (owner action — same two-step the platform-boot + # gate documents above). Until then it runs + fails loud but is advisory, + # exactly like e2e-staging-workspace-requests. + e2e-staging-plugin-lifecycle: + name: E2E Staging Plugin Install Lifecycle + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + timeout-minutes: 45 + permissions: + contents: read + env: + CP_BASE_URL: https://staging-api.moleculesai.app + CP_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }} + STAGING_E2E: '1' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: 'stable' + # cache:false — see the concierge-platform job; the self-hosted + # runner bind-mounts a persistent GOCACHE/GOMODCACHE and + # actions/cache corrupts it. + cache: false + cache-dependency-path: workspace-server/go.sum + - name: Verify admin token present + run: | + if [ -z "$CP_ADMIN_API_TOKEN" ]; then + echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)" + exit 2 + fi + echo "Admin token present" + - name: CP staging health preflight + run: | + code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$CP_BASE_URL/health") + if [ "$code" != "200" ]; then + echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a plugin-lifecycle bug." + exit 1 + fi + echo "Staging CP healthy" + - name: Run plugin-install-lifecycle staginge2e + working-directory: workspace-server + run: go test -tags staging_e2e ./internal/staginge2e/ -run TestPluginInstallLifecycle_Staging -count=1 -v -timeout 40m + # Teardown: the test installs a t.Cleanup admin-DELETE of its own tenant + # (e2e-plgn-* slug), running even on a t.Fatal. The age-guarded + # sweep-stale-e2e-orgs workflow (30-min floor, e2e- prefix) is the final + # net for a tenant orphaned by a hard runner cancel. diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx index a201112e..0a80869f 100644 --- a/canvas/src/components/tabs/SkillsTab.tsx +++ b/canvas/src/components/tabs/SkillsTab.tsx @@ -92,7 +92,17 @@ export function SkillsTab({ workspaceId, data }: Props) { // positive. const [installedLoaded, setInstalledLoaded] = useState(false); + // Tracks an in-flight installed-plugins fetch so the panel can render + // skeleton rows instead of the bare "0 installed" header while the + // request is pending. Without this the full panel paints "0 installed" + // with no list during the initial load (compactEmpty is gated on + // installedLoaded so it can't fire yet) — indistinguishable from a + // genuinely-empty workspace, the "looks broken until data arrives a + // second later" report. + const [installedLoading, setInstalledLoading] = useState(true); + const loadInstalled = useCallback(async () => { + if (mountedRef.current) setInstalledLoading(true); try { const result = await api.get(`/workspaces/${workspaceId}/plugins`); if (mountedRef.current) { @@ -101,6 +111,8 @@ export function SkillsTab({ workspaceId, data }: Props) { } } catch (e) { console.warn("SkillsTab: installed plugins load failed", e); + } finally { + if (mountedRef.current) setInstalledLoading(false); } }, [workspaceId]); @@ -357,8 +369,12 @@ export function SkillsTab({ workspaceId, data }: Props) { - {/* Installed plugins */} - {installed.length > 0 && ( + {/* Installed plugins — skeleton while the initial fetch is in + flight (distinguish loading from a genuinely-empty list), + then the rows. */} + {installedLoading && !installedLoaded && installed.length === 0 ? ( + + ) : installed.length > 0 ? (
{installed.map((p) => { // Plugin was installed but does NOT declare support for @@ -409,7 +425,7 @@ export function SkillsTab({ workspaceId, data }: Props) { ); })}
- )} + ) : null} {/* Plugin registry (expandable) */} {showRegistry && ( @@ -486,7 +502,7 @@ export function SkillsTab({ workspaceId, data }: Props) { )} {registryLoading && registry.length === 0 ? ( -
Loading registry…
+ ) : registryError ? (
@@ -663,6 +679,39 @@ export function extractSkills(agentCard: Record | null): SkillE .filter((skill) => skill.id.length > 0); } +// Animated skeleton placeholder rows for the async plugin sections +// (installed list + registry). Rendered while the fetch is in flight so +// the user sees an explicit "loading" affordance rather than the empty +// state ("0 installed" / "Registry returned 0 plugins"). aria-busy + +// role=status keep it announced for assistive tech. Mirrors the +// MemoryInspectorPanel skeleton style. `motion-safe:` honours +// prefers-reduced-motion. +function PluginSkeletonRows({ label }: { label: string }) { + return ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} + {label}… +
+ ); +} + function MetaPill({ label, value }: { label: string; value: string }) { return ( diff --git a/canvas/src/components/tabs/__tests__/SkillsTab.loadingState.test.tsx b/canvas/src/components/tabs/__tests__/SkillsTab.loadingState.test.tsx new file mode 100644 index 00000000..3a034ad9 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/SkillsTab.loadingState.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +// +// Regression guard for the Plugins-tab async loading states (user- +// reported: the tab shows the EMPTY state — "0 installed", registry +// "Registry returned 0 plugins" — while the fetch is still in flight, +// so it looks broken until data arrives a second later). +// +// Pins, for each async section: +// - installed plugins: skeleton while pending, NOT the "0 installed" +// empty look; rows on resolve-with-data; empty (compact pill) on +// resolve-empty. +// - install-dialog registry: skeleton while pending, registry rows +// when the /plugins fetch returns entries (the "no registry / still +// no registry" report — confirm the dialog lists the registry). +// +// A future refactor that re-introduces the empty-during-load flash, or +// drops the registry list from the dialog, fails here. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import React from "react"; + +afterEach(cleanup); + +const apiGet = vi.fn(); +vi.mock("@/lib/api", () => ({ + api: { + get: (path: string, opts?: unknown) => apiGet(path, opts), + post: vi.fn(() => Promise.resolve({})), + del: vi.fn(), + patch: vi.fn(), + put: vi.fn(), + }, +})); + +beforeEach(() => { + apiGet.mockReset(); + Element.prototype.scrollIntoView = vi.fn(); +}); + +import { SkillsTab } from "../SkillsTab"; + +const minimalData = { + status: "online" as const, + runtime: "claude-code", + currentTask: "", + agentCard: undefined, +} as unknown as Parameters[0]["data"]; + +// Returns a controllable deferred promise so a test can hold a fetch +// "in flight" and assert the loading state before resolving. +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const REGISTRY_PLUGINS = [ + { name: "browser-automation", version: "0.2.0", description: "Drive a headless browser", tags: ["web"], skills: [], author: "" }, + { name: "image-gen", version: "0.1.1", description: "Generate images", tags: ["media"], skills: [], author: "" }, +]; + +describe("SkillsTab Plugins async loading states", () => { + it("shows a loading skeleton (not the empty state) while installed plugins are in flight", async () => { + const installedDef = deferred(); + apiGet.mockImplementation((path: string) => { + if (path === `/workspaces/ws-1/plugins`) return installedDef.promise; + return Promise.resolve([]); // /plugins, /plugins/sources + }); + + render(); + + // While the installed fetch is pending: skeleton present, and the + // compact "0 installed" empty pill MUST NOT be shown. + await waitFor(() => { + expect(screen.getByTestId("plugin-skeleton")).toBeTruthy(); + }); + expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull(); + + // Resolve empty → the skeleton goes away and the empty (compact) + // state appears only AFTER the fetch resolves. + installedDef.resolve([]); + await waitFor(() => { + expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy(); + }); + expect(screen.queryByTestId("plugin-skeleton")).toBeNull(); + }); + + it("shows installed rows after the fetch resolves with data", async () => { + apiGet.mockImplementation((path: string) => { + if (path === `/workspaces/ws-2/plugins`) { + return Promise.resolve([ + { name: "memory-postgres", version: "1.0.0", description: "memory backend", supported_on_runtime: true }, + ]); + } + return Promise.resolve([]); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/1 installed/i)).toBeTruthy(); + }); + expect(screen.getByText("memory-postgres")).toBeTruthy(); + // No skeleton lingering after resolve. + expect(screen.queryByTestId("plugin-skeleton")).toBeNull(); + }); + + it("install dialog shows a skeleton while the registry is in flight, then lists registry plugins", async () => { + const registryDef = deferred(); + apiGet.mockImplementation((path: string) => { + if (path === "/plugins") return registryDef.promise; + // installed resolves empty immediately so the compact pill renders + // and we can click "+ Install Plugin". + if (path === `/workspaces/ws-3/plugins`) return Promise.resolve([]); + return Promise.resolve([]); // /plugins/sources + }); + + render(); + + // Open the install dialog from the compact pill. + await waitFor(() => { + expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: /\+ Install Plugin/i })); + + // Dialog open, registry still loading → skeleton, NOT the "Registry + // returned 0 plugins" empty banner. + await waitFor(() => { + expect(screen.getByTestId("plugin-skeleton")).toBeTruthy(); + }); + expect(screen.queryByText(/Registry returned 0 plugins/i)).toBeNull(); + + // Registry resolves with entries → the dialog lists them (name + + // version + description), and the skeleton is gone. + registryDef.resolve(REGISTRY_PLUGINS); + await waitFor(() => { + expect(screen.getByText("browser-automation")).toBeTruthy(); + }); + expect(screen.getByText("image-gen")).toBeTruthy(); + expect(screen.getByText("v0.2.0")).toBeTruthy(); + expect(screen.getByText("Generate images")).toBeTruthy(); + expect(screen.queryByTestId("plugin-skeleton")).toBeNull(); + // The empty banner must NOT show when the registry has entries. + expect(screen.queryByText(/Registry returned 0 plugins/i)).toBeNull(); + }); + + it("shows the empty registry banner only after the registry resolves with zero entries", async () => { + apiGet.mockImplementation((path: string) => { + if (path === `/workspaces/ws-4/plugins`) return Promise.resolve([]); + return Promise.resolve([]); // /plugins resolves empty, /plugins/sources + }); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: /\+ Install Plugin/i })); + + await waitFor(() => { + expect(screen.getByText(/Registry returned 0 plugins/i)).toBeTruthy(); + }); + expect(screen.queryByTestId("plugin-skeleton")).toBeNull(); + }); +}); diff --git a/workspace-server/internal/staginge2e/plugin_lifecycle_e2e_test.go b/workspace-server/internal/staginge2e/plugin_lifecycle_e2e_test.go new file mode 100644 index 00000000..156ad065 --- /dev/null +++ b/workspace-server/internal/staginge2e/plugin_lifecycle_e2e_test.go @@ -0,0 +1,177 @@ +//go:build staging_e2e + +package staginge2e + +// plugin_lifecycle_e2e_test.go — live, against-real-staging guard for the +// tenant plugin-install lifecycle that backs the canvas Plugins tab. +// +// Guards three regressions that were each invisible to the existing suites: +// +// 1. Registry is non-empty (the canvas "no registry available" / +// "Registry returned 0 plugins" report): GET /plugins on the tenant +// ws-server must list the plugins clone-manifest.sh populates at deploy +// time. A 0-length registry here is the exact server-side state that +// makes the install dialog look broken. +// +// 2. ListInstalled returns an installed plugin on a SaaS workspace +// (guards CP #3125 — the EIC branch that fixed "[] readback after a +// successful install" for every SaaS tenant): install a registry plugin +// via POST /workspaces/:id/plugins, then GET /workspaces/:id/plugins must +// include it. Without the EIC branch this read back [] forever. +// +// 3. The agent stays online after the install-triggered restart (guards +// the #159 mgmt-MCP self-heal — a plugin install restarts the workspace, +// and a broken online-gate would leave it stuck 'failed'/offline): the +// workspace must return to online+routable AND serve A2A after the +// install. +// +// Reuses the workspace_lifecycle_test.go harness wholesale (requireStagingEnv +// / adminCreateOrg / adminDeleteTenant / tenantAdminToken / tenantCreateWorkspace +// / waitForWorkspaceOnlineRoutable / waitForWorkspaceStatus / doTenantJSON / +// serveProbe / jsonField). NOTHING here re-implements org provisioning or +// teardown — t.Cleanup-driven admin DELETE guarantees no leaked tenant. +// +// Guarded by the staging_e2e build tag + STAGING_E2E=1 (requireStagingEnv). + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" +) + +func TestPluginInstallLifecycle_Staging(t *testing.T) { + cfg := requireStagingEnv(t) + + slug := fmt.Sprintf("e2e-plgn-%d", time.Now().Unix()%100000000) + t.Logf("plugin-lifecycle: slug=%s", slug) + + // --- Step 1: provision throwaway org + tenant (reused scaffolding) --- + orgID := adminCreateOrg(t, cfg, slug) + t.Cleanup(func() { adminDeleteTenant(t, cfg, slug) }) + host := slug + "." + cfg.subdomainSuffix + token := tenantAdminToken(t, cfg, slug) + waitForHTTP(t, host, http.StatusOK, 10*time.Minute, "tenant /health ready") + t.Logf("tenant TLS ready: %s", host) + + // --- Step 2: registry must be non-empty BEFORE we pick a plugin ---------- + // This is independent of any workspace — the registry is host-local + // (clone-manifest.sh populated). We both assert it's non-empty (guard #1) + // AND use the first runtime-agnostic entry as the plugin to install, so the + // test never hardcodes a registry slug that could be renamed/removed. + var pluginName string + t.Run("registry_non_empty", func(t *testing.T) { + hs, body := doTenantJSON(t, "GET", "https://"+host+"/plugins", token, orgID, "") + if hs != http.StatusOK { + t.Fatalf("GET /plugins registry: HTTP %d: %s", hs, body) + } + reg := parsePluginList(t, body) + if len(reg) == 0 { + t.Fatalf("GET /plugins returned an EMPTY registry — the canvas install dialog would "+ + "show 'no registry available'. clone-manifest.sh must populate plugins/ at deploy. body=%s", + truncate(body, 400)) + } + pluginName = pickInstallablePlugin(reg) + if pluginName == "" { + t.Fatalf("registry has %d entries but none with a usable name: %s", len(reg), truncate(body, 400)) + } + t.Logf("registry has %d plugins; will install %q", len(reg), pluginName) + }) + if pluginName == "" { + t.Fatal("no installable plugin established by registry_non_empty — dependent subtests cannot run") + } + + // --- Step 3: create + boot a workspace ---------------------------------- + wsID := tenantCreateWorkspace(t, cfg, host, token, orgID) + t.Logf("workspace created: %s", wsID) + waitForWorkspaceOnlineRoutable(t, host, token, orgID, wsID, 15*time.Minute, "initial boot") + t.Logf("workspace %s online + routable", wsID) + + // --- Step 4: install → ListInstalled returns it → agent stays online ----- + t.Run("install_then_list_then_stay_online", func(t *testing.T) { + // Install the registry plugin via the local:// source — the same path + // the canvas "Install" button uses (handleInstall → local://). + installBody, _ := json.Marshal(map[string]string{"source": "local://" + pluginName}) + hs, body := doTenantJSON(t, "POST", "https://"+host+"/workspaces/"+wsID+"/plugins", token, orgID, string(installBody)) + if hs != http.StatusOK && hs != http.StatusCreated && hs != http.StatusAccepted { + t.Fatalf("install %q: HTTP %d: %s", pluginName, hs, body) + } + t.Logf("install %q accepted (HTTP %d) — workspace will restart", pluginName, hs) + + // The install restarts the workspace. Wait for it to come back + // online+routable. A stuck 'failed'/offline here is the #159 self-heal + // regression (online-gate refusing a freshly-rebooted box). + waitForWorkspaceOnlineRoutable(t, host, token, orgID, wsID, 15*time.Minute, "post-install restart→online") + + // ListInstalled must now include the plugin (guard #3125 EIC branch). + // The readback can race the restart, so poll until it appears. + if !pollListInstalledContains(t, host, token, orgID, wsID, pluginName, 5*time.Minute) { + _, listBody := doTenantJSON(t, "GET", "https://"+host+"/workspaces/"+wsID+"/plugins", token, orgID, "") + t.Fatalf("installed plugin %q never appeared in ListInstalled within 5m — "+ + "the SaaS EIC readback (#3125) regressed; last list=%s", pluginName, truncate(listBody, 600)) + } + t.Logf("ListInstalled returned %q after install (EIC readback OK)", pluginName) + + // And the agent must actually SERVE — online-row + a live A2A reply. + if served, code := serveProbe(t, host, token, orgID, wsID); !served { + t.Fatalf("agent did not serve A2A after plugin install (code=%d) — "+ + "the install-triggered restart left it un-serveable (#159 self-heal regression)", code) + } + t.Logf("agent served A2A after install — stayed online through the restart") + }) +} + +// ─── helpers (plugin-lifecycle specific; lifecycle suite owns the shared ones) ── + +// pluginListRow is the flat view of one /plugins or /workspaces/:id/plugins row. +type pluginListRow struct { + Name string + Version string +} + +func parsePluginList(t *testing.T, body string) []pluginListRow { + t.Helper() + var raw []map[string]json.RawMessage + if err := json.Unmarshal([]byte(body), &raw); err != nil { + t.Fatalf("plugins body not a JSON array: %v (%s)", err, truncate(body, 300)) + } + out := make([]pluginListRow, 0, len(raw)) + for _, m := range raw { + out = append(out, pluginListRow{ + Name: rawString(m["name"]), + Version: rawString(m["version"]), + }) + } + return out +} + +// pickInstallablePlugin returns the first registry entry with a non-empty name. +// (Runtime filtering is handled server-side; an unnamed entry is unusable.) +func pickInstallablePlugin(reg []pluginListRow) string { + for _, p := range reg { + if p.Name != "" { + return p.Name + } + } + return "" +} + +// pollListInstalledContains polls GET /workspaces/:id/plugins until name appears +// or the timeout elapses. Returns true if found. +func pollListInstalledContains(t *testing.T, host, token, orgID, wsID, name string, timeout time.Duration) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + hs, body := doTenantJSON(t, "GET", "https://"+host+"/workspaces/"+wsID+"/plugins", token, orgID, "") + if hs == http.StatusOK { + for _, p := range parsePluginList(t, body) { + if p.Name == name { + return true + } + } + } + time.Sleep(10 * time.Second) + } + return false +} -- 2.52.0 From 0a5bfd99dcd6ce2e874e3eaacc31a282b5307cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Molecule=20AI=20=C2=B7=20core-devops?= Date: Mon, 22 Jun 2026 00:20:50 +0000 Subject: [PATCH 2/2] ci(e2e): add bp-required: pending #3133 directive to Plugin Install Lifecycle job Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/e2e-staging-saas.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/e2e-staging-saas.yml b/.gitea/workflows/e2e-staging-saas.yml index 35950f6f..40997fb9 100644 --- a/.gitea/workflows/e2e-staging-saas.yml +++ b/.gitea/workflows/e2e-staging-saas.yml @@ -979,6 +979,7 @@ jobs: # status_check_contexts (owner action — same two-step the platform-boot # gate documents above). Until then it runs + fails loud but is advisory, # exactly like e2e-staging-workspace-requests. + # bp-required: pending #3133 e2e-staging-plugin-lifecycle: name: E2E Staging Plugin Install Lifecycle runs-on: ubuntu-latest -- 2.52.0