Files
awoooi/scripts/ci/generate-cicd-artifact-receipts.py

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())