SimpleDocReview / scripts /sync-upstream.ps1
Eric Putnam
Claude Opus 5 (1M context)
feat: deploy simpledocreview as a browser-only static space
3d968d9
Raw History Blame Contribute Delete
7.16 kB
<#
.SYNOPSIS
Mirror the upstream SimpleDocReview checkout into ./app.
.DESCRIPTION
./app is a vendored copy of upstream, trimmed to what the deployment needs:
the server (app.py, kept for the Dockerfile escape hatch) and the front end
(static/) that the Static Space actually serves.
This script wipes and re-mirrors it, then records the upstream commit in
UPSTREAM.json so the Space can always be traced back to a revision.
Anything you edit under ./app is destroyed by the next run. Fix upstream
instead, then re-sync.
.PARAMETER UpstreamPath
The local SimpleDocReview checkout. Defaults to C:\dev\SimpleDocReview.
.PARAMETER AllowDirty
Sync even when the upstream checkout has uncommitted changes. Without this,
a dirty upstream is an error, because the recorded commit would not describe
what actually got deployed.
.EXAMPLE
.\scripts\sync-upstream.ps1
#>
[CmdletBinding()]
param(
[string] $UpstreamPath = 'C:\dev\SimpleDocReview',
[switch] $AllowDirty
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$appDir = Join-Path $repoRoot 'app'
# --- Validate upstream -----------------------------------------------------
if (-not (Test-Path -LiteralPath $UpstreamPath -PathType Container)) {
throw "Upstream path not found: $UpstreamPath"
}
$upstreamAppPy = Join-Path $UpstreamPath 'app.py'
if (-not (Test-Path -LiteralPath $upstreamAppPy -PathType Leaf)) {
throw "No app.py at $UpstreamPath - is that really the SimpleDocReview checkout?"
}
$appPySource = Get-Content -LiteralPath $upstreamAppPy -Raw
if ($appPySource -notmatch 'server_version\s*=\s*"SimpleDocReview/') {
throw "app.py at $UpstreamPath does not identify itself as SimpleDocReview."
}
$serverVersion = ([regex]'server_version\s*=\s*"(SimpleDocReview/[^"]+)"').Match($appPySource).Groups[1].Value
# --- Read upstream git provenance -----------------------------------------
$commit = 'unknown'
$branch = 'unknown'
$isDirty = $false
$originUrl = 'unknown'
if (Test-Path -LiteralPath (Join-Path $UpstreamPath '.git')) {
Push-Location $UpstreamPath
try {
$commit = (& git rev-parse HEAD).Trim()
$branch = (& git rev-parse --abbrev-ref HEAD).Trim()
$status = & git status --porcelain
$isDirty = -not [string]::IsNullOrWhiteSpace(($status -join ''))
try { $originUrl = (& git remote get-url origin).Trim() } catch { $originUrl = 'none' }
}
finally { Pop-Location }
}
else {
Write-Warning "$UpstreamPath is not a git repo - provenance will be recorded as 'unknown'."
}
if ($isDirty -and -not $AllowDirty) {
throw @"
Upstream checkout has uncommitted changes.
Commit them in $UpstreamPath first so the deployed Space maps to a real revision,
or re-run with -AllowDirty to deploy the working tree as-is.
"@
}
# --- Mirror upstream -> ./app ---------------------------------------------
# Local review data is a user's documents, not source. It must never be copied
# into a repo that gets pushed to a public Space.
$excludeDirs = @(
(Join-Path $UpstreamPath '.git'),
(Join-Path $UpstreamPath '.github'),
(Join-Path $UpstreamPath '.claude'),
(Join-Path $UpstreamPath '.vscode'),
(Join-Path $UpstreamPath '.idea'),
(Join-Path $UpstreamPath 'tests'),
(Join-Path $UpstreamPath 'docs'),
(Join-Path $UpstreamPath 'scripts'),
(Join-Path $UpstreamPath 'data'),
(Join-Path $UpstreamPath 'uploads'),
'__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache'
)
# Repo-hygiene files the deployment does not read. README.md in particular must
# not land in app/, because the Space card at the repository root is the README
# Hugging Face renders.
$excludeFiles = @(
'README.md', 'CLAUDE.md', 'CHANGELOG.md',
'.gitignore', '.gitattributes',
'*.pyc', '*.log', '.env', '.env.local'
)
Write-Host "Syncing $UpstreamPath -> $appDir" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $appDir | Out-Null
$robocopyArgs = @(
$UpstreamPath, $appDir,
'/MIR', # mirror: also deletes files removed upstream
'/NFL', '/NDL', # quiet: no per-file or per-directory listing
'/NJH', '/NP',
'/R:2', '/W:1'
) + @('/XD') + $excludeDirs + @('/XF') + $excludeFiles
& robocopy @robocopyArgs | Out-Null
# Robocopy exit codes 0-7 are success (8+ means copy failures).
if ($LASTEXITCODE -ge 8) {
throw "robocopy failed with exit code $LASTEXITCODE"
}
# --- Sanity-check what landed --------------------------------------------
$required = @('app.py', 'static/index.html', 'static/app.js', 'static/styles.css')
$missing = $required | Where-Object { -not (Test-Path -LiteralPath (Join-Path $appDir $_)) }
if ($missing.Count -gt 0) {
throw "Sync incomplete - missing from app/: $($missing -join ', ')"
}
# --- Warn if the Dockerfile's entry point no longer fits app.py ----------
# The container calls app.run('0.0.0.0', 7860) positionally, so a change to that
# signature breaks the escape hatch without breaking the static build - exactly
# the kind of drift nobody notices until they need the container.
if ($appPySource -notmatch 'def\s+run\s*\(\s*host[^)]*port') {
Write-Warning "app.py's run() signature changed. Check the CMD line in Dockerfile still matches."
}
# --- Record provenance ---------------------------------------------------
function Get-Sha256([string] $path) {
return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
}
$upstream = [ordered]@{
upstreamRepo = $originUrl
upstreamPath = $UpstreamPath
commit = $commit
branch = $branch
dirtyWorkingTree = $isDirty
serverVersion = $serverVersion
portedSources = [ordered]@{
'app.py' = Get-Sha256 (Join-Path $appDir 'app.py')
'static/app.js' = Get-Sha256 (Join-Path $appDir 'static/app.js')
}
syncedAtUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
}
# Written without a BOM: Windows PowerShell 5.1's -Encoding utf8 emits one, and
# a BOM breaks strict JSON parsers that may read this file later.
$upstreamJson = ($upstream | ConvertTo-Json -Depth 4) + [Environment]::NewLine
[System.IO.File]::WriteAllText(
(Join-Path $repoRoot 'UPSTREAM.json'),
$upstreamJson,
(New-Object System.Text.UTF8Encoding $false)
)
# --- Summary -------------------------------------------------------------
$shortCommit = if ($commit.Length -ge 7) { $commit.Substring(0, 7) } else { $commit }
Write-Host ''
Write-Host "Synced $serverVersion @ $shortCommit ($branch)" -ForegroundColor Green
if ($isDirty) { Write-Warning 'Synced from a dirty upstream working tree.' }
Write-Host ''
Push-Location $repoRoot
try {
$changes = & git status --porcelain
if ([string]::IsNullOrWhiteSpace(($changes -join ''))) {
Write-Host 'No changes - the Space already matches upstream.' -ForegroundColor Yellow
}
else {
Write-Host 'Pending changes:'
& git status --short
Write-Host ''
Write-Host 'Deploy with: .\scripts\deploy.ps1' -ForegroundColor Cyan
}
}
finally { Pop-Location }