diff --git a/.github/workflows/macos-private-build.yml b/.github/workflows/macos-private-build.yml index ece281ee..97d37e55 100644 --- a/.github/workflows/macos-private-build.yml +++ b/.github/workflows/macos-private-build.yml @@ -37,24 +37,10 @@ jobs: env: UV_MANAGED_PYTHON: "true" WCE_MACOS_SIGNING_MODE: self-signed - # The artifact repository identifies the producer; the download repository - # identifies where the immutable asset is currently hosted. - WCE_MACOS_XKEY_ARTIFACT_REPOSITORY: ${{ vars.WCE_MACOS_XKEY_ARTIFACT_REPOSITORY }} - WCE_MACOS_XKEY_ARTIFACT_DOWNLOAD_REPOSITORY: ${{ vars.WCE_MACOS_XKEY_ARTIFACT_DOWNLOAD_REPOSITORY }} - WCE_MACOS_XKEY_ARTIFACT_SHA256: ${{ vars.WCE_MACOS_XKEY_ARTIFACT_SHA256 }} - WCE_MACOS_XKEY_ARTIFACT_RUN_ID: ${{ vars.WCE_MACOS_XKEY_ARTIFACT_RUN_ID }} - WCE_MACOS_XKEY_SOURCE_REVISION: ${{ vars.WCE_MACOS_XKEY_SOURCE_REVISION }} - WCE_MACOS_XKEY_BUILD_ID: ${{ vars.WCE_MACOS_XKEY_BUILD_ID }} WCE_MACOS_KEY_HELPER_SIGNER_SHA256: ${{ vars.WCE_MACOS_KEY_HELPER_SIGNER_SHA256 }} WCE_MACOS_WCDA_HOST_SIGNER_SHA256: ${{ vars.WCE_MACOS_WCDA_HOST_SIGNER_SHA256 }} WCE_MACOS_WCDA_HOST_SIGNING_IDENTITY: ${{ vars.WCE_MACOS_WCDA_HOST_SIGNING_IDENTITY }} WCE_MACOS_XKEY_ALLOW_DEVELOPMENT_ARTIFACTS: "0" - WCE_NATIVE_CORE_ARTIFACT_REPOSITORY: ${{ vars.WCE_NATIVE_CORE_ARTIFACT_REPOSITORY }} - WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY: ${{ vars.WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY }} - WCE_NATIVE_CORE_ARTIFACT_SHA256: ${{ vars.WCE_NATIVE_CORE_ARTIFACT_SHA256 }} - WCE_NATIVE_CORE_ARTIFACT_RUN_ID: ${{ vars.WCE_NATIVE_CORE_ARTIFACT_RUN_ID }} - WCE_NATIVE_CORE_SOURCE_REVISION: ${{ vars.WCE_NATIVE_CORE_SOURCE_REVISION }} - WCE_NATIVE_CORE_BUILD_ID: ${{ vars.WCE_NATIVE_CORE_BUILD_ID }} WCE_NATIVE_CORE_CLIENT_SIGNER_SHA256: ${{ vars.WCE_NATIVE_CORE_CLIENT_SIGNER_SHA256 }} WCE_NATIVE_CORE_BROKER_SIGNER_SHA256: ${{ vars.WCE_NATIVE_CORE_BROKER_SIGNER_SHA256 }} WCE_NATIVE_CORE_HOST_SIGNER_SHA256: ${{ vars.WCE_NATIVE_CORE_HOST_SIGNER_SHA256 }} @@ -64,13 +50,6 @@ jobs: WCE_NATIVE_CORE_HOST_SIGNING_IDENTIFIER: ${{ vars.WCE_NATIVE_CORE_HOST_SIGNING_IDENTIFIER }} WCE_NATIVE_CORE_REQUIRED: "1" WCE_NATIVE_CORE_ALLOW_DEVELOPMENT_ARTIFACTS: "0" - WCE_INTEGRITY_ARTIFACT_REPOSITORY: ${{ vars.WCE_INTEGRITY_ARTIFACT_REPOSITORY }} - WCE_INTEGRITY_ARTIFACT_DOWNLOAD_REPOSITORY: ${{ vars.WCE_INTEGRITY_ARTIFACT_DOWNLOAD_REPOSITORY }} - WCE_INTEGRITY_ARTIFACT_RUN_ID: ${{ vars.WCE_INTEGRITY_ARTIFACT_RUN_ID }} - WCE_INTEGRITY_ARTIFACT_SHA256: ${{ vars.WCE_INTEGRITY_ARTIFACT_SHA256 }} - WCE_INTEGRITY_SOURCE_REVISION: ${{ vars.WCE_INTEGRITY_SOURCE_REVISION }} - WCE_INTEGRITY_BUILD_ID: ${{ vars.WCE_INTEGRITY_BUILD_ID }} - WCE_INTEGRITY_BINARY_SHA256: ${{ vars.WCE_INTEGRITY_BINARY_SHA256 }} WCE_INTEGRITY_UI_SOURCE_REPOSITORY: ${{ github.repository }} WCE_INTEGRITY_UI_SOURCE_REVISION: ${{ github.sha }} WCE_INTEGRITY_REQUIRED: "1" @@ -87,7 +66,12 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Verify immutable source and protected pins + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version-file: .python-version + + - name: Verify immutable source and package version shell: bash env: WORKFLOW_REF: ${{ github.ref }} @@ -124,6 +108,24 @@ jobs: [[ "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]] printf 'PACKAGE_VERSION=%s\n' "$package_version" >> "$GITHUB_ENV" + - name: Rebuild macOS native components for this release + shell: bash + env: + GH_TOKEN: ${{ secrets.WCE_NATIVE_CORE_PRODUCER_TOKEN }} + run: | + set -euo pipefail + python3 tools/rebuild_wcdb_release.py \ + --component macos-native \ + --component macos-xkey \ + --component macos-integrity \ + --output-root "$RUNNER_TEMP" + + - name: Verify protected pins + shell: bash + env: + WORKFLOW_REVISION: ${{ github.sha }} + run: | + set -euo pipefail [[ "$WCE_MACOS_XKEY_ARTIFACT_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] if [[ -n "${WCE_MACOS_XKEY_ARTIFACT_DOWNLOAD_REPOSITORY:-}" ]]; then [[ "$WCE_MACOS_XKEY_ARTIFACT_DOWNLOAD_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] @@ -174,139 +176,6 @@ jobs: test "$WCE_INTEGRITY_UI_SOURCE_REPOSITORY" = "$GITHUB_REPOSITORY" test "$WCE_INTEGRITY_UI_SOURCE_REVISION" = "$WORKFLOW_REVISION" - - name: Download exact Producer helper artifact - timeout-minutes: 15 - shell: bash - env: - GH_TOKEN: ${{ secrets.WCE_MACOS_PRODUCER_READ_TOKEN }} - run: | - set -euo pipefail - test -n "$GH_TOKEN" - artifact_dir="$RUNNER_TEMP/wda-xkey" - release_repository="${WCE_MACOS_XKEY_ARTIFACT_DOWNLOAD_REPOSITORY:-}" - release_tag="macos-xkey-$WCE_MACOS_XKEY_BUILD_ID" - release_asset="wda-xkey-macos-universal-production-$WCE_MACOS_XKEY_BUILD_ID.zip" - release_archive="$RUNNER_TEMP/$release_asset" - downloaded=0 - for attempt in 1 2 3; do - rm -rf "$artifact_dir" - mkdir -p "$artifact_dir" - rm -f "$release_archive" - if [[ -n "$release_repository" ]] && \ - gh release download "$release_tag" \ - --repo "$release_repository" \ - --pattern "$release_asset" \ - --dir "$RUNNER_TEMP" - then - actual_sha256="$(shasum -a 256 "$release_archive" | awk '{print $1}')" - test "$actual_sha256" = "$WCE_MACOS_XKEY_ARTIFACT_SHA256" - /usr/bin/unzip -q "$release_archive" -d "$artifact_dir" - downloaded=1 - break - elif gh run download "$WCE_MACOS_XKEY_ARTIFACT_RUN_ID" \ - --repo "$WCE_MACOS_XKEY_ARTIFACT_REPOSITORY" \ - --name wda-xkey-macos-universal-production \ - --dir "$artifact_dir" - then - downloaded=1 - break - fi - if [ "$attempt" -lt 3 ]; then - sleep "$((attempt * 15))" - fi - done - test "$downloaded" = 1 - test -f "$artifact_dir/wda_xkey_helper" - echo "WCE_MACOS_XKEY_ARTIFACT_DIR=$artifact_dir" >> "$GITHUB_ENV" - - - name: Download exact Producer native-core artifact - timeout-minutes: 15 - shell: bash - env: - GH_TOKEN: ${{ secrets.WCE_MACOS_PRODUCER_READ_TOKEN }} - run: | - set -euo pipefail - test -n "$GH_TOKEN" - artifact_dir="$RUNNER_TEMP/wechatdb-native-macos-arm64-production" - release_repository="${WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY:-}" - release_tag="macos-native-$WCE_NATIVE_CORE_BUILD_ID" - release_asset="wechatdb-native-macos-arm64-production-$WCE_NATIVE_CORE_BUILD_ID.zip" - release_archive="$RUNNER_TEMP/$release_asset" - downloaded=0 - for attempt in 1 2 3; do - rm -rf "$artifact_dir" - mkdir -p "$artifact_dir" - rm -f "$release_archive" - if [[ -n "$release_repository" ]] && \ - gh release download "$release_tag" \ - --repo "$release_repository" \ - --pattern "$release_asset" \ - --dir "$RUNNER_TEMP" - then - actual_sha256="$(shasum -a 256 "$release_archive" | awk '{print $1}')" - test "$actual_sha256" = "$WCE_NATIVE_CORE_ARTIFACT_SHA256" - /usr/bin/unzip -q "$release_archive" -d "$artifact_dir" - downloaded=1 - break - elif gh run download "$WCE_NATIVE_CORE_ARTIFACT_RUN_ID" \ - --repo "$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY" \ - --name wechatdb-native-macos-arm64-production \ - --dir "$artifact_dir" - then - downloaded=1 - break - fi - if [ "$attempt" -lt 3 ]; then - sleep "$((attempt * 15))" - fi - done - test "$downloaded" = 1 - test -f "$artifact_dir/libwechatdb_client.dylib" - test -f "$artifact_dir/wechatdb_broker" - test -f "$artifact_dir/wechatdb_native_build.json" - echo "WCE_NATIVE_CORE_ARTIFACT_DIR=$artifact_dir" >> "$GITHUB_ENV" - - - name: Download exact private export-integrity artifact - timeout-minutes: 15 - shell: bash - env: - GH_TOKEN: ${{ secrets.WCE_MACOS_PRODUCER_READ_TOKEN }} - run: | - set -euo pipefail - test -n "$GH_TOKEN" - release_tag="macos-integrity-$WCE_INTEGRITY_BUILD_ID" - asset_name="wce-integrity-macos-arm64-production-$WCE_INTEGRITY_BUILD_ID.zip" - download_repository="${WCE_INTEGRITY_ARTIFACT_DOWNLOAD_REPOSITORY:-$WCE_INTEGRITY_ARTIFACT_REPOSITORY}" - archive="$RUNNER_TEMP/$asset_name" - artifact_dir="$RUNNER_TEMP/wce-integrity-macos-arm64-production" - downloaded=0 - test ! -e "$artifact_dir" - for attempt in 1 2 3; do - rm -f "$archive" - if gh release download "$release_tag" \ - --repo "$download_repository" \ - --pattern "$asset_name" \ - --dir "$RUNNER_TEMP" - then - downloaded=1 - break - fi - if [ "$attempt" -lt 3 ]; then - sleep "$((attempt * 15))" - fi - done - test "$downloaded" = 1 - test -s "$archive" - actual_sha256="$(shasum -a 256 "$archive" | awk '{print $1}')" - test "$actual_sha256" = "$WCE_INTEGRITY_ARTIFACT_SHA256" - mkdir -p "$artifact_dir" - /usr/bin/unzip -q "$archive" -d "$artifact_dir" - test -f "$artifact_dir/libwce_integrity.dylib" - test -f "$artifact_dir/wce_integrity_build.json" - test -f "$artifact_dir/provenance.json" - test -f "$artifact_dir/SHA256SUMS.txt" - echo "WCE_INTEGRITY_ARTIFACT_DIR=$artifact_dir" >> "$GITHUB_ENV" - - name: Import persistent self-signed host identity timeout-minutes: 5 shell: bash @@ -412,11 +281,6 @@ jobs: frontend/package-lock.json desktop/package-lock.json - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version-file: .python-version - - name: Install build dependencies shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8a2abb3..49324605 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,28 +52,34 @@ jobs: "TAG_NAME=$tag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version-file: .python-version + + - name: Rebuild Windows native core for this release + shell: pwsh + env: + GH_TOKEN: ${{ secrets.WCE_NATIVE_CORE_PRODUCER_TOKEN }} + run: | + python tools/rebuild_wcdb_release.py ` + --component windows-native ` + --output-root $env:RUNNER_TEMP + - name: Require pinned native artifact source id: native-artifact-source shell: pwsh env: - # The artifact repository identifies the producer; the download - # repository identifies where the immutable asset is hosted. - NATIVE_ARTIFACT_REPOSITORY: ${{ vars.WCE_NATIVE_CORE_ARTIFACT_REPOSITORY }} - NATIVE_ARTIFACT_DOWNLOAD_REPOSITORY: ${{ vars.WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY }} - NATIVE_ARTIFACT_RUN_ID: ${{ vars.WCE_NATIVE_CORE_ARTIFACT_RUN_ID }} - NATIVE_ARTIFACT_SHA256: ${{ vars.WCE_NATIVE_CORE_ARTIFACT_SHA256 }} - NATIVE_SOURCE_REVISION: ${{ vars.WCE_NATIVE_CORE_SOURCE_REVISION }} - NATIVE_BUILD_ID: ${{ vars.WCE_NATIVE_CORE_BUILD_ID }} NATIVE_CLIENT_SIGNER_SHA256: ${{ vars.WCE_NATIVE_CORE_CLIENT_SIGNER_SHA256 }} NATIVE_BROKER_SIGNER_SHA256: ${{ vars.WCE_NATIVE_CORE_BROKER_SIGNER_SHA256 }} PRIVATE_ROOT_SHA256: ${{ vars.WCE_WINDOWS_PRIVATE_ROOT_SHA256 }} run: | - $repository = [string]$env:NATIVE_ARTIFACT_REPOSITORY + $repository = [string]$env:WCE_NATIVE_CORE_ARTIFACT_REPOSITORY $repository = $repository.Trim() if ($repository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { throw "WCE_NATIVE_CORE_ARTIFACT_REPOSITORY must be an explicit owner/repository value." } - $downloadRepository = [string]$env:NATIVE_ARTIFACT_DOWNLOAD_REPOSITORY + $downloadRepository = [string]$env:WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY $downloadRepository = $downloadRepository.Trim() if ([string]::IsNullOrWhiteSpace($downloadRepository)) { $downloadRepository = $repository @@ -81,23 +87,23 @@ jobs: throw "WCE_NATIVE_CORE_ARTIFACT_DOWNLOAD_REPOSITORY must be an explicit owner/repository value." } - $rawRunId = ([string]$env:NATIVE_ARTIFACT_RUN_ID).Trim() + $rawRunId = ([string]$env:WCE_NATIVE_CORE_ARTIFACT_RUN_ID).Trim() [long]$runId = 0 if (-not [long]::TryParse($rawRunId, [ref]$runId) -or $runId -le 0) { throw "WCE_NATIVE_CORE_ARTIFACT_RUN_ID must be an explicit positive workflow run ID." } - $artifactSha256 = ([string]$env:NATIVE_ARTIFACT_SHA256).Trim().ToLowerInvariant() + $artifactSha256 = ([string]$env:WCE_NATIVE_CORE_ARTIFACT_SHA256).Trim().ToLowerInvariant() if ($artifactSha256 -cnotmatch '^[0-9a-f]{64}$' -or $artifactSha256 -match '^0{64}$') { throw "WCE_NATIVE_CORE_ARTIFACT_SHA256 must be a non-zero lowercase SHA-256 digest." } - $sourceRevision = ([string]$env:NATIVE_SOURCE_REVISION).Trim() + $sourceRevision = ([string]$env:WCE_NATIVE_CORE_SOURCE_REVISION).Trim() if ($sourceRevision -cnotmatch '^[0-9a-f]{40}$') { throw "WCE_NATIVE_CORE_SOURCE_REVISION must be an exact lowercase 40-character Git revision." } - $buildId = ([string]$env:NATIVE_BUILD_ID).Trim() + $buildId = ([string]$env:WCE_NATIVE_CORE_BUILD_ID).Trim() if ($buildId -notmatch '^[A-Za-z0-9._-]{8,128}$' -or $buildId -match '(^|[._-])(dev|debug|test|local|snapshot|staging)([._-]|$)') { throw "WCE_NATIVE_CORE_BUILD_ID must be an immutable production build ID." @@ -199,42 +205,6 @@ jobs: frontend/package-lock.json desktop/package-lock.json - - name: Download pinned native source-public release asset - shell: pwsh - env: - GH_TOKEN: ${{ secrets.WCE_NATIVE_CORE_ARTIFACT_READ_TOKEN }} - NATIVE_REPOSITORY: ${{ steps.native-artifact-source.outputs.download-repository }} - NATIVE_BUILD_ID: ${{ steps.native-artifact-source.outputs.build-id }} - NATIVE_ARTIFACT_SHA256: ${{ steps.native-artifact-source.outputs.artifact-sha256 }} - run: | - if ([string]::IsNullOrWhiteSpace($env:GH_TOKEN)) { - throw "WCE_NATIVE_CORE_ARTIFACT_READ_TOKEN is required to download the private native Release." - } - $releaseTag = "windows-native-$env:NATIVE_BUILD_ID" - $assetName = "wechatdb-native-windows-x64-source-public-$env:NATIVE_BUILD_ID.zip" - $archivePath = Join-Path $env:RUNNER_TEMP $assetName - $artifactPath = Join-Path $env:RUNNER_TEMP 'wechatdb-native-windows-x64-source-public' - if ((Test-Path -LiteralPath $archivePath) -or - (Test-Path -LiteralPath $artifactPath)) { - throw "Native Release destination already exists on the clean runner." - } - - gh release download $releaseTag ` - --repo $env:NATIVE_REPOSITORY ` - --pattern $assetName ` - --dir $env:RUNNER_TEMP - if ($LASTEXITCODE -ne 0 -or - -not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { - throw "Failed to download the exact native source-public Release asset." - } - - $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualSha256 -cne $env:NATIVE_ARTIFACT_SHA256) { - throw "Native source-public Release asset SHA-256 does not match the pinned digest." - } - New-Item -ItemType Directory -Path $artifactPath | Out-Null - Expand-Archive -LiteralPath $archivePath -DestinationPath $artifactPath - - name: Import protected cloud signing identity id: cloud-signing shell: powershell @@ -418,11 +388,6 @@ jobs: "WCE_NATIVE_CORE_SECURITY_CHECKPOINT_SET_SHA256=$($manifest.securityCheckpointSetSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version-file: .python-version - - name: Install uv shell: pwsh run: python -m pip install uv diff --git a/docs/release-native-build.md b/docs/release-native-build.md new file mode 100644 index 00000000..09fdb186 --- /dev/null +++ b/docs/release-native-build.md @@ -0,0 +1,44 @@ +# Release native builds + +Windows and macOS packaging rebuild their WCDB components from the current +`2977094657/WCDB` main revision. Each producer receives a unique build ID and +the current UTC time. The signed components and their manifests expire exactly +45 days after that time. A failed producer stops packaging. + +`tools/rebuild_wcdb_release.py` downloads artifacts from those exact producer +runs, verifies GitHub's archive SHA-256 and the new build window, and supplies +the artifact coordinates to the existing signature and provenance checks. +The macOS native core, key helper and export-integrity module are built in +parallel. The integrity module uses the exact WeChatDataAnalysis revision being +packaged. + +## GitHub configuration + +Deploy these production workflows to the main branch of `2977094657/WCDB`: + +- `windows-native-production.yml` +- `macos-native-production.yml` +- `macos-key-capture-production.yml` +- `macos-integrity-production.yml` + +Configure their protected environments with the existing signing identities: +`windows-native-production`, `macos-native-production` and +`macos-xkey-production`. The workflow `vars` and `secrets` entries specify the +required names. Preserve the existing client, broker, helper and host +certificate pins and the export signing key. + +In `LifeArchiveProject/WeChatDataAnalysis`, add repository secret +`WCE_NATIVE_CORE_PRODUCER_TOKEN`. Use a fine-grained token with access only to +`2977094657/WCDB`, Actions read/write and Contents read. Store it directly in +GitHub Actions secrets; do not place it in source files or build arguments. + +Keep the trusted signing pins and host signing secrets in +`windows-private-pki-production` and `macos-private-pki-production`. +The macOS environment requires the native client, broker, host and root pins, +their signing identifiers, and the key-helper and host pins used by +`macos-private-build.yml`. + +Artifact run IDs, build IDs, source revisions and archive/binary hashes are +generated for each packaging run. They do not need repository-variable updates. +Existing installed packages retain their original expiry; users must install +a release containing the newly built components. diff --git a/tools/rebuild_wcdb_release.py b/tools/rebuild_wcdb_release.py new file mode 100644 index 00000000..4ac8eae4 --- /dev/null +++ b/tools/rebuild_wcdb_release.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Build and download this release's signed WCDB components.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time +import zipfile + + +REPOSITORY = "2977094657/WCDB" +LIFETIME_SECONDS = 45 * 24 * 60 * 60 +COMPONENTS = { + "windows-native": ( + "windows-native-production.yml", + "wechatdb-native-windows-x64-source-public", + "wechatdb-native-windows-x64-source-public", + "WCE_NATIVE_CORE", + "wechatdb_native_build.json", + ), + "macos-native": ( + "macos-native-production.yml", + "wechatdb-native-macos-arm64-production", + "wechatdb-native-macos-arm64-production", + "WCE_NATIVE_CORE", + "wechatdb_native_build.json", + ), + "macos-xkey": ( + "macos-key-capture-production.yml", + "wda-xkey-macos-universal-production", + "wda-xkey", + "WCE_MACOS_XKEY", + "wda_xkey_build.json", + ), + "macos-integrity": ( + "macos-integrity-production.yml", + "wce-integrity-macos-arm64-production", + "wce-integrity-macos-arm64-production", + "WCE_INTEGRITY", + "wce_integrity_build.json", + ), +} + + +def api(path: str, payload: dict | None = None): + command = ["gh", "api", f"repos/{REPOSITORY}/{path}"] + if payload is not None: + command += ["--method", "POST", "--input", "-"] + result = subprocess.run( + command, + input=json.dumps(payload) if payload is not None else None, + stdout=subprocess.PIPE, + text=True, + check=True, + ) + return json.loads(result.stdout) if result.stdout.strip() else None + + +def dispatch(component: str, revision: str, issued_at: int) -> dict: + workflow = COMPONENTS[component][0] + build_id = f"wcda-{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}-{component}" + inputs = { + "source_revision": revision, + "build_id": build_id, + "build_issued_at_unix": str(issued_at), + } + if component == "macos-integrity": + inputs["wcda_revision"] = os.environ["GITHUB_SHA"] + api(f"actions/workflows/{workflow}/dispatches", {"ref": "main", "inputs": inputs}) + print(f"Building {component}: {build_id} ({revision})", flush=True) + return {"component": component, "workflow": workflow, "build_id": build_id} + + +def wait_for_build(build: dict, revision: str) -> int: + run_id = None + while run_id is None: + page = 1 + while True: + runs = api( + f"actions/workflows/{build['workflow']}/runs" + f"?event=workflow_dispatch&head_sha={revision}&page={page}" + )["workflow_runs"] + match = next( + (run for run in runs if run["display_title"] == f"WCDB {build['build_id']}"), + None, + ) + if match: + run_id = match["id"] + break + if not runs: + break + page += 1 + if run_id is None: + time.sleep(5) + print(f"Waiting for https://github.com/{REPOSITORY}/actions/runs/{run_id}", flush=True) + run = api(f"actions/runs/{run_id}") + while run["status"] != "completed": + time.sleep(10) + run = api(f"actions/runs/{run_id}") + if run["conclusion"] != "success": + raise RuntimeError(f"WCDB producer {run_id} finished with {run['conclusion']}") + if ( + run["head_sha"] != revision + or run["head_branch"] != "main" + or run["event"] != "workflow_dispatch" + or run["path"] != f".github/workflows/{build['workflow']}" + ): + raise RuntimeError("WCDB producer identity does not match this release") + return run_id + + +def download(build: dict, run_id: int, revision: str, issued_at: int, output_root: Path) -> dict: + component = build["component"] + _, artifact_name, directory_name, prefix, manifest_name = COMPONENTS[component] + artifacts = api(f"actions/runs/{run_id}/artifacts")["artifacts"] + artifact = next(item for item in artifacts if item["name"] == artifact_name) + if artifact["expired"]: + raise RuntimeError(f"Producer artifact has expired: {artifact_name}") + expected_digest = artifact["digest"] + if not re.fullmatch(r"sha256:[0-9a-f]{64}", expected_digest or ""): + raise RuntimeError(f"Producer artifact has no SHA-256 digest: {artifact_name}") + destination = output_root / directory_name + destination.mkdir(parents=True, exist_ok=False) + with tempfile.TemporaryFile() as archive: + subprocess.run( + ["gh", "api", f"repos/{REPOSITORY}/actions/artifacts/{artifact['id']}/zip"], + stdout=archive, + check=True, + ) + archive.seek(0) + digest = hashlib.file_digest(archive, "sha256").hexdigest() + if f"sha256:{digest}" != expected_digest: + raise RuntimeError(f"Producer artifact digest mismatch: {artifact_name}") + archive.seek(0) + with zipfile.ZipFile(archive) as package: + package.extractall(destination) + if component in ("macos-native", "macos-xkey"): + executable = "wechatdb_broker" if component == "macos-native" else "wda_xkey_helper" + (destination / executable).chmod(0o755) + manifest = json.loads((destination / manifest_name).read_text(encoding="utf-8")) + if component.endswith("native"): + identity = manifest["buildId"] + issued = manifest["buildIssuedAtUnix"] + expires = manifest["buildExpiresAtUnix"] + development = manifest["developmentBuild"] + if component == "windows-native" and (manifest.get("readOnlyBuild") is not True + or manifest.get("databaseWriteBuild") is not False + or manifest.get("wechatActions") != []): + raise RuntimeError("Release native core must be read-only") + elif component == "macos-xkey": + identity = manifest["build"]["id"] + issued = manifest["build"]["issuedAtUnix"] + expires = manifest["build"]["expiresAtUnix"] + development = manifest["build"]["development"] + else: + identity = manifest["buildId"] + issued = manifest["buildIssuedAtUnix"] + expires = manifest["buildExpiresAtUnix"] + development = manifest["development"] + if (identity != build["build_id"] or development + or issued != issued_at or expires != issued_at + LIFETIME_SECONDS): + raise RuntimeError(f"{component} was not freshly built with this release's 45-day window") + values = { + f"{prefix}_ARTIFACT_REPOSITORY": REPOSITORY, + f"{prefix}_ARTIFACT_DOWNLOAD_REPOSITORY": REPOSITORY, + f"{prefix}_ARTIFACT_RUN_ID": str(run_id), + f"{prefix}_ARTIFACT_SHA256": digest, + f"{prefix}_SOURCE_REVISION": revision, + f"{prefix}_BUILD_ID": identity, + f"{prefix}_ARTIFACT_DIR": str(destination), + } + if component == "macos-integrity": + with (destination / "libwce_integrity.dylib").open("rb") as binary: + values["WCE_INTEGRITY_BINARY_SHA256"] = hashlib.file_digest(binary, "sha256").hexdigest() + return values + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--component", action="append", choices=COMPONENTS, required=True) + parser.add_argument("--output-root", type=Path, required=True) + args = parser.parse_args() + if not os.environ.get("GH_TOKEN"): + raise SystemExit("WCE_NATIVE_CORE_PRODUCER_TOKEN is required to rebuild WCDB") + revision = api("commits/main")["sha"] + issued_at = int(time.time()) + builds = [dispatch(component, revision, issued_at) for component in args.component] + values = {} + for build in builds: + run_id = wait_for_build(build, revision) + values.update(download(build, run_id, revision, issued_at, args.output_root)) + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as stream: + for name, value in values.items(): + stream.write(f"{name}={value}\n") + print(f"WCDB build window: {issued_at} → {issued_at + LIFETIME_SECONDS}", flush=True) + + +if __name__ == "__main__": + main()