StatusBadge: all 3 status variants, aria-label, role=status, config class names.
ValidationHint: error/valid/neutral states, warning icon, valid icon, class names.
Spinner: sm/md/lg size classes, aria-hidden, motion-safe:animate-spin.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Root cause of issue #213: canary-verify.yml still used GHCR
(ghcr.io/molecule-ai/platform-tenant) while
publish-workspace-server-image.yml migrated to ECR on 2026-05-07
(commit 10e510f5). Canary smoke tests were silently testing a stale
GHCR image while actual staging/prod tenants ran the ECR build.
The POST /org/import and POST /workspaces routes were missing from
the ECR binary (likely a Docker layer-caching artefact during the
staging push window) but smoke tests passed because they never tested
the ECR image at all.
Changes:
- canary-verify.yml: migrate promote-to-latest from GHCR crane tag
ops to the CP redeploy-fleet endpoint (same mechanism as
redeploy-tenants-on-main.yml). The wait-for-canaries step already
read SHA from the running tenant /health (registry-agnostic), so
no change needed there. Pre-fix promote step used `crane tag` against
GHCR, which was never updated after the ECR migration.
- redeploy-tenants-on-main.yml: update stale comments that reference
GHCR to reflect ECR; replace the 30s GHCR CDN propagation wait
with a no-op comment (ECR has no CDN cache to wait for).
- scripts/canary-smoke.sh: add POST /org/import and POST /workspaces
smoke tests (steps 6-8). These assert HTTP 401 unauthenticated
(proves AdminAuth enforced AND the route is compiled in — 404 would
mean route missing from binary). GET /workspaces was already covered;
POST was the untested gap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
publish-runtime.yml was dead on Gitea Actions because Gitea reads
.gitea/workflows/, not .github/workflows/ (the GitHub Actions paths are
ignored). Issue #206 identified this as one of three bugs blocking the
runtime versioning pipeline.
Changes:
- Add .gitea/workflows/publish-runtime.yml (canonical Gitea version)
- Drop environment: + id-token: write (Gitea has no OIDC/OAuth)
- Replace pypa/gh-action-pypi-publish with twine upload using PYPI_TOKEN secret
- Replace github.ref_name with ${GITHUB_REF#refs/tags/} (Gitea exposes github.ref)
- Drop merge_group trigger (Gitea has no merge queue)
- Drop staging branch trigger (staging branch does not exist)
- Cascade step unchanged (DISPATCH_TOKEN + Gitea API already compatible)
- Add DEPRECATED notice to .github/workflows/publish-runtime.yml
Required secrets (repo Settings → Actions → Variables and Secrets):
PYPI_TOKEN: PyPI API token for molecule-ai-workspace-runtime
DISPATCH_TOKEN: Gitea PAT with write:repo on template repos (already used)
Closes#206 (publish-runtime Gitea port).
dorny/paths-filter is GitHub-Actions-only and does not work correctly on
Gitea Actions — it silently returns no file changes regardless of what
files were modified, causing the harness-replays workflow to silently
skip on Gitea even when workspace-server/** or canvas/** files change.
Verified: zero harness-replays statuses on PR #188 and #168 (both changed
workspace-server files) vs GitHub Actions where the same workflow
correctly detects changes.
Replace with a shell-based approach that uses:
- github.event.pull_request.base.sha (Gitea + GitHub: merge-base for PRs)
- github.event.before (Gitea + GitHub: previous tip for pushes)
- git diff --name-only <BASE> github.sha (portable git, works on both platforms)
Also adds detect-changes.debug output so future no-op passes show WHY
the workflow decided to skip, and the first real run on Gitea will
confirm the diff detection is working.
Closes#141 (followup: root-cause fix still TBD — failure logs
inaccessible via Gitea Actions API).
Adds a note to the audit doc footer tracking the new component tests
(PR #205: Tooltip, Legend, TermsGate, ApprovalBanner) and bumps the
updated date.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
Adds the version-subscription drift detection and operator-apply workflow for
per-workspace plugin tracking (core#113).
## Components
**Migration** (`20260510000000_plugin_drift_queue`):
- Adds `installed_sha` column to `workspace_plugins` — records the commit SHA
installed so the drift sweeper can compare against upstream.
- Creates `plugin_update_queue` table with status: pending | applied | dismissed.
- Adds partial unique index to prevent duplicate pending rows per
(workspace_id, plugin_name).
**GithubResolver** (`github.go`):
- `LastFetchSHA` field + `LastSHA()` getter — populated by `Fetch` after a
successful shallow clone (captured before `.git` is stripped). Used by the
install pipeline to seed `installed_sha`.
- `ResolveRef(ctx, spec)` method — resolves a plugin spec to its full commit
SHA using `git fetch --depth=1 + git rev-parse`. Used by the drift sweeper
to get the current upstream SHA for a tracked ref (tag:vX.Y.Z, tag:latest,
sha:…, or bare branch).
**Drift sweeper** (`plugins/drift_sweeper.go`):
- Periodic sweep every 1h: SELECTs rows where `tracked_ref != 'none' AND
installed_sha IS NOT NULL`, resolves upstream SHA, queues drift if different.
- `ListPendingUpdates()` — reads pending queue rows for the admin endpoint.
- `ApplyDriftUpdate()` — marks entry applied (idempotent).
- ctx.Err() guard on ticker arm to avoid post-shutdown work.
**Install pipeline** (`plugins_install_pipeline.go`, `plugins_tracking.go`,
`plugins_install.go`):
- `stageResult.InstalledSHA` field — carries the SHA from Fetch to the DB.
- `recordWorkspacePluginInstall` now accepts and stores `installed_sha`.
- `deleteWorkspacePluginRow` — removes tracking row on uninstall so a stale
SHA doesn't prevent the next install from creating a fresh row.
- Both Docker and EIC uninstall paths call `deleteWorkspacePluginRow`.
**Admin endpoints** (`handlers/admin_plugin_drift.go`):
- `GET /admin/plugin-updates-pending` — list all pending drift entries.
- `POST /admin/plugin-updates/:id/apply` — re-installs plugin from source_raw
(re-fetching the same tracked ref), records the new SHA, marks entry applied,
triggers workspace restart. Idempotent (already-applied returns 200).
**Router wiring** (`router.go`, `cmd/server/main.go`):
- Plugin registry created in main.go and shared between PluginsHandler and drift
sweeper.
- `router.Setup` accepts optional `pluginResolver` param.
- `PluginsHandler.Sources()` export for the sweeper wiring pattern.
## Tests
- `plugins/github_test.go` — `ResolveRef` coverage (invalid spec, git error,
not-found mapping, no-panic for all ref shapes).
- `plugins/drift_sweeper_test.go` — `ResolveRef` happy path, stub resolver
interface compliance.
- `handlers/admin_plugin_drift_test.go` — ListPending (empty, non-empty, DB
error), Apply (not found, already applied, already dismissed, workspace_plugins
missing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add 10 tests for StatusDot covering:
- All known STATUS_CONFIG statuses (online, offline, degraded,
failed, paused, not_configured, provisioning)
- Correct color class applied per status
- Glow class applied when declared in STATUS_CONFIG
- motion-safe:animate-pulse on provisioning status
- Fallback to bg-zinc-500 for unknown status
- size prop (sm/md) applies correct Tailwind dimension class
- aria-hidden="true" for accessibility tree isolation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Pre-commit Hook: moved stray "Action:" line inside the section (was appended to
WCAG entry below it after a rebase conflict resolution)
- Removed duplicate text-ink-soft WCAG AA entry (lines 62-68 were a rebase artifact)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>