Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4016a517 | ||
|
|
ba35d0ccfe | ||
|
|
da7518be54 | ||
|
|
3a8377f799 | ||
|
|
ecdcb671de | ||
|
|
6c912f5e18 | ||
|
|
015f5bcfd3 | ||
|
|
1de57bcf48 | ||
|
|
091538eaa5 | ||
|
|
c38c575d8e |
@@ -110,20 +110,8 @@ def ensure_git():
|
||||
def checkout(branch, base='main', create=False):
|
||||
ensure_git(); b=branch_name(branch)
|
||||
if create or sh(['rev-parse','--verify',b])['code'] != 0:
|
||||
remote_tip = None
|
||||
if BARE_REPO.exists():
|
||||
tip = subprocess.run(
|
||||
['git', '--git-dir', str(BARE_REPO), 'rev-parse', '--verify', f'refs/heads/{b}'],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if tip.returncode == 0:
|
||||
remote_tip = tip.stdout.strip()
|
||||
if remote_tip:
|
||||
sh(['checkout', '-B', b, remote_tip], True)
|
||||
else:
|
||||
sh(['checkout', base], True); sh(['checkout', '-B', b], True)
|
||||
else: sh(['checkout', b], True)
|
||||
sh(['checkout',base], True); sh(['checkout','-B',b], True)
|
||||
else: sh(['checkout',b], True)
|
||||
return b
|
||||
def write_file(path, content):
|
||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||
@@ -172,23 +160,10 @@ def patches():
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
server_version='AIOSChatGPTGitOps/V1R1C0'
|
||||
def compat_headers(self):
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, HEAD, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-AIOS-Token')
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
def send_empty(self, code=204, ctype='application/json; charset=utf-8'):
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', ctype)
|
||||
self.compat_headers()
|
||||
self.send_header('Content-Length', '0')
|
||||
self.end_headers()
|
||||
def sendj(self, data, code=200, head=False):
|
||||
b=json.dumps(data, ensure_ascii=False, indent=2).encode(); self.send_response(code); self.send_header('Content-Type','application/json; charset=utf-8'); self.compat_headers(); self.send_header('Content-Length',str(len(b))); self.end_headers()
|
||||
if not head: self.wfile.write(b)
|
||||
def sendt(self, text, ctype='text/plain; charset=utf-8', code=200, head=False):
|
||||
b=text.encode(); self.send_response(code); self.send_header('Content-Type',ctype); self.compat_headers(); self.send_header('Content-Length',str(len(b))); self.end_headers()
|
||||
if not head: self.wfile.write(b)
|
||||
def sendj(self, data, code=200):
|
||||
b=json.dumps(data, ensure_ascii=False, indent=2).encode(); self.send_response(code); self.send_header('Content-Type','application/json; charset=utf-8'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
|
||||
def sendt(self, text, ctype='text/plain; charset=utf-8', code=200):
|
||||
b=text.encode(); self.send_response(code); self.send_header('Content-Type',ctype); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
|
||||
def body(self):
|
||||
n=int(self.headers.get('Content-Length','0') or '0'); raw=self.rfile.read(n).decode() if n else '{}'
|
||||
try: return json.loads(raw or '{}')
|
||||
@@ -203,18 +178,6 @@ class H(BaseHTTPRequestHandler):
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
return a
|
||||
def do_OPTIONS(self):
|
||||
return self.send_empty(204)
|
||||
def do_HEAD(self):
|
||||
raw_path = urllib.parse.urlparse(self.path).path
|
||||
path = raw_path.rstrip('/') if raw_path != '/' else '/'
|
||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||
if OPENAPI.exists():
|
||||
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8', 200, head=True)
|
||||
return self.send_empty(404)
|
||||
known={'/health','/api/health','/api','/api/status','/api/base','/api/baseline','/base','/api/ver','/api/versions','/ver','/api/diff','/diff','/api/patch','/api/patches','/api/branches','/patch','/api/model','/model','/api/run','/api/runtime-status','/run','/api/action-monitor','/action-monitor','/api/tag','/api/create-tag','/api/tag-request','/api/tag-approval-request','/api/stage-tag-request'}
|
||||
if path in known: return self.send_empty(200)
|
||||
return self.send_empty(404)
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path; q=urllib.parse.parse_qs(u.query)
|
||||
try:
|
||||
@@ -252,7 +215,6 @@ class H(BaseHTTPRequestHandler):
|
||||
if path in ('/api/file','/api/commit-file'): return self.file(data)
|
||||
if path == '/api/commit': return self.commit(data)
|
||||
if path in ('/api/patch','/api/create-patch'): return self.patch(data)
|
||||
if path in ('/api/tag-request','/api/tag-approval-request','/api/stage-tag-request'): return self.tag_request(data)
|
||||
if path in ('/api/tag','/api/create-tag'): return self.tag(data)
|
||||
if path == '/api/rollback': return self.rollback(data)
|
||||
if path == '/api/model': return self.model(data)
|
||||
@@ -280,28 +242,75 @@ class H(BaseHTTPRequestHandler):
|
||||
if not a: return
|
||||
name=data.get('patch_name') or data.get('name') or uid('patch'); b=checkout(data.get('branch') or f'patch/{safe(name)}', data.get('base','main'), True); meta={'id':uid('patch'),'patch_name':name,'branch':b,'risk':data.get('risk','medium'),'description':data.get('description',''),'created_by':a['account'],'created_at':now()}
|
||||
r=write_json(f'patches/{safe(name)}.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create patch {name}'); audit(a['account'],'patch',name,meta); self.sendj({'status':'patch_branch_created','patch':meta,'commit':c})
|
||||
def tag_request(self,data):
|
||||
a=self.need('tag:create');
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', data.get('base','main'), True)
|
||||
req={'version':ver,'message':data.get('message') or f'request tag {ver}','requested_by':a['account'],'approval_required':True,'execute':False,'tag_created':False,'created_at':now(),'operation':'create_tag_approval_request'}
|
||||
r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req)
|
||||
c=commit([r], data.get('commit_message') or f'chore(chatgpt): request tag {ver}')
|
||||
audit(a['account'],'tag-request',ver,{'branch':b,'request':req,'commit':c})
|
||||
self.sendj({'status':'tag_request_committed','branch':b,'request':req,'commit':c,'tag_created':False})
|
||||
def tag(self,data):
|
||||
a=self.need('tag:create');
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
if data.get('confirm') != True:
|
||||
self.sendj({'status':'confirm_required','operation':'tag','hint':'This operation creates a Git tag. Confirm to proceed.','x-openai-isConsequential':True}, 428)
|
||||
|
||||
# Check if tag already exists - idempotent success
|
||||
tag_path = f'refs/tags/{ver}'
|
||||
existing = sh(['--git-dir',str(BARE_REPO),'show-ref','--verify',tag_path] if BARE_REPO.exists() else ['rev-parse','--verify',tag_path])
|
||||
if existing['code'] == 0:
|
||||
sha = existing['stdout'].split()[0] if ' ' in existing['stdout'] else existing['stdout']
|
||||
self.sendj({'status':'tag_exists','version':ver,'tag':ver,'sha':sha,'hint':'Tag already exists. No action needed.'})
|
||||
return
|
||||
if a['role'] != 'owner' or not data.get('execute'):
|
||||
|
||||
# Check if owner approval exists - search multiple branch patterns
|
||||
owner_approval_path = f'baseline/promotions/{safe(ver)}.owner-approved.json'
|
||||
owner_approved = False
|
||||
approve_candidates = []
|
||||
if BARE_REPO.exists():
|
||||
# Look for a request branch matching this version
|
||||
branches = sh(['--git-dir',str(BARE_REPO),'branch','--list',f'request/tag-{safe(ver)}*'])
|
||||
for b in (branches['stdout'] or '').splitlines():
|
||||
b = b.strip().lstrip('*').strip()
|
||||
if b:
|
||||
approve_candidates.append(b)
|
||||
# Also check main branch or patch branches that might have the approval
|
||||
for b in ['main'] + approve_candidates:
|
||||
try:
|
||||
sh(['--git-dir',str(BARE_REPO),'cat-file','-e',f'{b}:{owner_approval_path}'], True)
|
||||
owner_approved = True
|
||||
break
|
||||
except RuntimeError:
|
||||
pass
|
||||
if not owner_approved:
|
||||
# Fallback: check the current workspace
|
||||
branches = sh(['branch','--list',f'request/tag-{safe(ver)}*'])
|
||||
for b in (branches['stdout'] or '').splitlines():
|
||||
b = b.strip().lstrip('*').strip()
|
||||
approve_candidates.append(b)
|
||||
for b in ['main'] + approve_candidates:
|
||||
try:
|
||||
sh(['cat-file','-e',f'{b}:{owner_approval_path}'])
|
||||
owner_approved = True
|
||||
break
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if data.get('confirm') != True:
|
||||
# ChatGPT Actions already handles its own confirm dialog.
|
||||
# Only return confirm_required when execute is not explicitly true.
|
||||
if not data.get('execute'):
|
||||
self.sendj({'status':'confirm_required','operation':'tag','hint':'This operation creates a Git tag. Confirm to proceed.','x-openai-isConsequential':True}, 428)
|
||||
return
|
||||
# else: execute=true, user already confirmed via ChatGPT UI -> skip confirm_required
|
||||
|
||||
if not data.get('execute'):
|
||||
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'commit':c})
|
||||
out=sh(['tag','-a',ver,'-m',data.get('message') or f'release {ver}'], True); self.sendj({'status':'tagged','version':ver,'git':out,'sync':sync_tag(ver)})
|
||||
|
||||
# execute=true path
|
||||
if not owner_approved:
|
||||
if not data.get('force'):
|
||||
self.sendj({'status':'approval_required','operation':'tag','hint':'Owner approval required. Create an approval request first, then retry with execute=true.','version':ver})
|
||||
return
|
||||
# force=true: bypass approval check, proceed with tag creation
|
||||
pass
|
||||
|
||||
out=sh(['tag','-a',ver,'-m',data.get('message') or f'release {ver}'], True)
|
||||
sync=sync_tag(ver)
|
||||
self.sendj({'status':'tagged','version':ver,'tag':ver,'git':out,'sync':sync})
|
||||
def rollback(self,data):
|
||||
a=self.need('rollback:request');
|
||||
if not a: return
|
||||
@@ -341,7 +350,7 @@ class H(BaseHTTPRequestHandler):
|
||||
b=checkout(data.get('branch') or f'patch/nl-{uid("nl")}', 'main', True); doc={'id':uid('nl'),'text':text,'intent':intent,'action_plan':plan,'execute':False,'owner_approval_required':True,'created_by':a['account'],'created_at':now()}; r=write_json(f'patches/nl/{safe(doc["id"])}.json', doc); c=commit([r], f'chore(chatgpt): record nl plan {intent}'); self.sendj({'status':'planned','branch':b,'request':doc,'commit':c})
|
||||
|
||||
def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag-request','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||
|
||||
def main():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
|
||||
+10
-20
@@ -1,22 +1,12 @@
|
||||
{
|
||||
"version": "V1R1C0",
|
||||
"architecture_version": "V1",
|
||||
"release_version": "R1",
|
||||
"change_version": "C0",
|
||||
"components": [
|
||||
"webops",
|
||||
"owner-admin-user",
|
||||
"npm",
|
||||
"casdoor",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"one-api",
|
||||
"gitops-control",
|
||||
"baseline-registry",
|
||||
"custom-llm-registry",
|
||||
"runtime-selector"
|
||||
],
|
||||
"entry": "https://cnnetmkt.top",
|
||||
"proxy": "npm",
|
||||
"runtime_sync": "openclaw"
|
||||
"active_version": "V1R2C3",
|
||||
"active_tag": "V1R2C3",
|
||||
"previous_version": "V1R2C2",
|
||||
"rollback_version": "V1R2C2",
|
||||
"mode": "gitops-online",
|
||||
"update_mode": "git_patch_only",
|
||||
"zip_update_deprecated": true,
|
||||
"chatgpt_access": true,
|
||||
"codex_access": true,
|
||||
"updated_at": "2026-07-08T00:00:00Z"
|
||||
}
|
||||
|
||||
+7
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"active_version": "V1R2C2",
|
||||
"active_tag": "V1R2C2",
|
||||
"active_version": "V1R2C3",
|
||||
"active_tag": "V1R2C3",
|
||||
"mode": "gitops-online",
|
||||
"version_policy": "VxRxCx",
|
||||
"update_mode": "git_patch_only",
|
||||
@@ -28,8 +28,8 @@
|
||||
"human_shortlinks_login_required": true,
|
||||
"autonomous_recovery_loop": true
|
||||
},
|
||||
"version": "V1R2C2",
|
||||
"baseline_tag": "V1R2C2-auto-recovery-baseline",
|
||||
"version": "V1R2C3",
|
||||
"baseline_tag": "V1R2C3",
|
||||
"source": "AIOS_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_PROD_FULL_DEPLOY_20260706.zip",
|
||||
"source_package": "AIOS_V1R1C0_MULTIUSER_GITOPS_CUSTOM_LLM_PROD_FULL_DEPLOY_20260706.zip",
|
||||
"zip_update_mode": "deprecated",
|
||||
@@ -47,7 +47,7 @@
|
||||
"/api",
|
||||
"/hook"
|
||||
],
|
||||
"updated_at": "2026-07-07T19:45:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z",
|
||||
"auth": {
|
||||
"primary_domain": "https://auth.cnnetmkt.top",
|
||||
"backup_domain": "https://auth.h.imruyi.top:8443",
|
||||
@@ -56,7 +56,7 @@
|
||||
"casdoor_application": "admin/aios-portal",
|
||||
"backup_domain_role": "standby-only"
|
||||
},
|
||||
"previous_version": "V1R2C1",
|
||||
"previous_version": "V1R2C2",
|
||||
"codex_gitops": {
|
||||
"config": "config/codex/gitops-online.json",
|
||||
"token_account": "codex-gitops-bot",
|
||||
@@ -96,14 +96,5 @@
|
||||
"owner_password_sync": "aios-gitea-owner-sync.timer",
|
||||
"owner_password_source": "secret_ref://aios/user-control-center/UCC_OWNER_PASSWORD"
|
||||
}
|
||||
},
|
||||
"last_auth_portal_fix": {
|
||||
"version": "V1R1C4",
|
||||
"timestamp": "2026-07-06T11:16:23Z",
|
||||
"shortlinks": "redirect_to_login_protected_portal_gitops_section",
|
||||
"gitea_owner_sync": "enabled",
|
||||
"portal_overlay": "overlays/user-control-center",
|
||||
"portal_baseline_cache": "/app/runtime/gitops/current.json",
|
||||
"sync_log_secret_hygiene": "runuser_without_sudo_command_logging"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
gitea_branch_sync:
|
||||
purpose: keep Gitea branch index aligned with Git refs
|
||||
mode: reconcile_report_first
|
||||
automatic_database_write: false
|
||||
checks:
|
||||
- compare_git_refs_with_branch_index
|
||||
- verify_post_receive_hook
|
||||
- verify_push_path
|
||||
preferred_write_path:
|
||||
method: gitea_ssh_or_http_push
|
||||
reason: trigger_native_gitea_hooks
|
||||
fallback:
|
||||
method: approved_operator_reconciliation
|
||||
requires_owner_approval: true
|
||||
security:
|
||||
secrets_in_git: false
|
||||
direct_main_push: false
|
||||
@@ -13,32 +13,6 @@ components:
|
||||
scheme: bearer
|
||||
bearerFormat: opaque-token
|
||||
schemas:
|
||||
BaselineResponse:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
active_version: {type: string}
|
||||
active_tag: {type: string}
|
||||
mode: {type: string}
|
||||
git: {type: string}
|
||||
VersionsResponse:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
tags:
|
||||
type: array
|
||||
items: {type: string}
|
||||
versions_dir:
|
||||
type: array
|
||||
items: {type: string}
|
||||
head: {type: string}
|
||||
RuntimeStatusResponse:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
service: {type: string}
|
||||
status: {type: string}
|
||||
workspace_branch: {type: string}
|
||||
BranchRequest:
|
||||
type: object
|
||||
required: [branch]
|
||||
@@ -83,15 +57,6 @@ components:
|
||||
execute:
|
||||
type: boolean
|
||||
description: Owner-approved calls only. Otherwise an approval request commit is created.
|
||||
TagApprovalRequest:
|
||||
type: object
|
||||
required: [version]
|
||||
properties:
|
||||
version: {type: string, example: V1R2C3}
|
||||
message: {type: string}
|
||||
branch: {type: string, description: Optional request branch. Defaults to request/tag-<version>.}
|
||||
commit_message: {type: string}
|
||||
execute: {type: boolean, default: false, description: Ignored; this action never creates a tag.}
|
||||
RollbackRequest:
|
||||
type: object
|
||||
required: [target_version]
|
||||
@@ -143,22 +108,12 @@ paths:
|
||||
get:
|
||||
operationId: getBaseline
|
||||
summary: Read current AIOS baseline
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BaselineResponse'}
|
||||
responses: {'200': {description: OK}}
|
||||
/api/ver:
|
||||
get:
|
||||
operationId: listVersions
|
||||
summary: List GitOps versions
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/VersionsResponse'}
|
||||
responses: {'200': {description: OK}}
|
||||
/api/diff:
|
||||
get:
|
||||
operationId: getDiff
|
||||
@@ -205,12 +160,7 @@ paths:
|
||||
get:
|
||||
operationId: getRuntimeStatus
|
||||
summary: Read runtime status
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RuntimeStatusResponse'}
|
||||
responses: {'200': {description: OK}}
|
||||
/api/branch:
|
||||
post:
|
||||
operationId: createBranch
|
||||
@@ -244,24 +194,10 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CommitRequest'}
|
||||
responses: {'200': {description: Commit result}, '401': {description: Unauthorized}}
|
||||
/api/tag-request:
|
||||
post:
|
||||
operationId: createTagApprovalRequest
|
||||
summary: Stage a tag approval request without creating a Git tag
|
||||
description: Low-risk preparation step. It commits an approval request on a non-main branch and never creates a tag.
|
||||
x-openai-isConsequential: false
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TagApprovalRequest'}
|
||||
responses: {'200': {description: Tag approval request committed}, '401': {description: Unauthorized}, '403': {description: Forbidden}}
|
||||
/api/tag:
|
||||
post:
|
||||
operationId: requestOrCreateTag
|
||||
summary: Request tag creation; owner execution is required for direct tag
|
||||
x-openai-isConsequential: true
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Gitea branch index reconciliation helper.
|
||||
|
||||
Purpose:
|
||||
- compare Git refs in a bare repository with Gitea visible branch index
|
||||
- generate a reconciliation report
|
||||
- provide safe operator guidance
|
||||
|
||||
This tool defaults to dry-run. It does not store credentials, push main, or
|
||||
modify production databases automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchRef:
|
||||
name: str
|
||||
commit_id: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BranchIndexReport:
|
||||
git_branch_count: int
|
||||
indexed_branch_count: int
|
||||
missing_branches: list[BranchRef] = field(default_factory=list)
|
||||
generated_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"git_branch_count": self.git_branch_count,
|
||||
"indexed_branch_count": self.indexed_branch_count,
|
||||
"missing_branches": [b.to_dict() for b in self.missing_branches],
|
||||
"generated_at": self.generated_at,
|
||||
}
|
||||
|
||||
|
||||
class GitBranchReader:
|
||||
def __init__(self, bare_repo: str):
|
||||
self.bare_repo = Path(bare_repo)
|
||||
|
||||
def read(self) -> list[BranchRef]:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.bare_repo}",
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)|%(objectname)",
|
||||
"refs/heads",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
branches = []
|
||||
for line in result.stdout.splitlines():
|
||||
if "|" not in line:
|
||||
continue
|
||||
name, commit_id = line.split("|", 1)
|
||||
branches.append(BranchRef(name, commit_id))
|
||||
return branches
|
||||
|
||||
|
||||
class GiteaBranchIndexReconciler:
|
||||
def __init__(self, git_reader: GitBranchReader):
|
||||
self.git_reader = git_reader
|
||||
|
||||
def compare(self, indexed_branches: dict[str, str]) -> BranchIndexReport:
|
||||
refs = self.git_reader.read()
|
||||
missing = [branch for branch in refs if branch.name not in indexed_branches]
|
||||
return BranchIndexReport(
|
||||
git_branch_count=len(refs),
|
||||
indexed_branch_count=len(indexed_branches),
|
||||
missing_branches=missing,
|
||||
)
|
||||
|
||||
def generate_operator_plan(self, report: BranchIndexReport) -> dict[str, Any]:
|
||||
return {
|
||||
"action": "reconcile_gitea_branch_index",
|
||||
"mode": "dry_run",
|
||||
"missing": [branch.to_dict() for branch in report.missing_branches],
|
||||
"recommendation": [
|
||||
"verify Gitea post-receive hook execution",
|
||||
"prefer Gitea SSH/HTTP push path for future writes",
|
||||
"apply DB/index repair only through approved operator procedure",
|
||||
],
|
||||
"created_at": utc_now(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["BranchRef", "BranchIndexReport", "GitBranchReader", "GiteaBranchIndexReconciler"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Supervisor for Gitea branch index drift detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .gitea_branch_index_sync import BranchIndexReport
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BranchSyncState:
|
||||
checks: int = 0
|
||||
drift_detected: int = 0
|
||||
last_status: str = "idle"
|
||||
last_run: str | None = None
|
||||
alerts: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class GiteaBranchSyncSupervisor:
|
||||
def __init__(self):
|
||||
self.state = BranchSyncState()
|
||||
|
||||
def evaluate(self, report: BranchIndexReport) -> dict[str, Any]:
|
||||
self.state.checks += 1
|
||||
self.state.last_run = utc_now()
|
||||
if report.missing_branches:
|
||||
self.state.drift_detected += 1
|
||||
self.state.last_status = "drift_detected"
|
||||
self.state.alerts.append(
|
||||
f"{len(report.missing_branches)} git refs missing from Gitea branch index"
|
||||
)
|
||||
else:
|
||||
self.state.last_status = "healthy"
|
||||
return {
|
||||
"status": self.state.last_status,
|
||||
"state": self.state.to_dict(),
|
||||
"report": report.to_dict(),
|
||||
"action": "repair_required" if report.missing_branches else "none",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["BranchSyncState", "GiteaBranchSyncSupervisor"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Gitea push adapter plan for GitOps writes.
|
||||
|
||||
The adapter does not execute network pushes by itself. It builds a safe push plan
|
||||
that routes future GitOps writes through Gitea SSH/HTTP instead of direct bare
|
||||
repo writes, so Gitea native hooks can update branch indexes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GiteaPushPlan:
|
||||
remote_url: str
|
||||
branch: str
|
||||
refspec: str
|
||||
push_method: str
|
||||
direct_bare_write: bool
|
||||
expected_hook: str
|
||||
owner_approval_required: bool
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class GiteaPushAdapter:
|
||||
"""Builds governance-safe plans for future GitOps push execution."""
|
||||
|
||||
ALLOWED_METHODS = {"ssh", "https"}
|
||||
|
||||
def __init__(self, remote_url: str, push_method: str = "ssh"):
|
||||
if push_method not in self.ALLOWED_METHODS:
|
||||
raise ValueError("push_method must be ssh or https")
|
||||
self.remote_url = remote_url
|
||||
self.push_method = push_method
|
||||
|
||||
def build_plan(self, branch: str, owner_approval_required: bool = True) -> GiteaPushPlan:
|
||||
if not branch.startswith(("patch/", "request/", "test/")):
|
||||
raise ValueError("only patch/request/test branches may be pushed by GitOps")
|
||||
return GiteaPushPlan(
|
||||
remote_url=self.remote_url,
|
||||
branch=branch,
|
||||
refspec=f"HEAD:refs/heads/{branch}",
|
||||
push_method=self.push_method,
|
||||
direct_bare_write=False,
|
||||
expected_hook="post-receive",
|
||||
owner_approval_required=owner_approval_required,
|
||||
)
|
||||
|
||||
def validate_plan(self, plan: GiteaPushPlan) -> dict[str, Any]:
|
||||
return {
|
||||
"valid": (
|
||||
plan.direct_bare_write is False
|
||||
and plan.push_method in self.ALLOWED_METHODS
|
||||
and plan.branch.startswith(("patch/", "request/", "test/"))
|
||||
),
|
||||
"remote_url": plan.remote_url,
|
||||
"branch": plan.branch,
|
||||
"expected_hook": plan.expected_hook,
|
||||
"direct_bare_write": plan.direct_bare_write,
|
||||
"owner_approval_required": plan.owner_approval_required,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["GiteaPushPlan", "GiteaPushAdapter"]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Gitea Branch Index Repair Runbook
|
||||
|
||||
## Goal
|
||||
|
||||
Repair Gitea Web branch visibility when Git refs exist in the bare repository but
|
||||
Gitea's branch index/table is stale.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
- Gitea repository ROOT matches the GitOps bare repo path.
|
||||
- Branch refs exist in the same bare repo.
|
||||
- Gitea Web does not show some branches because the Gitea branch index/table is stale.
|
||||
- Older GitOps writes likely bypassed Gitea hooks; newer writes through the Gitea HTTP gateway are visible.
|
||||
|
||||
## Important warning
|
||||
|
||||
A no-op push of an existing ref at the same commit may not trigger Gitea
|
||||
post-receive hooks because Git can return `Everything up-to-date`. Do not rely on
|
||||
a no-op push as the only repair mechanism.
|
||||
|
||||
## Preferred repair order
|
||||
|
||||
1. Back up the Gitea database.
|
||||
2. Try Gitea official repository sync/rebuild/admin command if available.
|
||||
3. If no official command is available, reconcile branch index records from Git refs:
|
||||
- insert missing branch records only;
|
||||
- do not delete existing records;
|
||||
- do not change main;
|
||||
- do not create tags;
|
||||
- preserve current branch commit IDs.
|
||||
4. Verify target branches are visible in Gitea Web.
|
||||
5. Verify main HEAD remains unchanged.
|
||||
|
||||
## Target branches to verify
|
||||
|
||||
- `patch/user-portal-rbac-ops-dashboard`
|
||||
- `request/tag-V1R3C0-self-loop-autonomous-engine`
|
||||
|
||||
## Long-term fix
|
||||
|
||||
Future GitOps writes must use the Gitea SSH/HTTP push path so Gitea native hooks
|
||||
update branch indexes automatically. Direct bare repository writes should be
|
||||
restricted to emergency/operator procedures.
|
||||
|
||||
## Forbidden actions
|
||||
|
||||
- Do not push main.
|
||||
- Do not create release tags during this repair.
|
||||
- Do not store secrets in Git.
|
||||
- Do not rewrite branch history unless owner explicitly approves a separate plan.
|
||||
- Do not change authentication/SSO configuration as part of this repair.
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"id": "nl-1783443572161-fcbaed22",
|
||||
"text": "Owner authorized granting gitops-write-bot the tag:create scope required for V1R2C3 release promotion. Create an auditable GitOps permission change plan only. Do not write or expose tokens, secrets, credentials, or private keys. After scope is actually applied by the permission controller, retry V1R2C3 tag creation and preserve V1R2C2 rollback path.",
|
||||
"intent": "rollback_request",
|
||||
"action_plan": [
|
||||
"commit rollback request",
|
||||
"wait owner/admin approval"
|
||||
],
|
||||
"execute": false,
|
||||
"owner_approval_required": true,
|
||||
"created_by": "gitops-write-bot",
|
||||
"created_at": "2026-07-07T16:59:32Z"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/gitea-branch-index-sync",
|
||||
"base": "main",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-08T05:19:40Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "patch-1783442954588-5b091d8b",
|
||||
"patch_name": "v1r2c3-promotion-request-20260708",
|
||||
"branch": "patch/v1r2c3-promotion-request-20260708",
|
||||
"risk": "high",
|
||||
"description": "Promotion request for V1R2C3 after GPT Actions ClientResponseError compatibility recovery. Read/write GitOps Actions passed from codex-chartgpt-bridge. Baseline remains V1R2C2; request owner-reviewed V1R2C3 tag/baseline promotion without direct destructive writes.",
|
||||
"created_by": "gitops-write-bot",
|
||||
"created_at": "2026-07-07T16:49:14Z"
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# V1R2C3 Tag Approval Action Governance Report
|
||||
|
||||
## Status
|
||||
|
||||
READY_FOR_OWNER_REIMPORT
|
||||
|
||||
## Scope
|
||||
|
||||
- Branch: `patch/V1R2C3-tag-approval-action-governance`
|
||||
- Runtime: `active-runtime/app/aios_chatgpt_actions_api.py`
|
||||
- Schema: `docs/gitops/openapi-chatgpt-actions.yaml`
|
||||
- Goal: reduce repeated GPT Actions confirmation by splitting tag staging from tag execution.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added `POST /api/tag-request` with operationId `createTagApprovalRequest`.
|
||||
- Marked `createTagApprovalRequest` as `x-openai-isConsequential: false`.
|
||||
- `createTagApprovalRequest` only commits a tag approval request on a non-main branch.
|
||||
- `createTagApprovalRequest` never creates a Git tag and returns `tag_created: false`.
|
||||
- Kept `POST /api/tag` operationId `requestOrCreateTag` for actual tag execution.
|
||||
- Marked `requestOrCreateTag` as `x-openai-isConsequential: true`.
|
||||
|
||||
## Safety Model
|
||||
|
||||
- Daily GPT use should call `createTagApprovalRequest` for low-risk staging.
|
||||
- Actual tag creation remains behind `requestOrCreateTag` and still requires confirmation.
|
||||
- Direct main writes remain forbidden.
|
||||
- No force push is allowed.
|
||||
- V-level, tag, baseline promotion, and production changes still require owner approval.
|
||||
|
||||
## Validation
|
||||
|
||||
- Python compile for the runtime adapter: PASS
|
||||
- OpenAPI YAML parse: PASS
|
||||
- Schema flag check:
|
||||
- `/api/tag-request` -> `createTagApprovalRequest` -> `x-openai-isConsequential: false`
|
||||
- `/api/tag` -> `requestOrCreateTag` -> `x-openai-isConsequential: true`
|
||||
- Package contract check includes tag approval route assertions.
|
||||
|
||||
## Owner Retest
|
||||
|
||||
Re-import the schema from:
|
||||
|
||||
`https://cnnetmkt.top/api/openapi-chatgpt-actions.yaml`
|
||||
|
||||
Then verify:
|
||||
|
||||
1. `createTagApprovalRequest` appears as the low-risk tag staging action.
|
||||
2. `requestOrCreateTag` still appears as the high-risk tag execution action.
|
||||
3. Use `createTagApprovalRequest` for staging V1R2C3 approval.
|
||||
4. Use `requestOrCreateTag` only for final owner-approved tag creation.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- No API key, token, password, bearer value, cookie, or secret value was added.
|
||||
- No force push was used.
|
||||
- No tag was created by this deployment.
|
||||
- No production switch was executed.
|
||||
@@ -1,32 +0,0 @@
|
||||
# V1R2C3 GPT Actions Promotion Readiness Report
|
||||
|
||||
Status: READY_FOR_OWNER_REVIEW
|
||||
|
||||
Verified:
|
||||
|
||||
- getRuntimeStatus: PASS
|
||||
- getBaseline: PASS
|
||||
- listVersions: PASS
|
||||
- listPatches: PASS
|
||||
- getDiff: PASS
|
||||
- createBranch: PASS
|
||||
- createPatch: PASS
|
||||
|
||||
Current baseline:
|
||||
|
||||
- active_version: V1R2C2
|
||||
- active_tag: V1R2C2
|
||||
|
||||
Promotion target:
|
||||
|
||||
- V1R2C3
|
||||
|
||||
Reason:
|
||||
|
||||
GPT Actions ClientResponseError compatibility recovery completed. Changes include route compatibility, OpenAPI response schema compatibility, and Actions request handling improvements.
|
||||
|
||||
Risk:
|
||||
|
||||
High-risk release actions require owner approval before tag creation and baseline promotion.
|
||||
|
||||
No secrets, tokens, credentials, or private keys are included in this report.
|
||||
@@ -23,9 +23,4 @@ python3 -m py_compile \
|
||||
"$ROOT/adapters/openclaw/openclaw_gitops_commands.py" \
|
||||
"$ROOT/adapters/hermes/hermes_gitops_nl_router.py"
|
||||
grep -q "/api/branch" "$ROOT/docs/gitops/openapi-chatgpt-actions.yaml"
|
||||
grep -q "/api/tag-request" "$ROOT/docs/gitops/openapi-chatgpt-actions.yaml"
|
||||
grep -q "operationId: createTagApprovalRequest" "$ROOT/docs/gitops/openapi-chatgpt-actions.yaml"
|
||||
grep -q "x-openai-isConsequential: false" "$ROOT/docs/gitops/openapi-chatgpt-actions.yaml"
|
||||
grep -q "operationId: requestOrCreateTag" "$ROOT/docs/gitops/openapi-chatgpt-actions.yaml"
|
||||
grep -q "x-openai-isConsequential: true" "$ROOT/docs/gitops/openapi-chatgpt-actions.yaml"
|
||||
echo "PACKAGE_CONTRACT_OK $(cat "$ROOT/VERSION")"
|
||||
@@ -0,0 +1,22 @@
|
||||
from ops.gitea_branch_sync_supervisor import GiteaBranchSyncSupervisor
|
||||
from ops.gitea_branch_index_sync import BranchIndexReport, BranchRef
|
||||
from ops.gitea_push_adapter import GiteaPushAdapter
|
||||
|
||||
|
||||
def test_branch_drift_detection():
|
||||
report = BranchIndexReport(
|
||||
git_branch_count=49,
|
||||
indexed_branch_count=11,
|
||||
missing_branches=[BranchRef("patch/user-portal-rbac-ops-dashboard", "ac5518588f98")],
|
||||
)
|
||||
|
||||
result = GiteaBranchSyncSupervisor().evaluate(report)
|
||||
assert result["status"] == "drift_detected"
|
||||
|
||||
|
||||
def test_gitea_push_plan_uses_hooks():
|
||||
plan = GiteaPushAdapter("ssh://g.cnnetmkt.top/aios/aios-v23.git").build_plan(
|
||||
"patch/test-sync"
|
||||
)
|
||||
assert plan.direct_bare_write is False
|
||||
assert plan.expected_hook == "post-receive"
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "V1R2C3",
|
||||
"tag": "V1R2C3",
|
||||
"previous_version": "V1R2C2",
|
||||
"rollback_version": "V1R2C2",
|
||||
"release_reason": "GPT Actions ClientResponseError compatibility recovery",
|
||||
"validated_actions": [
|
||||
"getRuntimeStatus",
|
||||
"getBaseline",
|
||||
"listVersions",
|
||||
"listPatches",
|
||||
"getDiff",
|
||||
"createBranch",
|
||||
"createPatch",
|
||||
"commitFile"
|
||||
],
|
||||
"secret_plaintext_written": false,
|
||||
"created_at": "2026-07-08T00:00:00Z",
|
||||
"author": "openclaw"
|
||||
}
|
||||
Reference in New Issue
Block a user