380 lines
13 KiB
Python
380 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate CI/CD artifact receipts without reading secrets.
|
|
|
|
The output is intentionally metadata-only. It records package manifests,
|
|
source digests, build identity, SBOM/provenance receipt names, and retention
|
|
policy evidence for Gitea Actions logs. It does not read environment secrets,
|
|
runtime volumes, raw sessions, SQLite databases, or `.env` files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import tomllib
|
|
from typing import Any
|
|
|
|
SCHEMA_VERSION = "awoooi_cicd_artifact_receipts_v1"
|
|
SBOM_SCHEMA_VERSION = "awoooi_cicd_sbom_v1"
|
|
PROVENANCE_SCHEMA_VERSION = "awoooi_cicd_provenance_v1"
|
|
RETENTION_SCHEMA_VERSION = "awoooi_cicd_artifact_retention_v1"
|
|
PACKAGE_RELEASE_SCHEMA_VERSION = "awoooi_cicd_package_release_promotion_v1"
|
|
|
|
PYPROJECT_PATHS = [
|
|
"apps/api/pyproject.toml",
|
|
"packages/lewooogo-data/pyproject.toml",
|
|
"packages/lewooogo-brain/pyproject.toml",
|
|
"scripts/aider_watch_client/pyproject.toml",
|
|
]
|
|
PACKAGE_JSON_PATHS = [
|
|
"package.json",
|
|
"apps/web/package.json",
|
|
"packages/lewooogo-core/package.json",
|
|
"packages/shared-types/package.json",
|
|
"packages/eslint-config/package.json",
|
|
"packages/tsconfig/package.json",
|
|
]
|
|
LOCKFILE_PATHS = [
|
|
"pnpm-lock.yaml",
|
|
"apps/api/requirements.txt",
|
|
"apps/sensor/requirements.txt",
|
|
]
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _write_json(path: Path, payload: dict[str, Any]) -> str:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
return _sha256_file(path)
|
|
|
|
|
|
def _safe_component_name(raw: str) -> str:
|
|
value = raw.strip()
|
|
if not value:
|
|
return "unknown"
|
|
# Preserve package scope names while stripping version specifiers/extras.
|
|
match = re.match(r"([A-Za-z0-9_.@/-]+)", value)
|
|
return match.group(1) if match else value[:80]
|
|
|
|
|
|
def _python_manifest_components(repo_root: Path) -> list[dict[str, Any]]:
|
|
components: list[dict[str, Any]] = []
|
|
for rel in PYPROJECT_PATHS:
|
|
path = repo_root / rel
|
|
if not path.is_file():
|
|
continue
|
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
project = data.get("project") or {}
|
|
project_name = str(project.get("name") or path.parent.name)
|
|
project_version = str(project.get("version") or "0.0.0")
|
|
components.append(
|
|
{
|
|
"type": "application",
|
|
"ecosystem": "python",
|
|
"name": project_name,
|
|
"version": project_version,
|
|
"manifest_path": rel,
|
|
"manifest_sha256": _sha256_file(path),
|
|
}
|
|
)
|
|
dependencies = list(project.get("dependencies") or [])
|
|
optional = project.get("optional-dependencies") or {}
|
|
for group, values in sorted(optional.items()):
|
|
dependencies.extend(f"{item} ; extra == '{group}'" for item in values or [])
|
|
for dep in dependencies:
|
|
components.append(
|
|
{
|
|
"type": "library",
|
|
"ecosystem": "python",
|
|
"name": _safe_component_name(str(dep)),
|
|
"version": "",
|
|
"manifest_path": rel,
|
|
"scope": "runtime_or_optional",
|
|
}
|
|
)
|
|
return components
|
|
|
|
|
|
def _node_manifest_components(repo_root: Path) -> list[dict[str, Any]]:
|
|
components: list[dict[str, Any]] = []
|
|
dependency_fields = (
|
|
"dependencies",
|
|
"devDependencies",
|
|
"peerDependencies",
|
|
"optionalDependencies",
|
|
)
|
|
for rel in PACKAGE_JSON_PATHS:
|
|
path = repo_root / rel
|
|
if not path.is_file():
|
|
continue
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
package_name = str(data.get("name") or path.parent.name)
|
|
package_version = str(data.get("version") or "0.0.0")
|
|
components.append(
|
|
{
|
|
"type": "application",
|
|
"ecosystem": "node",
|
|
"name": package_name,
|
|
"version": package_version,
|
|
"manifest_path": rel,
|
|
"manifest_sha256": _sha256_file(path),
|
|
}
|
|
)
|
|
for field in dependency_fields:
|
|
deps = data.get(field) or {}
|
|
if not isinstance(deps, dict):
|
|
continue
|
|
for name, version in sorted(deps.items()):
|
|
components.append(
|
|
{
|
|
"type": "library",
|
|
"ecosystem": "node",
|
|
"name": str(name),
|
|
"version": str(version),
|
|
"manifest_path": rel,
|
|
"scope": field,
|
|
}
|
|
)
|
|
return components
|
|
|
|
|
|
def _existing_file_digests(repo_root: Path, paths: list[str]) -> list[dict[str, str]]:
|
|
rows: list[dict[str, str]] = []
|
|
for rel in paths:
|
|
path = repo_root / rel
|
|
if path.is_file():
|
|
rows.append({"path": rel, "sha256": _sha256_file(path)})
|
|
return rows
|
|
|
|
|
|
def build_receipts(repo_root: Path, out_dir: Path) -> dict[str, Any]:
|
|
components = _python_manifest_components(repo_root) + _node_manifest_components(
|
|
repo_root
|
|
)
|
|
lockfile_digests = _existing_file_digests(repo_root, LOCKFILE_PATHS)
|
|
source_digests = _existing_file_digests(
|
|
repo_root,
|
|
[
|
|
".gitea/workflows/cd.yaml",
|
|
"apps/api/Dockerfile",
|
|
"apps/web/Dockerfile",
|
|
"product.awoooi.yaml",
|
|
],
|
|
)
|
|
|
|
source_sha = (
|
|
os.environ.get("GITHUB_SHA") or os.environ.get("CI_COMMIT_SHA") or "local"
|
|
)
|
|
run_id = os.environ.get("GITHUB_RUN_ID") or os.environ.get("CI_RUN_ID") or "local"
|
|
run_attempt = os.environ.get("GITHUB_RUN_ATTEMPT") or "1"
|
|
|
|
sbom = {
|
|
"schema_version": SBOM_SCHEMA_VERSION,
|
|
"source_control_authority": "gitea",
|
|
"format": "metadata_only_sbom",
|
|
"component_count": len(components),
|
|
"components": components,
|
|
"lockfile_digests": lockfile_digests,
|
|
"operation_boundaries": {
|
|
"secret_value_read_allowed": False,
|
|
"env_file_read_allowed": False,
|
|
"external_registry_lookup_allowed": False,
|
|
"package_install_allowed": False,
|
|
},
|
|
}
|
|
sbom_sha = _write_json(out_dir / "awoooi-ci-sbom.json", sbom)
|
|
|
|
provenance = {
|
|
"schema_version": PROVENANCE_SCHEMA_VERSION,
|
|
"source_control_authority": "gitea",
|
|
"builder": {
|
|
"id": "gitea-actions-self-hosted",
|
|
"workflow": ".gitea/workflows/cd.yaml",
|
|
"runner_isolation_contract": "explicit_labels_non110_preferred_controlled_runtime",
|
|
},
|
|
"subject": {
|
|
"name": "awoooi",
|
|
"source_sha": source_sha,
|
|
"run_id": run_id,
|
|
"run_attempt": run_attempt,
|
|
},
|
|
"build_type": "gitea-actions-docker-build-and-argocd-deploy",
|
|
"slsa_source_contract": {
|
|
"isolated_build_lane_required": True,
|
|
"digest_bound_subject_required": True,
|
|
"provenance_receipt_generated": True,
|
|
"verified_builder_identity_required_for_release_closure": True,
|
|
},
|
|
"source_digests": source_digests,
|
|
"sbom_sha256": sbom_sha,
|
|
"operation_boundaries": {
|
|
"repo_write_allowed": False,
|
|
"workflow_trigger_allowed": False,
|
|
"secret_value_read_allowed": False,
|
|
"github_api_allowed": False,
|
|
},
|
|
}
|
|
provenance_sha = _write_json(out_dir / "awoooi-ci-provenance.json", provenance)
|
|
|
|
retention = {
|
|
"schema_version": RETENTION_SCHEMA_VERSION,
|
|
"source_control_authority": "gitea",
|
|
"retention_policy": {
|
|
"test_log_days": 30,
|
|
"build_receipt_days": 90,
|
|
"deploy_marker_days": 365,
|
|
"sbom_provenance_days": 365,
|
|
"backup_dr_receipt_days": 365,
|
|
},
|
|
"artifact_receipts": [
|
|
{
|
|
"artifact_id": "awoooi-ci-sbom",
|
|
"path": "awoooi-ci-sbom.json",
|
|
"sha256": sbom_sha,
|
|
"retention_days": 365,
|
|
},
|
|
{
|
|
"artifact_id": "awoooi-ci-provenance",
|
|
"path": "awoooi-ci-provenance.json",
|
|
"sha256": provenance_sha,
|
|
"retention_days": 365,
|
|
},
|
|
],
|
|
"cache_key_contract": {
|
|
"python_deps_hash_input": "apps/api/pyproject.toml",
|
|
"node_deps_hash_input": "pnpm-lock.yaml",
|
|
"docker_cache_image": "registry.wooo.work/awoooi/api:latest",
|
|
},
|
|
"operation_boundaries": {
|
|
"artifact_upload_attempted": False,
|
|
"package_registry_write_attempted": False,
|
|
"secret_value_read_allowed": False,
|
|
},
|
|
}
|
|
retention_sha = _write_json(
|
|
out_dir / "awoooi-ci-artifact-retention-manifest.json",
|
|
retention,
|
|
)
|
|
|
|
package_release = {
|
|
"schema_version": PACKAGE_RELEASE_SCHEMA_VERSION,
|
|
"source_control_authority": "gitea",
|
|
"release_channel_contract": {
|
|
"primary_package_registry": "gitea_packages",
|
|
"container_registry": "registry.wooo.work/awoooi",
|
|
"release_tag_pattern": "awoooi-api-{short_sha}",
|
|
"rollback_artifact_pattern": "awoooi-api-{previous_short_sha}",
|
|
"changelog_receipt_required": True,
|
|
"sbom_ref_required": True,
|
|
"provenance_ref_required": True,
|
|
"deploy_marker_ref_required": True,
|
|
},
|
|
"promotion_subject": {
|
|
"name": "awoooi",
|
|
"source_sha": source_sha,
|
|
"run_id": run_id,
|
|
"run_attempt": run_attempt,
|
|
"sbom_sha256": sbom_sha,
|
|
"provenance_sha256": provenance_sha,
|
|
"artifact_retention_sha256": retention_sha,
|
|
},
|
|
"rollback_receipt_contract": {
|
|
"rollback_source": "previous_deploy_marker",
|
|
"rollback_requires_image_digest": True,
|
|
"rollback_requires_package_or_release_ref": True,
|
|
"rollback_requires_post_apply_readback": True,
|
|
},
|
|
"operation_boundaries": {
|
|
"package_registry_write_attempted": False,
|
|
"gitea_release_write_attempted": False,
|
|
"tag_write_attempted": False,
|
|
"secret_value_read_allowed": False,
|
|
"github_api_allowed": False,
|
|
},
|
|
}
|
|
package_release_sha = _write_json(
|
|
out_dir / "awoooi-ci-package-release-promotion.json",
|
|
package_release,
|
|
)
|
|
|
|
manifest = {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"source_control_authority": "gitea",
|
|
"source_sha": source_sha,
|
|
"run_id": run_id,
|
|
"receipt_count": 4,
|
|
"receipts": [
|
|
{
|
|
"id": "sbom",
|
|
"path": str(out_dir / "awoooi-ci-sbom.json"),
|
|
"sha256": sbom_sha,
|
|
},
|
|
{
|
|
"id": "provenance",
|
|
"path": str(out_dir / "awoooi-ci-provenance.json"),
|
|
"sha256": provenance_sha,
|
|
},
|
|
{
|
|
"id": "artifact_retention",
|
|
"path": str(out_dir / "awoooi-ci-artifact-retention-manifest.json"),
|
|
"sha256": retention_sha,
|
|
},
|
|
{
|
|
"id": "package_release_promotion",
|
|
"path": str(out_dir / "awoooi-ci-package-release-promotion.json"),
|
|
"sha256": package_release_sha,
|
|
},
|
|
],
|
|
"ready": True,
|
|
"operation_boundaries": {
|
|
"secret_values_collected": False,
|
|
"github_api_used": False,
|
|
"gitea_api_write_performed": False,
|
|
"host_write_performed": False,
|
|
},
|
|
}
|
|
manifest_sha = _write_json(
|
|
out_dir / "awoooi-ci-artifact-receipts.manifest.json", manifest
|
|
)
|
|
manifest["manifest_sha256"] = manifest_sha
|
|
return manifest
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--repo-root", type=Path, default=Path.cwd())
|
|
parser.add_argument(
|
|
"--out-dir", type=Path, default=Path("/tmp/awoooi-cicd-artifacts")
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
repo_root = args.repo_root.resolve()
|
|
out_dir = args.out_dir.resolve()
|
|
manifest = build_receipts(repo_root, out_dir)
|
|
print(
|
|
f"cicd_artifact_receipt_manifest={out_dir / 'awoooi-ci-artifact-receipts.manifest.json'}"
|
|
)
|
|
print(f"cicd_artifact_receipt_manifest_sha256={manifest['manifest_sha256']}")
|
|
for receipt in manifest["receipts"]:
|
|
print(f"cicd_artifact_receipt={receipt['id']} sha256={receipt['sha256']}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|