From 1749712b285a027d9ceb052d1101f280e83a7d16 Mon Sep 17 00:00:00 2001 From: infraclaw-dash Date: Wed, 24 Jun 2026 06:05:21 +0000 Subject: [PATCH 1/7] ci: add latest core testnet sync worker --- .../scripts/latest-core-testnet-sync/run.cjs | 307 ++++++++++++++++++ .../latest-core-testnet-sync-status.yml | 98 ++++++ .gitignore | 3 + docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md | 105 ++++++ ops/latest-core-testnet-sync/README.md | 75 +++++ .../install-worker.sh | 53 +++ .../latest-core-testnet-sync.env.example | 32 ++ .../latest-core-testnet-sync.service | 15 + .../latest-core-testnet-sync.timer | 11 + ops/latest-core-testnet-sync/run-worker.sh | 30 ++ 10 files changed, 729 insertions(+) create mode 100755 .github/scripts/latest-core-testnet-sync/run.cjs create mode 100644 .github/workflows/latest-core-testnet-sync-status.yml create mode 100644 docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md create mode 100644 ops/latest-core-testnet-sync/README.md create mode 100755 ops/latest-core-testnet-sync/install-worker.sh create mode 100644 ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example create mode 100644 ops/latest-core-testnet-sync/latest-core-testnet-sync.service create mode 100644 ops/latest-core-testnet-sync/latest-core-testnet-sync.timer create mode 100755 ops/latest-core-testnet-sync/run-worker.sh diff --git a/.github/scripts/latest-core-testnet-sync/run.cjs b/.github/scripts/latest-core-testnet-sync/run.cjs new file mode 100755 index 00000000000..2ff7d49cd24 --- /dev/null +++ b/.github/scripts/latest-core-testnet-sync/run.cjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const repoRoot = process.env.PLATFORM_REPO_DIR || process.cwd(); +const runId = process.env.LATEST_CORE_TESTNET_RUN_ID + || now().replace(/[:.]/g, '-'); +const runDir = process.env.LATEST_CORE_TESTNET_SYNC_RUN_DIR + || path.join(repoRoot, '.latest-core-testnet-sync', runId); +const metadataPath = path.join(runDir, 'run-metadata.json'); + +const STATUS_CONTEXT = 'Latest public Core testnet sync'; + +const STATUS_LABELS = { + sync_passed: 'Sync Passed', + build_failed: 'Build Failed', + sync_failed: 'Sync Failed', +}; + +function now() { + return new Date().toISOString(); +} + +function ensureRunDir() { + fs.mkdirSync(runDir, { recursive: true }); +} + +function appendLog(fileName, message) { + fs.appendFileSync(path.join(runDir, fileName), message); +} + +function writeMetadata(metadata) { + fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); +} + +function firstLine(value) { + return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean); +} + +function gitOutput(args) { + const result = spawnSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + }); + + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`); + } + + return result.stdout.trim(); +} + +function resolveTargetSha() { + return process.env.TARGET_SHA + || process.env.GITHUB_SHA + || gitOutput(['rev-parse', 'HEAD']); +} + +async function runCommand(name, command, env, timeoutMinutes) { + if (!command || !command.trim()) { + throw new Error(`Missing command for phase: ${name}`); + } + + const logFileName = `${name}.log`; + appendLog(logFileName, `$ ${command}\n\n`); + + const timeoutMs = timeoutMinutes * 60 * 1000; + const startedAt = Date.now(); + + return new Promise((resolve, reject) => { + const child = spawn('bash', ['-c', `set -euo pipefail\n${command}`], { + cwd: repoRoot, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`${name} timed out after ${timeoutMinutes} minutes`)); + }, timeoutMs); + + let output = ''; + + child.stdout.on('data', (chunk) => { + process.stdout.write(chunk); + output += chunk.toString(); + appendLog(logFileName, chunk.toString()); + }); + + child.stderr.on('data', (chunk) => { + process.stderr.write(chunk); + output += chunk.toString(); + appendLog(logFileName, chunk.toString()); + }); + + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + + child.on('close', (code, signal) => { + clearTimeout(timeout); + const durationSeconds = Math.round((Date.now() - startedAt) / 1000); + appendLog(logFileName, `\nexit_code=${code} signal=${signal || ''} duration_seconds=${durationSeconds}\n`); + + if (code === 0) { + resolve({ output, durationSeconds }); + return; + } + + reject(new Error(`${name} exited with code ${code}${signal ? ` (${signal})` : ''}`)); + }); + }); +} + +async function resolveLatestCoreVersion(metadata) { + if (process.env.LATEST_CORE_TESTNET_CORE_VERSION) { + return process.env.LATEST_CORE_TESTNET_CORE_VERSION; + } + + if (process.env.LATEST_CORE_TESTNET_CORE_VERSION_COMMAND) { + const result = await runCommand( + 'resolve-core-version', + process.env.LATEST_CORE_TESTNET_CORE_VERSION_COMMAND, + process.env, + Number(process.env.LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES || 30), + ); + const version = firstLine(result.output); + if (!version) { + throw new Error('Core version command did not print a version'); + } + return version; + } + + const releaseRepo = process.env.LATEST_CORE_RELEASE_REPO || 'dashpay/dash'; + const response = await fetch(`https://api.github.com/repos/${releaseRepo}/releases`, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'dash-platform-latest-core-testnet-sync', + ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), + }, + }); + + if (!response.ok) { + throw new Error(`Unable to fetch releases for ${releaseRepo}: HTTP ${response.status}`); + } + + const releases = await response.json(); + const latestPublicRelease = releases.find((release) => !release.draft && !release.prerelease); + if (!latestPublicRelease) { + throw new Error(`No public non-prerelease releases found for ${releaseRepo}`); + } + + metadata.core_release_url = latestPublicRelease.html_url; + return latestPublicRelease.tag_name; +} + +function statusForFailurePhase(phase) { + if (phase === 'platform-build') { + return 'build_failed'; + } + + return 'sync_failed'; +} + +function statusDescription(status, metadata) { + const details = [ + metadata.core_version, + metadata.platform_sha ? metadata.platform_sha.slice(0, 12) : null, + metadata.completed_at, + ].filter(Boolean).join(' '); + + return `${STATUS_LABELS[status]}${details ? ` - ${details}` : ''}`.slice(0, 140); +} + +async function publishCommitStatus(status, metadata) { + if (process.env.SKIP_GITHUB_STATUS === '1') { + console.log(`Skipping GitHub status publish: ${STATUS_LABELS[status]}`); + return; + } + + const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (!githubToken) { + throw new Error('GITHUB_TOKEN or GH_TOKEN is required to publish commit status'); + } + + const repository = process.env.GITHUB_REPOSITORY || 'dashpay/platform'; + if (!repository) { + throw new Error('GITHUB_REPOSITORY is required to publish commit status'); + } + + const targetUrl = metadata.target_url + || process.env.LATEST_CORE_TESTNET_TARGET_URL + || (process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}` + : undefined); + + const body = { + state: status === 'sync_passed' ? 'success' : 'failure', + context: STATUS_CONTEXT, + description: statusDescription(status, metadata), + }; + + if (targetUrl) { + body.target_url = targetUrl; + } + + const response = await fetch(`https://api.github.com/repos/${repository}/statuses/${metadata.target_sha}`, { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${githubToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'dash-platform-latest-core-testnet-sync', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Unable to publish commit status: HTTP ${response.status} ${body}`); + } +} + +async function main() { + ensureRunDir(); + + const timeoutMinutes = Number(process.env.LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES || 1440); + const targetSha = resolveTargetSha(); + const metadata = { + status: 'running', + started_at: now(), + target_sha: targetSha, + platform_sha: targetSha, + run_dir: runDir, + run_id: process.env.GITHUB_RUN_ID || null, + run_attempt: process.env.GITHUB_RUN_ATTEMPT || null, + }; + + writeMetadata(metadata); + + let finalStatus = 'sync_passed'; + + try { + metadata.core_version = await resolveLatestCoreVersion(metadata); + writeMetadata(metadata); + + const phaseEnv = { + ...process.env, + LATEST_CORE_VERSION: metadata.core_version, + PLATFORM_SHA: metadata.platform_sha, + LATEST_CORE_TESTNET_SYNC_RUN_DIR: runDir, + }; + + metadata.phase = 'core-sync'; + writeMetadata(metadata); + await runCommand( + 'core-sync', + process.env.LATEST_CORE_TESTNET_CORE_SYNC_COMMAND, + phaseEnv, + timeoutMinutes, + ); + + metadata.phase = 'platform-build'; + writeMetadata(metadata); + await runCommand( + 'platform-build', + process.env.LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND, + phaseEnv, + timeoutMinutes, + ); + + metadata.phase = 'platform-sync'; + writeMetadata(metadata); + await runCommand( + 'platform-sync', + process.env.LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND, + phaseEnv, + timeoutMinutes, + ); + + metadata.phase = 'complete'; + } catch (error) { + metadata.failure_phase = metadata.phase || 'resolve-core-version'; + metadata.failure_summary = error.message; + finalStatus = statusForFailurePhase(metadata.failure_phase); + console.error(error.stack || error.message); + } finally { + metadata.status = finalStatus; + metadata.completed_at = now(); + writeMetadata(metadata); + await publishCommitStatus(finalStatus, metadata); + } + + console.log(`${STATUS_CONTEXT}: ${STATUS_LABELS[finalStatus]}`); + + if (finalStatus !== 'sync_passed') { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exitCode = 1; +}); diff --git a/.github/workflows/latest-core-testnet-sync-status.yml b/.github/workflows/latest-core-testnet-sync-status.yml new file mode 100644 index 00000000000..d84b3db8a53 --- /dev/null +++ b/.github/workflows/latest-core-testnet-sync-status.yml @@ -0,0 +1,98 @@ +name: "Latest public Core testnet sync status" + +"on": + repository_dispatch: + types: + - latest-core-testnet-sync-completed + workflow_dispatch: + inputs: + status: + description: Final completed run status + required: true + type: choice + options: + - sync_passed + - build_failed + - sync_failed + target_sha: + description: Platform commit SHA to report against + required: false + type: string + target_url: + description: URL for run logs or status details + required: false + type: string + core_version: + description: Public Core version used by the run + required: false + type: string + platform_sha: + description: Platform commit SHA built and synced + required: false + type: string + completed_at: + description: Completion timestamp, preferably ISO 8601 UTC + required: false + type: string + +permissions: + contents: read + statuses: write + +jobs: + report: + name: Report latest completed sync status + runs-on: ubuntu-24.04 + steps: + - name: Publish commit status + uses: actions/github-script@v7 + env: + CLIENT_PAYLOAD: ${{ toJson(github.event.client_payload) }} + with: + script: | + const dispatchPayload = JSON.parse(process.env.CLIENT_PAYLOAD || '{}'); + const payload = context.eventName === 'workflow_dispatch' + ? context.payload.inputs + : dispatchPayload; + + const status = payload.status; + const labels = { + sync_passed: 'Sync Passed', + build_failed: 'Build Failed', + sync_failed: 'Sync Failed', + }; + + if (!Object.prototype.hasOwnProperty.call(labels, status)) { + core.setFailed(`Unsupported status: ${status || ''}`); + return; + } + + const targetSha = payload.target_sha || payload.platform_sha || context.sha; + const state = status === 'sync_passed' ? 'success' : 'failure'; + const label = labels[status]; + const details = [ + payload.core_version, + payload.platform_sha ? payload.platform_sha.slice(0, 12) : null, + payload.completed_at, + ].filter(Boolean).join(' '); + const description = `${label}${details ? ` - ${details}` : ''}`.slice(0, 140); + + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: targetSha, + state, + context: 'Latest public Core testnet sync', + description, + target_url: payload.target_url || undefined, + }); + + await core.summary + .addHeading('Latest public Core testnet sync') + .addRaw(`Status: ${label}\n\n`) + .addRaw(`Target SHA: ${targetSha}\n\n`) + .addRaw(`Core version: ${payload.core_version || 'not provided'}\n\n`) + .addRaw(`Platform SHA: ${payload.platform_sha || targetSha}\n\n`) + .addRaw(`Completed at: ${payload.completed_at || 'not provided'}\n\n`) + .addRaw(`Details: ${payload.target_url || 'not provided'}\n`) + .write(); diff --git a/.gitignore b/.gitignore index 80f977f5be6..5fba6a73f38 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ __pycache__/ # Security audit reports (local-only, not committed) audits/ + +# Latest public Core testnet sync local artifacts +.latest-core-testnet-sync/ diff --git a/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md b/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md new file mode 100644 index 00000000000..3876ebc6348 --- /dev/null +++ b/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md @@ -0,0 +1,105 @@ +# Nightly Latest Core Testnet Sync + +This document defines the first repo-side contract for reporting Platform sync against the latest public Dash Core release on testnet. + +The visible GitHub status is intentionally the last completed run only. A currently running build or sync must not replace the useful completed result with a running state. + +## Visible Status + +The external orchestrator reports one commit status on the tested Platform commit: + +- Context: `Latest public Core testnet sync` +- Success state: + - `Sync Passed` +- Failure states: + - `Build Failed` + - `Sync Failed` + +Detailed build, Core, and sync logs belong behind the status `target_url`, not in the status description. + +## System Phases + +1. Keep a long-lived testnet Core node synced on the latest public Core release. +2. Build Platform for the target `dashpay/platform` commit on a fast disposable builder. +3. Run Platform sync on a normal sync runner using the synced Core baseline and freshly built Platform artifacts. +4. Report only the final completed result back to this repository. + +Core sync should be stateful and warm. Platform build should be disposable and cache-heavy. Platform sync should be reproducible and log-rich. + +## Scheduling + +The long-running sync is owned by a persistent worker, not by a GitHub-hosted runner. Platform sync can take 16+ hours, which is too long for GitHub-hosted runner limits and too expensive to keep idle while waiting for chain progress. + +The worker assets live in `ops/latest-core-testnet-sync/`: + +- `install-worker.sh` +- `run-worker.sh` +- `latest-core-testnet-sync.service` +- `latest-core-testnet-sync.timer` +- `latest-core-testnet-sync.env.example` + +The default timer runs nightly at 01:30 UTC with a 30 minute randomized delay. The service uses a lock file, so overlapping runs exit without changing the visible GitHub status. + +The worker does not publish a pending or running commit status. It leaves the previous completed status in place while the new run is active, then publishes one final completed result when the run finishes. + +The worker delegates host-specific work to environment variables: + +- `LATEST_CORE_TESTNET_CORE_SYNC_COMMAND` +- `LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND` +- `LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND` + +Optional variables: + +- `LATEST_CORE_TESTNET_CORE_VERSION_COMMAND` overrides release discovery. It must print the Core version/tag on stdout. +- `LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES` overrides the per-phase timeout. Defaults to 1440 minutes. +- `LATEST_CORE_RELEASE_REPO` overrides the GitHub release source. Defaults to `dashpay/dash`. +- `LATEST_CORE_TESTNET_LOG_DIR` controls where run logs and metadata are written. +- `LATEST_CORE_TESTNET_TARGET_URL` links the GitHub status to durable logs or a run page. + +Each phase command receives: + +- `LATEST_CORE_VERSION` +- `PLATFORM_SHA` +- `LATEST_CORE_TESTNET_SYNC_RUN_DIR` + +The run directory should be used as the durable artifact location for phase logs and metadata. + +## Reporting Contract + +When a run completes, the orchestrator sends a `repository_dispatch` event: + +```json +{ + "event_type": "latest-core-testnet-sync-completed", + "client_payload": { + "status": "sync_passed", + "target_sha": "0000000000000000000000000000000000000000", + "target_url": "https://example.invalid/runs/2026-06-24", + "core_version": "vX.Y.Z", + "platform_sha": "0000000000000000000000000000000000000000", + "completed_at": "2026-06-24T05:30:00Z" + } +} +``` + +Allowed `status` values: + +- `sync_passed` +- `build_failed` +- `sync_failed` + +Manual status testing is available through the `Latest public Core testnet sync status` workflow's `workflow_dispatch` trigger with the same fields. The normal worker path reports directly through the GitHub commit statuses API. + +## Log and Artifact Expectations + +The `target_url` should lead to a run page or artifact bundle containing: + +- selected public Core version and binary/image digest +- target Platform commit and image digests +- Core tip height/hash and sync health at run start +- build logs +- Platform service logs +- Platform sync progress and terminal state +- concise failure phase and reason + +The GitHub status description stays short so the repo panel remains readable. diff --git a/ops/latest-core-testnet-sync/README.md b/ops/latest-core-testnet-sync/README.md new file mode 100644 index 00000000000..26b4754b448 --- /dev/null +++ b/ops/latest-core-testnet-sync/README.md @@ -0,0 +1,75 @@ +# Latest Public Core Testnet Sync Worker + +The Platform sync can run for 16+ hours, so the long-running work belongs on a persistent worker instead of a GitHub-hosted runner. + +The worker: + +1. Updates a dedicated `dashpay/platform` checkout. +2. Resolves the latest public Dash Core release. +3. Runs the configured Core sync, Platform build, and Platform sync commands. +4. Publishes one final commit status to the tested Platform commit: + - `Sync Passed` + - `Build Failed` + - `Sync Failed` + +It does not publish a running status. The previous completed GitHub status remains visible while a new worker run is active. + +## Install + +On the sync worker, clone a dedicated checkout first: + +```bash +sudo useradd --system --create-home --shell /usr/sbin/nologin platform-sync +sudo git clone https://github.com/dashpay/platform.git /opt/dash-platform +sudo chown -R platform-sync:platform-sync /opt/dash-platform +``` + +Then install the units: + +```bash +sudo /opt/dash-platform/ops/latest-core-testnet-sync/install-worker.sh + +sudo editor /etc/latest-core-testnet-sync.env +sudo systemctl enable --now latest-core-testnet-sync.timer +``` + +The checkout should be dedicated to this worker because `run-worker.sh` resets it to `origin/$PLATFORM_BRANCH` before every run. + +## Required Configuration + +Set these in `/etc/latest-core-testnet-sync.env`: + +- `GITHUB_TOKEN` +- `LATEST_CORE_TESTNET_CORE_SYNC_COMMAND` +- `LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND` +- `LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND` + +The phase commands run from `PLATFORM_REPO_DIR` and receive: + +- `LATEST_CORE_VERSION` +- `PLATFORM_SHA` +- `LATEST_CORE_TESTNET_SYNC_RUN_DIR` + +Write logs or machine-readable metadata into `LATEST_CORE_TESTNET_SYNC_RUN_DIR` so failures can be inspected without overloading the GitHub status panel. + +## Operations + +Run immediately: + +```bash +sudo systemctl start latest-core-testnet-sync.service +``` + +Check timer: + +```bash +systemctl list-timers latest-core-testnet-sync.timer +``` + +Follow logs: + +```bash +journalctl -u latest-core-testnet-sync.service -f +``` + +Run artifacts are stored under `LATEST_CORE_TESTNET_LOG_DIR`. diff --git a/ops/latest-core-testnet-sync/install-worker.sh b/ops/latest-core-testnet-sync/install-worker.sh new file mode 100755 index 00000000000..b3782e6bbcb --- /dev/null +++ b/ops/latest-core-testnet-sync/install-worker.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${PLATFORM_REPO_DIR:=/opt/dash-platform}" +PLATFORM_SYNC_USER=platform-sync +PLATFORM_SYNC_GROUP=platform-sync + +if [[ "${EUID}" -ne 0 ]]; then + echo "install-worker.sh must be run as root" >&2 + exit 1 +fi + +if ! getent group "${PLATFORM_SYNC_GROUP}" >/dev/null 2>&1; then + groupadd --system "${PLATFORM_SYNC_GROUP}" +fi + +if ! id "${PLATFORM_SYNC_USER}" >/dev/null 2>&1; then + useradd \ + --system \ + --create-home \ + --shell /usr/sbin/nologin \ + --gid "${PLATFORM_SYNC_GROUP}" \ + "${PLATFORM_SYNC_USER}" +fi + +install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" "${PLATFORM_REPO_DIR}" +install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/lib/latest-core-testnet-sync +install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/log/latest-core-testnet-sync + +install -m 0644 \ + "${PLATFORM_REPO_DIR}/ops/latest-core-testnet-sync/latest-core-testnet-sync.service" \ + /etc/systemd/system/latest-core-testnet-sync.service +install -m 0644 \ + "${PLATFORM_REPO_DIR}/ops/latest-core-testnet-sync/latest-core-testnet-sync.timer" \ + /etc/systemd/system/latest-core-testnet-sync.timer + +if [[ ! -f /etc/latest-core-testnet-sync.env ]]; then + install -m 0600 \ + "${PLATFORM_REPO_DIR}/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example" \ + /etc/latest-core-testnet-sync.env + echo "Created /etc/latest-core-testnet-sync.env; fill it before enabling the timer." +else + chmod 0600 /etc/latest-core-testnet-sync.env +fi + +systemctl daemon-reload + +echo "Installed latest Core testnet sync worker units." +echo "Next:" +echo " 1. Edit /etc/latest-core-testnet-sync.env" +echo " 2. Run: systemctl start latest-core-testnet-sync.service" +echo " 3. Enable nightly timer: systemctl enable --now latest-core-testnet-sync.timer" diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example b/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example new file mode 100644 index 00000000000..f3c19872d5a --- /dev/null +++ b/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example @@ -0,0 +1,32 @@ +# Copy to /etc/latest-core-testnet-sync.env on the sync worker. + +# Required for final GitHub commit status reporting. Use a fine-scoped token +# that can write commit statuses for dashpay/platform. +GITHUB_TOKEN= +GITHUB_REPOSITORY=dashpay/platform + +# Dedicated checkout used by the worker wrapper. +PLATFORM_REPO_DIR=/opt/dash-platform +PLATFORM_BRANCH=v3.1-dev + +# Persistent worker state and logs. +LATEST_CORE_TESTNET_STATE_DIR=/var/lib/latest-core-testnet-sync +LATEST_CORE_TESTNET_LOG_DIR=/var/log/latest-core-testnet-sync + +# Optional URL linked from the GitHub commit status, for example an S3 +# prefix, dashboard, or log viewer. +# LATEST_CORE_TESTNET_TARGET_URL= + +# Optional release source/override. +LATEST_CORE_RELEASE_REPO=dashpay/dash +# LATEST_CORE_TESTNET_CORE_VERSION=vX.Y.Z +# LATEST_CORE_TESTNET_CORE_VERSION_COMMAND= + +# Allow long syncs. Default in the harness is 1440 minutes. +LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES=1440 + +# Required phase commands. These run from PLATFORM_REPO_DIR and receive: +# LATEST_CORE_VERSION, PLATFORM_SHA, and LATEST_CORE_TESTNET_SYNC_RUN_DIR. +LATEST_CORE_TESTNET_CORE_SYNC_COMMAND= +LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND= +LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND= diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.service b/ops/latest-core-testnet-sync/latest-core-testnet-sync.service new file mode 100644 index 00000000000..626a1c2dc2c --- /dev/null +++ b/ops/latest-core-testnet-sync/latest-core-testnet-sync.service @@ -0,0 +1,15 @@ +[Unit] +Description=Dash Platform latest public Core testnet sync +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=platform-sync +Group=platform-sync +EnvironmentFile=/etc/latest-core-testnet-sync.env +WorkingDirectory=/opt/dash-platform +ExecStart=/bin/bash /opt/dash-platform/ops/latest-core-testnet-sync/run-worker.sh +TimeoutStartSec=0 +StateDirectory=latest-core-testnet-sync +LogsDirectory=latest-core-testnet-sync diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.timer b/ops/latest-core-testnet-sync/latest-core-testnet-sync.timer new file mode 100644 index 00000000000..9e3972b201e --- /dev/null +++ b/ops/latest-core-testnet-sync/latest-core-testnet-sync.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Nightly Dash Platform latest public Core testnet sync + +[Timer] +OnCalendar=*-*-* 01:30:00 UTC +Persistent=true +RandomizedDelaySec=30m +Unit=latest-core-testnet-sync.service + +[Install] +WantedBy=timers.target diff --git a/ops/latest-core-testnet-sync/run-worker.sh b/ops/latest-core-testnet-sync/run-worker.sh new file mode 100755 index 00000000000..b8dc152a041 --- /dev/null +++ b/ops/latest-core-testnet-sync/run-worker.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${PLATFORM_REPO_DIR:=/opt/dash-platform}" +: "${PLATFORM_BRANCH:=v3.1-dev}" +: "${LATEST_CORE_TESTNET_STATE_DIR:=/var/lib/latest-core-testnet-sync}" +: "${LATEST_CORE_TESTNET_LOG_DIR:=/var/log/latest-core-testnet-sync}" + +mkdir -p "${LATEST_CORE_TESTNET_STATE_DIR}" "${LATEST_CORE_TESTNET_LOG_DIR}" + +exec 9>"${LATEST_CORE_TESTNET_STATE_DIR}/run.lock" +if ! flock -n 9; then + echo "latest Core testnet sync is already running" + exit 0 +fi + +cd "${PLATFORM_REPO_DIR}" + +git fetch --prune origin "${PLATFORM_BRANCH}" +git checkout "${PLATFORM_BRANCH}" +git reset --hard "origin/${PLATFORM_BRANCH}" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +export LATEST_CORE_TESTNET_RUN_ID="${RUN_ID}" +export LATEST_CORE_TESTNET_SYNC_RUN_DIR="${LATEST_CORE_TESTNET_LOG_DIR}/${RUN_ID}" +export TARGET_SHA +TARGET_SHA="$(git rev-parse HEAD)" + +node .github/scripts/latest-core-testnet-sync/run.cjs From 4069ab1612a3f8d9bc6bd6bd769f9c74e33e175b Mon Sep 17 00:00:00 2001 From: infraclaw-dash Date: Wed, 24 Jun 2026 06:37:33 +0000 Subject: [PATCH 2/7] ci: harden latest Core sync worker --- .../scripts/latest-core-testnet-sync/run.cjs | 190 ++++++++++++++++-- .../latest-core-testnet-sync-status.yml | 41 +++- docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md | 7 +- ops/latest-core-testnet-sync/README.md | 8 +- .../install-worker.sh | 20 +- .../latest-core-testnet-sync.env.example | 3 + .../latest-core-testnet-sync.service | 3 +- ops/latest-core-testnet-sync/run-worker.sh | 14 +- 8 files changed, 249 insertions(+), 37 deletions(-) diff --git a/.github/scripts/latest-core-testnet-sync/run.cjs b/.github/scripts/latest-core-testnet-sync/run.cjs index 2ff7d49cd24..8ae5877faed 100755 --- a/.github/scripts/latest-core-testnet-sync/run.cjs +++ b/.github/scripts/latest-core-testnet-sync/run.cjs @@ -19,6 +19,19 @@ const STATUS_LABELS = { sync_failed: 'Sync Failed', }; +const DEFAULT_RESOLVE_TIMEOUT_MINUTES = 30; +const DEFAULT_PHASE_TIMEOUT_MINUTES = 1440; +const GITHUB_FETCH_TIMEOUT_MS = parsePositiveInteger( + process.env.LATEST_CORE_TESTNET_GITHUB_FETCH_TIMEOUT_MS, + 60_000, + 'LATEST_CORE_TESTNET_GITHUB_FETCH_TIMEOUT_MS', +); +const STATUS_PUBLISH_ATTEMPTS = parsePositiveInteger( + process.env.LATEST_CORE_TESTNET_STATUS_PUBLISH_ATTEMPTS, + 3, + 'LATEST_CORE_TESTNET_STATUS_PUBLISH_ATTEMPTS', +); + function now() { return new Date().toISOString(); } @@ -39,6 +52,67 @@ function firstLine(value) { return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean); } +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function parsePositiveMinutes(rawValue, fallback, name) { + if (rawValue === undefined || rawValue === null || String(rawValue).trim() === '') { + return fallback; + } + + const value = Number(rawValue); + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive number, got: ${rawValue}`); + } + + return value; +} + +function parsePositiveInteger(rawValue, fallback, name) { + const value = parsePositiveMinutes(rawValue, fallback, name); + if (!Number.isInteger(value)) { + throw new Error(`${name} must be a positive integer, got: ${rawValue}`); + } + + return value; +} + +function sanitizePhaseEnv(env) { + const sanitized = { ...env }; + delete sanitized.GITHUB_TOKEN; + delete sanitized.GH_TOKEN; + return sanitized; +} + +function killProcessGroup(child, signal) { + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error.code !== 'ESRCH') { + throw error; + } + } +} + +async function fetchWithTimeout(url, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, GITHUB_FETCH_TIMEOUT_MS); + + try { + return await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + function gitOutput(args) { const result = spawnSync('git', args, { cwd: repoRoot, @@ -58,11 +132,12 @@ function resolveTargetSha() { || gitOutput(['rev-parse', 'HEAD']); } -async function runCommand(name, command, env, timeoutMinutes) { +async function runCommand(name, command, env, timeoutMinutes, options = {}) { if (!command || !command.trim()) { throw new Error(`Missing command for phase: ${name}`); } + const { captureStdout = false } = options; const logFileName = `${name}.log`; appendLog(logFileName, `$ ${command}\n\n`); @@ -74,39 +149,58 @@ async function runCommand(name, command, env, timeoutMinutes) { cwd: repoRoot, env, stdio: ['ignore', 'pipe', 'pipe'], + detached: true, }); + let timedOut = false; + let settled = false; + let killTimeout; + let stdout = ''; + const timeout = setTimeout(() => { - child.kill('SIGTERM'); - reject(new Error(`${name} timed out after ${timeoutMinutes} minutes`)); + timedOut = true; + killProcessGroup(child, 'SIGTERM'); + killTimeout = setTimeout(() => { + if (!settled) { + killProcessGroup(child, 'SIGKILL'); + } + }, 30_000); }, timeoutMs); - let output = ''; - child.stdout.on('data', (chunk) => { process.stdout.write(chunk); - output += chunk.toString(); + if (captureStdout) { + stdout += chunk.toString(); + } appendLog(logFileName, chunk.toString()); }); child.stderr.on('data', (chunk) => { process.stderr.write(chunk); - output += chunk.toString(); appendLog(logFileName, chunk.toString()); }); child.on('error', (error) => { + settled = true; clearTimeout(timeout); + clearTimeout(killTimeout); reject(error); }); child.on('close', (code, signal) => { + settled = true; clearTimeout(timeout); + clearTimeout(killTimeout); const durationSeconds = Math.round((Date.now() - startedAt) / 1000); appendLog(logFileName, `\nexit_code=${code} signal=${signal || ''} duration_seconds=${durationSeconds}\n`); + if (timedOut) { + reject(new Error(`${name} timed out after ${timeoutMinutes} minutes`)); + return; + } + if (code === 0) { - resolve({ output, durationSeconds }); + resolve({ stdout, durationSeconds }); return; } @@ -115,6 +209,34 @@ async function runCommand(name, command, env, timeoutMinutes) { }); } +async function prepareWorkspace(metadata, timeoutMinutes) { + if (process.env.LATEST_CORE_TESTNET_SKIP_WORKSPACE_PREP === '1') { + return; + } + + const branch = process.env.PLATFORM_BRANCH || 'v3.1-dev'; + metadata.phase = 'workspace-prepare'; + writeMetadata(metadata); + + await runCommand( + 'workspace-prepare', + [ + `git fetch --prune origin ${JSON.stringify(branch)}`, + `git checkout ${JSON.stringify(branch)}`, + `git reset --hard origin/${JSON.stringify(branch)}`, + 'git clean -ffdx -e .latest-core-testnet-sync/', + ].join('\n'), + sanitizePhaseEnv(process.env), + timeoutMinutes, + ); + + const targetSha = resolveTargetSha(); + metadata.target_sha = targetSha; + metadata.platform_sha = targetSha; + process.env.TARGET_SHA = targetSha; + writeMetadata(metadata); +} + async function resolveLatestCoreVersion(metadata) { if (process.env.LATEST_CORE_TESTNET_CORE_VERSION) { return process.env.LATEST_CORE_TESTNET_CORE_VERSION; @@ -124,10 +246,15 @@ async function resolveLatestCoreVersion(metadata) { const result = await runCommand( 'resolve-core-version', process.env.LATEST_CORE_TESTNET_CORE_VERSION_COMMAND, - process.env, - Number(process.env.LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES || 30), + sanitizePhaseEnv(process.env), + parsePositiveMinutes( + process.env.LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES, + DEFAULT_RESOLVE_TIMEOUT_MINUTES, + 'LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES', + ), + { captureStdout: true }, ); - const version = firstLine(result.output); + const version = firstLine(result.stdout); if (!version) { throw new Error('Core version command did not print a version'); } @@ -135,7 +262,7 @@ async function resolveLatestCoreVersion(metadata) { } const releaseRepo = process.env.LATEST_CORE_RELEASE_REPO || 'dashpay/dash'; - const response = await fetch(`https://api.github.com/repos/${releaseRepo}/releases`, { + const response = await fetchWithTimeout(`https://api.github.com/repos/${releaseRepo}/releases`, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'dash-platform-latest-core-testnet-sync', @@ -144,7 +271,8 @@ async function resolveLatestCoreVersion(metadata) { }); if (!response.ok) { - throw new Error(`Unable to fetch releases for ${releaseRepo}: HTTP ${response.status}`); + const errorBody = await response.text(); + throw new Error(`Unable to fetch releases for ${releaseRepo}: HTTP ${response.status} ${errorBody}`); } const releases = await response.json(); @@ -175,7 +303,7 @@ function statusDescription(status, metadata) { return `${STATUS_LABELS[status]}${details ? ` - ${details}` : ''}`.slice(0, 140); } -async function publishCommitStatus(status, metadata) { +async function publishCommitStatusOnce(status, metadata) { if (process.env.SKIP_GITHUB_STATUS === '1') { console.log(`Skipping GitHub status publish: ${STATUS_LABELS[status]}`); return; @@ -207,7 +335,7 @@ async function publishCommitStatus(status, metadata) { body.target_url = targetUrl; } - const response = await fetch(`https://api.github.com/repos/${repository}/statuses/${metadata.target_sha}`, { + const response = await fetchWithTimeout(`https://api.github.com/repos/${repository}/statuses/${metadata.target_sha}`, { method: 'POST', headers: { Accept: 'application/vnd.github+json', @@ -219,15 +347,37 @@ async function publishCommitStatus(status, metadata) { }); if (!response.ok) { - const body = await response.text(); - throw new Error(`Unable to publish commit status: HTTP ${response.status} ${body}`); + const errorBody = await response.text(); + throw new Error(`Unable to publish commit status: HTTP ${response.status} ${errorBody}`); } } +async function publishCommitStatus(status, metadata) { + let lastError; + for (let attempt = 1; attempt <= STATUS_PUBLISH_ATTEMPTS; attempt += 1) { + try { + await publishCommitStatusOnce(status, metadata); + return; + } catch (error) { + lastError = error; + console.error(`Commit status publish attempt ${attempt} failed: ${error.message}`); + if (attempt < STATUS_PUBLISH_ATTEMPTS) { + await sleep(2 ** attempt * 1000); + } + } + } + + throw lastError; +} + async function main() { ensureRunDir(); - const timeoutMinutes = Number(process.env.LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES || 1440); + const timeoutMinutes = parsePositiveMinutes( + process.env.LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES, + DEFAULT_PHASE_TIMEOUT_MINUTES, + 'LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES', + ); const targetSha = resolveTargetSha(); const metadata = { status: 'running', @@ -244,6 +394,8 @@ async function main() { let finalStatus = 'sync_passed'; try { + await prepareWorkspace(metadata, timeoutMinutes); + metadata.core_version = await resolveLatestCoreVersion(metadata); writeMetadata(metadata); @@ -253,6 +405,8 @@ async function main() { PLATFORM_SHA: metadata.platform_sha, LATEST_CORE_TESTNET_SYNC_RUN_DIR: runDir, }; + delete phaseEnv.GITHUB_TOKEN; + delete phaseEnv.GH_TOKEN; metadata.phase = 'core-sync'; writeMetadata(metadata); diff --git a/.github/workflows/latest-core-testnet-sync-status.yml b/.github/workflows/latest-core-testnet-sync-status.yml index d84b3db8a53..ad3653a68ab 100644 --- a/.github/workflows/latest-core-testnet-sync-status.yml +++ b/.github/workflows/latest-core-testnet-sync-status.yml @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Publish commit status - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: CLIENT_PAYLOAD: ${{ toJson(github.event.client_payload) }} with: @@ -55,6 +55,11 @@ jobs: ? context.payload.inputs : dispatchPayload; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + core.setFailed('Missing repository_dispatch client_payload'); + return; + } + const status = payload.status; const labels = { sync_passed: 'Sync Passed', @@ -67,7 +72,35 @@ jobs: return; } - const targetSha = payload.target_sha || payload.platform_sha || context.sha; + const targetSha = String(payload.target_sha || payload.platform_sha || '').trim(); + if (!/^[0-9a-f]{40}$/i.test(targetSha)) { + core.setFailed('target_sha or platform_sha must be an explicit 40-character commit SHA'); + return; + } + + await github.rest.repos.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: targetSha, + }); + + let targetUrl; + if (payload.target_url) { + let parsedUrl; + try { + parsedUrl = new URL(payload.target_url); + } catch (error) { + core.setFailed(`target_url must be a valid URL: ${error.message}`); + return; + } + + if (parsedUrl.origin !== 'https://github.com') { + core.setFailed('target_url must use the https://github.com origin'); + return; + } + targetUrl = parsedUrl.toString(); + } + const state = status === 'sync_passed' ? 'success' : 'failure'; const label = labels[status]; const details = [ @@ -84,7 +117,7 @@ jobs: state, context: 'Latest public Core testnet sync', description, - target_url: payload.target_url || undefined, + target_url: targetUrl || undefined, }); await core.summary @@ -94,5 +127,5 @@ jobs: .addRaw(`Core version: ${payload.core_version || 'not provided'}\n\n`) .addRaw(`Platform SHA: ${payload.platform_sha || targetSha}\n\n`) .addRaw(`Completed at: ${payload.completed_at || 'not provided'}\n\n`) - .addRaw(`Details: ${payload.target_url || 'not provided'}\n`) + .addRaw(`Details: ${targetUrl || 'not provided'}\n`) .write(); diff --git a/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md b/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md index 3876ebc6348..c3d7f858d54 100644 --- a/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md +++ b/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md @@ -52,6 +52,7 @@ Optional variables: - `LATEST_CORE_TESTNET_CORE_VERSION_COMMAND` overrides release discovery. It must print the Core version/tag on stdout. - `LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES` overrides the per-phase timeout. Defaults to 1440 minutes. +- `LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES` overrides the Core-version resolution command timeout. Defaults to 30 minutes. - `LATEST_CORE_RELEASE_REPO` overrides the GitHub release source. Defaults to `dashpay/dash`. - `LATEST_CORE_TESTNET_LOG_DIR` controls where run logs and metadata are written. - `LATEST_CORE_TESTNET_TARGET_URL` links the GitHub status to durable logs or a run page. @@ -64,6 +65,8 @@ Each phase command receives: The run directory should be used as the durable artifact location for phase logs and metadata. +The GitHub token is used only by the parent worker process for final status publication. It is intentionally stripped from the environment passed to Core sync, Platform build, and Platform sync commands. + ## Reporting Contract When a run completes, the orchestrator sends a `repository_dispatch` event: @@ -74,7 +77,7 @@ When a run completes, the orchestrator sends a `repository_dispatch` event: "client_payload": { "status": "sync_passed", "target_sha": "0000000000000000000000000000000000000000", - "target_url": "https://example.invalid/runs/2026-06-24", + "target_url": "https://github.com/dashpay/platform/actions/runs/0000000000", "core_version": "vX.Y.Z", "platform_sha": "0000000000000000000000000000000000000000", "completed_at": "2026-06-24T05:30:00Z" @@ -90,6 +93,8 @@ Allowed `status` values: Manual status testing is available through the `Latest public Core testnet sync status` workflow's `workflow_dispatch` trigger with the same fields. The normal worker path reports directly through the GitHub commit statuses API. +The dispatch credential should be held only by the persistent sync worker/operator account. The status workflow requires an explicit 40-character commit SHA that exists in `dashpay/platform`, and workflow-provided `target_url` values are limited to the `https://github.com` origin. + ## Log and Artifact Expectations The `target_url` should lead to a run page or artifact bundle containing: diff --git a/ops/latest-core-testnet-sync/README.md b/ops/latest-core-testnet-sync/README.md index 26b4754b448..089f6b17cf3 100644 --- a/ops/latest-core-testnet-sync/README.md +++ b/ops/latest-core-testnet-sync/README.md @@ -4,7 +4,7 @@ The Platform sync can run for 16+ hours, so the long-running work belongs on a p The worker: -1. Updates a dedicated `dashpay/platform` checkout. +1. Updates and cleans a dedicated `dashpay/platform` checkout. 2. Resolves the latest public Dash Core release. 3. Runs the configured Core sync, Platform build, and Platform sync commands. 4. Publishes one final commit status to the tested Platform commit: @@ -33,7 +33,8 @@ sudo editor /etc/latest-core-testnet-sync.env sudo systemctl enable --now latest-core-testnet-sync.timer ``` -The checkout should be dedicated to this worker because `run-worker.sh` resets it to `origin/$PLATFORM_BRANCH` before every run. +The checkout should be dedicated to this worker because the harness resets and cleans it to `origin/$PLATFORM_BRANCH` before every run. +The installer validates that `PLATFORM_REPO_DIR` already exists as a writable git checkout for the `platform-sync` user. ## Required Configuration @@ -52,6 +53,8 @@ The phase commands run from `PLATFORM_REPO_DIR` and receive: Write logs or machine-readable metadata into `LATEST_CORE_TESTNET_SYNC_RUN_DIR` so failures can be inspected without overloading the GitHub status panel. +`GITHUB_TOKEN` and `GH_TOKEN` are stripped from phase command environments. They remain available only to the worker harness for publishing the final commit status. + ## Operations Run immediately: @@ -73,3 +76,4 @@ journalctl -u latest-core-testnet-sync.service -f ``` Run artifacts are stored under `LATEST_CORE_TESTNET_LOG_DIR`. +Run directories older than `LATEST_CORE_TESTNET_LOG_RETENTION_DAYS` are pruned by `run-worker.sh`; the default is 30 days. diff --git a/ops/latest-core-testnet-sync/install-worker.sh b/ops/latest-core-testnet-sync/install-worker.sh index b3782e6bbcb..13db879d8b0 100755 --- a/ops/latest-core-testnet-sync/install-worker.sh +++ b/ops/latest-core-testnet-sync/install-worker.sh @@ -5,6 +5,7 @@ set -euo pipefail : "${PLATFORM_REPO_DIR:=/opt/dash-platform}" PLATFORM_SYNC_USER=platform-sync PLATFORM_SYNC_GROUP=platform-sync +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" if [[ "${EUID}" -ne 0 ]]; then echo "install-worker.sh must be run as root" >&2 @@ -24,20 +25,31 @@ if ! id "${PLATFORM_SYNC_USER}" >/dev/null 2>&1; then "${PLATFORM_SYNC_USER}" fi -install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" "${PLATFORM_REPO_DIR}" +if [[ ! -d "${PLATFORM_REPO_DIR}/.git" ]]; then + echo "PLATFORM_REPO_DIR must be an existing git checkout: ${PLATFORM_REPO_DIR}" >&2 + echo "Clone dashpay/platform there before running this installer." >&2 + exit 1 +fi + +if ! runuser -u "${PLATFORM_SYNC_USER}" -- test -w "${PLATFORM_REPO_DIR}"; then + echo "PLATFORM_REPO_DIR must be writable by ${PLATFORM_SYNC_USER}: ${PLATFORM_REPO_DIR}" >&2 + echo "Run: chown -R ${PLATFORM_SYNC_USER}:${PLATFORM_SYNC_GROUP} ${PLATFORM_REPO_DIR}" >&2 + exit 1 +fi + install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/lib/latest-core-testnet-sync install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/log/latest-core-testnet-sync install -m 0644 \ - "${PLATFORM_REPO_DIR}/ops/latest-core-testnet-sync/latest-core-testnet-sync.service" \ + "${SCRIPT_DIR}/latest-core-testnet-sync.service" \ /etc/systemd/system/latest-core-testnet-sync.service install -m 0644 \ - "${PLATFORM_REPO_DIR}/ops/latest-core-testnet-sync/latest-core-testnet-sync.timer" \ + "${SCRIPT_DIR}/latest-core-testnet-sync.timer" \ /etc/systemd/system/latest-core-testnet-sync.timer if [[ ! -f /etc/latest-core-testnet-sync.env ]]; then install -m 0600 \ - "${PLATFORM_REPO_DIR}/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example" \ + "${SCRIPT_DIR}/latest-core-testnet-sync.env.example" \ /etc/latest-core-testnet-sync.env echo "Created /etc/latest-core-testnet-sync.env; fill it before enabling the timer." else diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example b/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example index f3c19872d5a..13b6b6bf742 100644 --- a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example +++ b/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example @@ -12,6 +12,7 @@ PLATFORM_BRANCH=v3.1-dev # Persistent worker state and logs. LATEST_CORE_TESTNET_STATE_DIR=/var/lib/latest-core-testnet-sync LATEST_CORE_TESTNET_LOG_DIR=/var/log/latest-core-testnet-sync +LATEST_CORE_TESTNET_LOG_RETENTION_DAYS=30 # Optional URL linked from the GitHub commit status, for example an S3 # prefix, dashboard, or log viewer. @@ -24,6 +25,8 @@ LATEST_CORE_RELEASE_REPO=dashpay/dash # Allow long syncs. Default in the harness is 1440 minutes. LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES=1440 +# Optional timeout for resolving the Core release version. Default is 30 minutes. +# LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES=30 # Required phase commands. These run from PLATFORM_REPO_DIR and receive: # LATEST_CORE_VERSION, PLATFORM_SHA, and LATEST_CORE_TESTNET_SYNC_RUN_DIR. diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.service b/ops/latest-core-testnet-sync/latest-core-testnet-sync.service index 626a1c2dc2c..a22e6b352a5 100644 --- a/ops/latest-core-testnet-sync/latest-core-testnet-sync.service +++ b/ops/latest-core-testnet-sync/latest-core-testnet-sync.service @@ -8,8 +8,7 @@ Type=oneshot User=platform-sync Group=platform-sync EnvironmentFile=/etc/latest-core-testnet-sync.env -WorkingDirectory=/opt/dash-platform -ExecStart=/bin/bash /opt/dash-platform/ops/latest-core-testnet-sync/run-worker.sh +ExecStart=/bin/bash -lc 'cd "${PLATFORM_REPO_DIR:-/opt/dash-platform}" && exec ops/latest-core-testnet-sync/run-worker.sh' TimeoutStartSec=0 StateDirectory=latest-core-testnet-sync LogsDirectory=latest-core-testnet-sync diff --git a/ops/latest-core-testnet-sync/run-worker.sh b/ops/latest-core-testnet-sync/run-worker.sh index b8dc152a041..ab32add27f7 100755 --- a/ops/latest-core-testnet-sync/run-worker.sh +++ b/ops/latest-core-testnet-sync/run-worker.sh @@ -6,9 +6,17 @@ set -euo pipefail : "${PLATFORM_BRANCH:=v3.1-dev}" : "${LATEST_CORE_TESTNET_STATE_DIR:=/var/lib/latest-core-testnet-sync}" : "${LATEST_CORE_TESTNET_LOG_DIR:=/var/log/latest-core-testnet-sync}" +: "${LATEST_CORE_TESTNET_LOG_RETENTION_DAYS:=30}" mkdir -p "${LATEST_CORE_TESTNET_STATE_DIR}" "${LATEST_CORE_TESTNET_LOG_DIR}" +find "${LATEST_CORE_TESTNET_LOG_DIR}" \ + -mindepth 1 \ + -maxdepth 1 \ + -type d \ + -mtime +"${LATEST_CORE_TESTNET_LOG_RETENTION_DAYS}" \ + -exec rm -rf {} + + exec 9>"${LATEST_CORE_TESTNET_STATE_DIR}/run.lock" if ! flock -n 9; then echo "latest Core testnet sync is already running" @@ -17,14 +25,8 @@ fi cd "${PLATFORM_REPO_DIR}" -git fetch --prune origin "${PLATFORM_BRANCH}" -git checkout "${PLATFORM_BRANCH}" -git reset --hard "origin/${PLATFORM_BRANCH}" - RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" export LATEST_CORE_TESTNET_RUN_ID="${RUN_ID}" export LATEST_CORE_TESTNET_SYNC_RUN_DIR="${LATEST_CORE_TESTNET_LOG_DIR}/${RUN_ID}" -export TARGET_SHA -TARGET_SHA="$(git rev-parse HEAD)" node .github/scripts/latest-core-testnet-sync/run.cjs From 64a8fca54019bf22f46608eea39cc5dd10379b9f Mon Sep 17 00:00:00 2001 From: infraclaw-dash Date: Wed, 24 Jun 2026 07:00:42 +0000 Subject: [PATCH 3/7] ci: record latest Core status publish outcome --- .../scripts/latest-core-testnet-sync/run.cjs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/scripts/latest-core-testnet-sync/run.cjs b/.github/scripts/latest-core-testnet-sync/run.cjs index 8ae5877faed..d9bac3ef9e7 100755 --- a/.github/scripts/latest-core-testnet-sync/run.cjs +++ b/.github/scripts/latest-core-testnet-sync/run.cjs @@ -306,7 +306,7 @@ function statusDescription(status, metadata) { async function publishCommitStatusOnce(status, metadata) { if (process.env.SKIP_GITHUB_STATUS === '1') { console.log(`Skipping GitHub status publish: ${STATUS_LABELS[status]}`); - return; + return 'skipped'; } const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; @@ -350,14 +350,15 @@ async function publishCommitStatusOnce(status, metadata) { const errorBody = await response.text(); throw new Error(`Unable to publish commit status: HTTP ${response.status} ${errorBody}`); } + + return 'published'; } async function publishCommitStatus(status, metadata) { let lastError; for (let attempt = 1; attempt <= STATUS_PUBLISH_ATTEMPTS; attempt += 1) { try { - await publishCommitStatusOnce(status, metadata); - return; + return await publishCommitStatusOnce(status, metadata); } catch (error) { lastError = error; console.error(`Commit status publish attempt ${attempt} failed: ${error.message}`); @@ -396,6 +397,8 @@ async function main() { try { await prepareWorkspace(metadata, timeoutMinutes); + metadata.phase = 'resolve-core-version'; + writeMetadata(metadata); metadata.core_version = await resolveLatestCoreVersion(metadata); writeMetadata(metadata); @@ -445,7 +448,22 @@ async function main() { metadata.status = finalStatus; metadata.completed_at = now(); writeMetadata(metadata); - await publishCommitStatus(finalStatus, metadata); + try { + const publishResult = await publishCommitStatus(finalStatus, metadata); + if (publishResult === 'skipped') { + metadata.status_publish_skipped_at = now(); + } else { + metadata.status_published_at = now(); + } + delete metadata.status_publish_error; + delete metadata.status_publish_failed_at; + } catch (error) { + metadata.status_publish_error = error.message; + metadata.status_publish_failed_at = now(); + writeMetadata(metadata); + throw error; + } + writeMetadata(metadata); } console.log(`${STATUS_CONTEXT}: ${STATUS_LABELS[finalStatus]}`); From f6783a6ed2c1ff3022b73d5a45a1d0d6a1ba4aed Mon Sep 17 00:00:00 2001 From: infraclaw-dash Date: Wed, 24 Jun 2026 07:04:10 +0000 Subject: [PATCH 4/7] ci: harden latest Core branch handling --- .../scripts/latest-core-testnet-sync/run.cjs | 25 ++++++++++++++++--- .../latest-core-testnet-sync.env.example | 2 ++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/scripts/latest-core-testnet-sync/run.cjs b/.github/scripts/latest-core-testnet-sync/run.cjs index d9bac3ef9e7..9c32e574cda 100755 --- a/.github/scripts/latest-core-testnet-sync/run.cjs +++ b/.github/scripts/latest-core-testnet-sync/run.cjs @@ -87,6 +87,19 @@ function sanitizePhaseEnv(env) { return sanitized; } +function validatePlatformBranch(branch) { + const validBranchPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + if ( + !validBranchPattern.test(branch) + || branch.includes('..') + || branch.includes('//') + || branch.endsWith('/') + || branch.endsWith('.') + ) { + throw new Error(`PLATFORM_BRANCH contains unsupported characters or ref syntax: ${branch}`); + } +} + function killProcessGroup(child, signal) { try { process.kill(-child.pid, signal); @@ -217,16 +230,20 @@ async function prepareWorkspace(metadata, timeoutMinutes) { const branch = process.env.PLATFORM_BRANCH || 'v3.1-dev'; metadata.phase = 'workspace-prepare'; writeMetadata(metadata); + validatePlatformBranch(branch); await runCommand( 'workspace-prepare', [ - `git fetch --prune origin ${JSON.stringify(branch)}`, - `git checkout ${JSON.stringify(branch)}`, - `git reset --hard origin/${JSON.stringify(branch)}`, + 'git fetch --prune origin "$PLATFORM_BRANCH"', + 'git checkout "$PLATFORM_BRANCH"', + 'git reset --hard "origin/$PLATFORM_BRANCH"', 'git clean -ffdx -e .latest-core-testnet-sync/', ].join('\n'), - sanitizePhaseEnv(process.env), + { + ...sanitizePhaseEnv(process.env), + PLATFORM_BRANCH: branch, + }, timeoutMinutes, ); diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example b/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example index 13b6b6bf742..1308cc2c661 100644 --- a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example +++ b/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example @@ -7,6 +7,8 @@ GITHUB_REPOSITORY=dashpay/platform # Dedicated checkout used by the worker wrapper. PLATFORM_REPO_DIR=/opt/dash-platform +# Branch names are intentionally limited to letters, numbers, dot, underscore, +# slash, and dash before being passed to git. PLATFORM_BRANCH=v3.1-dev # Persistent worker state and logs. From 06c64059a884c164bf7fa3bb996f5480f8ea2b81 Mon Sep 17 00:00:00 2001 From: infraclaw-dash Date: Wed, 24 Jun 2026 07:31:59 +0000 Subject: [PATCH 5/7] ci: clarify latest Core baseline readiness --- .github/scripts/latest-core-testnet-sync/run.cjs | 6 +++--- docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md | 8 +++++--- ops/latest-core-testnet-sync/README.md | 10 ++++++---- .../latest-core-testnet-sync.env.example | 5 +++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/scripts/latest-core-testnet-sync/run.cjs b/.github/scripts/latest-core-testnet-sync/run.cjs index 9c32e574cda..67744099a41 100755 --- a/.github/scripts/latest-core-testnet-sync/run.cjs +++ b/.github/scripts/latest-core-testnet-sync/run.cjs @@ -428,11 +428,11 @@ async function main() { delete phaseEnv.GITHUB_TOKEN; delete phaseEnv.GH_TOKEN; - metadata.phase = 'core-sync'; + metadata.phase = 'core-readiness'; writeMetadata(metadata); await runCommand( - 'core-sync', - process.env.LATEST_CORE_TESTNET_CORE_SYNC_COMMAND, + 'core-readiness', + process.env.LATEST_CORE_TESTNET_CORE_READY_COMMAND, phaseEnv, timeoutMinutes, ); diff --git a/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md b/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md index c3d7f858d54..f57df2a568e 100644 --- a/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md +++ b/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md @@ -24,7 +24,7 @@ Detailed build, Core, and sync logs belong behind the status `target_url`, not i 3. Run Platform sync on a normal sync runner using the synced Core baseline and freshly built Platform artifacts. 4. Report only the final completed result back to this repository. -Core sync should be stateful and warm. Platform build should be disposable and cache-heavy. Platform sync should be reproducible and log-rich. +Core baseline maintenance should be owned by a preconfigured, stateful, warm Core system. Platform build should be disposable and cache-heavy. Platform sync should be reproducible and log-rich. ## Scheduling @@ -42,9 +42,11 @@ The default timer runs nightly at 01:30 UTC with a 30 minute randomized delay. T The worker does not publish a pending or running commit status. It leaves the previous completed status in place while the new run is active, then publishes one final completed result when the run finishes. +The Platform sync worker assumes the latest-Core testnet node/baseline already exists. It may run a readiness command to verify that baseline before building and syncing Platform, but it should not perform a full Core chain sync itself. + The worker delegates host-specific work to environment variables: -- `LATEST_CORE_TESTNET_CORE_SYNC_COMMAND` +- `LATEST_CORE_TESTNET_CORE_READY_COMMAND` - `LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND` - `LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND` @@ -65,7 +67,7 @@ Each phase command receives: The run directory should be used as the durable artifact location for phase logs and metadata. -The GitHub token is used only by the parent worker process for final status publication. It is intentionally stripped from the environment passed to Core sync, Platform build, and Platform sync commands. +The GitHub token is used only by the parent worker process for final status publication. It is intentionally stripped from the environment passed to Core readiness, Platform build, and Platform sync commands. ## Reporting Contract diff --git a/ops/latest-core-testnet-sync/README.md b/ops/latest-core-testnet-sync/README.md index 089f6b17cf3..28570f9c50a 100644 --- a/ops/latest-core-testnet-sync/README.md +++ b/ops/latest-core-testnet-sync/README.md @@ -6,7 +6,7 @@ The worker: 1. Updates and cleans a dedicated `dashpay/platform` checkout. 2. Resolves the latest public Dash Core release. -3. Runs the configured Core sync, Platform build, and Platform sync commands. +3. Verifies the preconfigured latest-Core testnet baseline, then runs Platform build and Platform sync commands. 4. Publishes one final commit status to the tested Platform commit: - `Sync Passed` - `Build Failed` @@ -41,11 +41,13 @@ The installer validates that `PLATFORM_REPO_DIR` already exists as a writable gi Set these in `/etc/latest-core-testnet-sync.env`: - `GITHUB_TOKEN` -- `LATEST_CORE_TESTNET_CORE_SYNC_COMMAND` +- `LATEST_CORE_TESTNET_CORE_READY_COMMAND` - `LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND` - `LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND` -The phase commands run from `PLATFORM_REPO_DIR` and receive: +`LATEST_CORE_TESTNET_CORE_READY_COMMAND` should verify that the preconfigured Core node or baseline is on the selected `LATEST_CORE_VERSION` and synced far enough for the Platform sync run. It is not intended to do a full Core chain sync on the Platform worker. + +The worker commands run from `PLATFORM_REPO_DIR` and receive: - `LATEST_CORE_VERSION` - `PLATFORM_SHA` @@ -53,7 +55,7 @@ The phase commands run from `PLATFORM_REPO_DIR` and receive: Write logs or machine-readable metadata into `LATEST_CORE_TESTNET_SYNC_RUN_DIR` so failures can be inspected without overloading the GitHub status panel. -`GITHUB_TOKEN` and `GH_TOKEN` are stripped from phase command environments. They remain available only to the worker harness for publishing the final commit status. +`GITHUB_TOKEN` and `GH_TOKEN` are stripped from worker command environments. They remain available only to the worker harness for publishing the final commit status. ## Operations diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example b/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example index 1308cc2c661..397ba6488f4 100644 --- a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example +++ b/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example @@ -30,8 +30,9 @@ LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES=1440 # Optional timeout for resolving the Core release version. Default is 30 minutes. # LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES=30 -# Required phase commands. These run from PLATFORM_REPO_DIR and receive: +# Required worker commands. These run from PLATFORM_REPO_DIR and receive: # LATEST_CORE_VERSION, PLATFORM_SHA, and LATEST_CORE_TESTNET_SYNC_RUN_DIR. -LATEST_CORE_TESTNET_CORE_SYNC_COMMAND= +# Verifies the preconfigured Core baseline; it should not do full Core chain synchronization. +LATEST_CORE_TESTNET_CORE_READY_COMMAND= LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND= LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND= From 07c7e221c4a57f7ce7c8b151cb0d818228975d50 Mon Sep 17 00:00:00 2001 From: infraclaw-dash Date: Wed, 24 Jun 2026 07:45:44 +0000 Subject: [PATCH 6/7] ci: make Platform testnet sync naming explicit --- .../run.cjs | 58 +++++++++---------- ...s.yml => platform-testnet-sync-status.yml} | 12 ++-- .gitignore | 4 +- README.md | 1 + ...STNET_SYNC.md => PLATFORM_TESTNET_SYNC.md} | 50 ++++++++-------- .../latest-core-testnet-sync.service | 14 ----- ops/latest-core-testnet-sync/run-worker.sh | 32 ---------- .../README.md | 38 ++++++------ .../install-worker.sh | 30 +++++----- .../platform-testnet-sync.env.example} | 30 +++++----- .../platform-testnet-sync.service | 14 +++++ .../platform-testnet-sync.timer} | 4 +- ops/platform-testnet-sync/run-worker.sh | 32 ++++++++++ 13 files changed, 162 insertions(+), 157 deletions(-) rename .github/scripts/{latest-core-testnet-sync => platform-testnet-sync}/run.cjs (88%) rename .github/workflows/{latest-core-testnet-sync-status.yml => platform-testnet-sync-status.yml} (94%) rename docs/{NIGHTLY_LATEST_CORE_TESTNET_SYNC.md => PLATFORM_TESTNET_SYNC.md} (58%) delete mode 100644 ops/latest-core-testnet-sync/latest-core-testnet-sync.service delete mode 100755 ops/latest-core-testnet-sync/run-worker.sh rename ops/{latest-core-testnet-sync => platform-testnet-sync}/README.md (51%) rename ops/{latest-core-testnet-sync => platform-testnet-sync}/install-worker.sh (62%) rename ops/{latest-core-testnet-sync/latest-core-testnet-sync.env.example => platform-testnet-sync/platform-testnet-sync.env.example} (50%) create mode 100644 ops/platform-testnet-sync/platform-testnet-sync.service rename ops/{latest-core-testnet-sync/latest-core-testnet-sync.timer => platform-testnet-sync/platform-testnet-sync.timer} (53%) create mode 100755 ops/platform-testnet-sync/run-worker.sh diff --git a/.github/scripts/latest-core-testnet-sync/run.cjs b/.github/scripts/platform-testnet-sync/run.cjs similarity index 88% rename from .github/scripts/latest-core-testnet-sync/run.cjs rename to .github/scripts/platform-testnet-sync/run.cjs index 67744099a41..4b7ca0346bd 100755 --- a/.github/scripts/latest-core-testnet-sync/run.cjs +++ b/.github/scripts/platform-testnet-sync/run.cjs @@ -5,13 +5,13 @@ const path = require('path'); const { spawn, spawnSync } = require('child_process'); const repoRoot = process.env.PLATFORM_REPO_DIR || process.cwd(); -const runId = process.env.LATEST_CORE_TESTNET_RUN_ID +const runId = process.env.PLATFORM_TESTNET_SYNC_RUN_ID || now().replace(/[:.]/g, '-'); -const runDir = process.env.LATEST_CORE_TESTNET_SYNC_RUN_DIR - || path.join(repoRoot, '.latest-core-testnet-sync', runId); +const runDir = process.env.PLATFORM_TESTNET_SYNC_RUN_DIR + || path.join(repoRoot, '.platform-testnet-sync', runId); const metadataPath = path.join(runDir, 'run-metadata.json'); -const STATUS_CONTEXT = 'Latest public Core testnet sync'; +const STATUS_CONTEXT = 'Platform testnet sync'; const STATUS_LABELS = { sync_passed: 'Sync Passed', @@ -22,14 +22,14 @@ const STATUS_LABELS = { const DEFAULT_RESOLVE_TIMEOUT_MINUTES = 30; const DEFAULT_PHASE_TIMEOUT_MINUTES = 1440; const GITHUB_FETCH_TIMEOUT_MS = parsePositiveInteger( - process.env.LATEST_CORE_TESTNET_GITHUB_FETCH_TIMEOUT_MS, + process.env.PLATFORM_TESTNET_SYNC_GITHUB_FETCH_TIMEOUT_MS, 60_000, - 'LATEST_CORE_TESTNET_GITHUB_FETCH_TIMEOUT_MS', + 'PLATFORM_TESTNET_SYNC_GITHUB_FETCH_TIMEOUT_MS', ); const STATUS_PUBLISH_ATTEMPTS = parsePositiveInteger( - process.env.LATEST_CORE_TESTNET_STATUS_PUBLISH_ATTEMPTS, + process.env.PLATFORM_TESTNET_SYNC_STATUS_PUBLISH_ATTEMPTS, 3, - 'LATEST_CORE_TESTNET_STATUS_PUBLISH_ATTEMPTS', + 'PLATFORM_TESTNET_SYNC_STATUS_PUBLISH_ATTEMPTS', ); function now() { @@ -223,7 +223,7 @@ async function runCommand(name, command, env, timeoutMinutes, options = {}) { } async function prepareWorkspace(metadata, timeoutMinutes) { - if (process.env.LATEST_CORE_TESTNET_SKIP_WORKSPACE_PREP === '1') { + if (process.env.PLATFORM_TESTNET_SYNC_SKIP_WORKSPACE_PREP === '1') { return; } @@ -238,7 +238,7 @@ async function prepareWorkspace(metadata, timeoutMinutes) { 'git fetch --prune origin "$PLATFORM_BRANCH"', 'git checkout "$PLATFORM_BRANCH"', 'git reset --hard "origin/$PLATFORM_BRANCH"', - 'git clean -ffdx -e .latest-core-testnet-sync/', + 'git clean -ffdx -e .platform-testnet-sync/', ].join('\n'), { ...sanitizePhaseEnv(process.env), @@ -255,19 +255,19 @@ async function prepareWorkspace(metadata, timeoutMinutes) { } async function resolveLatestCoreVersion(metadata) { - if (process.env.LATEST_CORE_TESTNET_CORE_VERSION) { - return process.env.LATEST_CORE_TESTNET_CORE_VERSION; + if (process.env.PLATFORM_TESTNET_SYNC_CORE_VERSION) { + return process.env.PLATFORM_TESTNET_SYNC_CORE_VERSION; } - if (process.env.LATEST_CORE_TESTNET_CORE_VERSION_COMMAND) { + if (process.env.PLATFORM_TESTNET_SYNC_CORE_VERSION_COMMAND) { const result = await runCommand( 'resolve-core-version', - process.env.LATEST_CORE_TESTNET_CORE_VERSION_COMMAND, + process.env.PLATFORM_TESTNET_SYNC_CORE_VERSION_COMMAND, sanitizePhaseEnv(process.env), parsePositiveMinutes( - process.env.LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES, + process.env.PLATFORM_TESTNET_SYNC_RESOLVE_TIMEOUT_MINUTES, DEFAULT_RESOLVE_TIMEOUT_MINUTES, - 'LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES', + 'PLATFORM_TESTNET_SYNC_RESOLVE_TIMEOUT_MINUTES', ), { captureStdout: true }, ); @@ -278,11 +278,11 @@ async function resolveLatestCoreVersion(metadata) { return version; } - const releaseRepo = process.env.LATEST_CORE_RELEASE_REPO || 'dashpay/dash'; + const releaseRepo = process.env.PLATFORM_TESTNET_SYNC_CORE_RELEASE_REPO || 'dashpay/dash'; const response = await fetchWithTimeout(`https://api.github.com/repos/${releaseRepo}/releases`, { headers: { Accept: 'application/vnd.github+json', - 'User-Agent': 'dash-platform-latest-core-testnet-sync', + 'User-Agent': 'dash-platform-testnet-sync', ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), }, }); @@ -337,7 +337,7 @@ async function publishCommitStatusOnce(status, metadata) { } const targetUrl = metadata.target_url - || process.env.LATEST_CORE_TESTNET_TARGET_URL + || process.env.PLATFORM_TESTNET_SYNC_TARGET_URL || (process.env.GITHUB_RUN_ID ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}` : undefined); @@ -358,7 +358,7 @@ async function publishCommitStatusOnce(status, metadata) { Accept: 'application/vnd.github+json', Authorization: `Bearer ${githubToken}`, 'Content-Type': 'application/json', - 'User-Agent': 'dash-platform-latest-core-testnet-sync', + 'User-Agent': 'dash-platform-testnet-sync', }, body: JSON.stringify(body), }); @@ -392,9 +392,9 @@ async function main() { ensureRunDir(); const timeoutMinutes = parsePositiveMinutes( - process.env.LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES, + process.env.PLATFORM_TESTNET_SYNC_PHASE_TIMEOUT_MINUTES, DEFAULT_PHASE_TIMEOUT_MINUTES, - 'LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES', + 'PLATFORM_TESTNET_SYNC_PHASE_TIMEOUT_MINUTES', ); const targetSha = resolveTargetSha(); const metadata = { @@ -421,18 +421,18 @@ async function main() { const phaseEnv = { ...process.env, - LATEST_CORE_VERSION: metadata.core_version, + CORE_VERSION: metadata.core_version, PLATFORM_SHA: metadata.platform_sha, - LATEST_CORE_TESTNET_SYNC_RUN_DIR: runDir, + PLATFORM_TESTNET_SYNC_RUN_DIR: runDir, }; delete phaseEnv.GITHUB_TOKEN; delete phaseEnv.GH_TOKEN; - metadata.phase = 'core-readiness'; + metadata.phase = 'baseline-readiness'; writeMetadata(metadata); await runCommand( - 'core-readiness', - process.env.LATEST_CORE_TESTNET_CORE_READY_COMMAND, + 'baseline-readiness', + process.env.PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND, phaseEnv, timeoutMinutes, ); @@ -441,7 +441,7 @@ async function main() { writeMetadata(metadata); await runCommand( 'platform-build', - process.env.LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND, + process.env.PLATFORM_TESTNET_SYNC_PLATFORM_BUILD_COMMAND, phaseEnv, timeoutMinutes, ); @@ -450,7 +450,7 @@ async function main() { writeMetadata(metadata); await runCommand( 'platform-sync', - process.env.LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND, + process.env.PLATFORM_TESTNET_SYNC_PLATFORM_SYNC_COMMAND, phaseEnv, timeoutMinutes, ); diff --git a/.github/workflows/latest-core-testnet-sync-status.yml b/.github/workflows/platform-testnet-sync-status.yml similarity index 94% rename from .github/workflows/latest-core-testnet-sync-status.yml rename to .github/workflows/platform-testnet-sync-status.yml index ad3653a68ab..f7433268b45 100644 --- a/.github/workflows/latest-core-testnet-sync-status.yml +++ b/.github/workflows/platform-testnet-sync-status.yml @@ -1,9 +1,9 @@ -name: "Latest public Core testnet sync status" +name: "Platform testnet sync status" "on": repository_dispatch: types: - - latest-core-testnet-sync-completed + - platform-testnet-sync-completed workflow_dispatch: inputs: status: @@ -115,13 +115,13 @@ jobs: repo: context.repo.repo, sha: targetSha, state, - context: 'Latest public Core testnet sync', + context: 'Platform testnet sync', description, target_url: targetUrl || undefined, }); await core.summary - .addHeading('Latest public Core testnet sync') + .addHeading('Platform testnet sync') .addRaw(`Status: ${label}\n\n`) .addRaw(`Target SHA: ${targetSha}\n\n`) .addRaw(`Core version: ${payload.core_version || 'not provided'}\n\n`) @@ -129,3 +129,7 @@ jobs: .addRaw(`Completed at: ${payload.completed_at || 'not provided'}\n\n`) .addRaw(`Details: ${targetUrl || 'not provided'}\n`) .write(); + + if (status !== 'sync_passed') { + core.setFailed(label); + } diff --git a/.gitignore b/.gitignore index 5fba6a73f38..c1bd49b5cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -103,5 +103,5 @@ __pycache__/ # Security audit reports (local-only, not committed) audits/ -# Latest public Core testnet sync local artifacts -.latest-core-testnet-sync/ +# Platform testnet sync local artifacts +.platform-testnet-sync/ diff --git a/README.md b/README.md index 0335eceed4f..c644db833d7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@

CI Nightly Tests + Platform Sync codecov commit activity last commit diff --git a/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md b/docs/PLATFORM_TESTNET_SYNC.md similarity index 58% rename from docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md rename to docs/PLATFORM_TESTNET_SYNC.md index f57df2a568e..31c99d95e5f 100644 --- a/docs/NIGHTLY_LATEST_CORE_TESTNET_SYNC.md +++ b/docs/PLATFORM_TESTNET_SYNC.md @@ -1,4 +1,4 @@ -# Nightly Latest Core Testnet Sync +# Platform Testnet Sync This document defines the first repo-side contract for reporting Platform sync against the latest public Dash Core release on testnet. @@ -8,66 +8,66 @@ The visible GitHub status is intentionally the last completed run only. A curren The external orchestrator reports one commit status on the tested Platform commit: -- Context: `Latest public Core testnet sync` +- Context: `Platform testnet sync` - Success state: - `Sync Passed` - Failure states: - `Build Failed` - `Sync Failed` -Detailed build, Core, and sync logs belong behind the status `target_url`, not in the status description. +Detailed build, baseline, and sync logs belong behind the status `target_url`, not in the status description. ## System Phases -1. Keep a long-lived testnet Core node synced on the latest public Core release. +1. Maintain a long-lived testnet baseline on the latest public Dash Core release. 2. Build Platform for the target `dashpay/platform` commit on a fast disposable builder. -3. Run Platform sync on a normal sync runner using the synced Core baseline and freshly built Platform artifacts. +3. Run Platform sync on a normal sync runner using the synced testnet baseline and freshly built Platform artifacts. 4. Report only the final completed result back to this repository. -Core baseline maintenance should be owned by a preconfigured, stateful, warm Core system. Platform build should be disposable and cache-heavy. Platform sync should be reproducible and log-rich. +Testnet baseline maintenance should be owned by a preconfigured, stateful, warm testnet system. Platform build should be disposable and cache-heavy. Platform sync should be reproducible and log-rich. ## Scheduling The long-running sync is owned by a persistent worker, not by a GitHub-hosted runner. Platform sync can take 16+ hours, which is too long for GitHub-hosted runner limits and too expensive to keep idle while waiting for chain progress. -The worker assets live in `ops/latest-core-testnet-sync/`: +The worker assets live in `ops/platform-testnet-sync/`: - `install-worker.sh` - `run-worker.sh` -- `latest-core-testnet-sync.service` -- `latest-core-testnet-sync.timer` -- `latest-core-testnet-sync.env.example` +- `platform-testnet-sync.service` +- `platform-testnet-sync.timer` +- `platform-testnet-sync.env.example` The default timer runs nightly at 01:30 UTC with a 30 minute randomized delay. The service uses a lock file, so overlapping runs exit without changing the visible GitHub status. The worker does not publish a pending or running commit status. It leaves the previous completed status in place while the new run is active, then publishes one final completed result when the run finishes. -The Platform sync worker assumes the latest-Core testnet node/baseline already exists. It may run a readiness command to verify that baseline before building and syncing Platform, but it should not perform a full Core chain sync itself. +The Platform sync worker assumes the testnet baseline already exists. It may run a readiness command to verify that baseline before building and syncing Platform, but it should not perform baseline synchronization itself. The worker delegates host-specific work to environment variables: -- `LATEST_CORE_TESTNET_CORE_READY_COMMAND` -- `LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND` -- `LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND` +- `PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND` +- `PLATFORM_TESTNET_SYNC_PLATFORM_BUILD_COMMAND` +- `PLATFORM_TESTNET_SYNC_PLATFORM_SYNC_COMMAND` Optional variables: -- `LATEST_CORE_TESTNET_CORE_VERSION_COMMAND` overrides release discovery. It must print the Core version/tag on stdout. -- `LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES` overrides the per-phase timeout. Defaults to 1440 minutes. -- `LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES` overrides the Core-version resolution command timeout. Defaults to 30 minutes. -- `LATEST_CORE_RELEASE_REPO` overrides the GitHub release source. Defaults to `dashpay/dash`. -- `LATEST_CORE_TESTNET_LOG_DIR` controls where run logs and metadata are written. -- `LATEST_CORE_TESTNET_TARGET_URL` links the GitHub status to durable logs or a run page. +- `PLATFORM_TESTNET_SYNC_CORE_VERSION_COMMAND` overrides release discovery. It must print the Core version/tag on stdout. +- `PLATFORM_TESTNET_SYNC_PHASE_TIMEOUT_MINUTES` overrides the per-phase timeout. Defaults to 1440 minutes. +- `PLATFORM_TESTNET_SYNC_RESOLVE_TIMEOUT_MINUTES` overrides the Core-version resolution command timeout. Defaults to 30 minutes. +- `PLATFORM_TESTNET_SYNC_CORE_RELEASE_REPO` overrides the GitHub release source. Defaults to `dashpay/dash`. +- `PLATFORM_TESTNET_SYNC_LOG_DIR` controls where run logs and metadata are written. +- `PLATFORM_TESTNET_SYNC_TARGET_URL` links the GitHub status to durable logs or a run page. Each phase command receives: -- `LATEST_CORE_VERSION` +- `CORE_VERSION` - `PLATFORM_SHA` -- `LATEST_CORE_TESTNET_SYNC_RUN_DIR` +- `PLATFORM_TESTNET_SYNC_RUN_DIR` The run directory should be used as the durable artifact location for phase logs and metadata. -The GitHub token is used only by the parent worker process for final status publication. It is intentionally stripped from the environment passed to Core readiness, Platform build, and Platform sync commands. +The GitHub token is used only by the parent worker process for final status publication. It is intentionally stripped from the environment passed to baseline readiness, Platform build, and Platform sync commands. ## Reporting Contract @@ -75,7 +75,7 @@ When a run completes, the orchestrator sends a `repository_dispatch` event: ```json { - "event_type": "latest-core-testnet-sync-completed", + "event_type": "platform-testnet-sync-completed", "client_payload": { "status": "sync_passed", "target_sha": "0000000000000000000000000000000000000000", @@ -93,7 +93,7 @@ Allowed `status` values: - `build_failed` - `sync_failed` -Manual status testing is available through the `Latest public Core testnet sync status` workflow's `workflow_dispatch` trigger with the same fields. The normal worker path reports directly through the GitHub commit statuses API. +Manual status testing is available through the `Platform testnet sync status` workflow's `workflow_dispatch` trigger with the same fields. The normal worker path reports directly through the GitHub commit statuses API. The dispatch credential should be held only by the persistent sync worker/operator account. The status workflow requires an explicit 40-character commit SHA that exists in `dashpay/platform`, and workflow-provided `target_url` values are limited to the `https://github.com` origin. diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.service b/ops/latest-core-testnet-sync/latest-core-testnet-sync.service deleted file mode 100644 index a22e6b352a5..00000000000 --- a/ops/latest-core-testnet-sync/latest-core-testnet-sync.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Dash Platform latest public Core testnet sync -Wants=network-online.target -After=network-online.target - -[Service] -Type=oneshot -User=platform-sync -Group=platform-sync -EnvironmentFile=/etc/latest-core-testnet-sync.env -ExecStart=/bin/bash -lc 'cd "${PLATFORM_REPO_DIR:-/opt/dash-platform}" && exec ops/latest-core-testnet-sync/run-worker.sh' -TimeoutStartSec=0 -StateDirectory=latest-core-testnet-sync -LogsDirectory=latest-core-testnet-sync diff --git a/ops/latest-core-testnet-sync/run-worker.sh b/ops/latest-core-testnet-sync/run-worker.sh deleted file mode 100755 index ab32add27f7..00000000000 --- a/ops/latest-core-testnet-sync/run-worker.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -: "${PLATFORM_REPO_DIR:=/opt/dash-platform}" -: "${PLATFORM_BRANCH:=v3.1-dev}" -: "${LATEST_CORE_TESTNET_STATE_DIR:=/var/lib/latest-core-testnet-sync}" -: "${LATEST_CORE_TESTNET_LOG_DIR:=/var/log/latest-core-testnet-sync}" -: "${LATEST_CORE_TESTNET_LOG_RETENTION_DAYS:=30}" - -mkdir -p "${LATEST_CORE_TESTNET_STATE_DIR}" "${LATEST_CORE_TESTNET_LOG_DIR}" - -find "${LATEST_CORE_TESTNET_LOG_DIR}" \ - -mindepth 1 \ - -maxdepth 1 \ - -type d \ - -mtime +"${LATEST_CORE_TESTNET_LOG_RETENTION_DAYS}" \ - -exec rm -rf {} + - -exec 9>"${LATEST_CORE_TESTNET_STATE_DIR}/run.lock" -if ! flock -n 9; then - echo "latest Core testnet sync is already running" - exit 0 -fi - -cd "${PLATFORM_REPO_DIR}" - -RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" -export LATEST_CORE_TESTNET_RUN_ID="${RUN_ID}" -export LATEST_CORE_TESTNET_SYNC_RUN_DIR="${LATEST_CORE_TESTNET_LOG_DIR}/${RUN_ID}" - -node .github/scripts/latest-core-testnet-sync/run.cjs diff --git a/ops/latest-core-testnet-sync/README.md b/ops/platform-testnet-sync/README.md similarity index 51% rename from ops/latest-core-testnet-sync/README.md rename to ops/platform-testnet-sync/README.md index 28570f9c50a..c1cf9dd6128 100644 --- a/ops/latest-core-testnet-sync/README.md +++ b/ops/platform-testnet-sync/README.md @@ -1,12 +1,12 @@ -# Latest Public Core Testnet Sync Worker +# Platform Testnet Sync Worker The Platform sync can run for 16+ hours, so the long-running work belongs on a persistent worker instead of a GitHub-hosted runner. The worker: 1. Updates and cleans a dedicated `dashpay/platform` checkout. -2. Resolves the latest public Dash Core release. -3. Verifies the preconfigured latest-Core testnet baseline, then runs Platform build and Platform sync commands. +2. Resolves the testnet baseline version from the latest public Dash Core release. +3. Verifies the preconfigured testnet baseline, then runs Platform build and Platform sync commands. 4. Publishes one final commit status to the tested Platform commit: - `Sync Passed` - `Build Failed` @@ -27,10 +27,10 @@ sudo chown -R platform-sync:platform-sync /opt/dash-platform Then install the units: ```bash -sudo /opt/dash-platform/ops/latest-core-testnet-sync/install-worker.sh +sudo /opt/dash-platform/ops/platform-testnet-sync/install-worker.sh -sudo editor /etc/latest-core-testnet-sync.env -sudo systemctl enable --now latest-core-testnet-sync.timer +sudo editor /etc/platform-testnet-sync.env +sudo systemctl enable --now platform-testnet-sync.timer ``` The checkout should be dedicated to this worker because the harness resets and cleans it to `origin/$PLATFORM_BRANCH` before every run. @@ -38,22 +38,22 @@ The installer validates that `PLATFORM_REPO_DIR` already exists as a writable gi ## Required Configuration -Set these in `/etc/latest-core-testnet-sync.env`: +Set these in `/etc/platform-testnet-sync.env`: - `GITHUB_TOKEN` -- `LATEST_CORE_TESTNET_CORE_READY_COMMAND` -- `LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND` -- `LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND` +- `PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND` +- `PLATFORM_TESTNET_SYNC_PLATFORM_BUILD_COMMAND` +- `PLATFORM_TESTNET_SYNC_PLATFORM_SYNC_COMMAND` -`LATEST_CORE_TESTNET_CORE_READY_COMMAND` should verify that the preconfigured Core node or baseline is on the selected `LATEST_CORE_VERSION` and synced far enough for the Platform sync run. It is not intended to do a full Core chain sync on the Platform worker. +`PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND` should verify that the preconfigured baseline is on the selected `CORE_VERSION` and synced far enough for the Platform sync run. It is not intended to perform baseline synchronization on the Platform worker. The worker commands run from `PLATFORM_REPO_DIR` and receive: -- `LATEST_CORE_VERSION` +- `CORE_VERSION` - `PLATFORM_SHA` -- `LATEST_CORE_TESTNET_SYNC_RUN_DIR` +- `PLATFORM_TESTNET_SYNC_RUN_DIR` -Write logs or machine-readable metadata into `LATEST_CORE_TESTNET_SYNC_RUN_DIR` so failures can be inspected without overloading the GitHub status panel. +Write logs or machine-readable metadata into `PLATFORM_TESTNET_SYNC_RUN_DIR` so failures can be inspected without overloading the GitHub status panel. `GITHUB_TOKEN` and `GH_TOKEN` are stripped from worker command environments. They remain available only to the worker harness for publishing the final commit status. @@ -62,20 +62,20 @@ Write logs or machine-readable metadata into `LATEST_CORE_TESTNET_SYNC_RUN_DIR` Run immediately: ```bash -sudo systemctl start latest-core-testnet-sync.service +sudo systemctl start platform-testnet-sync.service ``` Check timer: ```bash -systemctl list-timers latest-core-testnet-sync.timer +systemctl list-timers platform-testnet-sync.timer ``` Follow logs: ```bash -journalctl -u latest-core-testnet-sync.service -f +journalctl -u platform-testnet-sync.service -f ``` -Run artifacts are stored under `LATEST_CORE_TESTNET_LOG_DIR`. -Run directories older than `LATEST_CORE_TESTNET_LOG_RETENTION_DAYS` are pruned by `run-worker.sh`; the default is 30 days. +Run artifacts are stored under `PLATFORM_TESTNET_SYNC_LOG_DIR`. +Run directories older than `PLATFORM_TESTNET_SYNC_LOG_RETENTION_DAYS` are pruned by `run-worker.sh`; the default is 30 days. diff --git a/ops/latest-core-testnet-sync/install-worker.sh b/ops/platform-testnet-sync/install-worker.sh similarity index 62% rename from ops/latest-core-testnet-sync/install-worker.sh rename to ops/platform-testnet-sync/install-worker.sh index 13db879d8b0..feee85a3b73 100755 --- a/ops/latest-core-testnet-sync/install-worker.sh +++ b/ops/platform-testnet-sync/install-worker.sh @@ -37,29 +37,29 @@ if ! runuser -u "${PLATFORM_SYNC_USER}" -- test -w "${PLATFORM_REPO_DIR}"; then exit 1 fi -install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/lib/latest-core-testnet-sync -install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/log/latest-core-testnet-sync +install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/lib/platform-testnet-sync +install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/log/platform-testnet-sync install -m 0644 \ - "${SCRIPT_DIR}/latest-core-testnet-sync.service" \ - /etc/systemd/system/latest-core-testnet-sync.service + "${SCRIPT_DIR}/platform-testnet-sync.service" \ + /etc/systemd/system/platform-testnet-sync.service install -m 0644 \ - "${SCRIPT_DIR}/latest-core-testnet-sync.timer" \ - /etc/systemd/system/latest-core-testnet-sync.timer + "${SCRIPT_DIR}/platform-testnet-sync.timer" \ + /etc/systemd/system/platform-testnet-sync.timer -if [[ ! -f /etc/latest-core-testnet-sync.env ]]; then +if [[ ! -f /etc/platform-testnet-sync.env ]]; then install -m 0600 \ - "${SCRIPT_DIR}/latest-core-testnet-sync.env.example" \ - /etc/latest-core-testnet-sync.env - echo "Created /etc/latest-core-testnet-sync.env; fill it before enabling the timer." + "${SCRIPT_DIR}/platform-testnet-sync.env.example" \ + /etc/platform-testnet-sync.env + echo "Created /etc/platform-testnet-sync.env; fill it before enabling the timer." else - chmod 0600 /etc/latest-core-testnet-sync.env + chmod 0600 /etc/platform-testnet-sync.env fi systemctl daemon-reload -echo "Installed latest Core testnet sync worker units." +echo "Installed Platform testnet sync worker units." echo "Next:" -echo " 1. Edit /etc/latest-core-testnet-sync.env" -echo " 2. Run: systemctl start latest-core-testnet-sync.service" -echo " 3. Enable nightly timer: systemctl enable --now latest-core-testnet-sync.timer" +echo " 1. Edit /etc/platform-testnet-sync.env" +echo " 2. Run: systemctl start platform-testnet-sync.service" +echo " 3. Enable nightly timer: systemctl enable --now platform-testnet-sync.timer" diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example b/ops/platform-testnet-sync/platform-testnet-sync.env.example similarity index 50% rename from ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example rename to ops/platform-testnet-sync/platform-testnet-sync.env.example index 397ba6488f4..acbda2620ed 100644 --- a/ops/latest-core-testnet-sync/latest-core-testnet-sync.env.example +++ b/ops/platform-testnet-sync/platform-testnet-sync.env.example @@ -1,4 +1,4 @@ -# Copy to /etc/latest-core-testnet-sync.env on the sync worker. +# Copy to /etc/platform-testnet-sync.env on the sync worker. # Required for final GitHub commit status reporting. Use a fine-scoped token # that can write commit statuses for dashpay/platform. @@ -12,27 +12,27 @@ PLATFORM_REPO_DIR=/opt/dash-platform PLATFORM_BRANCH=v3.1-dev # Persistent worker state and logs. -LATEST_CORE_TESTNET_STATE_DIR=/var/lib/latest-core-testnet-sync -LATEST_CORE_TESTNET_LOG_DIR=/var/log/latest-core-testnet-sync -LATEST_CORE_TESTNET_LOG_RETENTION_DAYS=30 +PLATFORM_TESTNET_SYNC_STATE_DIR=/var/lib/platform-testnet-sync +PLATFORM_TESTNET_SYNC_LOG_DIR=/var/log/platform-testnet-sync +PLATFORM_TESTNET_SYNC_LOG_RETENTION_DAYS=30 # Optional URL linked from the GitHub commit status, for example an S3 # prefix, dashboard, or log viewer. -# LATEST_CORE_TESTNET_TARGET_URL= +# PLATFORM_TESTNET_SYNC_TARGET_URL= # Optional release source/override. -LATEST_CORE_RELEASE_REPO=dashpay/dash -# LATEST_CORE_TESTNET_CORE_VERSION=vX.Y.Z -# LATEST_CORE_TESTNET_CORE_VERSION_COMMAND= +PLATFORM_TESTNET_SYNC_CORE_RELEASE_REPO=dashpay/dash +# PLATFORM_TESTNET_SYNC_CORE_VERSION=vX.Y.Z +# PLATFORM_TESTNET_SYNC_CORE_VERSION_COMMAND= # Allow long syncs. Default in the harness is 1440 minutes. -LATEST_CORE_TESTNET_PHASE_TIMEOUT_MINUTES=1440 +PLATFORM_TESTNET_SYNC_PHASE_TIMEOUT_MINUTES=1440 # Optional timeout for resolving the Core release version. Default is 30 minutes. -# LATEST_CORE_TESTNET_RESOLVE_TIMEOUT_MINUTES=30 +# PLATFORM_TESTNET_SYNC_RESOLVE_TIMEOUT_MINUTES=30 # Required worker commands. These run from PLATFORM_REPO_DIR and receive: -# LATEST_CORE_VERSION, PLATFORM_SHA, and LATEST_CORE_TESTNET_SYNC_RUN_DIR. -# Verifies the preconfigured Core baseline; it should not do full Core chain synchronization. -LATEST_CORE_TESTNET_CORE_READY_COMMAND= -LATEST_CORE_TESTNET_PLATFORM_BUILD_COMMAND= -LATEST_CORE_TESTNET_PLATFORM_SYNC_COMMAND= +# CORE_VERSION, PLATFORM_SHA, and PLATFORM_TESTNET_SYNC_RUN_DIR. +# Verifies the preconfigured testnet baseline; it should not perform baseline synchronization. +PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND= +PLATFORM_TESTNET_SYNC_PLATFORM_BUILD_COMMAND= +PLATFORM_TESTNET_SYNC_PLATFORM_SYNC_COMMAND= diff --git a/ops/platform-testnet-sync/platform-testnet-sync.service b/ops/platform-testnet-sync/platform-testnet-sync.service new file mode 100644 index 00000000000..69d8bc25bbe --- /dev/null +++ b/ops/platform-testnet-sync/platform-testnet-sync.service @@ -0,0 +1,14 @@ +[Unit] +Description=Dash Platform testnet sync +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +User=platform-sync +Group=platform-sync +EnvironmentFile=/etc/platform-testnet-sync.env +ExecStart=/bin/bash -lc 'cd "${PLATFORM_REPO_DIR:-/opt/dash-platform}" && exec ops/platform-testnet-sync/run-worker.sh' +TimeoutStartSec=0 +StateDirectory=platform-testnet-sync +LogsDirectory=platform-testnet-sync diff --git a/ops/latest-core-testnet-sync/latest-core-testnet-sync.timer b/ops/platform-testnet-sync/platform-testnet-sync.timer similarity index 53% rename from ops/latest-core-testnet-sync/latest-core-testnet-sync.timer rename to ops/platform-testnet-sync/platform-testnet-sync.timer index 9e3972b201e..f508faa77df 100644 --- a/ops/latest-core-testnet-sync/latest-core-testnet-sync.timer +++ b/ops/platform-testnet-sync/platform-testnet-sync.timer @@ -1,11 +1,11 @@ [Unit] -Description=Nightly Dash Platform latest public Core testnet sync +Description=Nightly Dash Platform testnet sync [Timer] OnCalendar=*-*-* 01:30:00 UTC Persistent=true RandomizedDelaySec=30m -Unit=latest-core-testnet-sync.service +Unit=platform-testnet-sync.service [Install] WantedBy=timers.target diff --git a/ops/platform-testnet-sync/run-worker.sh b/ops/platform-testnet-sync/run-worker.sh new file mode 100755 index 00000000000..b31a0fcedfc --- /dev/null +++ b/ops/platform-testnet-sync/run-worker.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${PLATFORM_REPO_DIR:=/opt/dash-platform}" +: "${PLATFORM_BRANCH:=v3.1-dev}" +: "${PLATFORM_TESTNET_SYNC_STATE_DIR:=/var/lib/platform-testnet-sync}" +: "${PLATFORM_TESTNET_SYNC_LOG_DIR:=/var/log/platform-testnet-sync}" +: "${PLATFORM_TESTNET_SYNC_LOG_RETENTION_DAYS:=30}" + +mkdir -p "${PLATFORM_TESTNET_SYNC_STATE_DIR}" "${PLATFORM_TESTNET_SYNC_LOG_DIR}" + +find "${PLATFORM_TESTNET_SYNC_LOG_DIR}" \ + -mindepth 1 \ + -maxdepth 1 \ + -type d \ + -mtime +"${PLATFORM_TESTNET_SYNC_LOG_RETENTION_DAYS}" \ + -exec rm -rf {} + + +exec 9>"${PLATFORM_TESTNET_SYNC_STATE_DIR}/run.lock" +if ! flock -n 9; then + echo "Platform testnet sync is already running" + exit 0 +fi + +cd "${PLATFORM_REPO_DIR}" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" +export PLATFORM_TESTNET_SYNC_RUN_ID="${RUN_ID}" +export PLATFORM_TESTNET_SYNC_RUN_DIR="${PLATFORM_TESTNET_SYNC_LOG_DIR}/${RUN_ID}" + +node .github/scripts/platform-testnet-sync/run.cjs From 3546112a7ba115801f30158f7e9a008ba794018f Mon Sep 17 00:00:00 2001 From: infraclaw-dash Date: Wed, 24 Jun 2026 10:27:06 +0000 Subject: [PATCH 7/7] ci: keep Platform sync server code out of repo --- .github/scripts/platform-testnet-sync/run.cjs | 496 ------------------ .gitignore | 3 - docs/PLATFORM_TESTNET_SYNC.md | 90 +--- ops/platform-testnet-sync/README.md | 81 --- ops/platform-testnet-sync/install-worker.sh | 65 --- .../platform-testnet-sync.env.example | 38 -- .../platform-testnet-sync.service | 14 - .../platform-testnet-sync.timer | 11 - ops/platform-testnet-sync/run-worker.sh | 32 -- 9 files changed, 22 insertions(+), 808 deletions(-) delete mode 100755 .github/scripts/platform-testnet-sync/run.cjs delete mode 100644 ops/platform-testnet-sync/README.md delete mode 100755 ops/platform-testnet-sync/install-worker.sh delete mode 100644 ops/platform-testnet-sync/platform-testnet-sync.env.example delete mode 100644 ops/platform-testnet-sync/platform-testnet-sync.service delete mode 100644 ops/platform-testnet-sync/platform-testnet-sync.timer delete mode 100755 ops/platform-testnet-sync/run-worker.sh diff --git a/.github/scripts/platform-testnet-sync/run.cjs b/.github/scripts/platform-testnet-sync/run.cjs deleted file mode 100755 index 4b7ca0346bd..00000000000 --- a/.github/scripts/platform-testnet-sync/run.cjs +++ /dev/null @@ -1,496 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); -const { spawn, spawnSync } = require('child_process'); - -const repoRoot = process.env.PLATFORM_REPO_DIR || process.cwd(); -const runId = process.env.PLATFORM_TESTNET_SYNC_RUN_ID - || now().replace(/[:.]/g, '-'); -const runDir = process.env.PLATFORM_TESTNET_SYNC_RUN_DIR - || path.join(repoRoot, '.platform-testnet-sync', runId); -const metadataPath = path.join(runDir, 'run-metadata.json'); - -const STATUS_CONTEXT = 'Platform testnet sync'; - -const STATUS_LABELS = { - sync_passed: 'Sync Passed', - build_failed: 'Build Failed', - sync_failed: 'Sync Failed', -}; - -const DEFAULT_RESOLVE_TIMEOUT_MINUTES = 30; -const DEFAULT_PHASE_TIMEOUT_MINUTES = 1440; -const GITHUB_FETCH_TIMEOUT_MS = parsePositiveInteger( - process.env.PLATFORM_TESTNET_SYNC_GITHUB_FETCH_TIMEOUT_MS, - 60_000, - 'PLATFORM_TESTNET_SYNC_GITHUB_FETCH_TIMEOUT_MS', -); -const STATUS_PUBLISH_ATTEMPTS = parsePositiveInteger( - process.env.PLATFORM_TESTNET_SYNC_STATUS_PUBLISH_ATTEMPTS, - 3, - 'PLATFORM_TESTNET_SYNC_STATUS_PUBLISH_ATTEMPTS', -); - -function now() { - return new Date().toISOString(); -} - -function ensureRunDir() { - fs.mkdirSync(runDir, { recursive: true }); -} - -function appendLog(fileName, message) { - fs.appendFileSync(path.join(runDir, fileName), message); -} - -function writeMetadata(metadata) { - fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); -} - -function firstLine(value) { - return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean); -} - -function sleep(ms) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function parsePositiveMinutes(rawValue, fallback, name) { - if (rawValue === undefined || rawValue === null || String(rawValue).trim() === '') { - return fallback; - } - - const value = Number(rawValue); - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`${name} must be a positive number, got: ${rawValue}`); - } - - return value; -} - -function parsePositiveInteger(rawValue, fallback, name) { - const value = parsePositiveMinutes(rawValue, fallback, name); - if (!Number.isInteger(value)) { - throw new Error(`${name} must be a positive integer, got: ${rawValue}`); - } - - return value; -} - -function sanitizePhaseEnv(env) { - const sanitized = { ...env }; - delete sanitized.GITHUB_TOKEN; - delete sanitized.GH_TOKEN; - return sanitized; -} - -function validatePlatformBranch(branch) { - const validBranchPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; - if ( - !validBranchPattern.test(branch) - || branch.includes('..') - || branch.includes('//') - || branch.endsWith('/') - || branch.endsWith('.') - ) { - throw new Error(`PLATFORM_BRANCH contains unsupported characters or ref syntax: ${branch}`); - } -} - -function killProcessGroup(child, signal) { - try { - process.kill(-child.pid, signal); - } catch (error) { - if (error.code !== 'ESRCH') { - throw error; - } - } -} - -async function fetchWithTimeout(url, options = {}) { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, GITHUB_FETCH_TIMEOUT_MS); - - try { - return await fetch(url, { - ...options, - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } -} - -function gitOutput(args) { - const result = spawnSync('git', args, { - cwd: repoRoot, - encoding: 'utf8', - }); - - if (result.status !== 0) { - throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`); - } - - return result.stdout.trim(); -} - -function resolveTargetSha() { - return process.env.TARGET_SHA - || process.env.GITHUB_SHA - || gitOutput(['rev-parse', 'HEAD']); -} - -async function runCommand(name, command, env, timeoutMinutes, options = {}) { - if (!command || !command.trim()) { - throw new Error(`Missing command for phase: ${name}`); - } - - const { captureStdout = false } = options; - const logFileName = `${name}.log`; - appendLog(logFileName, `$ ${command}\n\n`); - - const timeoutMs = timeoutMinutes * 60 * 1000; - const startedAt = Date.now(); - - return new Promise((resolve, reject) => { - const child = spawn('bash', ['-c', `set -euo pipefail\n${command}`], { - cwd: repoRoot, - env, - stdio: ['ignore', 'pipe', 'pipe'], - detached: true, - }); - - let timedOut = false; - let settled = false; - let killTimeout; - let stdout = ''; - - const timeout = setTimeout(() => { - timedOut = true; - killProcessGroup(child, 'SIGTERM'); - killTimeout = setTimeout(() => { - if (!settled) { - killProcessGroup(child, 'SIGKILL'); - } - }, 30_000); - }, timeoutMs); - - child.stdout.on('data', (chunk) => { - process.stdout.write(chunk); - if (captureStdout) { - stdout += chunk.toString(); - } - appendLog(logFileName, chunk.toString()); - }); - - child.stderr.on('data', (chunk) => { - process.stderr.write(chunk); - appendLog(logFileName, chunk.toString()); - }); - - child.on('error', (error) => { - settled = true; - clearTimeout(timeout); - clearTimeout(killTimeout); - reject(error); - }); - - child.on('close', (code, signal) => { - settled = true; - clearTimeout(timeout); - clearTimeout(killTimeout); - const durationSeconds = Math.round((Date.now() - startedAt) / 1000); - appendLog(logFileName, `\nexit_code=${code} signal=${signal || ''} duration_seconds=${durationSeconds}\n`); - - if (timedOut) { - reject(new Error(`${name} timed out after ${timeoutMinutes} minutes`)); - return; - } - - if (code === 0) { - resolve({ stdout, durationSeconds }); - return; - } - - reject(new Error(`${name} exited with code ${code}${signal ? ` (${signal})` : ''}`)); - }); - }); -} - -async function prepareWorkspace(metadata, timeoutMinutes) { - if (process.env.PLATFORM_TESTNET_SYNC_SKIP_WORKSPACE_PREP === '1') { - return; - } - - const branch = process.env.PLATFORM_BRANCH || 'v3.1-dev'; - metadata.phase = 'workspace-prepare'; - writeMetadata(metadata); - validatePlatformBranch(branch); - - await runCommand( - 'workspace-prepare', - [ - 'git fetch --prune origin "$PLATFORM_BRANCH"', - 'git checkout "$PLATFORM_BRANCH"', - 'git reset --hard "origin/$PLATFORM_BRANCH"', - 'git clean -ffdx -e .platform-testnet-sync/', - ].join('\n'), - { - ...sanitizePhaseEnv(process.env), - PLATFORM_BRANCH: branch, - }, - timeoutMinutes, - ); - - const targetSha = resolveTargetSha(); - metadata.target_sha = targetSha; - metadata.platform_sha = targetSha; - process.env.TARGET_SHA = targetSha; - writeMetadata(metadata); -} - -async function resolveLatestCoreVersion(metadata) { - if (process.env.PLATFORM_TESTNET_SYNC_CORE_VERSION) { - return process.env.PLATFORM_TESTNET_SYNC_CORE_VERSION; - } - - if (process.env.PLATFORM_TESTNET_SYNC_CORE_VERSION_COMMAND) { - const result = await runCommand( - 'resolve-core-version', - process.env.PLATFORM_TESTNET_SYNC_CORE_VERSION_COMMAND, - sanitizePhaseEnv(process.env), - parsePositiveMinutes( - process.env.PLATFORM_TESTNET_SYNC_RESOLVE_TIMEOUT_MINUTES, - DEFAULT_RESOLVE_TIMEOUT_MINUTES, - 'PLATFORM_TESTNET_SYNC_RESOLVE_TIMEOUT_MINUTES', - ), - { captureStdout: true }, - ); - const version = firstLine(result.stdout); - if (!version) { - throw new Error('Core version command did not print a version'); - } - return version; - } - - const releaseRepo = process.env.PLATFORM_TESTNET_SYNC_CORE_RELEASE_REPO || 'dashpay/dash'; - const response = await fetchWithTimeout(`https://api.github.com/repos/${releaseRepo}/releases`, { - headers: { - Accept: 'application/vnd.github+json', - 'User-Agent': 'dash-platform-testnet-sync', - ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), - }, - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Unable to fetch releases for ${releaseRepo}: HTTP ${response.status} ${errorBody}`); - } - - const releases = await response.json(); - const latestPublicRelease = releases.find((release) => !release.draft && !release.prerelease); - if (!latestPublicRelease) { - throw new Error(`No public non-prerelease releases found for ${releaseRepo}`); - } - - metadata.core_release_url = latestPublicRelease.html_url; - return latestPublicRelease.tag_name; -} - -function statusForFailurePhase(phase) { - if (phase === 'platform-build') { - return 'build_failed'; - } - - return 'sync_failed'; -} - -function statusDescription(status, metadata) { - const details = [ - metadata.core_version, - metadata.platform_sha ? metadata.platform_sha.slice(0, 12) : null, - metadata.completed_at, - ].filter(Boolean).join(' '); - - return `${STATUS_LABELS[status]}${details ? ` - ${details}` : ''}`.slice(0, 140); -} - -async function publishCommitStatusOnce(status, metadata) { - if (process.env.SKIP_GITHUB_STATUS === '1') { - console.log(`Skipping GitHub status publish: ${STATUS_LABELS[status]}`); - return 'skipped'; - } - - const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; - if (!githubToken) { - throw new Error('GITHUB_TOKEN or GH_TOKEN is required to publish commit status'); - } - - const repository = process.env.GITHUB_REPOSITORY || 'dashpay/platform'; - if (!repository) { - throw new Error('GITHUB_REPOSITORY is required to publish commit status'); - } - - const targetUrl = metadata.target_url - || process.env.PLATFORM_TESTNET_SYNC_TARGET_URL - || (process.env.GITHUB_RUN_ID - ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}` - : undefined); - - const body = { - state: status === 'sync_passed' ? 'success' : 'failure', - context: STATUS_CONTEXT, - description: statusDescription(status, metadata), - }; - - if (targetUrl) { - body.target_url = targetUrl; - } - - const response = await fetchWithTimeout(`https://api.github.com/repos/${repository}/statuses/${metadata.target_sha}`, { - method: 'POST', - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${githubToken}`, - 'Content-Type': 'application/json', - 'User-Agent': 'dash-platform-testnet-sync', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Unable to publish commit status: HTTP ${response.status} ${errorBody}`); - } - - return 'published'; -} - -async function publishCommitStatus(status, metadata) { - let lastError; - for (let attempt = 1; attempt <= STATUS_PUBLISH_ATTEMPTS; attempt += 1) { - try { - return await publishCommitStatusOnce(status, metadata); - } catch (error) { - lastError = error; - console.error(`Commit status publish attempt ${attempt} failed: ${error.message}`); - if (attempt < STATUS_PUBLISH_ATTEMPTS) { - await sleep(2 ** attempt * 1000); - } - } - } - - throw lastError; -} - -async function main() { - ensureRunDir(); - - const timeoutMinutes = parsePositiveMinutes( - process.env.PLATFORM_TESTNET_SYNC_PHASE_TIMEOUT_MINUTES, - DEFAULT_PHASE_TIMEOUT_MINUTES, - 'PLATFORM_TESTNET_SYNC_PHASE_TIMEOUT_MINUTES', - ); - const targetSha = resolveTargetSha(); - const metadata = { - status: 'running', - started_at: now(), - target_sha: targetSha, - platform_sha: targetSha, - run_dir: runDir, - run_id: process.env.GITHUB_RUN_ID || null, - run_attempt: process.env.GITHUB_RUN_ATTEMPT || null, - }; - - writeMetadata(metadata); - - let finalStatus = 'sync_passed'; - - try { - await prepareWorkspace(metadata, timeoutMinutes); - - metadata.phase = 'resolve-core-version'; - writeMetadata(metadata); - metadata.core_version = await resolveLatestCoreVersion(metadata); - writeMetadata(metadata); - - const phaseEnv = { - ...process.env, - CORE_VERSION: metadata.core_version, - PLATFORM_SHA: metadata.platform_sha, - PLATFORM_TESTNET_SYNC_RUN_DIR: runDir, - }; - delete phaseEnv.GITHUB_TOKEN; - delete phaseEnv.GH_TOKEN; - - metadata.phase = 'baseline-readiness'; - writeMetadata(metadata); - await runCommand( - 'baseline-readiness', - process.env.PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND, - phaseEnv, - timeoutMinutes, - ); - - metadata.phase = 'platform-build'; - writeMetadata(metadata); - await runCommand( - 'platform-build', - process.env.PLATFORM_TESTNET_SYNC_PLATFORM_BUILD_COMMAND, - phaseEnv, - timeoutMinutes, - ); - - metadata.phase = 'platform-sync'; - writeMetadata(metadata); - await runCommand( - 'platform-sync', - process.env.PLATFORM_TESTNET_SYNC_PLATFORM_SYNC_COMMAND, - phaseEnv, - timeoutMinutes, - ); - - metadata.phase = 'complete'; - } catch (error) { - metadata.failure_phase = metadata.phase || 'resolve-core-version'; - metadata.failure_summary = error.message; - finalStatus = statusForFailurePhase(metadata.failure_phase); - console.error(error.stack || error.message); - } finally { - metadata.status = finalStatus; - metadata.completed_at = now(); - writeMetadata(metadata); - try { - const publishResult = await publishCommitStatus(finalStatus, metadata); - if (publishResult === 'skipped') { - metadata.status_publish_skipped_at = now(); - } else { - metadata.status_published_at = now(); - } - delete metadata.status_publish_error; - delete metadata.status_publish_failed_at; - } catch (error) { - metadata.status_publish_error = error.message; - metadata.status_publish_failed_at = now(); - writeMetadata(metadata); - throw error; - } - writeMetadata(metadata); - } - - console.log(`${STATUS_CONTEXT}: ${STATUS_LABELS[finalStatus]}`); - - if (finalStatus !== 'sync_passed') { - process.exitCode = 1; - } -} - -main().catch((error) => { - console.error(error.stack || error.message); - process.exitCode = 1; -}); diff --git a/.gitignore b/.gitignore index c1bd49b5cb2..80f977f5be6 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,3 @@ __pycache__/ # Security audit reports (local-only, not committed) audits/ - -# Platform testnet sync local artifacts -.platform-testnet-sync/ diff --git a/docs/PLATFORM_TESTNET_SYNC.md b/docs/PLATFORM_TESTNET_SYNC.md index 31c99d95e5f..117f3b622bf 100644 --- a/docs/PLATFORM_TESTNET_SYNC.md +++ b/docs/PLATFORM_TESTNET_SYNC.md @@ -1,12 +1,18 @@ # Platform Testnet Sync -This document defines the first repo-side contract for reporting Platform sync against the latest public Dash Core release on testnet. +This document defines the repo-side reporting contract for Platform sync against the latest public Dash Core release on testnet. -The visible GitHub status is intentionally the last completed run only. A currently running build or sync must not replace the useful completed result with a running state. +The Platform sync worker and server infrastructure live outside this repository. This repository only owns the public status surface: + +- the `Platform Sync` README badge +- the `Platform testnet sync status` workflow +- the `Platform testnet sync` commit status context ## Visible Status -The external orchestrator reports one commit status on the tested Platform commit: +The visible status is intentionally the last completed run only. A currently running build or sync must not replace the useful completed result with a running state. + +The external worker reports one final completed status for the tested Platform commit: - Context: `Platform testnet sync` - Success state: @@ -17,61 +23,9 @@ The external orchestrator reports one commit status on the tested Platform commi Detailed build, baseline, and sync logs belong behind the status `target_url`, not in the status description. -## System Phases - -1. Maintain a long-lived testnet baseline on the latest public Dash Core release. -2. Build Platform for the target `dashpay/platform` commit on a fast disposable builder. -3. Run Platform sync on a normal sync runner using the synced testnet baseline and freshly built Platform artifacts. -4. Report only the final completed result back to this repository. - -Testnet baseline maintenance should be owned by a preconfigured, stateful, warm testnet system. Platform build should be disposable and cache-heavy. Platform sync should be reproducible and log-rich. - -## Scheduling - -The long-running sync is owned by a persistent worker, not by a GitHub-hosted runner. Platform sync can take 16+ hours, which is too long for GitHub-hosted runner limits and too expensive to keep idle while waiting for chain progress. - -The worker assets live in `ops/platform-testnet-sync/`: - -- `install-worker.sh` -- `run-worker.sh` -- `platform-testnet-sync.service` -- `platform-testnet-sync.timer` -- `platform-testnet-sync.env.example` - -The default timer runs nightly at 01:30 UTC with a 30 minute randomized delay. The service uses a lock file, so overlapping runs exit without changing the visible GitHub status. - -The worker does not publish a pending or running commit status. It leaves the previous completed status in place while the new run is active, then publishes one final completed result when the run finishes. - -The Platform sync worker assumes the testnet baseline already exists. It may run a readiness command to verify that baseline before building and syncing Platform, but it should not perform baseline synchronization itself. +## Reporting Flow -The worker delegates host-specific work to environment variables: - -- `PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND` -- `PLATFORM_TESTNET_SYNC_PLATFORM_BUILD_COMMAND` -- `PLATFORM_TESTNET_SYNC_PLATFORM_SYNC_COMMAND` - -Optional variables: - -- `PLATFORM_TESTNET_SYNC_CORE_VERSION_COMMAND` overrides release discovery. It must print the Core version/tag on stdout. -- `PLATFORM_TESTNET_SYNC_PHASE_TIMEOUT_MINUTES` overrides the per-phase timeout. Defaults to 1440 minutes. -- `PLATFORM_TESTNET_SYNC_RESOLVE_TIMEOUT_MINUTES` overrides the Core-version resolution command timeout. Defaults to 30 minutes. -- `PLATFORM_TESTNET_SYNC_CORE_RELEASE_REPO` overrides the GitHub release source. Defaults to `dashpay/dash`. -- `PLATFORM_TESTNET_SYNC_LOG_DIR` controls where run logs and metadata are written. -- `PLATFORM_TESTNET_SYNC_TARGET_URL` links the GitHub status to durable logs or a run page. - -Each phase command receives: - -- `CORE_VERSION` -- `PLATFORM_SHA` -- `PLATFORM_TESTNET_SYNC_RUN_DIR` - -The run directory should be used as the durable artifact location for phase logs and metadata. - -The GitHub token is used only by the parent worker process for final status publication. It is intentionally stripped from the environment passed to baseline readiness, Platform build, and Platform sync commands. - -## Reporting Contract - -When a run completes, the orchestrator sends a `repository_dispatch` event: +When a run completes, the external worker sends a `repository_dispatch` event to `dashpay/platform`: ```json { @@ -93,20 +47,20 @@ Allowed `status` values: - `build_failed` - `sync_failed` -Manual status testing is available through the `Platform testnet sync status` workflow's `workflow_dispatch` trigger with the same fields. The normal worker path reports directly through the GitHub commit statuses API. +The workflow validates the target commit SHA, writes the `Platform testnet sync` commit status, and fails the workflow run for `build_failed` and `sync_failed` so the README badge reflects the final outcome. + +Manual status testing is available through the `Platform testnet sync status` workflow's `workflow_dispatch` trigger with the same fields. -The dispatch credential should be held only by the persistent sync worker/operator account. The status workflow requires an explicit 40-character commit SHA that exists in `dashpay/platform`, and workflow-provided `target_url` values are limited to the `https://github.com` origin. +## Expectations For The External Worker -## Log and Artifact Expectations +The worker should: -The `target_url` should lead to a run page or artifact bundle containing: +- maintain or consume a synced latest-public-Core testnet baseline +- build Platform for the target `dashpay/platform` commit +- run Platform sync against that baseline +- report only the final completed result to this repository +- keep detailed logs and diagnostics outside this repository, linked through `target_url` -- selected public Core version and binary/image digest -- target Platform commit and image digests -- Core tip height/hash and sync health at run start -- build logs -- Platform service logs -- Platform sync progress and terminal state -- concise failure phase and reason +The dispatch credential should be held only by the external worker/operator account. The status workflow requires an explicit 40-character commit SHA that exists in `dashpay/platform`, and workflow-provided `target_url` values are limited to the `https://github.com` origin. The GitHub status description stays short so the repo panel remains readable. diff --git a/ops/platform-testnet-sync/README.md b/ops/platform-testnet-sync/README.md deleted file mode 100644 index c1cf9dd6128..00000000000 --- a/ops/platform-testnet-sync/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Platform Testnet Sync Worker - -The Platform sync can run for 16+ hours, so the long-running work belongs on a persistent worker instead of a GitHub-hosted runner. - -The worker: - -1. Updates and cleans a dedicated `dashpay/platform` checkout. -2. Resolves the testnet baseline version from the latest public Dash Core release. -3. Verifies the preconfigured testnet baseline, then runs Platform build and Platform sync commands. -4. Publishes one final commit status to the tested Platform commit: - - `Sync Passed` - - `Build Failed` - - `Sync Failed` - -It does not publish a running status. The previous completed GitHub status remains visible while a new worker run is active. - -## Install - -On the sync worker, clone a dedicated checkout first: - -```bash -sudo useradd --system --create-home --shell /usr/sbin/nologin platform-sync -sudo git clone https://github.com/dashpay/platform.git /opt/dash-platform -sudo chown -R platform-sync:platform-sync /opt/dash-platform -``` - -Then install the units: - -```bash -sudo /opt/dash-platform/ops/platform-testnet-sync/install-worker.sh - -sudo editor /etc/platform-testnet-sync.env -sudo systemctl enable --now platform-testnet-sync.timer -``` - -The checkout should be dedicated to this worker because the harness resets and cleans it to `origin/$PLATFORM_BRANCH` before every run. -The installer validates that `PLATFORM_REPO_DIR` already exists as a writable git checkout for the `platform-sync` user. - -## Required Configuration - -Set these in `/etc/platform-testnet-sync.env`: - -- `GITHUB_TOKEN` -- `PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND` -- `PLATFORM_TESTNET_SYNC_PLATFORM_BUILD_COMMAND` -- `PLATFORM_TESTNET_SYNC_PLATFORM_SYNC_COMMAND` - -`PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND` should verify that the preconfigured baseline is on the selected `CORE_VERSION` and synced far enough for the Platform sync run. It is not intended to perform baseline synchronization on the Platform worker. - -The worker commands run from `PLATFORM_REPO_DIR` and receive: - -- `CORE_VERSION` -- `PLATFORM_SHA` -- `PLATFORM_TESTNET_SYNC_RUN_DIR` - -Write logs or machine-readable metadata into `PLATFORM_TESTNET_SYNC_RUN_DIR` so failures can be inspected without overloading the GitHub status panel. - -`GITHUB_TOKEN` and `GH_TOKEN` are stripped from worker command environments. They remain available only to the worker harness for publishing the final commit status. - -## Operations - -Run immediately: - -```bash -sudo systemctl start platform-testnet-sync.service -``` - -Check timer: - -```bash -systemctl list-timers platform-testnet-sync.timer -``` - -Follow logs: - -```bash -journalctl -u platform-testnet-sync.service -f -``` - -Run artifacts are stored under `PLATFORM_TESTNET_SYNC_LOG_DIR`. -Run directories older than `PLATFORM_TESTNET_SYNC_LOG_RETENTION_DAYS` are pruned by `run-worker.sh`; the default is 30 days. diff --git a/ops/platform-testnet-sync/install-worker.sh b/ops/platform-testnet-sync/install-worker.sh deleted file mode 100755 index feee85a3b73..00000000000 --- a/ops/platform-testnet-sync/install-worker.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -: "${PLATFORM_REPO_DIR:=/opt/dash-platform}" -PLATFORM_SYNC_USER=platform-sync -PLATFORM_SYNC_GROUP=platform-sync -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - -if [[ "${EUID}" -ne 0 ]]; then - echo "install-worker.sh must be run as root" >&2 - exit 1 -fi - -if ! getent group "${PLATFORM_SYNC_GROUP}" >/dev/null 2>&1; then - groupadd --system "${PLATFORM_SYNC_GROUP}" -fi - -if ! id "${PLATFORM_SYNC_USER}" >/dev/null 2>&1; then - useradd \ - --system \ - --create-home \ - --shell /usr/sbin/nologin \ - --gid "${PLATFORM_SYNC_GROUP}" \ - "${PLATFORM_SYNC_USER}" -fi - -if [[ ! -d "${PLATFORM_REPO_DIR}/.git" ]]; then - echo "PLATFORM_REPO_DIR must be an existing git checkout: ${PLATFORM_REPO_DIR}" >&2 - echo "Clone dashpay/platform there before running this installer." >&2 - exit 1 -fi - -if ! runuser -u "${PLATFORM_SYNC_USER}" -- test -w "${PLATFORM_REPO_DIR}"; then - echo "PLATFORM_REPO_DIR must be writable by ${PLATFORM_SYNC_USER}: ${PLATFORM_REPO_DIR}" >&2 - echo "Run: chown -R ${PLATFORM_SYNC_USER}:${PLATFORM_SYNC_GROUP} ${PLATFORM_REPO_DIR}" >&2 - exit 1 -fi - -install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/lib/platform-testnet-sync -install -d -o "${PLATFORM_SYNC_USER}" -g "${PLATFORM_SYNC_GROUP}" /var/log/platform-testnet-sync - -install -m 0644 \ - "${SCRIPT_DIR}/platform-testnet-sync.service" \ - /etc/systemd/system/platform-testnet-sync.service -install -m 0644 \ - "${SCRIPT_DIR}/platform-testnet-sync.timer" \ - /etc/systemd/system/platform-testnet-sync.timer - -if [[ ! -f /etc/platform-testnet-sync.env ]]; then - install -m 0600 \ - "${SCRIPT_DIR}/platform-testnet-sync.env.example" \ - /etc/platform-testnet-sync.env - echo "Created /etc/platform-testnet-sync.env; fill it before enabling the timer." -else - chmod 0600 /etc/platform-testnet-sync.env -fi - -systemctl daemon-reload - -echo "Installed Platform testnet sync worker units." -echo "Next:" -echo " 1. Edit /etc/platform-testnet-sync.env" -echo " 2. Run: systemctl start platform-testnet-sync.service" -echo " 3. Enable nightly timer: systemctl enable --now platform-testnet-sync.timer" diff --git a/ops/platform-testnet-sync/platform-testnet-sync.env.example b/ops/platform-testnet-sync/platform-testnet-sync.env.example deleted file mode 100644 index acbda2620ed..00000000000 --- a/ops/platform-testnet-sync/platform-testnet-sync.env.example +++ /dev/null @@ -1,38 +0,0 @@ -# Copy to /etc/platform-testnet-sync.env on the sync worker. - -# Required for final GitHub commit status reporting. Use a fine-scoped token -# that can write commit statuses for dashpay/platform. -GITHUB_TOKEN= -GITHUB_REPOSITORY=dashpay/platform - -# Dedicated checkout used by the worker wrapper. -PLATFORM_REPO_DIR=/opt/dash-platform -# Branch names are intentionally limited to letters, numbers, dot, underscore, -# slash, and dash before being passed to git. -PLATFORM_BRANCH=v3.1-dev - -# Persistent worker state and logs. -PLATFORM_TESTNET_SYNC_STATE_DIR=/var/lib/platform-testnet-sync -PLATFORM_TESTNET_SYNC_LOG_DIR=/var/log/platform-testnet-sync -PLATFORM_TESTNET_SYNC_LOG_RETENTION_DAYS=30 - -# Optional URL linked from the GitHub commit status, for example an S3 -# prefix, dashboard, or log viewer. -# PLATFORM_TESTNET_SYNC_TARGET_URL= - -# Optional release source/override. -PLATFORM_TESTNET_SYNC_CORE_RELEASE_REPO=dashpay/dash -# PLATFORM_TESTNET_SYNC_CORE_VERSION=vX.Y.Z -# PLATFORM_TESTNET_SYNC_CORE_VERSION_COMMAND= - -# Allow long syncs. Default in the harness is 1440 minutes. -PLATFORM_TESTNET_SYNC_PHASE_TIMEOUT_MINUTES=1440 -# Optional timeout for resolving the Core release version. Default is 30 minutes. -# PLATFORM_TESTNET_SYNC_RESOLVE_TIMEOUT_MINUTES=30 - -# Required worker commands. These run from PLATFORM_REPO_DIR and receive: -# CORE_VERSION, PLATFORM_SHA, and PLATFORM_TESTNET_SYNC_RUN_DIR. -# Verifies the preconfigured testnet baseline; it should not perform baseline synchronization. -PLATFORM_TESTNET_SYNC_BASELINE_READY_COMMAND= -PLATFORM_TESTNET_SYNC_PLATFORM_BUILD_COMMAND= -PLATFORM_TESTNET_SYNC_PLATFORM_SYNC_COMMAND= diff --git a/ops/platform-testnet-sync/platform-testnet-sync.service b/ops/platform-testnet-sync/platform-testnet-sync.service deleted file mode 100644 index 69d8bc25bbe..00000000000 --- a/ops/platform-testnet-sync/platform-testnet-sync.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Dash Platform testnet sync -Wants=network-online.target -After=network-online.target - -[Service] -Type=oneshot -User=platform-sync -Group=platform-sync -EnvironmentFile=/etc/platform-testnet-sync.env -ExecStart=/bin/bash -lc 'cd "${PLATFORM_REPO_DIR:-/opt/dash-platform}" && exec ops/platform-testnet-sync/run-worker.sh' -TimeoutStartSec=0 -StateDirectory=platform-testnet-sync -LogsDirectory=platform-testnet-sync diff --git a/ops/platform-testnet-sync/platform-testnet-sync.timer b/ops/platform-testnet-sync/platform-testnet-sync.timer deleted file mode 100644 index f508faa77df..00000000000 --- a/ops/platform-testnet-sync/platform-testnet-sync.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Nightly Dash Platform testnet sync - -[Timer] -OnCalendar=*-*-* 01:30:00 UTC -Persistent=true -RandomizedDelaySec=30m -Unit=platform-testnet-sync.service - -[Install] -WantedBy=timers.target diff --git a/ops/platform-testnet-sync/run-worker.sh b/ops/platform-testnet-sync/run-worker.sh deleted file mode 100755 index b31a0fcedfc..00000000000 --- a/ops/platform-testnet-sync/run-worker.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -: "${PLATFORM_REPO_DIR:=/opt/dash-platform}" -: "${PLATFORM_BRANCH:=v3.1-dev}" -: "${PLATFORM_TESTNET_SYNC_STATE_DIR:=/var/lib/platform-testnet-sync}" -: "${PLATFORM_TESTNET_SYNC_LOG_DIR:=/var/log/platform-testnet-sync}" -: "${PLATFORM_TESTNET_SYNC_LOG_RETENTION_DAYS:=30}" - -mkdir -p "${PLATFORM_TESTNET_SYNC_STATE_DIR}" "${PLATFORM_TESTNET_SYNC_LOG_DIR}" - -find "${PLATFORM_TESTNET_SYNC_LOG_DIR}" \ - -mindepth 1 \ - -maxdepth 1 \ - -type d \ - -mtime +"${PLATFORM_TESTNET_SYNC_LOG_RETENTION_DAYS}" \ - -exec rm -rf {} + - -exec 9>"${PLATFORM_TESTNET_SYNC_STATE_DIR}/run.lock" -if ! flock -n 9; then - echo "Platform testnet sync is already running" - exit 0 -fi - -cd "${PLATFORM_REPO_DIR}" - -RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" -export PLATFORM_TESTNET_SYNC_RUN_ID="${RUN_ID}" -export PLATFORM_TESTNET_SYNC_RUN_DIR="${PLATFORM_TESTNET_SYNC_LOG_DIR}/${RUN_ID}" - -node .github/scripts/platform-testnet-sync/run.cjs