From 99a794ed9b3d219110b84014d9f579aac2cdfaea Mon Sep 17 00:00:00 2001 From: KellyMichels Date: Tue, 8 Sep 2026 13:28:45 -0500 Subject: [PATCH] chore(mirror): mirror tagOnDeploy and the zstart gitPull fix, and stop publishing current product names The mirror carries the tagOnDeploy feature, its tests, and the zstart gitPull fix from the private tree. Twelve published references to current product names and internal issue numbers are reworded generically, the denylist learns the current spellings (a dot or hyphen broke the word, an underscore hid the boundary), and planted cases prove the suite now sees them. CHECKSUMS.txt regenerated. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 26 ++++ CHANGELOG.txt | 28 ++++ CHECKSUMS.txt | 6 +- README.md | 4 +- README.txt | 4 +- ZHelpers.ps1 | 199 +++++++++++++++++++++++-- tests/DeployTag.Tests.ps1 | 197 +++++++++++++++++++++++++ tests/Sanitization.Tests.ps1 | 19 +++ tests/StartGitPull.Tests.ps1 | 240 +++++++++++++++++++++++++++++++ tests/VerifyPlan.Tests.ps1 | 10 +- tests/sanitization-patterns.psd1 | 6 + zconfig.example.json | 1 + zdeploy.ps1 | 4 + zstart.ps1 | 24 ++-- 14 files changed, 729 insertions(+), 39 deletions(-) create mode 100644 tests/DeployTag.Tests.ps1 create mode 100644 tests/StartGitPull.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f105d..dee8b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,32 @@ Notable changes to the Evomedia.net Token Savers. ## Unreleased +### Added +- **`zdeploy` can lay the release tag it already knows the number for.** + A versioning scheme that asks every release to carry an annotated tag needs + something to enforce it, and for a project whose build number lives outside + git - in a database, say - nothing did: one project reached thirty-eight + builds with four tags, and the missing ones were unrecoverable because the + number had never existed anywhere else. With `deploy.tagOnDeploy`, the + deployed commit is tagged with its build number and pushed, but only after + the live build has been *verified* - a tag is a claim about what is running. + Opt-in, because a project that already tags releases through a pull request + must not also collect a tag per deploy. It can never fail a deploy: an + existing tag is left alone, a failed push keeps the tag local and prints the + command to finish it, and a missing repo just says so. + +### Fixed +- **`zstart` no longer aborts on a pull that succeeded.** git reports + ordinary fetch progress (`From https://...`) on stderr, and under Windows + PowerShell 5.1 the script's `2>&1` turned that into a terminating error - + so a pull that had *worked* stopped the dev server from starting, before + the script's own "Auto-pull skipped" branch could run. The pull now lives + in `Invoke-StartGitPull`, which never throws, never switches branch, and + never touches a dirty tree: it fast-forwards when it can, reports when it + can't, and `zstart` carries on either way - the opposite failure mode + from `Invoke-DeployGitPull`, on purpose. Fourteen tests drive real git + under `Stop` on 5.1, the host the defect lives on (#130). + ## v1.0.0.0.23 - 2026-08-31 ### Changed diff --git a/CHANGELOG.txt b/CHANGELOG.txt index c67d08e..57b5204 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -10,6 +10,34 @@ Notable changes to the Evomedia.net Token Savers. Unreleased ---------- +Added +----- +- zdeploy can lay the release tag it already knows the number for. + A versioning scheme that asks every release to carry an annotated tag needs + something to enforce it, and for a project whose build number lives outside + git - in a database, say - nothing did: one project reached thirty-eight + builds with four tags, and the missing ones were unrecoverable because the + number had never existed anywhere else. With deploy.tagOnDeploy, the + deployed commit is tagged with its build number and pushed, but only after + the live build has been verified - a tag is a claim about what is running. + Opt-in, because a project that already tags releases through a pull request + must not also collect a tag per deploy. It can never fail a deploy: an + existing tag is left alone, a failed push keeps the tag local and prints the + command to finish it, and a missing repo just says so. + +Fixed +----- +- zstart no longer aborts on a pull that succeeded. git reports + ordinary fetch progress (From https://...) on stderr, and under Windows + PowerShell 5.1 the script's 2>&1 turned that into a terminating error - + so a pull that had worked stopped the dev server from starting, before + the script's own "Auto-pull skipped" branch could run. The pull now lives + in Invoke-StartGitPull, which never throws, never switches branch, and + never touches a dirty tree: it fast-forwards when it can, reports when it + can't, and zstart carries on either way - the opposite failure mode + from Invoke-DeployGitPull, on purpose. Fourteen tests drive real git + under Stop on 5.1, the host the defect lives on (#130). + v1.0.0.0.23 - 2026-08-31 ------------------------ diff --git a/CHECKSUMS.txt b/CHECKSUMS.txt index 052be19..ee763a4 100644 --- a/CHECKSUMS.txt +++ b/CHECKSUMS.txt @@ -8,14 +8,14 @@ cefe3201301d1bee8aa1cdd35818123836eda4d6975eb0f1d46352f45a484d35 zbackup_ec2.cm 7dae9f0f531cdd1a1ba46f9c52c41d26d2cf1168841c247d71dbea09362127b0 zchecksums.cmd 3d4bb2eee3aea0d8416b027a621e3e82b54c257a2cd694d4af65d00f828e1a3f zchecksums.ps1 6a5dd5f658338a44eae25379e51714550503cd0b85bb494a10af20138a0e948c zdeploy.cmd -258ce6a81bd75016fe430bd48630c110fda08a0f45256caa071245dab0f8c36d zdeploy.ps1 +e074a9340e13b8a01e4153062116c43a5e705f8f4b456fe4bca2fbb3f8a9ca3d zdeploy.ps1 c7bca9f557a816c2db5805fe4d0e90a4784976d7e2a177e3fa14b45465546702 zec2.cmd bb5b4d306ef2d4a4a02b77546052259ad6895b339741d8da112a5a2252e95ba1 zec2.ps1 73486d8df9a0afb5a87efb580036e71878c496c11f8074d12ccaad5ddc572393 zec2_rotatekeys.cmd 8cca7a1977bec02f569b78b8de1470307ffe62c4590443270704227671b4667a zec2_rotatekeys.ps1 f10e9d1ec91098e7e736bfbd57ad74d80a56b1121e34bdc381c0bfd2dd603007 zec2online.cmd 259b11058582b505a9cf8e0fd3aaff475ce4c131d40c8dfa00205090ad0a68c4 zec2online.ps1 -57cbfc44c5c179d64c6fc4ea3f2688ea79dfc5881ac98bf87c4b3ad54b6f2ce8 ZHelpers.ps1 +7451f625c730278907aa3eaac8ef18aa33b4aeec2cccb3c536dfeceb8232d396 ZHelpers.ps1 4269c1850b05f2c13cfb0545e8e6f28429b6b7da7e986f766f1ba2f7bf2c9535 zkill.cmd 2c592ace4f565e9cedc9e296c42132115ea9ba8963f1718a6b85aafb76981702 zkill.ps1 e8f3c7207d68cf291a1e83a55dd8d49d71a68980830e5d4f6d89b07012cac314 ZKiller.ps1 @@ -31,7 +31,7 @@ f9efb2777e4463c131bf88c267082c307b063047bb3c2817808f5b7bf2aaf328 zrestartd.cmd d42ec4c112aa0aa136d2dfb8d4a3ca2533b14be3370ce1d025a39ae64049fcc0 zsetup.ps1 7ad635ee4a43de1c2a7cd4d4e0086b94cdda6e8483ace046718032282a668d9a zsetup_mail.ps1 22b022af72e8fc621aed59d2ed351f5bb1e12f336bb626d986c0e7dd578dff68 zstart.cmd -f610ea73419b1c8dccd5a6e6d86392a990dbfb152f390439d5e91fea2e4e0693 zstart.ps1 +41a19a77c1c05ea345411024c584a2dadf5be11fcf8dcad8272d1cb7c46d2f00 zstart.ps1 45b6dd4ce6f1a3459a1c7bd71e07ac7883b5ac57b403bd7b754c517bfcde3e78 zstart_docker.cmd 2e50d20697f84860d55ec22dc050cdd7fb494b40eacf022f321cefbaa2806c3e zstart_docker.ps1 ceced5153e51daddd2316e538d23d1cfad01ff19fbbca619119acd1628a37eb5 zstartd.cmd diff --git a/README.md b/README.md index 8fdb50e..cc12b5c 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Set the `ZCONFIG` environment variable to point at a config somewhere else — h "deploy": { "zipName": "MyAppDeploy.zip", // optional: defaults to Deploy.zip "gitPull": true, // optional: git pull --ff-only before zipping + "tagOnDeploy": false, // optional: tag the deployed commit with its build number "exclude": ["docs", "big-data-folder"] // optional: extra top-level dirs/files to skip } } @@ -101,9 +102,10 @@ Set the `ZCONFIG` environment variable to point at a config somewhere else — h Optional blocks do real work: - **`install`** — how `zsetup` installs a python project's dependencies into its `.venv`: the pip args, e.g. `"-e ."`, `"-e backend"` (deps in a subfolder), or `"-r requirements.txt"`. Omit it and `zsetup` auto-detects a root `pyproject.toml`/`setup.py` (`-e .`) or `requirements.txt` (`-r requirements.txt`). `zstart` never installs — run `zsetup ` once, then `zstart `. -- **`start`** — pre-start steps for `zstart`: `gitPull: true` runs `git pull --ff-only` in the project root first (never starts a stale checkout), and `env` sets environment variables for the dev-server process (feature flags, reload switches). +- **`start`** — pre-start steps for `zstart`: `gitPull: true` fast-forwards the checkout from its upstream first, and when it can't — no upstream, diverged history, a remote that wants credentials — says so and starts the server anyway. A pull is never allowed to stand between you and a running dev server (that is `deploy.gitPull`'s job, below, where refusing is correct). `env` sets environment variables for the dev-server process (feature flags, reload switches). - **`db`** — deploys wait for `pg_isready` and `zbackup_ec2` pulls a `pg_dump`, both against the compose service named `db`. Omit it and those steps are skipped cleanly. - **`deploy.gitPull`** — `git pull --ff-only` in the project root before zipping, so a merged PR actually ships. Since `zdeploy` zips your working tree, a checkout left behind `origin` would otherwise deploy stale code *and still bump the build number* — a silent no-op that looks like success. A failed pull (dirty tree that conflicts, diverged history) aborts the deploy rather than shipping uncertain code. +- **`deploy.tagOnDeploy`** — after a deploy whose live build number has been *verified*, lay an annotated git tag for that number on the deployed commit and push it. Off by default, and deliberately opt-in: a project that already tags its releases through a pull request must not also collect a tag per deploy, because a release ledger and a deploy counter are two different numbers. Nothing here can fail a deploy — an existing tag is left alone, a failed push leaves the tag local and tells you the command to finish it, and a working tree with uncommitted changes gets a warning that the tagged commit is not everything that shipped. - **`migrations": "prisma"`** — runs `npx prisma migrate deploy` inside the app container after each deploy. - **Compose service-name conventions** — handlers assume the app service is named `app` (python) or `web` (nextjs) and the database service `db`. Override the app service with `remote.appService`. - **Edge extras** — an `edge`-kind project can set `proxyContainer` (the nginx container's name, used for reloads and stale-container cleanup) and `certsSource` (a host path with TLS certs, mounted read-only when validating `nginx.conf`). diff --git a/README.txt b/README.txt index 5fb7b26..f6d0740 100644 --- a/README.txt +++ b/README.txt @@ -88,6 +88,7 @@ Config reference "deploy": { "zipName": "MyAppDeploy.zip", // optional: defaults to Deploy.zip "gitPull": true, // optional: git pull --ff-only before zipping + "tagOnDeploy": false, // optional: tag the deployed commit with its build number "exclude": ["docs", "big-data-folder"] // optional: extra top-level dirs/files to skip } } @@ -97,9 +98,10 @@ Config reference Optional blocks do real work: - install — how zsetup installs a python project's dependencies into its .venv: the pip args, e.g. "-e .", "-e backend" (deps in a subfolder), or "-r requirements.txt". Omit it and zsetup auto-detects a root pyproject.toml/setup.py (-e .) or requirements.txt (-r requirements.txt). zstart never installs — run zsetup once, then zstart . -- start — pre-start steps for zstart: gitPull: true runs git pull --ff-only in the project root first (never starts a stale checkout), and env sets environment variables for the dev-server process (feature flags, reload switches). +- start — pre-start steps for zstart: gitPull: true fast-forwards the checkout from its upstream first, and when it can't — no upstream, diverged history, a remote that wants credentials — says so and starts the server anyway. A pull is never allowed to stand between you and a running dev server (that is deploy.gitPull's job, below, where refusing is correct). env sets environment variables for the dev-server process (feature flags, reload switches). - db — deploys wait for pg_isready and zbackup_ec2 pulls a pg_dump, both against the compose service named db. Omit it and those steps are skipped cleanly. - deploy.gitPull — git pull --ff-only in the project root before zipping, so a merged PR actually ships. Since zdeploy zips your working tree, a checkout left behind origin would otherwise deploy stale code and still bump the build number — a silent no-op that looks like success. A failed pull (dirty tree that conflicts, diverged history) aborts the deploy rather than shipping uncertain code. +- deploy.tagOnDeploy — after a deploy whose live build number has been verified, lay an annotated git tag for that number on the deployed commit and push it. Off by default, and deliberately opt-in: a project that already tags its releases through a pull request must not also collect a tag per deploy, because a release ledger and a deploy counter are two different numbers. Nothing here can fail a deploy — an existing tag is left alone, a failed push leaves the tag local and tells you the command to finish it, and a working tree with uncommitted changes gets a warning that the tagged commit is not everything that shipped. - migrations": "prisma" — runs npx prisma migrate deploy inside the app container after each deploy. - Compose service-name conventions — handlers assume the app service is named app (python) or web (nextjs) and the database service db. Override the app service with remote.appService. - Edge extras — an edge-kind project can set proxyContainer (the nginx container's name, used for reloads and stale-container cleanup) and certsSource (a host path with TLS certs, mounted read-only when validating nginx.conf). diff --git a/ZHelpers.ps1 b/ZHelpers.ps1 index 98760a7..94b2f3a 100644 --- a/ZHelpers.ps1 +++ b/ZHelpers.ps1 @@ -279,10 +279,9 @@ function Invoke-DeployGitPull { # unreviewed SOURCE shipping; a stamp the script just wrote is not # that. It is still committed separately, one bump per release, # per the versioning rule - this only stops it being a gate. - $deployWritten = @('build-version.json', 'CHANGELOG.md') $dirty = $dirty | Where-Object { $path = ($_ -replace '^..\s+', '') -replace '^.*/', '' - $deployWritten -notcontains $path + (Get-DeployStampFiles) -notcontains $path } if ($dirty) { $files = ($dirty | ForEach-Object { " $_" }) -join "`n" @@ -315,6 +314,179 @@ function Invoke-DeployGitPull { } } +# ── Files the deploy itself writes ────────────────────────────────────────── +# +# zdeploy stamps the bumped build version, and appends a changelog line, into +# the working tree after a successful run. Neither is unreviewed SOURCE, so +# neither should make a tree look dirty to the guards below - leaving them in +# scope made each deploy block the next one, over a change the operator never +# made. +# +# One list, because two copies drift: the first copy knew about CHANGELOG.md +# and not build_changelog.md, which is the name a project's own changelog +# tool may write. +function Get-DeployStampFiles { + return @('build-version.json', 'CHANGELOG.md', 'build_changelog.md') +} + +# Tracked modifications, minus those stamps. Runs in the CURRENT directory; +# both callers are inside a Push-Location on the project root. +function Get-TrackedChangesExcludingStamps { + $ErrorActionPreference = 'Continue' + $dirty = git status --porcelain --untracked-files=no + $stamps = Get-DeployStampFiles + return @($dirty | Where-Object { + $path = ($_ -replace '^..\s+', '') -replace '^.*/', '' + $stamps -notcontains $path + }) +} + + +# ── The release tag zdeploy owes the versioning scheme ────────────────────── +# +# The scheme says every release lays an annotated tag alongside its version +# stamp. For a project whose build number lives OUTSIDE git nothing enforced +# that, and the tag history quietly stopped tracking reality: one project kept +# its number in a database and reached thirty-eight builds with four tags. +# Those builds were not recoverable - the number only ever existed in the +# database - so this stops the bleeding rather than back-filling. +# +# Opt-in per project, via deploy.tagOnDeploy. A project that already tags its +# releases through a pull request must NOT also get a tag per deploy: a +# git-side release ledger and a container's own deploy counter are two +# different numbers on purpose. +# +# Runs only after the live build has been VERIFIED, and never throws. A deploy +# that reached production must not be reported as failed because a tag could +# not be written afterwards. +function New-DeployTag { + param( + [Parameter(Mandatory)]$Proj, + [Parameter(Mandatory)][string]$Version, + [string]$Note = "Build deployed" + ) + if (-not ($Proj.deploy -and $Proj.deploy.tagOnDeploy)) { return } + $root = $Proj.localRoot + if (-not (Test-Path -LiteralPath (Join-Path $root ".git"))) { + Write-Host " tagOnDeploy is set but '$root' is not a git repo - no tag written." -ForegroundColor Yellow + return + } + + Write-Host "`n--- [6] Tagging the deployed commit as $Version ---" -ForegroundColor Cyan + Push-Location -LiteralPath $root + try { + # The same PS 5.1 trap the rest of this file documents: success is + # judged by $LASTEXITCODE, and git writes ordinary progress to stderr. + $ErrorActionPreference = 'Continue' + + $sha = git rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or -not $sha) { + Write-Host " Could not read HEAD - no tag written. The deploy stands." -ForegroundColor Yellow + return + } + $sha = "$sha".Trim() + $short = $sha.Substring(0, 7) + + # The zip is taken from the WORKING TREE, so uncommitted changes ship + # while the commit this tag names does not contain them. Say so rather + # than let a tag quietly claim to describe the build. + $dirty = Get-TrackedChangesExcludingStamps + if ($dirty.Count -gt 0) { + Write-Host " Working tree has $($dirty.Count) uncommitted change(s): $Version names $short, which is NOT everything that shipped." -ForegroundColor Yellow + } + + git rev-parse -q --verify "refs/tags/$Version" *> $null + if ($LASTEXITCODE -eq 0) { + Write-Host " Tag $Version already exists - left alone." -ForegroundColor DarkYellow + return + } + + git tag -a $Version -m "$Version - $Note" + if ($LASTEXITCODE -ne 0) { + Write-Host " git tag failed - the deploy stands, the tag does not." -ForegroundColor Yellow + return + } + git push origin $Version + if ($LASTEXITCODE -ne 0) { + Write-Host " $Version written locally but the push failed. Push it when you can: git push origin $Version" -ForegroundColor Yellow + return + } + Write-Host " Tagged $Version at $short and pushed." -ForegroundColor Green + } + catch { + Write-Host " Tagging failed ($($_.Exception.Message)) - the deploy stands." -ForegroundColor Yellow + } + finally { + Pop-Location + } +} + + +# ── zstart's pull: fast-forward if you can, start regardless ───────────────── +# +# The OPPOSITE failure mode from Invoke-DeployGitPull above, on purpose. A +# deploy that cannot prove it has the default branch must refuse - shipping +# stale code and still bumping the build is a silent lie. A dev server has no +# such stake: the person is already at a checkout they chose, often a feature +# branch, and a pull that cannot complete is information, not a reason to +# leave them without a server. So this never throws, never switches branch, +# and never touches a dirty tree - it reports, and zstart carries on. +# +# It shares the deploy helper's one hard-won mechanic. Under zstart's +# $ErrorActionPreference = 'Stop', the previous inline `git pull --ff-only +# 2>&1` wrapped every stderr line in a terminating ErrorRecord - and git +# prints ordinary fetch progress ("From https://...") on stderr. A pull that +# SUCCEEDED aborted the start before its own skip-and-continue branch could +# run (#130). Success is judged by $LASTEXITCODE, nothing is redirected, and +# the preference drops to Continue for this function's scope only. +# +# Fetch, then merge --ff-only against the branch's upstream, rather than +# `git pull` - see the FETCH_HEAD race note on Invoke-DeployGitPull. +function Invoke-StartGitPull { + param([Parameter(Mandatory)][string]$Root) + $result = [pscustomobject]@{ Ok = $false; Skipped = $false; Message = '' } + if (-not (Test-Path -LiteralPath (Join-Path $Root '.git'))) { + $result.Skipped = $true + $result.Message = "'$Root' is not a git repo" + return $result + } + Push-Location -LiteralPath $Root + # GIT_TERMINAL_PROMPT=0 so a repo that needs credentials fails fast + # instead of blocking the server start on a "Username for ..." prompt. + $prevPrompt = $env:GIT_TERMINAL_PROMPT + $env:GIT_TERMINAL_PROMPT = '0' + try { + $ErrorActionPreference = 'Continue' + git fetch origin --prune + if ($LASTEXITCODE -ne 0) { + $result.Skipped = $true + $result.Message = 'git fetch failed - credentials, or the remote' + return $result + } + $branch = git rev-parse --abbrev-ref HEAD + $upstream = git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>$null + if ($LASTEXITCODE -ne 0 -or -not $upstream) { + $result.Skipped = $true + $result.Message = "'$branch' has no upstream to fast-forward from" + return $result + } + $before = git rev-parse HEAD + git merge --ff-only $upstream + if ($LASTEXITCODE -ne 0) { + $result.Message = "cannot fast-forward '$branch' onto $upstream (diverged, or local changes in the way) - left as is" + return $result + } + $result.Ok = $true + $result.Message = if ((git rev-parse HEAD) -eq $before) { 'Already up to date.' } + else { "Now at: $(git log -1 --oneline)" } + return $result + } + finally { + $env:GIT_TERMINAL_PROMPT = $prevPrompt + Pop-Location + } +} + # ── Build-version helpers ──────────────────────────────────────────────────── function Read-JsonBuildVersion { @@ -334,18 +506,18 @@ function Get-ServerSideVersionCommand { the public edge: zdeploy's post-deploy check, zec2, zec2online, and bash/zhelpers.sh. That works only for as long as the endpoint is public, and it should not be: www's /build-version.json has been - blocked at the edge since the 2026-05-29 security pass, and evo.ehs + blocked at the edge since an earlier security pass, and one app answering /api/build-version to anyone is the inconsistency this closes. Going through the edge is also how a check reads the WRONG product. The proxy answers from whichever vhost matches the Host header, so a service with no public route gets somebody else's version back -- - evo-ai's deploy check compared evo.ehs's build against its own and - reported a failure on a deploy that had worked (evo.scripts#101). + one project's deploy check compared another project's build against + its own and reported a failure on a deploy that had worked. A project reached only on the shared docker network cannot be curled - from the host: evoehs_app publishes no port. It IS reachable by name + from the host: an app container that publishes no port. It IS reachable by name from another container on that network, which also exercises the real HTTP path -- so this proves the app is serving, not merely that its database knows a version. @@ -355,7 +527,7 @@ function Get-ServerSideVersionCommand { "verify": { "path": "/api/build-version", "viaProxy": "evo_edge_proxy", - "upstream": "evoehs_app:80" + "upstream": "myapp_app:80" } Returns $null when either key is missing, so every project without @@ -376,8 +548,8 @@ function Get-LabelFromVersionJson { The build label out of a version endpoint's JSON text, or $null. .DESCRIPTION - Two field names in the fleet: evo.ehs answers `build_version` on - /api/build-version, evo-ai answers `version` on /health. Both mean + Two field names in the fleet: one app answers `build_version` on + /api/build-version, another answers `version` on /health. Both mean "the build that is live", so both are accepted rather than making an app rename its own field. #> @@ -852,8 +1024,7 @@ function Get-VerifyTimeout { BOOT say so, instead of every deploy of it warning on a success. An app that runs database migrations in its entrypoint exceeds a 30s window on every deploy that ships one - and a warning that fires on - routine success trains people to ignore the one that matters - (evo.scripts#101). + routine success trains people to ignore the one that matters. #> param($Proj, [int]$DefaultSec) if ($Proj.verify -and $Proj.verify.timeoutSeconds) { @@ -870,7 +1041,7 @@ function Get-VerifyAttempts { without ssh. .DESCRIPTION - Three channels exist, and their order is the whole point (#101): + Three channels exist, and their order is the whole point: exec - docker-network read via verify.viaProxy/upstream. Cannot answer from the wrong product, works for apps with no @@ -880,8 +1051,8 @@ function Get-VerifyAttempts { edge - http:// with a Host header. The proxy answers from whichever vhost MATCHES that header, so without one this channel can only reach the default vhost - which is a - different product (that is how evo-ai's check once read - evo.ehs's build number). It is therefore included ONLY + different product (that is how one project's check once read + another's build number). It is therefore included ONLY when the project has a host to route by, and never otherwise: no answer at all beats somebody else's answer. diff --git a/tests/DeployTag.Tests.ps1 b/tests/DeployTag.Tests.ps1 new file mode 100644 index 0000000..bc9ac66 --- /dev/null +++ b/tests/DeployTag.Tests.ps1 @@ -0,0 +1,197 @@ +# zdeploy lays the release tag it already knows the number for. +# +# Invoke-Pester .\tests +# +# A versioning scheme that asks every release to lay an annotated tag needs +# something to enforce it. For a project whose build number lives OUTSIDE git - +# in a database, say - nothing did, and one project reached thirty-eight builds +# with four tags. Those builds were not recoverable: the number only ever +# existed in the database. +# +# These tests drive REAL git against a real bare remote in a temp directory, +# the way StartGitPull.Tests.ps1 does. A stand-in that faked git would prove +# nothing about the two properties that matter most here: that the tag is +# annotated and pushed, and that NOTHING this function does can fail a deploy +# which already reached production. + +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) "ZHelpers.ps1") + + $script:tmpRoots = New-Object System.Collections.ArrayList + + function New-TempDir { + param([string]$Tag) + $dir = Join-Path ([IO.Path]::GetTempPath()) ("zdeploy-tag-$Tag-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [void]$script:tmpRoots.Add($dir) + return $dir + } + + function Invoke-Git { + # Test plumbing only. The thing under test does its own git handling + # and must not go through here. + param([string]$In, [string[]]$GitArgs) + Push-Location -LiteralPath $In + try { + $ErrorActionPreference = 'Continue' + $quoted = $GitArgs | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } } + $out = & cmd /c ("git " + ($quoted -join ' ') + " 2>&1") + if ($LASTEXITCODE -ne 0) { throw "test plumbing: git $($GitArgs -join ' ') failed in $In`n$out" } + return $out + } + finally { Pop-Location } + } + + function New-RepoWithRemote { + param([string]$Tag) + $remote = New-TempDir "$Tag-remote" + Invoke-Git -In $remote -GitArgs @('init', '--bare', '-q') | Out-Null + $work = New-TempDir "$Tag-work" + Invoke-Git -In $work -GitArgs @('init', '-q', '-b', 'main') | Out-Null + Invoke-Git -In $work -GitArgs @('config', 'user.email', 'test@example.com') | Out-Null + Invoke-Git -In $work -GitArgs @('config', 'user.name', 'Test') | Out-Null + Set-Content -LiteralPath (Join-Path $work 'app.txt') -Value 'v1' -Encoding utf8 + Invoke-Git -In $work -GitArgs @('add', '-A') | Out-Null + Invoke-Git -In $work -GitArgs @('commit', '-q', '-m', 'first') | Out-Null + Invoke-Git -In $work -GitArgs @('remote', 'add', 'origin', $remote) | Out-Null + Invoke-Git -In $work -GitArgs @('push', '-q', '-u', 'origin', 'main') | Out-Null + return @{ Work = $work; Remote = $remote } + } + + function New-Proj { + param([string]$Root, $TagOnDeploy = $true) + $deploy = if ($null -eq $TagOnDeploy) { + [pscustomobject]@{ zipName = 'X.zip' } + } else { + [pscustomobject]@{ zipName = 'X.zip'; tagOnDeploy = $TagOnDeploy } + } + return [pscustomobject]@{ localRoot = $Root; deploy = $deploy } + } + + function Get-Tags { + param([string]$In) + $out = Invoke-Git -In $In -GitArgs @('tag', '--list') + return @($out | Where-Object { $_ -and $_.ToString().Trim() } | + ForEach-Object { $_.ToString().Trim() }) + } +} + +AfterAll { + foreach ($d in $script:tmpRoots) { + Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Describe 'New-DeployTag' { + + It 'tags the deployed commit and pushes it' { + $r = New-RepoWithRemote 'happy' + New-DeployTag -Proj (New-Proj $r.Work) -Version 'v0.0.1.0.39' -Note 'Build deployed' + + Get-Tags -In $r.Work | Should -Contain 'v0.0.1.0.39' + # On the remote too: a tag only in the local checkout is not a record. + $remoteTags = Invoke-Git -In $r.Work -GitArgs @('ls-remote', '--tags', 'origin') + ($remoteTags -join "`n") | Should -Match 'refs/tags/v0\.0\.1\.0\.39' + } + + It 'writes an ANNOTATED tag carrying the version and the note' { + $r = New-RepoWithRemote 'annotated' + New-DeployTag -Proj (New-Proj $r.Work) -Version 'v1.2.3.4.5' -Note 'Ask AI sources' + + # cat-file says "tag" for an annotated object and "commit" for a + # lightweight one. The scheme asks for annotated. + $type = Invoke-Git -In $r.Work -GitArgs @('cat-file', '-t', 'v1.2.3.4.5') + ($type -join '').Trim() | Should -Be 'tag' + $msg = Invoke-Git -In $r.Work -GitArgs @('tag', '-l', 'v1.2.3.4.5', '--format=%(contents)') + ($msg -join ' ') | Should -Match 'v1\.2\.3\.4\.5' + ($msg -join ' ') | Should -Match 'Ask AI sources' + } + + It 'points the tag at the commit that was deployed' { + $r = New-RepoWithRemote 'sha' + $head = (Invoke-Git -In $r.Work -GitArgs @('rev-parse', 'HEAD') -join '').Trim() + New-DeployTag -Proj (New-Proj $r.Work) -Version 'v9.9.9.9.9' + $tagged = (Invoke-Git -In $r.Work -GitArgs @('rev-list', '-n', '1', 'v9.9.9.9.9') -join '').Trim() + $tagged | Should -Be $head + } + + It 'does nothing at all unless the project opts in' { + $r = New-RepoWithRemote 'optout' + New-DeployTag -Proj (New-Proj $r.Work -TagOnDeploy $false) -Version 'v0.0.0.0.1' + Get-Tags -In $r.Work | Should -BeNullOrEmpty + + # And when the key is absent entirely, which is every existing project. + New-DeployTag -Proj (New-Proj $r.Work -TagOnDeploy $null) -Version 'v0.0.0.0.2' + Get-Tags -In $r.Work | Should -BeNullOrEmpty + } + + It 'leaves an existing tag alone rather than failing a redeploy' { + $r = New-RepoWithRemote 'exists' + New-DeployTag -Proj (New-Proj $r.Work) -Version 'v0.0.1.0.40' -Note 'first' + { New-DeployTag -Proj (New-Proj $r.Work) -Version 'v0.0.1.0.40' -Note 'second' } | + Should -Not -Throw + $msg = Invoke-Git -In $r.Work -GitArgs @('tag', '-l', 'v0.0.1.0.40', '--format=%(contents)') + ($msg -join ' ') | Should -Match 'first' + ($msg -join ' ') | Should -Not -Match 'second' + } + + It 'never throws when the directory is not a git repo' { + $plain = New-TempDir 'notrepo' + { New-DeployTag -Proj (New-Proj $plain) -Version 'v0.0.0.0.3' } | Should -Not -Throw + } + + It 'never throws when the push fails, and still writes the tag locally' { + # A deploy that reached production must not be reported as failed + # because a tag could not leave the machine. + $r = New-RepoWithRemote 'badremote' + Invoke-Git -In $r.Work -GitArgs @('remote', 'set-url', 'origin', + (Join-Path ([IO.Path]::GetTempPath()) 'no-such-remote-zz')) | Out-Null + { New-DeployTag -Proj (New-Proj $r.Work) -Version 'v0.0.1.0.41' } | Should -Not -Throw + Get-Tags -In $r.Work | Should -Contain 'v0.0.1.0.41' + } + + It 'survives the deploy-wide ErrorActionPreference of Stop' { + # The trap this whole file documents: under 'Stop', PS 5.1 turns any + # native stderr line into a terminating error. git push writes its + # ordinary progress to stderr, so a tag that works interactively can + # still kill a deploy. + $r = New-RepoWithRemote 'stoppref' + $ErrorActionPreference = 'Stop' + { New-DeployTag -Proj (New-Proj $r.Work) -Version 'v0.0.1.0.42' } | Should -Not -Throw + Get-Tags -In $r.Work | Should -Contain 'v0.0.1.0.42' + } +} + +Describe 'Get-DeployStampFiles' { + + It 'covers every changelog name the fleet actually writes' { + # The old inline copy knew CHANGELOG.md and not build_changelog.md, + # a name a project's own changelog tool may write - so that stamp + # counted as unreviewed source in the branch guard. + $stamps = Get-DeployStampFiles + $stamps | Should -Contain 'build-version.json' + $stamps | Should -Contain 'CHANGELOG.md' + $stamps | Should -Contain 'build_changelog.md' + } + + It 'does not count a stamp the deploy just wrote as an uncommitted change' { + $r = New-RepoWithRemote 'stamps' + Set-Content -LiteralPath (Join-Path $r.Work 'build_changelog.md') -Value 'x' -Encoding utf8 + Invoke-Git -In $r.Work -GitArgs @('add', '-A') | Out-Null + Invoke-Git -In $r.Work -GitArgs @('commit', '-q', '-m', 'add changelog') | Out-Null + Set-Content -LiteralPath (Join-Path $r.Work 'build_changelog.md') -Value 'stamped by the deploy' -Encoding utf8 + + Push-Location -LiteralPath $r.Work + try { $changes = Get-TrackedChangesExcludingStamps } finally { Pop-Location } + $changes.Count | Should -Be 0 + } + + It 'still reports real source changes' { + $r = New-RepoWithRemote 'realchange' + Set-Content -LiteralPath (Join-Path $r.Work 'app.txt') -Value 'edited' -Encoding utf8 + Push-Location -LiteralPath $r.Work + try { $changes = Get-TrackedChangesExcludingStamps } finally { Pop-Location } + $changes.Count | Should -Be 1 + ($changes -join ' ') | Should -Match 'app\.txt' + } +} diff --git a/tests/Sanitization.Tests.ps1 b/tests/Sanitization.Tests.ps1 index a29552e..750a050 100644 --- a/tests/Sanitization.Tests.ps1 +++ b/tests/Sanitization.Tests.ps1 @@ -89,6 +89,25 @@ Describe "public repo carries no private detail" { $hits | Should -BeNullOrEmpty -Because "these look like private detail copied in from the internal toolkit:`n$($hits -join "`n")" } + It "catches the current spelling " -ForEach @( + @{ Rule = 'current product name'; Sample = 'evo.ehs answers build_version' } + @{ Rule = 'current product name'; Sample = 'evo-ai answers version on /health' } + @{ Rule = 'current product name'; Sample = 'upstream evoehs_app:80' } + @{ Rule = 'internal issue reference'; Sample = 'see evo.scripts#101 for the trap' } + ) { + # The rename went past the old pattern: the dot and the hyphen break + # the word and the underscore hides the boundary, so a suite that ran + # green was trusted on a tree that named the fleet. + $pattern = ($script:Denied | Where-Object { $_.Name -eq $Rule }).Pattern + $pattern | Should -Not -BeNullOrEmpty + ($Sample -match $pattern) | Should -BeTrue + } + + It "still allows this repo's own name" { + $pattern = ($script:Denied | Where-Object { $_.Name -eq 'current product name' }).Pattern + ('https://github.com/evomedia-net/evo.zscripts' -match $pattern) | Should -BeFalse + } + It "still detects a planted violation" { # Mutation check. Without this the suite passes just as happily when the # patterns are broken as when the repo is clean - the failure mode that diff --git a/tests/StartGitPull.Tests.ps1 b/tests/StartGitPull.Tests.ps1 new file mode 100644 index 0000000..1f1ed85 --- /dev/null +++ b/tests/StartGitPull.Tests.ps1 @@ -0,0 +1,240 @@ +# zstart's auto-pull must never stand between the user and a running server (#130). +# +# Invoke-Pester .\tests +# +# The report was "I merged it but not sure why it crashed, should have skipped +# it and moved on". It crashed on a pull that SUCCEEDED: +# +# git : From https://github.com/example/some-app +# At ZStart.ps1:206 char:24 +# + $pullOut = git pull --ff-only 2>&1 +# +# Under $ErrorActionPreference = 'Stop', a stderr redirect on a native command +# in Windows PowerShell 5.1 wraps every stderr line in a terminating +# ErrorRecord - and git writes ordinary fetch progress ("From ...") to stderr. +# So the try block died before its own "Auto-pull skipped" branch could run. +# +# These tests drive REAL git under 'Stop' on the same host the defect lives +# on. A stand-in that faked git's output would prove nothing about the stream +# semantics that are the entire bug; one test asserts the fixture really does +# put that "From ..." line on stderr, so the reproduction cannot quietly go +# stale the way an LF changelog fixture once did. +# +# ZHelpers.ps1 is dot-sourced rather than ZStart.ps1, which runs its main flow +# on load. That is also why the logic moved into a helper: it was untestable +# where it sat. + +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) "ZHelpers.ps1") + + $script:tmpRoots = New-Object System.Collections.ArrayList + + function New-TempDir { + param([string]$Tag) + $dir = Join-Path ([IO.Path]::GetTempPath()) ("zstart-pull-$Tag-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + [void]$script:tmpRoots.Add($dir) + return $dir + } + + function Invoke-Git { + # Test plumbing only. Runs git quietly from a directory and fails the + # test loudly if it did not work - the thing under test does its own + # git handling and must not go through here. + param([string]$In, [string[]]$GitArgs) + Push-Location -LiteralPath $In + try { + $ErrorActionPreference = 'Continue' + # Quote anything with whitespace: a Windows temp root usually + # sits under a user profile whose name has a space in it, and + # an unquoted path splits into two arguments on the way through + # cmd. + $quoted = $GitArgs | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } } + $out = & cmd /c ("git " + ($quoted -join ' ') + " 2>&1") + if ($LASTEXITCODE -ne 0) { throw "test plumbing: git $($GitArgs -join ' ') failed in $In`n$out" } + return $out + } finally { Pop-Location } + } + + function New-ClonePair { + # A bare "origin" and a working clone tracking main, one commit in. + # Returns @{ Bare; Clone } and leaves a helper to advance origin. + $bare = New-TempDir 'origin' + Invoke-Git $bare @('init', '--bare', '--initial-branch=main', '--quiet') | Out-Null + $seed = New-TempDir 'seed' + Invoke-Git $seed @('init', '--initial-branch=main', '--quiet') | Out-Null + Invoke-Git $seed @('config', 'user.email', 'test@example.invalid') | Out-Null + Invoke-Git $seed @('config', 'user.name', 'zstart test') | Out-Null + Set-Content -LiteralPath (Join-Path $seed 'a.txt') -Value 'one' + Invoke-Git $seed @('add', '.') | Out-Null + Invoke-Git $seed @('commit', '-q', '-m', 'one') | Out-Null + Invoke-Git $seed @('remote', 'add', 'origin', $bare) | Out-Null + Invoke-Git $seed @('push', '-q', '-u', 'origin', 'main') | Out-Null + + $clone = New-TempDir 'clone' + Invoke-Git (Split-Path -Parent $clone) @('clone', '-q', $bare, $clone) | Out-Null + Invoke-Git $clone @('config', 'user.email', 'test@example.invalid') | Out-Null + Invoke-Git $clone @('config', 'user.name', 'zstart test') | Out-Null + return [pscustomobject]@{ Bare = $bare; Clone = $clone; Seed = $seed } + } + + function Add-OriginCommit { + # Advance origin from the seed checkout, so the clone has something + # to fetch - which is exactly what makes git print "From ..." on + # stderr. + param($Pair, [string]$Name = 'two') + Set-Content -LiteralPath (Join-Path $Pair.Seed "$Name.txt") -Value $Name + Invoke-Git $Pair.Seed @('add', '.') | Out-Null + Invoke-Git $Pair.Seed @('commit', '-q', '-m', $Name) | Out-Null + Invoke-Git $Pair.Seed @('push', '-q', 'origin', 'main') | Out-Null + } + + function Get-Head { + param([string]$Repo) + return (Invoke-Git $Repo @('rev-parse', 'HEAD') | Select-Object -Last 1).ToString().Trim() + } +} + +AfterAll { + foreach ($d in $script:tmpRoots) { + try { Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue } catch { } + } +} + +Describe "Invoke-StartGitPull" { + + Context "the reported case: origin has new commits" { + + BeforeAll { + $script:pair = New-ClonePair + Add-OriginCommit $script:pair + $script:originTip = Get-Head $script:pair.Seed + } + + It "the fixture really puts fetch progress on stderr - the shape of the bug" { + # Checked on a second clone so the one under test is still behind. + $probe = New-TempDir 'probe' + Invoke-Git (Split-Path -Parent $probe) @('clone', '-q', $script:pair.Bare, $probe) | Out-Null + Add-OriginCommit $script:pair 'three' + Push-Location -LiteralPath $probe + try { + $ErrorActionPreference = 'Continue' + # stderr only: stdout is dropped, so anything captured came + # from the stream that ErrorRecords are made from. + $stderr = & cmd /c "git fetch origin 2>&1 1>nul" + } finally { Pop-Location } + ($stderr -join "`n") | Should -Match '(?m)^From ' + } + + It "does not abort under ErrorActionPreference = 'Stop'" { + $ErrorActionPreference = 'Stop' + { $script:r = Invoke-StartGitPull -Root $script:pair.Clone } | Should -Not -Throw + } + + It "reports success" { + $script:r.Ok | Should -BeTrue + $script:r.Skipped | Should -BeFalse + $script:r.Message | Should -Match '^Now at: ' + } + + It "actually fast-forwarded the checkout" { + Get-Head $script:pair.Clone | Should -Be (Get-Head $script:pair.Seed) + } + + It "leaves the caller's ErrorActionPreference as it found it" { + $ErrorActionPreference = 'Stop' + Invoke-StartGitPull -Root $script:pair.Clone | Out-Null + $ErrorActionPreference | Should -Be 'Stop' + } + } + + Context "nothing to fetch" { + + It "is Ok and says so, not a skip" { + $pair = New-ClonePair + $ErrorActionPreference = 'Stop' + $r = Invoke-StartGitPull -Root $pair.Clone + $r.Ok | Should -BeTrue + $r.Message | Should -Be 'Already up to date.' + } + } + + Context "a pull that cannot complete" { + + It "diverged history is reported, not thrown, and the checkout is left alone" { + $pair = New-ClonePair + # Local commit on the clone AND a different one on origin. + Set-Content -LiteralPath (Join-Path $pair.Clone 'local.txt') -Value 'mine' + Invoke-Git $pair.Clone @('add', '.') | Out-Null + Invoke-Git $pair.Clone @('commit', '-q', '-m', 'local') | Out-Null + $localTip = Get-Head $pair.Clone + Add-OriginCommit $pair 'theirs' + + $ErrorActionPreference = 'Stop' + { $script:div = Invoke-StartGitPull -Root $pair.Clone } | Should -Not -Throw + $script:div.Ok | Should -BeFalse + $script:div.Skipped | Should -BeFalse + $script:div.Message | Should -Match 'cannot fast-forward' + Get-Head $pair.Clone | Should -Be $localTip + } + + It "a branch with no upstream is skipped - and never switched away from" { + $pair = New-ClonePair + Invoke-Git $pair.Clone @('checkout', '-q', '-b', 'feature/thing') | Out-Null + $ErrorActionPreference = 'Stop' + { $script:noup = Invoke-StartGitPull -Root $pair.Clone } | Should -Not -Throw + $script:noup.Skipped | Should -BeTrue + $script:noup.Message | Should -Match "no upstream" + (Invoke-Git $pair.Clone @('rev-parse', '--abbrev-ref', 'HEAD') | Select-Object -Last 1).ToString().Trim() | + Should -Be 'feature/thing' + } + + It "a directory that is not a repo is skipped, not thrown" { + $dir = New-TempDir 'norepo' + $ErrorActionPreference = 'Stop' + { $script:norepo = Invoke-StartGitPull -Root $dir } | Should -Not -Throw + $script:norepo.Skipped | Should -BeTrue + $script:norepo.Message | Should -Match 'not a git repo' + } + } + + Context "housekeeping" { + + It "restores GIT_TERMINAL_PROMPT to whatever it was" { + $pair = New-ClonePair + $prev = $env:GIT_TERMINAL_PROMPT + try { + $env:GIT_TERMINAL_PROMPT = 'sentinel' + Invoke-StartGitPull -Root $pair.Clone | Out-Null + $env:GIT_TERMINAL_PROMPT | Should -Be 'sentinel' + } finally { $env:GIT_TERMINAL_PROMPT = $prev } + } + + It "returns to the directory it was called from" { + $pair = New-ClonePair + $here = (Get-Location).Path + Invoke-StartGitPull -Root $pair.Clone | Out-Null + (Get-Location).Path | Should -Be $here + } + } +} + +Describe "ZStart.ps1 uses the helper" { + + BeforeAll { + $script:zstart = Get-Content -Raw (Join-Path (Split-Path -Parent $PSScriptRoot) "ZStart.ps1") + } + + It "no longer carries its own redirected pull - the line that crashed" { + $script:zstart | Should -Not -Match 'git pull --ff-only 2>&1' + } + + It "calls Invoke-StartGitPull" { + $script:zstart | Should -Match 'Invoke-StartGitPull -Root' + } + + It "still tells the user when it skipped, and how to stop it trying" { + $script:zstart | Should -Match 'Auto-pull skipped' + $script:zstart | Should -Match 'start\.gitPull=false' + } +} diff --git a/tests/VerifyPlan.Tests.ps1 b/tests/VerifyPlan.Tests.ps1 index 3d8bce3..2bb8e46 100644 --- a/tests/VerifyPlan.Tests.ps1 +++ b/tests/VerifyPlan.Tests.ps1 @@ -1,4 +1,4 @@ -# Deploy-verification planning (#101). +# Deploy-verification planning. # # Invoke-Pester .\tests # @@ -7,8 +7,8 @@ # function (Get-VerifyAttempts) and are pinned here, where they can be tested # without an EC2 box: a project with no domain must never produce an edge # attempt, because an edge request with no Host header can only reach the -# default vhost - which is a different product. That exact gap read evo.ehs's -# build number during evo-ai deploys twice on 2026-08-31 alone. +# default vhost - which is a different product. That exact gap read one +# product's build number during another's deploys, twice in one day. # # ZHelpers.ps1 is dot-sourced rather than zdeploy.ps1: zdeploy executes its # main flow on load, helpers only define functions. @@ -35,8 +35,8 @@ Describe "Get-VerifyAttempts" { ($attempts | ForEach-Object Kind) | Should -Be @('exec', 'port', 'edge') } - It "never asks the edge for a project with no domain (the #101 trap)" { - # The shape that hit #101: viaProxy + port, no domain. The old code + It "never asks the edge for a project with no domain (the wrong-vhost trap)" { + # The shape that hit it: viaProxy + port, no domain. The old code # fell back to the bare IP here and read another product's counter. $proj = New-Proj -Verify @{ viaProxy = "evo_edge_proxy"; upstream = "deploy-app-1:8000"; port = 8005; path = "/health" } $attempts = Get-VerifyAttempts -Proj $proj -ExecCmd "docker exec ..." diff --git a/tests/sanitization-patterns.psd1 b/tests/sanitization-patterns.psd1 index 0462b98..50a191f 100644 --- a/tests/sanitization-patterns.psd1 +++ b/tests/sanitization-patterns.psd1 @@ -24,6 +24,12 @@ @{ Denied = @( @{ Name = 'private project name'; Pattern = '\b(EvoCivilCode|EvoPlatform|DocketMail|SmartPlant\w*|ProvenSheet|evoehs|evoproven|evoaicc|evolocate|evoplatform)\b' } + # The current spellings, which the line above never saw: a dot or a + # hyphen breaks the word and an underscore hides the boundary, so + # evo.ehs, evo-ai and evoehs_app all passed (evo.scripts#138 in the + # private tree). Internal issue references travel with them. + @{ Name = 'current product name'; Pattern = '(?i)\bevo[.-](ehs|ai|edge|locate|proven|platform|civilcode|scripts)\b|\bevoehs' } + @{ Name = 'internal issue reference'; Pattern = 'evo\.scripts#\d+' } @{ Name = 'private product domain'; Pattern = '\b(smartplantehs\.com|provensheet\.com|evoehs\.com|civilcode\.evomedia\.net|dashboard\.evomedia\.net|webmail\.evomedia\.net|mail-admin\.evomedia\.net|docketmail\.evomedia\.net|cardiff\.evomedia\.net|platform\.evomedia\.net|ai\.evomedia\.net|git\.evomedia\.net|analytics\.evomedia\.net)\b' } @{ Name = 'private-only script'; Pattern = '\b(register_civilcode|register_docketmail|sp_seed_demo_prod|zpublish_stats|zcoverage|zmerge|zpull|zresume|swag_set_owner|provision_demo|apply_platform_config_fixes)\b' } @{ Name = 'local drive path'; Pattern = '[A-Za-z]:\\\\?evomedia\.net' } diff --git a/zconfig.example.json b/zconfig.example.json index 042ad91..a655e7f 100644 --- a/zconfig.example.json +++ b/zconfig.example.json @@ -54,6 +54,7 @@ "deploy": { "zipName": "PyAppDeploy.zip", "gitPull": true, + "tagOnDeploy": false, "exclude": [ "docs" ], diff --git a/zdeploy.ps1 b/zdeploy.ps1 index 8135d5c..fbd174d 100644 --- a/zdeploy.ps1 +++ b/zdeploy.ps1 @@ -557,6 +557,10 @@ function Invoke-PythonDeploy { -FailHint "App restart after the build bump failed." Wait-VerifyApiBuild -Key $Key -Proj $Proj -ExpectedLabel $BuildVersion -TimeoutSec 30 | Out-Null + + # Only now: the tag is a claim about what is RUNNING, so it + # is written after the live build has been proven, never before. + New-DeployTag -Proj $Proj -Version $BuildVersion -Note $ChangeNote } elseif ($Proj.verify -and $Proj.verify.port) { Test-DeployHealth -Key $Key -Proj $Proj -TimeoutSec 60 | Out-Null } elseif ($Proj.domain) { diff --git a/zstart.ps1 b/zstart.ps1 index 5d311ae..3fde94f 100644 --- a/zstart.ps1 +++ b/zstart.ps1 @@ -198,21 +198,15 @@ function Invoke-ProjectStartPrep { Write-Host " env $($item.Name)=$($item.Value)" -ForegroundColor DarkGray } } - if ($Proj.start.gitPull -and (Test-Path (Join-Path $Proj.localRoot ".git"))) { - Push-Location -LiteralPath $Proj.localRoot - # GIT_TERMINAL_PROMPT=0 so a repo that needs credentials fails fast - # instead of blocking the server start on a "Username for ..." prompt. - $prev = $env:GIT_TERMINAL_PROMPT; $env:GIT_TERMINAL_PROMPT = "0" - try { - $pullOut = git pull --ff-only 2>&1 - $last = ($pullOut | Select-Object -Last 1) - Write-Host " git pull: $last" -ForegroundColor DarkGray - if ($LASTEXITCODE -ne 0) { - Write-Host " Auto-pull skipped - starting with the current checkout. (git needs credentials here, or set start.gitPull=false)" -ForegroundColor Yellow - } - } finally { - $env:GIT_TERMINAL_PROMPT = $prev - Pop-Location + if ($Proj.start.gitPull) { + # Never lets a pull stand between the user and a running server: the + # helper reports, it does not throw (#130). The inline version this + # replaced aborted on git's ordinary stderr progress under Stop. + $pull = Invoke-StartGitPull -Root $Proj.localRoot + if ($pull.Ok) { + Write-Host " git pull: $($pull.Message)" -ForegroundColor DarkGray + } else { + Write-Host " Auto-pull skipped - starting with the current checkout. ($($pull.Message); set start.gitPull=false to stop trying)" -ForegroundColor Yellow } } }