From 62096e8210740f563de653d2f4938a38f407b794 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Thu, 9 Jul 2026 16:03:48 +0300 Subject: [PATCH] Release: weekly deploy --- .gitignore | 1 + Documentation/deploy-dev-to-prod.md | 4 +- Documentation/environments.md | 6 +-- infra/deploy/dev-autostart-dev | 0 infra/deploy/devtoprod.config.example.json | 3 -- infra/docker/.env.prod.example | 1 - infra/docker/Dockerfile | 8 +++ infra/docker/build-push-lan.ps1 | 43 ++++++++++++++-- infra/docker/compose.prod.yaml | 2 +- infra/docker/compose.truenas.yaml | 3 +- infra/docker/push-lan.ps1 | 12 +++-- infra/scripts/deploy-dev-to-prod.ps1 | 4 +- infra/scripts/lib/Build-ReleaseTag.ps1 | 58 ++++++++++++++++++++++ scripts/dev-web.js | 1 + server/index.js | 14 +----- server/version-info.js | 47 ++++++++++++++++++ 16 files changed, 169 insertions(+), 38 deletions(-) create mode 100644 infra/deploy/dev-autostart-dev create mode 100644 infra/scripts/lib/Build-ReleaseTag.ps1 create mode 100644 server/version-info.js diff --git a/.gitignore b/.gitignore index 9b1b118..09dda19 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ client/node_modules/ .env.*.local infra/docker/.env.prod infra/deploy/devtoprod.config.json +infra/deploy/last-docker-release.json # DB backups (may contain data) db/DataBackup/ diff --git a/Documentation/deploy-dev-to-prod.md b/Documentation/deploy-dev-to-prod.md index bd7a07b..f2b8357 100644 --- a/Documentation/deploy-dev-to-prod.md +++ b/Documentation/deploy-dev-to-prod.md @@ -34,13 +34,11 @@ For a typical weekly release, use the orchestrator instead of running each step - `autoStartDevStack`: `true` starts `dev:server` (or `dev:web`) automatically when `validateBuild` finds API down - `git.message`, `git.branch` — used when `gitCommitPush` is enabled - `schemaChanged`: `true` enables prod schema migration on `full` profile - - `release.imageTag`: image/version tag shared between Gitea push and runtime (`latest`, `2026-07-09`, `v1.3.0`, etc.) - `smb.user` / `smb.password` — optional; maps `\\host\share` before image sync - `backupFile` — optional fixed path; otherwise uses the newest `gallery_dev_data_*.txt` after backup 3. Prerequisites still apply: **`npm run dev:web` running** (Vite `:5173` + API `:3451`), Docker Desktop running, `docker login gitea.mysuperlab.netcraze.pro`, [`infra/docker/.env.prod`](../infra/docker/.env.prod) present. - Before migration/restore steps, verify prod DB target in `.env.prod`: `DB_NAME=gallery_prod`. - - Set `IMAGE_TAG=` in prod env (TrueNAS app vars or `.env.prod`) to control which pushed Gitea image tag is active. ### Run @@ -68,7 +66,7 @@ npm run devtoprod:release -- -DryRun | `migrateProdSchema` | Step 4 — `dev:migrate` on `gallery_prod` | | `restoreProd` | Step 5 — `devtoprod:db:restore` | | `syncImages` | Step 6 — `devtoprod:images` | -| `dockerPublish` | Step 7 — `prod:docker:publish -- -Tag ` | +| `dockerPublish` | Step 7 — `prod:docker:publish` (auto tag: `YYYYMMDD-HHMMSS-` + push `latest`) | | `truenasRestartPause` | Step 8 — **manual** pause; script waits for Enter after you restart **gallery-web** | | `verify` | Step 9 — curl LAN + public `/api/bounds` | diff --git a/Documentation/environments.md b/Documentation/environments.md index 6c089cb..18d9a71 100644 --- a/Documentation/environments.md +++ b/Documentation/environments.md @@ -8,9 +8,9 @@ Gallery uses **one PostgreSQL server** on TrueNAS (`192.168.10.122`) with **two | **Production** | `gallery_prod` | `https://gallery.mysuperlab.netcraze.pro` | TrueNAS container `192.168.10.122:5173` | Version tracking: -- Runtime version endpoint: `/api/version` (reports `app_env`, `app_version`, `image_tag`, `git_sha`, `db_name`) -- Prod image selection: set `IMAGE_TAG` in TrueNAS app env / `infra/docker/.env.prod` -- Release push tag: set `release.imageTag` in `infra/deploy/devtoprod.config.json` +- Runtime version endpoint: `/api/version` (reports `app_env`, `app_version`, `image_tag`, `git_sha`, `built_at`, `db_name`) +- `npm run prod:docker:publish` auto-creates a release tag like `20260709-155412-f72ddcc` (build time + git commit), pushes it and `latest` +- Version metadata is baked into the Docker image; TrueNAS only needs `gallery-web:latest` with `pull_policy: always` --- diff --git a/infra/deploy/dev-autostart-dev b/infra/deploy/dev-autostart-dev new file mode 100644 index 0000000..e69de29 diff --git a/infra/deploy/devtoprod.config.example.json b/infra/deploy/devtoprod.config.example.json index a977ab9..11f1beb 100644 --- a/infra/deploy/devtoprod.config.example.json +++ b/infra/deploy/devtoprod.config.example.json @@ -22,9 +22,6 @@ "stageAll": true }, "schemaChanged": false, - "release": { - "imageTag": "latest" - }, "backupFile": "", "smb": { "host": "192.168.10.122", diff --git a/infra/docker/.env.prod.example b/infra/docker/.env.prod.example index 2f6ae89..b30af3b 100644 --- a/infra/docker/.env.prod.example +++ b/infra/docker/.env.prod.example @@ -11,7 +11,6 @@ DB_NAME=gallery_prod PORT=5173 HOST=0.0.0.0 APP_ENV=prod -IMAGE_TAG=latest PUBLIC_URL=https://gallery.mysuperlab.netcraze.pro TRUST_PROXY=true IMAGE_DIR=/app/data/images diff --git a/infra/docker/Dockerfile b/infra/docker/Dockerfile index d88d2cd..03c75c7 100644 --- a/infra/docker/Dockerfile +++ b/infra/docker/Dockerfile @@ -23,10 +23,18 @@ COPY client ./client RUN npm run build --prefix client FROM node:20-alpine AS runner +ARG APP_VERSION=0.0.0 +ARG GIT_SHA= +ARG IMAGE_TAG=latest +ARG BUILD_TIME= ENV NODE_ENV=production ENV PORT=5173 ENV HOST=0.0.0.0 ENV IMAGE_DIR=/app/data/images +ENV APP_VERSION=$APP_VERSION +ENV GIT_SHA=$GIT_SHA +ENV IMAGE_TAG=$IMAGE_TAG +ENV BUILD_TIME=$BUILD_TIME RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs gallery diff --git a/infra/docker/build-push-lan.ps1 b/infra/docker/build-push-lan.ps1 index bd575ae..e591a3f 100644 --- a/infra/docker/build-push-lan.ps1 +++ b/infra/docker/build-push-lan.ps1 @@ -1,15 +1,17 @@ # Build and push the production web image to Gitea over LAN (fast path). param( - [string]$Tag = "latest", + [string]$Tag = '', [switch]$SkipHosts, [switch]$SkipBuild ) $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "..\scripts\lib\Deploy-CliResult.ps1") +. (Join-Path $PSScriptRoot "..\scripts\lib\Build-ReleaseTag.ps1") $Registry = "gitea.mysuperlab.netcraze.pro" $Image = "$Registry/danilka/gallery-web" +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path function Test-DockerRunning { $prevEap = $ErrorActionPreference @@ -27,10 +29,24 @@ if (-not (Test-DockerRunning)) { exit 1 } +$meta = Get-BuildMetadata -RepoRoot $RepoRoot +$releaseTag = if ($Tag -and $Tag.Trim()) { $Tag.Trim() } else { $meta.ReleaseTag } +$meta.ReleaseTag = $releaseTag + +Write-Host "Release tag: $releaseTag" +Write-Host "Build metadata: app_version=$($meta.AppVersion), git_sha=$($meta.GitSha), built_at=$($meta.BuildTimeUtc)" + if (-not $SkipBuild) { Write-Host "" - Write-Host "Building ${Image}:${Tag} ..." - docker build -f infra/docker/Dockerfile -t "${Image}:${Tag}" . + Write-Host "Building ${Image}:$releaseTag and ${Image}:latest ..." + docker build -f infra/docker/Dockerfile ` + --build-arg "APP_VERSION=$($meta.AppVersion)" ` + --build-arg "GIT_SHA=$($meta.GitSha)" ` + --build-arg "IMAGE_TAG=$releaseTag" ` + --build-arg "BUILD_TIME=$($meta.BuildTimeUtc)" ` + -t "${Image}:$releaseTag" ` + -t "${Image}:latest" ` + $RepoRoot if ($LASTEXITCODE -ne 0) { Write-DeployCliResult -Script 'build-push-lan' -Success $false -Summary "Docker build failed with exit code $LASTEXITCODE" exit $LASTEXITCODE @@ -41,11 +57,28 @@ if (-not $SkipBuild) { } Write-Host "" -& "$PSScriptRoot/push-lan.ps1" -Tag $Tag -SkipHosts:$SkipHosts +if ($SkipBuild) { + $pushTags = @('latest') + if ($Tag -and $Tag.Trim()) { + $pushTags = @($Tag.Trim(), 'latest') | Select-Object -Unique + } +} else { + $pushTags = @($releaseTag, 'latest') | Select-Object -Unique +} +& "$PSScriptRoot/push-lan.ps1" -Tags $pushTags -SkipHosts:$SkipHosts if ($LASTEXITCODE -ne 0) { Write-DeployCliResult -Script 'build-push-lan' -Success $false -Summary "Docker push failed with exit code $LASTEXITCODE" exit $LASTEXITCODE } -Write-DeployCliResult -Script 'build-push-lan' -Success $true -Summary 'Docker image built and pushed to Gitea.' -Details @("Image: ${Image}:${Tag}") +$manifestPath = Write-LastDockerRelease -RepoRoot $RepoRoot -Metadata $meta -Image "${Image}:$releaseTag" + +Write-DeployCliResult -Script 'build-push-lan' -Success $true -Summary 'Docker image built and pushed to Gitea.' -Details @( + "Release tag: $releaseTag", + "Also pushed as: latest", + "Git commit: $($meta.GitSha)", + "Built at: $($meta.BuildTimeUtc)", + "Manifest: $manifestPath", + "TrueNAS: restart gallery-web (pull_policy: always uses latest)." +) exit 0 diff --git a/infra/docker/compose.prod.yaml b/infra/docker/compose.prod.yaml index 9f91bae..b08b34f 100644 --- a/infra/docker/compose.prod.yaml +++ b/infra/docker/compose.prod.yaml @@ -6,7 +6,7 @@ services: build: context: ../.. dockerfile: infra/docker/Dockerfile - image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:${IMAGE_TAG:-latest} + image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest container_name: gallery-web restart: unless-stopped ports: diff --git a/infra/docker/compose.truenas.yaml b/infra/docker/compose.truenas.yaml index 8647c8e..1ab521f 100644 --- a/infra/docker/compose.truenas.yaml +++ b/infra/docker/compose.truenas.yaml @@ -10,7 +10,7 @@ # See infra/docker/DEPLOY-truenas.md and Documentation/environments.md services: web: - image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:${IMAGE_TAG:-latest} + image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest container_name: gallery-web restart: unless-stopped pull_policy: always @@ -19,7 +19,6 @@ services: environment: NODE_ENV: production APP_ENV: "prod" - IMAGE_TAG: "${IMAGE_TAG:-latest}" PORT: "5173" HOST: "0.0.0.0" DB_HOST: "192.168.10.122" diff --git a/infra/docker/push-lan.ps1 b/infra/docker/push-lan.ps1 index 6cf0c44..3b53864 100644 --- a/infra/docker/push-lan.ps1 +++ b/infra/docker/push-lan.ps1 @@ -1,6 +1,6 @@ # Push Docker image to Gitea over LAN (avoids KeenDNS hairpin slow upload). param( - [string]$Tag = "latest", + [string[]]$Tags = @('latest'), [switch]$SkipHosts ) @@ -55,10 +55,12 @@ if (-not $SkipHosts) { $after = Get-GiteaDnsAddress Write-Host "After override, resolves to: $after" -Write-Host "" -Write-Host "Pushing ${Image}:${Tag} ..." -docker push "${Image}:${Tag}" -if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +foreach ($tag in ($Tags | Select-Object -Unique)) { + Write-Host "" + Write-Host "Pushing ${Image}:$tag ..." + docker push "${Image}:$tag" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} Write-Host "" Write-Host "Done. Remove hosts entry ($HostsMarker) when finished if you no longer need LAN override." -ForegroundColor Cyan diff --git a/infra/scripts/deploy-dev-to-prod.ps1 b/infra/scripts/deploy-dev-to-prod.ps1 index 057de50..6bac93b 100644 --- a/infra/scripts/deploy-dev-to-prod.ps1 +++ b/infra/scripts/deploy-dev-to-prod.ps1 @@ -564,9 +564,7 @@ if (Get-StepEnabled $steps 'syncImages') { if (Get-StepEnabled $steps 'dockerPublish') { $ok = Invoke-Step 'dockerPublish' { - $tag = if ($cfg.release.imageTag) { $cfg.release.imageTag } else { 'latest' } - Write-Host "Docker release tag: $tag" - $code = Invoke-Npm 'prod:docker:publish' @('-Tag', $tag) + $code = Invoke-Npm 'prod:docker:publish' return $code } if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } diff --git a/infra/scripts/lib/Build-ReleaseTag.ps1 b/infra/scripts/lib/Build-ReleaseTag.ps1 new file mode 100644 index 0000000..82541b6 --- /dev/null +++ b/infra/scripts/lib/Build-ReleaseTag.ps1 @@ -0,0 +1,58 @@ +function Get-BuildMetadata { + param([string]$RepoRoot) + + $gitSha = '' + $appVersion = '0.0.0' + try { + Push-Location $RepoRoot + $gitSha = (git rev-parse --short HEAD 2>$null).Trim() + $appVersion = (node -p "require('./package.json').version" 2>$null).Trim() + } finally { + Pop-Location + } + if (-not $gitSha) { $gitSha = 'unknown' } + if (-not $appVersion) { $appVersion = '0.0.0' } + + $buildTime = Get-Date + $buildTimeUtc = $buildTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") + + return @{ + GitSha = $gitSha + AppVersion = $appVersion + BuildTimeUtc = $buildTimeUtc + ReleaseTag = New-ReleaseImageTag -GitSha $gitSha -BuildTime $buildTime + } +} + +function New-ReleaseImageTag { + param( + [string]$GitSha, + [datetime]$BuildTime + ) + + if (-not $BuildTime) { $BuildTime = Get-Date } + if (-not $GitSha) { $GitSha = 'unknown' } + + $stamp = $BuildTime.ToString('yyyyMMdd-HHmmss') + return "$stamp-$GitSha" +} + +function Write-LastDockerRelease { + param( + [string]$RepoRoot, + [hashtable]$Metadata, + [string]$Image + ) + + $manifestPath = Join-Path $RepoRoot 'infra\deploy\last-docker-release.json' + $manifest = @{ + image = $Image + release_tag = $Metadata.ReleaseTag + git_sha = $Metadata.GitSha + app_version = $Metadata.AppVersion + built_at = $Metadata.BuildTimeUtc + pushed_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") + } + $manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 + return $manifestPath +} diff --git a/scripts/dev-web.js b/scripts/dev-web.js index a749a5b..1e4d31d 100644 --- a/scripts/dev-web.js +++ b/scripts/dev-web.js @@ -15,6 +15,7 @@ const env = { PORT: apiPort, APP_ENV: process.env.APP_ENV || 'dev', IMAGE_TAG: process.env.IMAGE_TAG || 'dev-local', + APP_VERSION: process.env.APP_VERSION || require('../package.json').version, }; function run(label, command, args, cwd) { diff --git a/server/index.js b/server/index.js index 667dbae..641f14a 100644 --- a/server/index.js +++ b/server/index.js @@ -12,16 +12,13 @@ const { requireCurator } = require('./middleware/auth'); const { logCuratorAction } = require('./audit-log'); const authRoutes = require('./routes/auth'); const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service'); +const { getVersionInfo } = require('./version-info'); const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher'); const app = express(); const HOST = process.env.HOST || '0.0.0.0'; const PORT = Number(process.env.PORT) || 3001; const PUBLIC_URL = process.env.PUBLIC_URL || ''; -const APP_ENV = process.env.APP_ENV || (process.env.DB_NAME === 'gallery_prod' ? 'prod' : 'dev'); -const IMAGE_TAG = process.env.IMAGE_TAG || 'local'; -const GIT_SHA = process.env.GIT_SHA || ''; -const APP_VERSION = process.env.npm_package_version || '0.0.0'; if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') { app.set('trust proxy', 1); @@ -1044,14 +1041,7 @@ app.get('/api/bounds', async (req, res) => { }); app.get('/api/version', async (_req, res) => { - res.json({ - app_env: APP_ENV, - app_version: APP_VERSION, - image_tag: IMAGE_TAG, - git_sha: GIT_SHA, - db_name: process.env.DB_NAME || '', - public_url: PUBLIC_URL || '', - }); + res.json(getVersionInfo()); }); const CLIENT_DIST = path.join(__dirname, '..', 'client', 'dist'); diff --git a/server/version-info.js b/server/version-info.js new file mode 100644 index 0000000..9efebb3 --- /dev/null +++ b/server/version-info.js @@ -0,0 +1,47 @@ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const rootDir = path.join(__dirname, '..'); + +function readPackageVersion() { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')); + return pkg.version || '0.0.0'; + } catch { + return '0.0.0'; + } +} + +function readGitSha() { + if (process.env.GIT_SHA) { + return process.env.GIT_SHA; + } + try { + return execSync('git rev-parse --short HEAD', { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +function getVersionInfo() { + const dbName = process.env.DB_NAME || ''; + const appEnv = process.env.APP_ENV || (dbName === 'gallery_prod' ? 'prod' : 'dev'); + const imageTag = process.env.IMAGE_TAG || (appEnv === 'prod' ? 'unknown' : 'dev-local'); + + return { + app_env: appEnv, + app_version: process.env.APP_VERSION || readPackageVersion(), + image_tag: imageTag, + git_sha: readGitSha(), + built_at: process.env.BUILD_TIME || '', + db_name: dbName, + public_url: process.env.PUBLIC_URL || '', + }; +} + +module.exports = { getVersionInfo };