Materialize agent project CI
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
name: Repository Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- task/**
|
||||
|
||||
jobs:
|
||||
repository-checks:
|
||||
runs-on: agent-environment
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_SERVER_URL: ${{ github.server_url }}
|
||||
CHECKOUT_REPOSITORY: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 - <<'CHECKOUT_PY'
|
||||
"""Self-contained checkout embedded in generated CI; no downloaded actions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def checkout(environment=None):
|
||||
env = dict(os.environ if environment is None else environment)
|
||||
server = env.pop('CHECKOUT_SERVER_URL', '').rstrip('/')
|
||||
repo = env.pop('CHECKOUT_REPOSITORY', '')
|
||||
sha = env.pop('CHECKOUT_SHA', '')
|
||||
token = env.pop('CHECKOUT_TOKEN', '')
|
||||
parsed = urlsplit(server)
|
||||
if (parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username
|
||||
or parsed.password or parsed.query or parsed.fragment):
|
||||
raise ValueError('invalid checkout server URL')
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repo) or any(p in ('.', '..') for p in repo.split('/')):
|
||||
raise ValueError('invalid checkout repository')
|
||||
if not re.fullmatch(r'[0-9a-fA-F]{40}|[0-9a-fA-F]{64}', sha):
|
||||
raise ValueError('checkout requires an exact commit SHA')
|
||||
workspace = Path(env.get('GITHUB_WORKSPACE') or env.get('GITEA_WORKSPACE') or os.getcwd()).resolve()
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
# Never inherit authentication overrides, tracing, hooks or checkout filters.
|
||||
env = {k: v for k, v in env.items() if not k.startswith('GIT_')}
|
||||
env.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL='/dev/null',
|
||||
GIT_TERMINAL_PROMPT='0', GIT_LFS_SKIP_SMUDGE='1')
|
||||
if (workspace / '.git').exists():
|
||||
raise ValueError('checkout requires a fresh workspace')
|
||||
|
||||
def git(*args, auth=False):
|
||||
child = dict(env)
|
||||
if auth and token:
|
||||
credential = base64.b64encode(('x-access-token:' + token).encode()).decode()
|
||||
child.update(GIT_CONFIG_COUNT='1',
|
||||
GIT_CONFIG_KEY_0='http.extraHeader',
|
||||
GIT_CONFIG_VALUE_0='Authorization: Basic ' + credential)
|
||||
result = subprocess.run(['git', '-c', 'core.hooksPath=/dev/null', '-C', str(workspace), *args],
|
||||
env=child, capture_output=True, text=True, timeout=180)
|
||||
if result.returncode:
|
||||
# Transport output may contain credentials or upstream-controlled data.
|
||||
raise RuntimeError('checkout git operation failed: ' + args[0])
|
||||
return result.stdout.strip()
|
||||
|
||||
git('init')
|
||||
git('remote', 'add', 'origin', server + '/' + repo + '.git')
|
||||
git('-c', 'http.followRedirects=false', 'fetch', '--no-tags', '--depth=1', 'origin', sha, auth=True)
|
||||
actual = git('rev-parse', 'FETCH_HEAD^{commit}')
|
||||
if actual.lower() != sha.lower():
|
||||
raise ValueError('fetched commit does not match requested SHA')
|
||||
git('checkout', '--detach', '--force', actual)
|
||||
if git('rev-parse', 'HEAD').lower() != sha.lower():
|
||||
raise ValueError('checked out commit does not match requested SHA')
|
||||
print('Checked out requested commit ' + actual)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
checkout()
|
||||
except (ValueError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
||||
print('AGENT_CI_RESULT command_id=checkout stage=checkout owner=infrastructure '
|
||||
'repair_role=operator exit_code=1 expected_exit_code=0 status=failed')
|
||||
# Do not stringify subprocess exceptions: they may include command output.
|
||||
raise SystemExit('Checkout failed (' + type(exc).__name__ + ')') from None
|
||||
CHECKOUT_PY
|
||||
|
||||
- name: Validate facade structure
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(".agent/project.yaml")
|
||||
if not path.is_file():
|
||||
raise SystemExit(".agent/project.yaml is missing")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for key in ["schema_version", "registry", "project", "components"]:
|
||||
if not any(line.startswith(key + ":") for line in text.splitlines()):
|
||||
raise SystemExit(f".agent/project.yaml missing required key: {key}")
|
||||
if not any(line.lstrip().startswith("- ") for line in text.splitlines()):
|
||||
raise SystemExit(".agent/project.yaml must declare at least one component")
|
||||
print("facade structure ok")
|
||||
PY
|
||||
|
||||
- name: Run facade commands
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
full_run=true
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [[ "${GITHUB_BASE_REF:-}" == task/* ]]; then
|
||||
full_run=false
|
||||
fi
|
||||
export PATH="$PWD/.venv/bin:$PATH"
|
||||
printf 'AGENT_CI_MODE full_run=%s event=%s base_ref=%s\n' "$full_run" "${GITHUB_EVENT_NAME:-unknown}" "${GITHUB_BASE_REF:-}"
|
||||
run_ci_stage() {
|
||||
command_id="$1"
|
||||
stage="$2"
|
||||
owner="$3"
|
||||
repair_role="$4"
|
||||
command="$5"
|
||||
expected_exit_code="$6"
|
||||
missing_policy="$7"
|
||||
shift 7
|
||||
missing_paths=()
|
||||
for required_path in "$@"; do
|
||||
if [ ! -e "$required_path" ]; then
|
||||
missing_paths+=("$required_path")
|
||||
fi
|
||||
done
|
||||
if [ "${#missing_paths[@]}" -gt 0 ]; then
|
||||
missing_csv="$(IFS=,; echo "${missing_paths[*]}")"
|
||||
if [ "$full_run" != true ] && [ "$missing_policy" = skip_partial ]; then
|
||||
printf 'AGENT_CI_RESULT command_id=%s stage=%s owner=%s repair_role=%s exit_code=0 expected_exit_code=%s status=skipped_missing_prerequisites missing_paths=%s\n' "$command_id" "$stage" "$owner" "$repair_role" "$expected_exit_code" "$missing_csv"
|
||||
return 0
|
||||
fi
|
||||
printf 'AGENT_CI_RESULT command_id=%s stage=%s owner=%s repair_role=%s exit_code=97 expected_exit_code=%s status=missing_prerequisites missing_paths=%s\n' "$command_id" "$stage" "$owner" "$repair_role" "$expected_exit_code" "$missing_csv"
|
||||
return 97
|
||||
fi
|
||||
if [ -z "$command" ]; then
|
||||
echo "skip $command_id (not declared)"
|
||||
return 0
|
||||
fi
|
||||
echo "::group::$command_id"
|
||||
set +e
|
||||
bash -o pipefail -eu -c "$command"
|
||||
exit_code="$?"
|
||||
set -e
|
||||
# Executor reserves 78 for tracked preparation-output conflicts.
|
||||
# Other dependency failures retain infrastructure ownership.
|
||||
if [ "$stage" = dependency_preparation ] && [ "$exit_code" -eq 78 ]; then
|
||||
owner=project
|
||||
repair_role=developer
|
||||
fi
|
||||
status=failed
|
||||
if [ "$exit_code" -eq "$expected_exit_code" ]; then
|
||||
status=passed
|
||||
fi
|
||||
if [ "$status" = failed ]; then
|
||||
python3 - "$command_id" "$exit_code" <<'CI_DIAGNOSTICS_PY' || echo 'AGENT_CI_DIAGNOSTICS_ARTIFACT collector_failed'
|
||||
"""Bounded, opt-in CI failure reports; embedded in the canonical workflow.
|
||||
|
||||
Gitea Actions artifact v3 protocol (no external Action or package dependency).
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
FILE_LIMIT = 65536
|
||||
FILE_COUNT = 8
|
||||
MANIFEST = '.ci/diagnostics.json'
|
||||
|
||||
|
||||
def read_relative(root, name, limit=FILE_LIMIT):
|
||||
path = PurePosixPath(name)
|
||||
if not name or path.is_absolute() or any(p in ('', '.', '..') for p in name.split('/')):
|
||||
raise ValueError('invalid path')
|
||||
fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
for part in path.parts[:-1]:
|
||||
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
child = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fd)
|
||||
with os.fdopen(child, 'rb') as stream:
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
raise ValueError('not a regular file')
|
||||
return stream.read(limit + 1)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def scrub(text, env):
|
||||
for key, value in env.items():
|
||||
if len(value) >= 8 and any(word in key.upper() for word in ('TOKEN', 'SECRET', 'PASSWORD', 'API_KEY')):
|
||||
text = text.replace(value, '<redacted>')
|
||||
return text
|
||||
|
||||
|
||||
def collect(root, command, exit_code, env):
|
||||
report = {'version': 1, 'command_id': command, 'exit_code': exit_code,
|
||||
'sha': env.get('GITHUB_SHA', ''), 'files': []}
|
||||
try:
|
||||
raw = read_relative(root, MANIFEST)
|
||||
if len(raw) > FILE_LIMIT:
|
||||
raise ValueError('manifest too large')
|
||||
spec = json.loads(raw)
|
||||
if spec.get('version') != 1 or not isinstance(spec.get('commands'), dict):
|
||||
raise ValueError('invalid manifest')
|
||||
names = spec['commands'].get(command, [])
|
||||
if not isinstance(names, list) or len(names) > FILE_COUNT or any(not isinstance(n, str) for n in names):
|
||||
raise ValueError('invalid file list')
|
||||
report['manifest_status'] = 'loaded'
|
||||
except FileNotFoundError:
|
||||
report['manifest_status'] = 'missing'
|
||||
names = []
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
report['manifest_status'] = 'invalid'
|
||||
names = []
|
||||
for name in names:
|
||||
item = {'path': name}
|
||||
try:
|
||||
# No directory traversal, symlinks, devices, hidden credential files,
|
||||
# wildcards or recursive workspace scans.
|
||||
if any(p.startswith('.') for p in PurePosixPath(name).parts) or len(name) > 512:
|
||||
raise ValueError('hidden or invalid path')
|
||||
raw = read_relative(root, name)
|
||||
item.update(status='captured', truncated=len(raw) > FILE_LIMIT,
|
||||
content=scrub(raw[:FILE_LIMIT].decode('utf-8', errors='replace'), env))
|
||||
except (OSError, ValueError):
|
||||
item['status'] = 'unavailable'
|
||||
report['files'].append(item)
|
||||
return report
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def upload(report, env, request=None):
|
||||
base = env.get('ACTIONS_RUNTIME_URL', '')
|
||||
token = env.get('ACTIONS_RUNTIME_TOKEN', '')
|
||||
run = env.get('GITHUB_RUN_ID', '')
|
||||
parsed = urllib.parse.urlsplit(base)
|
||||
if not base or not token or not run:
|
||||
return 'runtime_unavailable'
|
||||
if parsed.scheme not in ('http', 'https') or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError('invalid runtime URL')
|
||||
if request is None:
|
||||
opener = urllib.request.build_opener(NoRedirect)
|
||||
def request(method, url, body, headers):
|
||||
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
||||
try:
|
||||
with opener.open(req, timeout=10) as response:
|
||||
return response.read(65536)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(f'artifact {method} HTTP {exc.code}') from None
|
||||
endpoint = base.rstrip('/')+'/_apis/pipelines/workflows/'+urllib.parse.quote(run, safe='')+'/artifacts'
|
||||
name = 'ci-failure-'+re.sub('[^A-Za-z0-9_-]', '-', report['command_id'])[:60]+'-'+re.sub('[^0-9]', '', env.get('GITHUB_RUN_ATTEMPT', '1'))[:8]
|
||||
headers = {'Authorization': 'Bearer '+token, 'Content-Type': 'application/json'}
|
||||
created = request('POST', endpoint+'?api-version=6.0-preview', json.dumps({'Type': 'actions_storage', 'Name': name}).encode(), headers)
|
||||
location = json.loads(created)['fileContainerResourceUrl']
|
||||
location = urllib.parse.urljoin(endpoint, location)
|
||||
target = urllib.parse.urlsplit(location)
|
||||
if (target.scheme, target.netloc) != (parsed.scheme, parsed.netloc) or target.username or target.password:
|
||||
raise ValueError('cross-origin artifact URL')
|
||||
data = json.dumps(report, ensure_ascii=True).encode()
|
||||
url = location+('&' if target.query else '?')+urllib.parse.urlencode({'itemPath': name+'/diagnostics.json'})
|
||||
request('PUT', url, data, {**headers, 'Content-Type': 'application/octet-stream',
|
||||
'Content-Range': f'bytes 0-{len(data)-1}/{len(data)}', 'x-tfs-filelength': str(len(data)),
|
||||
'x-actions-results-md5': base64.b64encode(hashlib.md5(data).digest()).decode('ascii')})
|
||||
request('PATCH', endpoint+'?'+urllib.parse.urlencode({'artifactName': name, 'api-version': '6.0-preview'}),
|
||||
json.dumps({'Size': len(data)}).encode(), headers)
|
||||
return 'uploaded:'+name
|
||||
|
||||
|
||||
def main():
|
||||
report = collect(os.getcwd(), sys.argv[1], int(sys.argv[2]), os.environ)
|
||||
# A bounded preview remains in the job log even if artifact upload fails.
|
||||
preview = json.loads(json.dumps(report))
|
||||
for item in preview['files']:
|
||||
if 'content' in item:
|
||||
item['content'] = item['content'][:1000]
|
||||
print('AGENT_CI_DIAGNOSTICS '+json.dumps(preview, ensure_ascii=True))
|
||||
try:
|
||||
result = upload(report, os.environ)
|
||||
except Exception as exc:
|
||||
result = 'upload_failed:'+str(exc) if isinstance(exc, RuntimeError) else 'upload_failed:'+type(exc).__name__
|
||||
print('AGENT_CI_DIAGNOSTICS_ARTIFACT '+result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
CI_DIAGNOSTICS_PY
|
||||
fi
|
||||
printf 'AGENT_CI_RESULT command_id=%s stage=%s owner=%s repair_role=%s exit_code=%s expected_exit_code=%s status=%s\n' "$command_id" "$stage" "$owner" "$repair_role" "$exit_code" "$expected_exit_code" "$status"
|
||||
echo "::endgroup::"
|
||||
if [ "$status" = passed ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
return "$exit_code"
|
||||
}
|
||||
run_ci_stage 'tools.dependencies.resolution' 'dependency_prerequisite' 'project' 'developer' '' '0' 'fail' 'uv.lock'
|
||||
run_ci_stage 'tools.dependencies.restore' 'dependency_preparation' 'infrastructure' 'infrastructure' '/opt/agent-environment/bin/python -I -m agent_environment_executor.cli --workspace "$PWD" --component '"'"'tools'"'"' --mode restore' '0' 'fail'
|
||||
run_ci_stage 'tools.validate' 'command' 'project' 'developer' 'python -m compileall tools tests' '0' 'skip_partial' 'pyproject.toml' 'tools' 'tests'
|
||||
run_ci_stage 'tools.test' 'command' 'project' 'developer' 'pytest' '0' 'skip_partial' 'pyproject.toml' 'tests/test_example.py'
|
||||
run_ci_stage 'tools.smoke' 'command' 'project' 'developer' 'python -m tools.example examples/input.json' '0' 'skip_partial' 'pyproject.toml' 'tools/example.py' 'examples/input.json'
|
||||
run_ci_stage 'acceptance.help-command' 'acceptance' 'project' 'developer' 'python -m tools.example --help' '0' 'skip_partial' 'examples/input.json' 'tools/example.py'
|
||||
Reference in New Issue
Block a user