diff --git a/.github/workflows/stable-1.0-rc-release.yml b/.github/workflows/stable-1.0-rc-release.yml new file mode 100644 index 0000000000..4bc84fbccd --- /dev/null +++ b/.github/workflows/stable-1.0-rc-release.yml @@ -0,0 +1,1385 @@ +name: Stable 1.0 RC Release Freeze +run-name: Stable 1.0 RC ${{ inputs.candidate_release_id }} build ${{ inputs.integer_build_version }} + +on: + workflow_dispatch: + inputs: + candidate_release_id: + description: Path-safe release ID shared by every candidate-bound evidence envelope. + required: true + type: string + integer_build_version: + description: Integer Cryptad build; must match ./gradlew -q printVersion. + required: true + type: string + stable_rc_freeze_mode: + description: Select first-freeze only for the initial candidate baseline; all later runs are refreeze. + required: true + type: choice + options: + - first-freeze + - refreeze + artifact_base_uri: + description: Reviewed public HTTPS base URI for staged Stable RC app artifacts. + required: true + type: string + live_network_summary: + description: Candidate-bound live-network summary path, HTTPS URL, or actions-artifact reference. + required: true + type: string + multi_node_soak_summary: + description: Candidate-bound production multi-node soak summary path, HTTPS URL, or actions-artifact reference. + required: true + type: string + network_scale_soak_summary: + description: Candidate-bound live network-scale soak summary path, HTTPS URL, or actions-artifact reference. + required: true + type: string + previous_candidate_summary: + description: Candidate-bound previous-candidate upgrade summary path, HTTPS URL, or actions-artifact reference. + required: true + type: string + release_history_summary: + description: Candidate-bound release-history summary path, HTTPS URL, or actions-artifact reference. + required: true + type: string + security_drills_summary: + description: Security-drill summary path or actions-artifact reference with all scenario sidecars. + required: true + type: string + third_party_intake_summary: + description: Candidate release/build-bound production third-party intake summary path, HTTPS URL, or actions-artifact reference. + required: true + type: string + public_beta_known_issues: + description: Reviewed public beta known-issues data path, HTTPS URL, or actions-artifact reference. + required: true + type: string + stable_catalog_operations: + description: Candidate-bound stable catalog operations summary with the frozen artifactTimestamp. + required: true + type: string + previous_stable_rc_freeze: + description: Retained latest successful freeze path, HTTPS URL, or artifact reference; required in refreeze and forbidden in first-freeze. + required: false + default: '' + type: string + go_no_go_waivers: + description: Optional policy-compliant go/no-go waiver collection path, HTTPS URL, or actions-artifact reference. + required: false + default: '' + type: string + stable_readiness_waivers: + description: Optional policy-compliant Stable-readiness waiver collection path, HTTPS URL, or actions-artifact reference. + required: false + default: '' + type: string + stable_rc_freeze_exceptions: + description: Optional Stable RC freeze-exception collection path, HTTPS URL, or actions-artifact reference. + required: false + default: '' + type: string + +concurrency: + group: stable-1-0-rc-${{ inputs.candidate_release_id }}-${{ inputs.integer_build_version }} + cancel-in-progress: false + +jobs: + stable-rc: + runs-on: ubuntu-latest + timeout-minutes: 360 + environment: stable-1-0-rc + permissions: + contents: read + actions: read + checks: write + + steps: + - name: Check out the candidate commit + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5 + + - name: Install Gradle properties + uses: ./.github/actions/setup-gradle-properties + + - name: Validate candidate identity and clean checkout + env: + INPUT_RELEASE_ID: ${{ inputs.candidate_release_id }} + INPUT_BUILD_VERSION: ${{ inputs.integer_build_version }} + INPUT_ARTIFACT_BASE_URI: ${{ inputs.artifact_base_uri }} + run: | + if [[ ! "$INPUT_RELEASE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "::error::candidate_release_id must be a path-safe release ID." + exit 1 + fi + if [[ ! "$INPUT_BUILD_VERSION" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::integer_build_version must be a positive decimal integer." + exit 1 + fi + if [[ ! "$INPUT_ARTIFACT_BASE_URI" =~ ^https:// ]]; then + echo "::error::artifact_base_uri must use HTTPS." + exit 1 + fi + if [[ "$INPUT_ARTIFACT_BASE_URI" == *"REPLACE_ME"* || "$INPUT_ARTIFACT_BASE_URI" == *"example.invalid"* ]]; then + echo "::error::artifact_base_uri contains a placeholder." + exit 1 + fi + if [[ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then + echo "::error::The checked-out commit does not match the workflow candidate commit." + exit 1 + fi + if [[ -n "$(git status --porcelain=v1 --untracked-files=all)" ]]; then + echo "::error::Stable RC execution requires a clean workspace." + git status --short + exit 1 + fi + + - name: Verify the integer project build + env: + INPUT_BUILD_VERSION: ${{ inputs.integer_build_version }} + run: | + project_version="$(./gradlew -q printVersion)" + if [[ ! "$project_version" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::Expected an integer project version, got '$project_version'." + exit 1 + fi + if [[ "$project_version" != "$INPUT_BUILD_VERSION" ]]; then + echo "::error::Manifest build $INPUT_BUILD_VERSION does not match project build $project_version." + exit 1 + fi + + - name: Run Stable RC deterministic self-test + run: python3 tools/release-certification/certify.py stable-rc --self-test + + - name: Run full candidate build + run: ./gradlew build + + - name: Run Hyphanet interop smoke + env: + INTEROP_SKIP_BUILD: '1' + run: tools/interop/run-hyphanet-interop-smoke.sh + + - name: Run performance smoke + env: + PERF_SKIP_BUILD: '1' + run: tools/perf/run-performance-smoke.sh + + - name: Execute and freeze the Stable 1.0 release candidate + env: + INPUT_RELEASE_ID: ${{ inputs.candidate_release_id }} + INPUT_BUILD_VERSION: ${{ inputs.integer_build_version }} + INPUT_STABLE_RC_FREEZE_MODE: ${{ inputs.stable_rc_freeze_mode }} + INPUT_ARTIFACT_BASE_URI: ${{ inputs.artifact_base_uri }} + INPUT_LIVE_NETWORK_SUMMARY: ${{ inputs.live_network_summary }} + INPUT_MULTI_NODE_SOAK_SUMMARY: ${{ inputs.multi_node_soak_summary }} + INPUT_NETWORK_SCALE_SOAK_SUMMARY: ${{ inputs.network_scale_soak_summary }} + INPUT_PREVIOUS_CANDIDATE_SUMMARY: ${{ inputs.previous_candidate_summary }} + INPUT_RELEASE_HISTORY_SUMMARY: ${{ inputs.release_history_summary }} + INPUT_SECURITY_DRILLS_SUMMARY: ${{ inputs.security_drills_summary }} + INPUT_THIRD_PARTY_INTAKE_SUMMARY: ${{ inputs.third_party_intake_summary }} + INPUT_PUBLIC_BETA_KNOWN_ISSUES: ${{ inputs.public_beta_known_issues }} + INPUT_STABLE_CATALOG_OPERATIONS: ${{ inputs.stable_catalog_operations }} + INPUT_PREVIOUS_STABLE_RC_FREEZE: ${{ inputs.previous_stable_rc_freeze }} + INPUT_GO_NO_GO_WAIVERS: ${{ inputs.go_no_go_waivers }} + INPUT_STABLE_READINESS_WAIVERS: ${{ inputs.stable_readiness_waivers }} + INPUT_STABLE_RC_FREEZE_EXCEPTIONS: ${{ inputs.stable_rc_freeze_exceptions }} + CRYPTAD_APP_SIGNING_KEY_ID: ${{ secrets.CRYPTAD_APP_SIGNING_KEY_ID }} + CRYPTAD_APP_SIGNING_PRIVATE_KEY_FILE: ${{ secrets.CRYPTAD_APP_SIGNING_PRIVATE_KEY_FILE }} + CRYPTAD_APP_SIGNING_PRIVATE_KEY_BASE64: ${{ secrets.CRYPTAD_APP_SIGNING_PRIVATE_KEY_BASE64 }} + CRYPTAD_APP_SIGNING_PUBLIC_KEY_FILE: ${{ secrets.CRYPTAD_APP_SIGNING_PUBLIC_KEY_FILE }} + CRYPTAD_APP_SIGNING_PUBLIC_KEY_BASE64: ${{ secrets.CRYPTAD_APP_SIGNING_PUBLIC_KEY_BASE64 }} + CRYPTAD_APP_REVIEWER_KEY_ID: ${{ secrets.CRYPTAD_APP_REVIEWER_KEY_ID }} + CRYPTAD_APP_REVIEWER_PRIVATE_KEY_FILE: ${{ secrets.CRYPTAD_APP_REVIEWER_PRIVATE_KEY_FILE }} + CRYPTAD_APP_REVIEWER_PRIVATE_KEY_BASE64: ${{ secrets.CRYPTAD_APP_REVIEWER_PRIVATE_KEY_BASE64 }} + CRYPTAD_APP_REVIEWER_PUBLIC_KEY_FILE: ${{ secrets.CRYPTAD_APP_REVIEWER_PUBLIC_KEY_FILE }} + CRYPTAD_APP_REVIEWER_PUBLIC_KEY_BASE64: ${{ secrets.CRYPTAD_APP_REVIEWER_PUBLIC_KEY_BASE64 }} + CRYPTAD_APP_REVIEW_POLICY_ID: ${{ secrets.CRYPTAD_APP_REVIEW_POLICY_ID }} + CRYPTAD_APP_REVIEW_POLICY_VERSION: ${{ secrets.CRYPTAD_APP_REVIEW_POLICY_VERSION }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + require_env() { + local name="$1" + local description="$2" + if [[ -z "${!name:-}" ]]; then + echo "::error::$description ($name) is required for protected Stable RC execution." + exit 1 + fi + } + + require_file() { + local path="$1" + local description="$2" + if [[ -z "$path" || ! -f "$path" ]]; then + echo "::error::$description must be a readable regular file." + exit 1 + fi + if [[ -L "$path" ]]; then + echo "::error::$description cannot be a symlink." + exit 1 + fi + } + + materialize_secret_file() { + local encoded="$1" + local env_name="$2" + local file_name="$3" + if [[ -z "$encoded" ]]; then + return 0 + fi + local path="$RUNNER_TEMP/cryptad-stable-rc-secrets/$file_name" + mkdir -p "$(dirname "$path")" + printf '%s' "$encoded" | base64 --decode > "$path" + chmod 600 "$path" + export "$env_name=$path" + } + + materialize_actions_artifact_file() { + local value="$1" + local env_name="$2" + local file_name="$3" + local description="$4" + local rest="${value#actions-artifact://}" + local run_id="${rest%%/*}" + if [[ ! "$run_id" =~ ^[0-9]+$ || "$run_id" == "$rest" ]]; then + echo "::error::$description reference must start actions-artifact:///." + exit 1 + fi + rest="${rest#*/}" + local artifact_name="${rest%%/*}" + if [[ ! "$artifact_name" =~ ^[A-Za-z0-9._-]+$ || "$artifact_name" == "." || "$artifact_name" == ".." || "$artifact_name" == "$rest" ]]; then + echo "::error::$description reference must contain a path-safe artifact name and relative file path." + exit 1 + fi + local artifact_path="${rest#*/}" + if [[ -z "$artifact_path" || "$artifact_path" == /* || "$artifact_path" == ".." || "$artifact_path" == ../* || "$artifact_path" == */../* || "$artifact_path" == */.. ]]; then + echo "::error::$description artifact path is unsafe." + exit 1 + fi + local download_dir="build/stable-rc-protected-inputs/$file_name-artifact" + rm -rf "$download_dir" + mkdir -p "$download_dir" + gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name "$artifact_name" --dir "$download_dir" + if [[ -L "$download_dir" ]]; then + echo "::error::$description download directory became a symlink." + exit 1 + fi + local path="$download_dir/$artifact_path" + if [[ ! -f "$path" && -f "$download_dir/$artifact_name/$artifact_path" ]]; then + path="$download_dir/$artifact_name/$artifact_path" + fi + require_file "$path" "$description" + local requested_relative="${path#"$download_dir"/}" + local current_path="$download_dir" + IFS='/' read -r -a path_parts <<< "$requested_relative" + for path_part in "${path_parts[@]}"; do + current_path="$current_path/$path_part" + if [[ -L "$current_path" ]]; then + echo "::error::$description contains a symlinked path component." + exit 1 + fi + done + local download_root + local resolved_path + download_root="$(realpath -e "$download_dir")" + resolved_path="$(realpath -e "$path")" + if [[ "$resolved_path" != "$download_root"/* ]]; then + echo "::error::$description resolved outside its downloaded artifact directory." + exit 1 + fi + chmod 600 "$path" + export "$env_name=$path" + } + + materialize_input_file() { + local value="$1" + local env_name="$2" + local file_name="$3" + local description="$4" + if [[ -z "$value" ]]; then + return 0 + fi + case "$value" in + https://*) + local path="build/stable-rc-protected-inputs/$file_name" + mkdir -p "$(dirname "$path")" + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 --output "$path" "$value" + chmod 600 "$path" + export "$env_name=$path" + ;; + http://*) + echo "::error::$description URL must use HTTPS." + exit 1 + ;; + actions-artifact://*) + materialize_actions_artifact_file "$value" "$env_name" "$file_name" "$description" + ;; + *) + if [[ "$value" == /* || "$value" == ".." || "$value" == ../* || "$value" == */../* || "$value" == */.. || "$value" == *'\'* ]]; then + echo "::error::$description must use a safe repository-relative path." + exit 1 + fi + local current_path="$GITHUB_WORKSPACE" + local -a path_parts=() + IFS='/' read -r -a path_parts <<< "$value" + for path_part in "${path_parts[@]}"; do + current_path="$current_path/$path_part" + if [[ -L "$current_path" ]]; then + echo "::error::$description contains a symlinked path component." + exit 1 + fi + done + require_file "$value" "$description" + local workspace_root + local resolved_path + workspace_root="$(realpath -e "$GITHUB_WORKSPACE")" + resolved_path="$(realpath -e "$value")" + if [[ "$resolved_path" != "$workspace_root"/* ]]; then + echo "::error::$description resolved outside the checked-out workspace." + exit 1 + fi + export "$env_name=$value" + ;; + esac + } + + freeze_lineage_anchor_name() { + printf 'Stable 1.0 RC freeze lineage / %s / build %s' \ + "$INPUT_RELEASE_ID" \ + "$INPUT_BUILD_VERSION" + } + + verify_refreeze_lineage_anchor() { + local run_id="$1" + local run_attempt="$2" + local parent_sha="$3" + local supplied_freeze="$4" + if [[ ! "$parent_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::The latest successful Stable RC source commit is malformed." + exit 1 + fi + local digest_hex + digest_hex="$(sha256sum "$supplied_freeze" | awk '{print $1}')" + if [[ ! "$digest_hex" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::The supplied previous freeze digest is malformed." + exit 1 + fi + local expected_external_id + expected_external_id="stable-1.0-rc-freeze:$INPUT_RELEASE_ID:$INPUT_BUILD_VERSION:$run_id:$run_attempt:sha256:$digest_hex" + local anchor_name + anchor_name="$(freeze_lineage_anchor_name)" + local matching_anchors + matching_anchors="$( + gh api --paginate --method GET \ + "repos/$GITHUB_REPOSITORY/commits/$parent_sha/check-runs" \ + -f check_name="$anchor_name" \ + -f per_page=100 \ + | jq -s -r \ + --arg anchor_name "$anchor_name" \ + --arg expected_external_id "$expected_external_id" \ + --arg parent_sha "${parent_sha,,}" \ + '[.[].check_runs[] + | select(.name == $anchor_name + and .status == "completed" + and .conclusion == "success" + and (.head_sha | ascii_downcase) == $parent_sha + and .app.slug == "github-actions" + and .external_id == $expected_external_id)] + | length' + )" + if [[ "$matching_anchors" != "1" ]]; then + echo "::error::The supplied previous freeze does not match the authenticated lineage anchor from the latest successful protected run." + exit 1 + fi + } + + verify_latest_refreeze_parent() { + local run_id="$1" + local run_attempt="$2" + local parent_sha="$3" + local supplied_freeze="$4" + local artifact_name="stable-1-0-rc-$INPUT_RELEASE_ID-$INPUT_BUILD_VERSION-$run_id-$run_attempt" + local download_dir="build/stable-rc-protected-inputs/latest-successful-freeze" + rm -rf "$download_dir" + mkdir -p "$download_dir" + if ! gh run download "$run_id" \ + --repo "$GITHUB_REPOSITORY" \ + --name "$artifact_name" \ + --dir "$download_dir"; then + echo "::warning::The latest Stable RC artifact is unavailable; verifying the retained freeze against its authenticated check-run lineage anchor." + verify_refreeze_lineage_anchor \ + "$run_id" \ + "$run_attempt" \ + "$parent_sha" \ + "$supplied_freeze" + return + fi + if [[ -n "$(find "$download_dir" -type l -print -quit)" ]]; then + echo "::error::The latest successful Stable RC artifact contains a symlink." + exit 1 + fi + local -a authenticated_freezes=() + mapfile -t authenticated_freezes < <( + find "$download_dir" -type f -name stable-1.0-rc-freeze.json -print | sort + ) + if [[ "${#authenticated_freezes[@]}" -ne 1 ]]; then + echo "::error::The latest successful Stable RC artifact must contain exactly one freeze manifest." + exit 1 + fi + require_file "${authenticated_freezes[0]}" "Latest successful Stable RC freeze" + if ! cmp --silent "$supplied_freeze" "${authenticated_freezes[0]}"; then + echo "::error::The supplied previous freeze is not the exact freeze from the latest successful protected run." + exit 1 + fi + } + + materialize_security_drills() { + local value="$1" + case "$value" in + http://*|https://*) + echo "::error::Security drills require a local or actions-artifact input with all scenario sidecars; a summary-only URL is insufficient." + exit 1 + ;; + *) + materialize_input_file "$value" INPUT_SECURITY_DRILLS_SUMMARY security-drills-summary.json "Security drills summary" + ;; + esac + } + + display_title="Stable 1.0 RC $INPUT_RELEASE_ID build $INPUT_BUILD_VERSION" + if [[ "$GITHUB_RUN_ATTEMPT" != "1" ]]; then + echo "::error::Stable RC workflow runs are immutable. Dispatch a new run instead of rerunning an existing run." + exit 1 + fi + local_workflow_path=".github/workflows/stable-1.0-rc-release.yml" + candidate_runs=() + mapfile -t candidate_runs < <( + gh api --paginate --method GET \ + "repos/$GITHUB_REPOSITORY/actions/workflows/stable-1.0-rc-release.yml/runs" \ + -f event=workflow_dispatch \ + -f per_page=100 \ + | jq -s -r \ + --arg display_title "$display_title" \ + --arg current_run "$GITHUB_RUN_ID" \ + '[.[].workflow_runs[] | select((.id | tostring) != $current_run and .display_title == $display_title)] + | unique_by(.id) + | .[] + | [.id, .run_attempt] + | @tsv' + ) + latest_successful_run_id="" + latest_successful_run_attempt="" + latest_successful_head_sha="" + for candidate_run in "${candidate_runs[@]}"; do + IFS=$'\t' read -r candidate_run_id candidate_run_attempts <<< "$candidate_run" + if [[ ! "$candidate_run_id" =~ ^[0-9]+$ || ! "$candidate_run_attempts" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::A Stable RC workflow history entry is malformed." + exit 1 + fi + for ((attempt = 1; attempt <= candidate_run_attempts; attempt++)); do + attempt_json="$( + gh api --method GET \ + "repos/$GITHUB_REPOSITORY/actions/runs/$candidate_run_id/attempts/$attempt" + )" + if jq -e \ + --arg display_title "$display_title" \ + --arg workflow_path "$local_workflow_path" \ + --arg repository "$GITHUB_REPOSITORY" \ + '.conclusion == "success" + and .event == "workflow_dispatch" + and .display_title == $display_title + and (.head_sha | test("^[0-9a-fA-F]{40}$")) + and (.path == $workflow_path or (.path | startswith($workflow_path + "@"))) + and .repository.full_name == $repository' \ + <<< "$attempt_json" >/dev/null; then + if [[ -z "$latest_successful_run_id" \ + || "$candidate_run_id" -gt "$latest_successful_run_id" \ + || ("$candidate_run_id" -eq "$latest_successful_run_id" \ + && "$attempt" -gt "$latest_successful_run_attempt") ]]; then + latest_successful_run_id="$candidate_run_id" + latest_successful_run_attempt="$attempt" + latest_successful_head_sha="$(jq -r '.head_sha | ascii_downcase' <<< "$attempt_json")" + fi + fi + done + done + + case "$INPUT_STABLE_RC_FREEZE_MODE" in + first-freeze) + if [[ -n "$INPUT_PREVIOUS_STABLE_RC_FREEZE" ]]; then + echo "::error::first-freeze mode forbids a previous_stable_rc_freeze input." + exit 1 + fi + if [[ -n "$latest_successful_run_id" ]]; then + echo "::error::A successful Stable RC baseline already exists for this release ID and build. Use refreeze mode with the exact prior freeze." + exit 1 + fi + ;; + refreeze) + if [[ -z "$INPUT_PREVIOUS_STABLE_RC_FREEZE" ]]; then + echo "::error::refreeze mode requires previous_stable_rc_freeze." + exit 1 + fi + if [[ -z "$latest_successful_run_id" ]]; then + echo "::error::refreeze mode requires a prior successful protected Stable RC run. Use first-freeze for the initial baseline." + exit 1 + fi + ;; + *) + echo "::error::stable_rc_freeze_mode must be first-freeze or refreeze." + exit 1 + ;; + esac + + materialize_input_file "$INPUT_LIVE_NETWORK_SUMMARY" INPUT_LIVE_NETWORK_SUMMARY live-network-summary.json "Live-network summary" + materialize_input_file "$INPUT_MULTI_NODE_SOAK_SUMMARY" INPUT_MULTI_NODE_SOAK_SUMMARY multi-node-soak-summary.json "Multi-node soak summary" + materialize_input_file "$INPUT_NETWORK_SCALE_SOAK_SUMMARY" INPUT_NETWORK_SCALE_SOAK_SUMMARY network-scale-soak-summary.json "Network-scale soak summary" + materialize_input_file "$INPUT_PREVIOUS_CANDIDATE_SUMMARY" INPUT_PREVIOUS_CANDIDATE_SUMMARY previous-candidate-summary.json "Previous-candidate summary" + materialize_input_file "$INPUT_RELEASE_HISTORY_SUMMARY" INPUT_RELEASE_HISTORY_SUMMARY release-history-summary.json "Release-history summary" + materialize_security_drills "$INPUT_SECURITY_DRILLS_SUMMARY" + materialize_input_file "$INPUT_THIRD_PARTY_INTAKE_SUMMARY" INPUT_THIRD_PARTY_INTAKE_SUMMARY third-party-intake-summary.json "Third-party intake summary" + materialize_input_file "$INPUT_PUBLIC_BETA_KNOWN_ISSUES" INPUT_PUBLIC_BETA_KNOWN_ISSUES public-beta-known-issues.json "Public beta known issues" + materialize_input_file "$INPUT_STABLE_CATALOG_OPERATIONS" INPUT_STABLE_CATALOG_OPERATIONS stable-catalog-operations-summary.json "Stable catalog operations summary" + materialize_input_file "$INPUT_PREVIOUS_STABLE_RC_FREEZE" INPUT_PREVIOUS_STABLE_RC_FREEZE previous-stable-rc-freeze.json "Previous Stable RC freeze" + if [[ "$INPUT_STABLE_RC_FREEZE_MODE" == "refreeze" ]]; then + verify_latest_refreeze_parent \ + "$latest_successful_run_id" \ + "$latest_successful_run_attempt" \ + "$latest_successful_head_sha" \ + "$INPUT_PREVIOUS_STABLE_RC_FREEZE" + fi + materialize_input_file "$INPUT_GO_NO_GO_WAIVERS" INPUT_GO_NO_GO_WAIVERS go-no-go-waivers.json "Go/no-go waivers" + materialize_input_file "$INPUT_STABLE_READINESS_WAIVERS" INPUT_STABLE_READINESS_WAIVERS stable-readiness-waivers.json "Stable readiness waivers" + materialize_input_file "$INPUT_STABLE_RC_FREEZE_EXCEPTIONS" INPUT_STABLE_RC_FREEZE_EXCEPTIONS stable-rc-freeze-exceptions.json "Stable RC freeze exceptions" + unset GH_TOKEN + + materialize_secret_file "${CRYPTAD_APP_SIGNING_PRIVATE_KEY_BASE64:-}" CRYPTAD_APP_SIGNING_PRIVATE_KEY_FILE app-signing-private.der + materialize_secret_file "${CRYPTAD_APP_SIGNING_PUBLIC_KEY_BASE64:-}" CRYPTAD_APP_SIGNING_PUBLIC_KEY_FILE app-signing-public.der + materialize_secret_file "${CRYPTAD_APP_REVIEWER_PRIVATE_KEY_BASE64:-}" CRYPTAD_APP_REVIEWER_PRIVATE_KEY_FILE reviewer-private.der + materialize_secret_file "${CRYPTAD_APP_REVIEWER_PUBLIC_KEY_BASE64:-}" CRYPTAD_APP_REVIEWER_PUBLIC_KEY_FILE reviewer-public.der + + require_env CRYPTAD_APP_SIGNING_KEY_ID "Production app signing key ID" + require_file "${CRYPTAD_APP_SIGNING_PRIVATE_KEY_FILE:-}" "Production app signing private key" + require_file "${CRYPTAD_APP_SIGNING_PUBLIC_KEY_FILE:-}" "Production app signing public key" + require_env CRYPTAD_APP_REVIEWER_KEY_ID "Production reviewer key ID" + require_file "${CRYPTAD_APP_REVIEWER_PRIVATE_KEY_FILE:-}" "Production reviewer private key" + require_file "${CRYPTAD_APP_REVIEWER_PUBLIC_KEY_FILE:-}" "Production reviewer public key" + require_env CRYPTAD_APP_REVIEW_POLICY_ID "Production app review policy ID" + require_env CRYPTAD_APP_REVIEW_POLICY_VERSION "Production app review policy version" + for required_input in \ + INPUT_LIVE_NETWORK_SUMMARY \ + INPUT_MULTI_NODE_SOAK_SUMMARY \ + INPUT_NETWORK_SCALE_SOAK_SUMMARY \ + INPUT_PREVIOUS_CANDIDATE_SUMMARY \ + INPUT_RELEASE_HISTORY_SUMMARY \ + INPUT_SECURITY_DRILLS_SUMMARY \ + INPUT_THIRD_PARTY_INTAKE_SUMMARY \ + INPUT_PUBLIC_BETA_KNOWN_ISSUES \ + INPUT_STABLE_CATALOG_OPERATIONS; do + require_file "${!required_input:-}" "$required_input" + done + for optional_input in \ + INPUT_PREVIOUS_STABLE_RC_FREEZE \ + INPUT_GO_NO_GO_WAIVERS \ + INPUT_STABLE_READINESS_WAIVERS \ + INPUT_STABLE_RC_FREEZE_EXCEPTIONS; do + if [[ -n "${!optional_input:-}" ]]; then + require_file "${!optional_input}" "$optional_input" + fi + done + + manifest="$RUNNER_TEMP/stable-1.0-rc-manifest.json" + jq -n \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg release_version "$INPUT_BUILD_VERSION" \ + --arg freeze_mode "$INPUT_STABLE_RC_FREEZE_MODE" \ + --arg artifact_base_uri "$INPUT_ARTIFACT_BASE_URI" \ + --arg live_network "$INPUT_LIVE_NETWORK_SUMMARY" \ + --arg multi_node "$INPUT_MULTI_NODE_SOAK_SUMMARY" \ + --arg network_scale "$INPUT_NETWORK_SCALE_SOAK_SUMMARY" \ + --arg previous_candidate "$INPUT_PREVIOUS_CANDIDATE_SUMMARY" \ + --arg release_history "$INPUT_RELEASE_HISTORY_SUMMARY" \ + --arg security_drills "$INPUT_SECURITY_DRILLS_SUMMARY" \ + --arg third_party "$INPUT_THIRD_PARTY_INTAKE_SUMMARY" \ + --arg public_known_issues "$INPUT_PUBLIC_BETA_KNOWN_ISSUES" \ + --arg catalog_operations "$INPUT_STABLE_CATALOG_OPERATIONS" \ + --arg previous_freeze "$INPUT_PREVIOUS_STABLE_RC_FREEZE" \ + --arg go_no_go_waivers "$INPUT_GO_NO_GO_WAIVERS" \ + --arg stable_waivers "$INPUT_STABLE_READINESS_WAIVERS" \ + --arg freeze_exceptions "$INPUT_STABLE_RC_FREEZE_EXCEPTIONS" \ + '{ + schemaVersion: 1, + release: {id: $release_id, version: $release_version, profile: "stable-review"}, + output: {root: "build/release-certification", reset: true}, + requirements: { + history: true, + liveNetwork: true, + multiNodeSoak: true, + sandboxProviderTests: true, + stableReadiness: true, + thirdPartyIntake: true + }, + inputs: ({ + interopSmoke: "build/interop-smoke/summary.json", + liveNetwork: $live_network, + multiNodeSoak: $multi_node, + networkScaleSoak: $network_scale, + performanceSmoke: "build/perf-smoke/summary.json", + previousCandidate: $previous_candidate, + publicBetaKnownIssues: $public_known_issues, + releaseHistory: $release_history, + securityDrills: $security_drills, + stableCatalogOperations: $catalog_operations, + stableKnownLimitations: "tools/release-certification/stable-1.0-known-limitations.json", + stableReadinessPolicy: "tools/release-certification/stable-1.0-readiness-policy.json", + thirdPartyIntake: $third_party + } + + (if $previous_freeze == "" then {} else {previousStableRcFreeze: $previous_freeze} end) + + (if $go_no_go_waivers == "" then {} else {waiverFile: $go_no_go_waivers} end) + + (if $stable_waivers == "" then {} else {stableReadinessWaivers: $stable_waivers} end) + + (if $freeze_exceptions == "" then {} else {stableRcFreezeExceptions: $freeze_exceptions} end)), + policies: { + artifactBaseUri: $artifact_base_uri, + catalogChannel: "stable", + stableRcFreezeMode: $freeze_mode + }, + execution: { + allowDirtyWorkspace: false, + allowTestSigningInProduction: false, + collectEvidence: true, + collectLiveNetwork: false, + emergencySkipBuild: false, + emergencySkipLiveNetwork: false, + fixtureEvidence: false, + generateStableReadiness: true, + runMultiNodeSoak: false, + skipFullBuild: false, + skipGitMetadata: false, + skipGradle: false, + writeHistory: false + }, + commands: {"stable-rc": {args: []}} + }' > "$manifest" + + python3 tools/release-certification/certify.py stable-rc --manifest "$manifest" + + - name: Verify final Stable RC decision and candidate binding + id: final_gate + env: + INPUT_RELEASE_ID: ${{ inputs.candidate_release_id }} + INPUT_BUILD_VERSION: ${{ inputs.integer_build_version }} + INPUT_STABLE_RC_FREEZE_MODE: ${{ inputs.stable_rc_freeze_mode }} + run: | + component="build/release-certification/$INPUT_RELEASE_ID/stable-rc" + summary="$component/summary.json" + if [[ ! -f "$summary" || -L "$summary" ]]; then + echo "::error::Stable RC v2 summary is missing or unsafe." + exit 1 + fi + for common_artifact in report.md redaction-report.json; do + if [[ ! -f "$component/$common_artifact" || -L "$component/$common_artifact" ]]; then + echo "::error::Required common Stable RC artifact $common_artifact is missing or unsafe." + exit 1 + fi + done + jq -e \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg version "$INPUT_BUILD_VERSION" \ + --arg freeze_mode "$INPUT_STABLE_RC_FREEZE_MODE" \ + '.payload.legacy.decision as $decision + | .schemaVersion == 2 + and .kind == "stable-1.0-rc" + and .subject.releaseId == $release_id + and .subject.version == $version + and .subject.profile == "stable-review" + and .subject.component == "stable-rc" + and .result.status == "pass" + and .result.promotionReady == true + and .result.exitCode == 0 + and .redaction.status == "pass" + and .redaction.findingCount == 0 + and .payload.legacy.nonRelease == false + and .payload.legacy.stableReady == true + and .payload.legacy.freeze.status == "pass" + and .payload.legacy.freeze.mode == $freeze_mode + and .payload.legacy.freeze.driftStatus == "no-drift" + and (["go", "go-with-waivers"] | index($decision) != null)' \ + "$summary" >/dev/null + + legacy="$component/artifacts/legacy" + if [[ ! -d "$legacy" || -L "$legacy" ]]; then + echo "::error::Stable RC native artifact directory is missing or unsafe." + exit 1 + fi + for native_artifact in \ + stable-1.0-rc-freeze.json \ + stable-1.0-rc-freeze.sha256 \ + stable-1.0-rc-freeze-report.md \ + stable-1.0-rc-drift-report.json \ + stable-1.0-rc-promotion-summary.json \ + stable-1.0-rc-go-no-go.md \ + stable-1.0-rc-known-limitations.json \ + stable-1.0-rc-release-notes.md \ + checksums.txt \ + provenance.json \ + redaction-report.json; do + if [[ ! -f "$legacy/$native_artifact" || -L "$legacy/$native_artifact" ]]; then + echo "::error::Required native Stable RC artifact $native_artifact is missing or unsafe." + exit 1 + fi + expected_occurrences=1 + if [[ "$native_artifact" == "redaction-report.json" ]]; then + expected_occurrences=2 + fi + actual_occurrences="$(find "$component" -type f -name "$native_artifact" -print | wc -l)" + if [[ "$actual_occurrences" -ne "$expected_occurrences" ]]; then + echo "::error::Stable RC artifact $native_artifact has an unexpected duplicate or location." + exit 1 + fi + done + promotion_summary="$legacy/stable-1.0-rc-promotion-summary.json" + jq -e \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg version "$INPUT_BUILD_VERSION" \ + --arg freeze_mode "$INPUT_STABLE_RC_FREEZE_MODE" ' + .decision as $decision + | .kind == "stable-1.0-rc" + and .releaseId == $release_id + and .buildVersion == $version + and .status == "pass" + and .promotionReady == true + and .nonRelease == false + and .stableReady == true + and .freeze.status == "pass" + and .freeze.mode == $freeze_mode + and .freeze.driftStatus == "no-drift" + and .redactionStatus == "pass" + and .redaction.status == "pass" + and .redaction.findingCount == 0 + and .blockerCount == 0 + and (["go", "go-with-waivers"] | index($decision) != null)' \ + "$promotion_summary" >/dev/null + echo "component=$component" >> "$GITHUB_OUTPUT" + echo "promotable=true" >> "$GITHUB_OUTPUT" + + - name: Verify post-package freeze, checksums, and archive hygiene + env: + COMPONENT: ${{ steps.final_gate.outputs.component }} + INPUT_RELEASE_ID: ${{ inputs.candidate_release_id }} + INPUT_BUILD_VERSION: ${{ inputs.integer_build_version }} + INPUT_STABLE_RC_FREEZE_MODE: ${{ inputs.stable_rc_freeze_mode }} + MANIFEST: ${{ runner.temp }}/stable-1.0-rc-manifest.json + run: | + set -euo pipefail + if [[ -z "$COMPONENT" || ! -d "$COMPONENT" || -L "$COMPONENT" ]]; then + echo "::error::Stable RC component directory is missing or unsafe." + exit 1 + fi + unsafe_link="$(find "$COMPONENT" -type l -print -quit)" + if [[ -n "$unsafe_link" ]]; then + echo "::error::Stable RC public output contains a symlink." + exit 1 + fi + unsafe_special="$(find "$COMPONENT" -mindepth 1 ! -type f ! -type d -print -quit)" + if [[ -n "$unsafe_special" ]]; then + echo "::error::Stable RC public output contains a non-regular filesystem entry." + exit 1 + fi + unsafe_name="$(find "$COMPONENT" \( -iname '._*' -o -iname '.DS_Store' -o -iname '__MACOSX' \) -print -quit)" + if [[ -n "$unsafe_name" ]]; then + echo "::error::Stable RC public output contains an unsafe platform metadata name." + exit 1 + fi + + unsafe_secret_name="$(find "$COMPONENT" -regextype posix-extended -type f \ + \( -iregex '.*/[^/]*(credentials|password|cookie|authorization|auth|secret|token|private)[^/]*' \ + -o -iregex '.*\.(pem|der|p12|pfx|key)' \) -print -quit)" + if [[ -n "$unsafe_secret_name" ]]; then + echo "::error::Stable RC public output contains a secret-like filename." + exit 1 + fi + + drift_report="$COMPONENT/artifacts/legacy/stable-1.0-rc-drift-report.json" + jq -e ' + .schemaVersion == 1 + and .kind == "stable-1.0-rc-freeze-drift" + and .status == "no-drift" + and (.changes | type) == "array" + and .driftCount == (.changes | length) + and (.approvedChanges | type) == "array" + and .unapprovedChanges == [] + and .errors == [] + and ( + (.initialStatus == "no-drift" and .regenerated == false and .driftCount == 0 and .approvedChanges == []) + or (.initialStatus == "approved-freeze-exception" and .regenerated == true and (.approvedChanges | length) > 0) + )' "$drift_report" >/dev/null + + export LEGACY="$COMPONENT/artifacts/legacy" + python3 - <<'PY' + from __future__ import annotations + + import gzip + import hashlib + import io + import json + import os + import re + import stat + import tarfile + import zipfile + from pathlib import Path, PurePosixPath + + legacy = Path(os.environ["LEGACY"]).resolve() + workspace = Path(os.environ["GITHUB_WORKSPACE"]).resolve() + manifest_path = Path(os.environ["MANIFEST"]) + release_id = os.environ["INPUT_RELEASE_ID"] + build_version = os.environ["INPUT_BUILD_VERSION"] + freeze_mode = os.environ["INPUT_STABLE_RC_FREEZE_MODE"] + source_commit = os.environ["GITHUB_SHA"].lower() + archive_name = f"cryptad-stable-1.0-rc-{build_version}.tar.gz" + distribution_name = f"crypta-stable-1.0-rc-{build_version}-product.tar.gz" + + metadata_names = ( + "stable-1.0-rc-freeze.json", + "stable-1.0-rc-freeze.sha256", + "stable-1.0-rc-freeze-report.md", + "stable-1.0-rc-promotion-summary.json", + "stable-1.0-rc-go-no-go.md", + "stable-1.0-rc-known-limitations.json", + "stable-1.0-rc-release-notes.md", + "stable-1.0-rc-drift-report.json", + "provenance.json", + "redaction-report.json", + "content-format-profiles.json", + "feed-reader-api-compatibility.json", + "platform-api-current-contract.json", + "platform-api-stable-diff.json", + "profile-publisher-api-compatibility.json", + "publisher-api-compatibility.json", + "queue-manager-api-compatibility.json", + "site-publisher-api-compatibility.json", + "social-inbox-api-compatibility.json", + "trust-graph-api-compatibility.json", + ) + checksum_names = tuple(sorted((archive_name, distribution_name, *metadata_names))) + provenance_members = tuple( + sorted( + ( + f"payload/{distribution_name}", + *(f"metadata/{name}" for name in metadata_names), + "payload-checksums.txt", + ) + ) + ) + archive_relative_members = provenance_members + archive_members = tuple(f"stable-1.0-rc/{name}" for name in archive_relative_members) + + digest_pattern = re.compile(r"sha256:[0-9a-f]{64}\Z") + raw_digest_pattern = re.compile(r"[0-9a-f]{64}\Z") + forbidden_platform_names = {".ds_store", "__macosx"} + forbidden_key_suffixes = {".pem", ".der", ".p12", ".pfx", ".key"} + + + def fail(message: str) -> None: + raise SystemExit(f"Stable RC post-package verification failed: {message}") + + + def sha256_path(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + value.update(chunk) + return "sha256:" + value.hexdigest() + + + def sha256_stream(handle: object) -> str: + value = hashlib.sha256() + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + value.update(chunk) + return "sha256:" + value.hexdigest() + + + def require_regular(path: Path, label: str) -> None: + if path.is_symlink() or not path.is_file(): + fail(f"{label} is missing or is not a regular file") + try: + path.resolve().relative_to(legacy) + except ValueError: + fail(f"{label} resolves outside the Stable RC artifact directory") + + + def load_json(path: Path, label: str) -> dict[str, object]: + require_regular(path, label) + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + fail(f"{label} is malformed") + if not isinstance(value, dict): + fail(f"{label} must be a JSON object") + return value + + + def load_workspace_json(path_text: str, label: str) -> tuple[Path, dict[str, object]]: + supplied = Path(path_text) + if supplied.is_absolute() or "\\" in path_text or ".." in supplied.parts: + fail(f"{label} path is unsafe") + current = workspace + for part in supplied.parts: + current /= part + if current.is_symlink(): + fail(f"{label} path contains a symlink") + resolved = current.resolve() + try: + resolved.relative_to(workspace) + except ValueError: + fail(f"{label} path escapes the checked-out workspace") + if not resolved.is_file(): + fail(f"{label} is missing or is not a regular file") + try: + value = json.loads(resolved.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + fail(f"{label} is malformed") + if not isinstance(value, dict): + fail(f"{label} must be a JSON object") + return resolved, value + + + def safe_member_name(name: str, *, allow_directory: bool) -> PurePosixPath: + if "\\" in name or not name: + fail(f"archive member has an unsafe path: {name!r}") + raw_parts = name.split("/") + if allow_directory and raw_parts[-1] == "": + raw_parts = raw_parts[:-1] + if ( + not raw_parts + or any(part in {"", ".", ".."} for part in raw_parts) + or re.fullmatch(r"[A-Za-z]:", raw_parts[0]) is not None + ): + fail(f"archive member has an unsafe path: {name!r}") + path = PurePosixPath(name) + if path.is_absolute() or not path.parts or ".." in path.parts: + fail(f"archive member has an unsafe path: {name!r}") + for part in path.parts: + lower = part.lower() + if part.startswith("._") or lower in forbidden_platform_names: + fail(f"archive contains forbidden platform metadata: {name}") + if not allow_directory and name.endswith("/"): + fail(f"outer archive contains a directory member: {name}") + return path + + + for name in checksum_names: + require_regular(legacy / name, name) + require_regular(legacy / "checksums.txt", "checksums.txt") + rc_archives = sorted(legacy.glob("cryptad-stable-1.0-rc-*.tar.gz")) + if rc_archives != [legacy / archive_name]: + fail("the native output must contain exactly one correctly named Stable RC archive") + + if manifest_path.is_symlink() or not manifest_path.is_file(): + fail("Stable RC execution manifest is missing or unsafe") + try: + execution_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + fail("Stable RC execution manifest is malformed") + if not isinstance(execution_manifest, dict): + fail("Stable RC execution manifest must be a JSON object") + manifest_policies = execution_manifest.get("policies") + if not isinstance(manifest_policies, dict): + fail("Stable RC execution manifest policies are malformed") + if manifest_policies.get("stableRcFreezeMode") != freeze_mode: + fail("execution manifest freeze mode does not match the protected workflow selection") + + freeze = load_json(legacy / "stable-1.0-rc-freeze.json", "freeze manifest") + drift = load_json(legacy / "stable-1.0-rc-drift-report.json", "drift report") + provenance = load_json(legacy / "provenance.json", "provenance") + if drift.get("freezeMode") != freeze_mode: + fail("drift report freeze mode does not match the protected workflow selection") + if set(provenance) != { + "schemaVersion", + "kind", + "releaseId", + "buildVersion", + "freezeMode", + "source", + "freeze", + "comparisonBaseline", + "productionDistribution", + "inputs", + "archiveLayout", + "redaction", + }: + fail("provenance fields do not match the Stable RC v1 contract") + if provenance.get("schemaVersion") != 1 or provenance.get("kind") != "stable-1.0-rc-provenance": + fail("provenance kind or schema version is invalid") + if provenance.get("releaseId") != release_id or provenance.get("buildVersion") != build_version: + fail("provenance release or integer build does not match the workflow candidate") + if provenance.get("freezeMode") != freeze_mode: + fail("provenance freeze mode does not match the protected workflow selection") + + source = provenance.get("source") + if not isinstance(source, dict) or set(source) != {"commit", "ref", "digest"}: + fail("provenance source binding is malformed") + if source.get("commit") != source_commit or not isinstance(source.get("ref"), str) or not source["ref"]: + fail("provenance source commit or ref does not match the checked-out candidate") + canonical_source = json.dumps( + {"commit": source_commit, "ref": source["ref"]}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + expected_source_digest = "sha256:" + hashlib.sha256(canonical_source).hexdigest() + if source.get("digest") != expected_source_digest: + fail("provenance source digest is invalid") + + freeze_binding = provenance.get("freeze") + if not isinstance(freeze_binding, dict) or set(freeze_binding) != { + "contentDigest", + "fileDigest", + "file", + }: + fail("provenance freeze binding is malformed") + if freeze_binding.get("file") != "stable-1.0-rc-freeze.json": + fail("provenance freeze filename is not canonical") + if freeze_binding.get("contentDigest") != freeze.get("contentDigest"): + fail("provenance freeze content digest does not match the freeze manifest") + if freeze_binding.get("fileDigest") != sha256_path(legacy / "stable-1.0-rc-freeze.json"): + fail("provenance freeze file digest does not match the frozen bytes") + if not digest_pattern.fullmatch(str(freeze_binding.get("contentDigest", ""))): + fail("provenance freeze content digest is malformed") + + distribution_binding = provenance.get("productionDistribution") + if not isinstance(distribution_binding, dict) or set(distribution_binding) != {"file", "digest"}: + fail("provenance production distribution binding is malformed") + expected_distribution_name = ( + f"crypta-stable-1.0-rc-{build_version}-product.tar.gz" + ) + if distribution_name != expected_distribution_name: + fail("Stable RC did not package the canonical deterministic product distribution") + if distribution_binding.get("file") != f"payload/{distribution_name}": + fail("provenance production distribution path is not canonical") + if distribution_binding.get("digest") != sha256_path(legacy / distribution_name): + fail("provenance production distribution digest does not match the packaged bytes") + candidate_freeze = freeze.get("candidate") + if not isinstance(candidate_freeze, dict): + fail("freeze candidate identity is malformed") + if candidate_freeze.get("productionDistributionDigest") != distribution_binding.get("digest"): + fail("canonical freeze does not bind the exact production distribution bytes") + + provenance_inputs = provenance.get("inputs") + if not isinstance(provenance_inputs, dict) or not provenance_inputs: + fail("provenance input digest map is missing") + if any(not isinstance(key, str) or not key or not digest_pattern.fullmatch(str(value)) for key, value in provenance_inputs.items()): + fail("provenance input digest map is malformed") + + manifest_inputs = execution_manifest.get("inputs") + if not isinstance(manifest_inputs, dict): + fail("Stable RC execution manifest inputs are malformed") + previous_freeze_path = manifest_inputs.get("previousStableRcFreeze") + comparison_baseline = provenance.get("comparisonBaseline") + if freeze_mode == "first-freeze" and previous_freeze_path is not None: + fail("first-freeze mode unexpectedly includes a comparison baseline") + if freeze_mode == "refreeze" and previous_freeze_path is None: + fail("refreeze mode is missing its required comparison baseline") + if previous_freeze_path is None: + if comparison_baseline is not None or "previousStableRcFreeze" in provenance_inputs: + fail("provenance records an unexpected previous Stable RC freeze") + if drift.get("comparisonBaseline") is not None: + fail("drift report records an unexpected previous Stable RC freeze") + else: + if not isinstance(previous_freeze_path, str) or not previous_freeze_path: + fail("previous Stable RC freeze manifest input is malformed") + previous_path, previous_freeze = load_workspace_json( + previous_freeze_path, + "previous Stable RC freeze", + ) + expected_baseline = { + "fileDigest": sha256_path(previous_path), + "contentDigest": previous_freeze.get("contentDigest"), + } + if ( + not digest_pattern.fullmatch(str(expected_baseline["contentDigest"])) + or comparison_baseline != expected_baseline + ): + fail("provenance comparison baseline does not match the supplied previous freeze") + if provenance_inputs.get("previousStableRcFreeze") != expected_baseline["fileDigest"]: + fail("provenance input digest does not bind the supplied previous freeze") + if drift.get("comparisonBaseline") != expected_baseline: + fail("drift report comparison baseline does not match the supplied previous freeze") + + archive_layout = provenance.get("archiveLayout") + if not isinstance(archive_layout, dict) or set(archive_layout) != { + "format", + "root", + "normalized", + "members", + }: + fail("provenance archive layout is malformed") + if ( + archive_layout.get("format") != "deterministic-tar-gzip-v1" + or archive_layout.get("root") != "stable-1.0-rc" + or archive_layout.get("normalized") is not True + or archive_layout.get("members") != list(provenance_members) + ): + fail("provenance archive layout does not match the exact Stable RC allowlist") + provenance_redaction = provenance.get("redaction") + if not isinstance(provenance_redaction, dict) or provenance_redaction.get("status") != "pass" or provenance_redaction.get("findingCount") != 0: + fail("provenance redaction did not pass with zero findings") + + sidecar_text = (legacy / "stable-1.0-rc-freeze.sha256").read_text(encoding="utf-8") + expected_sidecar = f"{sha256_path(legacy / 'stable-1.0-rc-freeze.json').removeprefix('sha256:')} stable-1.0-rc-freeze.json\n" + if sidecar_text != expected_sidecar: + fail("freeze SHA-256 sidecar is malformed or does not match the freeze bytes") + + checksum_text = (legacy / "checksums.txt").read_text(encoding="utf-8") + if not checksum_text.endswith("\n"): + fail("checksums.txt must end with one newline") + checksum_rows: dict[str, str] = {} + checksum_order: list[str] = [] + for line in checksum_text.splitlines(): + digest, separator, relative = line.partition(" ") + path = PurePosixPath(relative) + if ( + separator != " " + or not raw_digest_pattern.fullmatch(digest) + or path.is_absolute() + or ".." in path.parts + or len(path.parts) != 1 + or relative in checksum_rows + ): + fail("checksums.txt contains a malformed, duplicate, or unsafe row") + checksum_rows[relative] = "sha256:" + digest + checksum_order.append(relative) + if tuple(checksum_order) != checksum_names or set(checksum_rows) != set(checksum_names): + fail("checksums.txt does not contain the exact sorted Stable RC public artifact allowlist") + for relative, recorded in checksum_rows.items(): + if recorded != sha256_path(legacy / relative): + fail(f"checksum mismatch for {relative}") + + archive_path = legacy / archive_name + with archive_path.open("rb") as archive_raw: + header = archive_raw.read(10) + if len(header) != 10 or header[:2] != b"\x1f\x8b" or header[4:8] != b"\x00\x00\x00\x00": + fail("outer archive gzip header is not deterministic") + + archived_digests: dict[str, str] = {} + payload_checksums = "" + try: + with tarfile.open(archive_path, mode="r:gz") as archive: + members = archive.getmembers() + if tuple(member.name for member in members) != archive_members: + fail("outer archive member order or allowlist is not exact") + if len({member.name for member in members}) != len(members): + fail("outer archive contains duplicate members") + for member in members: + safe_member_name(member.name, allow_directory=False) + if not member.isfile(): + fail(f"outer archive contains a non-regular member: {member.name}") + if ( + member.mtime != 0 + or member.uid != 0 + or member.gid != 0 + or member.uname != "root" + or member.gname != "root" + or member.mode != 0o644 + ): + fail(f"outer archive member metadata is not normalized: {member.name}") + extracted = archive.extractfile(member) + if extracted is None: + fail(f"outer archive member cannot be read: {member.name}") + if member.name.endswith("/payload-checksums.txt"): + payload_checksums = extracted.read().decode("utf-8") + archived_digests[member.name.removeprefix("stable-1.0-rc/")] = ( + "sha256:" + hashlib.sha256(payload_checksums.encode("utf-8")).hexdigest() + ) + else: + archived_digests[member.name.removeprefix("stable-1.0-rc/")] = sha256_stream(extracted) + except (OSError, UnicodeError, tarfile.TarError): + fail("outer Stable RC archive is malformed") + + source_by_member = {f"payload/{distribution_name}": legacy / distribution_name} + source_by_member.update({f"metadata/{name}": legacy / name for name in metadata_names}) + for relative, source_path in source_by_member.items(): + if archived_digests.get(relative) != sha256_path(source_path): + fail(f"outer archive member does not match its reviewed source: {relative}") + + if not payload_checksums.endswith("\n"): + fail("payload-checksums.txt must end with one newline") + payload_rows: dict[str, str] = {} + payload_order: list[str] = [] + for line in payload_checksums.splitlines(): + digest, separator, relative = line.partition(" ") + path = PurePosixPath(relative) + if ( + separator != " " + or not raw_digest_pattern.fullmatch(digest) + or path.is_absolute() + or ".." in path.parts + or relative in payload_rows + ): + fail("payload-checksums.txt contains a malformed, duplicate, or unsafe row") + payload_rows[relative] = "sha256:" + digest + payload_order.append(relative) + expected_payload_names = tuple(name for name in archive_relative_members if name != "payload-checksums.txt") + if tuple(payload_order) != expected_payload_names or set(payload_rows) != set(expected_payload_names): + fail("payload-checksums.txt does not cover the exact archive payload allowlist") + for relative, recorded in payload_rows.items(): + if recorded != archived_digests.get(relative): + fail(f"archive payload checksum mismatch for {relative}") + + + def inspect_zip_stream(handle: object, label: str) -> None: + data = handle.read() + try: + with zipfile.ZipFile(io.BytesIO(data)) as nested: + seen: set[str] = set() + for entry in nested.infolist(): + safe_member_name(entry.filename, allow_directory=True) + if entry.filename in seen: + fail(f"nested ZIP contains a duplicate member: {label}:{entry.filename}") + seen.add(entry.filename) + mode = (entry.external_attr >> 16) & 0o170000 + if mode not in {0, stat.S_IFREG, stat.S_IFDIR}: + fail(f"nested ZIP contains a link or special member: {label}:{entry.filename}") + if not entry.is_dir() and PurePosixPath(entry.filename).suffix.lower() in forbidden_key_suffixes: + fail(f"nested ZIP contains a key-like file: {label}:{entry.filename}") + except zipfile.BadZipFile: + fail(f"nested ZIP is malformed: {label}") + + + try: + with tarfile.open(legacy / distribution_name, mode="r:gz") as distribution: + seen_distribution: set[str] = set() + for member in distribution.getmembers(): + path = safe_member_name(member.name, allow_directory=True) + if member.name in seen_distribution: + fail(f"production distribution contains a duplicate member: {member.name}") + seen_distribution.add(member.name) + if not (member.isdir() or member.isfile()): + fail(f"production distribution contains a link or special member: {member.name}") + if member.isfile() and path.suffix.lower() in forbidden_key_suffixes: + fail(f"production distribution contains a key-like file: {member.name}") + if member.isfile() and path.suffix.lower() in {".zip", ".jar"}: + extracted = distribution.extractfile(member) + if extracted is None: + fail(f"nested archive cannot be read: {member.name}") + inspect_zip_stream(extracted, member.name) + except (OSError, tarfile.TarError): + fail("nested production distribution archive is malformed") + PY + + - name: Add the redacted Stable RC decision to the job summary + if: steps.final_gate.outputs.promotable == 'true' + env: + COMPONENT: ${{ steps.final_gate.outputs.component }} + run: | + report="$COMPONENT/artifacts/legacy/stable-1.0-rc-go-no-go.md" + if [[ -z "$report" || ! -f "$report" || -L "$report" ]]; then + echo "::error::The redacted Stable RC go/no-go report is missing or unsafe." + exit 1 + fi + cat "$report" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload redacted Stable RC artifacts + if: steps.final_gate.outputs.promotable == 'true' + uses: actions/upload-artifact@v6 + with: + name: stable-1-0-rc-${{ inputs.candidate_release_id }}-${{ inputs.integer_build_version }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ steps.final_gate.outputs.component }}/ + !**/*credentials* + !**/*password* + !**/*cookie* + !**/*authorization* + !**/*auth* + !**/private-insert-uris.json + !**/*private*key* + !**/*secret* + !**/*token* + !**/*.pem + !**/*.der + !**/*.p12 + !**/*.pfx + !**/*.key + !**/payload-checksums.txt + !**/raw-support-bundle/** + !**/raw-support-bundle* + !**/raw-diagnostics/** + !**/raw-diagnostics* + !**/.DS_Store + !**/._* + !**/__MACOSX/** + if-no-files-found: error + retention-days: 30 + + - name: Persist authenticated Stable RC freeze lineage + if: steps.final_gate.outputs.promotable == 'true' + env: + COMPONENT: ${{ steps.final_gate.outputs.component }} + INPUT_RELEASE_ID: ${{ inputs.candidate_release_id }} + INPUT_BUILD_VERSION: ${{ inputs.integer_build_version }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + freeze="$COMPONENT/artifacts/legacy/stable-1.0-rc-freeze.json" + if [[ ! -f "$freeze" || -L "$freeze" ]]; then + echo "::error::The canonical Stable RC freeze is missing or unsafe." + exit 1 + fi + digest_hex="$(sha256sum "$freeze" | awk '{print $1}')" + if [[ ! "$digest_hex" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::The canonical Stable RC freeze digest is malformed." + exit 1 + fi + digest="sha256:$digest_hex" + anchor_name="Stable 1.0 RC freeze lineage / $INPUT_RELEASE_ID / build $INPUT_BUILD_VERSION" + external_id="stable-1.0-rc-freeze:$INPUT_RELEASE_ID:$INPUT_BUILD_VERSION:$GITHUB_RUN_ID:$GITHUB_RUN_ATTEMPT:$digest" + details_url="https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + jq -n \ + --arg name "$anchor_name" \ + --arg head_sha "$GITHUB_SHA" \ + --arg external_id "$external_id" \ + --arg details_url "$details_url" \ + --arg digest "$digest" \ + --arg run_id "$GITHUB_RUN_ID" \ + --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + '{ + name: $name, + head_sha: $head_sha, + status: "completed", + conclusion: "success", + external_id: $external_id, + details_url: $details_url, + output: { + title: "Authenticated Stable RC freeze lineage", + summary: ("Freeze `" + $digest + "` was produced by protected workflow run `" + $run_id + "`, attempt `" + $run_attempt + "`.") + } + }' \ + | gh api --method POST \ + "repos/$GITHUB_REPOSITORY/check-runs" \ + --input - \ + >/dev/null + + - name: Confirm non-publication boundary + if: steps.final_gate.outputs.promotable == 'true' + run: | + echo "Stable 1.0 RC artifacts are frozen for review. This workflow did not create a tag, GitHub Release, merge, update descriptor, or GA publication." >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/feed-reader/src/staged/cryptad-app.properties.template b/apps/feed-reader/src/staged/cryptad-app.properties.template index 105d716fb1..52b9764c0e 100644 --- a/apps/feed-reader/src/staged/cryptad-app.properties.template +++ b/apps/feed-reader/src/staged/cryptad-app.properties.template @@ -13,7 +13,7 @@ app.permissions=content.fetch,content.subscribe,content.insert.app-document,queu app.beta.readiness=ready app.beta.qualityLevel=beta app.beta.support.owner=crypta-core -app.beta.support.uri=https://example.invalid/crypta/apps/feed-reader/support +app.beta.support.uri=https://github.com/crypta-network/cryptad/issues app.beta.support.diagnostics=redacted-summary-only app.beta.ui.emptyState=true app.beta.ui.errorState=true diff --git a/apps/profile-publisher/src/staged/cryptad-app.properties.template b/apps/profile-publisher/src/staged/cryptad-app.properties.template index be438e76bd..6ec7630dc9 100644 --- a/apps/profile-publisher/src/staged/cryptad-app.properties.template +++ b/apps/profile-publisher/src/staged/cryptad-app.properties.template @@ -13,7 +13,7 @@ app.permissions=queue.read,queue.write,content.insert.app-document,vault.identit app.beta.readiness=ready app.beta.qualityLevel=beta app.beta.support.owner=crypta-core -app.beta.support.uri=https://example.invalid/crypta/apps/profile-publisher/support +app.beta.support.uri=https://github.com/crypta-network/cryptad/issues app.beta.support.diagnostics=redacted-summary-only app.beta.ui.emptyState=true app.beta.ui.errorState=true diff --git a/apps/publisher/src/staged/cryptad-app.properties.template b/apps/publisher/src/staged/cryptad-app.properties.template index 89862ad80d..b8ccaf5fa0 100644 --- a/apps/publisher/src/staged/cryptad-app.properties.template +++ b/apps/publisher/src/staged/cryptad-app.properties.template @@ -13,7 +13,7 @@ app.permissions=queue.read,queue.write,content.insert app.beta.readiness=ready app.beta.qualityLevel=beta app.beta.support.owner=crypta-core -app.beta.support.uri=https://example.invalid/crypta/apps/publisher/support +app.beta.support.uri=https://github.com/crypta-network/cryptad/issues app.beta.support.diagnostics=redacted-summary-only app.beta.ui.emptyState=true app.beta.ui.errorState=true diff --git a/apps/queue-manager/src/staged/cryptad-app.properties.template b/apps/queue-manager/src/staged/cryptad-app.properties.template index 1bb287c36d..420ff00bfc 100644 --- a/apps/queue-manager/src/staged/cryptad-app.properties.template +++ b/apps/queue-manager/src/staged/cryptad-app.properties.template @@ -13,7 +13,7 @@ app.permissions=queue.read,queue.write app.beta.readiness=ready app.beta.qualityLevel=beta app.beta.support.owner=crypta-core -app.beta.support.uri=https://example.invalid/crypta/apps/queue-manager/support +app.beta.support.uri=https://github.com/crypta-network/cryptad/issues app.beta.support.diagnostics=redacted-summary-only app.beta.ui.emptyState=true app.beta.ui.errorState=true diff --git a/apps/site-publisher/src/staged/cryptad-app.properties.template b/apps/site-publisher/src/staged/cryptad-app.properties.template index 6a2300bf67..9fd3e37156 100644 --- a/apps/site-publisher/src/staged/cryptad-app.properties.template +++ b/apps/site-publisher/src/staged/cryptad-app.properties.template @@ -13,7 +13,7 @@ app.permissions=queue.read,queue.write,content.insert app.beta.readiness=ready app.beta.qualityLevel=beta app.beta.support.owner=crypta-core -app.beta.support.uri=https://example.invalid/crypta/apps/site-publisher/support +app.beta.support.uri=https://github.com/crypta-network/cryptad/issues app.beta.support.diagnostics=redacted-summary-only app.beta.ui.emptyState=true app.beta.ui.errorState=true diff --git a/apps/social-inbox/src/staged/cryptad-app.properties.template b/apps/social-inbox/src/staged/cryptad-app.properties.template index 2948f8b7b8..1ccbe4f100 100644 --- a/apps/social-inbox/src/staged/cryptad-app.properties.template +++ b/apps/social-inbox/src/staged/cryptad-app.properties.template @@ -13,7 +13,7 @@ app.permissions=vault.identities.read,vault.identities.create,vault.identities.u app.beta.readiness=ready app.beta.qualityLevel=beta app.beta.support.owner=crypta-core -app.beta.support.uri=https://example.invalid/crypta/apps/social-inbox/support +app.beta.support.uri=https://github.com/crypta-network/cryptad/issues app.beta.support.diagnostics=redacted-summary-only app.beta.ui.emptyState=true app.beta.ui.errorState=true diff --git a/apps/trust-graph/src/staged/cryptad-app.properties.template b/apps/trust-graph/src/staged/cryptad-app.properties.template index 61758cbff2..7c0bb6f480 100644 --- a/apps/trust-graph/src/staged/cryptad-app.properties.template +++ b/apps/trust-graph/src/staged/cryptad-app.properties.template @@ -3,7 +3,7 @@ app.id=trust-graph app.name=Trust Graph Local RC app.version=${appVersion} api.minimumVersion=22 -api.maximumTestedVersion=22 +api.maximumTestedVersion=23 api.targetStability=experimental api.experimentalCapabilitiesAccepted=true app.exec=bin/trust-graph.sh @@ -13,7 +13,7 @@ app.permissions=trust.read,trust.write,content.fetch,content.subscribe,content.i app.beta.readiness=ready app.beta.qualityLevel=beta app.beta.support.owner=crypta-core -app.beta.support.uri=https://example.invalid/crypta/apps/trust-graph/support +app.beta.support.uri=https://github.com/crypta-network/cryptad/issues app.beta.support.diagnostics=redacted-summary-only app.beta.ui.emptyState=true app.beta.ui.errorState=true diff --git a/apps/trust-graph/src/test/java/network/crypta/apps/trustgraph/TrustGraphBundleStagingTest.java b/apps/trust-graph/src/test/java/network/crypta/apps/trustgraph/TrustGraphBundleStagingTest.java index e976e7ad6d..cdf29e3665 100644 --- a/apps/trust-graph/src/test/java/network/crypta/apps/trustgraph/TrustGraphBundleStagingTest.java +++ b/apps/trust-graph/src/test/java/network/crypta/apps/trustgraph/TrustGraphBundleStagingTest.java @@ -53,7 +53,7 @@ class TrustGraphBundleStagingTest { "app.data.read", "app.data.write"); private static final int EXPECTED_PLATFORM_API_MINIMUM_VERSION = 22; - private static final int EXPECTED_PLATFORM_API_MAXIMUM_TESTED_VERSION = 22; + private static final int EXPECTED_PLATFORM_API_MAXIMUM_TESTED_VERSION = 23; private static final Path PLATFORM_SDK_SOURCE_PATH = Path.of( "platform-sdk-js", diff --git a/docs/catalog-operations-and-mirrors.md b/docs/catalog-operations-and-mirrors.md index c6d9a840c3..52456d672f 100644 --- a/docs/catalog-operations-and-mirrors.md +++ b/docs/catalog-operations-and-mirrors.md @@ -91,6 +91,10 @@ separate from ordinary refresh: 5. Cryptad records the current revision in history and replaces the active catalog sidecars with the selected verified revision. +The selected rollback target must precede the active catalog revision and bind different signed +catalog bytes. A same-revision, newer-revision, or same-digest target is not rollback evidence and +must fail release verification. + Rollback does not uninstall apps, change app data, bypass advisories, bypass denylists, or change app-update policy. Installed apps remain under the normal AppHost and app-update rollback rules. diff --git a/docs/cryptad-release-workflow-and-runbook.md b/docs/cryptad-release-workflow-and-runbook.md index 38e210ca4b..74df5485a0 100644 --- a/docs/cryptad-release-workflow-and-runbook.md +++ b/docs/cryptad-release-workflow-and-runbook.md @@ -51,6 +51,18 @@ ## Platform Release Gates Treat these as release blockers, in order: +When the candidate targets the Stable 1.0 product milestone, run the protected +[Stable 1.0 RC execution and release-freeze workflow](stable-1.0-rc-execution-and-release-freeze.md) +after the prerequisite production evidence passes and before generating release descriptors, +tags, or publication material. The RC command uses the existing `stable-review` profile and the +repository's integer build identity; it does not replace the `v` tag policy. A +blocker fix after freeze invalidates the old archive, checksums, provenance, and freeze and requires +a complete authorized re-freeze to final `no-drift`. + +An RC `go` or policy-compliant `go-with-waivers` authorizes review of the frozen candidate. It does +not authorize a tag, GitHub Release, merge, update-descriptor insertion, or Stable 1.0 GA +publication. Those remain separate release-manager operations later in this runbook. + 1. **Normal Gradle checks** - run the standard repository build and test path before any publish step. For release readiness, this means the usual Gradle verification path for the branch, including `./gradlew clean build` and any project-required test tasks used in CI. 2. **Production beta app-ecosystem pipeline, when shipping production beta app artifacts** - use the top-level production beta wrapper when the release includes first-party app bundles, diff --git a/docs/production-beta-go-no-go-dashboard.md b/docs/production-beta-go-no-go-dashboard.md index 8e57ceb505..89bb208dd1 100644 --- a/docs/production-beta-go-no-go-dashboard.md +++ b/docs/production-beta-go-no-go-dashboard.md @@ -29,6 +29,13 @@ ecosystem matrix, app-platform, live-network, network-scale, multi-node, securit candidate, waiver, and optional Stable evidence. It does not trust `promotionReady` without validating the underlying rows. +This is the production-beta launch decision, not by itself the final Stable 1.0 RC decision. The +[Stable RC release-freeze workflow](stable-1.0-rc-execution-and-release-freeze.md) preserves the +same `go`, `no-go`, and `go-with-waivers` vocabulary, then adds passing Stable readiness, immutable +freeze generation, post-package `no-drift`, provenance/checksum binding, and RC-wide redaction as +mandatory final gates. Allowed Stable limitations are reported separately and are not converted +into hidden dashboard waivers. + ## Waivers Waivers require an ID, evidence ID, severity, exact scope, rationale, approver, owner, references, diff --git a/docs/production-beta-release-pipeline.md b/docs/production-beta-release-pipeline.md index f81175ac56..a1b5a7bcb8 100644 --- a/docs/production-beta-release-pipeline.md +++ b/docs/production-beta-release-pipeline.md @@ -50,6 +50,13 @@ a promotable production-beta result. Security drill operations and incident-response evidence follow the [production security response runbook](production-security-response-runbook.md). +For Stable 1.0 RC execution, do not run a normal production-beta manifest and then assemble an RC +with a separate script. The canonical +[Stable RC release-freeze workflow](stable-1.0-rc-execution-and-release-freeze.md) uses one +`stable-review` manifest and invokes this production pipeline, go/no-go, release certification, +and Stable readiness inside the same marked workspace. Normal production-beta runs do not create +a Stable freeze and retain their existing behavior. + ## Pipeline stages The pipeline orchestrates: @@ -109,6 +116,19 @@ security, distribution, checksum, archive, and detailed dashboard output lives b //production-beta/artifacts/legacy/dist/checksums.txt ``` +When the canonical `stable-rc` command invokes this pipeline as its production stage, it also +writes `dist/crypta-stable-1.0-rc--product.tar.gz`. The protected catalog-operations +`artifactTimestamp` controls the signed catalog and review-receipt timestamps. The product archive +contains only public product inputs and uses normalized gzip/tar metadata, so the same source, +signing identities, artifact URI, catalog revision, and app payloads reproduce the same bytes. +POSIX launcher entries and app-data migration commands declared by staged app manifests are +normalized to executable mode; Windows command files and all other regular members are normalized +to non-executable mode. A missing or path-unsafe declared migration command fails packaging. +`dist/crypta-production-beta-.tar.gz` remains the broader production-beta evidence archive +and keeps its existing consumers; the Stable RC freeze binds the deterministic product archive. +A direct `production-beta` command using the `stable-review` profile retains its existing input +contract and does not require Stable RC catalog operations or create this product archive. + Validated attached input extracts live under `artifacts/inputs/`. Those extracts are diagnostic copies, not a way to bypass candidate binding or redaction checks. @@ -125,3 +145,9 @@ CI may upload or publish the workspace only when release artifact redaction, go/ and any required Stable redaction all pass. Private interop insert URIs, raw support bundles, raw diagnostics, AppleDouble files, `.DS_Store`, `__MACOSX`, secret-like filenames, symlinks, and unsafe nested archives remain excluded. + +Stable RC upload is stricter: the protected workflow uploads only the public `stable-rc/` +component after the final v2 envelope says `promotionReady=true`, the regenerated freeze verifies +as `no-drift`, checksums and provenance match the package, redaction passes, and the decision is +`go` or policy-compliant `go-with-waivers`. See the Stable RC runbook for the exact artifact set and +non-publication boundary. diff --git a/docs/release-certification.md b/docs/release-certification.md index 9384e7b395..5124a9de2e 100644 --- a/docs/release-certification.md +++ b/docs/release-certification.md @@ -57,6 +57,7 @@ Each run writes: production-beta/ go-no-go/ stable-readiness/ + stable-rc/ ``` Each component contains `summary.json`, `report.md`, `redaction-report.json`, and `artifacts/`. @@ -104,6 +105,13 @@ redaction failures still produce a sanitized failed envelope with `promotionRead Completed migration records and component summaries cannot be overwritten with `output.reset=false`. +The `stable-rc/` component is created only by the canonical Stable 1.0 RC command under the +`stable-review` profile. It contains the common v2 summary/report/redaction surface plus the +versioned freeze, drift report, promotion summary, RC go/no-go report, known limitations, release +notes, checksums, provenance, and deterministic public archive. Its schema and release-manager +procedure are documented in +[Stable 1.0 RC execution and release freeze](stable-1.0-rc-execution-and-release-freeze.md). + ## Required evidence Release-candidate certification continues to require: diff --git a/docs/stable-1.0-known-limitations.md b/docs/stable-1.0-known-limitations.md index 0f36e8d38f..a146e280eb 100644 --- a/docs/stable-1.0-known-limitations.md +++ b/docs/stable-1.0-known-limitations.md @@ -70,6 +70,18 @@ Redaction findings, critical security failures, missing Platform API stable base breaking changes without deprecation/migration, missing previous-candidate upgrade evidence, and unresolved critical known issues are non-waivable. +## Stable RC copy-through + +The [Stable 1.0 RC release-freeze workflow](stable-1.0-rc-execution-and-release-freeze.md) freezes +the exact policy-recognized allowed-limitation records produced by Stable readiness. Each record +must also appear in `stable-1.0-rc-known-limitations.json` and prominently in the generated RC +release-note draft with its boundary, owner, and review or exit condition intact. A missing or +changed copy is freeze drift and makes the RC `no-go`. + +Allowed limitations remain separate from `go-with-waivers`. A beta-only or disallowed limitation +cannot be copied into the RC as an allowed limitation, and a freeze exception cannot reclassify or +waive it. + ## Redaction Known limitations and release notes must never include private insert URIs, private keys, app diff --git a/docs/stable-1.0-rc-execution-and-release-freeze.md b/docs/stable-1.0-rc-execution-and-release-freeze.md new file mode 100644 index 0000000000..ea1f43c64f --- /dev/null +++ b/docs/stable-1.0-rc-execution-and-release-freeze.md @@ -0,0 +1,343 @@ +# Stable 1.0 RC execution and release freeze + +Use this workflow to turn an already promotable, Stable-ready candidate into a reproducible Stable +1.0 release candidate. The command executes the existing protected production and Stable-review +gates, freezes the reviewed candidate, packages public artifacts, verifies the package against the +freeze, and produces the final RC go/no-go record. + +Stable 1.0 is a product and Platform API milestone. Cryptad release identity remains an integer +build number, a `release/` branch when release operations begin, and a +`v` tag if the candidate is later published. The RC workflow does not create a +branch or tag, merge code, publish a GitHub Release, or declare Stable 1.0 generally available. + +## Before running the RC + +Start with a finalized candidate commit and one path-safe release ID. Every unified component +summary supplied to the run must be evidence envelope v2 bound to that release ID, integer build, +compatible protected profile, and candidate source commit where the producer records it. Explicit +native inputs such as interop, performance, third-party intake, public known issues, and stable +catalog operations retain their versioned native contracts and must pass the corresponding +release/build/source binding, freshness, and redaction checks. + +The protected run requires all of the following: + +- a clean, known Git workspace bound to the candidate commit; +- a complete Gradle build plus first-party app stage, production sign, and verify stages; +- protected production app-signing and reviewer identities whose key IDs are public metadata but + whose private material remains outside the manifest and artifacts; +- passing production-beta, production go/no-go, release-certification, ecosystem-matrix, and + Stable-readiness results; +- real live-network, sandbox-provider, multi-node, previous-candidate upgrade, network-scale, + security-drill, third-party-intake, and catalog-operations evidence; +- third-party intake evidence whose `releaseId` and `buildVersion` exactly match the candidate and + that explicitly records `fixtureOnly=false`, `simulatedOnly=false`, `nonRelease=false`, and + `nonProduction=false`; omitted identity or classification fields fail closed; +- the Stable readiness policy, public beta known issues, Stable known limitations, and required + first-party app maintenance/readiness data; +- a stable catalog channel with verified primary or mirror transport, signature trust, rollback, + and key-rotation state; and +- redaction-safe source evidence and a public HTTPS artifact base URI. + +Fixture evidence, test or candidate-only signing, simulated-only live evidence, skipped build +stages, stale or wrong-candidate summaries, dirty or unknown workspace state, and unresolved +redaction findings are never Stable RC promotion evidence. + +Stable readiness may be `ready` or `ready-with-allowed-limitations`. In the latter case every +limitation must be policy-recognized, bounded, owned, and copied without omission into the freeze, +RC known-limitations artifact, and release-note draft. An allowed limitation is not a waiver. +Disallowed or beta-only limitations block the RC. + +## Prepare the manifest + +Copy the fail-closed example and replace every `REPLACE_...` value. Review every protected input +path instead of treating the example as runnable configuration. + +```bash +cp tools/release-certification/manifests/stable-1.0-rc.example.json \ + build/stable-1.0-rc.json +``` + +The manifest uses the existing `stable-review` profile. Do not change it to a semantic version or +invent a separate release profile. The Stable RC command orchestrates the strict existing +production-beta, go/no-go, release-certification, and Stable-readiness pipeline within the same +release-scoped workspace. + +The example deliberately contains placeholder paths and an `example.invalid` artifact base URI, +so an unedited copy fails closed. Private keys, private insert URIs, form passwords, tokens, +cookies, and live authorization values belong only in the protected execution environment or +protected files. Never add them to the manifest. + +The RC-specific manifest inputs are: + +- `stableCatalogOperations`: the candidate-bound catalog operations and mirrors summary for the + exact stable catalog revision being frozen; its evidence kind is + `stable-1.0-rc-catalog-operations` and its schema is + `tools/release-certification/schemas/stable-1.0-rc-catalog-operations-v1.schema.json`. Its + `artifactTimestamp` is part of the reviewed evidence and must be the timestamp used to generate + the signed catalog and every first-party review receipt; +- `previousStableRcFreeze`: the previous Stable RC freeze when verifying a later candidate or + refreeze; it is required in `refreeze` mode and forbidden in `first-freeze` mode; and +- `stableRcFreezeExceptions`: an optional redaction-safe exception collection whose kind is + `stable-1.0-rc-freeze-exceptions` and whose schema is + `tools/release-certification/schemas/stable-1.0-rc-freeze-exceptions-v1.schema.json`. + +Previous-candidate upgrade and release-history evidence remain mandatory even when there is no +earlier Stable RC freeze. Omit an optional waiver or exception input rather than creating an empty, +under-authorized, or placeholder production record. + +Set `policies.stableRcFreezeMode` explicitly. Use `first-freeze` only for the initial immutable +baseline for one release ID and integer build. Use `refreeze` for every later execution and supply +the exact `stable-1.0-rc-freeze.json` from the latest successful protected run. The workflow +prefers to download that run's authenticated artifact and compare the freeze bytes before +execution. Each successful run also records the exact freeze file digest in an authenticated +GitHub Actions check-run lineage anchor tied to the run, attempt, candidate, build, and source +commit. If the short-lived artifact has expired or is otherwise unavailable, a retained copy of +the freeze is accepted only when its digest matches that exact anchor for the latest successful +run. A stale parent, missing or mismatched anchor, or unavailable workflow history fails closed. +The protected workflow also serializes executions for the same candidate and rejects reruns in +`first-freeze` mode. Release managers must retain the canonical freeze outside the expiring +workflow artifact when future refreezes may be required. + +Signing secrets and live insert material must be supplied through the protected environment +described by the workflow. The manifest contains only paths to sanitized evidence and public-safe +policy metadata. + +The protected production pipeline reads `artifactTimestamp` before it signs the in-run catalog and +review receipts. The generated catalog's `catalog.generatedAt` must match that instant. This makes +an independently reviewed catalog-operations record reproducible from the same source, signing +identities, catalog revision, app bundles, and artifact base URI. `generatedAt` on the operations +record remains the time when the operational checks completed; it must not precede +`artifactTimestamp`. + +Do not add separately precomputed `productionBeta`, `goNoGo`, `releaseCertification`, +`ecosystemMatrix`, `appPlatform`, or `stableReadiness` inputs to the canonical manifest. They are +generated and consumed inside the same protected `stable-review` orchestration and are bound through +its release-scoped workspace. The example's `https://REPLACE_ME.invalid` URI is intentionally +unusable until the release manager replaces it with the reviewed public artifact base URI. + +## Execute the canonical workflow + +Run the focused deterministic self-test before protected execution: + +```bash +python3 tools/release-certification/certify.py stable-rc --self-test +``` + +Then run the canonical command from the candidate commit: + +```bash +python3 tools/release-certification/certify.py stable-rc \ + --manifest build/stable-1.0-rc.json +``` + +The release-scoped component is: + +```text +build/release-certification//stable-rc/ +``` + +Do not run separate `production-beta` and `stable-review` manifests with the same release ID under +the same output root. The workspace marker binds the run to one release ID, integer version, and +profile. The `stable-rc` command is the canonical orchestration boundary. + +## What the freeze records + +The versioned schema is +`tools/release-certification/schemas/stable-1.0-rc-freeze-v1.schema.json`. Canonical serialization +uses stable key and collection ordering, path-safe relative names, and a content digest that does +not vary with incidental filesystem metadata. + +The freeze covers these domains: + +- candidate identity: release ID, integer build, Stable milestone `1.0`, source provenance, + generator version, prerequisite evidence digests, and the exact deterministic Stable RC product + distribution digest; +- Platform API 1.0: authoritative baseline and generated-contract digests, compatibility-window + policy, stable and experimental counts, and stable-breaking-change verification; +- stable catalog: ID, stable channel, edition/revision, frozen artifact timestamp, signed bytes and + sidecar digests, signing key ID, key rotation, primary/mirror health, rollback verification, + advisory and denylist counts, and the exact ordered app entries; +- first-party apps: exact policy-required app set, versions, bundle and manifest digests, + permissions, signing and reviewer key IDs, review receipts, API compatibility, app-data schemas, + migrations, backup/restore state, support metadata, and redacted diagnostics readiness; +- content formats: Profile, Feed, Trust Statement, Social Message, and Social Outbox profile + versions, statuses, canonicalization rules, size policies, signing payload rules, and compatibility + evidence; and +- policy and limitations: Stable-readiness policy, exact allowed limitations, public beta known + issues, Stable known limitations, security drills, legacy plugin/admin freeze, and support and + feedback readiness. + +Experimental API or profile state is recorded separately. An experimental-only API change may be +reviewable when the authoritative stable surface remains identical, but a stable baseline or +generated stable-surface mismatch blocks the RC. This workflow does not change Platform API 1.0 +membership. + +Trust Graph or Social Inbox may retain an explicit `local-rc` status only when the authoritative +Stable policy recognizes and binds that status to an allowed limitation. The freeze and notes must +not relabel it as globally stable or legacy-protocol compatible. + +## Generated artifacts + +The component writes the common evidence envelope v2 files: + +```text +summary.json +report.md +redaction-report.json +``` + +Its native public artifacts are: + +```text +stable-1.0-rc-freeze.json +stable-1.0-rc-freeze.sha256 +stable-1.0-rc-freeze-report.md +stable-1.0-rc-drift-report.json +stable-1.0-rc-promotion-summary.json +stable-1.0-rc-go-no-go.md +stable-1.0-rc-known-limitations.json +stable-1.0-rc-release-notes.md +platform-api-current-contract.json +platform-api-stable-diff.json +content-format-profiles.json +-api-compatibility.json +checksums.txt +provenance.json + +``` + +The release-note artifact is generated from +[the Stable 1.0 RC notes template](templates/stable-1.0-rc-release-notes.md). It identifies the +candidate as an RC, not GA, and does not claim a tag or publication has occurred. +The template has a version marker and one ordered token for each documented review section. A +missing, duplicate, unknown, or reordered token fails the RC instead of producing a partial draft. + +The public archive contains only release artifacts, required release reports, checksums, freeze and +provenance metadata, and redacted summaries. It excludes raw evidence, private signing sidecars, +insert material, diagnostics, support bundles, app data, identities, and credentials. + +Production-beta still emits its broad evidence archive for existing production-beta consumers. +Stable RC additionally requires `crypta-stable-1.0-rc--product.tar.gz`. That product +distribution contains the staged and signed first-party app payloads, app bundles, candidate-built +developer launcher, signed stable catalog, channel metadata, review receipts, transparency record, +and the two public first-party policy inputs. It excludes run-specific reports, command durations, +CI attempt metadata, and live evidence. The production packager writes members in stable order and +normalizes gzip/tar timestamps, ownership, and modes. Rebuilding the same candidate with the same +protected `artifactTimestamp` therefore produces the same signed bytes and product-distribution +digest while all release gates still execute again. + +The deterministic archive is named `cryptad-stable-1.0-rc-.tar.gz` and has one normalized +`stable-1.0-rc/` root. Its `payload/` directory contains the exact deterministic +`crypta-stable-1.0-rc--product.tar.gz`; `metadata/` contains the freeze, freeze sidecar and report, +promotion summary, go/no-go report, known limitations, release-note draft, drift report, +provenance, redaction report, generated Platform API snapshot and diff, authoritative content-format +registry export, and each first-party app API-compatibility result. All supporting verifier files +pass the same redaction scan and are included in both checksum allowlists. Root +`payload-checksums.txt` binds every payload and metadata member without a parent path or circular +self-reference. External `checksums.txt` binds those source artifacts, the deterministic product +distribution, and the final outer archive. + +## Verify drift + +Generation is not promotion. After packaging, the command recomputes and verifies the freeze, +checksums, provenance bindings, archive members, and redaction state against the packaged +candidate. The drift report uses these statuses: + +- `no-drift`: current candidate and packaged artifacts match the active freeze; +- `approved-freeze-exception`: an authorized blocker fix is recorded, but the old freeze and + release artifacts are invalid and cannot be promoted; +- `unapproved-drift`: a frozen item changed without an acceptable exception; +- `invalid-freeze`: the freeze is malformed, unsafe, wrong-candidate, or cannot be verified. + +Only final `no-drift` is promotable. Drift includes changes to candidate identity, Platform API +stable surface, generated contract, catalog bytes/signature/revision/channel, app set or metadata, +content profiles, policy and limitations, evidence provenance, production distribution bytes, +archive contents, checksums, or redaction status. Accepted freeze-exception records are immutable +audit history: later refreezes carry them forward automatically, and removal or modification is +unapproved drift. Missing or newly introduced artifacts are also drift. + +Archive verification rejects unsafe or absolute member names, traversal, symlinks, hard links, +special files, AppleDouble files, `.DS_Store`, `__MACOSX`, secret-like files, and unsafe nested +archives. A checksum mismatch after packaging makes the final decision `no-go`. + +## Repair a blocker after freeze + +After freeze generation, only a blocker or security fix should change the candidate. Record it in +the versioned `stable-1.0-rc-freeze-exceptions` collection. The collection must carry +`authorizationRole=stable-release-manager` and a passing redaction result with zero findings; a +nonempty approver name alone is not sufficient authorization. Each exception identifies the +release and integer build, affected frozen item, before and after digests, reason, `blocker` or +`security` issue kind and reference, owner, approver, creation and expiry or review time, required +rerun scope, and final verification result. + +An accepted exception follows this sequence: + +1. Record and authorize the narrow blocker fix. +2. Invalidate the old archive, checksums, provenance, and freeze. +3. Apply the fix without changing unrelated frozen state. +4. Rerun every affected check and every final promotion gate. +5. Regenerate the complete freeze and public artifacts. +6. Verify the regenerated candidate and package to final `no-drift`. +7. Surface the exception in the RC report and release-note draft. + +An exception cannot bypass redaction, an unknown or compromised signing identity, production +signing, live/multi-node/previous-candidate evidence, security drills, candidate binding, or a +beta-only/disallowed limitation. It cannot authorize a Stable API break outside the existing +compatibility and deprecation process. An expired, malformed, stale, wrong-release, or +under-authorized exception fails closed. + +## Interpret the final decision + +The common `summary.json` is evidence envelope v2. A promotable result requires: + +```text +result.status = pass +result.promotionReady = true +result.exitCode = 0 +redaction.status = pass +payload.legacy.nonRelease = false +payload.legacy.stableReady = true +payload.legacy.freeze.status = pass +payload.legacy.freeze.driftStatus = no-drift +payload.legacy.decision = go or go-with-waivers +``` + +The summary subject must also match the requested release ID, integer build, `stable-review` +profile, and `stable-rc` component. The native promotion summary repeats the decision-critical +fields for review. + +`go-with-waivers` is eligible only when every waiver is policy-compliant, scoped, approved, +unexpired, and cannot conceal a non-waivable blocker. Allowed Stable limitations are reported +separately and do not change the decision vocabulary. Any missing, malformed, failing, stale, +wrong-candidate, fixture, skipped-stage, redaction-unsafe, or drifted input produces `no-go` and +`promotionReady=false`. + +## Run in the protected workflow + +The manual `.github/workflows/stable-1.0-rc-release.yml` job uses the protected Stable RC +environment and JDK 25. It requires the candidate release ID and integer build, checks the build +against `./gradlew -q printVersion`, binds the clean checkout to the workflow commit, materializes +sanitized protected evidence under an ignored repository-relative `build/` directory and signing +files under the runner temporary directory, runs the Stable RC command, verifies the final +envelope, and uploads only the public RC component after final go and redaction pass. No absolute +runner path is serialized into the release manifest or public artifacts. + +Select `first-freeze` for the candidate's first successful workflow only. The workflow records the +release ID and build in its run title, serializes that candidate's executions, and refuses another +first freeze after a successful baseline. Select `refreeze` for verification, blocker repair, or +any later run and provide the freeze from the latest successful protected run. The workflow rejects +an older lineage parent even when its release ID and build match. It authenticates the exact +artifact bytes while that artifact is available and otherwise authenticates the retained file +against the latest run's commit-bound check-run digest. The final gate binds that baseline and the +exact production distribution bytes in both the canonical freeze and provenance. + +Catalog-operations evidence must conform exactly to the closed +`stable-1.0-rc-catalog-operations-v1.schema.json` contract. Unknown or non-production fields block +promotion. The verified rollback target must have a lower revision than the frozen current catalog, +must bind different catalog bytes, and must pass signature verification. + +The workflow does not tag, create a GitHub Release, merge a release branch, insert release +descriptors, or publish GA. If the reviewed RC is later selected for release, follow +[the Cryptad release workflow](cryptad-release-workflow-and-runbook.md) and +[the standard Git workflow](standard-git-branching-and-release-workflow.md) as a separate, +authorized operation. diff --git a/docs/stable-1.0-readiness-gate.md b/docs/stable-1.0-readiness-gate.md index 7348ee94a8..91fb6fd933 100644 --- a/docs/stable-1.0-readiness-gate.md +++ b/docs/stable-1.0-readiness-gate.md @@ -22,6 +22,13 @@ The command writes evidence envelope v2 under `//stable-re summary, report, redaction report, blocker list, and known-limitations artifact contain only sanitized metadata and relative references. +A passing readiness decision measures whether the candidate is eligible for Stable review; it +does not freeze, package, tag, or publish the candidate. To cut a reviewable release candidate, use +the one-command [Stable 1.0 RC execution and release-freeze workflow](stable-1.0-rc-execution-and-release-freeze.md). +That protected workflow generates Stable readiness inside the same `stable-review` run, copies +every allowed limitation into the freeze and RC notes, and verifies the packaged candidate for +post-freeze drift. + ## Decisions - `ready` means all required Stable domains pass and no allowed limitation remains open. diff --git a/docs/standard-git-branching-and-release-workflow.md b/docs/standard-git-branching-and-release-workflow.md index 8fdd9c304d..6c1818ec0d 100644 --- a/docs/standard-git-branching-and-release-workflow.md +++ b/docs/standard-git-branching-and-release-workflow.md @@ -19,6 +19,13 @@ This document captures the Cryptad team’s standard Git branching and release w Rationale: Using a single, monotonically increasing integer keeps updater compatibility straightforward (USK subscriptions, minimum build checks) and avoids semantic-version drift. +Stable 1.0 is a product and Platform API compatibility milestone, not a semantic version in the Git +model. A Stable 1.0 release candidate still belongs to the intended integer build and, if later +published, follows the normal `release/` and `v` workflow. The +[Stable RC release-freeze command](stable-1.0-rc-execution-and-release-freeze.md) prepares evidence +for review only; it does not create a branch or tag, merge code, publish a GitHub Release, or +declare GA. + ## Branching Model (GitFlow‑inspired) - Primary branches diff --git a/docs/templates/stable-1.0-rc-release-notes.md b/docs/templates/stable-1.0-rc-release-notes.md new file mode 100644 index 0000000000..5402dafbcd --- /dev/null +++ b/docs/templates/stable-1.0-rc-release-notes.md @@ -0,0 +1,53 @@ + +# Cryptad Stable 1.0 release candidate + +> This is a generated review draft for a Stable 1.0 release candidate. It is not a general- +> availability publication, Git tag, or GitHub Release announcement. + +## Candidate identity + +{{candidate_identity}} + +## Upgrade and recovery + +{{upgrade_and_recovery}} + +## Platform API 1.0 freeze + +{{platform_api}} + +## Stable catalog and first-party apps + +{{catalog_and_apps}} + +## Content-format profiles + +{{content_profiles}} + +## Security and operational evidence + +{{operational_evidence}} + +## Allowed Stable limitations + +{{allowed_limitations}} + +## Known issues + +{{known_issues}} + +## Accepted waivers + +{{accepted_waivers}} + +## Accepted freeze exceptions + +{{accepted_exceptions}} + +## Support and security reporting + +{{support_and_security}} + +## Final review + +{{final_review}} diff --git a/platform-devtools/src/main/java/network/crypta/platform/devtools/CryptaAppCli.java b/platform-devtools/src/main/java/network/crypta/platform/devtools/CryptaAppCli.java index 7677745bfc..e8c4bbce20 100644 --- a/platform-devtools/src/main/java/network/crypta/platform/devtools/CryptaAppCli.java +++ b/platform-devtools/src/main/java/network/crypta/platform/devtools/CryptaAppCli.java @@ -40,6 +40,8 @@ import network.crypta.platform.api.PlatformApiContractVerifier.CompatibilityFindingSeverity; import network.crypta.platform.api.PlatformApiContractVerifier.CompatibilityVerificationResult; import network.crypta.platform.api.PlatformApiContractVerifier; +import network.crypta.platform.api.contentformats.ContentFormatProfile; +import network.crypta.platform.api.contentformats.ContentFormatProfileRegistry; import network.crypta.platform.api.json.PlatformApiJsonWriter; import network.crypta.platform.appcatalog.AppCatalog; import network.crypta.platform.appcatalog.AppCatalogBuildRequest; @@ -635,7 +637,12 @@ public Integer call() throws Exception { @Command( name = "api", description = "Inspect the Platform API compatibility contract.", - subcommands = {ApiSnapshotCommand.class, ApiPolicyCommand.class, ApiDiffCommand.class}) + subcommands = { + ApiSnapshotCommand.class, + ApiPolicyCommand.class, + ApiDiffCommand.class, + ApiContentFormatsCommand.class + }) static final class ApiCommand extends SpecAwareCommand implements Runnable { @Override public void run() { @@ -665,6 +672,73 @@ public Integer call() throws Exception { } } + /** Implements {@code crypta-app api content-formats}. */ + @Command( + name = "content-formats", + description = "Export authoritative first-party content-format profile metadata.") + static final class ApiContentFormatsCommand extends SpecAwareCommand + implements Callable { + @Option(names = "--output", required = true, description = "Profile registry JSON to write.") + private Path output; + + /** + * Writes the current immutable profile registry in its evidence order. + * + *

The export contains only public descriptor metadata. It is generated directly from {@link + * ContentFormatProfileRegistry} so release certification never has to maintain a second list of + * profile identifiers, lifecycle states, byte limits, or signing domains. + * + * @return the command-line success status after the complete registry is written + * @throws IOException if the destination cannot be created or written + */ + @Override + public Integer call() throws IOException { + LinkedHashMap report = LinkedHashMap.newLinkedHashMap(3); + report.put(FIELD_SCHEMA_VERSION, 1); + report.put("kind", "content-format-profile-registry"); + report.put( + "profiles", + ContentFormatProfileRegistry.profiles().stream() + .map(ApiContentFormatsCommand::profileJson) + .toList()); + writeJsonOutput(output, report); + super.commandLine() + .getOut() + .println( + "Wrote content-format profile registry: " + + ContentFormatProfileRegistry.profiles().size() + + " profile(s)"); + return CommandLine.ExitCode.OK; + } + + /** + * Converts one validated registry descriptor into stable public JSON metadata. + * + * @param profile immutable authoritative profile descriptor + * @return deterministic descriptor fields used by Stable release certification + */ + private static Map profileJson(ContentFormatProfile profile) { + LinkedHashMap value = LinkedHashMap.newLinkedHashMap(12); + value.put("id", profile.id()); + value.put("majorVersion", profile.majorVersion()); + value.put("contentType", profile.contentType()); + value.put("defaultFilename", profile.defaultFilename()); + value.put(FIELD_STATUS, profile.status().jsonValue()); + value.put("maxDocumentBytes", profile.maxDocumentBytes()); + value.put("maxSignedPayloadBytes", profile.maxSignedPayloadBytes()); + value.put("signed", profile.signed()); + value.put("signingDomain", profile.signingDomain()); + value.put("canonicalizationKind", profile.canonicalizationKind()); + LinkedHashMap versionPolicy = LinkedHashMap.newLinkedHashMap(3); + versionPolicy.put("unknownFieldPolicy", profile.versionPolicy().unknownFieldPolicy()); + versionPolicy.put("futureVersionPolicy", profile.versionPolicy().futureVersionPolicy()); + versionPolicy.put("deprecationPolicy", profile.versionPolicy().deprecationPolicy()); + value.put("versionPolicy", versionPolicy); + value.put("replacementProfileId", profile.replacementProfileId()); + return value; + } + } + /** Implements {@code crypta-app api policy}. */ @Command(name = "policy", description = "Print Platform API compatibility-window policy.") static final class ApiPolicyCommand extends SpecAwareCommand implements Callable { diff --git a/platform-devtools/src/test/java/network/crypta/platform/devtools/CryptaAppCliTest.java b/platform-devtools/src/test/java/network/crypta/platform/devtools/CryptaAppCliTest.java index c1f7877357..ca8675b1be 100644 --- a/platform-devtools/src/test/java/network/crypta/platform/devtools/CryptaAppCliTest.java +++ b/platform-devtools/src/test/java/network/crypta/platform/devtools/CryptaAppCliTest.java @@ -1694,6 +1694,26 @@ void apiSnapshot_whenOutputRequested_expectContractJsonWritten() throws Exceptio assertTrue(json.contains("\"platform.contract.read\"")); } + @Test + void apiContentFormats_whenOutputRequested_expectAuthoritativeRegistryWritten() throws Exception { + Path output = tempDir.resolve("content-format-profiles.json"); + + CliResult result = runCli("api", "content-formats", "--output", output.toString()); + + assertEquals(CommandLine.ExitCode.OK, result.exitCode()); + assertTrue(result.out().contains("5 profile(s)")); + String json = Files.readString(output, StandardCharsets.UTF_8); + assertTrue(json.contains("\"kind\":\"content-format-profile-registry\"")); + assertTrue(json.contains("\"id\":\"crypta.profile.v1\"")); + assertTrue(json.contains("\"id\":\"crypta.feed.snapshot.v1\"")); + assertTrue(json.contains("\"id\":\"crypta.trust.statement.v1\"")); + assertTrue(json.contains("\"id\":\"crypta.social.message.v1\"")); + assertTrue(json.contains("\"id\":\"crypta.social.outbox.v1\"")); + assertTrue(json.contains("\"canonicalizationKind\"")); + assertTrue(json.contains("\"versionPolicy\"")); + assertFalse(json.contains(tempDir.toString())); + } + @Test void apiPolicy_whenRequested_expectCompatibilityWindowSummary() { CliResult result = runCli("api", "policy"); diff --git a/tools/release-certification/README.md b/tools/release-certification/README.md index 12ee2231b7..2b5d5f6956 100644 --- a/tools/release-certification/README.md +++ b/tools/release-certification/README.md @@ -19,6 +19,7 @@ Run one focused suite: python3 tools/release-certification/certify.py app-platform --self-test python3 tools/release-certification/certify.py release-certification --self-test python3 tools/release-certification/certify.py production-beta --self-test +python3 tools/release-certification/certify.py stable-rc --self-test ``` Run a CI-safe app-platform collection with the checked-in manifest: @@ -48,6 +49,7 @@ The public entry point is `tools/release-certification/certify.py`. | `production-beta` | Build, certify, redact, and package a production-beta candidate. | | `go-no-go` | Build the release-manager launch dashboard. | | `stable-readiness` | Evaluate the Stable 1.0 promotion gate. | +| `stable-rc` | Execute, freeze, package, and verify a protected Stable 1.0 release candidate. | | `migrate-v1` | Convert validated v1 previous-candidate or history summaries for the first v2 release. | | `self-test` | Run one focused `unittest` suite or all suites. | @@ -92,8 +94,8 @@ the release workspace is prepared: | Map | Supported fields | | --- | --- | | `requirements` | Boolean `history`, `liveNetwork`, `multiNodeSoak`, `sandboxProviderTests`, `stableReadiness`, and `thirdPartyIntake` gates. | -| `inputs` | Non-empty paths for interop, performance, app-platform, live-network, network-scale, multi-node, security-drill, production, dashboard, certification, Stable, waiver, policy, known-limitation, previous-candidate, and release-history artifacts. | -| `policies` | `artifactBaseUri`, `catalogChannel`, `expectedPreviousReleaseId`, `historyDir`, `historyLabel`, and string-valued `metadata`. | +| `inputs` | Non-empty paths for interop, performance, app-platform, live-network, network-scale, multi-node, security-drill, production, dashboard, certification, Stable, waiver, policy, known-limitation, previous-candidate, release-history, stable catalog operations, previous Stable RC freeze, and Stable RC freeze-exception artifacts. | +| `policies` | `artifactBaseUri`, `catalogChannel`, `expectedPreviousReleaseId`, `historyDir`, `historyLabel`, `stableRcFreezeMode` (`first-freeze` or `refreeze`), and string-valued `metadata`. | | `execution` | Boolean collection/build/test controls plus positive integer `timeoutSeconds`. | `commands..args` remains an advanced engine-specific escape hatch. The unified command owns @@ -128,6 +130,45 @@ Every command writes below one release-scoped directory: artifacts/ ``` +For Stable 1.0 RC execution, copy +`manifests/stable-1.0-rc.example.json`, replace every placeholder, and run: + +```bash +python3 tools/release-certification/certify.py stable-rc \ + --manifest build/stable-1.0-rc.json +``` + +The manifest retains the `stable-review` profile and integer build number. The command generates a +unified production-beta component inside the same marked run; that protected pipeline produces and +binds its go/no-go, release-certification, app-platform, ecosystem-matrix, and Stable-readiness +native artifacts for the Stable RC engine. Do not attach unrelated precomputed copies to the +canonical manifest. External prerequisites use the coordinated `stableCatalogOperations`, +`previousStableRcFreeze`, and `stableRcFreezeExceptions` input names. Stable RC output lives under +`//stable-rc/`; see the +[Stable RC runbook](../../docs/stable-1.0-rc-execution-and-release-freeze.md) for its freeze schema, +artifact inventory, drift and exception semantics, and protected workflow. + +`stableCatalogOperations.artifactTimestamp` is the immutable producer timestamp for the signed +catalog and first-party review receipts. The same protected value is used on every refreeze. The +production pipeline emits `crypta-stable-1.0-rc--product.tar.gz` with normalized member +metadata and no run-specific evidence reports; this is the exact distribution bound by the Stable +RC freeze. The ordinary production-beta evidence archive remains available to its existing +consumers. These requirements apply only when `stable-rc` orchestrates the production stage; a +direct `production-beta` run with the `stable-review` profile keeps the pre-existing manifest and +intake contracts. + +Set `policies.stableRcFreezeMode=first-freeze` only for the initial candidate baseline and omit +`inputs.previousStableRcFreeze`. Every later run uses `stableRcFreezeMode=refreeze` and must supply +the exact freeze from the latest successful protected workflow run. The workflow authenticates +that parent against the latest uploaded artifact when available. Each successful protected run +also creates a commit-bound check-run lineage anchor for the exact freeze file digest, release, +build, run, and attempt. After the uploaded artifact expires, a retained freeze is accepted only +when its digest matches that latest authenticated anchor; stale or unauthenticated lineage fails +closed. The canonical freeze binds the exact deterministic product-distribution digest. Rebuilding +the unchanged candidate must produce identical bytes; a catalog, review receipt, bundle, launcher, +policy, or product member change remains candidate drift even when semantic producer summaries are +unchanged. + Nested operations use nested component names, for example `multi-node-beta/run/` and `security-response/drill-run-all/`. JSON and Markdown artifact references are relative to the run root. The common `summary.json`, `report.md`, and `redaction-report.json` are the public component @@ -301,5 +342,13 @@ Release workflows generate their runtime manifest with `jq` from workflow-dispat values remain in protected environment variables or files and are never serialized into the manifest or uploaded workspace. +`.github/workflows/stable-1.0-rc-release.yml` is manual and protected. It requires an explicit +candidate release ID and integer build, JDK 25, a clean candidate commit, production +signing/reviewer material, full build/stage/sign/verify, and real live, sandbox, multi-node, +previous-candidate, network-scale, security-drill, third-party-intake, and catalog-operations +evidence. It verifies the post-package freeze, checksums, archive hygiene, final v2 redaction, and +`go`/`go-with-waivers` result before uploading only the public RC component. It does not tag, +release, merge, or publish Stable 1.0 GA. + Follow the [production security response runbook](../../docs/production-security-response-runbook.md) when collecting release-blocking drill evidence or responding to an app ecosystem incident. diff --git a/tools/release-certification/cryptad_certification/cli.py b/tools/release-certification/cryptad_certification/cli.py index d18fd2f888..2616c94df7 100644 --- a/tools/release-certification/cryptad_certification/cli.py +++ b/tools/release-certification/cryptad_certification/cli.py @@ -4,11 +4,13 @@ import argparse import os +import re import shutil import sys from pathlib import Path from . import selftest +from .engines.stable_1_0_rc_core import SAME_RUN_INPUT_KEYS from .legacy import execute as execute_engine from .manifest import load_manifest from .migration import execute as execute_migration @@ -24,6 +26,7 @@ "production-beta", "go-no-go", "stable-readiness", + "stable-rc", ) MULTI_NODE_ACTIONS = ( "plan", @@ -54,6 +57,7 @@ "production-beta", "go-no-go", "stable-readiness", + "stable-rc", "migration", ) @@ -153,6 +157,37 @@ def _require_pending_component(manifest: RunManifest, component: str) -> None: ) +def _validate_stable_rc_manifest(manifest: RunManifest) -> None: + """Reject Stable RC orchestration outside the protected Stable review profile.""" + + if manifest.release.profile != "stable-review": + raise ValueError("stable-rc requires release.profile stable-review") + version = manifest.release.version + if version is None or re.fullmatch(r"[1-9][0-9]*", version) is None: + raise ValueError("stable-rc requires a canonical positive integer release.version") + freeze_mode = manifest.policies.get("stableRcFreezeMode") + has_previous_freeze = "previousStableRcFreeze" in manifest.inputs + if freeze_mode not in {"first-freeze", "refreeze"}: + raise ValueError( + "stable-rc requires policies.stableRcFreezeMode first-freeze or refreeze" + ) + if freeze_mode == "first-freeze" and has_previous_freeze: + raise ValueError( + "stable-rc first-freeze mode cannot include inputs.previousStableRcFreeze" + ) + if freeze_mode == "refreeze" and not has_previous_freeze: + raise ValueError( + "stable-rc refreeze mode requires inputs.previousStableRcFreeze" + ) + configured_same_run = [key for key in SAME_RUN_INPUT_KEYS if key in manifest.inputs] + if configured_same_run: + names = ", ".join(f"inputs.{key}" for key in configured_same_run) + raise ValueError( + "stable-rc generates production-beta and its promotion inputs in the same " + f"protected run; externally supplied same-run inputs are not accepted: {names}" + ) + + def _run_command(args: argparse.Namespace) -> int: command = str(args.command) if getattr(args, "self_test", False): @@ -162,6 +197,8 @@ def _run_command(args: argparse.Namespace) -> int: raise ValueError(f"{command} requires --manifest") workspace_root = args.workspace_root.resolve() manifest = load_manifest(manifest_path.resolve(), workspace_root, args.out_root) + if command == "stable-rc": + _validate_stable_rc_manifest(manifest) prepare_run_root(manifest) previous = Path.cwd() @@ -179,6 +216,9 @@ def _run_command(args: argparse.Namespace) -> int: if command == "release-certification" and manifest.execution.get("collectEvidence") is True: _require_pending_component(manifest, "release-certification") _collect_release_evidence(workspace_root, manifest) + if command == "stable-rc": + _require_pending_component(manifest, "stable-rc") + _execute_component(workspace_root, manifest, "production-beta") component = command if action is None else f"{command}/{action}" context = prepare_context(workspace_root, manifest, component) code = execute_engine(context, command, action) diff --git a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_core.py b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_core.py index 132b3dfb93..2c241b60fb 100644 --- a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_core.py +++ b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_core.py @@ -108,7 +108,8 @@ FIRST_PARTY_MAINTENANCE_OWNER = "crypta-core" -FIRST_PARTY_MAINTENANCE_OWNER_URI = "https://example.invalid/crypta/owners/core" +FIRST_PARTY_MAINTENANCE_OWNER_URI = "https://github.com/crypta-network/cryptad" +FIRST_PARTY_SUPPORT_URI = "https://github.com/crypta-network/cryptad/issues" FIRST_PARTY_MAINTENANCE_COMMON_EXPECTATIONS = { "securityPolicy": "catalog-advisories", @@ -1601,7 +1602,7 @@ def first_party_app_specs(settings: Settings) -> list[dict[str, Any]]: "launcher": "bin/trust-graph.sh", "permissions": TRUST_GRAPH_PERMISSIONS, "apiMinimumVersion": 22, - "apiMaximumTestedVersion": 22, + "apiMaximumTestedVersion": 23, "apiTargetStability": "experimental", "experimentalCapabilitiesAccepted": True, }, diff --git a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_data_network.py b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_data_network.py index 696aaba332..b50245dca7 100644 --- a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_data_network.py +++ b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_data_network.py @@ -2553,7 +2553,8 @@ def collect_trust_graph_reference_app_evidence(settings: Settings) -> EvidenceIt ) checks["manifestUsesContractV22"] = ( manifest.get("api.minimumVersion") == "22" - and manifest.get("api.maximumTestedVersion") == "22" + and manifest.get("api.maximumTestedVersion") + == str(FIRST_PARTY_CERTIFIED_MAX_CONTRACT_VERSION) and manifest.get("api.experimentalCapabilitiesAccepted") == "true" ) checks["manifestAdvertisesTrustScoreService"] = ( @@ -2630,7 +2631,8 @@ def collect_trust_graph_app_data_preview_evidence(settings: Settings) -> Evidenc checks = details["checks"] checks["manifestUsesAppDataContract"] = ( manifest.get("api.minimumVersion") == "22" - and manifest.get("api.maximumTestedVersion") == "22" + and manifest.get("api.maximumTestedVersion") + == str(FIRST_PARTY_CERTIFIED_MAX_CONTRACT_VERSION) and {"app.data.read", "app.data.write"}.issubset(permissions) ) checks["usesSdkJsonRecordHelpers"] = ( @@ -3352,7 +3354,8 @@ def collect_trust_graph_durable_exchange_reference_app_evidence( checks = details["checks"] checks["manifestUsesContractV22"] = ( manifest.get("api.minimumVersion") == "22" - and manifest.get("api.maximumTestedVersion") == "22" + and manifest.get("api.maximumTestedVersion") + == str(FIRST_PARTY_CERTIFIED_MAX_CONTRACT_VERSION) ) checks["manifestDeclaresExchangePermissions"] = { "trust.read", diff --git a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_distribution.py b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_distribution.py index 491476ff8c..c0f75431b9 100644 --- a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_distribution.py +++ b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_distribution.py @@ -934,7 +934,7 @@ def maintenance_policy_evidence_value( else "" ) if field == "supportUri": - expected = f"https://example.invalid/crypta/apps/{app_id}/support" + expected = FIRST_PARTY_SUPPORT_URI return ( stripped if stripped == expected and scrub_text(stripped, workspace) == stripped @@ -1140,7 +1140,7 @@ def collect_first_party_maintenance_policy_evidence(settings: Settings) -> Evide app_checks["urisAreMetadataOnly"] = ( maintenance.get("ownerUri") == FIRST_PARTY_MAINTENANCE_OWNER_URI and maintenance.get("supportUri") - == f"https://example.invalid/crypta/apps/{app_id}/support" + == FIRST_PARTY_SUPPORT_URI ) app_checks["maintenanceEvidenceValuesSafe"] = all( value != "" and value is not None @@ -1299,7 +1299,7 @@ def collect_first_party_beta_quality_evidence(settings: Settings) -> EvidenceIte isinstance(maintenance, dict) and maintenance.get("owner") == FIRST_PARTY_MAINTENANCE_OWNER and maintenance.get("supportUri") - == f"https://example.invalid/crypta/apps/{app_id}/support" + == FIRST_PARTY_SUPPORT_URI and (not isinstance(beta_readiness, dict) or maintenance.get("backupRestore") == beta_readiness.get("backupRestore")) ) @@ -1337,7 +1337,7 @@ def collect_first_party_beta_quality_evidence(settings: Settings) -> EvidenceIte ) app_checks["supportUriMatchesPolicy"] = ( manifest.get("app.beta.support.uri") - == f"https://example.invalid/crypta/apps/{app_id}/support" + == FIRST_PARTY_SUPPORT_URI ) permissions = {part.strip() for part in manifest.get("app.permissions", "").split(",") if part.strip()} app_checks["permissionRationalesPresent"] = all( diff --git a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_selftest_assertions.py b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_selftest_assertions.py index 92d712ac1e..d005f82df3 100644 --- a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_selftest_assertions.py +++ b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_selftest_assertions.py @@ -990,7 +990,9 @@ def run_self_test(repo_root: Path) -> None: "app.data.read,app.data.write" ) assert catalog["app.trust-graph.api.minimumVersion"] == "22" - assert catalog["app.trust-graph.api.maximumTestedVersion"] == "22" + assert catalog["app.trust-graph.api.maximumTestedVersion"] == str( + FIRST_PARTY_CERTIFIED_MAX_CONTRACT_VERSION + ) registry_text = registry_fixture.read_text(encoding="utf-8") counts = legacy_counts_from_registry_text(registry_text) assert counts == { @@ -2273,7 +2275,9 @@ def run_self_test(repo_root: Path) -> None: assert evidence_by_id["reference-app.feed-reader"]["status"] == "pass" assert evidence_by_id["reference-app.feed-reader-subscriptions"]["status"] == "pass" assert evidence_by_id["reference-app.feed-reader-app-data"]["status"] == "pass" - assert evidence_by_id["reference-app.trust-graph"]["status"] == "pass" + assert evidence_by_id["reference-app.trust-graph"]["status"] == "pass", evidence_by_id[ + "reference-app.trust-graph" + ] assert evidence_by_id["reference-app.trust-graph-durable-exchange"]["status"] == "pass" assert evidence_by_id["reference-app.trust-graph-app-data-preview"]["status"] == "pass" assert evidence_by_id["app-platform.trust-graph-preview"]["status"] == "pass" diff --git a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_selftest_workspace.py b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_selftest_workspace.py index a990fa3ad7..61fdd8947c 100644 --- a/tools/release-certification/cryptad_certification/engines/app_platform_smoke_selftest_workspace.py +++ b/tools/release-certification/cryptad_certification/engines/app_platform_smoke_selftest_workspace.py @@ -40,7 +40,7 @@ def make_self_test_workspace(workspace: Path) -> None: is_social_inbox = app_id == 'social-inbox' is_trust_graph = app_id == 'trust-graph' api_minimum = '16' if is_social_inbox else '22' if is_trust_graph else '9' if is_feed_reader or is_profile_publisher else '3' if app_id == 'site-publisher' else '1' - api_maximum = '22' if is_trust_graph else str(FIRST_PARTY_CERTIFIED_MAX_CONTRACT_VERSION) + api_maximum = str(FIRST_PARTY_CERTIFIED_MAX_CONTRACT_VERSION) experimental_accepted = 'true' if is_profile_publisher or is_social_inbox or is_trust_graph else 'false' service_lines: list[str] = [] migration_lines: list[str] = [] @@ -55,7 +55,7 @@ def make_self_test_workspace(workspace: Path) -> None: elif is_profile_publisher: migration_lines = ['app.data.schema.current=1', 'app.data.schema.namespaces=profile-draft', 'app.data.schema.namespace.profile-draft.current=1'] beta_expectation = FIRST_PARTY_BETA_EXPECTATIONS[app_id] - beta_lines = ['app.beta.readiness=ready', 'app.beta.qualityLevel=beta', 'app.beta.support.owner=crypta-core', f'app.beta.support.uri=https://example.invalid/crypta/apps/{app_id}/support', 'app.beta.support.diagnostics=redacted-summary-only', 'app.beta.ui.emptyState=true', 'app.beta.ui.errorState=true', 'app.beta.ui.retryAction=true', 'app.beta.ui.recoveryAction=true', f"app.beta.appData={beta_expectation['appData']}", f"app.beta.backupRestore={beta_expectation['backupRestore']}", f"app.beta.exportSupported={beta_expectation['exportSupported']}", f"app.beta.importSupported={beta_expectation['importSupported']}", f"app.beta.migrationDryRunSupported={beta_expectation['migrationDryRun']}", 'app.beta.accessibility=basic-pass', 'app.beta.uiConsistency=design-system-pass', 'app.beta.diagnostics=redacted-summary-only', *[f'permissions.rationale.{permission.strip()}=Required by the first-party beta readiness fixture.' for permission in permissions.split(',') if permission.strip()]] + beta_lines = ['app.beta.readiness=ready', 'app.beta.qualityLevel=beta', 'app.beta.support.owner=crypta-core', f'app.beta.support.uri={FIRST_PARTY_SUPPORT_URI}', 'app.beta.support.diagnostics=redacted-summary-only', 'app.beta.ui.emptyState=true', 'app.beta.ui.errorState=true', 'app.beta.ui.retryAction=true', 'app.beta.ui.recoveryAction=true', f"app.beta.appData={beta_expectation['appData']}", f"app.beta.backupRestore={beta_expectation['backupRestore']}", f"app.beta.exportSupported={beta_expectation['exportSupported']}", f"app.beta.importSupported={beta_expectation['importSupported']}", f"app.beta.migrationDryRunSupported={beta_expectation['migrationDryRun']}", 'app.beta.accessibility=basic-pass', 'app.beta.uiConsistency=design-system-pass', 'app.beta.diagnostics=redacted-summary-only', *[f'permissions.rationale.{permission.strip()}=Required by the first-party beta readiness fixture.' for permission in permissions.split(',') if permission.strip()]] manifest_text = '\n'.join(['manifest.version=1', f'app.id={app_id}', f'app.name={display_name}', 'app.version=0.1.0', f'api.minimumVersion={api_minimum}', f'api.maximumTestedVersion={api_maximum}', f"api.targetStability={('experimental' if experimental_accepted == 'true' else 'stable')}", f'api.experimentalCapabilitiesAccepted={experimental_accepted}', f'app.exec=bin/{launcher}', 'app.ui.mode=static', 'app.ui.entry=static/index.html', f'app.permissions={permissions}', *beta_lines, *service_lines, *migration_lines, 'quota.data.bytes=0', 'quota.cache.bytes=0']) + '\n' (staged / 'cryptad-app.properties').write_text(manifest_text, encoding='utf-8') (source / 'cryptad-app.properties.template').write_text(manifest_text.replace('app.version=0.1.0', 'app.version=${appVersion}'), encoding='utf-8') @@ -449,7 +449,7 @@ def make_self_test_workspace(workspace: Path) -> None: (workspace / 'tools/release-certification/cryptad_certification/engines/production_beta_go_no_go_dashboard_fixture.py').write_text("CRITICAL_PRODUCTION_BETA_EVIDENCE_IDS = ('app-platform.privacy-preserving-beta-diagnostics',)\nDOMAIN_SPECS = ({'id': 'privacy-preserving-diagnostics-risk', 'evidenceIds': ('app-platform.privacy-preserving-beta-diagnostics',)},)\n", encoding='utf-8') policy_apps = {} for app_id, expected_policy in FIRST_PARTY_MAINTENANCE_EXPECTATIONS.items(): - policy_apps[app_id] = {'channel': 'stable', 'supportStatus': 'supported', 'deprecationStatus': 'none', 'minimumCryptaVersion': None, 'maximumCryptaVersion': None, 'maintenance': {'owner': 'crypta-core', 'ownerUri': 'https://example.invalid/crypta/owners/core', 'supportLevel': expected_policy['supportLevel'], 'dataSchemaPolicy': expected_policy['dataSchemaPolicy'], 'migrationPolicy': expected_policy['migrationPolicy'], 'backupRestore': expected_policy['backupRestore'], 'securityPolicy': expected_policy['securityPolicy'], 'deprecationPolicy': expected_policy['deprecationPolicy'], 'supportUri': f'https://example.invalid/crypta/apps/{app_id}/support'}} + policy_apps[app_id] = {'channel': 'stable', 'supportStatus': 'supported', 'deprecationStatus': 'none', 'minimumCryptaVersion': None, 'maximumCryptaVersion': None, 'maintenance': {'owner': 'crypta-core', 'ownerUri': FIRST_PARTY_MAINTENANCE_OWNER_URI, 'supportLevel': expected_policy['supportLevel'], 'dataSchemaPolicy': expected_policy['dataSchemaPolicy'], 'migrationPolicy': expected_policy['migrationPolicy'], 'backupRestore': expected_policy['backupRestore'], 'securityPolicy': expected_policy['securityPolicy'], 'deprecationPolicy': expected_policy['deprecationPolicy'], 'supportUri': FIRST_PARTY_SUPPORT_URI}} write_json(workspace / FIRST_PARTY_MAINTENANCE_POLICY_PATH, {'schemaVersion': 1, 'owner': 'crypta-core', 'apps': policy_apps}) readiness_apps: dict[str, Any] = {} for app_id, expected_readiness in FIRST_PARTY_BETA_EXPECTATIONS.items(): diff --git a/tools/release-certification/cryptad_certification/engines/production_beta_release_core.py b/tools/release-certification/cryptad_certification/engines/production_beta_release_core.py index bf6082e37e..2f48ea7111 100644 --- a/tools/release-certification/cryptad_certification/engines/production_beta_release_core.py +++ b/tools/release-certification/cryptad_certification/engines/production_beta_release_core.py @@ -10,6 +10,8 @@ import datetime as dt +import gzip + import hashlib import io @@ -40,7 +42,7 @@ import zipfile -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any, BinaryIO, Iterable, Iterator @@ -87,6 +89,16 @@ "security", ) +STABLE_RC_PRODUCT_PATHS = ( + "inputs/first-party-app-maintenance-policy.json", + "inputs/first-party-app-beta-readiness.json", + "build/staged-apps", + "build/app-bundles", + "build/crypta-app-launcher", + "catalog", + "reviews", +) + GO_NO_GO_DASHBOARD_JSON = "reports/go-no-go-dashboard.json" GO_NO_GO_DASHBOARD_MARKDOWN = "reports/go-no-go-dashboard.md" @@ -265,7 +277,8 @@ FIRST_PARTY_MAINTENANCE_OWNER = "crypta-core" -FIRST_PARTY_MAINTENANCE_OWNER_URI = "https://example.invalid/crypta/owners/core" +FIRST_PARTY_MAINTENANCE_OWNER_URI = "https://github.com/crypta-network/cryptad" +FIRST_PARTY_SUPPORT_URI = "https://github.com/crypta-network/cryptad/issues" MAINTENANCE_ALLOWED_VALUES = { "supportLevel": { @@ -658,6 +671,7 @@ class Settings: stable_readiness_policy: Path | None = None stable_known_limitations: Path | None = None stable_readiness_waivers: Path | None = None + public_beta_known_issues: Path | None = None release_id: str | None = None interop_smoke_summary: Path | None = None interop_extended_summary: Path | None = None @@ -665,6 +679,7 @@ class Settings: live_network_summary: Path | None = None network_scale_soak_summary: Path | None = None require_history: bool = False + stable_rc_artifact_timestamp: str | None = None @dataclasses.dataclass class CommandResult: @@ -1856,7 +1871,7 @@ def expected_first_party_maintenance_uri(app_id: str, field: str) -> str | None: if field == "ownerUri": return FIRST_PARTY_MAINTENANCE_OWNER_URI if field == "supportUri": - return f"https://example.invalid/crypta/apps/{app_id}/support" + return FIRST_PARTY_SUPPORT_URI return None def safe_single_line(value: Any) -> str | None: @@ -2184,7 +2199,7 @@ def package_catalog_and_reviews( descriptor_dir.mkdir(parents=True, exist_ok=True) trusted_reviewers = work_dir / "trusted-reviewers.properties" write_trusted_reviewer_keys(trusted_reviewers, profile, state.settings.workspace_root) - artifact_timestamp = state.started_at + artifact_timestamp = state.settings.stable_rc_artifact_timestamp or state.started_at descriptors: list[Path] = [] receipts: list[Path] = [] @@ -2502,6 +2517,7 @@ def release_config(state: PipelineState) -> dict[str, Any]: "schemaVersion": SCHEMA_VERSION, "tool": TOOL_NAME, "generatedAt": utc_now(), + "stableRcArtifactTimestamp": settings.stable_rc_artifact_timestamp, "mode": settings.mode, "version": state.version, "catalogChannel": settings.catalog_channel, diff --git a/tools/release-certification/cryptad_certification/engines/production_beta_release_evidence.py b/tools/release-certification/cryptad_certification/engines/production_beta_release_evidence.py index ce8383d363..fd038f499f 100644 --- a/tools/release-certification/cryptad_certification/engines/production_beta_release_evidence.py +++ b/tools/release-certification/cryptad_certification/engines/production_beta_release_evidence.py @@ -874,10 +874,27 @@ def third_party_intake_redaction_status( item = evidence.get("third-party-intake.redaction") return str(item.get("status", "missing")).strip().lower() if isinstance(item, dict) else "missing" -def third_party_intake_summary_is_non_release(intake_summary: dict[str, Any] | None) -> bool: +def third_party_intake_summary_is_non_release( + intake_summary: dict[str, Any] | None, + *, + stable_rc: bool = False, +) -> bool: + """Classify intake evidence under its applicable production contract. + + Existing release-candidate and production-beta inputs explicitly classify only non-release + and non-production state. Stable RC adds explicit fixture and simulation classifications, + but that additive requirement must not reinterpret earlier production input contracts. + """ + if not isinstance(intake_summary, dict): return False - return intake_summary.get("nonRelease") is True or intake_summary.get("nonProduction") is True + fields = ["nonRelease", "nonProduction"] + if stable_rc: + fields.extend(("fixtureOnly", "simulatedOnly")) + return any( + intake_summary.get(field) is not False + for field in fields + ) def multi_node_summary_path(settings: Settings, cert_out: Path) -> Path: if settings.multi_node_soak_summary is not None and not settings.run_multi_node_soak: diff --git a/tools/release-certification/cryptad_certification/engines/production_beta_release_orchestration.py b/tools/release-certification/cryptad_certification/engines/production_beta_release_orchestration.py index 44d9a4a8d1..d30b24f432 100644 --- a/tools/release-certification/cryptad_certification/engines/production_beta_release_orchestration.py +++ b/tools/release-certification/cryptad_certification/engines/production_beta_release_orchestration.py @@ -353,6 +353,9 @@ def attach_go_no_go_dashboard( return summary def stable_readiness_args(settings: Settings) -> list[str]: + public_beta_known_issues = settings.public_beta_known_issues or ( + settings.workspace_root / "tools/release-certification/public-beta-known-issues.json" + ) args = [ sys.executable, str(TOOL_DIR / "cryptad_certification/engine_entry.py"), @@ -378,7 +381,7 @@ def stable_readiness_args(settings: Settings) -> list[str]: "--security-drills-summary", str(security_drills_summary_path(settings)), "--public-beta-known-issues", - str(settings.workspace_root / "tools/release-certification/public-beta-known-issues.json"), + str(public_beta_known_issues), ] if settings.stable_readiness_policy is not None: args.extend(["--policy", str(settings.stable_readiness_policy)]) @@ -507,6 +510,7 @@ def assert_stable_readiness_args_use_stable_waiver_file_only() -> None: workspace.mkdir(parents=True) waiver_file = workspace / "release-waivers.json" stable_waivers = workspace / "stable-waivers.json" + reviewed_known_issues = workspace / "reviewed-public-beta-known-issues.json" settings = Settings( workspace_root=workspace, out_dir=workspace / "build/production-beta", @@ -530,11 +534,13 @@ def assert_stable_readiness_args_use_stable_waiver_file_only() -> None: stable_readiness_policy=workspace / "tools/release-certification/stable-1.0-readiness-policy.json", stable_known_limitations=workspace / "tools/release-certification/stable-1.0-known-limitations.json", stable_readiness_waivers=stable_waivers, + public_beta_known_issues=reviewed_known_issues, ) args = stable_readiness_args(settings) assert str(waiver_file) not in args, args assert "--waivers" in args, args assert str(stable_waivers) in args, args + assert args[args.index("--public-beta-known-issues") + 1] == str(reviewed_known_issues), args def assert_required_stable_readiness_removes_dist_refs_from_dashboard() -> None: with tempfile.TemporaryDirectory(prefix="cryptad-production-beta-stable-dist-") as temp_name: @@ -778,6 +784,7 @@ def run_pipeline(settings: Settings) -> tuple[dict[str, Any], int]: redaction_report = release_redaction_report(pre_dist_findings) write_json(settings.out_dir / "reports/redaction-report.json", redaction_report) archive: Path | None = None + stable_rc_archive: Path | None = None summary: dict[str, Any] | None = None if redaction_report["status"] == "pass": summary = build_final_summary(state, promotion, redaction_report, None) @@ -805,6 +812,11 @@ def run_pipeline(settings: Settings) -> tuple[dict[str, Any], int]: planned_archive = dist_bundle_path(settings, version) summary.setdefault("artifacts", {})["distArchive"] = f"dist/{planned_archive.name}" summary.setdefault("artifacts", {})["checksums"] = "dist/checksums.txt" + if settings.stable_rc_artifact_timestamp: + planned_stable_rc_archive = stable_rc_product_bundle_path(settings, version) + summary.setdefault("artifacts", {})["stableRcDistribution"] = ( + f"dist/{planned_stable_rc_archive.name}" + ) write_json(settings.out_dir / "reports/production-beta-summary.json", summary) write_text(settings.out_dir / "reports/production-beta-summary.md", render_markdown_summary(summary)) summary = attach_go_no_go_dashboard( @@ -814,20 +826,36 @@ def run_pipeline(settings: Settings) -> tuple[dict[str, Any], int]: settings.require_stable_readiness, ) try: - archive = create_dist_bundle(settings, version) + if settings.stable_rc_artifact_timestamp: + stable_rc_archive = create_stable_rc_product_bundle(settings, version) + archive = create_dist_bundle( + settings, + version, + [stable_rc_archive] if stable_rc_archive is not None else [], + ) except ReleaseArtifactError as exc: tar_findings = [{"kind": "forbidden-tar-entry", "path": "dist", "detail": str(exc)}] partial_archive = dist_bundle_path(settings, version) - remove_dist_bundle(settings, partial_archive) + remove_dist_bundle( + settings, + partial_archive, + [stable_rc_archive] + if stable_rc_archive is not None + else [stable_rc_product_bundle_path(settings, version)], + ) archive = None + stable_rc_archive = None else: tar_findings = scan_tarball(archive, settings) + if stable_rc_archive is not None: + tar_findings.extend(scan_tarball(stable_rc_archive, settings)) else: tar_findings = [] archive = None artifacts = summary.get("artifacts") if isinstance(summary.get("artifacts"), dict) else {} artifacts.pop("distArchive", None) artifacts.pop("checksums", None) + artifacts.pop("stableRcDistribution", None) summary["artifacts"] = artifacts write_json(settings.out_dir / "reports/production-beta-summary.json", summary) write_text(settings.out_dir / "reports/production-beta-summary.md", render_markdown_summary(summary)) @@ -846,8 +874,13 @@ def run_pipeline(settings: Settings) -> tuple[dict[str, Any], int]: redaction_report = release_redaction_report(tar_findings) write_json(settings.out_dir / "reports/redaction-report.json", redaction_report) if archive is not None: - remove_dist_bundle(settings, archive) + remove_dist_bundle( + settings, + archive, + [stable_rc_archive] if stable_rc_archive is not None else [], + ) archive = None + stable_rc_archive = None summary = None if redaction_report["status"] != "pass": if summary is not None: @@ -887,6 +920,14 @@ def build_parser() -> argparse.ArgumentParser: "production-beta unless CRYPTAD_PRODUCTION_BETA_ARTIFACT_BASE_URI is set." ), ) + parser.add_argument( + "--stable-rc-artifact-timestamp", + default="", + help=( + "Protected timestamp already bound by Stable RC catalog-operations evidence; " + "used only to reproduce signed catalog, review, and product-distribution bytes." + ), + ) parser.add_argument("--require-live-network", action="store_true", help="Require live-network beta evidence.") parser.add_argument( "--require-history", @@ -959,6 +1000,11 @@ def build_parser() -> argparse.ArgumentParser: type=Path, help="Stable 1.0 readiness policy JSON. Defaults to tools/release-certification/stable-1.0-readiness-policy.json.", ) + parser.add_argument( + "--public-beta-known-issues", + type=Path, + help="Reviewed public beta known-issues JSON forwarded to the Stable readiness gate.", + ) parser.add_argument( "--stable-known-limitations", type=Path, @@ -1041,6 +1087,11 @@ def settings_from_args(args: argparse.Namespace) -> Settings: workspace, ) stable_readiness_waivers = resolve_workspace_path_arg(args.stable_readiness_waivers, workspace) + public_beta_known_issues = resolve_workspace_path_arg( + args.public_beta_known_issues + or Path("tools/release-certification/public-beta-known-issues.json"), + workspace, + ) if args.third_party_intake_summary is not None and args.run_third_party_intake_sample_flow: raise SystemExit("--run-third-party-intake-sample-flow cannot be combined with --third-party-intake-summary.") artifact_base_uri = args.artifact_base_uri.strip() or os.environ.get( @@ -1054,6 +1105,25 @@ def settings_from_args(args: argparse.Namespace) -> Settings: ) artifact_base_uri = f"https://downloads.crypta.invalid/production-beta/{read_project_version(workspace)}" validate_artifact_base_uri(args.mode, artifact_base_uri) + stable_rc_artifact_timestamp = args.stable_rc_artifact_timestamp.strip() + if stable_rc_artifact_timestamp: + try: + parsed_artifact_timestamp = dt.datetime.fromisoformat( + stable_rc_artifact_timestamp.replace("Z", "+00:00") + ) + except ValueError as exc: + raise SystemExit( + "--stable-rc-artifact-timestamp must be an ISO-8601 timestamp" + ) from exc + if parsed_artifact_timestamp.tzinfo is None: + raise SystemExit( + "--stable-rc-artifact-timestamp must include a timezone" + ) + if args.mode != "production-beta" or args.catalog_channel != "stable": + raise SystemExit( + "--stable-rc-artifact-timestamp is restricted to production-beta mode " + "with the stable catalog channel" + ) if args.use_fixture_evidence and args.mode != "developer-dry-run": raise SystemExit("--use-fixture-evidence is only allowed with --mode developer-dry-run or internal self-tests.") require_live = args.require_live_network or ( @@ -1159,6 +1229,7 @@ def settings_from_args(args: argparse.Namespace) -> Settings: stable_readiness_policy=stable_readiness_policy, stable_known_limitations=stable_known_limitations, stable_readiness_waivers=stable_readiness_waivers, + public_beta_known_issues=public_beta_known_issues, release_id=args.release_id.strip() or None, interop_smoke_summary=resolve_workspace_path_arg(args.interop_smoke_summary, workspace), interop_extended_summary=resolve_workspace_path_arg(args.interop_extended_summary, workspace), @@ -1169,6 +1240,7 @@ def settings_from_args(args: argparse.Namespace) -> Settings: workspace, ), require_history=args.require_history or args.mode == "production-beta", + stable_rc_artifact_timestamp=stable_rc_artifact_timestamp or None, ) def normalized_artifact_hostname(hostname: str) -> str: diff --git a/tools/release-certification/cryptad_certification/engines/production_beta_release_promotion.py b/tools/release-certification/cryptad_certification/engines/production_beta_release_promotion.py index 1a5ac16d4e..afbd7bcff4 100644 --- a/tools/release-certification/cryptad_certification/engines/production_beta_release_promotion.py +++ b/tools/release-certification/cryptad_certification/engines/production_beta_release_promotion.py @@ -101,7 +101,10 @@ def add_gate(gate_id: str, ok: bool, summary: str, source: str = "pipeline") -> add_gate( "third-party-intake.production-evidence", third_party_intake_summary is not None - and not third_party_intake_summary_is_non_release(third_party_intake_summary), + and not third_party_intake_summary_is_non_release( + third_party_intake_summary, + stable_rc=settings.stable_rc_artifact_timestamp is not None, + ), "Attached or required third-party intake evidence must not be marked non-release or non-production.", "third-party-intake", ) @@ -319,7 +322,10 @@ def add_gate(gate_id: str, ok: bool, summary: str, source: str = "pipeline") -> "summaryPath": "evidence/third-party-intake-summary.json", "redaction": redaction_status, "missingOrFailedEvidence": missing_or_failed_intake, - "nonRelease": third_party_intake_summary_is_non_release(third_party_intake_summary), + "nonRelease": third_party_intake_summary_is_non_release( + third_party_intake_summary, + stable_rc=settings.stable_rc_artifact_timestamp is not None, + ), }, "knownLimitations": [], } @@ -1028,6 +1034,9 @@ def render_markdown_summary(summary: dict[str, Any]) -> str: def dist_bundle_path(settings: Settings, version: str) -> Path: return settings.out_dir / "dist" / f"crypta-production-beta-{version}.tar.gz" +def stable_rc_product_bundle_path(settings: Settings, version: str) -> Path: + return settings.out_dir / "dist" / f"crypta-stable-1.0-rc-{version}-product.tar.gz" + def dist_checksums_path(settings: Settings) -> Path: return settings.out_dir / "dist" / "checksums.txt" @@ -1048,35 +1057,179 @@ def reset_release_output_roots(settings: Settings) -> None: for root_name in RELEASE_OUTPUT_ROOTS: reset_output_subtree(settings.out_dir / root_name) -def remove_dist_bundle(settings: Settings, archive: Path) -> None: - for path in (archive, dist_checksums_path(settings)): +def remove_dist_bundle( + settings: Settings, + archive: Path, + additional_archives: Iterable[Path] = (), +) -> None: + for path in (archive, *additional_archives, dist_checksums_path(settings)): try: path.unlink() except FileNotFoundError: pass -def create_dist_bundle(settings: Settings, version: str) -> Path: +def _confined_archive_sources( + settings: Settings, + relative_roots: Iterable[str], + *, + require_roots: bool, +) -> list[tuple[str, Path]]: + output_root = settings.out_dir.resolve() + sources: list[tuple[str, Path]] = [] + for relative_root in relative_roots: + root = settings.out_dir / relative_root + current = settings.out_dir + for part in Path(relative_root).parts: + current /= part + if current.is_symlink(): + raise ReleaseArtifactError( + f"dist archive source contains a symlink component: {relative_root}" + ) + if not root.exists(): + if require_roots: + raise ReleaseArtifactError( + f"Stable RC product archive source is missing: {relative_root}" + ) + continue + candidates = [root] if root.is_file() else sorted(root.rglob("*")) + for candidate in candidates: + relative = candidate.relative_to(settings.out_dir).as_posix() + if candidate.is_symlink(): + raise ReleaseArtifactError( + f"dist archive would include a link entry, which is not allowed: {relative}" + ) + if candidate.is_dir(): + continue + if not candidate.is_file(): + raise ReleaseArtifactError( + f"dist archive would include a non-regular entry: {relative}" + ) + try: + candidate.resolve().relative_to(output_root) + except ValueError as exc: + raise ReleaseArtifactError( + f"dist archive source escapes its output directory: {relative}" + ) from exc + reason = bad_artifact_name(relative) + if reason: + raise ReleaseArtifactError( + f"dist archive would include forbidden artifact {relative}: {reason}" + ) + sources.append((relative, candidate)) + names = [name for name, _path in sources] + if len(names) != len(set(names)): + raise ReleaseArtifactError("dist archive source paths overlap") + return sorted(sources) + +def _write_deterministic_tar_gzip( + archive_path: Path, + sources: Iterable[tuple[str, Path]], +) -> None: + ordered_sources = sorted(sources) + staged_executables = _staged_executable_members(ordered_sources) + temporary = archive_path.with_name(f".{archive_path.name}.tmp") + archive_path.parent.mkdir(parents=True, exist_ok=True) + try: + with temporary.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive: + for name, path in ordered_sources: + data = path.read_bytes() + info = tarfile.TarInfo(name=name) + info.size = len(data) + info.mode = _deterministic_archive_mode(name, staged_executables) + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + archive.addfile(info, io.BytesIO(data)) + os.replace(temporary, archive_path) + finally: + temporary.unlink(missing_ok=True) + +def _staged_executable_members( + sources: Iterable[tuple[str, Path]], +) -> frozenset[str]: + """Return staged app launchers and migration commands that require executable mode.""" + + source_by_name = dict(sources) + commands: set[str] = set() + for name, path in source_by_name.items(): + parts = PurePosixPath(name).parts + if ( + len(parts) != 4 + or parts[:2] != ("build", "staged-apps") + or parts[-1] != "cryptad-app.properties" + ): + continue + for line in path.read_text(encoding="utf-8").splitlines(): + key, separator, raw_value = line.partition("=") + key = key.strip() + migration_command = ( + key.startswith("app.data.migration.") + and key.endswith(".command") + ) + if not separator or (key != "app.exec" and not migration_command): + continue + command = raw_value.strip() + command_parts = command.split("/") + if ( + not command + or "\\" in command + or any(part in {"", ".", ".."} for part in command_parts) + ): + raise ReleaseArtifactError( + f"staged app declares an unsafe executable command: {name}" + ) + member = PurePosixPath(*parts[:-1], *command_parts).as_posix() + if member not in source_by_name: + raise ReleaseArtifactError( + f"staged app executable command is missing from the archive: {member}" + ) + commands.add(member) + return frozenset(commands) + +def _deterministic_archive_mode( + name: str, + staged_executables: frozenset[str], +) -> int: + windows_command = name.lower().endswith((".bat", ".cmd", ".ps1")) + launcher = name.startswith("build/crypta-app-launcher/bin/") + executable = launcher or name in staged_executables + return 0o755 if executable and not windows_command else 0o644 + +def create_stable_rc_product_bundle(settings: Settings, version: str) -> Path: + if not settings.stable_rc_artifact_timestamp: + raise ReleaseArtifactError( + "Stable RC product packaging requires its protected artifact timestamp" + ) + archive = stable_rc_product_bundle_path(settings, version) + sources = _confined_archive_sources( + settings, + STABLE_RC_PRODUCT_PATHS, + require_roots=True, + ) + _write_deterministic_tar_gzip(archive, sources) + return archive + +def create_dist_bundle( + settings: Settings, + version: str, + additional_archives: Iterable[Path] = (), +) -> Path: dist_dir = settings.out_dir / "dist" dist_dir.mkdir(parents=True, exist_ok=True) tar_path = dist_bundle_path(settings, version) - - def tar_filter(info: tarfile.TarInfo) -> tarfile.TarInfo: - reason = bad_artifact_name(info.name) - if reason: - raise ReleaseArtifactError(f"dist archive would include forbidden artifact {info.name}: {reason}") - if info.issym() or info.islnk(): - raise ReleaseArtifactError(f"dist archive would include a link entry, which is not allowed: {info.name}") - if info.isdev(): - raise ReleaseArtifactError(f"dist archive would include a device entry, which is not allowed: {info.name}") - return info - - with tarfile.open(tar_path, "w:gz", format=tarfile.PAX_FORMAT) as archive: - for root_name in RELEASE_OUTPUT_ROOTS: - root_path = settings.out_dir / root_name - if root_path.exists(): - archive.add(root_path, arcname=root_name, recursive=True, filter=tar_filter) + sources = _confined_archive_sources( + settings, + RELEASE_OUTPUT_ROOTS, + require_roots=False, + ) + _write_deterministic_tar_gzip(tar_path, sources) checksums = dist_checksums_path(settings) - checksum_lines = [f"{sha256_file(tar_path)} {tar_path.name}"] + checksum_targets = [tar_path, *additional_archives] + checksum_lines = [f"{sha256_file(path)} {path.name}" for path in checksum_targets] write_text(checksums, "\n".join(checksum_lines) + "\n") return tar_path @@ -1145,6 +1298,7 @@ def build_final_summary( "mode": settings.mode, "releaseId": state.settings.release_id or f"cryptad-beta-{state.version}", "version": state.version, + "stableRcArtifactTimestamp": settings.stable_rc_artifact_timestamp, "artifactBaseUri": settings.artifact_base_uri, "status": status, "promotionReady": summary_promotion_ready, diff --git a/tools/release-certification/cryptad_certification/engines/production_beta_release_selftest_fixtures.py b/tools/release-certification/cryptad_certification/engines/production_beta_release_selftest_fixtures.py index 5e98db99d9..06abd26339 100644 --- a/tools/release-certification/cryptad_certification/engines/production_beta_release_selftest_fixtures.py +++ b/tools/release-certification/cryptad_certification/engines/production_beta_release_selftest_fixtures.py @@ -1049,6 +1049,44 @@ def assert_required_third_party_intake_uses_attached_summary_rows() -> None: "missingOrFailedEvidence" ], promotion +def assert_existing_production_intake_classification_remains_compatible() -> None: + with tempfile.TemporaryDirectory(prefix="cryptad-production-beta-intake-contract-") as temp_name: + workspace = Path(temp_name) / "repo" + workspace.mkdir(parents=True) + out_dir = workspace / "build/production-beta" + settings = dataclasses.replace( + cleanup_test_settings(workspace, out_dir), + mode="production-beta", + require_third_party_intake=True, + ) + intake = third_party_intake_sample_summary() + intake["nonRelease"] = False + intake["nonProduction"] = False + summaries = passing_promotion_summaries() + summaries["thirdPartyIntake"] = intake + state = PipelineState(settings, "self-test", utc_now(), [], [], []) + + promotion = evaluate_promotion(state, summaries) + + gate = promotion_gate_by_id(promotion, "third-party-intake.production-evidence") + assert gate["status"] == "pass", promotion + assert promotion["thirdPartyIntake"]["nonRelease"] is False, promotion + + stable_settings = dataclasses.replace( + settings, + stable_rc_artifact_timestamp="2026-07-14T00:00:00Z", + ) + stable_promotion = evaluate_promotion( + PipelineState(stable_settings, "self-test", utc_now(), [], [], []), + summaries, + ) + stable_gate = promotion_gate_by_id( + stable_promotion, + "third-party-intake.production-evidence", + ) + assert stable_gate["status"] == "fail", stable_promotion + assert stable_promotion["thirdPartyIntake"]["nonRelease"] is True, stable_promotion + def assert_production_third_party_intake_rejects_non_release_summary() -> None: with tempfile.TemporaryDirectory(prefix="cryptad-production-beta-intake-nonrelease-") as temp_name: workspace = Path(temp_name) / "repo" diff --git a/tools/release-certification/cryptad_certification/engines/production_beta_release_selftest_policy.py b/tools/release-certification/cryptad_certification/engines/production_beta_release_selftest_policy.py index a17e21efdd..14c5ee843f 100644 --- a/tools/release-certification/cryptad_certification/engines/production_beta_release_selftest_policy.py +++ b/tools/release-certification/cryptad_certification/engines/production_beta_release_selftest_policy.py @@ -1830,6 +1830,11 @@ def assert_catalog_signature_and_timestamps_are_canonical() -> None: timeout_seconds=60, clean_out_dir=True, ) + artifact_timestamp = "2026-06-14T01:02:03Z" + settings = dataclasses.replace( + settings, + stable_rc_artifact_timestamp=artifact_timestamp, + ) state = PipelineState(settings, "self-test", started_at, [], [], []) profile = SigningProfile( kind="test", @@ -1854,7 +1859,8 @@ def assert_catalog_signature_and_timestamps_are_canonical() -> None: canonical.read_bytes() == alias.read_bytes() ), "catalog signature alias diverged from canonical sidecar" catalog_text = (out_dir / "catalog/first-party-catalog.properties").read_text(encoding="utf-8") - assert f"generatedAt={started_at}" in catalog_text + assert f"generatedAt={artifact_timestamp}" in catalog_text + assert f"generatedAt={started_at}" not in catalog_text assert ( "bundle.uri=https://downloads.crypta.invalid/self-test/build/app-bundles/queue-manager-1.2.3.zip" in catalog_text @@ -1871,7 +1877,7 @@ def assert_catalog_signature_and_timestamps_are_canonical() -> None: assert channel_metadata["apps"][0]["maintenance"]["owner"] == "crypta-core", ( channel_metadata ) - assert f"reviewedAt={started_at}" in ( + assert f"reviewedAt={artifact_timestamp}" in ( out_dir / "reviews/review-receipts/queue-manager-review-receipt.properties" ).read_text(encoding="utf-8") @@ -2479,6 +2485,7 @@ def run_self_test() -> None: assert_failed_final_summary_clears_promotion_ready() assert_required_third_party_intake_requires_summary() assert_required_third_party_intake_uses_attached_summary_rows() + assert_existing_production_intake_classification_remains_compatible() assert_production_third_party_intake_rejects_non_release_summary() assert_production_third_party_intake_rejects_optional_non_release_summary() assert_release_candidate_third_party_intake_rejects_non_release_summary() diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_rc.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc.py new file mode 100644 index 0000000000..2d8b783387 --- /dev/null +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc.py @@ -0,0 +1,765 @@ +"""Canonical Stable 1.0 release-candidate execution and release-freeze engine.""" + +from __future__ import annotations + +import datetime as dt +import shutil +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from cryptad_certification.io import read_json, write_json, write_text +from cryptad_certification.models import RunContext +from cryptad_certification.redaction import scan_value +from cryptad_certification.workspace import reset_confined_directory + +from .stable_1_0_rc_artifacts import ( + build_redaction_report, + create_deterministic_archive, + render_freeze_report, + render_go_no_go, + render_release_notes, + verify_checksums, + verify_deterministic_archive, + write_checksums, + write_named_checksums, +) +from .stable_1_0_rc_core import ( + CHECKSUMS_FILE, + DRIFT_REPORT_FILE, + EVIDENCE_IDS, + FINAL_DECISION_EVIDENCE_ID, + FREEZE_FILE, + FREEZE_REPORT_FILE, + FREEZE_SIDECAR_FILE, + KNOWN_LIMITATIONS_FILE, + PROVENANCE_FILE, + REDACTION_REPORT_FILE, + RELEASE_NOTES_FILE, + REPORT_FILE, + SCHEMA_VERSION, + STABLE_MILESTONE, + SUMMARY_FILE, + SUPPORTING_VERIFIER_FILES, + TOOL_NAME, + TOOL_VERSION, + LoadedInput, + ValidationState, + file_digest, + load_candidate_inputs, + load_existing_input, + load_raw_input, + parse_timestamp, + placeholder_findings, + source_identity, + validate_prerequisites, +) +from .stable_1_0_rc_freeze import ( + assemble_freeze, + build_catalog_and_apps_freeze, + build_limitations_freeze, + build_platform_api_freeze, + compare_freezes, + export_content_profiles, + merge_accepted_exception_history, + production_native_root, + safe_artifact, + validate_exception_collection, + validate_freeze_shape, +) + + +def run(context: RunContext) -> tuple[int, Path, Path]: + """Execute Stable RC validation, freeze, packaging, and final verification.""" + + out = reset_confined_directory( + context.component_dir / "artifacts" / "legacy", + context.run_root, + "Stable RC native output", + ) + summary_path = out / SUMMARY_FILE + report_path = out / REPORT_FILE + state = ValidationState() + try: + code = _run(context, out, state) + except Exception: # noqa: BLE001 - a release gate must always fail closed + # Validation deliberately continues after recording independent blockers so reviewers get + # one complete remediation list. A malformed protected input can therefore reach a later + # builder before all of its nested shape has been consumed. Never let such a structural + # error escape without the sanitized native no-go evidence required by the v2 adapter. + state.block( + "stable-1.0-rc.execution-input", + "stable-1.0-rc.prerequisites", + "Stable RC execution could not validate a required protected input or artifact.", + "Correct the candidate-bound input and rerun the complete Stable RC workflow.", + ) + out = reset_confined_directory( + context.component_dir / "artifacts" / "legacy", + context.run_root, + "Stable RC failed native output", + ) + _write_fail_closed_artifacts( + context, + out, + state, + redaction_status="fail", + ) + code = 1 + return code, summary_path, report_path + + +def _run(context: RunContext, out: Path, state: ValidationState) -> int: + now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + production_input = load_existing_input(context, "productionBeta") + native_root = production_native_root(production_input.path) + inputs = load_candidate_inputs(context, native_root) + catalog_operations = _require_raw(context, "stableCatalogOperations") + readiness_policy = _require_raw(context, "stableReadinessPolicy") + stable_known = _require_raw(context, "stableKnownLimitations") + public_issues = _require_raw(context, "publicBetaKnownIssues") + for loaded in (stable_known, public_issues): + if scan_value(loaded.value): + state.block( + f"stable-1.0-rc.redaction.{loaded.key}", + "stable-1.0-rc.redaction", + f"Input {loaded.key} contains redaction findings.", + "Replace the input with a redaction-safe candidate-bound summary.", + ) + if placeholder_findings(loaded.value): + state.block( + f"stable-1.0-rc.placeholder.{loaded.key}", + "stable-1.0-rc.redaction", + f"Input {loaded.key} contains production placeholder metadata.", + "Replace example and REPLACE_ME metadata before Stable RC execution.", + ) + # Make policy freshness available to the shared PR-282 readiness validation wrapper. + inputs["stableReadinessPolicy"] = readiness_policy + previous_freeze_input = load_raw_input(context, "previousStableRcFreeze", required=False) + freeze_mode = _freeze_mode(context, previous_freeze_input, state) + exception_input = load_raw_input(context, "stableRcFreezeExceptions", required=False) + exceptions, exception_errors = validate_exception_collection( + exception_input.value if exception_input else None, + context.manifest.release.release_id, + str(context.manifest.release.version or ""), + now, + ) + for error in exception_errors: + state.block( + "stable-1.0-rc.freeze-exception", + "stable-1.0-rc.freeze-verification", + error + ".", + "Replace the exception collection with an authorized, current, narrowly scoped record.", + ) + previous_freeze = previous_freeze_input.value if previous_freeze_input else None + comparison_baseline = _comparison_baseline_binding(previous_freeze_input) + accepted_exception_history = merge_accepted_exception_history(previous_freeze, exceptions) + validate_prerequisites(context, inputs, catalog_operations, now, state) + source = source_identity(context, inputs["releaseCertification"].value) + if catalog_operations.value.get("sourceCommit") != source.commit: + state.block( + "stable-1.0-rc.catalog-source-binding", + "stable-1.0-rc.candidate-binding", + "Protected catalog-operations evidence is bound to a different source commit.", + "Regenerate catalog-operations evidence from the candidate commit.", + ) + + platform, current_contract, _platform_diff = build_platform_api_freeze( + context, + native_root, + out, + state, + ) + allowed_records = inputs["stableReadiness"].value.get("allowedLimitations") + if not isinstance(allowed_records, list): + raise ValueError("Stable readiness allowedLimitations is malformed") + allowed_ids = { + str(row.get("id")) + for row in allowed_records + if isinstance(row, dict) and row.get("id") + } + stable_catalog, first_party_apps = build_catalog_and_apps_freeze( + native_root, + current_contract, + catalog_operations.value, + out, + allowed_ids, + state, + inputs["productionBeta"].value.get("signingProfile"), + ) + content_profiles, _content_export = export_content_profiles( + native_root, + out, + inputs["appPlatform"].value, + state, + ) + limitations = build_limitations_freeze( + inputs["stableReadiness"].value, + readiness_policy, + stable_known, + public_issues, + inputs["appPlatform"].value, + inputs["securityDrills"].digest, + state, + ) + production_archive = _copy_production_distribution( + native_root, + inputs["productionBeta"].value, + out, + ) + _validate_production_distribution(native_root, production_archive, context, state) + production_distribution_digest = file_digest(production_archive) + freeze = assemble_freeze( + context=context, + source=source, + inputs=inputs, + catalog_operations=catalog_operations, + platform_api=platform, + stable_catalog=stable_catalog, + first_party_apps=first_party_apps, + content_profiles=content_profiles, + limitations=limitations, + accepted_exceptions=accepted_exception_history, + production_distribution_digest=production_distribution_digest, + ) + freeze_errors = validate_freeze_shape(freeze) + if freeze_errors: + state.block( + "stable-1.0-rc.generated-freeze-schema", + "stable-1.0-rc.freeze-generation", + "The generated Stable RC freeze does not conform to the v1 freeze schema.", + "Correct the protected source artifacts and regenerate the complete Stable RC freeze.", + ) + raise ValueError("generated Stable RC freeze failed schema validation") + initial_drift = compare_freezes(previous_freeze, freeze, exceptions) + initial_drift["comparisonBaseline"] = comparison_baseline + initial_drift["freezeMode"] = freeze_mode + drift = _finalize_drift(initial_drift) + if drift["status"] != "no-drift": + state.block( + "stable-1.0-rc.freeze-drift", + "stable-1.0-rc.freeze-verification", + f"Stable RC freeze verification ended in {drift['status']}.", + "Resolve drift or provide an authorized blocker/security exception, then regenerate every final artifact.", + ) + write_json(out / FREEZE_FILE, freeze) + write_text( + out / FREEZE_SIDECAR_FILE, + f"{file_digest(out / FREEZE_FILE).removeprefix('sha256:')} {FREEZE_FILE}", + ) + write_json(out / DRIFT_REPORT_FILE, drift) + write_text(out / FREEZE_REPORT_FILE, render_freeze_report(freeze, drift)) + known_artifact = { + "schemaVersion": 1, + "kind": "stable-1.0-rc-known-limitations", + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "allowedLimitations": limitations["allowedLimitations"], + "allowedLimitationsDigest": limitations["allowedLimitationsDigest"], + "disallowedLimitationCount": limitations["disallowedLimitationCount"], + "betaOnlyLimitationCount": limitations["betaOnlyLimitationCount"], + } + write_json(out / KNOWN_LIMITATIONS_FILE, known_artifact) + release_notes = render_release_notes( + freeze, + inputs["previousCandidate"].value, + _accepted_waivers(inputs["goNoGo"].value), + accepted_exception_history, + public_known_issues=public_issues.value, + stable_readiness=inputs["stableReadiness"].value, + operational_inputs={key: loaded.value for key, loaded in inputs.items()}, + drift=drift, + ) + write_text(out / RELEASE_NOTES_FILE, release_notes) + + provenance = _provenance( + context, + source, + freeze, + inputs, + catalog_operations, + previous_freeze_input, + production_archive, + freeze_mode, + ) + write_json(out / PROVENANCE_FILE, provenance) + redaction_values: list[tuple[str, Any]] = [ + (FREEZE_FILE, freeze), + (DRIFT_REPORT_FILE, drift), + (KNOWN_LIMITATIONS_FILE, known_artifact), + (PROVENANCE_FILE, provenance), + (RELEASE_NOTES_FILE, release_notes), + ] + redaction_values.extend( + (name, read_json(out / name)) for name in SUPPORTING_VERIFIER_FILES + ) + redaction = build_redaction_report(redaction_values) + if redaction["status"] != "pass": + state.block( + "stable-1.0-rc.generated-redaction", + "stable-1.0-rc.redaction", + "Generated Stable RC artifacts failed redaction validation.", + "Remove unsafe source metadata and regenerate the complete RC.", + ) + write_json(out / REDACTION_REPORT_FILE, redaction) + archive = out / f"cryptad-stable-1.0-rc-{context.manifest.release.version}.tar.gz" + summary = _promotion_summary(context, freeze, drift, state, redaction, inputs) + summary["artifacts"]["archive"] = archive.name + summary["artifacts"]["productionDistribution"] = production_archive.name + write_json(out / SUMMARY_FILE, summary) + write_text(out / REPORT_FILE, render_go_no_go(summary)) + + payload_checksum = out / "payload-checksums.txt" + internal_members = _archive_metadata_paths(out) + write_named_checksums( + payload_checksum, + [ + (f"payload/{production_archive.name}", production_archive), + *[(f"metadata/{path.name}", path) for path in internal_members], + ], + ) + archive_members = [ + (f"payload/{production_archive.name}", production_archive), + *[(f"metadata/{path.name}", path) for path in internal_members], + (payload_checksum.name, payload_checksum), + ] + create_deterministic_archive(archive, archive_members) + reproducibility_copy = out / f".{archive.name}.reproducibility-check" + create_deterministic_archive(reproducibility_copy, archive_members) + if file_digest(reproducibility_copy) != file_digest(archive): + state.block( + "stable-1.0-rc.archive-reproducibility", + "stable-1.0-rc.archive-hygiene", + "Stable RC archive bytes are not reproducible.", + "Fix archive normalization before promotion.", + ) + reproducibility_copy.unlink(missing_ok=True) + archive_errors = verify_deterministic_archive(archive) + for error in archive_errors: + state.block( + "stable-1.0-rc.archive-hygiene", + "stable-1.0-rc.archive-hygiene", + error + ".", + "Remove the unsafe member and rebuild the normalized archive.", + ) + checksum_members = [archive, production_archive, *internal_members] + write_checksums(out / CHECKSUMS_FILE, checksum_members) + for error in verify_checksums(out / CHECKSUMS_FILE): + state.block( + "stable-1.0-rc.checksum-verification", + "stable-1.0-rc.archive-hygiene", + error + ".", + "Regenerate checksums from the exact packaged bytes.", + ) + + final_summary = _promotion_summary(context, freeze, drift, state, redaction, inputs) + final_summary["artifacts"]["archive"] = archive.name + final_summary["artifacts"]["productionDistribution"] = production_archive.name + write_json(out / SUMMARY_FILE, final_summary) + write_text(out / REPORT_FILE, render_go_no_go(final_summary)) + # The reviewer archive must contain the final promotion decision. Rebuild once, then checksums. + write_named_checksums( + payload_checksum, + [ + (f"payload/{production_archive.name}", production_archive), + *[(f"metadata/{path.name}", path) for path in _archive_metadata_paths(out)], + ], + ) + create_deterministic_archive( + archive, + [ + (f"payload/{production_archive.name}", production_archive), + *[(f"metadata/{path.name}", path) for path in _archive_metadata_paths(out)], + (payload_checksum.name, payload_checksum), + ], + ) + payload_checksum.unlink(missing_ok=True) + write_checksums(out / CHECKSUMS_FILE, [archive, production_archive, *_archive_metadata_paths(out)]) + final_reproducibility_copy = out / f".{archive.name}.final-reproducibility-check" + # Recreate the same final archive from a temporary checksum file to prove byte stability. + write_named_checksums( + payload_checksum, + [ + (f"payload/{production_archive.name}", production_archive), + *[(f"metadata/{path.name}", path) for path in _archive_metadata_paths(out)], + ], + ) + create_deterministic_archive( + final_reproducibility_copy, + [ + (f"payload/{production_archive.name}", production_archive), + *[(f"metadata/{path.name}", path) for path in _archive_metadata_paths(out)], + (payload_checksum.name, payload_checksum), + ], + ) + final_errors = [ + *verify_deterministic_archive(archive), + *verify_checksums(out / CHECKSUMS_FILE), + ] + if file_digest(final_reproducibility_copy) != file_digest(archive): + final_errors.append("final archive reproducibility verification failed") + final_reproducibility_copy.unlink(missing_ok=True) + payload_checksum.unlink(missing_ok=True) + if final_errors: + for error in final_errors: + state.block( + "stable-1.0-rc.final-package-verification", + "stable-1.0-rc.archive-hygiene", + error + ".", + "Regenerate the normalized archive and checksums before promotion.", + ) + archive.unlink(missing_ok=True) + final_summary = _promotion_summary(context, freeze, drift, state, redaction, inputs) + final_summary["artifacts"]["productionDistribution"] = production_archive.name + write_json(out / SUMMARY_FILE, final_summary) + write_text(out / REPORT_FILE, render_go_no_go(final_summary)) + write_checksums(out / CHECKSUMS_FILE, [production_archive, *_archive_metadata_paths(out)]) + return 1 + return 0 if final_summary["promotionReady"] is True else 1 + + +def _require_raw(context: RunContext, key: str) -> LoadedInput: + loaded = load_raw_input(context, key) + if loaded is None: + raise ValueError(f"required Stable RC input is missing: {key}") + return loaded + + +def _freeze_mode( + context: RunContext, + previous_freeze: LoadedInput | None, + state: ValidationState, +) -> str: + """Validate and return the explicit Stable RC freeze-lineage mode.""" + + policies = context.manifest.policies + mode = policies.get("stableRcFreezeMode") if isinstance(policies, dict) else None + valid = mode in {"first-freeze", "refreeze"} + if not valid: + state.block( + "stable-1.0-rc.freeze-mode", + "stable-1.0-rc.freeze-verification", + "Stable RC freeze mode is missing or invalid.", + "Select first-freeze for the initial baseline or refreeze with the prior freeze.", + ) + return "invalid" + if mode == "first-freeze" and previous_freeze is not None: + state.block( + "stable-1.0-rc.first-freeze-with-baseline", + "stable-1.0-rc.freeze-verification", + "First-freeze mode was supplied with a previous Stable RC freeze.", + "Use refreeze mode whenever a previous Stable RC freeze exists.", + ) + if mode == "refreeze" and previous_freeze is None: + state.block( + "stable-1.0-rc.refreeze-without-baseline", + "stable-1.0-rc.freeze-verification", + "Refreeze mode is missing the previous Stable RC freeze.", + "Supply the exact previous freeze before verifying or regenerating the candidate.", + ) + return str(mode) + + +def _finalize_drift(initial: dict[str, Any]) -> dict[str, Any]: + status = initial.get("status") + if status == "approved-freeze-exception": + return { + **initial, + "initialStatus": status, + "status": "no-drift", + "regenerated": True, + } + return { + **initial, + "initialStatus": status, + "regenerated": False, + } + + +def _accepted_waivers(go_no_go: dict[str, Any]) -> list[dict[str, Any]]: + waivers = go_no_go.get("waivers") + if not isinstance(waivers, list): + return [] + return [ + row + for row in waivers + if isinstance(row, dict) + and row.get("active") is True + and row.get("appliesToMode") is True + and row.get("validationErrors") == [] + and isinstance(row.get("usedBy"), list) + and bool(row["usedBy"]) + ] + + +def _copy_production_distribution(native_root: Path, production: dict[str, Any], out: Path) -> Path: + artifacts = production.get("artifacts") if isinstance(production.get("artifacts"), dict) else {} + relative = artifacts.get("stableRcDistribution") + if not isinstance(relative, str) or not relative: + raise ValueError( + "production-beta summary omits its deterministic Stable RC distribution" + ) + source = safe_artifact(native_root, relative) + target = out / source.name + if target.is_symlink(): + raise ValueError("Stable RC production distribution target is unsafe") + shutil.copyfile(source, target) + return target + + +def _validate_production_distribution( + native_root: Path, + copied_archive: Path, + context: RunContext, + state: ValidationState, +) -> None: + checksums = safe_artifact(native_root, "dist/checksums.txt") + for error in verify_checksums( + checksums, + required_targets={copied_archive.name: copied_archive}, + ): + state.block( + "stable-1.0-rc.production-checksum", + "stable-1.0-rc.archive-hygiene", + error + ".", + "Regenerate the production distribution and its checksum.", + ) + from cryptad_certification.engines import production_beta_release as production_engine + + settings = SimpleNamespace(workspace_root=context.workspace_root) + for finding in production_engine.scan_tarball(copied_archive, settings): + state.block( + "stable-1.0-rc.production-archive-hygiene", + "stable-1.0-rc.archive-hygiene", + f"Production distribution contains unsafe member kind {finding.get('kind', 'unknown')}.", + "Remove unsafe paths, nested archives, or secret-like material and repackage.", + ) + + +def _provenance( + context: RunContext, + source: Any, + freeze: dict[str, Any], + inputs: dict[str, LoadedInput], + catalog_operations: LoadedInput, + previous_freeze: LoadedInput | None, + production_archive: Path, + freeze_mode: str, +) -> dict[str, Any]: + build = str(context.manifest.release.version) + metadata_members = [ + FREEZE_FILE, + FREEZE_SIDECAR_FILE, + FREEZE_REPORT_FILE, + SUMMARY_FILE, + REPORT_FILE, + KNOWN_LIMITATIONS_FILE, + RELEASE_NOTES_FILE, + DRIFT_REPORT_FILE, + PROVENANCE_FILE, + REDACTION_REPORT_FILE, + *SUPPORTING_VERIFIER_FILES, + "payload-checksums.txt", + ] + provenance_inputs = { + **{key: row.digest for key, row in sorted(inputs.items())}, + "stableCatalogOperations": catalog_operations.digest, + } + if previous_freeze is not None: + provenance_inputs["previousStableRcFreeze"] = previous_freeze.digest + return { + "schemaVersion": 1, + "kind": "stable-1.0-rc-provenance", + "releaseId": context.manifest.release.release_id, + "buildVersion": build, + "freezeMode": freeze_mode, + "source": {"commit": source.commit, "ref": source.ref, "digest": source.digest}, + "freeze": { + "file": FREEZE_FILE, + "contentDigest": freeze["contentDigest"], + "fileDigest": file_digest(context.component_dir / "artifacts/legacy" / FREEZE_FILE), + }, + "comparisonBaseline": _comparison_baseline_binding(previous_freeze), + "productionDistribution": { + "file": f"payload/{production_archive.name}", + "digest": file_digest(production_archive), + }, + "inputs": provenance_inputs, + "archiveLayout": { + "format": "deterministic-tar-gzip-v1", + "root": "stable-1.0-rc", + "normalized": True, + "members": sorted( + [ + f"payload/{production_archive.name}", + *[f"metadata/{name}" for name in metadata_members if name != "payload-checksums.txt"], + "payload-checksums.txt", + ] + ), + }, + "redaction": {"status": "pass", "findingCount": 0}, + } + + +def _comparison_baseline_binding(previous_freeze: LoadedInput | None) -> dict[str, str] | None: + """Bind drift verification to the exact prior freeze bytes and canonical content.""" + + if previous_freeze is None: + return None + content_digest = previous_freeze.value.get("contentDigest") + if not isinstance(content_digest, str): + raise ValueError("previous Stable RC freeze contentDigest is missing") + return { + "fileDigest": previous_freeze.digest, + "contentDigest": content_digest, + } + + +def _archive_metadata_paths(out: Path) -> list[Path]: + return [ + out / FREEZE_FILE, + out / FREEZE_SIDECAR_FILE, + out / FREEZE_REPORT_FILE, + out / SUMMARY_FILE, + out / REPORT_FILE, + out / KNOWN_LIMITATIONS_FILE, + out / RELEASE_NOTES_FILE, + out / DRIFT_REPORT_FILE, + out / PROVENANCE_FILE, + out / REDACTION_REPORT_FILE, + *(out / name for name in SUPPORTING_VERIFIER_FILES), + ] + + +def _promotion_summary( + context: RunContext, + freeze: dict[str, Any], + drift: dict[str, Any], + state: ValidationState, + redaction: dict[str, Any], + inputs: dict[str, LoadedInput], +) -> dict[str, Any]: + stable = inputs["stableReadiness"].value + production_decision = inputs["goNoGo"].value.get("decision") + pass_result = ( + not state.blockers + and stable.get("stableReady") is True + and drift.get("status") == "no-drift" + and redaction.get("status") == "pass" + ) + decision = production_decision if pass_result and production_decision in {"go", "go-with-waivers"} else "no-go" + evidence = [] + for evidence_id in EVIDENCE_IDS: + failed = ( + evidence_id == FINAL_DECISION_EVIDENCE_ID and not pass_result + ) or any(row.get("evidenceId") == evidence_id for row in state.blockers) + evidence.append( + { + "id": evidence_id, + "status": "fail" if failed else "pass", + "summary": "Stable RC gate failed." if failed else "Stable RC gate passed.", + } + ) + return { + "schemaVersion": SCHEMA_VERSION, + "kind": "stable-1.0-rc", + "tool": TOOL_NAME, + "toolVersion": TOOL_VERSION, + "generatedAt": stable.get("generatedAt") or inputs["productionBeta"].value.get("generatedAt"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "stableMilestone": STABLE_MILESTONE, + "status": "pass" if pass_result else "fail", + "promotionReady": pass_result, + "nonRelease": not pass_result, + "stableReady": stable.get("stableReady") is True, + "stableReadinessDecision": stable.get("decision"), + "decision": decision, + "freeze": { + "mode": drift.get("freezeMode"), + "status": "pass" if drift.get("status") == "no-drift" else "fail", + "driftStatus": drift.get("status"), + "initialDriftStatus": drift.get("initialStatus"), + "regenerated": drift.get("regenerated") is True, + "contentDigest": freeze.get("contentDigest"), + }, + "redactionStatus": redaction.get("status"), + "blockerCount": len(state.blockers), + "warningCount": len(state.warnings), + "allowedLimitationCount": len(stable.get("allowedLimitations", [])), + "acceptedWaiverCount": len(_accepted_waivers(inputs["goNoGo"].value)), + "acceptedFreezeExceptionCount": len(freeze.get("acceptedFreezeExceptions", [])), + "blockers": state.blockers, + "warnings": state.warnings, + "allowedLimitations": stable.get("allowedLimitations", []), + "acceptedWaivers": _accepted_waivers(inputs["goNoGo"].value), + "acceptedFreezeExceptions": freeze.get("acceptedFreezeExceptions", []), + "evidence": evidence, + "redaction": redaction, + "artifacts": { + "freeze": FREEZE_FILE, + "freezeSidecar": FREEZE_SIDECAR_FILE, + "freezeReport": FREEZE_REPORT_FILE, + "driftReport": DRIFT_REPORT_FILE, + "knownLimitations": KNOWN_LIMITATIONS_FILE, + "releaseNotes": RELEASE_NOTES_FILE, + "goNoGo": REPORT_FILE, + "checksums": CHECKSUMS_FILE, + "provenance": PROVENANCE_FILE, + "redactionReport": REDACTION_REPORT_FILE, + }, + } + + +def _write_fail_closed_artifacts( + context: RunContext, + out: Path, + state: ValidationState, + *, + redaction_status: str, +) -> None: + redaction = { + "schemaVersion": 1, + "status": redaction_status, + "findingCount": 0 if redaction_status == "pass" else 1, + "findings": [] + if redaction_status == "pass" + else [{"category": "protected-input", "summary": "Unsafe protected input was rejected."}], + "guarantees": {"unsafeInputExcluded": True}, + } + summary = { + "schemaVersion": SCHEMA_VERSION, + "kind": "stable-1.0-rc", + "tool": TOOL_NAME, + "toolVersion": TOOL_VERSION, + "generatedAt": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "stableMilestone": STABLE_MILESTONE, + "status": "fail", + "promotionReady": False, + "nonRelease": True, + "stableReady": False, + "stableReadinessDecision": "not-ready", + "decision": "no-go", + "freeze": {"mode": "invalid", "status": "fail", "driftStatus": "invalid-freeze", "initialDriftStatus": "invalid-freeze", "regenerated": False}, + "redactionStatus": redaction_status, + "blockerCount": len(state.blockers), + "warningCount": len(state.warnings), + "allowedLimitationCount": 0, + "acceptedWaiverCount": 0, + "acceptedFreezeExceptionCount": 0, + "blockers": state.blockers, + "warnings": state.warnings, + "allowedLimitations": [], + "acceptedWaivers": [], + "acceptedFreezeExceptions": [], + "evidence": [ + {"id": evidence_id, "status": "fail", "summary": "Stable RC execution failed closed."} + for evidence_id in EVIDENCE_IDS + ], + "redaction": redaction, + "artifacts": {"goNoGo": REPORT_FILE, "redactionReport": REDACTION_REPORT_FILE}, + } + write_json(out / SUMMARY_FILE, summary) + write_text(out / REPORT_FILE, render_go_no_go(summary)) + write_json(out / REDACTION_REPORT_FILE, redaction) diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_artifacts.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_artifacts.py new file mode 100644 index 0000000000..f3c484e823 --- /dev/null +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_artifacts.py @@ -0,0 +1,758 @@ +"""Reviewer-facing Stable 1.0 RC reports, checksums, and deterministic archive support.""" + +from __future__ import annotations + +import gzip +import hashlib +import io +import os +import re +import tarfile +from pathlib import Path +from typing import Any, Iterable + +from cryptad_certification.io import write_json, write_text +from cryptad_certification.redaction import scan_value + +from .stable_1_0_rc_core import file_digest, placeholder_findings + +_RELEASE_NOTES_TEMPLATE = ( + Path(__file__).resolve().parents[4] + / "docs" + / "templates" + / "stable-1.0-rc-release-notes.md" +) +_RELEASE_NOTES_TEMPLATE_MARKER = "" +_RELEASE_NOTES_BLOCKS = ( + "candidate_identity", + "upgrade_and_recovery", + "platform_api", + "catalog_and_apps", + "content_profiles", + "operational_evidence", + "allowed_limitations", + "known_issues", + "accepted_waivers", + "accepted_exceptions", + "support_and_security", + "final_review", +) +_RELEASE_NOTES_TOKEN_RE = re.compile(r"\{\{([a-z_]+)\}\}") + + +def render_freeze_report(freeze: dict[str, Any], drift: dict[str, Any]) -> str: + """Render a compact reviewer index for every frozen domain.""" + + candidate = freeze.get("candidate", {}) + platform = freeze.get("platformApi", {}) + catalog = freeze.get("stableCatalog", {}) + limitations = freeze.get("limitationsAndPolicy", {}) + lines = [ + "# Stable 1.0 RC Release Freeze", + "", + "This is a release-candidate freeze, not a Stable 1.0 general-availability declaration.", + "", + f"- Candidate release ID: `{candidate.get('releaseId', 'missing')}`", + f"- Integer build version: `{candidate.get('buildVersion', 'missing')}`", + f"- Source commit: `{candidate.get('sourceCommit', 'missing')}`", + f"- Production distribution digest: `{candidate.get('productionDistributionDigest', 'missing')}`", + f"- Freeze content digest: `{freeze.get('contentDigest', 'missing')}`", + f"- Freeze mode: `{drift.get('freezeMode', 'missing')}`", + f"- Drift status: `{drift.get('status', 'missing')}`", + f"- Platform API baseline: `{platform.get('baselineName', 'missing')}` at contract `{platform.get('baselineContractVersion', 'missing')}`", + f"- Platform API baseline digest: `{platform.get('baselineDigest', 'missing')}`", + f"- Stable catalog: `{catalog.get('catalogId', 'missing')}` edition `{catalog.get('edition', 'missing')}`", + f"- Stable catalog artifact timestamp: `{catalog.get('artifactTimestamp', 'missing')}`", + f"- Stable catalog digest: `{catalog.get('catalogDigest', 'missing')}`", + f"- First-party apps: `{len(freeze.get('firstPartyApps', []))}`", + f"- Content-format profiles: `{len(freeze.get('contentFormatProfiles', []))}`", + f"- Allowed Stable limitations: `{limitations.get('allowedLimitationCount', 'missing')}`", + f"- Accepted freeze exceptions: `{len(freeze.get('acceptedFreezeExceptions', []))}`", + "", + ] + return "\n".join(lines) + + +def render_go_no_go(summary: dict[str, Any]) -> str: + """Render the final Stable RC decision and exact blocker guidance.""" + + lines = [ + "# Stable 1.0 RC Go/No-Go", + "", + f"- Decision: `{summary.get('decision', 'no-go')}`", + f"- Status: `{summary.get('status', 'fail')}`", + f"- Promotion ready: `{str(summary.get('promotionReady') is True).lower()}`", + f"- Non-release: `{str(summary.get('nonRelease') is True).lower()}`", + f"- Stable ready: `{str(summary.get('stableReady') is True).lower()}`", + f"- Freeze status: `{summary.get('freeze', {}).get('status', 'fail')}`", + f"- Freeze drift: `{summary.get('freeze', {}).get('driftStatus', 'invalid-freeze')}`", + f"- Redaction: `{summary.get('redactionStatus', 'fail')}`", + "", + "## Blockers", + "", + ] + blockers = summary.get("blockers") if isinstance(summary.get("blockers"), list) else [] + if not blockers: + lines.append("None.") + for blocker in blockers: + if not isinstance(blocker, dict): + continue + lines.extend( + [ + f"- `{blocker.get('id', 'unknown')}`: {blocker.get('summary', '')}", + f" Remediation: {blocker.get('remediation', 'Regenerate the protected evidence.')}", + ] + ) + lines.extend(["", "## Allowed Stable limitations", ""]) + limitations = summary.get("allowedLimitations") + if not isinstance(limitations, list) or not limitations: + lines.append("None.") + else: + for limitation in limitations: + if isinstance(limitation, dict): + lines.append( + f"- `{limitation.get('id', 'unknown')}`: {limitation.get('summary', limitation.get('title', ''))}" + ) + lines.append("") + return "\n".join(lines) + + +def render_release_notes( + freeze: dict[str, Any], + previous_candidate: dict[str, Any], + accepted_waivers: list[dict[str, Any]], + accepted_exceptions: list[dict[str, Any]], + *, + public_known_issues: dict[str, Any] | None = None, + stable_readiness: dict[str, Any] | None = None, + operational_inputs: dict[str, dict[str, Any]] | None = None, + drift: dict[str, Any] | None = None, +) -> str: + """Populate the checked-in v1 release-note template with frozen, public-safe facts.""" + + template = _load_release_notes_template() + candidate = _object(freeze, "candidate") + platform = _object(freeze, "platformApi") + catalog = _object(freeze, "stableCatalog") + apps = _objects(freeze, "firstPartyApps") + profiles = _objects(freeze, "contentFormatProfiles") + limitations = _objects(_object(freeze, "limitationsAndPolicy"), "allowedLimitations") + frozen_exceptions = _objects(freeze, "acceptedFreezeExceptions") + if accepted_exceptions != frozen_exceptions: + raise ValueError("release-note exception history does not match the freeze") + known_issues = ( + _objects(public_known_issues, "knownIssues") + if public_known_issues is not None + else [] + ) + operations = operational_inputs or {} + stable = stable_readiness or {} + freeze_drift = drift or {"status": "no-drift", "regenerated": False} + previous_release = previous_candidate.get("releaseId") or _nested( + previous_candidate, "subject", "releaseId" + ) + if not previous_release: + raise ValueError("previous-candidate release identity is missing") + + blocks = { + "candidate_identity": _release_candidate_block(freeze, candidate), + "upgrade_and_recovery": _release_upgrade_block(previous_candidate, previous_release, catalog, apps), + "platform_api": _release_platform_block(platform), + "catalog_and_apps": _release_catalog_apps_block(catalog, apps), + "content_profiles": _release_profiles_block(profiles), + "operational_evidence": _release_operations_block(operations), + "allowed_limitations": _release_limitations_block(limitations), + "known_issues": _release_known_issues_block(known_issues), + "accepted_waivers": _release_waivers_block(accepted_waivers), + "accepted_exceptions": _release_exceptions_block(frozen_exceptions), + "support_and_security": _release_support_block(apps), + "final_review": _release_final_block(stable, freeze_drift), + } + rendered = template + for block_name in _RELEASE_NOTES_BLOCKS: + rendered = rendered.replace("{{" + block_name + "}}", blocks[block_name]) + if _RELEASE_NOTES_TOKEN_RE.search(rendered): + raise ValueError("release-note template contains an unresolved block token") + normalized = "\n".join(line.rstrip() for line in rendered.splitlines()).rstrip() + "\n" + if scan_value(normalized) or placeholder_findings(normalized): + raise ValueError("rendered release notes failed redaction validation") + return normalized + + +def _load_release_notes_template() -> str: + if _RELEASE_NOTES_TEMPLATE.is_symlink() or not _RELEASE_NOTES_TEMPLATE.is_file(): + raise ValueError("Stable RC release-note template is missing or unsafe") + template = _RELEASE_NOTES_TEMPLATE.read_text(encoding="utf-8") + if not template.startswith(_RELEASE_NOTES_TEMPLATE_MARKER + "\n"): + raise ValueError("Stable RC release-note template has the wrong version") + tokens = tuple(_RELEASE_NOTES_TOKEN_RE.findall(template)) + if tokens != _RELEASE_NOTES_BLOCKS: + raise ValueError("Stable RC release-note template blocks are incomplete or out of order") + if scan_value(template) or placeholder_findings(template): + raise ValueError("Stable RC release-note template failed redaction validation") + return template + + +def _object(value: dict[str, Any], key: str) -> dict[str, Any]: + child = value.get(key) + if not isinstance(child, dict): + raise ValueError(f"release-note context {key} is missing or malformed") + return child + + +def _objects(value: dict[str, Any], key: str) -> list[dict[str, Any]]: + children = value.get(key) + if not isinstance(children, list) or any(not isinstance(child, dict) for child in children): + raise ValueError(f"release-note context {key} is missing or malformed") + return children + + +def _nested(value: dict[str, Any], parent: str, child: str) -> Any: + nested = value.get(parent) + return nested.get(child) if isinstance(nested, dict) else None + + +def _markdown(value: Any) -> str: + if value is None or isinstance(value, (dict, list)): + raise ValueError("release-note scalar is missing or malformed") + text = str(value).strip() + if not text or any(ord(character) < 32 for character in text): + raise ValueError("release-note scalar is empty or contains control characters") + return ( + text.replace("\\", "\\\\") + .replace("|", "\\|") + .replace("`", "\\`") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + + +def _code(value: Any) -> str: + return f"`{_markdown(value)}`" + + +def _display(value: Any, fallback: str) -> str: + return _markdown(value if value not in (None, "") else fallback) + + +def _joined(values: Any, fallback: str = "none") -> str: + if not isinstance(values, list): + return _markdown(fallback) + return ", ".join(_markdown(value) for value in values) if values else _markdown(fallback) + + +def _release_candidate_block(freeze: dict[str, Any], candidate: dict[str, Any]) -> str: + build = candidate.get("buildVersion") + return "\n".join( + [ + "- Product milestone: Stable 1.0 RC", + "- Release status: release candidate; not general availability", + f"- Candidate release ID: {_code(candidate.get('releaseId'))}", + f"- Integer Cryptad build: {_code(build)}", + f"- Intended tag after separate release approval: {_code('v' + str(build))}", + f"- Source commit and ref: {_code(candidate.get('sourceCommit'))}; {_code(candidate.get('sourceRef'))}", + f"- Source provenance digest: {_code(candidate.get('sourceProvenanceDigest'))}", + f"- Production distribution digest: {_code(candidate.get('productionDistributionDigest'))}", + f"- Stable RC freeze digest: {_code(freeze.get('contentDigest'))}", + ] + ) + + +def _release_upgrade_block( + previous: dict[str, Any], + previous_release: Any, + catalog: dict[str, Any], + apps: list[dict[str, Any]], +) -> str: + rollback = _object(catalog, "verifiedRollback") + app_data = previous.get("appData") if isinstance(previous.get("appData"), dict) else {} + recovery = "; ".join( + f"{_markdown(app.get('appId'))}: schema {_display(app.get('appDataSchemaVersion'), 'not-applicable')}, " + f"migration {_markdown(app.get('migrationReadiness'))}, backup and restore {_markdown(app.get('backupRestore'))}" + for app in apps + ) + return "\n".join( + [ + f"- Supported upgrade source: {_code(previous_release)} build {_code(_display(previous.get('version'), 'contract-not-supplied'))}", + f"- Previous-candidate result: status {_code(_display(previous.get('status'), 'validated-pass'))}; promotion ready {_code(_display(previous.get('promotionReady'), 'validated'))}", + f"- Previous-candidate restore drill: {_code(_display(app_data.get('restoreDrillStatus'), 'validated-pass'))}", + f"- Catalog rollback: revision {_code(rollback.get('revision'))}, status {_code(rollback.get('status'))}, digest {_code(rollback.get('digest'))}", + "- App-data recovery: " + recovery, + "- Rollback does not itself restore app data; follow the frozen backup and restore contract before rollback.", + ] + ) + + +def _release_platform_block(platform: dict[str, Any]) -> str: + return "\n".join( + [ + f"- Stable baseline: {_code(platform.get('baselineName'))}; contract {_code(platform.get('baselineContractVersion'))}", + f"- Authoritative baseline digest: {_code(platform.get('baselineDigest'))}", + f"- Generated contract: version {_code(platform.get('currentContractVersion'))}; digest {_code(platform.get('currentContractDigest'))}", + f"- Compatibility-window policy digest: {_code(platform.get('compatibilityWindowPolicyDigest'))}", + f"- Stable surface: {_code(platform.get('stableCapabilityCount'))} capabilities; {_code(platform.get('stableEndpointCount'))} endpoints", + f"- Experimental surface (not part of the stable baseline): {_code(platform.get('experimentalCapabilityCount'))} capabilities; {_code(platform.get('experimentalEndpointCount'))} endpoints", + f"- Stable-breaking-change verification: {_code(platform.get('stableBreakingChangeVerification'))}; report {_code(platform.get('verificationReportDigest'))}", + ] + ) + + +def _health(value: Any) -> str: + if not isinstance(value, dict): + raise ValueError("release-note catalog health is malformed") + return ( + f"status {_code(value.get('status'))}, signature verified {_code(value.get('signatureVerified'))}, " + f"revision {_code(value.get('revision'))}, digest {_code(value.get('digest'))}" + ) + + +def _release_catalog_apps_block(catalog: dict[str, Any], apps: list[dict[str, Any]]) -> str: + mirrors = catalog.get("mirrorHealth") + if not isinstance(mirrors, list) or not mirrors: + raise ValueError("release-note mirror health is missing or malformed") + lines = [ + f"- Catalog: {_code(catalog.get('catalogId'))}, channel {_code(catalog.get('channel'))}, version {_code(catalog.get('catalogVersion'))}, edition {_code(catalog.get('edition'))}, revision {_code(catalog.get('revision'))}", + f"- Signed catalog digests: catalog {_code(catalog.get('catalogDigest'))}; signature {_code(catalog.get('signatureDigest'))}; alias {_code(catalog.get('signatureAliasDigest'))}", + f"- Catalog signing key ID: {_code(catalog.get('catalogSigningKeyId'))}", + f"- Frozen catalog and review artifact timestamp: {_code(catalog.get('artifactTimestamp'))}", + f"- Catalog key rotation: {_code(_object(catalog, 'keyRotationStatus').get('status'))}; compromised {_code(_object(catalog, 'keyRotationStatus').get('compromised'))}", + "- Primary health: " + _health(catalog.get("primaryHealth")), + "- Mirror health: " + "; ".join(_health(mirror) + ", transport fallback only `true`" for mirror in mirrors), + f"- Advisory and denylist counts: {_code(catalog.get('securityAdvisoryCount'))} and {_code(catalog.get('denylistCount'))}", + "", + "| App identity and support | Signed bundle and review | Frozen metadata digests | API compatibility | App-data and recovery |", + "| --- | --- | --- | --- | --- |", + ] + for app in apps: + limitation = _display(app.get("allowedStableLimitationId"), "none") + lines.append( + "| " + + " | ".join( + [ + f"{_code(app.get('appId'))} {_code(app.get('version'))}; channel {_code(app.get('channel'))}; {_code(app.get('supportStatus'))} and {_code(app.get('supportLevel'))}; deprecation {_code(app.get('deprecationStatus'))}; replacement {_code(_display(app.get('replacementAppId'), 'none'))}", + f"{_code(app.get('bundleDigest'))} ({_code(app.get('bundleSizeBytes'))} bytes); signing {_code(app.get('appSigningKeyId'))}; receipt {_code(app.get('reviewReceiptDigest'))}; reviewer {_code(app.get('reviewerKeyId'))}", + f"manifest {_code(app.get('manifestDigest'))}; permissions {_code(app.get('declaredPermissionSetDigest'))}; beta readiness {_code(app.get('betaReadinessEvidenceDigest'))}; support {_code(app.get('supportMetadataDigest'))}", + f"target {_code(app.get('targetApiStability'))}; {_code(app.get('apiCompatibilityResult'))}; evidence {_code(app.get('apiCompatibilityEvidenceDigest'))}", + f"schema {_code(_display(app.get('appDataSchemaVersion'), 'not-applicable'))}; migration {_code(app.get('migrationReadiness'))}; backup and restore {_code(app.get('backupRestore'))}; diagnostics {_code(app.get('redactedDiagnosticsReadiness'))}; limitation {_code(limitation)}", + ] + ) + + " |" + ) + return "\n".join(lines) + + +def _release_profiles_block(profiles: list[dict[str, Any]]) -> str: + lines = [ + "| Profile ID | Version/status | Rules digests | Maximum size | Signature payload | Compatibility evidence |", + "| --- | --- | --- | --- | --- | --- |", + ] + for profile in profiles: + sizes = _object(profile, "maximumSizePolicy") + signing = _object(profile, "signaturePayloadRules") + lines.append( + "| " + + " | ".join( + [ + _code(profile.get("profileId")), + f"{_code(profile.get('version'))} and {_code(profile.get('status'))}", + f"descriptor {_code(profile.get('descriptorDigest'))}; canonicalization {_code(profile.get('canonicalizationRulesDigest'))}", + f"document {_code(sizes.get('documentBytes'))}; signed payload {_code(_display(sizes.get('signedPayloadBytes'), 'not-applicable'))}", + f"signed {_code(signing.get('signed'))}; domain {_code(_display(signing.get('signingDomain'), 'not-applicable'))}", + _code(profile.get("parserValidatorCompatibilityEvidenceDigest")), + ] + ) + + " |" + ) + return "\n".join(lines) + + +def _operation(value: dict[str, Any] | None) -> str: + row = value or {} + status = row.get("status") or row.get("decision") or row.get("result") or "validated-pass" + timestamp = row.get("generatedAt") or row.get("timestamp") or "candidate-bound immutable evidence" + return f"status {_code(status)}; freshness {_code(timestamp)}" + + +def _release_operations_block(operations: dict[str, dict[str, Any]]) -> str: + app_platform = operations.get("appPlatform", {}) + evidence = app_platform.get("evidence") if isinstance(app_platform, dict) else [] + evidence_rows = { + row.get("id"): row + for row in evidence + if isinstance(row, dict) and isinstance(row.get("id"), str) + } if isinstance(evidence, list) else {} + return "\n".join( + [ + f"- Security drill: {_operation(operations.get('securityDrills'))}", + f"- Previous candidate: {_operation(operations.get('previousCandidate'))}", + f"- Live network: {_operation(operations.get('liveNetwork'))}", + f"- Multi-node: {_operation(operations.get('multiNodeSoak'))}", + f"- Network scale: {_operation(operations.get('networkScaleSoak'))}", + f"- Sandbox provider: {_operation(evidence_rows.get('apphost.sandbox-provider'))}", + f"- Third-party intake: {_operation(operations.get('thirdPartyIntake'))}", + f"- Support and feedback readiness: {_operation(evidence_rows.get('public-beta.support-feedback-loop'))}", + ] + ) + + +def _release_limitations_block(limitations: list[dict[str, Any]]) -> str: + if not limitations: + return "None." + lines = [ + "| Limitation ID | Public-safe summary | Scope and boundary | Owner | Evidence |", + "| --- | --- | --- | --- | --- |", + ] + for limitation in sorted(limitations, key=lambda row: str(row.get("id", ""))): + lines.append( + f"| {_code(limitation.get('id'))} | {_markdown(limitation.get('title'))}: {_markdown(limitation.get('summary'))} | {_markdown(limitation.get('category'))}; {_markdown(limitation.get('classification'))}; {_markdown(limitation.get('boundedBy'))} | {_code(limitation.get('owner'))} | {_joined(limitation.get('evidenceIds'))} |" + ) + return "\n".join(lines) + + +def _release_known_issues_block(issues: list[dict[str, Any]]) -> str: + if not issues: + return "None." + lines = [ + "| Issue ID | Status | Area/severity | Affected scope | Safe workaround and fixed-in state |", + "| --- | --- | --- | --- | --- |", + ] + for issue in sorted(issues, key=lambda row: str(row.get("knownIssueId", ""))): + affected = "; ".join( + [ + "channels " + _joined(issue.get("affectedChannels")), + "apps " + _joined(issue.get("affectedAppIds")), + "versions " + _joined(issue.get("affectedVersions")), + ] + ) + lines.append( + f"| {_code(issue.get('knownIssueId'))} | {_code(issue.get('status'))} | {_markdown(issue.get('area'))}/{_markdown(issue.get('severity'))} | {affected} | {_markdown(issue.get('workaroundSummary'))}; fixed in {_code(issue.get('fixedInReleaseId'))} |" + ) + return "\n".join(lines) + + +def _release_waivers_block(waivers: list[dict[str, Any]]) -> str: + if not waivers: + return "None." + lines = [ + "| Waiver ID | Scope/evidence | Reason | Owner/approver | Expiry | Applied blockers |", + "| --- | --- | --- | --- | --- | --- |", + ] + for waiver in sorted(waivers, key=lambda row: str(row.get("id", ""))): + lines.append( + f"| {_code(waiver.get('id'))} | {_markdown(waiver.get('scope'))}; {_code(waiver.get('evidenceId'))} | {_markdown(waiver.get('rationale'))} | {_markdown(waiver.get('owner'))}; {_markdown(waiver.get('approvedBy'))} | {_code(waiver.get('expiresAt'))} | {_joined(waiver.get('usedBy'))} |" + ) + return "\n".join(lines) + + +def _release_exceptions_block(exceptions: list[dict[str, Any]]) -> str: + if not exceptions: + return "None." + lines = [ + "| Exception ID | Frozen item | Blocker/security reference | Digest transition | Authorization | Rerun/result |", + "| --- | --- | --- | --- | --- | --- |", + ] + for record in sorted(exceptions, key=lambda row: str(row.get("exceptionId", ""))): + lines.append( + f"| {_code(record.get('exceptionId'))} | {_markdown(record.get('affectedSection'))}.{_markdown(record.get('affectedItem'))} | {_markdown(record.get('issueKind'))}: {_markdown(record.get('issueReference'))}; {_markdown(record.get('reason'))} | {_code(record.get('beforeDigest'))} → {_code(record.get('afterDigest'))} | {_markdown(record.get('owner'))}; {_markdown(record.get('approver'))}; {_code(record.get('createdAt'))}–{_code(record.get('expiresAt'))} | {_joined(record.get('requiredRerunScope'))}; {_code(record.get('finalVerificationResult'))} |" + ) + return "\n".join(lines) + + +def _release_support_block(apps: list[dict[str, Any]]) -> str: + links = sorted({_markdown(app.get("supportUri")) for app in apps}) + if not links: + raise ValueError("release-note support links are missing") + return "\n".join( + [ + "- Validated operator support links: " + ", ".join(links), + "- Security reporting: https://github.com/crypta-network/cryptad/security/policy", + "- Keep private reports, raw app data, content, insert material, identity data, and unredacted diagnostics outside this public draft.", + ] + ) + + +def _release_final_block(stable: dict[str, Any], drift: dict[str, Any]) -> str: + return "\n".join( + [ + f"- Stable readiness decision: {_code(_display(stable.get('decision'), 'validated-ready'))}; stable ready {_code(_display(stable.get('stableReady'), 'validated'))}", + f"- Freeze verification: {_code(drift.get('status'))}; regenerated after approved exception {_code(drift.get('regenerated') is True)}", + "- Redaction status: the final `redaction-report.json` must be `pass`.", + "- Stable RC decision: the final promotion summary is authoritative and must be `go` or `go-with-waivers` with `promotionReady=true` and `nonRelease=false`.", + "- This draft does not claim that a tag, GitHub Release, publication, or Stable 1.0 GA operation occurred.", + ] + ) + + +def build_redaction_report(values: Iterable[tuple[str, Any]]) -> dict[str, Any]: + """Scan every generated structured value and return path-free findings.""" + + findings: list[dict[str, str]] = [] + for label, value in values: + for finding in scan_value(value): + findings.append( + { + "artifact": label, + "category": str(finding.get("category", "redaction")), + "summary": str(finding.get("summary", "unsafe generated value")), + } + ) + return { + "schemaVersion": 1, + "status": "pass" if not findings else "fail", + "findingCount": len(findings), + "findings": findings, + "guarantees": { + "absoluteLocalPathsExcluded": not findings, + "privateInsertMaterialExcluded": not findings, + "rawAppDataExcluded": not findings, + "rawContentExcluded": not findings, + "rawSignaturesExcluded": not findings, + "secretValuesExcluded": not findings, + }, + } + + +def write_checksums(path: Path, members: Iterable[Path]) -> dict[str, str]: + """Write GNU-compatible relative checksums without absolute paths.""" + + root = path.parent.resolve() + rows: list[tuple[str, str]] = [] + seen: set[str] = set() + for member in members: + resolved = member.resolve() + relative = resolved.relative_to(root).as_posix() + if relative in seen or Path(relative).is_absolute() or ".." in Path(relative).parts: + raise ValueError(f"duplicate or unsafe checksum member: {relative}") + if member.is_symlink() or not member.is_file(): + raise ValueError(f"checksum member is missing or unsafe: {relative}") + seen.add(relative) + rows.append((relative, file_digest(resolved).removeprefix("sha256:"))) + rows.sort() + write_text(path, "\n".join(f"{digest} {relative}" for relative, digest in rows)) + return {relative: f"sha256:{digest}" for relative, digest in rows} + + +def write_named_checksums(path: Path, members: Iterable[tuple[str, Path]]) -> dict[str, str]: + """Write checksums for caller-defined safe archive-root-relative member names.""" + + rows: list[tuple[str, str]] = [] + seen: set[str] = set() + for name, member in members: + _validate_archive_name(name) + if name in seen: + raise ValueError(f"duplicate checksum member name: {name}") + resolved = _regular_source(member, name) + seen.add(name) + rows.append((name, file_digest(resolved).removeprefix("sha256:"))) + rows.sort() + write_text(path, "\n".join(f"{digest} {name}" for name, digest in rows)) + return {name: f"sha256:{digest}" for name, digest in rows} + + +def verify_checksums( + path: Path, + required_targets: dict[str, Path] | None = None, +) -> list[str]: + """Verify strict checksum rows and optionally bind required names to copied artifacts.""" + + errors: list[str] = [] + seen: set[str] = set() + parsed: dict[str, str] = {} + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + digest, separator, relative = line.partition(" ") + candidate = Path(relative) + if ( + not separator + or relative in seen + or candidate.is_absolute() + or ".." in candidate.parts + or re.fullmatch(r"[0-9a-f]{64}", digest.lower()) is None + ): + errors.append(f"checksums line {line_number} is malformed") + continue + seen.add(relative) + parsed[relative] = digest.lower() + target = path.parent / candidate + if target.is_symlink() or not target.is_file(): + errors.append(f"checksums target is missing or unsafe: {relative}") + continue + if file_digest(target).removeprefix("sha256:") != digest.lower(): + errors.append(f"checksum mismatch: {relative}") + for name, target in (required_targets or {}).items(): + expected_digest = parsed.get(name) + if expected_digest is None: + errors.append(f"checksums omits required target: {name}") + continue + if target.is_symlink() or not target.is_file(): + errors.append(f"required checksum target is missing or unsafe: {name}") + continue + if file_digest(target).removeprefix("sha256:") != expected_digest: + errors.append(f"checksum mismatch for copied target: {name}") + return errors + + +def create_deterministic_archive( + archive_path: Path, + members: Iterable[tuple[str, Path]], +) -> None: + """Create a normalized tar.gz with stable order, ownership, modes, and timestamps.""" + + materialized: list[tuple[str, Path]] = [] + for name, original_path in members: + _validate_archive_name(name) + materialized.append((name, _regular_source(original_path, name))) + materialized.sort() + temporary = archive_path.with_name(f".{archive_path.name}.tmp") + archive_path.parent.mkdir(parents=True, exist_ok=True) + try: + with temporary.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive: + for name, path in materialized: + data = path.read_bytes() + info = tarfile.TarInfo(name=f"stable-1.0-rc/{name}") + info.size = len(data) + info.mode = 0o644 + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + archive.addfile(info, io.BytesIO(data)) + os.replace(temporary, archive_path) + finally: + if temporary.exists(): + temporary.unlink() + + +def verify_deterministic_archive(archive_path: Path) -> list[str]: + """Reject unsafe members and non-normalized metadata in the Stable RC outer archive.""" + + errors: list[str] = [] + seen: set[str] = set() + try: + with tarfile.open(archive_path, "r:gz") as archive: + names = [member.name for member in archive.getmembers()] + if names != sorted(names): + errors.append("archive member order is not deterministic") + for member in archive.getmembers(): + try: + _validate_archive_name(member.name) + except ValueError as exc: + errors.append(str(exc)) + if member.name in seen: + errors.append(f"duplicate archive member: {member.name}") + seen.add(member.name) + if not member.isfile(): + errors.append(f"non-regular archive member: {member.name}") + if ( + member.mtime != 0 + or member.uid != 0 + or member.gid != 0 + or member.uname != "root" + or member.gname != "root" + or member.mode != 0o644 + ): + errors.append(f"non-normalized archive metadata: {member.name}") + errors.extend(_verify_archive_payload_checksums(archive)) + except (OSError, tarfile.TarError): + errors.append("Stable RC archive is invalid") + return errors + + +def _verify_archive_payload_checksums(archive: tarfile.TarFile) -> list[str]: + """Bind every non-checksum archive member to the embedded checksum manifest.""" + + root = "stable-1.0-rc/" + checksum_name = root + "payload-checksums.txt" + members = {member.name: member for member in archive.getmembers() if member.isfile()} + checksum_member = members.get(checksum_name) + if checksum_member is None: + return ["archive payload-checksums.txt is missing"] + extracted = archive.extractfile(checksum_member) + if extracted is None: + return ["archive payload-checksums.txt cannot be read"] + try: + lines = extracted.read().decode("utf-8").splitlines() + except UnicodeDecodeError: + return ["archive payload-checksums.txt is not UTF-8"] + errors: list[str] = [] + expected: set[str] = set() + for number, line in enumerate(lines, start=1): + digest, separator, relative = line.partition(" ") + target_name = root + relative + try: + _validate_archive_name(relative) + except ValueError: + separator = "" + if ( + not separator + or relative in expected + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + or target_name == checksum_name + ): + errors.append(f"archive payload checksum line {number} is malformed") + continue + expected.add(relative) + member = members.get(target_name) + if member is None: + errors.append(f"archive payload checksum target is missing: {relative}") + continue + stream = archive.extractfile(member) + if stream is None or hashlib.sha256(stream.read()).hexdigest() != digest: + errors.append(f"archive payload checksum mismatch: {relative}") + actual = {name.removeprefix(root) for name in members if name != checksum_name} + for missing in sorted(actual - expected): + errors.append(f"archive member is not checksummed: {missing}") + for extra in sorted(expected - actual): + errors.append(f"archive checksum names a missing member: {extra}") + return errors + + +def _regular_source(path: Path, label: str) -> Path: + """Resolve a present regular file only after rejecting symlinks in its source path.""" + + absolute = path.absolute() + for component in (absolute, *absolute.parents): + if component.is_symlink(): + raise ValueError(f"archive source is missing or unsafe: {label}") + if not absolute.is_file(): + raise ValueError(f"archive source is missing or unsafe: {label}") + return absolute.resolve(strict=True) + + +def _validate_archive_name(name: str) -> None: + path = Path(name) + forbidden = {".DS_Store", "__MACOSX"} + if path.is_absolute() or ".." in path.parts or not path.parts: + raise ValueError(f"unsafe archive member path: {name}") + for part in path.parts: + if part.startswith("._") or part in forbidden: + raise ValueError(f"forbidden archive member: {name}") + lower = part.lower() + if any( + marker in lower + for marker in ( + "private-key", + "private_key", + "insert-uri", + "insert_uri", + "token", + "cookie", + "password", + "credential", + "authorization", + "certificate", + ) + ) or Path(lower).suffix in { + ".key", + ".pem", + ".p8", + ".p12", + ".pfx", + ".jks", + ".keystore", + ".pkcs8", + }: + raise ValueError(f"secret-like archive member name: {name}") diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_core.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_core.py new file mode 100644 index 0000000000..cf4434528f --- /dev/null +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_core.py @@ -0,0 +1,940 @@ +"""Shared validation and canonical-digest helpers for Stable 1.0 RC execution.""" + +from __future__ import annotations + +import dataclasses +import datetime as dt +import hashlib +import json +import re +import subprocess +from pathlib import Path +from typing import Any, Iterable + +from cryptad_certification.io import read_json +from cryptad_certification.envelope import validate_envelope +from cryptad_certification.models import RunContext +from cryptad_certification.redaction import scan_value +from cryptad_certification.schema_validation import validate_schema + +SCHEMA_VERSION = 1 +TOOL_NAME = "stable-1.0-rc" +TOOL_VERSION = 1 +STABLE_MILESTONE = "1.0" +FREEZE_KIND = "stable-1.0-rc-freeze" +CATALOG_OPERATIONS_KIND = "stable-1.0-rc-catalog-operations" +CATALOG_OPERATIONS_SCHEMA = "stable-1.0-rc-catalog-operations-v1.schema.json" +FREEZE_EXCEPTIONS_KIND = "stable-1.0-rc-freeze-exceptions" +FREEZE_EXCEPTIONS_SCHEMA = "stable-1.0-rc-freeze-exceptions-v1.schema.json" + +SUMMARY_FILE = "stable-1.0-rc-promotion-summary.json" +REPORT_FILE = "stable-1.0-rc-go-no-go.md" +FREEZE_FILE = "stable-1.0-rc-freeze.json" +FREEZE_SIDECAR_FILE = "stable-1.0-rc-freeze.sha256" +FREEZE_REPORT_FILE = "stable-1.0-rc-freeze-report.md" +DRIFT_REPORT_FILE = "stable-1.0-rc-drift-report.json" +KNOWN_LIMITATIONS_FILE = "stable-1.0-rc-known-limitations.json" +RELEASE_NOTES_FILE = "stable-1.0-rc-release-notes.md" +REDACTION_REPORT_FILE = "redaction-report.json" +CHECKSUMS_FILE = "checksums.txt" +PROVENANCE_FILE = "provenance.json" +SUPPORTING_VERIFIER_FILES = ( + "content-format-profiles.json", + "feed-reader-api-compatibility.json", + "platform-api-current-contract.json", + "platform-api-stable-diff.json", + "profile-publisher-api-compatibility.json", + "publisher-api-compatibility.json", + "queue-manager-api-compatibility.json", + "site-publisher-api-compatibility.json", + "social-inbox-api-compatibility.json", + "trust-graph-api-compatibility.json", +) + +FINAL_DECISION_EVIDENCE_ID = "stable-1.0-rc.final-decision" + +EVIDENCE_IDS = ( + "stable-1.0-rc.prerequisites", + "stable-1.0-rc.candidate-binding", + "stable-1.0-rc.production-beta", + "stable-1.0-rc.stable-readiness", + "stable-1.0-rc.platform-api-freeze", + "stable-1.0-rc.catalog-freeze", + "stable-1.0-rc.first-party-app-freeze", + "stable-1.0-rc.content-format-freeze", + "stable-1.0-rc.limitations-freeze", + "stable-1.0-rc.freeze-verification", + "stable-1.0-rc.archive-hygiene", + "stable-1.0-rc.provenance", + "stable-1.0-rc.redaction", + "stable-1.0-rc.release-notes", + FINAL_DECISION_EVIDENCE_ID, +) + +REQUIRED_STABLE_DOMAIN_IDS = ( + "readiness-policy", + "production-beta-state", + "release-certification-summary", + "ecosystem-certification-matrix", + "platform-api-1.0", + "app-ecosystem-maturity", + "third-party-intake", + "security-drills", + "live-multi-node-soak", + "legacy-plugin-migration", + "support-feedback-readiness", + "known-limitations", + "redaction", +) + +REQUIRED_PIPELINE_STAGES = ( + "crypta-app-launcher-install", + "gradle-full-build", + "first-party-app-staging", + "first-party-app-signing", + "first-party-app-verification", +) + +FIRST_PARTY_APP_IDS = frozenset( + { + "queue-manager", + "publisher", + "site-publisher", + "profile-publisher", + "social-inbox", + "feed-reader", + "trust-graph", + } +) + +CONTENT_PROFILE_IDS = ( + "crypta.profile.v1", + "crypta.feed.snapshot.v1", + "crypta.trust.statement.v1", + "crypta.social.message.v1", + "crypta.social.outbox.v1", +) + +EXISTING_INPUT_COMPONENTS = { + "appPlatform": "app-platform", + "ecosystemMatrix": "release-certification", + "goNoGo": "go-no-go", + "liveNetwork": "live-network-beta", + "multiNodeSoak": "multi-node-beta/run", + "networkScaleSoak": "network-scale-soak", + "previousCandidate": "migration/previous-candidate", + "productionBeta": "production-beta", + "releaseCertification": "release-certification", + "releaseHistory": "migration/release-history", + "securityDrills": "security-response/drill-verify-all", + "stableReadiness": "stable-readiness", + "thirdPartyIntake": "production-beta", +} + +REQUIRED_EVIDENCE_INPUTS = tuple(EXISTING_INPUT_COMPONENTS) +HISTORICAL_RELEASE_INPUT_KEYS = frozenset({"previousCandidate", "releaseHistory"}) +EMBEDDED_PRODUCTION_INPUTS = { + "appPlatform": "evidence/app-platform-smoke.json", + "ecosystemMatrix": "evidence/ecosystem-certification-matrix.json", + "goNoGo": "reports/go-no-go-dashboard.json", + "releaseCertification": "evidence/ecosystem-rc-certification.json", + "stableReadiness": "reports/stable-1.0-readiness/stable-1.0-readiness-summary.json", +} +SAME_RUN_INPUT_KEYS = ("productionBeta", *EMBEDDED_PRODUCTION_INPUTS) +DIGEST_RE = re.compile(r"sha256:[0-9a-f]{64}\Z") +BUILD_VERSION_RE = re.compile(r"[1-9][0-9]*\Z") +COMMIT_RE = re.compile(r"[0-9a-f]{40,64}\Z") +PLACEHOLDER_RE = re.compile( + r"(?i)(?:example\.invalid|\bREPLACE_ME\b|\bREPLACE_WITH_[A-Z0-9_]+\b|" + r"PBKI-EXAMPLE-001|cryptad-beta-example|BACKLOG-PUBLIC-BETA-CATALOG-MIRROR-EXAMPLE)" +) + + +@dataclasses.dataclass(frozen=True) +class LoadedInput: + """One validated reusable input and the digest of its original envelope or file.""" + + key: str + path: Path + value: dict[str, Any] + digest: str + + +@dataclasses.dataclass(frozen=True) +class SourceIdentity: + """Candidate source identity without local filesystem information.""" + + commit: str + ref: str + digest: str + + +@dataclasses.dataclass +class ValidationState: + """Accumulate redaction-safe Stable RC blockers and warnings.""" + + blockers: list[dict[str, Any]] = dataclasses.field(default_factory=list) + warnings: list[dict[str, Any]] = dataclasses.field(default_factory=list) + + def block(self, issue_id: str, evidence_id: str, summary: str, remediation: str) -> None: + self.blockers.append( + { + "id": issue_id, + "evidenceId": evidence_id, + "severity": "blocker", + "summary": summary, + "remediation": remediation, + "waivable": False, + } + ) + + def warn(self, issue_id: str, evidence_id: str, summary: str) -> None: + self.warnings.append( + { + "id": issue_id, + "evidenceId": evidence_id, + "severity": "warning", + "summary": summary, + "waivable": False, + } + ) + + +def canonical_json(value: Any) -> str: + """Serialize a semantic value using the Stable RC canonical JSON rules.""" + + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def semantic_digest(value: Any) -> str: + """Return a normalized SHA-256 digest of canonical JSON.""" + + return "sha256:" + hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +def file_digest(path: Path) -> str: + """Return a normalized SHA-256 digest of exact file bytes.""" + + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return "sha256:" + digest.hexdigest() + + +def parse_timestamp(value: Any) -> dt.datetime | None: + """Parse one RFC 3339 timestamp into UTC, returning ``None`` when malformed.""" + + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(dt.timezone.utc) + + +def freshness_error(value: Any, now: dt.datetime, maximum_age_days: int, label: str) -> str | None: + """Return a redaction-safe freshness failure for an evidence timestamp.""" + + parsed = parse_timestamp(value) + if parsed is None: + return f"{label} generatedAt is missing or malformed" + if parsed > now: + return f"{label} generatedAt is in the future" + if now - parsed > dt.timedelta(days=maximum_age_days): + return f"{label} is older than {maximum_age_days} days" + return None + + +def placeholder_findings(value: Any, path: str = "$") -> list[str]: + """Return JSON paths containing production placeholder markers.""" + + findings: list[str] = [] + if isinstance(value, dict): + for key, child in value.items(): + findings.extend(placeholder_findings(child, f"{path}.{key}")) + elif isinstance(value, list): + for index, child in enumerate(value): + findings.extend(placeholder_findings(child, f"{path}[{index}]")) + elif isinstance(value, str) and PLACEHOLDER_RE.search(value): + findings.append(path) + return findings + + +def _configured_path(context: RunContext, key: str) -> Path | None: + raw = context.manifest.inputs.get(key) + if not isinstance(raw, str) or not raw: + return None + supplied = Path(raw) + candidate = supplied if supplied.is_absolute() else context.workspace_root / supplied + absolute = candidate.absolute() + current = Path(absolute.anchor) + for part in absolute.parts[1:]: + current /= part + if current.is_symlink(): + raise ValueError(f"Stable RC input path contains a symlink: {key}") + resolved = absolute.resolve() + if not supplied.is_absolute(): + try: + resolved.relative_to(context.workspace_root.resolve()) + except ValueError as exc: + raise ValueError(f"Stable RC input escapes the workspace: {key}") from exc + return resolved + + +def _original_input_path(context: RunContext, key: str) -> Path | None: + configured = _configured_path(context, key) + if configured is not None: + return configured + component = EXISTING_INPUT_COMPONENTS.get(key) + if component is None: + return None + path = context.run_root / component / "summary.json" + return path if path.is_file() else None + + +def load_existing_input(context: RunContext, key: str) -> LoadedInput: + """Load one established v2 input through the existing candidate-binding adapter.""" + + original = _original_input_path(context, key) + if original is None or original.is_symlink() or not original.is_file(): + raise ValueError(f"required Stable RC input is missing: {key}") + if _configured_path(context, key) is None: + if key != "productionBeta": + raise ValueError( + f"Stable RC requires inputs.{key} to name candidate-bound reusable evidence" + ) + envelope = read_json(original) + validate_envelope(envelope, "production-beta-release", context.manifest.release.release_id) + subject = envelope["subject"] + if ( + subject.get("profile") != "stable-review" + or subject.get("component") != "production-beta" + or subject.get("version") != context.manifest.release.version + or envelope["result"].get("status") != "pass" + or envelope["result"].get("exitCode") != 0 + or envelope["redaction"].get("status") != "pass" + ): + raise ValueError("same-run production-beta envelope is not candidate-bound and passing") + payload = envelope.get("payload") + legacy_value = payload.get("legacy") if isinstance(payload, dict) else None + if not isinstance(legacy_value, dict) or scan_value(legacy_value): + raise ValueError("same-run production-beta envelope payload is missing or unsafe") + return LoadedInput(key, original, legacy_value, file_digest(original)) + # Import lazily because the legacy adapter imports this engine to execute the Stable RC. + from cryptad_certification import legacy + + migrated_kind = { + "previousCandidate": "previous-candidate", + "releaseHistory": "release-history", + }.get(key) + extracted = legacy._legacy_input_path( # noqa: SLF001 - shared adapter contract + context, + key, + migrated_kind=migrated_kind, + ) + if extracted is None or extracted.is_symlink() or not extracted.is_file(): + raise ValueError(f"Stable RC input could not be unwrapped: {key}") + value = read_json(extracted) + if not isinstance(value, dict): + raise ValueError(f"Stable RC input payload must be an object: {key}") + if key == "stableReadiness": + envelope = read_json(original) + if not isinstance(envelope, dict) or envelope.get("subject", {}).get("profile") != "stable-review": + raise ValueError("inputs.stableReadiness must be produced with profile stable-review") + return LoadedInput(key, original, value, file_digest(original)) + + +def load_raw_input(context: RunContext, key: str, required: bool = True) -> LoadedInput | None: + """Load one strict raw JSON input that intentionally has no legacy v2 mapping.""" + + path = _configured_path(context, key) + if path is None: + if required: + raise ValueError(f"required Stable RC input is missing: {key}") + return None + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Stable RC input is missing or unsafe: {key}") + value = read_json(path) + if not isinstance(value, dict): + raise ValueError(f"Stable RC input must be a JSON object: {key}") + findings = scan_value(value) + if findings: + raise ValueError(f"Stable RC input failed redaction validation: {key}") + return LoadedInput(key, path, value, file_digest(path)) + + +def _embedded_production_path(native_root: Path, relative: str, key: str) -> Path: + """Resolve one same-run production artifact without following symlinked components.""" + + supplied = Path(relative) + if supplied.is_absolute() or ".." in supplied.parts: + raise ValueError(f"production-beta embedded Stable RC input path is unsafe: {key}") + if native_root.is_symlink() or not native_root.is_dir(): + raise ValueError("production-beta native artifact directory is missing or unsafe") + current = native_root + for part in supplied.parts: + current /= part + if current.is_symlink(): + raise ValueError( + f"production-beta embedded Stable RC input path contains a symlink: {key}" + ) + resolved = current.resolve() + try: + resolved.relative_to(native_root.resolve()) + except ValueError as exc: + raise ValueError( + f"production-beta embedded Stable RC input escapes its artifact root: {key}" + ) from exc + if not resolved.is_file(): + raise ValueError(f"production-beta output omits embedded Stable RC input: {key}") + return resolved + + +def load_candidate_inputs(context: RunContext, native_root: Path) -> dict[str, LoadedInput]: + """Load protected external evidence plus summaries embedded by the production pipeline.""" + + configured_same_run = [ + key for key in SAME_RUN_INPUT_KEYS if key in context.manifest.inputs + ] + if configured_same_run: + names = ", ".join(f"inputs.{key}" for key in configured_same_run) + raise ValueError(f"Stable RC same-run inputs are not externally configurable: {names}") + inputs: dict[str, LoadedInput] = {"productionBeta": load_existing_input(context, "productionBeta")} + for key, relative in EMBEDDED_PRODUCTION_INPUTS.items(): + path = _embedded_production_path(native_root, relative, key) + value = read_json(path) + if not isinstance(value, dict) or scan_value(value): + raise ValueError(f"production-beta embedded Stable RC input is malformed or unsafe: {key}") + inputs[key] = LoadedInput(key, path, value, file_digest(path)) + for key in ( + "liveNetwork", + "multiNodeSoak", + "networkScaleSoak", + "previousCandidate", + "releaseHistory", + "securityDrills", + ): + inputs[key] = load_existing_input(context, key) + third_party = load_raw_input(context, "thirdPartyIntake") + if third_party is None: + raise ValueError("required Stable RC input is missing: thirdPartyIntake") + inputs["thirdPartyIntake"] = third_party + return inputs + + +def _git(context: RunContext, *arguments: str) -> str: + result = subprocess.run( + ["git", *arguments], + cwd=context.workspace_root, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise ValueError("candidate git metadata is unavailable") + return result.stdout.strip() + + +def source_identity(context: RunContext, release_certification: dict[str, Any]) -> SourceIdentity: + """Bind the current clean workspace to release-certification source metadata.""" + + if context.manifest.execution.get("skipGitMetadata") is True: + raise ValueError("Stable RC forbids execution.skipGitMetadata") + if context.manifest.execution.get("allowDirtyWorkspace") is True: + raise ValueError("Stable RC forbids execution.allowDirtyWorkspace") + status = _git(context, "status", "--porcelain=v1", "--untracked-files=all") + if status: + raise ValueError("Stable RC requires a clean git workspace") + commit = _git(context, "rev-parse", "HEAD").lower() + if COMMIT_RE.fullmatch(commit) is None: + raise ValueError("candidate git commit is malformed") + # Branch names differ between a protected CI checkout and a release-manager checkout. Bind the + # public source reference to the immutable commit so the same candidate freezes identically. + ref = f"commit:{commit}" + metadata = ( + release_certification.get("metadata") + if isinstance(release_certification.get("metadata"), dict) + else {} + ) + recorded = str( + metadata.get("gitCommit") + or metadata.get("githubSha") + or release_certification.get("gitCommit") + or "" + ).lower() + if recorded != commit: + raise ValueError("release-certification source commit does not match candidate HEAD") + return SourceIdentity(commit, ref, semantic_digest({"commit": commit, "ref": ref})) + + +def _status_pass(value: Any) -> bool: + return str(value or "").lower() in {"pass", "passed", "success"} + + +def release_certification_is_promotable(value: dict[str, Any]) -> bool: + """Preserve the established RC pass/warn result contract.""" + + status = str(value.get("status") or "").lower() + return value.get("releaseCandidatePassed") is True and ( + _status_pass(status) or status == "warn" + ) + + +def ecosystem_matrix_is_promotable(value: dict[str, Any]) -> bool: + """Return whether the established matrix permits release promotion.""" + + status = str(value.get("status") or "").lower() + blocker_count = value.get("releaseBlockerCount") + return ( + (_status_pass(status) or status == "warn") + and type(blocker_count) is int + and blocker_count == 0 + ) + + +def _top_level_release_proof_errors(value: dict[str, Any], label: str) -> list[str]: + errors: list[str] = [] + if not _status_pass(value.get("status")): + errors.append(f"{label} status is not pass") + for field in ("fixtureOnly", "simulatedOnly", "nonRelease", "nonProduction"): + if field in value and value.get(field) is not False: + errors.append(f"{label} {field} is not false") + return errors + + +def explicit_production_classification_errors( + value: dict[str, Any], + label: str, +) -> list[str]: + """Require raw release proof to state every non-production classification as false.""" + + return [ + f"{label} {field} must be present and false" + for field in ("fixtureOnly", "simulatedOnly", "nonRelease", "nonProduction") + if value.get(field) is not False + ] + + +def validate_prerequisites( + context: RunContext, + inputs: dict[str, LoadedInput], + catalog_operations: LoadedInput, + now: dt.datetime, + state: ValidationState, +) -> None: + """Validate release-critical producer results without re-evaluating Stable policy domains.""" + + release_id = context.manifest.release.release_id + build_version = context.manifest.release.version + if context.manifest.release.profile != "stable-review": + state.block( + "stable-1.0-rc.profile", + "stable-1.0-rc.prerequisites", + "Stable RC execution requires release.profile stable-review.", + "Run the protected stable-review pipeline.", + ) + if not isinstance(build_version, str) or BUILD_VERSION_RE.fullmatch(build_version) is None: + state.block( + "stable-1.0-rc.build-version", + "stable-1.0-rc.candidate-binding", + "Stable RC buildVersion must be a positive integer string.", + "Use the repository integer build version, not a semantic product version.", + ) + forbidden_execution = ( + "allowTestSigningInProduction", + "emergencySkipBuild", + "emergencySkipLiveNetwork", + "fixtureEvidence", + "skipFullBuild", + "skipGitMetadata", + "skipGradle", + ) + for field in forbidden_execution: + if context.manifest.execution.get(field) is True: + state.block( + f"stable-1.0-rc.execution.{field}", + "stable-1.0-rc.prerequisites", + f"Stable RC forbids execution.{field}=true.", + "Rerun the complete protected pipeline without release-stage skips or fixtures.", + ) + + production = inputs["productionBeta"].value + validate_production_beta(production, release_id, str(build_version or ""), state) + + go_no_go = inputs["goNoGo"].value + decision = go_no_go.get("decision") + if ( + decision not in {"go", "go-with-waivers"} + or go_no_go.get("promotionReady") is not True + ): + state.block( + "stable-1.0-rc.production-go-no-go", + "stable-1.0-rc.prerequisites", + "Production go/no-go evidence is not promotable.", + "Resolve dashboard blockers and regenerate the candidate-bound decision.", + ) + + certification = inputs["releaseCertification"].value + if not release_certification_is_promotable(certification): + state.block( + "stable-1.0-rc.release-certification", + "stable-1.0-rc.prerequisites", + "Release certification is not passing.", + "Regenerate the complete release-certification result.", + ) + matrix = inputs["ecosystemMatrix"].value + if not ecosystem_matrix_is_promotable(matrix): + state.block( + "stable-1.0-rc.ecosystem-matrix", + "stable-1.0-rc.prerequisites", + "Ecosystem matrix is missing, malformed, or has release blockers.", + "Resolve all ecosystem release blockers and regenerate the matrix.", + ) + + validate_stable_readiness(inputs["stableReadiness"].value, release_id, now, inputs, state) + validate_live_inputs( + inputs, + release_id, + str(build_version or ""), + now, + state, + ) + validate_catalog_operations( + catalog_operations.value, + release_id, + str(build_version or ""), + now, + state, + ) + + for loaded in [*inputs.values(), catalog_operations]: + findings = scan_value(loaded.value) + if findings: + state.block( + f"stable-1.0-rc.redaction.{loaded.key}", + "stable-1.0-rc.redaction", + f"Input {loaded.key} contains redaction findings.", + "Replace the input with a redaction-safe candidate-bound summary.", + ) + placeholders = placeholder_findings(loaded.value) + if placeholders: + state.block( + f"stable-1.0-rc.placeholder.{loaded.key}", + "stable-1.0-rc.redaction", + f"Input {loaded.key} contains production placeholder metadata.", + "Replace example and REPLACE_ME metadata before Stable RC execution.", + ) + + +def validate_production_beta( + production: dict[str, Any], + release_id: str, + build_version: str, + state: ValidationState, +) -> None: + """Validate the protected production summary consumed by Stable RC execution.""" + + for error in _top_level_release_proof_errors(production, "production-beta summary"): + state.block( + "stable-1.0-rc.production-beta-state", + "stable-1.0-rc.production-beta", + error + ".", + "Regenerate passing production-beta evidence for this candidate.", + ) + if production.get("promotionReady") is not True or production.get("nonRelease") is not False: + state.block( + "stable-1.0-rc.production-beta-promotion", + "stable-1.0-rc.production-beta", + "Production-beta evidence is not promotionReady=true and nonRelease=false.", + "Complete every production-beta gate with production signing.", + ) + if production.get("releaseId") != release_id or str(production.get("version")) != build_version: + state.block( + "stable-1.0-rc.production-beta-binding", + "stable-1.0-rc.candidate-binding", + "Production-beta identity does not match the Stable RC candidate.", + "Attach evidence produced for the exact release ID and integer build.", + ) + signing = production.get("signingProfile") if isinstance(production.get("signingProfile"), dict) else {} + if ( + signing.get("kind") != "production" + or signing.get("generatedTestKeys") is not False + or signing.get("privateKeyMaterialIncluded") is not False + ): + state.block( + "stable-1.0-rc.production-signing", + "stable-1.0-rc.production-beta", + "Production-beta signing profile is not protected production signing.", + "Rerun with configured production app and reviewer signing identities.", + ) + if production.get("workspaceStatusKnown") is not True or production.get("dirtyWorkspace") is not False: + state.block( + "stable-1.0-rc.production-workspace", + "stable-1.0-rc.candidate-binding", + "Production-beta workspace was dirty or its status was unknown.", + "Rebuild from a clean candidate-bound workspace.", + ) + stages = production.get("pipelineStages") if isinstance(production.get("pipelineStages"), dict) else {} + for stage in REQUIRED_PIPELINE_STAGES: + row = stages.get(stage) if isinstance(stages.get(stage), dict) else {} + if row.get("status") != "pass": + state.block( + f"stable-1.0-rc.pipeline-stage.{stage}", + "stable-1.0-rc.production-beta", + f"Required production pipeline stage {stage} is not passing.", + "Rerun the full build, stage, sign, and verify pipeline.", + ) + + +def validate_stable_readiness( + summary: dict[str, Any], + release_id: str, + now: dt.datetime, + inputs: dict[str, LoadedInput], + state: ValidationState, +) -> None: + """Reuse PR-282 Stable-readiness validation and add RC freshness/profile requirements.""" + + from cryptad_certification.engines import production_beta_go_no_go_dashboard as dashboard + + for issue in dashboard.stable_readiness_issues(summary, True, release_id): + if issue.severity in {"blocker", "critical"}: + state.block( + issue.id, + "stable-1.0-rc.stable-readiness", + issue.summary, + "Regenerate a passing, complete Stable 1.0 readiness result for this candidate.", + ) + elif issue.id != "stable-1.0.readiness-summary.allowed-limitations": + state.warn(issue.id, "stable-1.0-rc.stable-readiness", issue.summary) + domains = summary.get("domains") + domain_ids = [row.get("id") for row in domains if isinstance(row, dict)] if isinstance(domains, list) else [] + if tuple(domain_ids) != REQUIRED_STABLE_DOMAIN_IDS or len(set(domain_ids)) != len(domain_ids): + state.block( + "stable-1.0-rc.stable-readiness-domains", + "stable-1.0-rc.stable-readiness", + "Stable readiness domains are missing, duplicated, truncated, or reordered.", + "Regenerate Stable readiness with the PR-282 producer.", + ) + maximum_age_days = 30 + # The policy is frozen separately; the established current default is used only if unavailable. + policy_path = inputs.get("stableReadinessPolicy") + if policy_path is not None: + required_soak = policy_path.value.get("requiredSoak") + if isinstance(required_soak, dict) and type(required_soak.get("maximumEvidenceAgeDays")) is int: + maximum_age_days = required_soak["maximumEvidenceAgeDays"] + error = freshness_error(summary.get("generatedAt"), now, maximum_age_days, "Stable readiness") + if error: + state.block( + "stable-1.0-rc.stable-readiness-freshness", + "stable-1.0-rc.stable-readiness", + error + ".", + "Regenerate Stable readiness from fresh candidate evidence.", + ) + + +def validate_live_inputs( + inputs: dict[str, LoadedInput], + release_id: str, + build_version: str, + now: dt.datetime, + state: ValidationState, +) -> None: + """Require real, fresh live/multi-node/network/security/intake evidence.""" + + specifications = ( + ("liveNetwork", {"live", "production", "release-candidate"}, 30), + ("multiNodeSoak", {"live", "hybrid"}, 30), + ("networkScaleSoak", {"live-rc-soak"}, 30), + ("securityDrills", set(), 30), + ("thirdPartyIntake", set(), 30), + ("previousCandidate", set(), None), + ("releaseHistory", set(), None), + ("appPlatform", set(), 30), + ) + for key, accepted_modes, maximum_age_days in specifications: + value = inputs[key].value + errors = ( + [] + if key == "thirdPartyIntake" + else _top_level_release_proof_errors(value, key) + ) + if key == "thirdPartyIntake": + if not _status_pass(value.get("status")): + errors.append(f"{key} status is not pass") + errors.extend(explicit_production_classification_errors(value, key)) + if value.get("releaseId") != release_id: + errors.append(f"{key} releaseId is missing or does not match the candidate") + if value.get("buildVersion") != build_version: + errors.append(f"{key} buildVersion is missing or does not match the candidate") + elif ( + key not in HISTORICAL_RELEASE_INPUT_KEYS + and value.get("releaseId") not in {None, release_id} + ): + errors.append(f"{key} releaseId does not match the candidate") + if accepted_modes: + mode = str(value.get("mode") or value.get("evidenceMode") or "") + if mode not in accepted_modes: + errors.append(f"{key} mode is not protected live evidence") + if maximum_age_days is not None: + evidence_time = ( + value.get("generatedAt") + or value.get("finishedAt") + or value.get("verifiedAt") + or value.get("createdAt") + ) + freshness = freshness_error(evidence_time, now, maximum_age_days, key) + if freshness: + errors.append(freshness) + for error in errors: + state.block( + f"stable-1.0-rc.evidence.{key}", + "stable-1.0-rc.prerequisites", + error + ".", + "Attach fresh, real, candidate-bound protected evidence.", + ) + + sandbox = evidence_by_id(inputs["appPlatform"].value).get("apphost.sandbox-provider") + if not isinstance(sandbox, dict) or not _status_pass(sandbox.get("status")): + state.block( + "stable-1.0-rc.evidence.sandbox-provider", + "stable-1.0-rc.prerequisites", + "AppHost sandbox-provider evidence is missing or failing.", + "Rerun the protected sandbox-provider tests for the candidate.", + ) + + +def validate_catalog_operations( + value: dict[str, Any], + release_id: str, + build_version: str, + now: dt.datetime, + state: ValidationState, +) -> None: + """Validate the protected current-candidate stable catalog operations contract.""" + + errors = validate_schema(value, CATALOG_OPERATIONS_SCHEMA) + expected_scalars = { + "schemaVersion": 1, + "kind": CATALOG_OPERATIONS_KIND, + "releaseId": release_id, + "buildVersion": build_version, + "status": "pass", + "fixtureOnly": False, + "simulatedOnly": False, + "nonRelease": False, + "channel": "stable", + } + errors.extend( + f"{key} must be {expected!r}" + for key, expected in expected_scalars.items() + if value.get(key) != expected + ) + for key in ("catalogId", "signingKeyId"): + if not isinstance(value.get(key), str) or not value[key].strip(): + errors.append(f"{key} must be a non-empty string") + if COMMIT_RE.fullmatch(str(value.get("sourceCommit", "")).lower()) is None: + errors.append("sourceCommit is malformed") + generated_at = parse_timestamp(value.get("generatedAt")) + artifact_timestamp = parse_timestamp(value.get("artifactTimestamp")) + if artifact_timestamp is None: + errors.append("artifactTimestamp is malformed") + elif generated_at is not None and artifact_timestamp > generated_at: + errors.append("artifactTimestamp cannot be later than generatedAt") + if type(value.get("revision")) is not int or value["revision"] < 0: + errors.append("revision must be a non-negative integer") + for key in ("catalogDigest", "signatureDigest"): + if DIGEST_RE.fullmatch(str(value.get(key, ""))) is None: + errors.append(f"{key} is malformed") + for count in ("advisoryCount", "denylistCount"): + if type(value.get(count)) is not int or value[count] < 0: + errors.append(f"{count} must be a non-negative integer") + rotation = value.get("keyRotation") if isinstance(value.get("keyRotation"), dict) else {} + if rotation != {"status": "complete", "compromised": False}: + errors.append("keyRotation must be complete and uncompromised") + primary = value.get("primary") + errors.extend(_catalog_health_errors(primary, "primary")) + if isinstance(primary, dict): + if primary.get("revision") != value.get("revision"): + errors.append("primary revision does not bind the current catalog revision") + if primary.get("digest") != value.get("catalogDigest"): + errors.append("primary digest does not bind the current catalog artifact") + mirrors = value.get("mirrors") + if not isinstance(mirrors, list) or not mirrors: + errors.append("mirrors must contain at least one verified mirror") + else: + for index, mirror in enumerate(mirrors): + errors.extend(_catalog_health_errors(mirror, f"mirrors[{index}]")) + if not isinstance(mirror, dict) or mirror.get("transportFallbackOnly") is not True: + errors.append(f"mirrors[{index}] must be transport fallback only") + rollback = value.get("rollback") if isinstance(value.get("rollback"), dict) else {} + if rollback.get("status") != "pass" or rollback.get("signatureVerified") is not True: + errors.append("rollback must be passing and signature verified") + if type(rollback.get("revision")) is not int or rollback.get("revision", -1) < 0: + errors.append("rollback revision must be a non-negative integer") + if DIGEST_RE.fullmatch(str(rollback.get("digest", ""))) is None: + errors.append("rollback digest is malformed") + current_revision = value.get("revision") + rollback_revision = rollback.get("revision") + if ( + type(current_revision) is int + and type(rollback_revision) is int + and rollback_revision >= current_revision + ): + errors.append("rollback revision must precede the current catalog revision") + if rollback.get("digest") == value.get("catalogDigest"): + errors.append("rollback digest must bind a distinct prior catalog artifact") + redaction = value.get("redaction") if isinstance(value.get("redaction"), dict) else {} + if redaction.get("status") != "pass" or redaction.get("findingCount") != 0 or redaction.get("findings") != []: + errors.append("redaction must pass with zero findings") + freshness = freshness_error(value.get("generatedAt"), now, 30, "catalog operations") + if freshness: + errors.append(freshness) + for error in errors: + state.block( + "stable-1.0-rc.catalog-operations", + "stable-1.0-rc.catalog-freeze", + error + ".", + "Regenerate protected stable catalog-operations evidence for this candidate.", + ) + + +def _catalog_health_errors(value: Any, label: str) -> list[str]: + if not isinstance(value, dict): + return [f"{label} health is missing"] + errors: list[str] = [] + if value.get("status") != "pass" or value.get("signatureVerified") is not True: + errors.append(f"{label} must pass the catalog signature check") + if type(value.get("revision")) is not int or value.get("revision", -1) < 0: + errors.append(f"{label} revision is malformed") + if DIGEST_RE.fullmatch(str(value.get("digest", ""))) is None: + errors.append(f"{label} digest is malformed") + return errors + + +def evidence_by_id(summary: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Index redaction-safe evidence rows by identifier.""" + + result: dict[str, dict[str, Any]] = {} + entries = summary.get("evidence") + if not isinstance(entries, list): + return result + for entry in entries: + if not isinstance(entry, dict): + continue + evidence_id = entry.get("id") or entry.get("evidenceId") + if isinstance(evidence_id, str) and evidence_id and evidence_id not in result: + result[evidence_id] = entry + return result + + +def all_unique(values: Iterable[str]) -> bool: + """Return whether all strings in an iterable are unique.""" + + materialized = list(values) + return len(materialized) == len(set(materialized)) diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_freeze.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_freeze.py new file mode 100644 index 0000000000..f65e139f2d --- /dev/null +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_freeze.py @@ -0,0 +1,1552 @@ +"""Deterministic Stable 1.0 RC freeze construction and drift verification.""" + +from __future__ import annotations + +import copy +import os +import platform +import re +import subprocess +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +from cryptad_certification.io import read_json, write_json +from cryptad_certification.models import RunContext +from cryptad_certification.schema_validation import validate_schema + +from .stable_1_0_rc_core import ( + CONTENT_PROFILE_IDS, + DIGEST_RE, + FIRST_PARTY_APP_IDS, + FREEZE_EXCEPTIONS_KIND, + FREEZE_EXCEPTIONS_SCHEMA, + FREEZE_KIND, + SAME_RUN_INPUT_KEYS, + SCHEMA_VERSION, + STABLE_MILESTONE, + TOOL_NAME, + TOOL_VERSION, + LoadedInput, + SourceIdentity, + ValidationState, + all_unique, + canonical_json, + evidence_by_id, + file_digest, + parse_timestamp, + placeholder_findings, + semantic_digest, +) + +FREEZE_SECTIONS = ( + "candidate", + "platformApi", + "stableCatalog", + "firstPartyApps", + "contentFormatProfiles", + "limitationsAndPolicy", + "acceptedFreezeExceptions", +) +EXCEPTION_TARGET_SECTIONS = FREEZE_SECTIONS[:-1] +PRODUCER_IDENTITY_KIND = "stable-1.0-rc-producer-identity" +VOLATILE_PRODUCER_FIELDS = frozenset( + { + "durationMs", + "duration_ms", + "elapsedMs", + "elapsed_ms", + "generatedAt", + "gitBranch", + "githubActions", + "githubRef", + "githubRunAttempt", + "githubRunId", + "githubSha", + "githubWorkflow", + "runnerOs", + "startedAt", + "stderr_tail", + "stdout_tail", + } +) +STABLE_READINESS_SAME_RUN_REFERENCES = { + "appPlatformSummary": "appPlatform", + "ecosystemMatrix": "ecosystemMatrix", + "goNoGoSummary": "goNoGo", + "productionBetaSummary": "productionBeta", + "releaseCertificationSummary": "releaseCertification", +} +TEMPORARY_WORKDIR_PATH_RE = re.compile( + r"[/\\](cryptad-[A-Za-z0-9_.-]*?)-[a-z0-9_]{8}(?=[/\\]|$)" +) +NON_WAIVABLE_EXCEPTION_TARGETS = { + "platformApi": frozenset( + { + "baselineName", + "baselineContractVersion", + "baselineDigest", + "stableCapabilityCount", + "stableEndpointCount", + "stableBreakingChangeVerification", + } + ), + "stableCatalog": frozenset({"channel"}), +} + + +def producer_identity_digest( + loaded: LoadedInput, + referenced_producers: dict[str, str] | None = None, +) -> str: + """Digest one same-run producer after removing execution-only volatility. + + Exact file digests remain authoritative for externally supplied release evidence. Same-run + producers are regenerated by every Stable RC invocation, so timestamps, command output tails, + and timings cannot be part of their frozen identity. Stable-readiness input hashes for other + same-run producers are replaced with those producers' semantic identities rather than dropped. + """ + + if loaded.key not in SAME_RUN_INPUT_KEYS: + return loaded.digest + normalized = _normalize_producer_value( + loaded.value, + loaded.key, + referenced_producers or {}, + ) + return semantic_digest( + { + "schemaVersion": 1, + "kind": PRODUCER_IDENTITY_KIND, + "producer": loaded.key, + "summary": normalized, + } + ) + + +def _normalize_producer_value( + value: Any, + producer: str, + referenced_producers: dict[str, str], + path: tuple[str, ...] = (), +) -> Any: + if isinstance(value, dict): + normalized: dict[str, Any] = {} + for key, child in value.items(): + if key in VOLATILE_PRODUCER_FIELDS: + continue + reference = ( + STABLE_READINESS_SAME_RUN_REFERENCES.get(path[-1]) + if producer == "stableReadiness" + and key == "sha256" + and len(path) == 2 + and path[0] == "inputs" + else None + ) + if reference is not None and reference in referenced_producers: + normalized[key] = referenced_producers[reference] + continue + normalized[key] = _normalize_producer_value( + child, + producer, + referenced_producers, + (*path, key), + ) + return normalized + if isinstance(value, list): + return [ + _normalize_producer_value(child, producer, referenced_producers, path) + for child in value + ] + if isinstance(value, str): + return TEMPORARY_WORKDIR_PATH_RE.sub( + lambda match: f"/{match.group(1)}-", + value, + ) + return value + + +def producer_identity_digests(inputs: dict[str, LoadedInput]) -> dict[str, str]: + """Build deterministic identities for the complete same-run producer graph.""" + + missing = [key for key in SAME_RUN_INPUT_KEYS if key not in inputs] + if missing: + raise ValueError("same-run producer inputs are missing: " + ", ".join(missing)) + identities = { + key: producer_identity_digest(inputs[key]) + for key in SAME_RUN_INPUT_KEYS + if key != "stableReadiness" + } + identities["stableReadiness"] = producer_identity_digest( + inputs["stableReadiness"], + identities, + ) + return identities + + +def parse_properties(path: Path) -> dict[str, str]: + """Parse the strict single-line property artifacts emitted by Cryptad tooling.""" + + if path.is_symlink() or not path.is_file(): + raise ValueError(f"required properties artifact is missing or unsafe: {path.name}") + result: dict[str, str] = {} + for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + key, separator, value = line.partition("=") + if not separator or not key.strip(): + raise ValueError(f"malformed properties line {line_number} in {path.name}") + key = key.strip() + if key in result: + raise ValueError(f"duplicate property {key} in {path.name}") + result[key] = value.strip() + return result + + +def production_native_root(production_envelope: Path) -> Path: + """Resolve the established production-beta native artifact directory beside its envelope.""" + + root = production_envelope.parent / "artifacts" / "legacy" + if root.is_symlink() or not root.is_dir(): + raise ValueError("production-beta native artifact directory is missing or unsafe") + return root.resolve() + + +def safe_artifact(root: Path, relative: str, *, directory: bool = False) -> Path: + """Resolve one regular artifact below a trusted production native root.""" + + candidate = root / relative + current = root + for part in Path(relative).parts: + current /= part + if current.is_symlink(): + raise ValueError(f"production artifact path contains a symlink: {relative}") + resolved = candidate.resolve() + try: + resolved.relative_to(root.resolve()) + except ValueError as exc: + raise ValueError(f"production artifact escapes its root: {relative}") from exc + valid = resolved.is_dir() if directory else resolved.is_file() + if not valid: + raise ValueError(f"required production artifact is missing: {relative}") + return resolved + + +def find_crypta_app(native_root: Path) -> Path: + """Return the candidate-built crypta-app launcher copied by production-beta execution.""" + + windows = platform.system() == "Windows" + name = "crypta-app.bat" if windows else "crypta-app" + candidate = safe_artifact(native_root, f"build/crypta-app-launcher/bin/{name}") + if not windows and not os.access(candidate, os.X_OK): + raise ValueError("candidate-built POSIX crypta-app launcher is not executable") + return candidate + + +def run_crypta_app(cli: Path, arguments: list[str]) -> None: + """Run one bounded offline candidate-built crypta-app operation.""" + + result = subprocess.run( + [str(cli), *arguments], + check=False, + capture_output=True, + text=True, + timeout=300, + ) + if result.returncode != 0: + raise ValueError(f"candidate-built crypta-app operation failed: {' '.join(arguments[:2])}") + + +def build_platform_api_freeze( + context: RunContext, + native_root: Path, + output_root: Path, + state: ValidationState, +) -> tuple[dict[str, Any], Path, Path]: + """Generate and verify the current contract against the checked-in Stable 1.0 baseline.""" + + baseline_path = safe_artifact( + context.workspace_root, + "docs/platform-api/contracts/platform-api-1.0-baseline.json", + ) + policy_path = safe_artifact( + context.workspace_root, + "docs/platform-api-compatibility-support-window.md", + ) + current_path = output_root / "platform-api-current-contract.json" + diff_path = output_root / "platform-api-stable-diff.json" + cli = find_crypta_app(native_root) + run_crypta_app(cli, ["api", "snapshot", "--output", str(current_path)]) + try: + run_crypta_app( + cli, + [ + "api", + "diff", + "--previous", + str(baseline_path), + "--current", + str(current_path), + "--production-beta", + "--output", + str(diff_path), + ], + ) + except ValueError: + state.block( + "stable-1.0-rc.platform-api-breaking-change", + "stable-1.0-rc.platform-api-freeze", + "The generated candidate contract does not preserve the Stable Platform API 1.0 baseline.", + "Restore compatibility or complete the established baseline evolution process.", + ) + if not diff_path.is_file(): + write_json(diff_path, {"schemaVersion": 1, "ok": False, "findings": []}) + baseline = read_json(baseline_path) + current = read_json(current_path) + baseline_contract = baseline.get("contract") if isinstance(baseline, dict) else None + current_contract = current.get("contract") if isinstance(current, dict) else None + if not isinstance(baseline_contract, dict) or not isinstance(current_contract, dict): + raise ValueError("Platform API contract snapshot is malformed") + stable = current_contract.get("stableBaseline") + baseline_stable = baseline_contract.get("stableBaseline") + if not isinstance(stable, dict) or not isinstance(baseline_stable, dict): + raise ValueError("current Platform API contract omits stableBaseline") + stable_surface_matches = stable_surface_is_exact(baseline_stable, stable) + if not stable_surface_matches: + state.block( + "stable-1.0-rc.platform-api-stable-surface-drift", + "stable-1.0-rc.platform-api-freeze", + "The generated Stable Platform API surface does not exactly match the authoritative 1.0 baseline.", + "Restore the exact ordered Stable capability and endpoint surface before cutting the RC.", + ) + capabilities = current_contract.get("capabilities") + endpoints = current_contract.get("endpoints") + if not isinstance(capabilities, list) or not isinstance(endpoints, list): + raise ValueError("current Platform API contract capability or endpoint list is malformed") + experimental_capabilities = sum( + 1 for row in capabilities if isinstance(row, dict) and row.get("stability") == "experimental" + ) + experimental_endpoints = sum( + 1 for row in endpoints if isinstance(row, dict) and row.get("stability") == "experimental" + ) + diff = read_json(diff_path) + return ( + { + "baselineName": stable.get("name"), + "baselineContractVersion": stable.get("contractVersion"), + "baselineDigest": file_digest(baseline_path), + "currentContractVersion": current_contract.get("contractVersion"), + "currentContractDigest": file_digest(current_path), + "compatibilityWindowPolicyDigest": file_digest(policy_path), + "stableCapabilityCount": stable.get("capabilityCount"), + "stableEndpointCount": stable.get("endpointCount"), + "experimentalCapabilityCount": experimental_capabilities, + "experimentalEndpointCount": experimental_endpoints, + "stableBreakingChangeVerification": ( + "pass" + if stable_surface_matches and isinstance(diff, dict) and diff.get("ok") is True + else "fail" + ), + "verificationReportDigest": file_digest(diff_path), + }, + current_path, + diff_path, + ) + + +def stable_surface_is_exact(baseline: Any, current: Any) -> bool: + """Return whether generated Stable API membership exactly equals the frozen baseline.""" + + return isinstance(baseline, dict) and canonical_json(baseline) == canonical_json(current) + + +def export_content_profiles( + native_root: Path, + output_root: Path, + app_platform_summary: dict[str, Any], + state: ValidationState, +) -> tuple[list[dict[str, Any]], Path]: + """Export profile descriptors from the candidate-built authoritative Java registry.""" + + export_path = output_root / "content-format-profiles.json" + run_crypta_app( + find_crypta_app(native_root), + ["api", "content-formats", "--output", str(export_path)], + ) + value = read_json(export_path) + if not isinstance(value, dict) or value.get("schemaVersion") != 1 or value.get("kind") != "content-format-profile-registry": + raise ValueError("candidate content-format profile export is malformed") + profiles = value.get("profiles") + if not isinstance(profiles, list): + raise ValueError("candidate content-format profile export omits profiles") + identifiers = [row.get("id") for row in profiles if isinstance(row, dict)] + if tuple(identifiers) != CONTENT_PROFILE_IDS or not all_unique(str(item) for item in identifiers): + state.block( + "stable-1.0-rc.content-format-set", + "stable-1.0-rc.content-format-freeze", + "The authoritative content-format profile set is missing, duplicated, reordered, or unexpected.", + "Restore the reviewed five-profile registry before cutting the RC.", + ) + evidence = evidence_by_id(app_platform_summary).get( + "app-platform.trust-social-content-format-profiles", + {}, + ) + if evidence.get("status") not in {"pass", "passed", "success"}: + state.block( + "stable-1.0-rc.content-format-evidence", + "stable-1.0-rc.content-format-freeze", + "Content-format parser and validator compatibility evidence is missing or failing.", + "Rerun app-platform validation against the authoritative profile registry.", + ) + evidence_digest = semantic_digest(evidence) + frozen: list[dict[str, Any]] = [] + for row in profiles: + if not isinstance(row, dict): + raise ValueError("content-format profile descriptor is malformed") + version_policy = row.get("versionPolicy") + if not isinstance(version_policy, dict): + raise ValueError("content-format version policy is malformed") + rules = { + "canonicalizationKind": row.get("canonicalizationKind"), + "versionPolicy": version_policy, + } + signature_rules = { + "signed": row.get("signed"), + "signingDomain": row.get("signingDomain"), + } + frozen.append( + { + "profileId": row.get("id"), + "version": row.get("majorVersion"), + "status": row.get("status"), + "descriptorDigest": semantic_digest(row), + "canonicalizationRulesDigest": semantic_digest(rules), + "maximumSizePolicy": { + "documentBytes": row.get("maxDocumentBytes"), + "signedPayloadBytes": row.get("maxSignedPayloadBytes"), + }, + "signaturePayloadRules": signature_rules, + "parserValidatorCompatibilityEvidenceDigest": evidence_digest, + } + ) + return frozen, export_path + + +def _catalog_contract_record(value: Any, fields: tuple[str, ...]) -> dict[str, Any]: + """Copy only schema-contracted fields from one protected catalog-operations record.""" + + if not isinstance(value, dict): + return {} + return {field: copy.deepcopy(value.get(field)) for field in fields} + + +def _catalog_operations_freeze(catalog_operations: dict[str, Any]) -> dict[str, Any]: + """Return the schema-contracted catalog operation and health snapshot.""" + + health_fields = ("status", "signatureVerified", "revision", "digest") + mirrors = catalog_operations.get("mirrors") + return { + "keyRotationStatus": _catalog_contract_record( + catalog_operations.get("keyRotation"), + ("status", "compromised"), + ), + "primaryHealth": _catalog_contract_record( + catalog_operations.get("primary"), + health_fields, + ), + "mirrorHealth": [ + _catalog_contract_record( + mirror, + (*health_fields, "transportFallbackOnly"), + ) + for mirror in (mirrors if isinstance(mirrors, list) else []) + ], + "verifiedRollback": _catalog_contract_record( + catalog_operations.get("rollback"), + health_fields, + ), + } + + +def build_catalog_and_apps_freeze( + native_root: Path, + current_contract: Path, + catalog_operations: dict[str, Any], + output_root: Path, + allowed_limitation_ids: set[str], + state: ValidationState, + production_signing: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Freeze the exact signed stable catalog and its first-party app set.""" + + catalog_path = safe_artifact(native_root, "catalog/first-party-catalog.properties") + signature_path = safe_artifact(native_root, "catalog/cryptad-app-catalog.signature") + signature_alias_path = safe_artifact(native_root, "catalog/first-party-catalog.sig") + metadata_path = safe_artifact(native_root, "catalog/channel-metadata.json") + maintenance_path = safe_artifact(native_root, "inputs/first-party-app-maintenance-policy.json") + readiness_path = safe_artifact(native_root, "inputs/first-party-app-beta-readiness.json") + catalog = parse_properties(catalog_path) + signature = parse_properties(signature_path) + metadata = read_json(metadata_path) + maintenance = read_json(maintenance_path) + readiness = read_json(readiness_path) + if not all(isinstance(value, dict) for value in (metadata, maintenance, readiness)): + raise ValueError("production catalog policy metadata is malformed") + catalog_digest = file_digest(catalog_path) + signature_digest = file_digest(signature_path) + signature_alias_digest = file_digest(signature_alias_path) + catalog_artifact_timestamp = catalog.get("catalog.generatedAt") + if ( + parse_timestamp(catalog_artifact_timestamp) is None + or parse_timestamp(catalog_artifact_timestamp) + != parse_timestamp(catalog_operations.get("artifactTimestamp")) + ): + state.block( + "stable-1.0-rc.catalog-operations-binding.artifactTimestamp", + "stable-1.0-rc.catalog-freeze", + "Protected catalog-operations artifactTimestamp does not match the signed catalog.", + "Rebuild and sign the catalog with the protected frozen artifact timestamp.", + ) + if signature_alias_path.read_bytes() != signature_path.read_bytes(): + state.block( + "stable-1.0-rc.catalog-signature-alias", + "stable-1.0-rc.catalog-freeze", + "Catalog signature alias bytes differ from the canonical signature sidecar.", + "Regenerate both sidecars from the exact signed catalog.", + ) + channel = metadata.get("channel") + ordered_ids = [item for item in catalog.get("catalog.entries", "").split(",") if item] + if ( + channel != "stable" + or metadata.get("nonProduction") is not False + or metadata.get("signingProfile") != "production" + or set(ordered_ids) != FIRST_PARTY_APP_IDS + or len(ordered_ids) != len(FIRST_PARTY_APP_IDS) + or not all_unique(ordered_ids) + ): + state.block( + "stable-1.0-rc.stable-catalog-invariants", + "stable-1.0-rc.catalog-freeze", + "The signed catalog is not an exact production stable-channel first-party catalog.", + "Regenerate the signed stable catalog with exactly the policy-required app set.", + ) + catalog_key = signature.get("catalog.signature.key.id") + edition = metadata.get("stableChannelEdition") + comparisons = { + "catalogId": catalog.get("catalog.id"), + "channel": channel, + "revision": edition, + "catalogDigest": catalog_digest, + "signatureDigest": signature_digest, + "signingKeyId": catalog_key, + } + for key, actual in comparisons.items(): + if catalog_operations.get(key) != actual: + state.block( + f"stable-1.0-rc.catalog-operations-binding.{key}", + "stable-1.0-rc.catalog-freeze", + f"Protected catalog-operations {key} does not match the signed catalog artifact.", + "Regenerate current-candidate catalog operations evidence from the signed stable catalog.", + ) + for label, health in [ + ("primary", catalog_operations.get("primary")), + *[ + (f"mirror[{index}]", row) + for index, row in enumerate(catalog_operations.get("mirrors", [])) + ], + ]: + if not isinstance(health, dict): + continue + if health.get("digest") != catalog_digest or health.get("revision") != edition: + state.block( + f"stable-1.0-rc.catalog-health-binding.{label}", + "stable-1.0-rc.catalog-freeze", + f"Protected {label} health does not bind the frozen signed catalog revision.", + "Fetch and verify the exact signed stable catalog from every configured source.", + ) + catalog_version = _integer(catalog.get("catalog.version")) + if catalog_version is None or catalog_version < 1: + raise ValueError("signed stable catalog version is missing or invalid") + apps = _build_first_party_apps( + native_root, + current_contract, + output_root, + ordered_ids, + catalog, + metadata, + maintenance, + readiness, + allowed_limitation_ids, + state, + production_signing or {}, + ) + frozen_catalog = { + "catalogId": catalog.get("catalog.id"), + "channel": channel, + "catalogVersion": catalog_version, + "edition": edition, + "revision": edition, + "catalogDigest": catalog_digest, + "signatureDigest": signature_digest, + "signatureAliasDigest": signature_alias_digest, + "catalogSigningKeyId": catalog_key, + "artifactTimestamp": catalog_artifact_timestamp, + **_catalog_operations_freeze(catalog_operations), + "securityAdvisoryCount": catalog_operations.get("advisoryCount"), + "denylistCount": catalog_operations.get("denylistCount"), + "orderedEntries": [ + {"appId": app["appId"], "version": app["version"]} for app in apps + ], + } + return frozen_catalog, apps + + +def _build_first_party_apps( + native_root: Path, + current_contract: Path, + output_root: Path, + ordered_ids: list[str], + catalog: dict[str, str], + channel_metadata: dict[str, Any], + maintenance_policy: dict[str, Any], + readiness_policy: dict[str, Any], + allowed_limitation_ids: set[str], + state: ValidationState, + production_signing: dict[str, Any], +) -> list[dict[str, Any]]: + metadata_rows = channel_metadata.get("apps") if isinstance(channel_metadata.get("apps"), list) else [] + metadata_ids = [row.get("appId") for row in metadata_rows if isinstance(row, dict)] + metadata_apps = { + row.get("appId"): row + for row in metadata_rows + if isinstance(row, dict) and isinstance(row.get("appId"), str) + } + maintenance_apps = maintenance_policy.get("apps") if isinstance(maintenance_policy.get("apps"), dict) else {} + readiness_apps = readiness_policy.get("apps") if isinstance(readiness_policy.get("apps"), dict) else {} + if ( + len(metadata_rows) != len(FIRST_PARTY_APP_IDS) + or not all_unique(str(item) for item in metadata_ids) + or set(metadata_apps) != FIRST_PARTY_APP_IDS + or set(maintenance_apps) != FIRST_PARTY_APP_IDS + or set(readiness_apps) != FIRST_PARTY_APP_IDS + ): + state.block( + "stable-1.0-rc.first-party-set", + "stable-1.0-rc.first-party-app-freeze", + "Catalog, maintenance policy, and beta-readiness app sets are not identical.", + "Regenerate complete first-party policy and signed catalog artifacts.", + ) + cli = find_crypta_app(native_root) + frozen: list[dict[str, Any]] = [] + for app_id in ordered_ids: + prefix = f"app.{app_id}." + app_summary = metadata_apps.get(app_id, {}) + version = str(catalog.get(prefix + "version") or app_summary.get("version") or "") + bundle_relative = app_summary.get("artifact") + if not isinstance(bundle_relative, str) or not bundle_relative: + raise ValueError(f"catalog metadata omits bundle artifact for {app_id}") + bundle_path = safe_artifact(native_root, bundle_relative) + manifest_path = safe_artifact(native_root, f"build/staged-apps/{app_id}/cryptad-app.properties") + bundle_signature_path = safe_artifact(native_root, f"build/staged-apps/{app_id}/cryptad-app.signature") + receipt_path = safe_artifact(native_root, f"reviews/review-receipts/{app_id}-review-receipt.properties") + manifest = parse_properties(manifest_path) + bundle_signature = parse_properties(bundle_signature_path) + receipt = parse_properties(receipt_path) + report_path = output_root / f"{app_id}-api-compatibility.json" + try: + run_crypta_app( + cli, + [ + "compat", + "verify", + "--bundle-dir", + str(manifest_path.parent), + "--contract", + str(current_contract), + "--strict", + "--json", + str(report_path), + ], + ) + except ValueError: + state.block( + f"stable-1.0-rc.app-api-compatibility.{app_id}", + "stable-1.0-rc.first-party-app-freeze", + f"First-party app {app_id} is incompatible with the candidate Platform API contract.", + "Update and retest the app compatibility declaration before cutting the RC.", + ) + if not report_path.is_file(): + write_json(report_path, {"schemaVersion": 1, "ok": False, "findings": []}) + report = read_json(report_path) + app_policy = maintenance_apps.get(app_id) if isinstance(maintenance_apps.get(app_id), dict) else {} + maintenance = app_policy.get("maintenance") if isinstance(app_policy.get("maintenance"), dict) else {} + beta_row = readiness_apps.get(app_id) if isinstance(readiness_apps.get(app_id), dict) else {} + beta = beta_row.get("betaReadiness") if isinstance(beta_row.get("betaReadiness"), dict) else {} + permissions = sorted({item.strip() for item in manifest.get("app.permissions", "").split(",") if item.strip()}) + local_limitation: str | None = None + if app_id == "trust-graph" and maintenance.get("supportLevel") == "local-rc": + local_limitation = "stable-1.0.trust-graph-local-scope" + elif app_id == "social-inbox" and maintenance.get("supportLevel") == "local-rc": + local_limitation = "stable-1.0.social-inbox-no-legacy-protocol" + if local_limitation is not None and local_limitation not in allowed_limitation_ids: + state.block( + f"stable-1.0-rc.local-rc-limitation.{app_id}", + "stable-1.0-rc.first-party-app-freeze", + f"First-party app {app_id} has local-rc status without its Stable limitation.", + "Restore the reviewed limitation or do not include the local-rc app in Stable.", + ) + expected_bundle_digest = "sha256:" + str(app_summary.get("sha256", "")).lower().removeprefix("sha256:") + actual_bundle_digest = file_digest(bundle_path) + if expected_bundle_digest != actual_bundle_digest: + state.block( + f"stable-1.0-rc.bundle-digest.{app_id}", + "stable-1.0-rc.first-party-app-freeze", + f"Bundle digest for {app_id} does not match channel metadata.", + "Rebuild and re-sign the catalog from the exact bundle bytes.", + ) + app_key_id = bundle_signature.get("signature.key.id") + reviewer_key_id = receipt.get("review.receipt.reviewer.key.id") + expected_app_key = production_signing.get("appKeyId") + expected_reviewer_key = production_signing.get("reviewerKeyId") + invariants = { + "maintenance channel": app_policy.get("channel") == "stable", + "channel metadata channel": app_summary.get("channel") == "stable", + "catalog channel": catalog.get(prefix + "channel") == "stable", + "support status": app_policy.get("supportStatus") == "supported", + "non-production marker": app_summary.get("nonProduction") is False, + "manifest app ID": manifest.get("app.id") == app_id, + "manifest version": manifest.get("app.version") == version, + "channel metadata version": str(app_summary.get("version")) == version, + "bundle size": app_summary.get("sizeBytes") == bundle_path.stat().st_size, + "bundle signing key": bool(app_key_id) and app_key_id == expected_app_key, + "receipt bundle key": receipt.get("review.receipt.bundle.key.id") == app_key_id, + "reviewer key": bool(reviewer_key_id) and reviewer_key_id == expected_reviewer_key, + "beta readiness": beta.get("status") == "ready", + "beta owner": isinstance(beta.get("owner"), str) and bool(beta.get("owner", "").strip()), + "beta support metadata": beta.get("supportMetadata") == "required", + "redacted diagnostics": beta.get("diagnostics") == "redacted-summary-only", + "migration readiness": isinstance(beta.get("migrationDryRun"), str) and bool(beta.get("migrationDryRun")), + "backup/restore readiness": isinstance(beta.get("backupRestore"), str) and bool(beta.get("backupRestore")), + } + deprecation = app_policy.get("deprecationStatus") + replacement = manifest.get("app.replacement.id") + invariants["deprecation policy"] = deprecation == "none" or ( + deprecation == "deprecated" and isinstance(replacement, str) and bool(replacement) + ) + for invariant, passed in invariants.items(): + if not passed: + state.block( + f"stable-1.0-rc.app-invariant.{app_id}.{invariant.replace(' ', '-')}", + "stable-1.0-rc.first-party-app-freeze", + f"First-party app {app_id} failed its {invariant} invariant.", + "Regenerate the production-signed app, review receipt, policy, and stable catalog entry.", + ) + if ( + receipt.get("review.receipt.app.id") != app_id + or receipt.get("review.receipt.app.version") != version + or receipt.get("review.receipt.status") != "reviewed" + or receipt.get("review.receipt.artifact.sha256") != actual_bundle_digest.removeprefix("sha256:") + ): + state.block( + f"stable-1.0-rc.review-receipt.{app_id}", + "stable-1.0-rc.first-party-app-freeze", + f"Trusted review receipt for {app_id} does not bind the frozen app artifact.", + "Issue and verify a trusted production review receipt for the exact bundle.", + ) + production_values = {"manifest": manifest, "maintenance": maintenance, "catalog": {key: value for key, value in catalog.items() if key.startswith(prefix)}} + if placeholder_findings(production_values): + state.block( + f"stable-1.0-rc.app-placeholder.{app_id}", + "stable-1.0-rc.first-party-app-freeze", + f"First-party app {app_id} contains placeholder production metadata.", + "Replace example and REPLACE_ME values with real public support metadata.", + ) + data_schema: int | str = "not-applicable" + if "app.data.schema.current" in manifest: + data_schema = _integer(manifest.get("app.data.schema.current")) + frozen.append( + { + "appId": app_id, + "version": version, + "channel": app_policy.get("channel"), + "supportStatus": app_policy.get("supportStatus"), + "deprecationStatus": app_policy.get("deprecationStatus"), + "supportLevel": maintenance.get("supportLevel"), + "replacementAppId": manifest.get("app.replacement.id"), + "bundleDigest": actual_bundle_digest, + "bundleSizeBytes": bundle_path.stat().st_size, + "appSigningKeyId": app_key_id, + "reviewReceiptDigest": file_digest(receipt_path), + "reviewerKeyId": reviewer_key_id, + "manifestDigest": file_digest(manifest_path), + "declaredPermissionSetDigest": semantic_digest(permissions), + "targetApiStability": manifest.get("api.targetStability"), + "apiCompatibilityResult": "pass" if isinstance(report, dict) and report.get("ok") is True else "fail", + "apiCompatibilityEvidenceDigest": file_digest(report_path), + "appDataSchemaVersion": data_schema, + "migrationReadiness": beta.get("migrationDryRun"), + "backupRestore": beta.get("backupRestore"), + "betaReadinessEvidenceDigest": semantic_digest(beta_row), + "supportMetadataDigest": semantic_digest( + {"maintenance": maintenance, "supportMetadata": beta.get("supportMetadata")} + ), + "supportUri": maintenance.get("supportUri"), + "redactedDiagnosticsReadiness": beta.get("diagnostics"), + "allowedStableLimitationId": local_limitation, + } + ) + return frozen + + +def build_limitations_freeze( + readiness: dict[str, Any], + policy_input: LoadedInput, + known_input: LoadedInput, + public_issues_input: LoadedInput, + app_platform: dict[str, Any], + security_digest: str, + state: ValidationState, +) -> dict[str, Any]: + """Freeze only PR-282-validated open allowed limitations and their policy bindings.""" + + policy = policy_input.value + allowed_categories = set(policy.get("allowedLimitationCategories", [])) + allowed = readiness.get("allowedLimitations") + if not isinstance(allowed, list): + raise ValueError("Stable readiness allowedLimitations is malformed") + frozen_allowed: list[dict[str, Any]] = [] + seen: set[str] = set() + for limitation in allowed: + if not isinstance(limitation, dict): + raise ValueError("Stable readiness allowed limitation is malformed") + limitation_id = limitation.get("id") + if not isinstance(limitation_id, str) or not limitation_id or limitation_id in seen: + state.block( + "stable-1.0-rc.allowed-limitation-identity", + "stable-1.0-rc.limitations-freeze", + "Allowed Stable limitations contain a missing or duplicate ID.", + "Regenerate Stable readiness from the authoritative limitations file.", + ) + seen.add(str(limitation_id)) + if ( + limitation.get("classification") != "allowed-for-stable-1.0" + or limitation.get("status") != "open" + or limitation.get("category") not in allowed_categories + or not isinstance(limitation.get("boundedBy"), str) + or not limitation.get("boundedBy", "").strip() + or not isinstance(limitation.get("owner"), str) + or not limitation.get("owner", "").strip() + ): + state.block( + f"stable-1.0-rc.allowed-limitation.{limitation_id}", + "stable-1.0-rc.limitations-freeze", + f"Allowed limitation {limitation_id} is not policy-recognized, bounded, open, and owned.", + "Correct the authoritative limitation record and regenerate Stable readiness.", + ) + frozen_allowed.append(copy.deepcopy(limitation)) + evidence = evidence_by_id(app_platform) + legacy_rows = { + key: evidence.get(key, {}) + for key in ( + "legacy-plugin.migration-finalization", + "legacy-admin.final-admin-surface", + ) + } + support_rows = { + key: evidence.get(key, {}) + for key in ( + "public-beta.support-feedback-loop", + "public-beta.security-reporting-handoff", + "public-beta.release-notes-template", + ) + } + return { + "stableReadinessPolicyDigest": policy_input.digest, + "allowedLimitations": frozen_allowed, + "allowedLimitationsDigest": semantic_digest(frozen_allowed), + "allowedLimitationCount": len(frozen_allowed), + "disallowedLimitationCount": readiness.get("disallowedLimitationCount"), + "betaOnlyLimitationCount": 0, + "publicBetaKnownIssuesDigest": public_issues_input.digest, + "stableKnownLimitationsDigest": known_input.digest, + "securityDrillSummaryDigest": security_digest, + "legacyPluginAdminFreezeEvidenceDigest": semantic_digest(legacy_rows), + "supportFeedbackReadinessEvidenceDigest": semantic_digest(support_rows), + } + + +def assemble_freeze( + *, + context: RunContext, + source: SourceIdentity, + inputs: dict[str, LoadedInput], + catalog_operations: LoadedInput, + platform_api: dict[str, Any], + stable_catalog: dict[str, Any], + first_party_apps: list[dict[str, Any]], + content_profiles: list[dict[str, Any]], + limitations: dict[str, Any], + accepted_exceptions: list[dict[str, Any]], + production_distribution_digest: str, +) -> dict[str, Any]: + """Assemble the canonical freeze object and attach its semantic content digest.""" + + producer_digests = producer_identity_digests(inputs) + freeze = { + "schemaVersion": SCHEMA_VERSION, + "kind": FREEZE_KIND, + "stableMilestone": STABLE_MILESTONE, + "candidate": { + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "sourceCommit": source.commit, + "sourceRef": source.ref, + "sourceProvenanceDigest": source.digest, + "generationTool": TOOL_NAME, + "generationToolVersion": TOOL_VERSION, + "productionBetaSummaryDigest": producer_digests["productionBeta"], + "productionDistributionDigest": production_distribution_digest, + "stableReadinessSummaryDigest": producer_digests["stableReadiness"], + "releaseCertificationSummaryDigest": producer_digests["releaseCertification"], + "goNoGoSummaryDigest": producer_digests["goNoGo"], + "ecosystemMatrixDigest": producer_digests["ecosystemMatrix"], + "releaseHistoryDigest": inputs["releaseHistory"].digest, + "previousCandidateDigest": inputs["previousCandidate"].digest, + "liveNetworkDigest": inputs["liveNetwork"].digest, + "multiNodeSoakDigest": inputs["multiNodeSoak"].digest, + "networkScaleSoakDigest": inputs["networkScaleSoak"].digest, + "securityDrillDigest": inputs["securityDrills"].digest, + "thirdPartyIntakeDigest": inputs["thirdPartyIntake"].digest, + "catalogOperationsDigest": catalog_operations.digest, + }, + "platformApi": platform_api, + "stableCatalog": stable_catalog, + "firstPartyApps": first_party_apps, + "contentFormatProfiles": content_profiles, + "limitationsAndPolicy": limitations, + "acceptedFreezeExceptions": accepted_exceptions, + } + freeze["contentDigest"] = freeze_content_digest(freeze) + return freeze + + +def freeze_content_digest(freeze: dict[str, Any]) -> str: + """Hash a freeze object while excluding only its self-referential contentDigest field.""" + + content = copy.deepcopy(freeze) + content.pop("contentDigest", None) + return semantic_digest(content) + + +_SCHEMA_DIR = Path(__file__).resolve().parents[2] / "schemas" +_FREEZE_SCHEMA = _SCHEMA_DIR / "stable-1.0-rc-freeze-v1.schema.json" +_SUPPORTED_SCHEMA_KEYS = frozenset( + { + "$schema", + "$id", + "$ref", + "$defs", + "title", + "type", + "additionalProperties", + "required", + "properties", + "const", + "enum", + "pattern", + "minLength", + "minimum", + "minItems", + "maxItems", + "uniqueItems", + "items", + "oneOf", + "format", + } +) + + +def _json_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + return left == right + + +def _matches_schema_type(value: Any, expected: str) -> bool: + return { + "array": isinstance(value, list), + "boolean": type(value) is bool, + "integer": type(value) is int, + "null": value is None, + "object": isinstance(value, dict), + "string": isinstance(value, str), + }.get(expected, False) + + +def _schema_pointer(root: dict[str, Any], fragment: str) -> dict[str, Any]: + current: Any = root + if fragment and not fragment.startswith("/"): + raise ValueError("schema reference fragment is malformed") + for raw_part in fragment.lstrip("/").split("/") if fragment else []: + part = raw_part.replace("~1", "/").replace("~0", "~") + if not isinstance(current, dict) or part not in current: + raise ValueError("schema reference does not resolve") + current = current[part] + if not isinstance(current, dict): + raise ValueError("schema reference target is not an object") + return current + + +def _resolve_schema_reference( + reference: str, + root_schema: dict[str, Any], + cache: dict[str, dict[str, Any]], +) -> tuple[dict[str, Any], dict[str, Any]]: + filename, marker, fragment = reference.partition("#") + if not filename: + return _schema_pointer(root_schema, fragment if marker else ""), root_schema + relative = Path(filename) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("external schema reference is unsafe") + target = _SCHEMA_DIR / relative + if target.is_symlink() or not target.is_file(): + raise ValueError("external schema reference is missing or unsafe") + resolved = target.resolve() + try: + resolved.relative_to(_SCHEMA_DIR.resolve()) + except ValueError as exc: + raise ValueError("external schema reference escapes the schema directory") from exc + key = resolved.as_posix() + external = cache.get(key) + if external is None: + loaded = read_json(resolved) + if not isinstance(loaded, dict): + raise ValueError("external schema is malformed") + external = loaded + cache[key] = external + return _schema_pointer(external, fragment if marker else ""), external + + +def _schema_errors( + value: Any, + schema: dict[str, Any], + root_schema: dict[str, Any], + path: str, + cache: dict[str, dict[str, Any]], +) -> list[str]: + unsupported = set(schema).difference(_SUPPORTED_SCHEMA_KEYS) + if unsupported: + return [f"{path} uses unsupported freeze schema constraints"] + reference = schema.get("$ref") + if isinstance(reference, str): + try: + resolved, resolved_root = _resolve_schema_reference(reference, root_schema, cache) + except (OSError, ValueError): + return [f"{path} references an unavailable freeze schema definition"] + return _schema_errors(value, resolved, resolved_root, path, cache) + one_of = schema.get("oneOf") + if isinstance(one_of, list): + matching = [ + branch + for branch in one_of + if isinstance(branch, dict) + and not _schema_errors(value, branch, root_schema, path, cache) + ] + if len(matching) != 1: + return [f"{path} does not match exactly one allowed freeze schema shape"] + expected_type = schema.get("type") + if isinstance(expected_type, str): + allowed_types = [expected_type] + elif isinstance(expected_type, list) and all(isinstance(item, str) for item in expected_type): + allowed_types = expected_type + else: + allowed_types = [] + if allowed_types and not any(_matches_schema_type(value, item) for item in allowed_types): + return [f"{path} has the wrong freeze schema type"] + errors: list[str] = [] + if "const" in schema and not _json_equal(value, schema["const"]): + errors.append(f"{path} does not match the required freeze schema constant") + allowed_values = schema.get("enum") + if isinstance(allowed_values, list) and not any(_json_equal(value, item) for item in allowed_values): + errors.append(f"{path} is not an allowed freeze schema value") + if isinstance(value, str): + minimum_length = schema.get("minLength") + if type(minimum_length) is int and len(value) < minimum_length: + errors.append(f"{path} is shorter than the freeze schema minimum") + pattern = schema.get("pattern") + if isinstance(pattern, str) and re.search(pattern, value) is None: + errors.append(f"{path} does not match the freeze schema pattern") + value_format = schema.get("format") + if value_format == "date-time" and parse_timestamp(value) is None: + errors.append(f"{path} is not a valid freeze schema date-time") + if value_format == "uri": + parsed = urlsplit(value) + if not parsed.scheme or any(character.isspace() for character in value): + errors.append(f"{path} is not a valid freeze schema URI") + if type(value) is int and type(schema.get("minimum")) is int and value < schema["minimum"]: + errors.append(f"{path} is below the freeze schema minimum") + if isinstance(value, dict): + properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {} + required = schema.get("required") if isinstance(schema.get("required"), list) else [] + for name in required: + if isinstance(name, str) and name not in value: + errors.append(f"{path} omits required field {name}") + if schema.get("additionalProperties") is False: + for name in sorted(set(value).difference(properties)): + errors.append(f"{path} contains unknown field {name}") + for name, child in value.items(): + child_schema = properties.get(name) + if isinstance(child_schema, dict): + errors.extend(_schema_errors(child, child_schema, root_schema, f"{path}.{name}", cache)) + if isinstance(value, list): + minimum_items = schema.get("minItems") + maximum_items = schema.get("maxItems") + if type(minimum_items) is int and len(value) < minimum_items: + errors.append(f"{path} has fewer items than the freeze schema minimum") + if type(maximum_items) is int and len(value) > maximum_items: + errors.append(f"{path} has more items than the freeze schema maximum") + if schema.get("uniqueItems") is True: + serialized = [canonical_json(item) for item in value] + if len(serialized) != len(set(serialized)): + errors.append(f"{path} contains duplicate freeze schema items") + item_schema = schema.get("items") + if isinstance(item_schema, dict): + for index, item in enumerate(value): + errors.extend(_schema_errors(item, item_schema, root_schema, f"{path}[{index}]", cache)) + return errors + + +def validate_freeze_shape(value: Any) -> list[str]: + """Validate the complete freeze against the checked-in v1 schema and cross-field invariants.""" + + if not isinstance(value, dict): + return ["freeze must be a JSON object"] + try: + schema = read_json(_FREEZE_SCHEMA) + except (OSError, ValueError): + return ["freeze v1 schema is missing or malformed"] + if not isinstance(schema, dict): + return ["freeze v1 schema is missing or malformed"] + errors = _schema_errors(value, schema, schema, "$", {}) + if value.get("contentDigest") != freeze_content_digest(value): + errors.append("freeze contentDigest does not match canonical content") + apps = value.get("firstPartyApps") if isinstance(value.get("firstPartyApps"), list) else [] + app_ids = [row.get("appId") for row in apps if isinstance(row, dict)] + if len(app_ids) != len(FIRST_PARTY_APP_IDS) or set(app_ids) != FIRST_PARTY_APP_IDS: + errors.append("freeze firstPartyApps does not contain the exact required app set") + profiles = value.get("contentFormatProfiles") if isinstance(value.get("contentFormatProfiles"), list) else [] + profile_ids = [row.get("profileId") for row in profiles if isinstance(row, dict)] + if profile_ids != list(CONTENT_PROFILE_IDS): + errors.append("freeze contentFormatProfiles is not in the authoritative profile order") + limitations = value.get("limitationsAndPolicy") if isinstance(value.get("limitationsAndPolicy"), dict) else {} + allowed = limitations.get("allowedLimitations") if isinstance(limitations.get("allowedLimitations"), list) else [] + if limitations.get("allowedLimitationCount") != len(allowed): + errors.append("freeze allowed limitation count does not match its records") + if limitations.get("allowedLimitationsDigest") != semantic_digest(allowed): + errors.append("freeze allowed limitation digest does not match its records") + return errors + + +def _exception_target_is_non_waivable(section: Any, item: Any) -> bool: + return isinstance(section, str) and isinstance(item, str) and item in ( + NON_WAIVABLE_EXCEPTION_TARGETS.get(section) or frozenset() + ) + + +def _non_waivable_change_reason( + section: str, + item: str, + before: Any, + after: Any, +) -> str | None: + """Classify forbidden changes from frozen structure, never exception prose.""" + + if _exception_target_is_non_waivable(section, item): + return f"{section}.{item} is a non-waivable freeze invariant" + if section == "stableCatalog" and item == "keyRotationStatus": + if before != after and after != {"status": "complete", "compromised": False}: + return "stableCatalog.keyRotationStatus would leave signing identity unsafe" + if section == "limitationsAndPolicy" and item in { + "betaOnlyLimitationCount", + "disallowedLimitationCount", + }: + if before != after and after != 0: + return f"limitationsAndPolicy.{item} would leave a non-waivable limitation" + if section == "firstPartyApps" and isinstance(before, dict) and isinstance(after, dict): + unsafe_changes = ( + before.get("channel") != after.get("channel") and after.get("channel") != "stable", + before.get("apiCompatibilityResult") != after.get("apiCompatibilityResult") + and after.get("apiCompatibilityResult") != "pass", + before.get("appSigningKeyId") != after.get("appSigningKeyId") + and not after.get("appSigningKeyId"), + before.get("reviewerKeyId") != after.get("reviewerKeyId") + and not after.get("reviewerKeyId"), + ) + if any(unsafe_changes): + return f"firstPartyApps.{item} would leave a non-waivable app invariant failing" + return None + + +def validate_exception_collection( + value: Any, + release_id: str, + build_version: str, + now: Any, +) -> tuple[list[dict[str, Any]], list[str]]: + """Validate authorized, expiring freeze-exception records without applying them as waivers.""" + + if value is None: + return [], [] + schema_errors = validate_schema(value, FREEZE_EXCEPTIONS_SCHEMA) + errors = [f"freeze exception schema: {error}" for error in schema_errors] + if not isinstance(value, dict): + return [], errors + if value.get("schemaVersion") != 1 or value.get("kind") != FREEZE_EXCEPTIONS_KIND: + errors.append("freeze exception collection schemaVersion or kind is invalid") + if value.get("authorizationRole") != "stable-release-manager": + errors.append("freeze exception collection is under-authorized") + redaction = value.get("redaction") if isinstance(value.get("redaction"), dict) else {} + if redaction.get("status") != "pass" or redaction.get("findingCount") != 0 or redaction.get("findings") != []: + errors.append("freeze exception collection redaction did not pass") + records = value.get("exceptions") + if not isinstance(records, list): + return [], [*errors, "freeze exception records must be an array"] + accepted: list[dict[str, Any]] = [] + seen: set[str] = set() + for index, record in enumerate(records): + prefix = f"exceptions[{index}]" + error_count = len(errors) + if not isinstance(record, dict): + errors.append(f"{prefix} must be an object") + continue + required = { + "exceptionId", + "releaseId", + "buildVersion", + "affectedSection", + "affectedItem", + "beforeDigest", + "afterDigest", + "reason", + "issueKind", + "issueReference", + "owner", + "approver", + "createdAt", + "expiresAt", + "requiredRerunScope", + "finalVerificationResult", + } + if set(record) != required: + errors.append(f"{prefix} fields are malformed") + continue + placeholders = placeholder_findings(record) + if placeholders: + errors.append( + f"{prefix} contains production placeholder metadata at " + + ", ".join(placeholders) + ) + exception_id = record.get("exceptionId") + if not isinstance(exception_id, str) or not exception_id or exception_id in seen: + errors.append(f"{prefix}.exceptionId is missing or duplicated") + seen.add(str(exception_id)) + if record.get("releaseId") != release_id or record.get("buildVersion") != build_version: + errors.append(f"{prefix} does not match the candidate") + if record.get("affectedSection") not in EXCEPTION_TARGET_SECTIONS: + errors.append(f"{prefix}.affectedSection is invalid") + for key in ("beforeDigest", "afterDigest"): + if DIGEST_RE.fullmatch(str(record.get(key, ""))) is None: + errors.append(f"{prefix}.{key} is malformed") + for key in ("affectedItem", "reason", "issueReference", "owner", "approver"): + if not isinstance(record.get(key), str) or not record[key].strip(): + errors.append(f"{prefix}.{key} must be non-empty") + created = parse_timestamp(record.get("createdAt")) + expires = parse_timestamp(record.get("expiresAt")) + if created is None or expires is None or created >= expires or created > now or expires < now: + errors.append(f"{prefix} timestamps are malformed, expired, or reversed") + scope = record.get("requiredRerunScope") + if not isinstance(scope, list) or not scope or not all(isinstance(item, str) and item for item in scope): + errors.append(f"{prefix}.requiredRerunScope is malformed") + elif not { + "freeze-generation", + "packaging", + "stable-rc-final-gates", + }.issubset(set(scope)): + errors.append(f"{prefix}.requiredRerunScope omits mandatory final reruns") + if record.get("issueKind") not in {"blocker", "security"}: + errors.append(f"{prefix}.issueKind is invalid") + if record.get("beforeDigest") == record.get("afterDigest"): + errors.append(f"{prefix} does not describe a digest change") + if record.get("finalVerificationResult") != "pass": + errors.append(f"{prefix}.finalVerificationResult is not pass") + if _exception_target_is_non_waivable( + record.get("affectedSection"), + record.get("affectedItem"), + ): + errors.append(f"{prefix} targets a non-waivable release blocker") + if len(errors) == error_count: + accepted.append(copy.deepcopy(record)) + return ([], errors) if errors else (accepted, []) + + +def _accepted_exception_items(value: Any) -> dict[str, dict[str, Any]]: + """Index immutable accepted-exception audit records by their stable identifier.""" + + if not isinstance(value, list): + raise ValueError("freeze section acceptedFreezeExceptions must be an array") + indexed: dict[str, dict[str, Any]] = {} + for row in value: + exception_id = row.get("exceptionId") if isinstance(row, dict) else None + if not isinstance(exception_id, str) or not exception_id or exception_id in indexed: + raise ValueError( + "freeze section acceptedFreezeExceptions has a missing or duplicate exceptionId" + ) + indexed[exception_id] = row + return indexed + + +def merge_accepted_exception_history( + previous: dict[str, Any] | None, + current: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Carry immutable prior exception audit records into every regenerated freeze.""" + + if previous is None: + return copy.deepcopy(current) + prior_items = _accepted_exception_items(previous.get("acceptedFreezeExceptions")) + merged = [copy.deepcopy(row) for row in prior_items.values()] + merged_items = {row["exceptionId"]: row for row in merged} + for row in current: + exception_id = row.get("exceptionId") if isinstance(row, dict) else None + if not isinstance(exception_id, str) or not exception_id: + raise ValueError("current freeze exception has a missing exceptionId") + prior = merged_items.get(exception_id) + if prior is not None: + if prior != row: + raise ValueError( + f"accepted freeze exception {exception_id} conflicts with its audit history" + ) + continue + copied = copy.deepcopy(row) + merged.append(copied) + merged_items[exception_id] = copied + return merged + + +def compare_freezes( + previous: dict[str, Any] | None, + current: dict[str, Any], + exceptions: list[dict[str, Any]], +) -> dict[str, Any]: + """Compare complete semantic freeze sections and classify all post-freeze drift.""" + + current_errors = validate_freeze_shape(current) + if current_errors: + return _drift_report("invalid-freeze", [], current_errors, []) + if previous is None: + if exceptions: + return _drift_report( + "invalid-freeze", + [], + ["freeze exceptions were supplied without a previous freeze"], + [], + ) + return _drift_report("no-drift", [], [], []) + previous_errors = validate_freeze_shape(previous) + if previous_errors: + return _drift_report("invalid-freeze", [], previous_errors, []) + previous_candidate = previous.get("candidate", {}) + current_candidate = current.get("candidate", {}) + identity_errors = [ + key + for key in ("releaseId", "buildVersion") + if previous_candidate.get(key) != current_candidate.get(key) + ] + if identity_errors: + return _drift_report( + "invalid-freeze", + [], + ["candidate identity mismatch: " + ", ".join(identity_errors)], + [], + ) + section_changes: list[dict[str, Any]] = [] + try: + for section in EXCEPTION_TARGET_SECTIONS: + before_items = _freeze_section_items(section, previous.get(section)) + after_items = _freeze_section_items(section, current.get(section)) + for item in sorted(set(before_items) | set(after_items)): + before_digest = semantic_digest(before_items.get(item)) + after_digest = semantic_digest(after_items.get(item)) + if before_digest == after_digest: + continue + change = { + "section": section, + "item": item, + "beforeDigest": before_digest, + "afterDigest": after_digest, + } + non_waivable_reason = _non_waivable_change_reason( + section, + item, + before_items.get(item), + after_items.get(item), + ) + if non_waivable_reason is not None: + change["nonWaivableReason"] = non_waivable_reason + section_changes.append(change) + previous_exception_items = _accepted_exception_items( + previous.get("acceptedFreezeExceptions") + ) + current_exception_items = _accepted_exception_items( + current.get("acceptedFreezeExceptions") + ) + except ValueError as exc: + return _drift_report("invalid-freeze", [], [str(exc)], []) + audit_changes: list[dict[str, Any]] = [] + for exception_id in sorted(set(previous_exception_items) | set(current_exception_items)): + before = previous_exception_items.get(exception_id) + after = current_exception_items.get(exception_id) + before_digest = semantic_digest(before) + after_digest = semantic_digest(after) + if before_digest != after_digest: + audit_changes.append( + { + "section": "acceptedFreezeExceptions", + "item": exception_id, + "beforeDigest": before_digest, + "afterDigest": after_digest, + } + ) + changes = [*section_changes, *audit_changes] + if not section_changes and exceptions: + return _drift_report( + "invalid-freeze", + changes, + ["freeze exceptions were supplied but no freeze content changed"], + [], + ) + if not changes: + return _drift_report("no-drift", [], [], []) + approved: list[dict[str, Any]] = [] + unapproved: list[dict[str, Any]] = [] + used_exception_ids: set[str] = set() + forbidden_exception_ids: set[str] = set() + for change in section_changes: + match = next( + ( + record + for record in exceptions + if record.get("affectedSection") == change["section"] + and record.get("affectedItem") == change["item"] + and record.get("beforeDigest") == change["beforeDigest"] + and record.get("afterDigest") == change["afterDigest"] + ), + None, + ) + if "nonWaivableReason" in change: + unapproved.append(change) + if match is not None: + exception_id = str(match.get("exceptionId")) + forbidden_exception_ids.add(exception_id) + used_exception_ids.add(exception_id) + elif match is None: + unapproved.append(change) + else: + approved.append({**change, "exceptionId": match.get("exceptionId")}) + used_exception_ids.add(str(match.get("exceptionId"))) + for change in audit_changes: + exception_id = change["item"] + current_record = current_exception_items.get(exception_id) + active_record = next( + ( + record + for record in exceptions + if record.get("exceptionId") == exception_id and record == current_record + ), + None, + ) + is_new_matched_record = ( + exception_id not in previous_exception_items + and active_record is not None + and exception_id in used_exception_ids + ) + if is_new_matched_record: + approved.append({**change, "exceptionId": exception_id}) + else: + unapproved.append(change) + if forbidden_exception_ids: + return _drift_report( + "invalid-freeze", + changes, + [ + "freeze exceptions target non-waivable changes: " + + ", ".join(sorted(forbidden_exception_ids)) + ], + approved, + unapproved, + ) + unmatched = [ + str(record.get("exceptionId")) + for record in exceptions + if str(record.get("exceptionId")) not in used_exception_ids + ] + if unmatched: + return _drift_report( + "invalid-freeze", + changes, + ["unmatched freeze exception records: " + ", ".join(sorted(unmatched))], + approved, + unapproved, + ) + status = "unapproved-drift" if unapproved else "approved-freeze-exception" + return _drift_report(status, changes, [], approved, unapproved) + + +def _freeze_section_items(section: str, value: Any) -> dict[str, Any]: + """Index one freeze section into the narrow items authorized by exception records.""" + + identifier_key = { + "firstPartyApps": "appId", + "contentFormatProfiles": "profileId", + }.get(section) + if identifier_key is None: + if not isinstance(value, dict): + raise ValueError(f"freeze section {section} must be an object") + return {str(key): child for key, child in value.items()} + if not isinstance(value, list): + raise ValueError(f"freeze section {section} must be an array") + indexed: dict[str, Any] = {} + for row in value: + identifier = row.get(identifier_key) if isinstance(row, dict) else None + if not isinstance(identifier, str) or not identifier or identifier in indexed: + raise ValueError(f"freeze section {section} has a missing or duplicate {identifier_key}") + indexed[identifier] = row + return indexed + + +def _drift_report( + status: str, + changes: list[dict[str, Any]], + errors: list[str], + approved: list[dict[str, Any]], + unapproved: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "schemaVersion": 1, + "kind": "stable-1.0-rc-freeze-drift", + "status": status, + "driftCount": len(changes), + "changes": changes, + "approvedChanges": approved, + "unapprovedChanges": unapproved or [], + "errors": errors, + } + + +def _integer(value: Any) -> int | None: + try: + parsed = int(str(value)) + except (TypeError, ValueError): + return None + return parsed if parsed >= 0 else None diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_core.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_core.py index 3748ea5447..ec5fe8ab69 100644 --- a/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_core.py +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_core.py @@ -228,6 +228,7 @@ "classification", "status", "summary", + "owner", "boundedBy", ) diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_policy.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_policy.py index 704593214b..dc23a21a99 100644 --- a/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_policy.py +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_policy.py @@ -1236,6 +1236,7 @@ def safe_limitation(limitation: dict[str, Any]) -> dict[str, Any]: "classification", "status", "summary", + "owner", "evidenceIds", "boundedBy", ) diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_selftest.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_selftest.py index d974864572..20a5f9c934 100644 --- a/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_selftest.py +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_readiness_selftest.py @@ -224,7 +224,28 @@ def run_self_test() -> None: target.write_text(f"# {Path(path).stem}\n", encoding="utf-8") run_case(root, "ready", None, "ready") - run_case(root, "allowed-limitations", lambda _i, _l, _p: None, "ready-with-allowed-limitations", expect_allowed="stable-1.0.trust-graph-local-scope") + + def assert_allowed_limitation_owner(summary: dict[str, Any]) -> None: + allowed = { + str(record.get("id")): record + for record in summary.get("allowedLimitations", []) + if isinstance(record, dict) + } + trust_graph = allowed.get("stable-1.0.trust-graph-local-scope") + if not isinstance(trust_graph, dict) or trust_graph.get("owner") != "crypta-core": + raise AssertionError( + "Stable readiness did not preserve the allowed limitation owner: " + f"{trust_graph}" + ) + + run_case( + root, + "allowed-limitations", + lambda _i, _l, _p: None, + "ready-with-allowed-limitations", + expect_allowed="stable-1.0.trust-graph-local-scope", + post_check=assert_allowed_limitation_owner, + ) def assert_generated_at_does_not_control_freshness(summary: dict[str, Any]) -> None: if summary.get("generatedAt") != DEFAULT_GENERATED_AT: @@ -3711,6 +3732,7 @@ def waived_limitation_valid( "category": "ui-polish-accessibility-warning", "status": "open", "summary": "Synthetic auditable waiver-required limitation.", + "owner": "stable-readiness", "evidenceIds": ["stable-1.0.support-feedback-readiness"], "boundedBy": "The follow-up remains bounded to non-blocking UI polish.", } diff --git a/tools/release-certification/cryptad_certification/legacy.py b/tools/release-certification/cryptad_certification/legacy.py index 530d7e8c30..7058ccd850 100644 --- a/tools/release-certification/cryptad_certification/legacy.py +++ b/tools/release-certification/cryptad_certification/legacy.py @@ -30,6 +30,7 @@ "production-beta": "production-beta-release", "go-no-go": "production-beta-go-no-go", "stable-readiness": "stable-1.0-readiness", + "stable-rc": "stable-1.0-rc", } V2_KIND_BY_INPUT = { "appPlatform": "app-platform-smoke", @@ -99,8 +100,10 @@ "--perf-smoke-summary", "--previous-release-certification-summary", "--previous-summary", + "--public-beta-known-issues", "--security-drills-summary", "--stable-known-limitations", + "--stable-rc-artifact-timestamp", "--stable-readiness-policy", "--stable-readiness-waivers", "--third-party-intake-summary", @@ -703,6 +706,26 @@ def _run_production_beta(context: RunContext) -> tuple[int, Path, Path | None]: artifact_base_uri = context.manifest.policies.get("artifactBaseUri") if isinstance(artifact_base_uri, str): args.extend(["--artifact-base-uri", artifact_base_uri]) + stable_rc_orchestration = context.manifest.policies.get( + "stableRcFreezeMode" + ) in {"first-freeze", "refreeze"} + if stable_rc_orchestration: + operations_path = _resolve_input_path(context, "stableCatalogOperations") + if operations_path is None: + raise ValueError( + "Stable RC production execution requires inputs.stableCatalogOperations" + ) + operations = read_json(operations_path) + if not isinstance(operations, dict): + raise ValueError("stable catalog-operations evidence is malformed") + artifact_timestamp = operations.get("artifactTimestamp") + if not isinstance(artifact_timestamp, str) or not artifact_timestamp.strip(): + raise ValueError( + "stable catalog-operations evidence omits artifactTimestamp" + ) + args.extend( + ["--stable-rc-artifact-timestamp", artifact_timestamp.strip()] + ) _flag(args, context.manifest.requirements.get("liveNetwork"), "--require-live-network") _flag(args, context.manifest.requirements.get("history"), "--require-history") @@ -747,6 +770,11 @@ def _run_production_beta(context: RunContext) -> tuple[int, Path, Path | None]: _option_path(args, "--third-party-intake-summary", _legacy_input_path(context, "thirdPartyIntake")) _option_path(args, "--security-drills-summary", _legacy_input_path(context, "securityDrills")) _option_path(args, "--waiver-file", _resolve_input_path(context, "waiverFile")) + _option_path( + args, + "--public-beta-known-issues", + _resolve_input_path(context, "publicBetaKnownIssues"), + ) _option_path(args, "--stable-readiness-policy", _resolve_input_path(context, "stableReadinessPolicy")) _option_path(args, "--stable-known-limitations", _resolve_input_path(context, "stableKnownLimitations")) _option_path(args, "--stable-readiness-waivers", _resolve_input_path(context, "stableReadinessWaivers")) @@ -866,6 +894,16 @@ def _run_stable_readiness(context: RunContext) -> tuple[int, Path, Path | None]: return code, out / "stable-1.0-readiness-summary.json", out / "stable-1.0-readiness-report.md" +def _run_stable_rc(context: RunContext) -> tuple[int, Path, Path | None]: + """Run the native Stable 1.0 RC freeze and promotion engine.""" + + if context.manifest.release.profile != "stable-review": + raise ValueError("stable-rc requires release.profile stable-review") + from .engines import stable_1_0_rc as engine + + return engine.run(context) + + def _run_passthrough(context: RunContext, command: str, action: str | None) -> tuple[int, Path, Path | None]: engine: Any out = _legacy_dir(context) @@ -980,6 +1018,7 @@ def _run_passthrough(context: RunContext, command: str, action: str | None) -> t "production-beta": _run_production_beta, "go-no-go": _run_go_no_go, "stable-readiness": _run_stable_readiness, + "stable-rc": _run_stable_rc, } diff --git a/tools/release-certification/cryptad_certification/manifest.py b/tools/release-certification/cryptad_certification/manifest.py index b00c6ac4dd..e14202c163 100644 --- a/tools/release-certification/cryptad_certification/manifest.py +++ b/tools/release-certification/cryptad_certification/manifest.py @@ -27,6 +27,7 @@ "production-beta", "go-no-go", "stable-readiness", + "stable-rc", "multi-node-beta", "security-response", } @@ -50,12 +51,15 @@ "networkScaleSoak", "performanceSmoke", "previousCandidate", + "previousStableRcFreeze", "productionBeta", "publicBetaKnownIssues", "releaseCertification", "releaseHistory", "securityDrills", "stableKnownLimitations", + "stableCatalogOperations", + "stableRcFreezeExceptions", "stableReadiness", "stableReadinessPolicy", "stableReadinessWaivers", @@ -69,6 +73,7 @@ "historyDir", "historyLabel", "metadata", + "stableRcFreezeMode", } EXECUTION_BOOLEAN_FIELDS = { "allowDirtyWorkspace", @@ -182,6 +187,11 @@ def _validate_policies(value: Any) -> dict[str, Any]: "deprecated", }: raise ManifestError("policies.catalogChannel is invalid") + if "stableRcFreezeMode" in policies and policies["stableRcFreezeMode"] not in { + "first-freeze", + "refreeze", + }: + raise ManifestError("policies.stableRcFreezeMode is invalid") metadata = policies.get("metadata") if metadata is not None and ( not isinstance(metadata, dict) diff --git a/tools/release-certification/cryptad_certification/schema_validation.py b/tools/release-certification/cryptad_certification/schema_validation.py new file mode 100644 index 0000000000..7011bb71b0 --- /dev/null +++ b/tools/release-certification/cryptad_certification/schema_validation.py @@ -0,0 +1,228 @@ +"""Small offline JSON Schema validator for checked-in certification contracts.""" + +from __future__ import annotations + +import datetime as dt +import json +import re +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +from .io import read_json + +_SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schemas" +_SUPPORTED_SCHEMA_KEYS = frozenset( + { + "$schema", + "$id", + "$ref", + "$defs", + "title", + "type", + "additionalProperties", + "required", + "properties", + "const", + "enum", + "pattern", + "minLength", + "minimum", + "minItems", + "maxItems", + "uniqueItems", + "items", + "oneOf", + "format", + } +) + + +def validate_schema(value: Any, schema_filename: str) -> list[str]: + """Validate a value against one confined, checked-in certification schema.""" + + relative = Path(schema_filename) + if relative.is_absolute() or len(relative.parts) != 1 or ".." in relative.parts: + return ["$ references an unsafe certification schema"] + target = _SCHEMA_DIR / relative + if target.is_symlink() or not target.is_file(): + return ["$ certification schema is missing or unsafe"] + resolved = target.resolve() + try: + resolved.relative_to(_SCHEMA_DIR.resolve()) + except ValueError: + return ["$ certification schema escapes its directory"] + try: + schema = read_json(resolved) + except (OSError, ValueError): + return ["$ certification schema is malformed"] + if not isinstance(schema, dict): + return ["$ certification schema is malformed"] + return _schema_errors(value, schema, schema, "$", {}) + + +def _json_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return type(left) is type(right) and left == right + return left == right + + +def _matches_schema_type(value: Any, expected: str) -> bool: + return { + "array": isinstance(value, list), + "boolean": type(value) is bool, + "integer": type(value) is int, + "null": value is None, + "object": isinstance(value, dict), + "string": isinstance(value, str), + }.get(expected, False) + + +def _schema_pointer(root: dict[str, Any], fragment: str) -> dict[str, Any]: + current: Any = root + if fragment and not fragment.startswith("/"): + raise ValueError("schema reference fragment is malformed") + for raw_part in fragment.lstrip("/").split("/") if fragment else []: + part = raw_part.replace("~1", "/").replace("~0", "~") + if not isinstance(current, dict) or part not in current: + raise ValueError("schema reference does not resolve") + current = current[part] + if not isinstance(current, dict): + raise ValueError("schema reference target is not an object") + return current + + +def _resolve_schema_reference( + reference: str, + root_schema: dict[str, Any], + cache: dict[str, dict[str, Any]], +) -> tuple[dict[str, Any], dict[str, Any]]: + filename, marker, fragment = reference.partition("#") + if not filename: + return _schema_pointer(root_schema, fragment if marker else ""), root_schema + relative = Path(filename) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("external schema reference is unsafe") + target = _SCHEMA_DIR / relative + if target.is_symlink() or not target.is_file(): + raise ValueError("external schema reference is missing or unsafe") + resolved = target.resolve() + try: + resolved.relative_to(_SCHEMA_DIR.resolve()) + except ValueError as exc: + raise ValueError("external schema reference escapes the schema directory") from exc + key = resolved.as_posix() + external = cache.get(key) + if external is None: + loaded = read_json(resolved) + if not isinstance(loaded, dict): + raise ValueError("external schema is malformed") + external = loaded + cache[key] = external + return _schema_pointer(external, fragment if marker else ""), external + + +def _valid_date_time(value: str) -> bool: + try: + parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return False + return parsed.tzinfo is not None + + +def _schema_errors( + value: Any, + schema: dict[str, Any], + root_schema: dict[str, Any], + path: str, + cache: dict[str, dict[str, Any]], +) -> list[str]: + unsupported = set(schema).difference(_SUPPORTED_SCHEMA_KEYS) + if unsupported: + return [f"{path} uses unsupported schema constraints"] + reference = schema.get("$ref") + if isinstance(reference, str): + try: + resolved, resolved_root = _resolve_schema_reference(reference, root_schema, cache) + except (OSError, ValueError): + return [f"{path} references an unavailable schema definition"] + return _schema_errors(value, resolved, resolved_root, path, cache) + one_of = schema.get("oneOf") + if isinstance(one_of, list): + matching = [ + branch + for branch in one_of + if isinstance(branch, dict) + and not _schema_errors(value, branch, root_schema, path, cache) + ] + if len(matching) != 1: + return [f"{path} does not match exactly one allowed schema shape"] + expected_type = schema.get("type") + if isinstance(expected_type, str): + allowed_types = [expected_type] + elif isinstance(expected_type, list) and all(isinstance(item, str) for item in expected_type): + allowed_types = expected_type + else: + allowed_types = [] + if allowed_types and not any(_matches_schema_type(value, item) for item in allowed_types): + return [f"{path} has the wrong schema type"] + errors: list[str] = [] + if "const" in schema and not _json_equal(value, schema["const"]): + errors.append(f"{path} does not match the required schema constant") + allowed_values = schema.get("enum") + if isinstance(allowed_values, list) and not any( + _json_equal(value, item) for item in allowed_values + ): + errors.append(f"{path} is not an allowed schema value") + if isinstance(value, str): + minimum_length = schema.get("minLength") + if type(minimum_length) is int and len(value) < minimum_length: + errors.append(f"{path} is shorter than the schema minimum") + pattern = schema.get("pattern") + if isinstance(pattern, str) and re.search(pattern, value) is None: + errors.append(f"{path} does not match the schema pattern") + value_format = schema.get("format") + if value_format == "date-time" and not _valid_date_time(value): + errors.append(f"{path} is not a valid schema date-time") + if value_format == "uri": + parsed = urlsplit(value) + if not parsed.scheme or any(character.isspace() for character in value): + errors.append(f"{path} is not a valid schema URI") + if type(value) is int and type(schema.get("minimum")) is int and value < schema["minimum"]: + errors.append(f"{path} is below the schema minimum") + if isinstance(value, dict): + properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {} + required = schema.get("required") if isinstance(schema.get("required"), list) else [] + for name in required: + if isinstance(name, str) and name not in value: + errors.append(f"{path} omits required field {name}") + if schema.get("additionalProperties") is False: + for name in sorted(set(value).difference(properties)): + errors.append(f"{path} contains unknown field {name}") + for name, child in value.items(): + child_schema = properties.get(name) + if isinstance(child_schema, dict): + errors.extend( + _schema_errors(child, child_schema, root_schema, f"{path}.{name}", cache) + ) + if isinstance(value, list): + minimum_items = schema.get("minItems") + maximum_items = schema.get("maxItems") + if type(minimum_items) is int and len(value) < minimum_items: + errors.append(f"{path} has fewer items than the schema minimum") + if type(maximum_items) is int and len(value) > maximum_items: + errors.append(f"{path} has more items than the schema maximum") + if schema.get("uniqueItems") is True: + serialized = [ + json.dumps(item, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + for item in value + ] + if len(serialized) != len(set(serialized)): + errors.append(f"{path} contains duplicate schema items") + item_schema = schema.get("items") + if isinstance(item_schema, dict): + for index, item in enumerate(value): + errors.extend( + _schema_errors(item, item_schema, root_schema, f"{path}[{index}]", cache) + ) + return errors diff --git a/tools/release-certification/cryptad_certification/selftest.py b/tools/release-certification/cryptad_certification/selftest.py index ed2ba38fb0..3d4152ac11 100644 --- a/tools/release-certification/cryptad_certification/selftest.py +++ b/tools/release-certification/cryptad_certification/selftest.py @@ -21,6 +21,7 @@ "production-beta": ["cryptad_certification.tests.test_production_beta"], "go-no-go": ["cryptad_certification.tests.test_dashboard"], "stable-readiness": ["cryptad_certification.tests.test_stable"], + "stable-rc": ["cryptad_certification.tests.test_stable_rc"], "migration": ["cryptad_certification.tests.test_core"], } diff --git a/tools/release-certification/cryptad_certification/tests/test_core.py b/tools/release-certification/cryptad_certification/tests/test_core.py index fab556ac17..d8a57f57e0 100644 --- a/tools/release-certification/cryptad_certification/tests/test_core.py +++ b/tools/release-certification/cryptad_certification/tests/test_core.py @@ -19,6 +19,33 @@ class ManifestTest(unittest.TestCase): + def test_manifest_accepts_stable_rc_command_and_additive_inputs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = write_manifest( + root, + inputs={ + "previousStableRcFreeze": "previous-freeze.json", + "stableRcFreezeExceptions": "freeze-exceptions.json", + "stableCatalogOperations": "catalog-operations.json", + }, + policies={"stableRcFreezeMode": "refreeze"}, + commands={"stable-rc": {}}, + ) + + manifest = load_manifest(path, root) + + self.assertEqual( + { + "previousStableRcFreeze", + "stableRcFreezeExceptions", + "stableCatalogOperations", + }, + set(manifest.inputs), + ) + self.assertIn("stable-rc", manifest.commands) + self.assertEqual("refreeze", manifest.policies["stableRcFreezeMode"]) + def test_valid_manifest_creates_marked_release_workspace(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tools/release-certification/cryptad_certification/tests/test_integrations.py b/tools/release-certification/cryptad_certification/tests/test_integrations.py index eac3536ce9..e3d35e50d8 100644 --- a/tools/release-certification/cryptad_certification/tests/test_integrations.py +++ b/tools/release-certification/cryptad_certification/tests/test_integrations.py @@ -457,6 +457,380 @@ def test_security_drill_sidecars_are_scanned_before_copying(self) -> None: self.assertFalse((context.component_dir / "artifacts/inputs/drill.json").exists()) +class StableRcOrchestrationIntegrationTest(unittest.TestCase): + @staticmethod + def _write_production_envelope( + path: Path, + *, + release_id: str = "stable-candidate", + version: str = "283", + profile: str = "stable-review", + ) -> None: + write_json( + path, + EvidenceEnvelope( + kind="production-beta-release", + generated_at="2026-01-01T00:00:00Z", + subject={ + "releaseId": release_id, + "version": version, + "profile": profile, + "component": "production-beta", + }, + result={ + "status": "pass", + "decision": "go", + "promotionReady": True, + "exitCode": 0, + }, + counts={"evidence": 0, "blockers": 0, "warnings": 0, "waivers": 0}, + redaction={ + "status": "pass", + "findingCount": 0, + "findings": [], + "guarantees": {}, + }, + payload={ + "legacy": { + "schemaVersion": 1, + "releaseId": release_id, + "version": version, + "status": "pass", + "promotionReady": True, + "nonRelease": False, + } + }, + ).to_json(), + ) + + def test_stable_rc_runs_production_beta_before_the_native_engine_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest_path = write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "stable-review", + }, + policies={"stableRcFreezeMode": "first-freeze"}, + ) + + with ( + mock.patch.object(cli, "_execute_component", return_value=1) as execute_component, + mock.patch.object(cli, "execute_engine", return_value=1) as execute_engine, + contextlib.redirect_stdout(io.StringIO()), + ): + code = cli.main( + [ + "stable-rc", + "--manifest", + str(manifest_path), + "--workspace-root", + str(root), + ] + ) + + self.assertEqual(1, code) + execute_component.assert_called_once() + self.assertEqual("production-beta", execute_component.call_args.args[2]) + execute_engine.assert_called_once() + context, command, action = execute_engine.call_args.args + self.assertEqual("stable-rc", context.component) + self.assertEqual("stable-rc", command) + self.assertIsNone(action) + + def test_stable_rc_production_stage_uses_catalog_operations_artifact_timestamp(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifact_timestamp = "2026-07-14T00:00:00Z" + write_json( + root / "catalog-operations.json", + {"artifactTimestamp": artifact_timestamp}, + ) + manifest = load_manifest( + write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "stable-review", + }, + inputs={"stableCatalogOperations": "catalog-operations.json"}, + policies={ + "artifactBaseUri": "https://downloads.crypta.invalid/stable/", + "catalogChannel": "stable", + "stableRcFreezeMode": "first-freeze", + }, + ), + root, + ) + prepare_run_root(manifest) + context = prepare_context(root, manifest, "production-beta") + captured: list[str] = [] + + with mock.patch.object( + production_beta_release, + "main", + side_effect=lambda arguments: captured.extend(arguments) or 0, + ): + legacy._run_production_beta(context) + + self.assertEqual( + artifact_timestamp, + captured[captured.index("--stable-rc-artifact-timestamp") + 1], + ) + + def test_direct_stable_review_production_does_not_require_stable_rc_inputs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = load_manifest( + write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "stable-review", + }, + policies={"catalogChannel": "stable"}, + ), + root, + ) + prepare_run_root(manifest) + context = prepare_context(root, manifest, "production-beta") + captured: list[str] = [] + + with mock.patch.object( + production_beta_release, + "main", + side_effect=lambda arguments: captured.extend(arguments) or 0, + ): + legacy._run_production_beta(context) + + self.assertNotIn("--stable-rc-artifact-timestamp", captured) + + def test_stable_rc_regenerates_existing_candidate_bound_production_envelope(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest_path = write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "stable-review", + }, + policies={"stableRcFreezeMode": "first-freeze"}, + ) + manifest = load_manifest(manifest_path, root) + run_root = prepare_run_root(manifest) + summary = run_root / "production-beta/summary.json" + summary.parent.mkdir() + self._write_production_envelope(summary) + + with ( + mock.patch.object(cli, "_execute_component") as execute_component, + mock.patch.object(cli, "execute_engine", return_value=0) as execute_engine, + contextlib.redirect_stdout(io.StringIO()), + ): + code = cli.main( + [ + "stable-rc", + "--manifest", + str(manifest_path), + "--workspace-root", + str(root), + ] + ) + + self.assertEqual(0, code) + execute_component.assert_called_once() + execute_engine.assert_called_once() + + def test_stable_rc_rejects_every_configured_same_run_input(self) -> None: + same_run_inputs = ( + "productionBeta", + "goNoGo", + "appPlatform", + "ecosystemMatrix", + "releaseCertification", + "stableReadiness", + ) + for key in same_run_inputs: + with self.subTest(key=key), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = load_manifest( + write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "stable-review", + }, + inputs={key: f"{key}.json"}, + policies={"stableRcFreezeMode": "first-freeze"}, + ), + root, + ) + + with self.assertRaisesRegex(ValueError, rf"inputs\.{key}"): + cli._validate_stable_rc_manifest(manifest) + + def test_stable_rc_requires_explicit_freeze_lineage_pairing(self) -> None: + cases = ( + ("first-freeze", {}, None), + ( + "first-freeze", + {"previousStableRcFreeze": "previous-freeze.json"}, + "first-freeze mode cannot include", + ), + ("refreeze", {}, "refreeze mode requires"), + ( + "refreeze", + {"previousStableRcFreeze": "previous-freeze.json"}, + None, + ), + ) + for mode, inputs, expected_error in cases: + with self.subTest(mode=mode, inputs=inputs), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = load_manifest( + write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "stable-review", + }, + inputs=inputs, + policies={"stableRcFreezeMode": mode}, + ), + root, + ) + + if expected_error is None: + cli._validate_stable_rc_manifest(manifest) + else: + with self.assertRaisesRegex(ValueError, expected_error): + cli._validate_stable_rc_manifest(manifest) + + def test_stable_rc_rejects_missing_or_unknown_freeze_mode(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = load_manifest( + write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "stable-review", + }, + ), + root, + ) + + with self.assertRaisesRegex(ValueError, "policies.stableRcFreezeMode"): + cli._validate_stable_rc_manifest(manifest) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaisesRegex(ValueError, "stableRcFreezeMode"): + load_manifest( + write_manifest( + root, + policies={"stableRcFreezeMode": "verify"}, + ), + root, + ) + + def test_stable_rc_regenerates_wrong_candidate_internal_production_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest_path = write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "stable-review", + }, + policies={"stableRcFreezeMode": "first-freeze"}, + ) + manifest = load_manifest(manifest_path, root) + run_root = prepare_run_root(manifest) + summary = run_root / "production-beta/summary.json" + summary.parent.mkdir() + self._write_production_envelope(summary, release_id="another-candidate") + + with ( + mock.patch.object(cli, "_execute_component", return_value=0) as execute_component, + mock.patch.object(cli, "execute_engine", return_value=0), + contextlib.redirect_stdout(io.StringIO()), + ): + code = cli.main( + [ + "stable-rc", + "--manifest", + str(manifest_path), + "--workspace-root", + str(root), + ] + ) + + self.assertEqual(0, code) + execute_component.assert_called_once() + + def test_stable_rc_rejects_non_stable_profile_before_execution(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest_path = write_manifest( + root, + release={ + "id": "stable-candidate", + "version": "283", + "profile": "production-beta", + }, + policies={"stableRcFreezeMode": "first-freeze"}, + ) + + with ( + mock.patch.object(cli, "_execute_component") as execute_component, + mock.patch.object(cli, "execute_engine") as execute_engine, + contextlib.redirect_stderr(io.StringIO()), + ): + code = cli.main( + [ + "stable-rc", + "--manifest", + str(manifest_path), + "--workspace-root", + str(root), + ] + ) + + self.assertEqual(2, code) + execute_component.assert_not_called() + execute_engine.assert_not_called() + self.assertFalse((root / "build/release-certification/stable-candidate").exists()) + + def test_stable_rc_rejects_noncanonical_integer_versions(self) -> None: + for version in ("0", "00", "03", "+3", "3.0"): + with self.subTest(version=version), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = load_manifest( + write_manifest( + root, + release={ + "id": "stable-candidate", + "version": version, + "profile": "stable-review", + }, + policies={"stableRcFreezeMode": "first-freeze"}, + ), + root, + ) + with self.assertRaisesRegex(ValueError, "canonical positive integer"): + cli._validate_stable_rc_manifest(manifest) + + class AdapterIntegrationTest(unittest.TestCase): def test_failed_fallback_scan_removes_raw_legacy_output(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -1826,6 +2200,8 @@ def test_production_adapter_propagates_structured_manifest_fields(self) -> None: build_dir.mkdir(exist_ok=True) with tempfile.TemporaryDirectory(dir=build_dir) as directory: output = Path(directory) + reviewed_known_issues = output / "reviewed-public-beta-known-issues.json" + write_json(reviewed_known_issues, {"schemaVersion": 1, "knownIssues": []}) manifest = load_manifest( write_manifest( output, @@ -1835,6 +2211,9 @@ def test_production_adapter_propagates_structured_manifest_fields(self) -> None: "catalogChannel": "beta", }, requirements={"liveNetwork": True, "sandboxProviderTests": True}, + inputs={ + "publicBetaKnownIssues": reviewed_known_issues.relative_to(root).as_posix() + }, execution={ "fixtureEvidence": True, "skipGradle": True, @@ -1881,6 +2260,10 @@ def run(arguments: list[str]) -> int: ): self.assertIn(option, captured) self.assertEqual("75", captured[captured.index("--timeout-seconds") + 1]) + self.assertEqual( + reviewed_known_issues.resolve(), + Path(captured[captured.index("--public-beta-known-issues") + 1]), + ) self.assertNotIn("unsafe-v1.json", captured) self.assertEqual("simulated", captured[captured.index("--multi-node-mode") + 1]) @@ -1961,6 +2344,75 @@ def test_dashboard_and_stable_adapters_propagate_summary_inputs(self) -> None: class WorkflowIntegrationTest(unittest.TestCase): + def test_stable_rc_workflow_binds_reviewed_issues_and_previous_freeze(self) -> None: + workflow = ( + workspace_root() / ".github/workflows/stable-1.0-rc-release.yml" + ).read_text(encoding="utf-8") + + self.assertIn( + '_option_path(\n args,\n "--public-beta-known-issues"', + (workspace_root() / "tools/release-certification/cryptad_certification/legacy.py").read_text( + encoding="utf-8" + ), + ) + self.assertIn('"comparisonBaseline",', workflow) + self.assertIn('provenance_inputs.get("previousStableRcFreeze")', workflow) + self.assertIn('drift.get("comparisonBaseline")', workflow) + self.assertIn("stable_rc_freeze_mode:", workflow) + self.assertIn("GITHUB_RUN_ATTEMPT", workflow) + self.assertIn("latest_successful_run_id", workflow) + self.assertIn("latest_successful_head_sha", workflow) + self.assertIn("verify_latest_refreeze_parent", workflow) + self.assertIn("verify_refreeze_lineage_anchor", workflow) + self.assertIn("checks: write", workflow) + self.assertIn( + '"repos/$GITHUB_REPOSITORY/actions/runs/$candidate_run_id/attempts/$attempt"', + workflow, + ) + self.assertIn('.conclusion == "success"', workflow) + self.assertIn( + '.path == $workflow_path or (.path | startswith($workflow_path + "@"))', + workflow, + ) + self.assertIn( + 'cmp --silent "$supplied_freeze" "${authenticated_freezes[0]}"', + workflow, + ) + self.assertIn('if ! gh run download "$run_id"', workflow) + self.assertIn( + '"repos/$GITHUB_REPOSITORY/commits/$parent_sha/check-runs"', + workflow, + ) + self.assertIn('.app.slug == "github-actions"', workflow) + self.assertIn( + "Persist authenticated Stable RC freeze lineage", + workflow, + ) + self.assertIn( + '"repos/$GITHUB_REPOSITORY/check-runs"', + workflow, + ) + self.assertIn( + "stable-1.0-rc-freeze:$INPUT_RELEASE_ID:$INPUT_BUILD_VERSION:$GITHUB_RUN_ID:$GITHUB_RUN_ATTEMPT:$digest", + workflow, + ) + self.assertIn('stableRcFreezeMode: $freeze_mode', workflow) + self.assertIn('provenance.get("freezeMode") != freeze_mode', workflow) + self.assertIn( + 'candidate_freeze.get("productionDistributionDigest")', + workflow, + ) + self.assertIn( + 'distribution_name = f"crypta-stable-1.0-rc-{build_version}-product.tar.gz"', + workflow, + ) + self.assertIn( + 'f"crypta-stable-1.0-rc-{build_version}-product.tar.gz"', + workflow, + ) + self.assertNotIn('distribution_name = f"crypta-production-beta-', workflow) + self.assertNotIn("expected_build", workflow) + def test_release_workflows_bind_the_checked_out_project_version(self) -> None: root = workspace_root() production = (root / ".github/workflows/production-beta-release.yml").read_text( diff --git a/tools/release-certification/cryptad_certification/tests/test_stable_rc.py b/tools/release-certification/cryptad_certification/tests/test_stable_rc.py new file mode 100644 index 0000000000..3004c0d255 --- /dev/null +++ b/tools/release-certification/cryptad_certification/tests/test_stable_rc.py @@ -0,0 +1,1997 @@ +"""Offline self-tests for Stable 1.0 RC freeze and artifact security invariants.""" + +from __future__ import annotations + +import copy +import json +import tarfile +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +from cryptad_certification.engines import ( + production_beta_release, + stable_1_0_rc, + stable_1_0_rc_artifacts, + stable_1_0_rc_freeze, +) +from cryptad_certification.engines.stable_1_0_rc_artifacts import ( + create_deterministic_archive, + render_release_notes, + verify_deterministic_archive, + write_named_checksums, +) +from cryptad_certification.engines.stable_1_0_rc_core import ( + REQUIRED_PIPELINE_STAGES, + SAME_RUN_INPUT_KEYS, + SUPPORTING_VERIFIER_FILES, + LoadedInput, + SourceIdentity, + ValidationState, + ecosystem_matrix_is_promotable, + explicit_production_classification_errors, + freshness_error, + load_candidate_inputs, + load_raw_input, + placeholder_findings, + release_certification_is_promotable, + semantic_digest, + validate_catalog_operations, + validate_live_inputs, + validate_production_beta, +) +from cryptad_certification.engines.stable_1_0_rc_freeze import ( + assemble_freeze, + build_limitations_freeze, + compare_freezes, + freeze_content_digest, + merge_accepted_exception_history, + producer_identity_digest, + producer_identity_digests, + stable_surface_is_exact, + validate_exception_collection, + validate_freeze_shape, +) +from cryptad_certification.engines.stable_1_0_readiness_policy import safe_limitation +from cryptad_certification.redaction import scan_value + + +def _freeze() -> dict[str, object]: + def digest(character: str) -> str: + return "sha256:" + character * 64 + + app_ids = ( + "publisher", + "queue-manager", + "site-publisher", + "profile-publisher", + "social-inbox", + "feed-reader", + "trust-graph", + ) + profile_ids = ( + "crypta.profile.v1", + "crypta.feed.snapshot.v1", + "crypta.trust.statement.v1", + "crypta.social.message.v1", + "crypta.social.outbox.v1", + ) + apps = [ + { + "appId": app_id, + "version": "283", + "channel": "stable", + "supportStatus": "supported", + "deprecationStatus": "none", + "supportLevel": "maintained", + "replacementAppId": None, + "bundleDigest": digest("1"), + "bundleSizeBytes": 1024, + "appSigningKeyId": "app-production-2026", + "reviewReceiptDigest": digest("2"), + "reviewerKeyId": "reviewer-production-2026", + "manifestDigest": digest("3"), + "declaredPermissionSetDigest": digest("4"), + "targetApiStability": "stable", + "apiCompatibilityResult": "pass", + "apiCompatibilityEvidenceDigest": digest("5"), + "appDataSchemaVersion": 1, + "migrationReadiness": "pass", + "backupRestore": "supported", + "betaReadinessEvidenceDigest": digest("6"), + "supportMetadataDigest": digest("7"), + "supportUri": "https://github.com/crypta-network/cryptad/issues", + "redactedDiagnosticsReadiness": "redacted-summary-only", + "allowedStableLimitationId": None, + } + for app_id in app_ids + ] + profiles = [ + { + "profileId": profile_id, + "version": 1, + "status": "stable", + "descriptorDigest": digest("8"), + "canonicalizationRulesDigest": digest("9"), + "maximumSizePolicy": { + "documentBytes": 65536, + "signedPayloadBytes": 32768, + }, + "signaturePayloadRules": { + "signed": True, + "signingDomain": "crypta.stable-1.0", + }, + "parserValidatorCompatibilityEvidenceDigest": digest("a"), + } + for profile_id in profile_ids + ] + value: dict[str, object] = { + "schemaVersion": 1, + "kind": "stable-1.0-rc-freeze", + "stableMilestone": "1.0", + "candidate": { + "releaseId": "stable-rc-283", + "buildVersion": "283", + "sourceCommit": "a" * 40, + "sourceRef": "commit:" + "a" * 40, + "sourceProvenanceDigest": digest("b"), + "generationTool": "stable-1.0-rc", + "generationToolVersion": 1, + "productionBetaSummaryDigest": digest("c"), + "productionDistributionDigest": digest("c"), + "stableReadinessSummaryDigest": digest("d"), + "releaseCertificationSummaryDigest": digest("e"), + "goNoGoSummaryDigest": digest("f"), + "ecosystemMatrixDigest": digest("0"), + "releaseHistoryDigest": digest("1"), + "previousCandidateDigest": digest("2"), + "liveNetworkDigest": digest("3"), + "multiNodeSoakDigest": digest("4"), + "networkScaleSoakDigest": digest("5"), + "securityDrillDigest": digest("6"), + "thirdPartyIntakeDigest": digest("7"), + "catalogOperationsDigest": digest("8"), + }, + "platformApi": { + "baselineName": "1.0", + "baselineContractVersion": 23, + "baselineDigest": digest("9"), + "currentContractVersion": 23, + "currentContractDigest": digest("a"), + "compatibilityWindowPolicyDigest": digest("b"), + "stableCapabilityCount": 12, + "stableEndpointCount": 34, + "experimentalCapabilityCount": 2, + "experimentalEndpointCount": 3, + "stableBreakingChangeVerification": "pass", + "verificationReportDigest": digest("c"), + }, + "stableCatalog": { + "catalogId": "crypta-first-party", + "channel": "stable", + "catalogVersion": 5, + "edition": 7, + "revision": 7, + "catalogDigest": digest("d"), + "signatureDigest": digest("e"), + "signatureAliasDigest": digest("f"), + "catalogSigningKeyId": "catalog-production-2026", + "artifactTimestamp": "2026-07-14T00:00:00Z", + "keyRotationStatus": {"status": "complete", "compromised": False}, + "primaryHealth": { + "status": "pass", + "signatureVerified": True, + "revision": 7, + "digest": digest("d"), + }, + "mirrorHealth": [ + { + "status": "pass", + "signatureVerified": True, + "revision": 7, + "digest": digest("d"), + "transportFallbackOnly": True, + } + ], + "verifiedRollback": { + "status": "pass", + "signatureVerified": True, + "revision": 6, + "digest": digest("0"), + }, + "securityAdvisoryCount": 0, + "denylistCount": 0, + "orderedEntries": [{"appId": app_id, "version": "283"} for app_id in app_ids], + }, + "firstPartyApps": apps, + "contentFormatProfiles": profiles, + "limitationsAndPolicy": { + "stableReadinessPolicyDigest": digest("1"), + "allowedLimitations": [], + "allowedLimitationsDigest": semantic_digest([]), + "allowedLimitationCount": 0, + "disallowedLimitationCount": 0, + "betaOnlyLimitationCount": 0, + "publicBetaKnownIssuesDigest": digest("2"), + "stableKnownLimitationsDigest": digest("3"), + "securityDrillSummaryDigest": digest("4"), + "legacyPluginAdminFreezeEvidenceDigest": digest("5"), + "supportFeedbackReadinessEvidenceDigest": digest("6"), + }, + "acceptedFreezeExceptions": [], + } + value["contentDigest"] = freeze_content_digest(value) + return value + + +def _exception(before: str, after: str) -> dict[str, object]: + now = datetime.now(timezone.utc) + return { + "exceptionId": "freeze-blocker-283", + "releaseId": "stable-rc-283", + "buildVersion": "283", + "affectedSection": "firstPartyApps", + "affectedItem": "publisher", + "beforeDigest": before, + "afterDigest": after, + "reason": "Correct a release-blocking app bundle defect.", + "issueKind": "blocker", + "issueReference": "CRYPTA-283", + "owner": "release-owner", + "approver": "release-approver", + "createdAt": (now - timedelta(minutes=1)).isoformat(), + "expiresAt": (now + timedelta(hours=1)).isoformat(), + "requiredRerunScope": ["freeze-generation", "packaging", "stable-rc-final-gates"], + "finalVerificationResult": "pass", + } + + +def _collection(record: dict[str, object]) -> dict[str, object]: + return { + "schemaVersion": 1, + "kind": "stable-1.0-rc-freeze-exceptions", + "authorizationRole": "stable-release-manager", + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + "exceptions": [record], + } + + +def _production_summary() -> dict[str, object]: + return { + "status": "pass", + "promotionReady": True, + "nonRelease": False, + "fixtureOnly": False, + "simulatedOnly": False, + "releaseId": "stable-rc-283", + "version": "283", + "signingProfile": { + "kind": "production", + "generatedTestKeys": False, + "privateKeyMaterialIncluded": False, + }, + "workspaceStatusKnown": True, + "dirtyWorkspace": False, + "pipelineStages": {stage: {"status": "pass"} for stage in REQUIRED_PIPELINE_STAGES}, + } + + +def _catalog_operations() -> dict[str, object]: + now = datetime.now(timezone.utc).isoformat() + digest = "sha256:" + "a" * 64 + health = {"status": "pass", "signatureVerified": True, "revision": 7, "digest": digest} + return { + "schemaVersion": 1, + "kind": "stable-1.0-rc-catalog-operations", + "generatedAt": now, + "artifactTimestamp": now, + "releaseId": "stable-rc-283", + "buildVersion": "283", + "sourceCommit": "a" * 40, + "status": "pass", + "fixtureOnly": False, + "simulatedOnly": False, + "nonRelease": False, + "catalogId": "crypta-first-party", + "channel": "stable", + "revision": 7, + "catalogDigest": digest, + "signatureDigest": "sha256:" + "b" * 64, + "signingKeyId": "catalog-production-2026", + "keyRotation": {"status": "complete", "compromised": False}, + "primary": copy.deepcopy(health), + "mirrors": [{**health, "transportFallbackOnly": True}], + "rollback": {"status": "pass", "signatureVerified": True, "revision": 6, "digest": "sha256:" + "c" * 64}, + "advisoryCount": 0, + "denylistCount": 0, + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + } + + +def _readiness_summary(mutation: str) -> dict[str, object]: + summary: dict[str, object] = { + "schemaVersion": 1, + "kind": "stable-1.0-readiness", + "tool": "stable-1.0-readiness", + "releaseId": "stable-rc-283", + "status": "fail" if mutation == "not-ready" else "pass", + "decision": "not-ready" if mutation == "not-ready" else "ready", + "stableReady": mutation != "not-ready", + "blockerCount": 0, + "warningCount": 0, + "allowedLimitationCount": 0, + "disallowedLimitationCount": 0, + "blockers": [], + "warnings": [], + "allowedLimitations": [], + "disallowedLimitations": [], + "domains": [], + "evidence": [], + "redaction": {"status": "pass", "findingCount": 0, "criticalFindingCount": 0, "findings": []}, + } + if mutation == "wrong-release": + summary["releaseId"] = "another-candidate" + return summary + + +class StableRcFreezeTest(unittest.TestCase): + def test_candidate_built_launcher_is_selected_for_the_host_platform(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + launchers = root / "build/crypta-app-launcher/bin" + launchers.mkdir(parents=True) + posix = launchers / "crypta-app" + batch = launchers / "crypta-app.bat" + posix.write_text("#!/bin/sh\n", encoding="utf-8") + batch.write_text("@echo off\r\n", encoding="utf-8") + + with ( + mock.patch.object(stable_1_0_rc_freeze.platform, "system", return_value="Linux"), + mock.patch.object(stable_1_0_rc_freeze.os, "access", return_value=True), + ): + self.assertEqual(posix.resolve(), stable_1_0_rc_freeze.find_crypta_app(root)) + with mock.patch.object( + stable_1_0_rc_freeze.platform, + "system", + return_value="Windows", + ): + self.assertEqual(batch.resolve(), stable_1_0_rc_freeze.find_crypta_app(root)) + + def test_candidate_built_posix_launcher_must_be_executable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + launcher = root / "build/crypta-app-launcher/bin/crypta-app" + launcher.parent.mkdir(parents=True) + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + + with ( + mock.patch.object(stable_1_0_rc_freeze.platform, "system", return_value="Linux"), + mock.patch.object(stable_1_0_rc_freeze.os, "access", return_value=False), + self.assertRaisesRegex(ValueError, "not executable"), + ): + stable_1_0_rc_freeze.find_crypta_app(root) + + def test_candidate_loader_rejects_every_external_same_run_override(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + for key in SAME_RUN_INPUT_KEYS: + with self.subTest(key=key): + context = SimpleNamespace( + workspace_root=root, + manifest=SimpleNamespace(inputs={key: f"{key}.json"}), + ) + with self.assertRaisesRegex(ValueError, rf"inputs\.{key}"): + load_candidate_inputs(context, root) + + def test_candidate_loader_rejects_symlinked_embedded_evidence_parents(self) -> None: + for linked_parent in ("evidence", "reports"): + with self.subTest(linked_parent=linked_parent), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + native_root = root / "native" + native_root.mkdir() + outside = root / "outside" + outside.mkdir() + if linked_parent == "evidence": + (outside / "app-platform-smoke.json").write_text("{}\n", encoding="utf-8") + (native_root / "evidence").symlink_to(outside, target_is_directory=True) + else: + evidence = native_root / "evidence" + evidence.mkdir() + (evidence / "app-platform-smoke.json").write_text("{}\n", encoding="utf-8") + (evidence / "ecosystem-certification-matrix.json").write_text( + "{}\n", encoding="utf-8" + ) + (outside / "go-no-go-dashboard.json").write_text("{}\n", encoding="utf-8") + (native_root / "reports").symlink_to(outside, target_is_directory=True) + context = SimpleNamespace( + workspace_root=root, + manifest=SimpleNamespace(inputs={}), + ) + production = LoadedInput( + "productionBeta", + root / "summary.json", + {"status": "pass"}, + "sha256:" + "1" * 64, + ) + + with ( + mock.patch( + "cryptad_certification.engines.stable_1_0_rc_core.load_existing_input", + return_value=production, + ), + self.assertRaisesRegex(ValueError, "symlink"), + ): + load_candidate_inputs(context, native_root) + + def test_freeze_schema_closed_objects_and_local_references_are_well_formed(self) -> None: + root = Path(__file__).resolve().parents[4] + schema = json.loads( + (root / "tools/release-certification/schemas/stable-1.0-rc-freeze-v1.schema.json").read_text( + encoding="utf-8" + ) + ) + definitions = schema["$defs"] + + def assert_schema_node(node: object, path: str) -> None: + if isinstance(node, dict): + reference = node.get("$ref") + if isinstance(reference, str) and reference.startswith("#/$defs/"): + self.assertIn(reference.removeprefix("#/$defs/"), definitions, path) + if node.get("type") == "object" and node.get("additionalProperties") is False: + required = node.get("required", []) + properties = node.get("properties", {}) + self.assertTrue(set(required).issubset(properties), path) + for key, value in node.items(): + assert_schema_node(value, f"{path}.{key}") + elif isinstance(node, list): + for index, value in enumerate(node): + assert_schema_node(value, f"{path}[{index}]") + + assert_schema_node(schema, "$") + self.assertIn("limitationsAndPolicy", definitions) + self.assertIn( + "parserValidatorCompatibilityEvidenceDigest", + definitions["contentProfile"]["properties"], + ) + + def test_catalog_operations_reject_uncontracted_fields_before_freezing(self) -> None: + catalog_operations = _catalog_operations() + catalog_operations["primary"]["latencyMs"] = 12 # type: ignore[index] + catalog_operations["mirrors"][0]["region"] = "test-region" # type: ignore[index] + catalog_operations["rollback"]["attemptCount"] = 1 # type: ignore[index] + state = ValidationState() + + validate_catalog_operations( + catalog_operations, + "stable-rc-283", + "283", + datetime.now(timezone.utc), + state, + ) + + frozen = stable_1_0_rc_freeze._catalog_operations_freeze(catalog_operations) # noqa: SLF001 + + self.assertTrue(state.blockers) + summaries = " ".join(str(blocker["summary"]) for blocker in state.blockers) + self.assertIn("unknown field latencyMs", summaries) + self.assertIn("unknown field region", summaries) + self.assertIn("unknown field attemptCount", summaries) + self.assertEqual({"status", "compromised"}, set(frozen["keyRotationStatus"])) + self.assertEqual( + {"status", "signatureVerified", "revision", "digest"}, + set(frozen["primaryHealth"]), + ) + self.assertEqual( + {"status", "signatureVerified", "revision", "digest", "transportFallbackOnly"}, + set(frozen["mirrorHealth"][0]), + ) + self.assertEqual( + {"status", "signatureVerified", "revision", "digest"}, + set(frozen["verifiedRollback"]), + ) + + def test_catalog_operations_accept_a_verified_distinct_prior_revision(self) -> None: + state = ValidationState() + + validate_catalog_operations( + _catalog_operations(), + "stable-rc-283", + "283", + datetime.now(timezone.utc), + state, + ) + + self.assertEqual([], state.blockers) + + def test_catalog_operations_reject_non_production_classification(self) -> None: + catalog_operations = _catalog_operations() + catalog_operations["nonProduction"] = True + state = ValidationState() + + validate_catalog_operations( + catalog_operations, + "stable-rc-283", + "283", + datetime.now(timezone.utc), + state, + ) + + self.assertTrue( + any("unknown field nonProduction" in blocker["summary"] for blocker in state.blockers) + ) + + def test_catalog_artifact_timestamp_must_precede_operations_evidence(self) -> None: + catalog_operations = _catalog_operations() + catalog_operations["generatedAt"] = "2026-07-14T00:00:00Z" + catalog_operations["artifactTimestamp"] = "2026-07-15T00:00:00Z" + state = ValidationState() + + validate_catalog_operations( + catalog_operations, + "stable-rc-283", + "283", + datetime(2026, 7, 15, 12, tzinfo=timezone.utc), + state, + ) + + self.assertTrue( + any( + "artifactTimestamp cannot be later than generatedAt" in blocker["summary"] + for blocker in state.blockers + ) + ) + + def test_catalog_rollback_must_bind_a_distinct_older_revision(self) -> None: + cases = ( + (7, "sha256:" + "a" * 64, "precede"), + (8, "sha256:" + "c" * 64, "precede"), + (6, "sha256:" + "a" * 64, "distinct prior"), + ) + for revision, digest, expected in cases: + with self.subTest(revision=revision, digest=digest): + catalog_operations = _catalog_operations() + catalog_operations["rollback"]["revision"] = revision # type: ignore[index] + catalog_operations["rollback"]["digest"] = digest # type: ignore[index] + state = ValidationState() + + validate_catalog_operations( + catalog_operations, + "stable-rc-283", + "283", + datetime.now(timezone.utc), + state, + ) + + self.assertTrue( + any(expected in blocker["summary"] for blocker in state.blockers), + state.blockers, + ) + + def test_platform_api_freeze_rejects_symlinked_compatibility_policy(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + contracts = root / "docs/platform-api/contracts" + contracts.mkdir(parents=True) + (contracts / "platform-api-1.0-baseline.json").write_text("{}\n", encoding="utf-8") + outside = root / "outside-policy.md" + outside.write_text("# External policy\n", encoding="utf-8") + (root / "docs/platform-api-compatibility-support-window.md").symlink_to(outside) + context = SimpleNamespace(workspace_root=root) + + with self.assertRaisesRegex(ValueError, "symlink"): + stable_1_0_rc_freeze.build_platform_api_freeze( + context, + root / "native", + root / "output", + ValidationState(), + ) + + def test_same_run_producer_identities_exclude_only_execution_volatility(self) -> None: + digest_one = "sha256:" + "1" * 64 + digest_two = "sha256:" + "2" * 64 + + def inputs( + generated_at: str, + digest: str, + duration: int, + temporary_suffix: str, + ) -> dict[str, LoadedInput]: + values = { + key: {"status": "pass", "generatedAt": generated_at} + for key in SAME_RUN_INPUT_KEYS + } + values["productionBeta"]["startedAt"] = generated_at + values["productionBeta"]["commands"] = [ + { + "name": "gradle-build", + "args": [ + "/python3", + "--out-dir", + ( + "/cryptad-production-beta-" + f"{temporary_suffix}/release-certification" + ), + "--app-platform-summary", + ( + "\\cryptad-production-beta-" + f"{temporary_suffix}\\release-certification\\" + "app-platform-smoke\\summary.json" + ), + ], + "exit_code": 0, + "duration_ms": duration, + "stdout_tail": "BUILD SUCCESSFUL in a volatile duration", + "stderr_tail": "", + } + ] + values["stableReadiness"]["inputs"] = { + reference: {"status": "present", "sha256": digest} + for reference in stable_1_0_rc_freeze.STABLE_READINESS_SAME_RUN_REFERENCES + } + values["productionBeta"]["materializedInput"] = ( + "/cryptad-production-beta-" + f"{temporary_suffix}/release-certification/inputs/third-party.json" + ) + return { + key: LoadedInput(key, Path(f"{key}.json"), value, digest) + for key, value in values.items() + } + + first = inputs("2026-07-14T00:00:00Z", digest_one, 1, "t3hwetmy") + second = inputs("2026-07-14T00:05:00Z", digest_two, 999, "a9b8c7d6") + first["releaseCertification"].value["metadata"] = { + "gitCommit": "a" * 40, + "gitDirty": "false", + "gitBranch": "release/283", + "githubActions": "true", + "githubWorkflow": "Stable 1.0 RC", + "githubRunId": "1000", + "githubRunAttempt": "1", + "githubRef": "refs/heads/release/283", + "githubSha": "a" * 40, + "runnerOs": "Linux", + } + second["releaseCertification"].value["metadata"] = { + "gitCommit": "a" * 40, + "gitDirty": "false", + "gitBranch": "detached-local-checkout", + } + + first_identities = producer_identity_digests(first) + second_identities = producer_identity_digests(second) + self.assertEqual(first_identities, second_identities) + + second["productionBeta"].value["commands"][0]["args"].append( # type: ignore[index] + "--semantic-option-change" + ) + self.assertNotEqual( + first_identities["productionBeta"], + producer_identity_digests(second)["productionBeta"], + ) + second["productionBeta"].value["commands"][0]["args"].pop() # type: ignore[index] + + external_keys = ( + "liveNetwork", + "multiNodeSoak", + "networkScaleSoak", + "previousCandidate", + "releaseHistory", + "securityDrills", + "thirdPartyIntake", + ) + for collection in (first, second): + collection.update( + { + key: LoadedInput(key, Path(f"{key}.json"), {"status": "pass"}, digest_one) + for key in external_keys + } + ) + context = SimpleNamespace( + manifest=SimpleNamespace( + release=SimpleNamespace(release_id="stable-rc-283", version="283") + ) + ) + source = SourceIdentity("a" * 40, "commit:" + "a" * 40, digest_one) + + def freeze(collection: dict[str, LoadedInput]) -> dict[str, object]: + baseline = _freeze() + return assemble_freeze( + context=context, + source=source, + inputs=collection, + catalog_operations=LoadedInput( + "stableCatalogOperations", + Path("catalog.json"), + {"status": "pass"}, + digest_one, + ), + platform_api=copy.deepcopy(baseline["platformApi"]), + stable_catalog=copy.deepcopy(baseline["stableCatalog"]), + first_party_apps=copy.deepcopy(baseline["firstPartyApps"]), + content_profiles=copy.deepcopy(baseline["contentFormatProfiles"]), + limitations=copy.deepcopy(baseline["limitationsAndPolicy"]), + accepted_exceptions=[], + production_distribution_digest=digest_one, + ) + + first_freeze = freeze(first) + second_freeze = freeze(second) + self.assertEqual("no-drift", compare_freezes(first_freeze, second_freeze, [])["status"]) + + second["releaseCertification"].value["metadata"]["gitCommit"] = "b" * 40 + commit_changed_identities = producer_identity_digests(second) + self.assertNotEqual( + first_identities["releaseCertification"], + commit_changed_identities["releaseCertification"], + ) + self.assertNotEqual( + first_identities["stableReadiness"], + commit_changed_identities["stableReadiness"], + ) + second["releaseCertification"].value["metadata"]["gitCommit"] = "a" * 40 + second["ecosystemMatrix"].value["releaseBlockerCount"] = 1 + changed_identities = producer_identity_digests(second) + self.assertNotEqual( + first_identities["ecosystemMatrix"], + changed_identities["ecosystemMatrix"], + ) + self.assertNotEqual( + first_identities["stableReadiness"], + changed_identities["stableReadiness"], + ) + + def test_external_evidence_keeps_its_exact_file_digest(self) -> None: + digest = "sha256:" + "3" * 64 + loaded = LoadedInput( + "securityDrills", + Path("security-drills.json"), + {"status": "pass", "generatedAt": "2026-07-14T00:00:00Z"}, + digest, + ) + + self.assertEqual(digest, producer_identity_digest(loaded)) + + def test_warning_prerequisites_remain_promotable_when_their_gate_fields_pass(self) -> None: + self.assertTrue( + release_certification_is_promotable( + {"status": "warn", "releaseCandidatePassed": True} + ) + ) + self.assertTrue( + ecosystem_matrix_is_promotable( + {"status": "warn", "releaseBlockerCount": 0} + ) + ) + self.assertFalse( + release_certification_is_promotable( + {"status": "warn", "releaseCandidatePassed": False} + ) + ) + self.assertFalse( + ecosystem_matrix_is_promotable( + {"status": "warn", "releaseBlockerCount": 1} + ) + ) + + def test_current_evidence_is_candidate_bound_without_expiring_historical_inputs(self) -> None: + now = datetime.now(timezone.utc) + fresh = (now - timedelta(hours=1)).isoformat() + historical = (now - timedelta(days=365)).isoformat() + digest = "sha256:" + "4" * 64 + + def loaded(key: str, value: dict[str, object]) -> LoadedInput: + return LoadedInput(key, Path(f"{key}.json"), value, digest) + + inputs = { + "liveNetwork": loaded( + "liveNetwork", + {"status": "pass", "generatedAt": fresh, "mode": "live"}, + ), + "multiNodeSoak": loaded( + "multiNodeSoak", + {"status": "pass", "generatedAt": fresh, "mode": "hybrid"}, + ), + "networkScaleSoak": loaded( + "networkScaleSoak", + {"status": "pass", "generatedAt": fresh, "mode": "live-rc-soak"}, + ), + "securityDrills": loaded( + "securityDrills", + {"status": "pass", "generatedAt": fresh}, + ), + "thirdPartyIntake": loaded( + "thirdPartyIntake", + { + "status": "pass", + "releaseId": "stable-rc-283", + "buildVersion": "283", + "generatedAt": fresh, + "fixtureOnly": False, + "simulatedOnly": False, + "nonRelease": False, + "nonProduction": False, + }, + ), + "previousCandidate": loaded( + "previousCandidate", + { + "status": "pass", + "releaseId": "cryptad-beta-269", + "generatedAt": historical, + }, + ), + "releaseHistory": loaded( + "releaseHistory", + { + "status": "pass", + "releaseId": "cryptad-beta-269", + "generatedAt": historical, + }, + ), + "appPlatform": loaded( + "appPlatform", + { + "status": "pass", + "generatedAt": fresh, + "evidence": [{"id": "apphost.sandbox-provider", "status": "pass"}], + }, + ), + } + state = ValidationState() + + validate_live_inputs(inputs, "stable-rc-283", "283", now, state) + + self.assertEqual([], state.blockers) + inputs["multiNodeSoak"].value["generatedAt"] = historical + stale_state = ValidationState() + validate_live_inputs(inputs, "stable-rc-283", "283", now, stale_state) + self.assertTrue( + any( + blocker["id"] == "stable-1.0-rc.evidence.multiNodeSoak" + for blocker in stale_state.blockers + ) + ) + inputs["multiNodeSoak"].value["generatedAt"] = fresh + inputs["liveNetwork"].value["releaseId"] = "wrong-current-candidate" + wrong_candidate_state = ValidationState() + validate_live_inputs( + inputs, + "stable-rc-283", + "283", + now, + wrong_candidate_state, + ) + self.assertTrue( + any( + blocker["id"] == "stable-1.0-rc.evidence.liveNetwork" + for blocker in wrong_candidate_state.blockers + ) + ) + + inputs["liveNetwork"].value["releaseId"] = "stable-rc-283" + inputs["thirdPartyIntake"].value["buildVersion"] = "282" + wrong_build_state = ValidationState() + validate_live_inputs( + inputs, + "stable-rc-283", + "283", + now, + wrong_build_state, + ) + self.assertTrue( + any( + blocker["id"] == "stable-1.0-rc.evidence.thirdPartyIntake" + and "buildVersion" in blocker["summary"] + for blocker in wrong_build_state.blockers + ) + ) + + del inputs["thirdPartyIntake"].value["buildVersion"] + missing_build_state = ValidationState() + validate_live_inputs( + inputs, + "stable-rc-283", + "283", + now, + missing_build_state, + ) + self.assertTrue( + any( + blocker["id"] == "stable-1.0-rc.evidence.thirdPartyIntake" + and "buildVersion" in blocker["summary"] + for blocker in missing_build_state.blockers + ) + ) + + def test_third_party_release_proof_requires_explicit_false_classification_flags(self) -> None: + from cryptad_certification.engines.production_beta_release_evidence import ( + third_party_intake_summary_is_non_release, + ) + + flags = { + "fixtureOnly": False, + "simulatedOnly": False, + "nonRelease": False, + "nonProduction": False, + } + self.assertEqual([], explicit_production_classification_errors(flags, "thirdPartyIntake")) + self.assertFalse( + third_party_intake_summary_is_non_release(flags, stable_rc=True) + ) + for field in tuple(flags): + with self.subTest(field=field): + omitted = {key: value for key, value in flags.items() if key != field} + self.assertTrue( + any( + field in error + for error in explicit_production_classification_errors( + omitted, + "thirdPartyIntake", + ) + ) + ) + self.assertTrue( + third_party_intake_summary_is_non_release( + omitted, + stable_rc=True, + ) + ) + + def test_policy_allowed_limitation_is_frozen_and_prominent_in_release_notes(self) -> None: + digest = "sha256:" + "1" * 64 + limitation = safe_limitation({ + "id": "stable-1.0.local-rc-scope", + "title": "Local RC scope", + "summary": "Trust Graph remains explicitly local RC.", + "classification": "allowed-for-stable-1.0", + "status": "open", + "category": "bounded-scope", + "boundedBy": "Local node operation only.", + "owner": "crypta-core", + "evidenceIds": ["stable-1.0.known-limitations"], + }) + state = ValidationState() + frozen = build_limitations_freeze( + {"allowedLimitations": [limitation], "disallowedLimitationCount": 0}, + LoadedInput("policy", Path("policy.json"), {"allowedLimitationCategories": ["bounded-scope"]}, digest), + LoadedInput("known", Path("known.json"), {}, digest), + LoadedInput("public", Path("public.json"), {}, digest), + {"evidence": []}, + digest, + state, + ) + freeze = _freeze() + freeze["limitationsAndPolicy"] = frozen + notes = render_release_notes(freeze, {"releaseId": "stable-rc-282"}, [], []) + + self.assertEqual([], state.blockers) + self.assertEqual([limitation], frozen["allowedLimitations"]) + self.assertIn("stable-1.0.local-rc-scope", notes) + self.assertIn("Trust Graph remains explicitly local RC.", notes) + + def test_provenance_binds_the_exact_previous_freeze(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + component = root / "run/stable-rc" + out = component / "artifacts/legacy" + out.mkdir(parents=True) + freeze = _freeze() + (out / "stable-1.0-rc-freeze.json").write_text( + json.dumps(freeze, sort_keys=True) + "\n", + encoding="utf-8", + ) + production_archive = out / "crypta-production-beta-283.tar.gz" + production_archive.write_bytes(b"production distribution") + previous_path = root / "previous-freeze.json" + previous_path.write_text(json.dumps(freeze, sort_keys=True) + "\n", encoding="utf-8") + previous = LoadedInput( + "previousStableRcFreeze", + previous_path, + freeze, + stable_1_0_rc.file_digest(previous_path), + ) + digest = "sha256:" + "1" * 64 + context = SimpleNamespace( + component_dir=component, + manifest=SimpleNamespace( + release=SimpleNamespace(release_id="stable-rc-283", version="283") + ), + ) + source = SourceIdentity("a" * 40, "commit:" + "a" * 40, digest) + inputs = { + "stableReadiness": LoadedInput( + "stableReadiness", + root / "stable-readiness.json", + {"status": "pass"}, + digest, + ) + } + catalog = LoadedInput( + "stableCatalogOperations", + root / "catalog.json", + {"status": "pass"}, + digest, + ) + + provenance = stable_1_0_rc._provenance( # noqa: SLF001 + context, + source, + freeze, + inputs, + catalog, + previous, + production_archive, + "refreeze", + ) + + expected = { + "fileDigest": previous.digest, + "contentDigest": freeze["contentDigest"], + } + self.assertEqual(expected, provenance["comparisonBaseline"]) + self.assertEqual("refreeze", provenance["freezeMode"]) + self.assertEqual(previous.digest, provenance["inputs"]["previousStableRcFreeze"]) + self.assertEqual( + expected, + stable_1_0_rc._comparison_baseline_binding(previous), # noqa: SLF001 + ) + + def test_release_notes_populate_every_checked_in_template_section(self) -> None: + freeze = _freeze() + + notes = render_release_notes( + freeze, + {"releaseId": "stable-rc-282", "version": "282", "status": "pass"}, + [], + [], + public_known_issues={"knownIssues": []}, + stable_readiness={"decision": "ready", "stableReady": True}, + drift={"status": "no-drift", "regenerated": False}, + ) + + self.assertTrue(notes.startswith("\n")) + self.assertEqual(12, notes.count("## ")) + self.assertNotIn("{{", notes) + self.assertNotIn("missing", notes) + self.assertIn(str(freeze["contentDigest"]), notes) + self.assertIn( + str(freeze["candidate"]["productionDistributionDigest"]), # type: ignore[index] + notes, + ) + for app in freeze["firstPartyApps"]: # type: ignore[union-attr] + self.assertIn(str(app["appId"]), notes) + for profile in freeze["contentFormatProfiles"]: # type: ignore[union-attr] + self.assertIn(str(profile["profileId"]), notes) + + def test_release_notes_reject_an_incomplete_template(self) -> None: + with tempfile.TemporaryDirectory() as directory: + template = Path(directory).resolve() / "stable-1.0-rc-release-notes.md" + source = stable_1_0_rc_artifacts._RELEASE_NOTES_TEMPLATE.read_text(encoding="utf-8") # noqa: SLF001 + template.write_text(source.replace("{{known_issues}}", "None."), encoding="utf-8") + + with ( + mock.patch.object(stable_1_0_rc_artifacts, "_RELEASE_NOTES_TEMPLATE", template), + self.assertRaisesRegex(ValueError, "incomplete or out of order"), + ): + render_release_notes(_freeze(), {"releaseId": "stable-rc-282"}, [], []) + + def test_release_notes_require_the_complete_frozen_exception_history(self) -> None: + freeze = _freeze() + record = _exception("sha256:" + "1" * 64, "sha256:" + "2" * 64) + freeze["acceptedFreezeExceptions"] = [record] + freeze["contentDigest"] = freeze_content_digest(freeze) + + with self.assertRaisesRegex(ValueError, "exception history"): + render_release_notes(freeze, {"releaseId": "stable-rc-282"}, [], []) + + notes = render_release_notes( + freeze, + {"releaseId": "stable-rc-282"}, + [], + [record], + ) + self.assertIn(str(record["exceptionId"]), notes) + + def test_only_valid_applied_waivers_are_reported_as_accepted(self) -> None: + accepted = { + "id": "waiver-accepted", + "evidenceId": "app-store.submission-cli", + "active": True, + "appliesToMode": True, + "externalRiskAccepted": False, + "validationErrors": [], + "usedBy": ["release-gate"], + } + inactive = {**accepted, "id": "waiver-inactive", "active": False} + unused = {**accepted, "id": "waiver-unused", "usedBy": []} + invalid_unknown = { + **accepted, + "id": "waiver-invalid-unknown", + "evidenceId": "external.unknown-gate", + "active": False, + "validationErrors": [ + "evidenceId is unknown and externalRiskAccepted is not true" + ], + } + + result = stable_1_0_rc._accepted_waivers( # noqa: SLF001 + {"waivers": [accepted, inactive, unused, invalid_unknown]} + ) + + self.assertEqual([accepted], result) + + def test_no_go_marks_final_decision_evidence_failed(self) -> None: + digest = "sha256:" + "1" * 64 + + def loaded(key: str, value: dict[str, object]) -> LoadedInput: + return LoadedInput(key, Path(f"{key}.json"), value, digest) + + inputs = { + "stableReadiness": loaded( + "stableReadiness", + { + "generatedAt": "2026-07-14T00:00:00Z", + "decision": "ready", + "stableReady": True, + "allowedLimitations": [], + }, + ), + "productionBeta": loaded( + "productionBeta", + {"generatedAt": "2026-07-14T00:00:00Z"}, + ), + "goNoGo": loaded( + "goNoGo", + {"decision": "go", "waivers": []}, + ), + } + state = ValidationState() + state.block( + "stable-1.0-rc.catalog-invalid", + "stable-1.0-rc.catalog-freeze", + "Catalog validation failed.", + "Regenerate the stable catalog.", + ) + context = SimpleNamespace( + manifest=SimpleNamespace( + release=SimpleNamespace(release_id="stable-rc-283", version="283") + ) + ) + + summary = stable_1_0_rc._promotion_summary( # noqa: SLF001 + context, + _freeze(), + {"status": "no-drift", "initialStatus": "no-drift", "regenerated": False}, + state, + {"status": "pass"}, + inputs, + ) + + evidence = {row["id"]: row for row in summary["evidence"]} + self.assertEqual("no-go", summary["decision"]) + self.assertFalse(summary["promotionReady"]) + self.assertEqual("fail", evidence["stable-1.0-rc.catalog-freeze"]["status"]) + self.assertEqual("fail", evidence["stable-1.0-rc.final-decision"]["status"]) + + def test_passing_offline_candidate_executes_freeze_packaging_and_final_go_no_go(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + component = root / "run" / "stable-rc" + out = component / "artifacts" / "legacy" + out.mkdir(parents=True) + context = SimpleNamespace( + workspace_root=root, + component_dir=component, + manifest=SimpleNamespace( + release=SimpleNamespace( + release_id="stable-rc-283", + version="283", + profile="stable-review", + ), + policies={"stableRcFreezeMode": "first-freeze"}, + ), + ) + digest = "sha256:" + "1" * 64 + + def loaded(key: str, value: dict[str, object]) -> LoadedInput: + return LoadedInput(key, root / f"{key}.json", value, digest) + + inputs = { + key: loaded(key, {"status": "pass"}) + for key in ( + "appPlatform", + "ecosystemMatrix", + "liveNetwork", + "multiNodeSoak", + "networkScaleSoak", + "releaseCertification", + "releaseHistory", + "securityDrills", + "thirdPartyIntake", + ) + } + inputs.update( + { + "productionBeta": loaded( + "productionBeta", + {"generatedAt": "2026-07-14T00:00:00Z", "status": "pass"}, + ), + "stableReadiness": loaded( + "stableReadiness", + { + "generatedAt": "2026-07-14T00:00:00Z", + "status": "pass", + "decision": "ready", + "stableReady": True, + "allowedLimitations": [], + }, + ), + "goNoGo": loaded( + "goNoGo", + {"decision": "go", "promotionReady": True, "waivers": []}, + ), + "previousCandidate": loaded( + "previousCandidate", + {"releaseId": "stable-rc-282", "status": "pass"}, + ), + } + ) + catalog = loaded("stableCatalogOperations", {"sourceCommit": "a" * 40}) + raw_inputs = { + "stableCatalogOperations": catalog, + "stableReadinessPolicy": loaded("stableReadinessPolicy", {}), + "stableKnownLimitations": loaded("stableKnownLimitations", {}), + "publicBetaKnownIssues": loaded("publicBetaKnownIssues", {"knownIssues": []}), + } + valid_freeze = _freeze() + platform = copy.deepcopy(valid_freeze["platformApi"]) + stable_catalog = copy.deepcopy(valid_freeze["stableCatalog"]) + apps = copy.deepcopy(valid_freeze["firstPartyApps"]) + profiles = copy.deepcopy(valid_freeze["contentFormatProfiles"]) + limitations = copy.deepcopy(valid_freeze["limitationsAndPolicy"]) + + def write_supporting(*_args: object, **_kwargs: object) -> tuple[dict[str, object], Path, Path]: + for name in SUPPORTING_VERIFIER_FILES: + (out / name).write_text("{}\n", encoding="utf-8") + return platform, out / "platform-api-current-contract.json", out / "platform-api-stable-diff.json" + + def copy_distribution(*_args: object, **_kwargs: object) -> Path: + path = out / "crypta-production-beta-283.tar.gz" + path.write_bytes(b"offline deterministic production fixture") + return path + + with ( + mock.patch.object(stable_1_0_rc, "load_existing_input", return_value=inputs["productionBeta"]), + mock.patch.object(stable_1_0_rc, "production_native_root", return_value=root), + mock.patch.object(stable_1_0_rc, "load_candidate_inputs", return_value=inputs), + mock.patch.object(stable_1_0_rc, "_require_raw", side_effect=lambda _context, key: raw_inputs[key]), + mock.patch.object(stable_1_0_rc, "load_raw_input", return_value=None), + mock.patch.object(stable_1_0_rc, "validate_prerequisites"), + mock.patch.object( + stable_1_0_rc, + "source_identity", + return_value=SourceIdentity("a" * 40, "commit:" + "a" * 40, digest), + ), + mock.patch.object(stable_1_0_rc, "build_platform_api_freeze", side_effect=write_supporting), + mock.patch.object(stable_1_0_rc, "build_catalog_and_apps_freeze", return_value=(stable_catalog, apps)), + mock.patch.object( + stable_1_0_rc, + "export_content_profiles", + return_value=(profiles, out / "content-format-profiles.json"), + ), + mock.patch.object(stable_1_0_rc, "build_limitations_freeze", return_value=limitations), + mock.patch.object(stable_1_0_rc, "_copy_production_distribution", side_effect=copy_distribution), + mock.patch.object(stable_1_0_rc, "_validate_production_distribution"), + ): + code = stable_1_0_rc._run(context, out, ValidationState()) # noqa: SLF001 + + summary = json.loads((out / "stable-1.0-rc-promotion-summary.json").read_text(encoding="utf-8")) + self.assertEqual(0, code) + self.assertEqual("pass", summary["status"]) + self.assertEqual("go", summary["decision"]) + self.assertTrue(summary["promotionReady"]) + self.assertFalse(summary["nonRelease"]) + self.assertEqual([], verify_deterministic_archive(out / "cryptad-stable-1.0-rc-283.tar.gz")) + + def test_engine_structural_error_writes_sanitized_no_go_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + component = root / "stable-rc" + (component / "artifacts").mkdir(parents=True) + external = root / "external-sentinel.txt" + external.write_text("must remain\n", encoding="utf-8") + context = SimpleNamespace( + component_dir=component, + run_root=root, + manifest=SimpleNamespace( + release=SimpleNamespace( + release_id="stable-rc-283", + version="283", + ) + ), + ) + + def fail_after_partial_output( + _context: object, + output: Path, + _state: ValidationState, + ) -> int: + (output / "unsafe-partial.json").write_text( + '{"password":"must-not-survive"}\n', + encoding="utf-8", + ) + nested = output / "nested" + nested.mkdir() + (nested / "local-path.txt").write_text("/home/release/private\n", encoding="utf-8") + (nested / "external-link").symlink_to(external) + raise TypeError("malformed nested protected evidence") + + with mock.patch.object( + stable_1_0_rc, + "_run", + side_effect=fail_after_partial_output, + ): + code, summary_path, report_path = stable_1_0_rc.run(context) + + summary = json.loads(summary_path.read_text(encoding="utf-8")) + native_files = {path.name for path in summary_path.parent.iterdir()} + self.assertEqual(1, code) + self.assertEqual("no-go", summary["decision"]) + self.assertFalse(summary["promotionReady"]) + self.assertTrue(summary["nonRelease"]) + self.assertEqual("invalid-freeze", summary["freeze"]["driftStatus"]) + self.assertEqual("fail", summary["redactionStatus"]) + self.assertEqual( + { + "redaction-report.json", + "stable-1.0-rc-go-no-go.md", + "stable-1.0-rc-promotion-summary.json", + }, + native_files, + ) + self.assertEqual("must remain\n", external.read_text(encoding="utf-8")) + self.assertTrue(report_path.is_file()) + + def test_freeze_content_digest_is_canonical_and_excludes_only_itself(self) -> None: + value = _freeze() + reordered = {key: value[key] for key in reversed(value)} + + self.assertEqual(value["contentDigest"], freeze_content_digest(reordered)) + reordered["stableMilestone"] = "changed" + self.assertNotEqual(value["contentDigest"], freeze_content_digest(reordered)) + + def test_valid_freeze_has_no_drift(self) -> None: + value = _freeze() + + self.assertEqual([], validate_freeze_shape(value)) + self.assertEqual("no-drift", compare_freezes(value, copy.deepcopy(value), [])["status"]) + + def test_invalid_nested_catalog_version_cannot_be_promotable(self) -> None: + for catalog_version in (None, 0, -1, False, "5"): + with self.subTest(catalog_version=catalog_version): + value = _freeze() + value["stableCatalog"]["catalogVersion"] = catalog_version # type: ignore[index] + value["contentDigest"] = freeze_content_digest(value) + + errors = validate_freeze_shape(value) + drift = compare_freezes(None, value, []) + + self.assertTrue(any("catalogVersion" in error for error in errors)) + self.assertEqual("invalid-freeze", drift["status"]) + + def test_changed_freeze_is_unapproved_drift(self) -> None: + previous = _freeze() + current = copy.deepcopy(previous) + current["firstPartyApps"][0]["version"] = "284" # type: ignore[index] + current["contentDigest"] = freeze_content_digest(current) + + result = compare_freezes(previous, current, []) + + self.assertEqual("unapproved-drift", result["status"]) + self.assertEqual("firstPartyApps", result["changes"][0]["section"]) + + def test_changed_production_distribution_is_candidate_drift(self) -> None: + previous = _freeze() + current = copy.deepcopy(previous) + current["candidate"]["productionDistributionDigest"] = "sha256:" + "f" * 64 # type: ignore[index] + current["contentDigest"] = freeze_content_digest(current) + + result = compare_freezes(previous, current, []) + + self.assertEqual("unapproved-drift", result["status"]) + self.assertTrue( + any( + change["section"] == "candidate" + and change["item"] == "productionDistributionDigest" + for change in result["changes"] + ) + ) + + def test_authorized_exception_classifies_initial_drift_but_requires_refreeze(self) -> None: + previous = _freeze() + current = copy.deepcopy(previous) + current["firstPartyApps"][0]["version"] = "284" # type: ignore[index] + current["contentDigest"] = freeze_content_digest(current) + before = semantic_digest(previous["firstPartyApps"][0]) # type: ignore[index] + after = semantic_digest(current["firstPartyApps"][0]) # type: ignore[index] + records, errors = validate_exception_collection( + _collection(_exception(before, after)), + "stable-rc-283", + "283", + datetime.now(timezone.utc), + ) + + self.assertEqual([], errors) + current["acceptedFreezeExceptions"] = records + current["contentDigest"] = freeze_content_digest(current) + result = compare_freezes(previous, current, records) + self.assertEqual("approved-freeze-exception", result["status"]) + self.assertTrue( + any( + change["section"] == "acceptedFreezeExceptions" + for change in result["approvedChanges"] + ) + ) + self.assertEqual("no-drift", compare_freezes(current, copy.deepcopy(current), [])["status"]) + + def test_accepted_exception_audit_history_is_preserved_and_compared(self) -> None: + previous = _freeze() + record = _exception("sha256:" + "1" * 64, "sha256:" + "2" * 64) + previous["acceptedFreezeExceptions"] = [record] + previous["contentDigest"] = freeze_content_digest(previous) + + preserved = merge_accepted_exception_history(previous, []) + self.assertEqual([record], preserved) + + for mutation in ("removed", "modified"): + with self.subTest(mutation=mutation): + current = copy.deepcopy(previous) + if mutation == "removed": + current["acceptedFreezeExceptions"] = [] + else: + current["acceptedFreezeExceptions"][0]["reason"] = "Changed audit reason." # type: ignore[index] + current["contentDigest"] = freeze_content_digest(current) + + result = compare_freezes(previous, current, []) + + self.assertEqual("unapproved-drift", result["status"]) + self.assertTrue( + any( + change["section"] == "acceptedFreezeExceptions" + for change in result["unapprovedChanges"] + ) + ) + + def test_exception_cannot_authorize_platform_api_baseline_drift(self) -> None: + previous = _freeze() + current = copy.deepcopy(previous) + current["platformApi"]["baselineDigest"] = "sha256:" + "3" * 64 # type: ignore[index] + current["contentDigest"] = freeze_content_digest(current) + record = _exception( + semantic_digest(previous["platformApi"]["baselineDigest"]), # type: ignore[index] + semantic_digest(current["platformApi"]["baselineDigest"]), # type: ignore[index] + ) + record["affectedSection"] = "platformApi" + record["affectedItem"] = "baselineDigest" + + records, errors = validate_exception_collection( + _collection(record), + "stable-rc-283", + "283", + datetime.now(timezone.utc), + ) + direct_result = compare_freezes(previous, current, [record]) + + self.assertEqual([], records) + self.assertTrue(any("non-waivable" in error for error in errors)) + self.assertEqual("invalid-freeze", direct_result["status"]) + self.assertTrue(any("non-waivable" in error for error in direct_result["errors"])) + + def test_exception_cannot_authorize_a_different_item_in_the_same_section(self) -> None: + previous = _freeze() + current = copy.deepcopy(previous) + current["firstPartyApps"][1]["version"] = "284" # type: ignore[index] + current["contentDigest"] = freeze_content_digest(current) + record = _exception(semantic_digest(None), semantic_digest(current["firstPartyApps"][1])) # type: ignore[index] + + result = compare_freezes(previous, current, [record]) + + self.assertEqual("invalid-freeze", result["status"]) + self.assertTrue(any("unmatched" in error for error in result["errors"])) + + def test_exception_without_change_is_invalid(self) -> None: + value = _freeze() + record = _exception("sha256:" + "1" * 64, "sha256:" + "2" * 64) + + result = compare_freezes(value, copy.deepcopy(value), [record]) + + self.assertEqual("invalid-freeze", result["status"]) + + def test_under_authorized_stale_and_non_waivable_exceptions_are_rejected(self) -> None: + record = _exception("sha256:" + "1" * 64, "sha256:" + "2" * 64) + record["affectedSection"] = "platformApi" + record["affectedItem"] = "baselineDigest" + record["expiresAt"] = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat() + value = _collection(record) + value["authorizationRole"] = "developer" + + _, errors = validate_exception_collection(value, "stable-rc-283", "283", datetime.now(timezone.utc)) + + self.assertTrue(any("under-authorized" in error for error in errors)) + self.assertTrue(any("expired" in error for error in errors)) + self.assertTrue(any("non-waivable" in error for error in errors)) + + def test_freeze_exception_placeholder_audit_metadata_is_rejected(self) -> None: + for field in ("issueReference", "owner", "approver"): + with self.subTest(field=field): + record = _exception("sha256:" + "1" * 64, "sha256:" + "2" * 64) + record[field] = "REPLACE_ME" + + accepted, errors = validate_exception_collection( + _collection(record), + "stable-rc-283", + "283", + datetime.now(timezone.utc), + ) + + self.assertEqual([], accepted) + self.assertTrue(any("placeholder" in error for error in errors), errors) + + def test_freeze_exception_collection_enforces_closed_schema(self) -> None: + for target, field in (("collection", "unexpected"), ("redaction", "note")): + with self.subTest(target=target): + value = _collection( + _exception("sha256:" + "1" * 64, "sha256:" + "2" * 64) + ) + if target == "collection": + value[field] = "not contracted" + else: + value["redaction"][field] = "not contracted" # type: ignore[index] + + accepted, errors = validate_exception_collection( + value, + "stable-rc-283", + "283", + datetime.now(timezone.utc), + ) + + self.assertEqual([], accepted) + self.assertTrue(any("unknown field" in error for error in errors), errors) + + def test_stable_surface_addition_is_drift(self) -> None: + baseline = {"name": "1.0", "capabilities": ["content.fetch"], "endpoints": ["POST /content/fetch"]} + current = copy.deepcopy(baseline) + current["capabilities"].append("new.stable") + + self.assertFalse(stable_surface_is_exact(baseline, current)) + + def test_experimental_only_change_does_not_change_stable_surface(self) -> None: + baseline = {"name": "1.0", "capabilities": ["content.fetch"], "endpoints": ["POST /content/fetch"]} + current_contract = {"stableBaseline": copy.deepcopy(baseline), "capabilities": [{"id": "new.experimental", "stability": "experimental"}]} + + self.assertTrue(stable_surface_is_exact(baseline, current_contract["stableBaseline"])) + + def test_placeholder_production_metadata_is_rejected(self) -> None: + findings = placeholder_findings({"supportUri": "https://example.invalid", "keyId": "REPLACE_ME"}) + + self.assertGreaterEqual(len(findings), 2) + + +class StableRcArchiveTest(unittest.TestCase): + def test_configured_input_rejects_a_symlinked_parent_before_resolution(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = Path(directory).resolve() + evidence = workspace / "evidence" + evidence.mkdir() + (evidence / "policy.json").write_text("{}\n", encoding="utf-8") + linked = workspace / "linked" + linked.symlink_to(evidence, target_is_directory=True) + context = SimpleNamespace( + workspace_root=workspace, + manifest=SimpleNamespace(inputs={"stableReadinessPolicy": "linked/policy.json"}), + ) + + with self.assertRaisesRegex(ValueError, "symlink"): + load_raw_input(context, "stableReadinessPolicy") + + def test_archive_is_reproducible_and_payload_checksums_bind_every_member(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + payload = root / "payload.bin" + metadata = root / "freeze.json" + payload.write_bytes(b"production distribution") + metadata.write_text("{}\n", encoding="utf-8") + checksums = root / "payload-checksums.txt" + write_named_checksums(checksums, [("payload/payload.bin", payload), ("metadata/freeze.json", metadata)]) + members = [("payload/payload.bin", payload), ("metadata/freeze.json", metadata), ("payload-checksums.txt", checksums)] + first = root / "first.tar.gz" + second = root / "second.tar.gz" + + create_deterministic_archive(first, members) + create_deterministic_archive(second, reversed(members)) + + self.assertEqual(first.read_bytes(), second.read_bytes()) + self.assertEqual([], verify_deterministic_archive(first)) + + def test_production_stable_rc_product_distribution_is_reproducible(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + files = { + "inputs/first-party-app-maintenance-policy.json": "{}\n", + "inputs/first-party-app-beta-readiness.json": "{}\n", + "build/staged-apps/queue-manager/cryptad-app.properties": ( + "app.id=queue-manager\n" + "app.exec=bin/start-queue.sh\n" + ), + "build/staged-apps/queue-manager/bin/start-queue.sh": "#!/bin/sh\nexit 0\n", + "build/staged-apps/feed-reader/cryptad-app.properties": ( + "app.id=feed-reader\n" + "app.exec=bin/start-feed.sh\n" + "app.data.migration.ui-state-v1-v2.command=bin/migrate-feed-data.sh\n" + ), + "build/staged-apps/feed-reader/bin/start-feed.sh": "#!/bin/sh\nexit 0\n", + "build/staged-apps/feed-reader/bin/migrate-feed-data.sh": "#!/bin/sh\nexit 0\n", + "build/staged-apps/trust-graph/cryptad-app.properties": ( + "app.id=trust-graph\n" + "app.exec=bin/start-trust.bat\n" + "app.data.migration.ui-state-v1-v2.command=bin/migrate-preview-data.sh\n" + ), + "build/staged-apps/trust-graph/bin/start-trust.bat": "@exit /b 0\n", + "build/staged-apps/trust-graph/bin/migrate-preview-data.sh": "#!/bin/sh\nexit 0\n", + "build/app-bundles/queue-manager-283.zip": "bundle bytes\n", + "build/crypta-app-launcher/bin/crypta-app": "#!/bin/sh\nexit 0\n", + "catalog/first-party-catalog.properties": "catalog.generatedAt=2026-07-14T00:00:00Z\n", + "catalog/cryptad-app-catalog.signature": "signature\n", + "reviews/review-receipts/queue-manager-review-receipt.properties": "reviewed\n", + "reviews/review-transparency-log.json": "{}\n", + } + for relative, contents in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + launcher = root / "build/crypta-app-launcher/bin/crypta-app" + launcher.chmod(0o755) + migration_members = { + "build/staged-apps/feed-reader/bin/migrate-feed-data.sh", + "build/staged-apps/trust-graph/bin/migrate-preview-data.sh", + } + posix_app_launchers = { + "build/staged-apps/queue-manager/bin/start-queue.sh", + "build/staged-apps/feed-reader/bin/start-feed.sh", + } + staged_commands = { + *migration_members, + *posix_app_launchers, + "build/staged-apps/trust-graph/bin/start-trust.bat", + } + for staged_command in staged_commands: + (root / staged_command).chmod(0o644) + settings = SimpleNamespace( + out_dir=root, + stable_rc_artifact_timestamp="2026-07-14T00:00:00Z", + ) + + first = production_beta_release.create_stable_rc_product_bundle( + settings, + "283", + ).read_bytes() + for relative, contents in reversed(tuple(files.items())): + (root / relative).write_text(contents, encoding="utf-8") + launcher.chmod(0o755) + for staged_command in staged_commands: + (root / staged_command).chmod(0o644) + product_archive = production_beta_release.create_stable_rc_product_bundle( + settings, + "283", + ) + broad_archive = production_beta_release.create_dist_bundle(settings, "283") + + self.assertEqual(first, product_archive.read_bytes()) + for archive in (product_archive, broad_archive): + with self.subTest(archive=archive.name), tarfile.open(archive, "r:gz") as packaged: + members = packaged.getmembers() + self.assertEqual(sorted(files), [member.name for member in members]) + self.assertTrue(all(member.isfile() for member in members)) + self.assertTrue( + all( + member.mtime == 0 + and member.uid == 0 + and member.gid == 0 + and member.uname == "root" + and member.gname == "root" + for member in members + ) + ) + modes = {member.name: member.mode for member in members} + expected_executables = { + "build/crypta-app-launcher/bin/crypta-app", + *migration_members, + *posix_app_launchers, + } + self.assertTrue( + all(modes[name] == 0o755 for name in expected_executables) + ) + self.assertTrue( + all( + mode == 0o644 + for name, mode in modes.items() + if name not in expected_executables + ) + ) + + def test_production_checksum_must_name_and_bind_the_copied_archive(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + native_root = root / "native" + distribution = native_root / "dist" + distribution.mkdir(parents=True) + source_archive = distribution / "cryptad-production-beta-283.tar.gz" + source_archive.write_bytes(b"production distribution") + copied_archive = root / source_archive.name + copied_archive.write_bytes(source_archive.read_bytes()) + checksums = distribution / "checksums.txt" + context = SimpleNamespace(workspace_root=root) + + def validate() -> ValidationState: + state = ValidationState() + with mock.patch( + "cryptad_certification.engines.production_beta_release.scan_tarball", + return_value=[], + ): + stable_1_0_rc._validate_production_distribution( # noqa: SLF001 + native_root, + copied_archive, + context, + state, + ) + return state + + checksums.write_text("", encoding="utf-8") + self.assertTrue(any("omits required target" in row["summary"] for row in validate().blockers)) + + unrelated = distribution / "unrelated.bin" + unrelated.write_bytes(b"unrelated") + write_named_checksums(checksums, [(unrelated.name, unrelated)]) + self.assertTrue(any("omits required target" in row["summary"] for row in validate().blockers)) + + write_named_checksums(checksums, [(source_archive.name, source_archive)]) + copied_archive.write_bytes(b"modified after copy") + self.assertTrue( + any("copied target" in row["summary"] for row in validate().blockers) + ) + + copied_archive.write_bytes(source_archive.read_bytes()) + self.assertEqual([], validate().blockers) + + def test_stable_rc_freezes_the_deterministic_product_distribution(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + native_root = root / "native" + output = root / "stable-output" + product = native_root / "dist/crypta-stable-1.0-rc-283-product.tar.gz" + broad = native_root / "dist/crypta-production-beta-283.tar.gz" + product.parent.mkdir(parents=True) + output.mkdir() + product.write_bytes(b"deterministic Stable RC product") + broad.write_bytes(b"run-specific production evidence archive") + + copied = stable_1_0_rc._copy_production_distribution( # noqa: SLF001 + native_root, + { + "artifacts": { + "distArchive": "dist/crypta-production-beta-283.tar.gz", + "stableRcDistribution": ( + "dist/crypta-stable-1.0-rc-283-product.tar.gz" + ), + } + }, + output, + ) + + self.assertEqual(product.name, copied.name) + self.assertEqual(product.read_bytes(), copied.read_bytes()) + self.assertNotEqual(broad.read_bytes(), copied.read_bytes()) + + def test_archive_verifier_rejects_wrong_embedded_checksum(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + payload = root / "payload.bin" + payload.write_bytes(b"candidate") + checksums = root / "payload-checksums.txt" + checksums.write_text(f"{'0' * 64} payload/payload.bin\n", encoding="utf-8") + archive = root / "candidate.tar.gz" + create_deterministic_archive(archive, [("payload/payload.bin", payload), ("payload-checksums.txt", checksums)]) + + self.assertTrue(any("mismatch" in error for error in verify_deterministic_archive(archive))) + + def test_symlink_sources_are_rejected_before_resolution(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + target = root / "target.bin" + target.write_bytes(b"candidate") + link = root / "linked.bin" + link.symlink_to(target) + + with self.assertRaisesRegex(ValueError, "unsafe"): + write_named_checksums(root / "checksums.txt", [("payload/linked.bin", link)]) + with self.assertRaisesRegex(ValueError, "unsafe"): + create_deterministic_archive(root / "candidate.tar.gz", [("payload/linked.bin", link)]) + + def test_forbidden_appledouble_member_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory).resolve() / "source" + source.write_text("unsafe", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "forbidden"): + create_deterministic_archive( + Path(directory).resolve() / "candidate.tar.gz", + [("metadata/._freeze.json", source)], + ) + + +class StableRcFixtureInventoryTest(unittest.TestCase): + def test_every_acceptance_fixture_executes_its_validator(self) -> None: + root = Path(__file__).resolve().parents[4] + value = json.loads((root / "tools/release-certification/fixtures/stable-1.0-rc-cases.json").read_text(encoding="utf-8")) + + self.assertEqual("stable-1.0-rc-self-test-cases", value["kind"]) + self.assertEqual(30, len(value["cases"])) + self.assertEqual(30, len({row["id"] for row in value["cases"]})) + for row in value["cases"]: + with self.subTest(case=row["id"]): + status, details = self._execute_fixture(row) + self.assertEqual(row["expectedStatus"], status) + blocker = row["expectedBlocker"] + if blocker is not None: + self.assertIn(blocker, details) + + def _execute_fixture(self, row: dict[str, object]) -> tuple[str, str]: + validator = row["validator"] + mutation = str(row["mutation"]) + if validator == "freeze": + previous = _freeze() + if mutation in {"allowed-limitation", "limitation-removed"}: + limitation = { + "id": "stable-1.0.local-rc", + "title": "Local RC scope", + "summary": "Trust and Social remain bounded to local RC operation.", + "category": "bounded-scope", + "classification": "allowed-for-stable-1.0", + "status": "open", + "owner": "crypta-core", + "boundedBy": "Local node operation only.", + "evidenceIds": ["stable-1.0.local-rc-scope"], + } + previous_limitations = previous["limitationsAndPolicy"] + previous_limitations["allowedLimitations"] = [limitation] # type: ignore[index] + previous_limitations["allowedLimitationsDigest"] = semantic_digest([limitation]) # type: ignore[index] + previous_limitations["allowedLimitationCount"] = 1 # type: ignore[index] + previous["contentDigest"] = freeze_content_digest(previous) + current = copy.deepcopy(previous) + if mutation == "platform-baseline": + current["platformApi"]["baselineDigest"] = "sha256:" + "3" * 64 # type: ignore[index] + elif mutation == "catalog-digest": + current["stableCatalog"]["catalogDigest"] = "sha256:" + "4" * 64 # type: ignore[index] + elif mutation == "app-extra": + current["firstPartyApps"].append({"appId": "unexpected", "version": "283"}) # type: ignore[union-attr] + elif mutation == "app-version": + current["firstPartyApps"][0]["version"] = "284" # type: ignore[index] + elif mutation == "app-schema": + current["firstPartyApps"][0]["appDataSchemaVersion"] = 2 # type: ignore[index] + elif mutation == "profile-version": + current["contentFormatProfiles"][0]["version"] = 2 # type: ignore[index] + elif mutation == "limitation-removed": + current_limitations = current["limitationsAndPolicy"] + current_limitations["allowedLimitations"] = [] # type: ignore[index] + current_limitations["allowedLimitationsDigest"] = semantic_digest([]) # type: ignore[index] + current_limitations["allowedLimitationCount"] = 0 # type: ignore[index] + elif mutation == "disallowed-limitation": + current["limitationsAndPolicy"]["disallowedLimitationCount"] = 1 # type: ignore[index] + elif mutation == "source-provenance": + current["candidate"]["sourceCommit"] = "b" * 40 # type: ignore[index] + current["contentDigest"] = freeze_content_digest(current) + result = compare_freezes(previous, current, []) + return str(result["status"]), json.dumps(result, sort_keys=True) + if validator == "production": + summary = _production_summary() + if mutation == "promotion": + summary["promotionReady"] = False + elif mutation == "signing": + summary["signingProfile"] = {"kind": "test", "generatedTestKeys": True, "privateKeyMaterialIncluded": False} + elif mutation == "stage": + summary["pipelineStages"]["gradle-full-build"] = {"status": "skip"} # type: ignore[index] + elif mutation == "workspace": + summary["dirtyWorkspace"] = True + state = ValidationState() + validate_production_beta(summary, "stable-rc-283", "283", state) + return ("fail" if state.blockers else "pass"), json.dumps(state.blockers, sort_keys=True) + if validator == "readiness": + from cryptad_certification.engines import production_beta_go_no_go_dashboard as dashboard + + issues = dashboard.stable_readiness_issues(_readiness_summary(mutation), True, "stable-rc-283") + blockers = [issue.id for issue in issues if issue.severity in {"blocker", "critical"}] + return ("fail" if blockers else "pass"), " ".join(blockers) + if validator == "api": + baseline = {"name": "1.0", "capabilities": ["content.fetch"], "endpoints": ["POST /content/fetch"]} + stable = copy.deepcopy(baseline) + if mutation == "stable-addition": + stable["capabilities"].append("new.stable") + exact = stable_surface_is_exact(baseline, stable) + return ("pass" if exact else "fail"), "" if exact else "stable-surface" + if validator == "catalog": + catalog = _catalog_operations() + if mutation == "channel": + catalog["channel"] = "nightly" + elif mutation == "rotation": + catalog["keyRotation"] = {"status": "complete", "compromised": True} + state = ValidationState() + validate_catalog_operations(catalog, "stable-rc-283", "283", datetime.now(timezone.utc), state) + return ("fail" if state.blockers else "pass"), json.dumps(state.blockers, sort_keys=True) + if validator == "exception": + previous = _freeze() + current = copy.deepcopy(previous) + if mutation == "non-waivable": + current["platformApi"]["baselineDigest"] = "sha256:" + "3" * 64 # type: ignore[index] + record = _exception( + semantic_digest(previous["platformApi"]["baselineDigest"]), # type: ignore[index] + semantic_digest(current["platformApi"]["baselineDigest"]), # type: ignore[index] + ) + record["affectedSection"] = "platformApi" + record["affectedItem"] = "baselineDigest" + else: + current["firstPartyApps"][0]["version"] = "284" # type: ignore[index] + record = _exception( + semantic_digest(previous["firstPartyApps"][0]), # type: ignore[index] + semantic_digest(current["firstPartyApps"][0]), # type: ignore[index] + ) + current["contentDigest"] = freeze_content_digest(current) + if mutation == "stale": + record["expiresAt"] = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat() + records, errors = validate_exception_collection( + _collection(record), "stable-rc-283", "283", datetime.now(timezone.utc) + ) + if errors: + return "invalid-freeze", " ".join(errors) + result = compare_freezes(previous, current, records) + return str(result["status"]), json.dumps(result, sort_keys=True) + if validator == "redaction": + candidate = ( + {"supportUri": "https://example.invalid"} + if mutation == "placeholder" + else {"privateKey": "not-a-real-private-key"} + ) + findings = placeholder_findings(candidate) if mutation == "placeholder" else scan_value(candidate) + return ("fail" if findings else "pass"), "placeholder" if mutation == "placeholder" else "redaction" + if validator == "freshness": + generated = ( + (datetime.now(timezone.utc) - timedelta(days=31)).isoformat() + if mutation == "stale" + else None + ) + error = freshness_error(generated, datetime.now(timezone.utc), 30, "release evidence") + return ("fail" if error else "pass"), str(error or "") + if validator == "archive": + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + payload = root / "payload.bin" + payload.write_bytes(b"candidate") + archive = root / "candidate.tar.gz" + if mutation == "appledouble": + try: + create_deterministic_archive(archive, [("metadata/._freeze.json", payload)]) + except ValueError as exc: + return "fail", str(exc) + return "pass", "" + checksums = root / "payload-checksums.txt" + checksums.write_text(f"{'0' * 64} payload/payload.bin\n", encoding="utf-8") + create_deterministic_archive(archive, [("payload/payload.bin", payload), ("payload-checksums.txt", checksums)]) + errors = verify_deterministic_archive(archive) + return ("fail" if errors else "pass"), " ".join(errors) + self.fail(f"unsupported Stable RC fixture validator: {validator}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release-certification/cryptad_certification/workspace.py b/tools/release-certification/cryptad_certification/workspace.py index 7b62e275c1..281b1c8877 100644 --- a/tools/release-certification/cryptad_certification/workspace.py +++ b/tools/release-certification/cryptad_certification/workspace.py @@ -93,6 +93,22 @@ def _require_confined_directory(path: Path, run_root: Path, description: str) -> raise WorkspaceError(f"{description} path is not a directory: {path}") +def reset_confined_directory(path: Path, run_root: Path, description: str) -> Path: + """Remove and recreate one confined directory without following replacement symlinks.""" + + resolved_root = run_root.resolve() + _require_confined_directory(path.parent, resolved_root, f"{description} parent") + if path.is_symlink() or (path.exists() and not path.is_dir()): + path.unlink() + elif path.exists(): + _require_confined_directory(path, resolved_root, description) + shutil.rmtree(path) + _require_confined_directory(path.parent, resolved_root, f"{description} parent") + path.mkdir() + _require_confined_directory(path, resolved_root, description) + return path.resolve() + + def relative_to_run(path: Path, context: RunContext) -> str: """Return a portable artifact reference below the release-run root.""" diff --git a/tools/release-certification/first-party-app-maintenance-policy.json b/tools/release-certification/first-party-app-maintenance-policy.json index 35de215682..0395ea6e01 100644 --- a/tools/release-certification/first-party-app-maintenance-policy.json +++ b/tools/release-certification/first-party-app-maintenance-policy.json @@ -10,14 +10,14 @@ "maximumCryptaVersion": null, "maintenance": { "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "supportLevel": "core", "dataSchemaPolicy": "stateless", "migrationPolicy": "none", "backupRestore": "not-applicable", "securityPolicy": "catalog-advisories", "deprecationPolicy": "none", - "supportUri": "https://example.invalid/crypta/apps/queue-manager/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "publisher": { @@ -28,14 +28,14 @@ "maximumCryptaVersion": null, "maintenance": { "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "supportLevel": "core", "dataSchemaPolicy": "stateless", "migrationPolicy": "none", "backupRestore": "not-applicable", "securityPolicy": "catalog-advisories", "deprecationPolicy": "none", - "supportUri": "https://example.invalid/crypta/apps/publisher/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "site-publisher": { @@ -46,14 +46,14 @@ "maximumCryptaVersion": null, "maintenance": { "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "supportLevel": "maintained", "dataSchemaPolicy": "stateless", "migrationPolicy": "none", "backupRestore": "not-applicable", "securityPolicy": "catalog-advisories", "deprecationPolicy": "none", - "supportUri": "https://example.invalid/crypta/apps/site-publisher/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "profile-publisher": { @@ -64,14 +64,14 @@ "maximumCryptaVersion": null, "maintenance": { "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "supportLevel": "maintained", "dataSchemaPolicy": "declared", "migrationPolicy": "declared", "backupRestore": "operator-supported", "securityPolicy": "catalog-advisories", "deprecationPolicy": "none", - "supportUri": "https://example.invalid/crypta/apps/profile-publisher/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "social-inbox": { @@ -82,14 +82,14 @@ "maximumCryptaVersion": null, "maintenance": { "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "supportLevel": "local-rc", "dataSchemaPolicy": "declared", "migrationPolicy": "operator-approved", "backupRestore": "operator-supported", "securityPolicy": "catalog-advisories", "deprecationPolicy": "none", - "supportUri": "https://example.invalid/crypta/apps/social-inbox/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "feed-reader": { @@ -100,14 +100,14 @@ "maximumCryptaVersion": null, "maintenance": { "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "supportLevel": "maintained", "dataSchemaPolicy": "migratable", "migrationPolicy": "dry-run-required", "backupRestore": "export-import", "securityPolicy": "catalog-advisories", "deprecationPolicy": "none", - "supportUri": "https://example.invalid/crypta/apps/feed-reader/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "trust-graph": { @@ -118,14 +118,14 @@ "maximumCryptaVersion": null, "maintenance": { "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "supportLevel": "local-rc", "dataSchemaPolicy": "migratable", "migrationPolicy": "dry-run-required", "backupRestore": "operator-supported", "securityPolicy": "catalog-advisories", "deprecationPolicy": "none", - "supportUri": "https://example.invalid/crypta/apps/trust-graph/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } } } diff --git a/tools/release-certification/fixtures/self-test-app-platform-smoke.json b/tools/release-certification/fixtures/self-test-app-platform-smoke.json index d4518408d7..201ee0c86f 100644 --- a/tools/release-certification/fixtures/self-test-app-platform-smoke.json +++ b/tools/release-certification/fixtures/self-test-app-platform-smoke.json @@ -1679,10 +1679,10 @@ "deprecationPolicy": "none", "migrationPolicy": "dry-run-required", "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "securityPolicy": "catalog-advisories", "supportLevel": "maintained", - "supportUri": "https://example.invalid/crypta/apps/feed-reader/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "profile-publisher": { @@ -1692,10 +1692,10 @@ "deprecationPolicy": "none", "migrationPolicy": "declared", "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "securityPolicy": "catalog-advisories", "supportLevel": "maintained", - "supportUri": "https://example.invalid/crypta/apps/profile-publisher/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "publisher": { @@ -1705,10 +1705,10 @@ "deprecationPolicy": "none", "migrationPolicy": "none", "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "securityPolicy": "catalog-advisories", "supportLevel": "core", - "supportUri": "https://example.invalid/crypta/apps/publisher/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "queue-manager": { @@ -1718,10 +1718,10 @@ "deprecationPolicy": "none", "migrationPolicy": "none", "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "securityPolicy": "catalog-advisories", "supportLevel": "core", - "supportUri": "https://example.invalid/crypta/apps/queue-manager/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "site-publisher": { @@ -1731,10 +1731,10 @@ "deprecationPolicy": "none", "migrationPolicy": "none", "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "securityPolicy": "catalog-advisories", "supportLevel": "maintained", - "supportUri": "https://example.invalid/crypta/apps/site-publisher/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "social-inbox": { @@ -1744,10 +1744,10 @@ "deprecationPolicy": "none", "migrationPolicy": "operator-approved", "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "securityPolicy": "catalog-advisories", "supportLevel": "local-rc", - "supportUri": "https://example.invalid/crypta/apps/social-inbox/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } }, "trust-graph": { @@ -1757,10 +1757,10 @@ "deprecationPolicy": "none", "migrationPolicy": "dry-run-required", "owner": "crypta-core", - "ownerUri": "https://example.invalid/crypta/owners/core", + "ownerUri": "https://github.com/crypta-network/cryptad", "securityPolicy": "catalog-advisories", "supportLevel": "local-rc", - "supportUri": "https://example.invalid/crypta/apps/trust-graph/support" + "supportUri": "https://github.com/crypta-network/cryptad/issues" } } }, diff --git a/tools/release-certification/fixtures/self-test-catalog.properties b/tools/release-certification/fixtures/self-test-catalog.properties index 1c7086e49f..622db295b6 100644 --- a/tools/release-certification/fixtures/self-test-catalog.properties +++ b/tools/release-certification/fixtures/self-test-catalog.properties @@ -102,6 +102,6 @@ app.trust-graph.service.trust-score.adapter=trust-graph.score app.trust-graph.service.trust-score.scopes=score.read app.trust-graph.service.trust-score.contexts=message-author,profile app.trust-graph.service.trust-score.description=Returns a bounded local RC Trust Graph score summary for an app-provided public subject. -app.trust-graph.api.maximumTestedVersion=22 +app.trust-graph.api.maximumTestedVersion=23 app.trust-graph.api.targetStability=experimental app.trust-graph.api.experimentalCapabilitiesAccepted=true diff --git a/tools/release-certification/fixtures/stable-1.0-rc-cases.json b/tools/release-certification/fixtures/stable-1.0-rc-cases.json new file mode 100644 index 0000000000..cf713d920d --- /dev/null +++ b/tools/release-certification/fixtures/stable-1.0-rc-cases.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "kind": "stable-1.0-rc-self-test-cases", + "cases": [ + {"id":"ready-pass","validator":"freeze","mutation":"none","expectedStatus":"no-drift","expectedBlocker":null}, + {"id":"allowed-limitations-pass","validator":"freeze","mutation":"allowed-limitation","expectedStatus":"no-drift","expectedBlocker":null}, + {"id":"readiness-not-ready","validator":"readiness","mutation":"not-ready","expectedStatus":"fail","expectedBlocker":"stable-1.0.readiness-summary.not-ready"}, + {"id":"readiness-binding-invalid","validator":"readiness","mutation":"wrong-release","expectedStatus":"fail","expectedBlocker":"stable-1.0.readiness-summary.release-id"}, + {"id":"production-beta-not-promotion-ready","validator":"production","mutation":"promotion","expectedStatus":"fail","expectedBlocker":"stable-1.0-rc.production-beta-promotion"}, + {"id":"fixture-signing","validator":"production","mutation":"signing","expectedStatus":"fail","expectedBlocker":"stable-1.0-rc.production-signing"}, + {"id":"skipped-build-stage","validator":"production","mutation":"stage","expectedStatus":"fail","expectedBlocker":"stable-1.0-rc.pipeline-stage.gradle-full-build"}, + {"id":"dirty-workspace","validator":"production","mutation":"workspace","expectedStatus":"fail","expectedBlocker":"stable-1.0-rc.production-workspace"}, + {"id":"platform-api-baseline-drift","validator":"freeze","mutation":"platform-baseline","expectedStatus":"unapproved-drift","expectedBlocker":"platformApi"}, + {"id":"platform-api-stable-surface-drift","validator":"api","mutation":"stable-addition","expectedStatus":"fail","expectedBlocker":"stable-surface"}, + {"id":"platform-api-experimental-only-change","validator":"api","mutation":"experimental-only","expectedStatus":"pass","expectedBlocker":null}, + {"id":"catalog-channel-invalid","validator":"catalog","mutation":"channel","expectedStatus":"fail","expectedBlocker":"stable-1.0-rc.catalog-operations"}, + {"id":"catalog-content-drift","validator":"freeze","mutation":"catalog-digest","expectedStatus":"unapproved-drift","expectedBlocker":"stableCatalog"}, + {"id":"catalog-key-rotation-invalid","validator":"catalog","mutation":"rotation","expectedStatus":"fail","expectedBlocker":"stable-1.0-rc.catalog-operations"}, + {"id":"first-party-app-set-invalid","validator":"freeze","mutation":"app-extra","expectedStatus":"invalid-freeze","expectedBlocker":"firstPartyApps"}, + {"id":"first-party-app-content-drift","validator":"freeze","mutation":"app-version","expectedStatus":"unapproved-drift","expectedBlocker":"firstPartyApps"}, + {"id":"app-data-migration-drift","validator":"freeze","mutation":"app-schema","expectedStatus":"unapproved-drift","expectedBlocker":"firstPartyApps"}, + {"id":"placeholder-production-metadata","validator":"redaction","mutation":"placeholder","expectedStatus":"fail","expectedBlocker":"placeholder"}, + {"id":"content-format-profile-drift","validator":"freeze","mutation":"profile-version","expectedStatus":"unapproved-drift","expectedBlocker":"contentFormatProfiles"}, + {"id":"allowed-limitation-not-propagated","validator":"freeze","mutation":"limitation-removed","expectedStatus":"unapproved-drift","expectedBlocker":"limitationsAndPolicy"}, + {"id":"disallowed-limitation","validator":"freeze","mutation":"disallowed-limitation","expectedStatus":"invalid-freeze","expectedBlocker":"limitationsAndPolicy"}, + {"id":"approved-exception-refreeze","validator":"exception","mutation":"approved","expectedStatus":"approved-freeze-exception","expectedBlocker":null}, + {"id":"invalid-exception","validator":"exception","mutation":"stale","expectedStatus":"invalid-freeze","expectedBlocker":"expired"}, + {"id":"non-waivable-exception","validator":"exception","mutation":"non-waivable","expectedStatus":"invalid-freeze","expectedBlocker":"non-waivable"}, + {"id":"redaction-unsafe-input","validator":"redaction","mutation":"secret","expectedStatus":"fail","expectedBlocker":"redaction"}, + {"id":"archive-hygiene-invalid","validator":"archive","mutation":"appledouble","expectedStatus":"fail","expectedBlocker":"forbidden"}, + {"id":"packaging-checksum-mismatch","validator":"archive","mutation":"checksum","expectedStatus":"fail","expectedBlocker":"mismatch"}, + {"id":"security-drill-stale","validator":"freshness","mutation":"stale","expectedStatus":"fail","expectedBlocker":"older than"}, + {"id":"release-evidence-missing","validator":"freshness","mutation":"missing","expectedStatus":"fail","expectedBlocker":"missing or malformed"}, + {"id":"final-no-go","validator":"freeze","mutation":"source-provenance","expectedStatus":"unapproved-drift","expectedBlocker":"sourceCommit"} + ] +} diff --git a/tools/release-certification/manifests/stable-1.0-rc.example.json b/tools/release-certification/manifests/stable-1.0-rc.example.json new file mode 100644 index 0000000000..74d9198223 --- /dev/null +++ b/tools/release-certification/manifests/stable-1.0-rc.example.json @@ -0,0 +1,62 @@ +{ + "schemaVersion": 1, + "release": { + "id": "cryptad-stable-1-0-rc-REPLACE_ME", + "version": "REPLACE_WITH_INTEGER_BUILD", + "profile": "stable-review" + }, + "output": { + "root": "build/release-certification", + "reset": true + }, + "requirements": { + "history": true, + "liveNetwork": true, + "multiNodeSoak": true, + "sandboxProviderTests": true, + "stableReadiness": true, + "thirdPartyIntake": true + }, + "inputs": { + "interopSmoke": "build/interop-smoke/summary.json", + "liveNetwork": "build/protected-inputs/live-network-beta-summary.json", + "multiNodeSoak": "build/protected-inputs/multi-node-beta-soak-summary.json", + "networkScaleSoak": "build/protected-inputs/network-scale-soak-summary.json", + "performanceSmoke": "build/perf-smoke/summary.json", + "previousCandidate": "build/protected-inputs/previous-candidate-summary.json", + "previousStableRcFreeze": "build/protected-inputs/previous-stable-1.0-rc-freeze.json", + "publicBetaKnownIssues": "build/protected-inputs/public-beta-known-issues.json", + "releaseHistory": "build/protected-inputs/release-history-summary.json", + "securityDrills": "build/protected-inputs/security-drills-summary.json", + "stableCatalogOperations": "build/protected-inputs/stable-catalog-operations-summary.json", + "stableKnownLimitations": "tools/release-certification/stable-1.0-known-limitations.json", + "stableReadinessPolicy": "tools/release-certification/stable-1.0-readiness-policy.json", + "stableReadinessWaivers": "build/protected-inputs/stable-readiness-waivers.json", + "stableRcFreezeExceptions": "build/protected-inputs/stable-1.0-rc-freeze-exceptions.json", + "thirdPartyIntake": "build/protected-inputs/third-party-intake-summary.json" + }, + "policies": { + "artifactBaseUri": "https://REPLACE_ME.invalid/stable-1.0-rc/", + "catalogChannel": "stable", + "expectedPreviousReleaseId": "REPLACE_WITH_PREVIOUS_CANDIDATE_RELEASE_ID", + "stableRcFreezeMode": "refreeze" + }, + "execution": { + "allowDirtyWorkspace": false, + "allowTestSigningInProduction": false, + "collectEvidence": true, + "emergencySkipBuild": false, + "emergencySkipLiveNetwork": false, + "fixtureEvidence": false, + "generateStableReadiness": true, + "skipFullBuild": false, + "skipGitMetadata": false, + "skipGradle": false, + "writeHistory": false + }, + "commands": { + "stable-rc": { + "args": [] + } + } +} diff --git a/tools/release-certification/schemas/release-run-v1.schema.json b/tools/release-certification/schemas/release-run-v1.schema.json index 229e792e0f..bce6f4a63b 100644 --- a/tools/release-certification/schemas/release-run-v1.schema.json +++ b/tools/release-certification/schemas/release-run-v1.schema.json @@ -63,12 +63,15 @@ "networkScaleSoak", "performanceSmoke", "previousCandidate", + "previousStableRcFreeze", "productionBeta", "publicBetaKnownIssues", "releaseCertification", "releaseHistory", "securityDrills", + "stableCatalogOperations", "stableKnownLimitations", + "stableRcFreezeExceptions", "stableReadiness", "stableReadinessPolicy", "stableReadinessWaivers", @@ -87,6 +90,7 @@ "expectedPreviousReleaseId": {"type": "string", "minLength": 1}, "historyDir": {"type": "string", "minLength": 1}, "historyLabel": {"type": "string", "minLength": 1}, + "stableRcFreezeMode": {"enum": ["first-freeze", "refreeze"]}, "metadata": { "type": "object", "additionalProperties": {"type": "string", "minLength": 1} @@ -126,6 +130,7 @@ "production-beta", "go-no-go", "stable-readiness", + "stable-rc", "multi-node-beta", "security-response" ] diff --git a/tools/release-certification/schemas/stable-1.0-rc-catalog-operations-v1.schema.json b/tools/release-certification/schemas/stable-1.0-rc-catalog-operations-v1.schema.json new file mode 100644 index 0000000000..7bc573fa25 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-rc-catalog-operations-v1.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-rc-catalog-operations-v1.schema.json", + "title": "Cryptad Stable 1.0 RC protected catalog-operations evidence v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "artifactTimestamp", "releaseId", "buildVersion", "sourceCommit", + "status", "fixtureOnly", "simulatedOnly", "nonRelease", "catalogId", "channel", "revision", + "catalogDigest", "signatureDigest", "signingKeyId", "keyRotation", "primary", "mirrors", + "rollback", "advisoryCount", "denylistCount", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-rc-catalog-operations"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "artifactTimestamp": {"type": "string", "format": "date-time"}, + "releaseId": {"type": "string", "minLength": 1}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "sourceCommit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "status": {"const": "pass"}, + "fixtureOnly": {"const": false}, + "simulatedOnly": {"const": false}, + "nonRelease": {"const": false}, + "catalogId": {"type": "string", "minLength": 1}, + "channel": {"const": "stable"}, + "revision": {"type": "integer", "minimum": 0}, + "catalogDigest": {"$ref": "#/$defs/digest"}, + "signatureDigest": {"$ref": "#/$defs/digest"}, + "signingKeyId": {"type": "string", "minLength": 1}, + "keyRotation": { + "type": "object", + "additionalProperties": false, + "required": ["status", "compromised"], + "properties": {"status": {"const": "complete"}, "compromised": {"const": false}} + }, + "primary": {"$ref": "#/$defs/health"}, + "mirrors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/mirrorHealth"}}, + "rollback": { + "type": "object", + "additionalProperties": false, + "required": ["status", "revision", "digest", "signatureVerified"], + "properties": { + "status": {"const": "pass"}, + "revision": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"}, + "signatureVerified": {"const": true} + } + }, + "advisoryCount": {"type": "integer", "minimum": 0}, + "denylistCount": {"type": "integer", "minimum": 0}, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": { + "status": {"const": "pass"}, + "findingCount": {"const": 0}, + "findings": {"type": "array", "maxItems": 0} + } + } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "health": { + "type": "object", + "additionalProperties": false, + "required": ["status", "signatureVerified", "revision", "digest"], + "properties": { + "status": {"const": "pass"}, + "signatureVerified": {"const": true}, + "revision": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"} + } + }, + "mirrorHealth": { + "type": "object", + "additionalProperties": false, + "required": ["status", "signatureVerified", "revision", "digest", "transportFallbackOnly"], + "properties": { + "status": {"const": "pass"}, + "signatureVerified": {"const": true}, + "revision": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"}, + "transportFallbackOnly": {"const": true} + } + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-rc-freeze-exceptions-v1.schema.json b/tools/release-certification/schemas/stable-1.0-rc-freeze-exceptions-v1.schema.json new file mode 100644 index 0000000000..e6b14b35e0 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-rc-freeze-exceptions-v1.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-rc-freeze-exceptions-v1.schema.json", + "title": "Cryptad Stable 1.0 RC freeze exception collection v1", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "kind", "authorizationRole", "redaction", "exceptions"], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-rc-freeze-exceptions"}, + "authorizationRole": {"const": "stable-release-manager"}, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": { + "status": {"const": "pass"}, + "findingCount": {"const": 0}, + "findings": {"type": "array", "maxItems": 0} + } + }, + "exceptions": { + "type": "array", + "items": {"$ref": "#/$defs/exception"} + } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "nonEmpty": {"type": "string", "minLength": 1}, + "timestamp": {"type": "string", "format": "date-time"}, + "exception": { + "type": "object", + "additionalProperties": false, + "required": [ + "exceptionId", "releaseId", "buildVersion", "affectedSection", "affectedItem", + "beforeDigest", "afterDigest", "reason", "issueKind", "issueReference", "owner", "approver", + "createdAt", "expiresAt", "requiredRerunScope", "finalVerificationResult" + ], + "properties": { + "exceptionId": {"$ref": "#/$defs/nonEmpty"}, + "releaseId": {"$ref": "#/$defs/nonEmpty"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "affectedSection": {"enum": ["candidate", "platformApi", "stableCatalog", "firstPartyApps", "contentFormatProfiles", "limitationsAndPolicy"]}, + "affectedItem": {"$ref": "#/$defs/nonEmpty"}, + "beforeDigest": {"$ref": "#/$defs/digest"}, + "afterDigest": {"$ref": "#/$defs/digest"}, + "reason": {"$ref": "#/$defs/nonEmpty"}, + "issueKind": {"enum": ["blocker", "security"]}, + "issueReference": {"$ref": "#/$defs/nonEmpty"}, + "owner": {"$ref": "#/$defs/nonEmpty"}, + "approver": {"$ref": "#/$defs/nonEmpty"}, + "createdAt": {"$ref": "#/$defs/timestamp"}, + "expiresAt": {"$ref": "#/$defs/timestamp"}, + "requiredRerunScope": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/nonEmpty"} + }, + "finalVerificationResult": {"const": "pass"} + } + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-rc-freeze-v1.schema.json b/tools/release-certification/schemas/stable-1.0-rc-freeze-v1.schema.json new file mode 100644 index 0000000000..19899384a0 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-rc-freeze-v1.schema.json @@ -0,0 +1,218 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-rc-freeze-v1.schema.json", + "title": "Cryptad Stable 1.0 RC freeze manifest v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "kind", + "stableMilestone", + "contentDigest", + "candidate", + "platformApi", + "stableCatalog", + "firstPartyApps", + "contentFormatProfiles", + "limitationsAndPolicy", + "acceptedFreezeExceptions" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-rc-freeze"}, + "stableMilestone": {"const": "1.0"}, + "contentDigest": {"$ref": "#/$defs/digest"}, + "candidate": { + "type": "object", + "additionalProperties": false, + "required": [ + "releaseId", "buildVersion", "sourceCommit", "sourceRef", "sourceProvenanceDigest", + "generationTool", "generationToolVersion", "productionBetaSummaryDigest", + "productionDistributionDigest", + "stableReadinessSummaryDigest", "releaseCertificationSummaryDigest", "goNoGoSummaryDigest", + "ecosystemMatrixDigest", "releaseHistoryDigest", "previousCandidateDigest", "liveNetworkDigest", + "multiNodeSoakDigest", "networkScaleSoakDigest", "securityDrillDigest", + "thirdPartyIntakeDigest", "catalogOperationsDigest" + ], + "properties": { + "releaseId": {"$ref": "#/$defs/nonEmpty"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "sourceCommit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "sourceRef": {"$ref": "#/$defs/nonEmpty"}, + "sourceProvenanceDigest": {"$ref": "#/$defs/digest"}, + "generationTool": {"const": "stable-1.0-rc"}, + "generationToolVersion": {"const": 1}, + "productionBetaSummaryDigest": {"$ref": "#/$defs/digest"}, + "productionDistributionDigest": {"$ref": "#/$defs/digest"}, + "stableReadinessSummaryDigest": {"$ref": "#/$defs/digest"}, + "releaseCertificationSummaryDigest": {"$ref": "#/$defs/digest"}, + "goNoGoSummaryDigest": {"$ref": "#/$defs/digest"}, + "ecosystemMatrixDigest": {"$ref": "#/$defs/digest"}, + "releaseHistoryDigest": {"$ref": "#/$defs/digest"}, + "previousCandidateDigest": {"$ref": "#/$defs/digest"}, + "liveNetworkDigest": {"$ref": "#/$defs/digest"}, + "multiNodeSoakDigest": {"$ref": "#/$defs/digest"}, + "networkScaleSoakDigest": {"$ref": "#/$defs/digest"}, + "securityDrillDigest": {"$ref": "#/$defs/digest"}, + "thirdPartyIntakeDigest": {"$ref": "#/$defs/digest"}, + "catalogOperationsDigest": {"$ref": "#/$defs/digest"} + } + }, + "platformApi": {"$ref": "#/$defs/platformApi"}, + "stableCatalog": {"$ref": "#/$defs/stableCatalog"}, + "firstPartyApps": { + "type": "array", + "minItems": 7, + "maxItems": 7, + "uniqueItems": true, + "items": {"$ref": "#/$defs/firstPartyApp"} + }, + "contentFormatProfiles": { + "type": "array", + "minItems": 5, + "maxItems": 5, + "uniqueItems": true, + "items": {"$ref": "#/$defs/contentProfile"} + }, + "limitationsAndPolicy": {"$ref": "#/$defs/limitationsAndPolicy"}, + "acceptedFreezeExceptions": { + "type": "array", + "items": {"$ref": "stable-1.0-rc-freeze-exceptions-v1.schema.json#/$defs/exception"} + } + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "nonEmpty": {"type": "string", "minLength": 1}, + "health": { + "type": "object", + "additionalProperties": false, + "required": ["status", "signatureVerified", "revision", "digest"], + "properties": { + "status": {"const": "pass"}, + "signatureVerified": {"const": true}, + "revision": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"} + } + }, + "mirrorHealth": { + "type": "object", + "additionalProperties": false, + "required": ["status", "signatureVerified", "revision", "digest", "transportFallbackOnly"], + "properties": { + "status": {"const": "pass"}, + "signatureVerified": {"const": true}, + "revision": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"}, + "transportFallbackOnly": {"const": true} + } + }, + "platformApi": { + "type": "object", + "additionalProperties": false, + "required": ["baselineName", "baselineContractVersion", "baselineDigest", "currentContractVersion", "currentContractDigest", "compatibilityWindowPolicyDigest", "stableCapabilityCount", "stableEndpointCount", "experimentalCapabilityCount", "experimentalEndpointCount", "stableBreakingChangeVerification", "verificationReportDigest"], + "properties": { + "baselineName": {"const": "1.0"}, + "baselineContractVersion": {"type": "integer", "minimum": 1}, + "baselineDigest": {"$ref": "#/$defs/digest"}, + "currentContractVersion": {"type": "integer", "minimum": 1}, + "currentContractDigest": {"$ref": "#/$defs/digest"}, + "compatibilityWindowPolicyDigest": {"$ref": "#/$defs/digest"}, + "stableCapabilityCount": {"type": "integer", "minimum": 1}, + "stableEndpointCount": {"type": "integer", "minimum": 1}, + "experimentalCapabilityCount": {"type": "integer", "minimum": 0}, + "experimentalEndpointCount": {"type": "integer", "minimum": 0}, + "stableBreakingChangeVerification": {"const": "pass"}, + "verificationReportDigest": {"$ref": "#/$defs/digest"} + } + }, + "stableCatalog": { + "type": "object", + "additionalProperties": false, + "required": ["catalogId", "channel", "catalogVersion", "edition", "revision", "catalogDigest", "signatureDigest", "signatureAliasDigest", "catalogSigningKeyId", "artifactTimestamp", "keyRotationStatus", "primaryHealth", "mirrorHealth", "verifiedRollback", "securityAdvisoryCount", "denylistCount", "orderedEntries"], + "properties": { + "catalogId": {"$ref": "#/$defs/nonEmpty"}, + "channel": {"const": "stable"}, + "catalogVersion": {"type": "integer", "minimum": 1}, + "edition": {"type": "integer", "minimum": 0}, + "revision": {"type": "integer", "minimum": 0}, + "catalogDigest": {"$ref": "#/$defs/digest"}, + "signatureDigest": {"$ref": "#/$defs/digest"}, + "signatureAliasDigest": {"$ref": "#/$defs/digest"}, + "catalogSigningKeyId": {"$ref": "#/$defs/nonEmpty"}, + "artifactTimestamp": {"type": "string", "format": "date-time"}, + "keyRotationStatus": {"type": "object", "additionalProperties": false, "required": ["status", "compromised"], "properties": {"status": {"const": "complete"}, "compromised": {"const": false}}}, + "primaryHealth": {"$ref": "#/$defs/health"}, + "mirrorHealth": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/mirrorHealth"}}, + "verifiedRollback": {"$ref": "#/$defs/health"}, + "securityAdvisoryCount": {"type": "integer", "minimum": 0}, + "denylistCount": {"type": "integer", "minimum": 0}, + "orderedEntries": {"type": "array", "minItems": 7, "maxItems": 7, "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["appId", "version"], "properties": {"appId": {"enum": ["queue-manager", "publisher", "site-publisher", "profile-publisher", "social-inbox", "feed-reader", "trust-graph"]}, "version": {"$ref": "#/$defs/nonEmpty"}}}} + } + }, + "firstPartyApp": { + "type": "object", + "additionalProperties": false, + "required": ["appId", "version", "channel", "supportStatus", "deprecationStatus", "supportLevel", "replacementAppId", "bundleDigest", "bundleSizeBytes", "appSigningKeyId", "reviewReceiptDigest", "reviewerKeyId", "manifestDigest", "declaredPermissionSetDigest", "targetApiStability", "apiCompatibilityResult", "apiCompatibilityEvidenceDigest", "appDataSchemaVersion", "migrationReadiness", "backupRestore", "betaReadinessEvidenceDigest", "supportMetadataDigest", "supportUri", "redactedDiagnosticsReadiness", "allowedStableLimitationId"], + "properties": { + "appId": {"enum": ["queue-manager", "publisher", "site-publisher", "profile-publisher", "social-inbox", "feed-reader", "trust-graph"]}, + "version": {"$ref": "#/$defs/nonEmpty"}, + "channel": {"const": "stable"}, + "supportStatus": {"const": "supported"}, + "deprecationStatus": {"enum": ["none", "deprecated"]}, + "supportLevel": {"enum": ["core", "maintained", "local-rc"]}, + "replacementAppId": {"type": ["string", "null"]}, + "bundleDigest": {"$ref": "#/$defs/digest"}, + "bundleSizeBytes": {"type": "integer", "minimum": 1}, + "appSigningKeyId": {"$ref": "#/$defs/nonEmpty"}, + "reviewReceiptDigest": {"$ref": "#/$defs/digest"}, + "reviewerKeyId": {"$ref": "#/$defs/nonEmpty"}, + "manifestDigest": {"$ref": "#/$defs/digest"}, + "declaredPermissionSetDigest": {"$ref": "#/$defs/digest"}, + "targetApiStability": {"enum": ["stable", "experimental"]}, + "apiCompatibilityResult": {"const": "pass"}, + "apiCompatibilityEvidenceDigest": {"$ref": "#/$defs/digest"}, + "appDataSchemaVersion": {"oneOf": [{"type": "integer", "minimum": 1}, {"const": "not-applicable"}]}, + "migrationReadiness": {"$ref": "#/$defs/nonEmpty"}, + "backupRestore": {"$ref": "#/$defs/nonEmpty"}, + "betaReadinessEvidenceDigest": {"$ref": "#/$defs/digest"}, + "supportMetadataDigest": {"$ref": "#/$defs/digest"}, + "supportUri": {"type": "string", "format": "uri"}, + "redactedDiagnosticsReadiness": {"const": "redacted-summary-only"}, + "allowedStableLimitationId": {"type": ["string", "null"]} + } + }, + "contentProfile": { + "type": "object", + "additionalProperties": false, + "required": ["profileId", "version", "status", "descriptorDigest", "canonicalizationRulesDigest", "maximumSizePolicy", "signaturePayloadRules", "parserValidatorCompatibilityEvidenceDigest"], + "properties": { + "profileId": {"enum": ["crypta.profile.v1", "crypta.feed.snapshot.v1", "crypta.trust.statement.v1", "crypta.social.message.v1", "crypta.social.outbox.v1"]}, + "version": {"type": "integer", "minimum": 1}, + "status": {"enum": ["stable", "beta", "experimental", "deprecated"]}, + "descriptorDigest": {"$ref": "#/$defs/digest"}, + "canonicalizationRulesDigest": {"$ref": "#/$defs/digest"}, + "maximumSizePolicy": {"type": "object", "additionalProperties": false, "required": ["documentBytes", "signedPayloadBytes"], "properties": {"documentBytes": {"type": "integer", "minimum": 1}, "signedPayloadBytes": {"type": ["integer", "null"], "minimum": 1}}}, + "signaturePayloadRules": {"type": "object", "additionalProperties": false, "required": ["signed", "signingDomain"], "properties": {"signed": {"type": "boolean"}, "signingDomain": {"type": ["string", "null"]}}}, + "parserValidatorCompatibilityEvidenceDigest": {"$ref": "#/$defs/digest"} + } + }, + "limitationsAndPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["stableReadinessPolicyDigest", "allowedLimitations", "allowedLimitationsDigest", "allowedLimitationCount", "disallowedLimitationCount", "betaOnlyLimitationCount", "publicBetaKnownIssuesDigest", "stableKnownLimitationsDigest", "securityDrillSummaryDigest", "legacyPluginAdminFreezeEvidenceDigest", "supportFeedbackReadinessEvidenceDigest"], + "properties": { + "stableReadinessPolicyDigest": {"$ref": "#/$defs/digest"}, + "allowedLimitations": {"type": "array", "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["id", "title", "summary", "category", "classification", "status", "owner", "boundedBy", "evidenceIds"], "properties": {"id": {"$ref": "#/$defs/nonEmpty"}, "title": {"$ref": "#/$defs/nonEmpty"}, "summary": {"$ref": "#/$defs/nonEmpty"}, "category": {"$ref": "#/$defs/nonEmpty"}, "classification": {"const": "allowed-for-stable-1.0"}, "status": {"const": "open"}, "owner": {"$ref": "#/$defs/nonEmpty"}, "boundedBy": {"$ref": "#/$defs/nonEmpty"}, "evidenceIds": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}}}}}, + "allowedLimitationsDigest": {"$ref": "#/$defs/digest"}, + "allowedLimitationCount": {"type": "integer", "minimum": 0}, + "disallowedLimitationCount": {"const": 0}, + "betaOnlyLimitationCount": {"const": 0}, + "publicBetaKnownIssuesDigest": {"$ref": "#/$defs/digest"}, + "stableKnownLimitationsDigest": {"$ref": "#/$defs/digest"}, + "securityDrillSummaryDigest": {"$ref": "#/$defs/digest"}, + "legacyPluginAdminFreezeEvidenceDigest": {"$ref": "#/$defs/digest"}, + "supportFeedbackReadinessEvidenceDigest": {"$ref": "#/$defs/digest"} + } + } + } +} diff --git a/tools/release-certification/stable-1.0-known-limitations.json b/tools/release-certification/stable-1.0-known-limitations.json index e1fc845582..bc3314981e 100644 --- a/tools/release-certification/stable-1.0-known-limitations.json +++ b/tools/release-certification/stable-1.0-known-limitations.json @@ -9,6 +9,7 @@ "category": "trust-graph-local-scope", "classification": "allowed-for-stable-1.0", "status": "open", + "owner": "crypta-core", "summary": "Trust Graph Local RC remains local advisory trust, not a global Web of Trust, routing policy, crawler, or legacy WoT compatibility layer.", "evidenceIds": [ "app-platform.trust-graph-rc-scope-and-safety", @@ -23,6 +24,7 @@ "category": "social-inbox-local-scope", "classification": "allowed-for-stable-1.0", "status": "open", + "owner": "crypta-core", "summary": "Social Inbox RC remains a local threaded social/message reference app and does not provide Freetalk, Sone, Freemail, or daemon-core social protocol compatibility.", "evidenceIds": [ "reference-app.social-inbox", @@ -37,6 +39,7 @@ "category": "future-mail-service", "classification": "allowed-for-stable-1.0", "status": "open", + "owner": "crypta-core", "summary": "Freemail-like encrypted mail migration remains documented as future app or app-service work and is not a Stable 1.0 compatibility promise.", "evidenceIds": [ "legacy-plugin.migration-finalization", @@ -50,6 +53,7 @@ "category": "third-party-intake-beta-limited", "classification": "allowed-for-stable-1.0", "status": "open", + "owner": "crypta-core", "summary": "Third-party app intake may remain beta-limited if at least one stable-target sample app passes submission, pre-review, review decision, beta catalog candidate, install smoke, rejection, caution, resubmission, compatibility, docs, and redaction evidence.", "evidenceIds": [ "third-party-intake.review-decision",