Compare commits

...
6 Commits
7 changed files with 468 additions and 9 deletions
+42 -6
View File
@@ -140,6 +140,19 @@ def sync_tag(tag: str) -> dict:
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
oid = str(value or '').strip().lower()
if not re.fullmatch(r'[0-9a-f]{40}', oid):
raise ValueError(f'{field} must be a full 40-character commit OID')
result = sh(['rev-parse', '--verify', f'{oid}^{{commit}}'])
if result['code'] != 0 or result['stdout'] != oid:
raise ValueError(f'{field} does not resolve to the exact approved commit')
return oid
def existing_tag_target(tag: str) -> str | None:
result = sh(['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'])
return result['stdout'] if result['code'] == 0 else None
def commit(paths, msg):
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
branch=sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']
@@ -252,12 +265,35 @@ class H(BaseHTTPRequestHandler):
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)
return
try:
target_oid=resolve_commit_oid(data.get('target_oid'))
evidence_oid=resolve_commit_oid(data.get('evidence_closure_oid'), 'evidence_closure_oid') if data.get('evidence_closure_oid') else None
except ValueError as exc:
return self.sendj({'error':'invalid_tag_target','detail':str(exc)},400)
if evidence_oid and sh(['merge-base','--is-ancestor',target_oid,evidence_oid])['code'] != 0:
return self.sendj({'error':'evidence_lineage_mismatch','target_oid':target_oid,'evidence_closure_oid':evidence_oid},409)
if a['role'] != 'owner' or 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)})
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'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,'request':req,'commit':c})
current_target=existing_tag_target(ver)
if current_target and current_target != target_oid:
audit(a['account'],'tag_conflict',ver,{'target_oid':target_oid,'actual_target_oid':current_target,'approval_plan_id':data.get('approval_plan_id')})
return self.sendj({'status':'tag_target_conflict','version':ver,'target_oid':current_target,'expected_target_oid':target_oid,'main_changed':False,'force_push':False},409)
main_before=sh(['rev-parse','refs/heads/main'], True)['stdout']
if current_target:
status='tagged_existing'; out={'code':0,'stdout':'','stderr':'','cmd':['git','tag','existing']}
else:
status='tagged'; out=sh(['tag','-a',ver,target_oid,'-m',data.get('message') or f'release {ver}'], True)
actual_target=existing_tag_target(ver)
if actual_target != target_oid:
raise RuntimeError(f'tag target verification failed: expected {target_oid}, got {actual_target}')
tag_oid=sh(['rev-parse',f'refs/tags/{ver}'], True)['stdout']
main_after=sh(['rev-parse','refs/heads/main'], True)['stdout']
if main_after != main_before:
raise RuntimeError('main changed during tag operation')
sync=sync_tag(ver)
result={'status':status,'version':ver,'tag_name':ver,'tag_oid':tag_oid,'target_oid':actual_target,'expected_target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'workspace_head':sh(['rev-parse','HEAD'],True)['stdout'],'main_oid_before':main_before,'main_oid_after':main_after,'main_changed':False,'force_push':False,'git':out,'sync':sync}
audit(a['account'],'tag',ver,result)
self.sendj(result)
def rollback(self,data):
a=self.need('rollback:request');
if not a: return
@@ -395,4 +431,4 @@ def api_spec():
def main():
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
if __name__ == '__main__': main()
if __name__ == '__main__': main()
@@ -0,0 +1,27 @@
# AIOS V2R1C2 Unified Portal production metadata.
# No secret values belong in this file; runtime integrations use references.
AIOS_V2_HOST=127.0.0.1
AIOS_V2_PORT=18920
AIOS_V2_STATE_DIR=/var/lib/aios-v2r1c2-production
AIOS_PROVENANCE_STORE_FILE=/var/lib/aios-v2r1c2-production/run-provenance.sqlite3
AIOS_EXTERNAL_TASK_STORE_FILE=/var/lib/aios-v2r1c2-production/external-task-truth.sqlite3
AIOS_EXECUTOR_CATALOG_FILE=/var/lib/aios-v2r1c2-production/executor-catalog.json
AIOS_EXECUTION_PLAN_FILES=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-RELEASE.json:/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
AIOS_EXECUTION_PLAN_FILE=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
AIOS_RELEASE_VERSION=V2R1C2
AIOS_TARGET_VERSION=V2R1C2
AIOS_BASELINE_VERSION=V2R1C1
AIOS_PREVIOUS_VERSION=V2R1C1
AIOS_RELEASE_STATUS=PRODUCTION
AIOS_RELEASE_SOURCE_OID=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
AIOS_RELEASE_OID=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
AIOS_RELEASE_TAG=V2R1C2
AIOS_RELEASE_EVIDENCE_SHA256=2e27bb73b8e6297bd3077238f73a96d697fc3c3611c601c4dc9357e5fdcd8eee
AIOS_OPENCLAW_CONTRACT_VERSION=aios.openclaw.command.v2
AIOS_OPENCLAW_SIGNATURE_ALGORITHM=Ed25519
AIOS_OPENCLAW_PLUGIN_VERSION=2.1.0-rc.4
AIOS_OPENCLAW_SIGNING_KEY_ID=openclaw-v2r1-candidate-ed25519
AIOS_OWNER_WECHAT_TARGET_REF=AIOS_OWNER_WECHAT_TARGET_REF
@@ -0,0 +1,44 @@
[Unit]
Description=AIOS V2R1C2 Unified Portal Production
After=network-online.target
Wants=network-online.target
Conflicts=aios-orchestrator-gateway.service
ConditionPathExists=/opt/aios-v2r1c2/active-runtime/app/aios_web/release_application.py
ConditionPathExists=/opt/aios-v2r1c2/plans/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER.json
[Service]
Type=simple
User=aios
Group=aios
WorkingDirectory=/opt/aios-v2r1c2/active-runtime/app
EnvironmentFile=/etc/aios-v2/aios-v2r1c2-production.env
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStartPre=/usr/bin/test "$AIOS_V2_PORT" = "18920"
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_SOURCE_OID" = "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24"
ExecStartPre=/usr/bin/test "$AIOS_RELEASE_TAG" = "V2R1C2"
ExecStart=/usr/bin/python3 -m aios_web.release_application
Restart=on-failure
RestartSec=3
TimeoutStartSec=60
TimeoutStopSec=30
KillSignal=SIGTERM
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
ReadWritePaths=/var/lib/aios-v2r1c2-production
StateDirectory=aios-v2r1c2-production
StateDirectoryMode=0750
UMask=0027
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
+14 -3
View File
@@ -50,9 +50,20 @@ components:
message: {type: string}
TagRequest:
type: object
required: [version]
required: [version, target_oid]
properties:
version: {type: string, example: V1R1C1}
target_oid:
type: string
pattern: '^[0-9a-fA-F]{40}$'
description: Full approved commit OID. The tag is created explicitly at this commit, never implicitly at workspace HEAD.
evidence_closure_oid:
type: string
pattern: '^[0-9a-fA-F]{40}$'
description: Optional evidence closure commit. When supplied, target_oid must be its ancestor.
approval_plan_id:
type: string
description: Audited owner-approved release or tag plan identifier.
message: {type: string}
execute:
type: boolean
@@ -209,7 +220,7 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/TagRequest'}
responses: {'200': {description: Tag request committed or tag created}, '403': {description: Forbidden}}
responses: {'200': {description: Tag request committed or tag created}, '400': {description: Invalid or unresolved target OID}, '403': {description: Forbidden}, '409': {description: Existing tag target conflict or evidence lineage mismatch}}
x-openai-isConsequential: true
/api/rollback:
post:
@@ -245,4 +256,4 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/NLRequest'}
responses: {'
responses: {'
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
set -Eeuo pipefail
VERSION=V2R1C2
EXPECTED_TARGET=3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24
TAG_OBJECT=a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36
REPO=/data/aios-gitops/repo.git
SOURCE=/opt/aios-v2r1c2
TAKEOVER=/data/aios-gitops/worktrees/codex/AIOS-V2R1C2-UNIFIED-PORTAL-TAKEOVER-20260722
CANDIDATE_SERVICE=aios-v2r1c2-portal.service
OLD_SERVICE=aios-orchestrator-gateway.service
NEW_SERVICE=aios-v2r1c2-production.service
CANDIDATE_STATE=/var/lib/aios-v2r1c2-portal
PRODUCTION_STATE=/var/lib/aios-v2r1c2-production
RUN_ID=${AIOS_RELEASE_RUN_ID:-AIOS-V2R1C2-PRODUCTION-20260722T011836Z}
EVIDENCE_DIR=/var/lib/aios-gitops/acceptance/$RUN_ID
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
RELEASE_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd)
UNIT_SOURCE=$RELEASE_ROOT/deploy/aios-v2/systemd/aios-v2r1c2-production.service
ENV_SOURCE=$RELEASE_ROOT/deploy/aios-v2/systemd/aios-v2r1c2-production.env.example
LOCK=/run/lock/aios-v2r1c2-production-release.lock
require_root() {
test "$(id -u)" -eq 0 || { echo 'root required' >&2; exit 77; }
}
health_old() {
curl -fsS --max-time 10 http://127.0.0.1:18920/healthz
}
health_new() {
curl -fsS --max-time 10 http://127.0.0.1:18920/api/v2/health
}
health_candidate() {
curl -fsS --max-time 10 http://127.0.0.1:18921/api/v2/health
}
preflight() {
require_root
test "$(git --git-dir="$REPO" rev-parse refs/tags/$VERSION)" = "$TAG_OBJECT"
test "$(git --git-dir="$REPO" rev-list -n 1 "$VERSION")" = "$EXPECTED_TARGET"
test -f "$SOURCE/active-runtime/app/aios_web/release_application.py"
test -f "$UNIT_SOURCE"
test -f "$ENV_SOURCE"
command -v sqlite3 >/dev/null
systemctl is-active --quiet "$CANDIDATE_SERVICE"
systemctl is-active --quiet "$OLD_SERVICE"
health_candidate >/dev/null
health_old >/dev/null
(
cd "$TAKEOVER/artifacts/v2r1c2"
sha256sum -c checksums.sha256 >/dev/null
)
diff -qr --exclude=__pycache__ "$TAKEOVER/active-runtime/app" "$SOURCE/active-runtime/app" >/dev/null
diff -qr "$TAKEOVER/plans" "$SOURCE/plans" >/dev/null
echo PREFLIGHT=PASS
}
prepare() {
preflight
install -d -m 0750 "$EVIDENCE_DIR" "$EVIDENCE_DIR/backup"
health_old >"$EVIDENCE_DIR/production-health-before.json"
health_candidate >"$EVIDENCE_DIR/candidate-health-before.json"
systemctl cat "$OLD_SERVICE" >"$EVIDENCE_DIR/backup/$OLD_SERVICE.unit"
cp -a /etc/aios-orchestrator-gateway "$EVIDENCE_DIR/backup/"
cp -a /etc/systemd/system/aios-orchestrator-gateway.service "$EVIDENCE_DIR/backup/"
if test -e "$PRODUCTION_STATE"; then
mv "$PRODUCTION_STATE" "$EVIDENCE_DIR/backup/aios-v2r1c2-production-state.preexisting"
fi
systemctl stop "$CANDIDATE_SERVICE"
trap 'systemctl start "$CANDIDATE_SERVICE" || true' EXIT
while IFS= read -r -d '' database; do
sqlite3 "$database" 'PRAGMA wal_checkpoint(TRUNCATE);' >/dev/null
test "$(sqlite3 "$database" 'PRAGMA quick_check;')" = ok
done < <(find "$CANDIDATE_STATE" -maxdepth 1 -type f -name '*.sqlite3' -print0)
install -d -o aios -g aios -m 0750 "$PRODUCTION_STATE"
cp -a "$CANDIDATE_STATE/." "$PRODUCTION_STATE/"
chown -R aios:aios "$PRODUCTION_STATE"
while IFS= read -r -d '' database; do
test "$(sqlite3 "$database" 'PRAGMA quick_check;')" = ok
done < <(find "$PRODUCTION_STATE" -maxdepth 1 -type f -name '*.sqlite3' -print0)
install -o root -g root -m 0644 "$UNIT_SOURCE" /etc/systemd/system/aios-v2r1c2-production.service
install -d -o root -g aios -m 0750 /etc/aios-v2
install -o root -g aios -m 0640 "$ENV_SOURCE" /etc/aios-v2/aios-v2r1c2-production.env
systemctl daemon-reload
systemctl start "$CANDIDATE_SERVICE"
health_candidate >"$EVIDENCE_DIR/candidate-health-after-state-copy.json"
trap - EXIT
echo PREPARE=PASS
}
rollback() {
require_root
systemctl stop "$NEW_SERVICE" || true
systemctl disable "$NEW_SERVICE" >/dev/null 2>&1 || true
systemctl enable "$OLD_SERVICE" >/dev/null 2>&1 || true
systemctl start "$OLD_SERVICE"
health_old >"$EVIDENCE_DIR/rollback-health.json"
echo ROLLBACK=PASS
}
promote() {
require_root
test -f /etc/systemd/system/aios-v2r1c2-production.service
test -f /etc/aios-v2/aios-v2r1c2-production.env
! systemctl is-active --quiet "$NEW_SERVICE"
systemctl is-active --quiet "$CANDIDATE_SERVICE"
health_candidate >/dev/null
health_old >"$EVIDENCE_DIR/production-health-switch-input.json"
trap 'trap - ERR; rollback' ERR
systemctl stop "$OLD_SERVICE"
systemctl start "$NEW_SERVICE"
for _ in $(seq 1 30); do
if health_new >"$EVIDENCE_DIR/production-health-after-switch.json" 2>/dev/null; then
echo PROMOTE=PASS
trap - ERR
return 0
fi
sleep 1
done
return 1
}
finalize() {
require_root
systemctl is-active --quiet "$NEW_SERVICE"
health_new >"$EVIDENCE_DIR/production-health-final.json"
systemctl disable "$OLD_SERVICE" >/dev/null
systemctl enable "$NEW_SERVICE" >/dev/null
echo FINALIZE=PASS
}
exec 9>"$LOCK"
flock -n 9 || { echo 'release operation already running' >&2; exit 75; }
case "${1:-}" in
preflight) preflight ;;
prepare) prepare ;;
promote) promote ;;
rollback) rollback ;;
finalize) finalize ;;
*) echo "usage: $0 {preflight|prepare|promote|rollback|finalize}" >&2; exit 64 ;;
esac
+10
View File
@@ -0,0 +1,10 @@
{
"id": "nl-1784681185786-19ac0f82",
"text": "Create a safe GitOps plan for tag V2R1C2. The tag target must be commit 3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24. Evidence closure commit is 8cdca463aff9707edee00e20a3b56884130f6978. Do not tag the current workspace HEAD. Do not modify main. Do not force push. Do not promote production. If the target commit cannot be bound explicitly, keep the operation blocked.",
"intent": "unknown",
"action_plan": [],
"execute": false,
"owner_approval_required": true,
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-22T00:46:25Z"
}
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, call, patch
ROOT = Path(__file__).resolve().parents[1]
os.environ.setdefault("AIOS_GITOPS_WORKSPACE", str(ROOT))
sys.path.insert(0, str(ROOT / "runtime" / "agent-os"))
SPEC = importlib.util.spec_from_file_location(
"aios_chatgpt_actions_api",
ROOT / "active-runtime" / "app" / "aios_chatgpt_actions_api.py",
)
assert SPEC and SPEC.loader
api = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(api)
TARGET = "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24"
EVIDENCE = "8cdca463aff9707edee00e20a3b56884130f6978"
MAIN = "a281bfeacd990134dc2ebff5a7b9465756bc4bea"
TAG_OID = "1" * 40
class TagActionTests(unittest.TestCase):
def handler(self, role: str = "owner"):
handler = object.__new__(api.H)
handler.need = Mock(return_value={"account": "owner", "role": role, "scopes": {"tag:create"}})
handler.sendj = Mock(side_effect=lambda data, code=200: (data, code))
return handler
def test_resolve_commit_oid_requires_full_exact_commit(self):
with patch.object(api, "sh", return_value={"code": 0, "stdout": TARGET, "stderr": "", "cmd": []}):
self.assertEqual(api.resolve_commit_oid(TARGET.upper()), TARGET)
for value in (None, "3dd622b", "z" * 40):
with self.subTest(value=value), self.assertRaises(ValueError):
api.resolve_commit_oid(value)
with patch.object(api, "sh", return_value={"code": 1, "stdout": "", "stderr": "missing", "cmd": []}), self.assertRaises(ValueError):
api.resolve_commit_oid(TARGET)
def test_owner_execution_tags_explicit_target_without_backend_confirm_loop(self):
handler = self.handler()
git_calls = []
def fake_sh(args, check=False):
git_calls.append(args)
if args[:2] == ["merge-base", "--is-ancestor"]:
return {"code": 0, "stdout": "", "stderr": "", "cmd": ["git", *args]}
if args == ["rev-parse", "refs/heads/main"]:
return {"code": 0, "stdout": MAIN, "stderr": "", "cmd": ["git", *args]}
if args == ["rev-parse", "refs/tags/V2R1C2"]:
return {"code": 0, "stdout": TAG_OID, "stderr": "", "cmd": ["git", *args]}
if args == ["rev-parse", "HEAD"]:
return {"code": 0, "stdout": EVIDENCE, "stderr": "", "cmd": ["git", *args]}
return {"code": 0, "stdout": "", "stderr": "", "cmd": ["git", *args]}
with (
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
patch.object(api, "existing_tag_target", side_effect=[None, TARGET]),
patch.object(api, "sh", side_effect=fake_sh),
patch.object(api, "sync_tag", return_value={"status": "synced"}),
patch.object(api, "audit") as audit,
):
handler.tag({
"version": "V2R1C2", "target_oid": TARGET,
"evidence_closure_oid": EVIDENCE, "approval_plan_id": "nl-approved",
"execute": True, "message": "approved",
})
self.assertIn(["tag", "-a", "V2R1C2", TARGET, "-m", "approved"], git_calls)
payload = handler.sendj.call_args.args[0]
self.assertEqual(payload["status"], "tagged")
self.assertEqual(payload["target_oid"], TARGET)
self.assertEqual(payload["tag_oid"], TAG_OID)
self.assertFalse(payload["main_changed"])
self.assertFalse(payload["force_push"])
audit.assert_called_once()
def test_existing_matching_tag_is_idempotent(self):
handler = self.handler()
def fake_sh(args, check=False):
values = {
("merge-base", "--is-ancestor", TARGET, EVIDENCE): "",
("rev-parse", "refs/heads/main"): MAIN,
("rev-parse", "refs/tags/V2R1C2"): TAG_OID,
("rev-parse", "HEAD"): EVIDENCE,
}
return {"code": 0, "stdout": values.get(tuple(args), ""), "stderr": "", "cmd": ["git", *args]}
with (
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
patch.object(api, "existing_tag_target", side_effect=[TARGET, TARGET]),
patch.object(api, "sh", side_effect=fake_sh) as git,
patch.object(api, "sync_tag", return_value={"status": "synced"}),
patch.object(api, "audit"),
):
handler.tag({"version": "V2R1C2", "target_oid": TARGET, "evidence_closure_oid": EVIDENCE, "execute": True})
self.assertEqual(handler.sendj.call_args.args[0]["status"], "tagged_existing")
self.assertFalse(any(c.args[0][0] == "tag" for c in git.call_args_list))
def test_existing_different_tag_fails_closed(self):
handler = self.handler()
wrong = "2" * 40
with (
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
patch.object(api, "sh", return_value={"code": 0, "stdout": "", "stderr": "", "cmd": []}),
patch.object(api, "existing_tag_target", return_value=wrong),
patch.object(api, "audit"),
):
handler.tag({"version": "V2R1C2", "target_oid": TARGET, "evidence_closure_oid": EVIDENCE, "execute": True})
payload, code = handler.sendj.call_args.args
self.assertEqual(code, 409)
self.assertEqual(payload["status"], "tag_target_conflict")
self.assertEqual(payload["target_oid"], wrong)
def test_evidence_lineage_mismatch_fails_closed(self):
handler = self.handler()
with (
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
patch.object(api, "sh", return_value={"code": 1, "stdout": "", "stderr": "", "cmd": []}),
):
handler.tag({"version": "V2R1C2", "target_oid": TARGET, "evidence_closure_oid": EVIDENCE, "execute": True})
payload, code = handler.sendj.call_args.args
self.assertEqual(code, 409)
self.assertEqual(payload["error"], "evidence_lineage_mismatch")
def test_non_owner_request_persists_target_binding(self):
handler = self.handler(role="user")
with (
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
patch.object(api, "sh", return_value={"code": 0, "stdout": "", "stderr": "", "cmd": []}),
patch.object(api, "checkout", return_value="request/tag-v2r1c2"),
patch.object(api, "write_json", return_value="baseline/tag_requests/V2R1C2.json") as write_json,
patch.object(api, "commit", return_value={"status": "committed"}),
):
handler.tag({"version": "V2R1C2", "target_oid": TARGET, "evidence_closure_oid": EVIDENCE, "approval_plan_id": "nl-approved", "execute": True})
request = write_json.call_args.args[1]
self.assertEqual(request["target_oid"], TARGET)
self.assertEqual(request["evidence_closure_oid"], EVIDENCE)
self.assertEqual(request["approval_plan_id"], "nl-approved")
def test_openapi_requires_target_and_keeps_platform_confirmation(self):
schema = (ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
self.assertIn("required: [version, target_oid]", schema)
self.assertIn("x-openai-isConsequential: true", schema)
self.assertIn("never implicitly at workspace HEAD", schema)
def test_real_git_tag_targets_approved_commit_not_newer_workspace_head(self):
def run(cwd: Path, *args: str) -> str:
return subprocess.run(["git", *args], cwd=cwd, check=True, text=True, capture_output=True).stdout.strip()
with tempfile.TemporaryDirectory() as td:
root = Path(td)
bare = root / "repo.git"
work = root / "work"
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
subprocess.run(["git", "init", "-b", "main", str(work)], check=True, capture_output=True)
run(work, "config", "user.email", "test@example.invalid")
run(work, "config", "user.name", "Tag Test")
(work / "release.txt").write_text("candidate\n", encoding="utf-8")
run(work, "add", "release.txt"); run(work, "commit", "-m", "candidate")
target = run(work, "rev-parse", "HEAD")
(work / "evidence.txt").write_text("closure\n", encoding="utf-8")
run(work, "add", "evidence.txt"); run(work, "commit", "-m", "evidence")
evidence = run(work, "rev-parse", "HEAD")
run(work, "remote", "add", "origin", str(bare)); run(work, "push", "origin", "main")
old_ws, old_bare = api.WS, api.BARE_REPO
api.WS, api.BARE_REPO = work, bare
try:
handler = self.handler()
with patch.object(api, "audit"):
handler.tag({"version": "V9R9C9", "target_oid": target, "evidence_closure_oid": evidence, "execute": True, "message": "sandbox"})
payload = handler.sendj.call_args.args[0]
self.assertEqual(payload["target_oid"], target)
self.assertEqual(run(work, "rev-parse", "HEAD"), evidence)
self.assertEqual(run(work, "rev-parse", "V9R9C9^{}"), target)
self.assertEqual(subprocess.run(["git", "--git-dir", str(bare), "rev-parse", "V9R9C9^{}"], check=True, text=True, capture_output=True).stdout.strip(), target)
finally:
api.WS, api.BARE_REPO = old_ws, old_bare
if __name__ == "__main__":
unittest.main()