diff --git a/DEVELOPMENT_PLAN_NEXT.md b/DEVELOPMENT_PLAN_NEXT.md index 1c2a36e..c109e9b 100644 --- a/DEVELOPMENT_PLAN_NEXT.md +++ b/DEVELOPMENT_PLAN_NEXT.md @@ -106,6 +106,11 @@ Expected benefit: release and production state become auditable. ### P0-2. Controlled release evidence build +Status: addressed by TASK_070. Existing release evidence tooling now records +resolved Git SHA, deterministic `SOURCE_DATE_EPOCH` / `BUILD_TIME`, runner +environment and artifact checksums, and the evidence checker enforces the +required provenance fields. + Purpose: produce Production 1.0 release evidence outside GitHub public mirror. Reason: `docs/PROJECT_STATUS_RU.md`, `docs/QUALITY_STATUS_RU.md` and diff --git a/docs/registry/RELEASE_EVIDENCE_MANIFEST_RU.md b/docs/registry/RELEASE_EVIDENCE_MANIFEST_RU.md index 46717f9..b2f2ea8 100644 --- a/docs/registry/RELEASE_EVIDENCE_MANIFEST_RU.md +++ b/docs/registry/RELEASE_EVIDENCE_MANIFEST_RU.md @@ -58,6 +58,9 @@ checks only. They are not primary registry release evidence. - `rustc` / `cargo` versions; - git commit; - tag/version; +- resolved 40-character Git commit SHA; +- `SOURCE_DATE_EPOCH`; +- deterministic `BUILD_TIME` derived from `SOURCE_DATE_EPOCH`; - checks; - artifacts; - SBOM; diff --git a/docs/registry/RELEASE_EVIDENCE_RUNBOOK_RU.md b/docs/registry/RELEASE_EVIDENCE_RUNBOOK_RU.md index e0baf25..4fe2c8f 100644 --- a/docs/registry/RELEASE_EVIDENCE_RUNBOOK_RU.md +++ b/docs/registry/RELEASE_EVIDENCE_RUNBOOK_RU.md @@ -34,6 +34,19 @@ produced on the Russian build-runner. 17. Сохранить logs. 18. Сохранить final report. +Build provenance requirements: + +- `RELEASE_COMMIT` may be a SHA or tag, but manifest `release_commit` must be + the resolved full 40-character Git SHA. +- `GIT_COMMIT` must be exported during Rust build so `/version` and other + build/version probes can report the source revision. +- `SOURCE_DATE_EPOCH` defaults to the resolved commit timestamp and must be + recorded in release evidence. +- `BUILD_TIME` must be derived from `SOURCE_DATE_EPOCH`, not from wall-clock + build time. +- Wall-clock `generated_at` is evidence generation time only; it is not the + embedded build timestamp. + Если smoke требует live stand, не удалять и не скрывать его. В release report фиксировать: `skipped: requires live stand`. @@ -63,6 +76,8 @@ product/runtime release candidate. - `RELEASE_EVIDENCE_REPORT_RU.md`. - `SHA256SUMS`. - `logs/`. +- `logs/source-date-epoch.log`. +- `logs/build-time-utc.log`. - Source archive. - Binary artifacts archive или documented skip только для `DOCS_ONLY=1`. - `cargo-metadata.json` или documented skip. diff --git a/scripts/build_release_candidate.sh b/scripts/build_release_candidate.sh index 0babc93..ac00f21 100755 --- a/scripts/build_release_candidate.sh +++ b/scripts/build_release_candidate.sh @@ -129,7 +129,16 @@ trap cleanup_on_failure EXIT mkdir -p "$OUT_DIR" -git rev-parse HEAD > "$OUT_DIR/git-commit.txt" +GIT_COMMIT="$(git rev-parse HEAD)" +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct "$GIT_COMMIT")}" +BUILD_TIME="$(date -u -d "@${SOURCE_DATE_EPOCH}" +%Y-%m-%dT%H:%M:%SZ)" +export GIT_COMMIT +export SOURCE_DATE_EPOCH +export BUILD_TIME + +printf '%s\n' "$GIT_COMMIT" > "$OUT_DIR/git-commit.txt" +printf '%s\n' "$SOURCE_DATE_EPOCH" > "$OUT_DIR/source-date-epoch.txt" +printf '%s\n' "$BUILD_TIME" > "$OUT_DIR/build-time-utc.txt" cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check cargo test --manifest-path adk-rust/Cargo.toml --workspace diff --git a/scripts/build_release_evidence.sh b/scripts/build_release_evidence.sh index 69e56f5..b675f88 100644 --- a/scripts/build_release_evidence.sh +++ b/scripts/build_release_evidence.sh @@ -118,11 +118,21 @@ if [[ "$RELEASE_COMMIT" != "$(git rev-parse HEAD)" ]]; then git -c filter.lfs.smudge= -c filter.lfs.process= -c filter.lfs.required=false checkout --detach "$RELEASE_COMMIT" fi +RELEASE_COMMIT_INPUT="$RELEASE_COMMIT" +RELEASE_COMMIT="$(git rev-parse HEAD)" +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct "$RELEASE_COMMIT")}" +BUILD_TIME_UTC="$(date -u -d "@${SOURCE_DATE_EPOCH}" +%Y-%m-%dT%H:%M:%SZ)" +export GIT_COMMIT="$RELEASE_COMMIT" +export BUILD_TIME="$BUILD_TIME_UTC" +export SOURCE_DATE_EPOCH + { git remote -v } > "$LOG_DIR/git-remotes.log" 2>&1 git status --short > "$LOG_DIR/git-status-short.log" 2>&1 git rev-parse HEAD > "$LOG_DIR/git-rev-parse-head.log" 2>&1 +printf '%s\n' "$SOURCE_DATE_EPOCH" > "$LOG_DIR/source-date-epoch.log" +printf '%s\n' "$BUILD_TIME_UTC" > "$LOG_DIR/build-time-utc.log" git log --oneline -20 > "$LOG_DIR/git-log-oneline-20.log" 2>&1 capture_command rustc-version rustc --version capture_command cargo-version cargo --version @@ -198,14 +208,21 @@ git -c filter.lfs.smudge= -c filter.lfs.process= -c filter.lfs.required=false ar record_check "source_archive" "ok" "$SOURCE_ARCHIVE" BINARY_ARCHIVE="$ARTIFACT_DIR/${RELEASE_VERSION}-binaries.tar.gz" +if [[ -n "${CARGO_TARGET_DIR:-}" ]]; then + BINARY_RELEASE_DIR="${CARGO_TARGET_DIR%/}/release" +elif [[ -n "$CARGO_WORKSPACE_DIR" ]]; then + BINARY_RELEASE_DIR="$CARGO_WORKSPACE_DIR/target/release" +else + BINARY_RELEASE_DIR="$ROOT/target/release" +fi if [[ "$DOCS_ONLY" == "1" ]]; then printf 'skipped: DOCS_ONLY=1\n' > "$BINARY_ARCHIVE.skip" skip_check "binary_archive" "DOCS_ONLY=1" -elif [[ -d target/release ]]; then - tar -czf "$BINARY_ARCHIVE" target/release +elif [[ -d "$BINARY_RELEASE_DIR" ]]; then + tar -czf "$BINARY_ARCHIVE" -C "$BINARY_RELEASE_DIR" . record_check "binary_archive" "ok" "$BINARY_ARCHIVE" else - skip_check "binary_archive" "target/release missing" + skip_check "binary_archive" "$BINARY_RELEASE_DIR missing" fi if command -v cargo-cyclonedx >/dev/null 2>&1; then @@ -226,12 +243,22 @@ GENERATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" BUILD_RUNNER_HOST="$(hostname 2>/dev/null || printf unknown)" PRIMARY_SOURCE_REPOSITORY="https://git.iri1968.dpdns.org/awatch-rus/AWatch-rus" -python3 - "$OUTPUT_DIR/release-evidence-manifest.json" "$RELEASE_VERSION" "$RELEASE_COMMIT" "$GENERATED_AT" "$BUILD_RUNNER_HOST" "$PRIMARY_SOURCE_REPOSITORY" <<'PY' +python3 - "$OUTPUT_DIR/release-evidence-manifest.json" "$RELEASE_VERSION" "$RELEASE_COMMIT_INPUT" "$RELEASE_COMMIT" "$SOURCE_DATE_EPOCH" "$BUILD_TIME_UTC" "$GENERATED_AT" "$BUILD_RUNNER_HOST" "$PRIMARY_SOURCE_REPOSITORY" <<'PY' import json import sys from pathlib import Path -manifest_path, release_version, release_commit, generated_at, build_runner, primary_source = sys.argv[1:] +( + manifest_path, + release_version, + release_commit_input, + release_commit, + source_date_epoch, + build_time_utc, + generated_at, + build_runner, + primary_source, +) = sys.argv[1:] root = Path(manifest_path).parent checks = [] checks_file = root / "checks.tsv" @@ -248,7 +275,10 @@ for path in sorted((root / "artifacts").glob("*")): data = { "product": "AWatch-rus", "release_version": release_version, + "release_commit_input": release_commit_input, "release_commit": release_commit, + "source_date_epoch": int(source_date_epoch), + "build_time_utc": build_time_utc, "build_runner": build_runner, "primary_source_repository": primary_source, "github_role": "public_mirror_only", @@ -269,6 +299,12 @@ Release version: ${RELEASE_VERSION} Release commit: ${RELEASE_COMMIT} +Release commit input: ${RELEASE_COMMIT_INPUT} + +SOURCE_DATE_EPOCH: ${SOURCE_DATE_EPOCH} + +Build time UTC: ${BUILD_TIME_UTC} + Generated at: ${GENERATED_AT} Build runner: ${BUILD_RUNNER_HOST} @@ -300,7 +336,10 @@ EOF ( cd "$OUTPUT_DIR" - find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + sums_tmp="$(mktemp)" + trap 'rm -f "$sums_tmp"' EXIT + find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > "$sums_tmp" + mv "$sums_tmp" SHA256SUMS ) if [[ -f scripts/check_release_evidence.sh ]]; then diff --git a/scripts/check_release_evidence.sh b/scripts/check_release_evidence.sh index 1823da7..d11a5eb 100644 --- a/scripts/check_release_evidence.sh +++ b/scripts/check_release_evidence.sh @@ -48,7 +48,10 @@ if [[ -s "$EVIDENCE_DIR/release-evidence-manifest.json" ]]; then jq -e ' .product == "AWatch-rus" and (.release_version | type == "string" and length > 0) - and (.release_commit | type == "string" and length > 0) + and (.release_commit | type == "string" and test("^[0-9a-f]{40}$")) + and (.release_commit_input | type == "string" and length > 0) + and (.source_date_epoch | type == "number") + and (.build_time_utc | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and (.build_runner | type == "string" and length > 0) and .primary_source_repository == "https://git.iri1968.dpdns.org/awatch-rus/AWatch-rus" and .github_role == "public_mirror_only" @@ -72,9 +75,13 @@ required = { for key, value in required.items(): if data.get(key) != value: raise SystemExit(f"{key} mismatch") -for key in ("release_version", "release_commit", "build_runner", "generated_at"): +for key in ("release_version", "release_commit", "release_commit_input", "build_time_utc", "build_runner", "generated_at"): if not isinstance(data.get(key), str) or not data[key]: raise SystemExit(f"{key} missing") +if len(data["release_commit"]) != 40 or any(char not in "0123456789abcdef" for char in data["release_commit"]): + raise SystemExit("release_commit must be a full lowercase git SHA") +if not isinstance(data.get("source_date_epoch"), int): + raise SystemExit("source_date_epoch missing") if not isinstance(data.get("checks"), list): raise SystemExit("checks missing") if not isinstance(data.get("artifacts"), list): diff --git a/scripts/package_rust_release_binaries.py b/scripts/package_rust_release_binaries.py index aef0490..ff808b2 100644 --- a/scripts/package_rust_release_binaries.py +++ b/scripts/package_rust_release_binaries.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import hashlib import json +import os import shutil import stat import tarfile @@ -84,6 +85,18 @@ def create_compatibility_aliases(out_dir: Path, archive: Path) -> None: write_archive_checksum(archive_alias) +def build_time_utc() -> str: + source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH") + if source_date_epoch: + try: + stamp = datetime.fromtimestamp(int(source_date_epoch), timezone.utc) + except ValueError as exc: + raise SystemExit("SOURCE_DATE_EPOCH must be an integer Unix timestamp") from exc + else: + stamp = datetime.now(timezone.utc) + return stamp.isoformat(timespec="seconds").replace("+00:00", "Z") + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--release-dir", type=Path, required=True) @@ -127,7 +140,8 @@ def main() -> None: "commit": args.commit, "ref": args.ref, "run_id": args.run_id, - "build_time_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "source_date_epoch": os.environ.get("SOURCE_DATE_EPOCH", ""), + "build_time_utc": build_time_utc(), "binaries": manifest_binaries, } write(out_dir / "BUILD_MANIFEST.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")