Compare commits

..

8 Commits

Author SHA1 Message Date
fullstack-engineer a60033dc16 test(handlers): add missing DB-error tests for Record and SessionSearch
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Waiting to run
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Waiting to run
publish-runtime-autobump / bump-and-tag (pull_request) Waiting to run
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 3m8s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m22s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m11s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m41s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m6s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m59s
audit-force-merge / audit (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 34s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 43s
CI / Detect changes (pull_request) Successful in 1m13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 24s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m16s
Harness Replays / detect-changes (pull_request) Successful in 53s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m57s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 38s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 58s
qa-review / approved (pull_request) Failing after 48s
gate-check-v3 / gate-check (pull_request) Failing after 55s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m51s
security-review / approved (pull_request) Failing after 41s
sop-tier-check / tier-check (pull_request) Successful in 40s
CI / Python Lint & Test (pull_request) Successful in 8m14s
CI / Canvas (Next.js) (pull_request) Successful in 19m47s
CI / Platform (Go) (pull_request) Successful in 21m16s
CI / all-required (pull_request) Successful in 21m27s
CI / Canvas Deploy Reminder (pull_request) Successful in 8s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
- TestDelegationRecord_DBInsertFails: verifies 500 on activity_logs insert failure
- TestSessionSearch_DBError: verifies 500 on WITH query failure

Both are regression coverage for error paths that lacked test coverage.

🤖 Generated with [Claude Code](https://claude.ai/claude-code)
2026-05-15 07:38:15 +00:00
app-fe 5dc1e462de fix(external-workspace): pin molecule-ai-workspace-runtime>=0.1.999 in OpenClaw snippet (#1143)
Block internal-flavored paths / Block forbidden paths (push) Successful in 17s
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Successful in 35s
Handlers Postgres Integration / detect-changes (push) Successful in 25s
Harness Replays / detect-changes (push) Successful in 22s
CI / Detect changes (push) Successful in 58s
E2E API Smoke Test / detect-changes (push) Successful in 59s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m1s
Harness Replays / Harness Replays (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 53s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4m7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 6m36s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 7m11s
CI / Python Lint & Test (push) Successful in 8m23s
publish-workspace-server-image / build-and-push (push) Successful in 13m9s
CI / Canvas (Next.js) (push) Successful in 21m39s
CI / Platform (Go) (push) Successful in 23m7s
CI / all-required (push) Successful in 27m51s
CI / Canvas Deploy Reminder (push) Successful in 13s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Has started running
Runtime Pin Compatibility / PyPI-latest install + import smoke (push) Has started running
Railway pin audit (drift detection) / Audit Railway env vars for drift-prone pins (push) Has started running
main-red-watchdog / watchdog (push) Has started running
gate-check-v3 / gate-check (push) Has started running
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Has started running
ci-required-drift / drift (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 28s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 1m0s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m52s
gitea-merge-queue / queue (push) Successful in 27s
status-reaper / reap (push) Successful in 3m26s
fix(external-workspace): pin molecule-ai-workspace-runtime>=0.1.999 in OpenClaw snippet

Ensures the molecule-mcp console script (heartbeat + register-on-startup) is present on install. Older versions only ship a2a_mcp_server which does not heartbeat, causing workspaces to go OFFLINE within 60s.

Closes openclaw keepalive regression.
Co-authored-by: Molecule AI App-FE <app-fe@agents.moleculesai.app>
Co-committed-by: Molecule AI App-FE <app-fe@agents.moleculesai.app>
2026-05-15 07:35:57 +00:00
devops-engineer ec96a8f600 Merge pull request 'fix(ci): throttle SOP refire workflow fan-out' (#1134) from fix/ci-sop-refire-concurrency into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
Handlers Postgres Integration / detect-changes (push) Successful in 15s
CI / Shellcheck (E2E scripts) (push) Successful in 27s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 19s
CI / Detect changes (push) Successful in 46s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 47s
E2E API Smoke Test / detect-changes (push) Successful in 54s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 49s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 11s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m43s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 3m6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m1s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6m19s
CI / Python Lint & Test (push) Successful in 7m26s
publish-workspace-server-image / build-and-push (push) Successful in 11m8s
CI / Canvas (Next.js) (push) Successful in 19m19s
CI / Canvas Deploy Reminder (push) Successful in 8s
CI / Platform (Go) (push) Successful in 21m11s
CI / all-required (push) Successful in 21m44s
publish-workspace-server-image / Production auto-deploy (push) Successful in 9m57s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 23s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m4s
main-red-watchdog / watchdog (push) Successful in 58s
gate-check-v3 / gate-check (push) Successful in 1m24s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 35s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 15m54s
ci-required-drift / drift (push) Successful in 1m58s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 5s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m54s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m32s
gitea-merge-queue / queue (push) Successful in 31s
status-reaper / reap (push) Successful in 2m51s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m55s
2026-05-15 05:57:38 +00:00
claude-ceo-assistant 3198a3ee5d fix(ci): throttle SOP refire workflow fan-out
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 29s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1m21s
CI / Detect changes (pull_request) Successful in 1m29s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 55s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 25s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 21s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 37s
gate-check-v3 / gate-check (pull_request) Successful in 38s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m10s
sop-checklist / all-items-acked (pull_request) Successful in 23s
sop-tier-check / tier-check (pull_request) Successful in 19s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 14s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 15s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m49s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 58s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m38s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m55s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m59s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m54s
CI / Python Lint & Test (pull_request) Successful in 7m53s
qa-review / approved (pull_request) Approved by core-qa review #3561
security-review / approved (pull_request) Approved by core-security review #3562
CI / Platform (Go) (pull_request) Successful in 13m43s
CI / Canvas (Next.js) (pull_request) Successful in 14m23s
CI / Canvas Deploy Reminder (pull_request) Successful in 7s
CI / all-required (pull_request) Successful in 14m35s
audit-force-merge / audit (pull_request) Successful in 18s
2026-05-14 22:39:05 -07:00
hongming-codex-laptop 85b93feacc ci: prevent advisory workflow timeout flakes
Block internal-flavored paths / Block forbidden paths (push) Successful in 15s
CI / Detect changes (push) Successful in 27s
CI / Shellcheck (E2E scripts) (push) Successful in 36s
Handlers Postgres Integration / detect-changes (push) Successful in 16s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 16s
E2E API Smoke Test / detect-changes (push) Successful in 31s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 41s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 23s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 50s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m53s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m26s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3m2s
CI / Python Lint & Test (push) Successful in 7m33s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6m59s
publish-workspace-server-image / build-and-push (push) Successful in 12m40s
dev-lead/test orchestrator test
CI / Canvas (Next.js) (push) Successful in 18m53s
CI / Platform (Go) (push) Successful in 21m52s
CI / Canvas Deploy Reminder (push) Successful in 6s
CI / all-required (push) Successful in 21m53s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m15s
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Successful in 2m13s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (push) Successful in 1m32s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 42s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Failing after 14m9s
ci-required-drift / drift (push) Successful in 2m7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Has started running
main-red-watchdog / watchdog (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 6s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
gitea-merge-queue / queue (push) Successful in 10s
status-reaper / reap (push) Successful in 1m28s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m16s
2026-05-14 20:00:19 -07:00
hongming-codex-laptop da2fefa398 ci: harden scheduled gate check against list timeouts
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 28s
CI / Shellcheck (E2E scripts) (push) Successful in 1m1s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 32s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 37s
CI / Detect changes (push) Successful in 2m24s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 2m16s
E2E API Smoke Test / detect-changes (push) Successful in 2m18s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 2m12s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m44s
CI / Python Lint & Test (push) Successful in 8m40s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 20s
Handlers Postgres Integration / detect-changes (push) Failing after 10m24s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Failing after 10m19s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 36s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m58s
publish-workspace-server-image / build-and-push (push) Successful in 13m23s
CI / Canvas (Next.js) (push) Successful in 19m24s
CI / Canvas Deploy Reminder (push) Successful in 12s
CI / Platform (Go) (push) Successful in 21m49s
CI / all-required (push) Successful in 21m50s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m6s
publish-workspace-server-image / Production auto-deploy (push) Successful in 6m4s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 16s
gitea-merge-queue / queue (push) Successful in 33s
status-reaper / reap (push) Successful in 2m23s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m22s
2026-05-14 19:31:31 -07:00
hongming-codex-laptop 8f4c00ba05 ci: rearm after runner disk gc
CI / Canvas Deploy Reminder (push) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
publish-workspace-server-image / Production auto-deploy (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 25s
CI / Shellcheck (E2E scripts) (push) Successful in 43s
CI / Detect changes (push) Successful in 1m16s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 22s
E2E API Smoke Test / detect-changes (push) Successful in 1m20s
Handlers Postgres Integration / detect-changes (push) Successful in 58s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 50s
CI / Python Lint & Test (push) Successful in 8m9s
publish-workspace-server-image / build-and-push (push) Successful in 13m23s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 1m10s
CI / Platform (Go) (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / all-required (push) Has been cancelled
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 31s
ci-required-drift / drift (push) Successful in 2m47s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-14 19:06:22 -07:00
hongming-codex-laptop 106baadf2b ci: fix platform staticcheck lint
Block internal-flavored paths / Block forbidden paths (push) Successful in 26s
CI / Detect changes (push) Successful in 1m5s
CI / Shellcheck (E2E scripts) (push) Successful in 50s
Harness Replays / detect-changes (push) Successful in 28s
E2E API Smoke Test / detect-changes (push) Successful in 54s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 23s
Handlers Postgres Integration / detect-changes (push) Successful in 1m8s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 48s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m9s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 24s
ci-required-drift / drift (push) Successful in 2m22s
CI / Python Lint & Test (push) Successful in 7m52s
publish-workspace-server-image / build-and-push (push) Successful in 10m3s
CI / Canvas (Next.js) (push) Successful in 18m10s
Harness Replays / Harness Replays (push) Successful in 13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 20s
CI / Platform (Go) (push) Successful in 19m30s
CI / all-required (push) Successful in 19m25s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m49s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Failing after 4m12s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 5m31s
publish-workspace-server-image / Production auto-deploy (push) Successful in 2m22s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 21s
CI / Canvas Deploy Reminder (push) Successful in 11s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 16s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m57s
main-red-watchdog / watchdog (push) Successful in 1m18s
2026-05-14 18:12:21 -07:00
12 changed files with 266 additions and 388 deletions
+54 -268
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# sop-checklist - evaluate whether a PR has peer-acked each
# sop-checklist evaluate whether a PR has peer-acked each
# SOP-checklist item. Posts a commit-status that branch protection
# can require.
#
@@ -10,18 +10,18 @@
# - issue_comment: [created, edited, deleted]
#
# Flow:
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref - trusted).
# 2. GET /repos/{R}/pulls/{N} - author, head.sha, tier label
# 3. GET /repos/{R}/issues/{N}/comments - extract /sop-ack and /sop-revoke
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref trusted).
# 2. GET /repos/{R}/pulls/{N} author, head.sha, tier label
# 3. GET /repos/{R}/issues/{N}/comments extract /sop-ack and /sop-revoke
# 4. For each checklist item:
# a. Is the section marker present in PR body? (author answered)
# b. Is there >=1 unrevoked /sop-ack from a non-author whose
# b. Is there 1 unrevoked /sop-ack from a non-author whose
# team-membership matches required_teams?
# 5. POST /repos/{R}/statuses/{sha} - context
# 5. POST /repos/{R}/statuses/{sha} context
# `sop-checklist / all-items-acked (pull_request)`,
# state=success | failure | pending, description=`acked: N/M ...`.
# state=success | failure | pending, description=`acked: N/M `.
#
# Trust boundary (mirrors RFC#324 SSA4):
# Trust boundary (mirrors RFC#324 §A4):
# This script is loaded from the BASE branch. The workflow's
# actions/checkout step pins ref=base.sha. PR-HEAD code is never
# executed. We only HTTP-call the Gitea API.
@@ -30,7 +30,7 @@
# - read:repository / read:organization to enumerate PR + comments
# + team membership (Gitea 1.22.6 quirk: team-membership endpoint
# returns 403 if token owner is not in the team; see review-check.sh
# for the same gotcha - we surface the same fail-closed message).
# for the same gotcha we surface the same fail-closed message).
# - write:repository for `POST /repos/{R}/statuses/{sha}`. Unlike
# RFC#324's pattern (which uses the JOB's own pass/fail as the
# status), we POST the status explicitly because the gate posts
@@ -39,7 +39,7 @@
#
# Slug normalization rules (canonical form: kebab-case):
# - Lowercase
# - Whitespace + underscores -> single dash
# - Whitespace + underscores single dash
# - Strip non [a-z0-9-] characters
# - Collapse adjacent dashes
# - Strip leading/trailing dashes
@@ -47,13 +47,13 @@
# config.items[*].numeric_alias to get the kebab-case slug.
#
# Examples:
# "Comprehensive_Testing" -> "comprehensive-testing"
# "comprehensive testing" -> "comprehensive-testing"
# "1" -> "comprehensive-testing"
# "Five-Axis-Review" -> "five-axis-review"
# "Comprehensive_Testing" "comprehensive-testing"
# "comprehensive testing" "comprehensive-testing"
# "1" "comprehensive-testing"
# "Five-Axis-Review" "five-axis-review"
#
# Revoke semantics:
# /sop-revoke <slug> [reason] - most-recent comment per (slug, user)
# /sop-revoke <slug> [reason] most-recent comment per (slug, user)
# wins. So if Alice posts /sop-ack X then later /sop-revoke X, her ack
# for X is invalidated. Bob's prior /sop-ack X is unaffected. If Alice
# posts /sop-revoke X then later /sop-ack X again, the ack is restored.
@@ -70,17 +70,6 @@ import urllib.parse
import urllib.request
from typing import Any
# ---------------------------------------------------------------------------
# /sop-n/a parsing
# ---------------------------------------------------------------------------
# Matches /sop-n/a <gate> [reason] on its own line.
# Gate names: qa-review, security-review (must match review-check.sh contexts).
_NA_DIRECTIVE_RE = re.compile(
r"^[ \t]*/sop-n/a[ \t]+([a-z\-_]+)(?:[ \t]+(.*))?[ \t]*$",
re.MULTILINE,
)
# ---------------------------------------------------------------------------
# Slug normalization
@@ -113,12 +102,12 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
# ---------------------------------------------------------------------------
# Comment parsing - /sop-ack and /sop-revoke
# Comment parsing /sop-ack and /sop-revoke
# ---------------------------------------------------------------------------
# A directive must be on its own line. Permits leading whitespace.
# Optional trailing note after the slug for /sop-ack and required reason
# for /sop-revoke (RFC#351 open question 4 - reason is captured but not
# for /sop-revoke (RFC#351 open question 4 reason is captured but not
# yet validated; future iteration may require a min-length).
_DIRECTIVE_RE = re.compile(
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
@@ -129,19 +118,17 @@ _DIRECTIVE_RE = re.compile(
def parse_directives(
comment_body: str,
numeric_aliases: dict[int, str],
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str]]]:
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
) -> list[tuple[str, str, str]]:
"""Extract /sop-ack and /sop-revoke directives from a comment body.
Returns a 2-tuple:
[0] ack_directives - list of (kind, canonical_slug, note) tuples where
kind is "sop-ack" or "sop-revoke"
[1] na_directives - list of (gate_name, reason) tuples (from /sop-n/a)
N/A directives are parsed by parse_na_directives() internally so callers
get both in one call.
Returns a list of (kind, canonical_slug, note) tuples where:
kind is "sop-ack" or "sop-revoke"
canonical_slug is the normalized form (or "" if unparseable)
note is the trailing free-text (may be "")
"""
out: list[tuple[str, str, str]] = []
if not comment_body:
return out, []
return out
for m in _DIRECTIVE_RE.finditer(comment_body):
kind = m.group(1)
raw_slug = (m.group(2) or "").strip()
@@ -157,10 +144,10 @@ def parse_directives(
# "comprehensive testing"), preserve normalize behavior: join
# the WHOLE first-word-token only; trailing words get appended to
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
# may have multi-word forms here - normalize handles them.
# may have multi-word forms here normalize handles them.
if len(parts) > 1:
# User wrote "/sop-ack comprehensive testing extra-note"
# -> treat "comprehensive testing" as the slug source if it
# treat "comprehensive testing" as the slug source if it
# normalizes to a known item; otherwise treat "comprehensive"
# as slug and "testing extra-note" as note. We defer the
# disambiguation to the caller via the returned canonical
@@ -172,7 +159,7 @@ def parse_directives(
# If we collapsed multi-word slug into kebab and there's a
# trailing-text group too, append it.
out.append((kind, canonical, note_from_group))
return out, parse_na_directives(comment_body)
return out
# ---------------------------------------------------------------------------
@@ -185,7 +172,7 @@ def section_marker_present(body: str, marker: str) -> bool:
on a non-empty line (i.e. the author actually filled it in).
We require the marker substring AND non-whitespace content on the
same line OR within the next line - this prevents trivially-empty
same line OR within the next line this prevents trivially-empty
checklists like:
## SOP-Checklist
@@ -252,17 +239,17 @@ def compute_ack_state(
...
}
"""
# Step 1: collapse directives per (commenter, slug) - most recent wins.
# Step 1: collapse directives per (commenter, slug) most recent wins.
# comments are expected to come in chronological order from the
# API (Gitea returns oldest-first by default for issues/{N}/comments).
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) -> kind
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) kind
unparseable_per_user: dict[str, int] = {}
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, slug, _note in parse_directives(body, numeric_aliases)[0]:
for kind, slug, _note in parse_directives(body, numeric_aliases):
if not slug:
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
continue
@@ -279,7 +266,7 @@ def compute_ack_state(
if kind != "sop-ack":
continue # revokes leave the (user,slug) state as "no ack"
if slug not in items_by_slug:
# Slug normalized to something not in our config - store
# Slug normalized to something not in our config store
# under a synthetic key for diagnostic surfacing. Don't add
# to any item.
continue
@@ -289,7 +276,7 @@ def compute_ack_state(
pending_team_check[slug].append(user)
# Step 3: team membership probe per slug (batched per slug to keep
# API call count down - same user may ack multiple items but the
# API call count down same user may ack multiple items but the
# required_teams differ per item, so we MUST probe per (user, item)).
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
for slug, candidates in pending_team_check.items():
@@ -314,115 +301,6 @@ def compute_ack_state(
}
# ---------------------------------------------------------------------------
# N/A gate computation
# ---------------------------------------------------------------------------
def parse_na_directives(
comment_body: str,
) -> list[tuple[str, str]]:
"""Extract /sop-n/a directives from a comment body.
Returns a list of (gate_name, reason) tuples.
"""
out: list[tuple[str, str]] = []
if not comment_body:
return out
for m in _NA_DIRECTIVE_RE.finditer(comment_body):
gate = (m.group(1) or "").strip()
reason = (m.group(2) or "").strip()
if gate:
out.append((gate, reason))
return out
def compute_na_state(
comments: list[dict[str, Any]],
pr_author: str,
na_gates: dict[str, dict[str, Any]],
team_membership_probe_gate: "callable[[str, list[str]], list[str]]",
) -> dict[str, dict[str, Any]]:
"""Compute per-gate N/A declaration state.
Most-recent /sop-n/a per (commenter, gate) wins.
/sop-revoke <gate> revokes that user's prior declaration.
Authors cannot self-declare N/A (fail-closed).
Returns a dict keyed by gate name:
{
"qa-review": {
"declared": True,
"declarer": "bob",
"reason": "pure-infra, no qa surface",
"rejected": {"self_declare": [], "not_in_team": []},
},
...
}
"""
# Collapse to most-recent directive per (user, gate).
latest: dict[tuple[str, str], str] = {} # (user, gate) -> kind
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
# /sop-n/a
for gate, _reason in parse_na_directives(body):
latest[(user, gate)] = "sop-n/a"
# /sop-revoke - affects any gate; most-recent wins per (user, gate)
for kind, slug, _note in parse_directives(body, {})[0]:
if kind == "sop-revoke":
# slug may be a gate name like "qa-review"
latest[(user, slug)] = "sop-revoke"
# Evaluate per gate.
result: dict[str, dict[str, Any]] = {}
for gate_name, gate_cfg in na_gates.items():
result[gate_name] = {
"declared": False,
"declarer": "",
"reason": "",
"rejected": {"self_declare": [], "not_in_team": []},
}
# Find the most-recent directive for each user for this gate.
user_directives: dict[str, str] = {} # user -> kind (sop-n/a or sop-revoke)
for (user, gate), kind in latest.items():
if gate == gate_name and user not in user_directives:
user_directives[user] = kind
valid_declarers: list[str] = []
for user, kind in user_directives.items():
if kind == "sop-revoke":
continue # revoked; no declaration from this user
# kind == "sop-n/a"
if user == pr_author:
result[gate_name]["rejected"]["self_declare"].append(user)
continue
# Probe team membership using the gate's required_teams.
candidates = [user]
approved = team_membership_probe_gate(gate_name, candidates)
if approved:
valid_declarers.extend(approved)
else:
result[gate_name]["rejected"]["not_in_team"].append(user)
if valid_declarers:
result[gate_name]["declared"] = True
result[gate_name]["declarer"] = valid_declarers[0]
# Find the reason for the winning declarer.
for c in reversed(comments):
user = (c.get("user") or {}).get("login", "")
if user == valid_declarers[0]:
for gate, reason in parse_na_directives(c.get("body", "") or ""):
if gate == gate_name:
result[gate_name]["reason"] = reason
break
break
return result
# ---------------------------------------------------------------------------
# Gitea API client
# ---------------------------------------------------------------------------
@@ -432,7 +310,7 @@ class GiteaClient:
def __init__(self, host: str, token: str):
self.base = f"https://{host}/api/v1"
self.token = token
# Cache team-name -> team-id resolutions per org.
# Cache team-name team-id resolutions per org.
self._team_id_cache: dict[tuple[str, str], int | None] = {}
def _req(
@@ -468,7 +346,7 @@ class GiteaClient:
def get_pr(self, owner: str, repo: str, pr: int) -> dict[str, Any]:
code, data = self._req("GET", f"/repos/{owner}/{repo}/pulls/{pr}")
if code != 200:
raise RuntimeError(f"GET pulls/{pr} -> HTTP {code}: {data!r}")
raise RuntimeError(f"GET pulls/{pr} HTTP {code}: {data!r}")
return data
def get_issue_comments(
@@ -484,7 +362,7 @@ class GiteaClient:
)
if code != 200:
raise RuntimeError(
f"GET issues/{issue}/comments page={page} -> HTTP {code}: {data!r}"
f"GET issues/{issue}/comments page={page} HTTP {code}: {data!r}"
)
if not data:
break
@@ -514,7 +392,7 @@ class GiteaClient:
return team_id
def is_team_member(self, team_id: int, login: str) -> bool | None:
"""Return True / False / None (unknown - 403 from API)."""
"""Return True / False / None (unknown 403 from API)."""
code, _ = self._req(
"GET", f"/teams/{team_id}/members/{urllib.parse.quote(login)}"
)
@@ -550,12 +428,12 @@ class GiteaClient:
)
if code not in (200, 201):
raise RuntimeError(
f"POST statuses/{sha} -> HTTP {code}: {data!r}"
f"POST statuses/{sha} HTTP {code}: {data!r}"
)
# ---------------------------------------------------------------------------
# Config loader (PyYAML-free - config file is intentionally tiny + flat)
# Config loader (PyYAML-free config file is intentionally tiny + flat)
# ---------------------------------------------------------------------------
@@ -645,7 +523,7 @@ def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
key = key.strip()
rest = rest.strip()
if rest == "":
# Block - could be map or list.
# Block could be map or list.
i += 1
# Look ahead for first child.
if i < n and cleaned[i][1].startswith("- "):
@@ -741,8 +619,8 @@ def render_status(
"""Return (state, description) for the commit-status post.
state is "success" if every item has at least one valid ack
(body section presence is informational only - peer-ack is the
real gate). tier:low PRs receive state="success" (soft-fail - no
(body section presence is informational only peer-ack is the
real gate). tier:low PRs receive state="success" (soft-fail no
acks required); the description carries "[info tier:low]" prefix.
"""
n = len(items)
@@ -767,7 +645,7 @@ def render_status(
shown += f", +{len(missing_body) - 3}"
desc_parts.append(f"body-unfilled: {shown}")
state = "success" if not missing and not missing_body else "failure"
return state, " - ".join(desc_parts)
return state, " ".join(desc_parts)
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
@@ -798,21 +676,12 @@ def main(argv: list[str] | None = None) -> int:
"--status-context",
default="sop-checklist / all-items-acked (pull_request)",
)
p.add_argument(
"--na-declarations-mode",
action="store_true",
help=(
"Run in N/A declarations mode instead of item-ack mode. "
"Reads /sop-n/a comments for qa-review and security-review gates "
"and posts sop-checklist / na-declarations (pull_request) status."
),
)
p.add_argument(
"--exit-on-state",
action="store_true",
help=(
"If set, exit non-zero when state=failure. Default OFF so the "
"job-level conclusion is independent of ack-state - the only "
"job-level conclusion is independent of ack-state the only "
"thing BP sees is the POSTed status. Useful for local debugging."
),
)
@@ -837,7 +706,7 @@ def main(argv: list[str] | None = None) -> int:
pr = client.get_pr(args.owner, args.repo, args.pr)
if pr.get("state") != "open":
print(f"::notice::PR #{args.pr} is {pr.get('state')} - gate is a no-op")
print(f"::notice::PR #{args.pr} is {pr.get('state')} gate is a no-op")
return 0
author = (pr.get("user") or {}).get("login", "")
@@ -858,8 +727,8 @@ def main(argv: list[str] | None = None) -> int:
def probe(slug: str, users: list[str]) -> list[str]:
item = items_by_slug[slug]
team_names: list[str] = item["required_teams"]
# Resolve names -> ids. NOTE: orgs/{org}/teams/search may not be
# available - fall back to the list endpoint.
# Resolve names ids. NOTE: orgs/{org}/teams/search may not be
# available fall back to the list endpoint.
team_ids: list[int] = []
for tn in team_names:
tid = client.resolve_team_id(args.owner, tn)
@@ -879,7 +748,7 @@ def main(argv: list[str] | None = None) -> int:
else:
print(
f"::warning::could not resolve team-id for '{tn}' "
f"in org '{args.owner}' - item '{slug}' will fail closed",
f"in org '{args.owner}' item '{slug}' will fail closed",
file=sys.stderr,
)
approved: list[str] = []
@@ -895,7 +764,7 @@ def main(argv: list[str] | None = None) -> int:
if result is None:
print(
f"::warning::team-probe for {u} in team-id {tid} returned 403 "
"(token owner not in that team - fail-closed per RFC#324)",
"(token owner not in that team fail-closed per RFC#324)",
file=sys.stderr,
)
# Treat as not-in-team for this user/team pair; loop
@@ -908,7 +777,7 @@ def main(argv: list[str] | None = None) -> int:
state, description = render_status(items, ack_state, body_state)
mode = get_tier_mode(pr, cfg)
if mode == "soft":
# tier:low: acks are informational only - post success so BP gate passes.
# tier:low: acks are informational only post success so BP gate passes.
# Description carries "[info tier:low]" prefix so reviewers know acks
# were not required (vs a tier:medium+ PR that truly passed all acks).
state = "success"
@@ -920,7 +789,7 @@ def main(argv: list[str] | None = None) -> int:
slug = it["slug"]
ackers = ack_state[slug]["ackers"]
if ackers:
print(f"::notice:: [PASS] {slug} - acked by {','.join(ackers)}")
print(f"::notice:: [PASS] {slug} acked by {','.join(ackers)}")
else:
r = ack_state[slug]["rejected"]
extras: list[str] = []
@@ -929,90 +798,7 @@ def main(argv: list[str] | None = None) -> int:
if r["not_in_team"]:
extras.append(f"not-in-team:{','.join(r['not_in_team'])}")
extra = " (" + "; ".join(extras) + ")" if extras else ""
print(f"::notice:: [WAIT] {slug} - no valid peer-ack yet{extra}")
# ── N/A declarations mode ────────────────────────────────────────────────
if args.na_declarations_mode:
na_gates = cfg.get("n/a_gates") or {}
if not na_gates:
print("::notice::--na-declarations-mode but no n/a_gates in config - no-op")
return 0
# Gate-level team-membership probe: maps gate_name -> team_names -> approved users.
def probe_gate(gate_name: str, users: list[str]) -> list[str]:
gate_cfg = na_gates.get(gate_name)
if not gate_cfg:
return []
team_names: list[str] = gate_cfg.get("required_teams", [])
team_ids: list[int] = []
for tn in team_names:
tid = client.resolve_team_id(args.owner, tn)
if tid is not None:
team_ids.append(tid)
approved: list[str] = []
for u in users:
for tid in team_ids:
cache_key = (u, tid)
if cache_key not in team_member_cache:
team_member_cache[cache_key] = client.is_team_member(tid, u)
result = team_member_cache[cache_key]
if result is True:
approved.append(u)
break
if result is None:
print(
f"::warning::team-probe for {u} in gate '{gate_name}' "
"team-id {tid} returned 403 - fail-closed",
file=sys.stderr,
)
return approved
na_state = compute_na_state(comments, author, na_gates, probe_gate)
declared_gates = [g for g, s in na_state.items() if s["declared"]]
rejected_self = {
g: s["rejected"]["self_declare"]
for g, s in na_state.items()
if s["rejected"]["self_declare"]
}
rejected_not_in_team = {
g: s["rejected"]["not_in_team"]
for g, s in na_state.items()
if s["rejected"]["not_in_team"]
}
if declared_gates:
na_desc = "N/A: " + ", ".join(sorted(declared_gates))
for g in declared_gates:
na_state_g = na_state[g]
if na_state_g["reason"]:
na_desc += f" ({na_state_g['reason']})"
break
na_state_str = "success"
else:
na_desc = "no N/A declarations"
na_state_str = "success" # always success - absence of declaration is fine
print(f"::notice::NA declarations: declared={declared_gates}")
for g, users in rejected_self.items():
print(f"::notice:: [REJECT] {g} - self-declare rejected: {users}")
for g, users in rejected_not_in_team.items():
print(f"::notice:: [REJECT] {g} - not-in-team rejected: {users}")
print(f"::notice::posting na-declarations status: state={na_state_str} desc={na_desc!r}")
if args.dry_run:
print("::notice::--dry-run: not posting status")
return 0
client.post_status(
args.owner, args.repo, head_sha,
state=na_state_str,
context="sop-checklist / na-declarations (pull_request)",
description=na_desc,
target_url=target_url,
)
print("::notice::na-declarations status posted")
return 0
print(f"::notice:: [WAIT] {slug} no valid peer-ack yet{extra}")
print(f"::notice::posting status: state={state} desc={description!r}")
@@ -1028,8 +814,8 @@ def main(argv: list[str] | None = None) -> int:
state=state, context=args.status_context,
description=description, target_url=target_url,
)
print(f"::notice::status posted: {args.status_context} -> {state}")
# By default exit 0 - the POSTed status IS the gate, NOT the job
print(f"::notice::status posted: {args.status_context} {state}")
# By default exit 0 the POSTed status IS the gate, NOT the job
# conclusion. If the job exits 1 BP will see TWO failure signals
# (one from the job's auto-status, one from our POST), making the
# description less actionable. --exit-on-state restores the old
+112 -68
View File
@@ -145,10 +145,10 @@ jobs:
# the diagnostic step with its own continue-on-error: true (line 203).
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
continue-on-error: false
# Job-level ceiling. Slow runner: golangci-lint ~10m + full test suite ~20m
# = ~30m real runtime. Set to 50m to stay safely above that while still
# catching truly runaway steps.
timeout-minutes: 50
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
# this cap catches any step that leaks past that. Set well above 10m so
# the per-step timeout is the active constraint.
timeout-minutes: 15
defaults:
run:
working-directory: workspace-server
@@ -174,23 +174,14 @@ jobs:
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
- if: always()
name: Run golangci-lint
# --no-config bypasses .golangci.yaml timeout: 3m (mc#1099).
# 20m step ceiling: slow runner takes ~10m for golangci-lint.
# continue-on-error: true so the test suite still runs when linting
# fails on the slow runner (the coverage-threshold check is the real
# hard gate; linting failures are advisory here).
continue-on-error: true
run: $(go env GOPATH)/bin/golangci-lint run --no-config --timeout 20m ./...
- if: success()
name: Diagnostic — per-package verbose 1200s
# Skip when golangci-lint fails so slow diagnostics don't push the
# job past the ceiling (mc#1099). 20m per-package timeout handles
# slow runner (~5m real per package).
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always()
name: Diagnostic — per-package verbose 60s
run: |
set +e
go test -race -v -timeout 1200s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
handlers_exit=$?
go test -race -v -timeout 1200s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
pu_exit=$?
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
tail -100 /tmp/test-handlers.log
@@ -202,11 +193,11 @@ jobs:
continue-on-error: true
- if: always()
name: Run tests with race detection and coverage
# Cold runner cache causes OOM kills at ~4m39s on the full ./... suite
# with race detection + coverage. A 30m per-step timeout lets the suite
# complete on slow runners (~20m real) while failing cleanly instead of
# OOM-killing. The job-level timeout (40m) is a backstop.
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
# full ./... suite with race detection + coverage. A 10m per-step timeout
# lets the suite complete on cold cache (~5-7m) while failing cleanly
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
- if: always()
name: Per-file coverage report
@@ -409,9 +400,9 @@ jobs:
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
# This job must run on every CI trigger (including PRs) because all-required
# needs it as a dependency. The step body exits 0 when it is not a main-push,
# giving the aggregator a concrete success instead of a skipped/missing result.
# This job must run on PRs because all-required needs it. The step exits
# 0 when it is not a main push, giving branch protection a green no-op
# instead of a skipped/missing required dependency.
needs: canvas-build
steps:
- name: Write deploy reminder to step summary
@@ -554,51 +545,104 @@ jobs:
# red silently merged through. See internal#286 for the three concrete
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
#
# Uses `needs:` so Gitea waits for all upstream jobs before this sentinel
# emits. `if: always()` ensures the sentinel runs (and reports pass/fail)
# even when an upstream job failed or was skipped. canvas-deploy-reminder
# is intentionally included — it exits 0 on non-main-push events so it
# never blocks PRs, and excluding it would leave the sentinel permanently
# pending on main pushes where reminder is a no-op.
# This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a
# job-level `if: always()` + `needs:` sentinel as skipped before upstream
# jobs settle, leaving branch protection with a permanent pending
# `CI / all-required` context. Instead, this independent sentinel polls the
# required commit-status contexts for this SHA and fails if any fail, skip,
# or never emit.
#
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
# It is an informational main-push reminder, not a PR quality gate. Keeping
# it in this dependency list lets a skipped reminder skip the required
# sentinel before the `always()` guard can emit a branch-protection status.
#
needs:
- changes
- platform-build
- canvas-build
- shellcheck
- python-lint
- canvas-deploy-reminder
if: ${{ always() }}
continue-on-error: false
runs-on: ubuntu-latest
timeout-minutes: 1
timeout-minutes: 45
steps:
- name: Verify all required jobs succeeded
- name: Wait for required CI contexts
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
API_ROOT: ${{ github.server_url }}/api/v1
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
FAILED=0
for job in changes platform-build canvas-build shellcheck python-lint canvas-deploy-reminder; do
result="$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs --jq '.jobs[] | select(.name == env.JOB) | .conclusion' 2>/dev/null || echo 'missing')"
echo "CI / ${job^}: ${result}"
case "$result" in
success) ;;
skipped)
# canvas-deploy-reminder skips on non-main-push — expected
if [ "$job" != "canvas-deploy-reminder" ]; then
echo "::error::CI / ${job} was skipped"
FAILED=1
fi
;;
'') ;;
*)
echo "::error::CI / ${job} = ${result} (expected success)"
FAILED=1
;;
esac
done
if [ "$FAILED" -ne 0 ]; then
echo ""
echo "One or more required CI jobs failed or skipped. Fix before merging."
exit 1
fi
echo "All required CI jobs passed."
python3 - <<'PY'
import json
import os
import sys
import time
import urllib.error
import urllib.request
token = os.environ["GITEA_TOKEN"]
api_root = os.environ["API_ROOT"].rstrip("/")
repo = os.environ["REPOSITORY"]
sha = os.environ["COMMIT_SHA"]
event = os.environ["EVENT_NAME"]
required = [
f"CI / Detect changes ({event})",
f"CI / Platform (Go) ({event})",
f"CI / Canvas (Next.js) ({event})",
f"CI / Shellcheck (E2E scripts) ({event})",
f"CI / Python Lint & Test ({event})",
]
terminal_bad = {"failure", "error"}
deadline = time.time() + 40 * 60
last_summary = None
def fetch_statuses():
statuses = []
for page in range(1, 6):
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=10) as resp:
chunk = json.load(resp)
if not chunk:
break
statuses.extend(chunk)
latest = {}
for item in statuses:
ctx = item.get("context")
if not ctx:
continue
prev = latest.get(ctx)
if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""):
latest[ctx] = item
return latest
while True:
try:
latest = fetch_statuses()
except (TimeoutError, OSError, urllib.error.URLError) as exc:
if time.time() >= deadline:
print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr)
sys.exit(1)
print(f"WARN: status poll failed, retrying: {exc}", flush=True)
time.sleep(15)
continue
states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required}
summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items())
if summary != last_summary:
print(summary, flush=True)
last_summary = summary
bad = {ctx: state for ctx, state in states.items() if state in terminal_bad}
if bad:
print("FAIL: required CI context failed:", file=sys.stderr)
for ctx, state in bad.items():
desc = (latest.get(ctx) or {}).get("description") or ""
print(f" - {ctx}: {state} {desc}", file=sys.stderr)
sys.exit(1)
if all(state == "success" for state in states.values()):
print(f"OK: all {len(required)} required CI contexts succeeded")
sys.exit(0)
if time.time() >= deadline:
print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr)
for ctx, state in states.items():
print(f" - {ctx}: {state}", file=sys.stderr)
sys.exit(1)
time.sleep(15)
PY
+27 -11
View File
@@ -83,25 +83,41 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Fetch all open PRs and run gate-check on each
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
# gate_check.py uses timeout=15 on every urlopen call; this catches the
# inline Python polling loop too (issue #603).
# Fetch all open PRs and run gate-check on each. This scheduled
# refresher is advisory; a transient Gitea list timeout must not turn
# main red. PR-specific gate-check runs still use normal failure
# semantics.
pr_numbers=$(python3 <<'PY'
import json
import os
import socket
import sys
import time
import urllib.error
import urllib.request
socket.setdefaulttimeout(15)
socket.setdefaulttimeout(30)
token = os.environ["GITEA_TOKEN"]
repo = os.environ["REPO"]
req = urllib.request.Request(
f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
with urllib.request.urlopen(req) as r:
prs = json.loads(r.read())
url = f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100"
last_error = None
for attempt in range(1, 4):
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
prs = json.loads(r.read())
break
except (TimeoutError, OSError, urllib.error.URLError, urllib.error.HTTPError) as exc:
last_error = exc
print(f"warning: PR list fetch attempt {attempt}/3 failed: {exc}", file=sys.stderr)
if attempt < 3:
time.sleep(2 * attempt)
else:
print(f"warning: skipped scheduled gate-check refresh; failed to list open PRs after 3 attempts: {last_error}", file=sys.stderr)
raise SystemExit(0)
for pr in prs:
print(pr["number"])
PY
@@ -86,7 +86,11 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# A full-history checkout can exceed the runner's quiet/startup
# window before the path filter emits logs. Fetch the common push
# case cheaply; the script below fetches the exact BASE SHA if it is
# not present in the shallow checkout.
fetch-depth: 2
- id: filter
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
run: |
@@ -93,7 +93,7 @@ jobs:
lint:
name: lint-continue-on-error-tracking
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 20
# Phase 3 (RFC #219 §1): surface masked defects without blocking
# PRs. Pre-existing continue-on-error: true directives on main
# all violate this lint at first — intentional. Flip to false
@@ -18,6 +18,10 @@ permissions:
pull-requests: read
statuses: write
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
cancel-in-progress: true
jobs:
dispatch:
runs-on: ubuntu-latest
+1 -37
View File
@@ -70,7 +70,7 @@ name: sop-checklist
# Cancel any in-progress runs for the same PR to prevent
# stale runs from overwriting newer status contexts.
concurrency:
group: ${{ github.repository }}-${{ github.event.pull_request.number }}
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: true
# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request)
@@ -128,39 +128,3 @@ jobs:
--pr "$PR_NUMBER" \
--config .gitea/sop-checklist-config.yaml \
--gitea-host git.moleculesai.app
# Posts `sop-checklist / na-declarations (pull_request)` when a non-author
# peer in the gate's required_teams posts `/sop-n/a <gate>`. This status
# is read by review-check.sh to waive the qa-review/security-review
# APPROVE requirement for that gate.
# Context: review-check.sh reads "sop-checklist / na-declarations (pull_request)"
# bp-required: pending #1098 ← BP PATCH tracked in mc#1098; merge without requiring new context in BP
na-declarations:
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
(contains(github.event.comment.body, '/sop-n/a') ||
contains(github.event.comment.body, '/sop-revoke')))
runs-on: ubuntu-latest
steps:
- name: Check out BASE ref (trust boundary — never PR-head)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Run sop-checklist (N/A declarations mode)
env:
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -euo pipefail
python3 .gitea/scripts/sop-checklist.py \
--owner "$OWNER" \
--repo "$REPO_NAME" \
--pr "$PR_NUMBER" \
--config .gitea/sop-checklist-config.yaml \
--gitea-host git.moleculesai.app \
--na-declarations-mode
+4
View File
@@ -61,6 +61,10 @@ on:
pull_request_review:
types: [submitted, dismissed, edited]
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
tier-check:
runs-on: ubuntu-latest
@@ -63,6 +63,31 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
}
}
func TestSessionSearch_DBError(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery("WITH session_items AS").
WillReturnError(context.DeadlineExceeded)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/session-search?q=test", bytes.NewBufferString(""))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ws-123"}}
handler.SessionSearch(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on DB error, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ---------- Activity List source filter ----------
func TestActivityList_SourceCanvas(t *testing.T) {
@@ -543,6 +543,33 @@ func TestDelegationRecord_RejectsInvalidUUID(t *testing.T) {
}
}
func TestDelegationRecord_DBInsertFails(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
h := NewDelegationHandler(wh, broadcaster)
mock.ExpectExec("INSERT INTO activity_logs").
WillReturnError(fmt.Errorf("connection refused"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
body := `{"target_id":"550e8400-e29b-41d4-a716-446655440001","task":"hello","delegation_id":"del-xyz"}`
c.Request = httptest.NewRequest("POST", "/delegations/record", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Record(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500 on DB insert failure, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestDelegationUpdateStatus_CompletedInsertsResultRow(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -646,8 +646,12 @@ const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path.
# external machine today, pair with the Python SDK tab.
# 1. Install openclaw CLI + the workspace runtime wheel:
# The version pin (>=0.1.999) ensures the "molecule-mcp" console
# script is present — it is what keeps the workspace ALIVE on canvas
# (register-on-startup + 20s heartbeat). Older versions only ship
# a2a_mcp_server which does not heartbeat.
npm install -g openclaw@latest
pip install molecule-ai-workspace-runtime
pip install "molecule-ai-workspace-runtime>=0.1.999"
# 2. Onboard openclaw against your model provider (one-time setup).
# --non-interactive needs an explicit --provider + --model so it
@@ -287,7 +287,7 @@ func TestRenderCategoryRoutingYAML_StableOrdering(t *testing.T) {
if ai <= 0 || zi <= 0 || mi <= 0 {
t.Fatalf("could not locate all keys in output: %s", out)
}
if !(ai < mi && mi < zi) {
if ai >= mi || mi >= zi {
t.Errorf("keys not sorted: alpha=%d middle=%d zebra=%d, output:\n%s", ai, mi, zi, out)
}
}