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 <noreply@anthropic.com>
This commit is contained in:
KellyMichels 2026-09-08 13:28:45 -05:00
parent f1b1fd6264
commit 99a794ed9b
14 changed files with 729 additions and 39 deletions

View File

@ -10,6 +10,32 @@ Notable changes to the Evomedia.net Token Savers.
## Unreleased ## 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 ## v1.0.0.0.23 - 2026-08-31
### Changed ### Changed

View File

@ -10,6 +10,34 @@ Notable changes to the Evomedia.net Token Savers.
Unreleased 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 v1.0.0.0.23 - 2026-08-31
------------------------ ------------------------

View File

@ -8,14 +8,14 @@ cefe3201301d1bee8aa1cdd35818123836eda4d6975eb0f1d46352f45a484d35 zbackup_ec2.cm
7dae9f0f531cdd1a1ba46f9c52c41d26d2cf1168841c247d71dbea09362127b0 zchecksums.cmd 7dae9f0f531cdd1a1ba46f9c52c41d26d2cf1168841c247d71dbea09362127b0 zchecksums.cmd
3d4bb2eee3aea0d8416b027a621e3e82b54c257a2cd694d4af65d00f828e1a3f zchecksums.ps1 3d4bb2eee3aea0d8416b027a621e3e82b54c257a2cd694d4af65d00f828e1a3f zchecksums.ps1
6a5dd5f658338a44eae25379e51714550503cd0b85bb494a10af20138a0e948c zdeploy.cmd 6a5dd5f658338a44eae25379e51714550503cd0b85bb494a10af20138a0e948c zdeploy.cmd
258ce6a81bd75016fe430bd48630c110fda08a0f45256caa071245dab0f8c36d zdeploy.ps1 e074a9340e13b8a01e4153062116c43a5e705f8f4b456fe4bca2fbb3f8a9ca3d zdeploy.ps1
c7bca9f557a816c2db5805fe4d0e90a4784976d7e2a177e3fa14b45465546702 zec2.cmd c7bca9f557a816c2db5805fe4d0e90a4784976d7e2a177e3fa14b45465546702 zec2.cmd
bb5b4d306ef2d4a4a02b77546052259ad6895b339741d8da112a5a2252e95ba1 zec2.ps1 bb5b4d306ef2d4a4a02b77546052259ad6895b339741d8da112a5a2252e95ba1 zec2.ps1
73486d8df9a0afb5a87efb580036e71878c496c11f8074d12ccaad5ddc572393 zec2_rotatekeys.cmd 73486d8df9a0afb5a87efb580036e71878c496c11f8074d12ccaad5ddc572393 zec2_rotatekeys.cmd
8cca7a1977bec02f569b78b8de1470307ffe62c4590443270704227671b4667a zec2_rotatekeys.ps1 8cca7a1977bec02f569b78b8de1470307ffe62c4590443270704227671b4667a zec2_rotatekeys.ps1
f10e9d1ec91098e7e736bfbd57ad74d80a56b1121e34bdc381c0bfd2dd603007 zec2online.cmd f10e9d1ec91098e7e736bfbd57ad74d80a56b1121e34bdc381c0bfd2dd603007 zec2online.cmd
259b11058582b505a9cf8e0fd3aaff475ce4c131d40c8dfa00205090ad0a68c4 zec2online.ps1 259b11058582b505a9cf8e0fd3aaff475ce4c131d40c8dfa00205090ad0a68c4 zec2online.ps1
57cbfc44c5c179d64c6fc4ea3f2688ea79dfc5881ac98bf87c4b3ad54b6f2ce8 ZHelpers.ps1 7451f625c730278907aa3eaac8ef18aa33b4aeec2cccb3c536dfeceb8232d396 ZHelpers.ps1
4269c1850b05f2c13cfb0545e8e6f28429b6b7da7e986f766f1ba2f7bf2c9535 zkill.cmd 4269c1850b05f2c13cfb0545e8e6f28429b6b7da7e986f766f1ba2f7bf2c9535 zkill.cmd
2c592ace4f565e9cedc9e296c42132115ea9ba8963f1718a6b85aafb76981702 zkill.ps1 2c592ace4f565e9cedc9e296c42132115ea9ba8963f1718a6b85aafb76981702 zkill.ps1
e8f3c7207d68cf291a1e83a55dd8d49d71a68980830e5d4f6d89b07012cac314 ZKiller.ps1 e8f3c7207d68cf291a1e83a55dd8d49d71a68980830e5d4f6d89b07012cac314 ZKiller.ps1
@ -31,7 +31,7 @@ f9efb2777e4463c131bf88c267082c307b063047bb3c2817808f5b7bf2aaf328 zrestartd.cmd
d42ec4c112aa0aa136d2dfb8d4a3ca2533b14be3370ce1d025a39ae64049fcc0 zsetup.ps1 d42ec4c112aa0aa136d2dfb8d4a3ca2533b14be3370ce1d025a39ae64049fcc0 zsetup.ps1
7ad635ee4a43de1c2a7cd4d4e0086b94cdda6e8483ace046718032282a668d9a zsetup_mail.ps1 7ad635ee4a43de1c2a7cd4d4e0086b94cdda6e8483ace046718032282a668d9a zsetup_mail.ps1
22b022af72e8fc621aed59d2ed351f5bb1e12f336bb626d986c0e7dd578dff68 zstart.cmd 22b022af72e8fc621aed59d2ed351f5bb1e12f336bb626d986c0e7dd578dff68 zstart.cmd
f610ea73419b1c8dccd5a6e6d86392a990dbfb152f390439d5e91fea2e4e0693 zstart.ps1 41a19a77c1c05ea345411024c584a2dadf5be11fcf8dcad8272d1cb7c46d2f00 zstart.ps1
45b6dd4ce6f1a3459a1c7bd71e07ac7883b5ac57b403bd7b754c517bfcde3e78 zstart_docker.cmd 45b6dd4ce6f1a3459a1c7bd71e07ac7883b5ac57b403bd7b754c517bfcde3e78 zstart_docker.cmd
2e50d20697f84860d55ec22dc050cdd7fb494b40eacf022f321cefbaa2806c3e zstart_docker.ps1 2e50d20697f84860d55ec22dc050cdd7fb494b40eacf022f321cefbaa2806c3e zstart_docker.ps1
ceced5153e51daddd2316e538d23d1cfad01ff19fbbca619119acd1628a37eb5 zstartd.cmd ceced5153e51daddd2316e538d23d1cfad01ff19fbbca619119acd1628a37eb5 zstartd.cmd

View File

@ -91,6 +91,7 @@ Set the `ZCONFIG` environment variable to point at a config somewhere else — h
"deploy": { "deploy": {
"zipName": "MyAppDeploy.zip", // optional: defaults to <key>Deploy.zip "zipName": "MyAppDeploy.zip", // optional: defaults to <key>Deploy.zip
"gitPull": true, // optional: git pull --ff-only before zipping "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 "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: 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 <key>` once, then `zstart <key>`. - **`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 <key>` once, then `zstart <key>`.
- **`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. - **`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.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. - **`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`. - **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`). - **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`).

View File

@ -88,6 +88,7 @@ Config reference
"deploy": { "deploy": {
"zipName": "MyAppDeploy.zip", // optional: defaults to <key>Deploy.zip "zipName": "MyAppDeploy.zip", // optional: defaults to <key>Deploy.zip
"gitPull": true, // optional: git pull --ff-only before zipping "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 "exclude": ["docs", "big-data-folder"] // optional: extra top-level dirs/files to skip
} }
} }
@ -97,9 +98,10 @@ Config reference
Optional blocks do real work: 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 <key> once, then zstart <key>. - 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 <key> once, then zstart <key>.
- 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. - 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.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. - 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. - 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). - 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).

View File

@ -279,10 +279,9 @@ function Invoke-DeployGitPull {
# unreviewed SOURCE shipping; a stamp the script just wrote is not # unreviewed SOURCE shipping; a stamp the script just wrote is not
# that. It is still committed separately, one bump per release, # that. It is still committed separately, one bump per release,
# per the versioning rule - this only stops it being a gate. # per the versioning rule - this only stops it being a gate.
$deployWritten = @('build-version.json', 'CHANGELOG.md')
$dirty = $dirty | Where-Object { $dirty = $dirty | Where-Object {
$path = ($_ -replace '^..\s+', '') -replace '^.*/', '' $path = ($_ -replace '^..\s+', '') -replace '^.*/', ''
$deployWritten -notcontains $path (Get-DeployStampFiles) -notcontains $path
} }
if ($dirty) { if ($dirty) {
$files = ($dirty | ForEach-Object { " $_" }) -join "`n" $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 ──────────────────────────────────────────────────── # ── Build-version helpers ────────────────────────────────────────────────────
function Read-JsonBuildVersion { function Read-JsonBuildVersion {
@ -334,18 +506,18 @@ function Get-ServerSideVersionCommand {
the public edge: zdeploy's post-deploy check, zec2, zec2online, and the public edge: zdeploy's post-deploy check, zec2, zec2online, and
bash/zhelpers.sh. That works only for as long as the endpoint is 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 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 answering /api/build-version to anyone is the inconsistency this
closes. closes.
Going through the edge is also how a check reads the WRONG product. 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 The proxy answers from whichever vhost matches the Host header, so a
service with no public route gets somebody else's version back -- 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 one project's deploy check compared another project's build against
reported a failure on a deploy that had worked (evo.scripts#101). its own and reported a failure on a deploy that had worked.
A project reached only on the shared docker network cannot be curled 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 from another container on that network, which also exercises the real
HTTP path -- so this proves the app is serving, not merely that its HTTP path -- so this proves the app is serving, not merely that its
database knows a version. database knows a version.
@ -355,7 +527,7 @@ function Get-ServerSideVersionCommand {
"verify": { "verify": {
"path": "/api/build-version", "path": "/api/build-version",
"viaProxy": "evo_edge_proxy", "viaProxy": "evo_edge_proxy",
"upstream": "evoehs_app:80" "upstream": "myapp_app:80"
} }
Returns $null when either key is missing, so every project without 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. The build label out of a version endpoint's JSON text, or $null.
.DESCRIPTION .DESCRIPTION
Two field names in the fleet: evo.ehs answers `build_version` on Two field names in the fleet: one app answers `build_version` on
/api/build-version, evo-ai answers `version` on /health. Both mean /api/build-version, another answers `version` on /health. Both mean
"the build that is live", so both are accepted rather than making an "the build that is live", so both are accepted rather than making an
app rename its own field. 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 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 app that runs database migrations in its entrypoint exceeds a 30s
window on every deploy that ships one - and a warning that fires on window on every deploy that ships one - and a warning that fires on
routine success trains people to ignore the one that matters routine success trains people to ignore the one that matters.
(evo.scripts#101).
#> #>
param($Proj, [int]$DefaultSec) param($Proj, [int]$DefaultSec)
if ($Proj.verify -and $Proj.verify.timeoutSeconds) { if ($Proj.verify -and $Proj.verify.timeoutSeconds) {
@ -870,7 +1041,7 @@ function Get-VerifyAttempts {
without ssh. without ssh.
.DESCRIPTION .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 exec - docker-network read via verify.viaProxy/upstream. Cannot
answer from the wrong product, works for apps with no answer from the wrong product, works for apps with no
@ -880,8 +1051,8 @@ function Get-VerifyAttempts {
edge - http://<ip> with a Host header. The proxy answers from edge - http://<ip> with a Host header. The proxy answers from
whichever vhost MATCHES that header, so without one this whichever vhost MATCHES that header, so without one this
channel can only reach the default vhost - which is a channel can only reach the default vhost - which is a
different product (that is how evo-ai's check once read different product (that is how one project's check once read
evo.ehs's build number). It is therefore included ONLY another's build number). It is therefore included ONLY
when the project has a host to route by, and never when the project has a host to route by, and never
otherwise: no answer at all beats somebody else's answer. otherwise: no answer at all beats somebody else's answer.

197
tests/DeployTag.Tests.ps1 Normal file
View File

@ -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'
}
}

View File

@ -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")" $hits | Should -BeNullOrEmpty -Because "these look like private detail copied in from the internal toolkit:`n$($hits -join "`n")"
} }
It "catches the current spelling <Sample>" -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" { It "still detects a planted violation" {
# Mutation check. Without this the suite passes just as happily when the # 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 # patterns are broken as when the repo is clean - the failure mode that

View File

@ -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'
}
}

View File

@ -1,4 +1,4 @@
# Deploy-verification planning (#101). # Deploy-verification planning.
# #
# Invoke-Pester .\tests # Invoke-Pester .\tests
# #
@ -7,8 +7,8 @@
# function (Get-VerifyAttempts) and are pinned here, where they can be tested # 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 # 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 # 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 # default vhost - which is a different product. That exact gap read one
# build number during evo-ai deploys twice on 2026-08-31 alone. # product's build number during another's deploys, twice in one day.
# #
# ZHelpers.ps1 is dot-sourced rather than zdeploy.ps1: zdeploy executes its # ZHelpers.ps1 is dot-sourced rather than zdeploy.ps1: zdeploy executes its
# main flow on load, helpers only define functions. # main flow on load, helpers only define functions.
@ -35,8 +35,8 @@ Describe "Get-VerifyAttempts" {
($attempts | ForEach-Object Kind) | Should -Be @('exec', 'port', 'edge') ($attempts | ForEach-Object Kind) | Should -Be @('exec', 'port', 'edge')
} }
It "never asks the edge for a project with no domain (the #101 trap)" { It "never asks the edge for a project with no domain (the wrong-vhost trap)" {
# The shape that hit #101: viaProxy + port, no domain. The old code # 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. # 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" } $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 ..." $attempts = Get-VerifyAttempts -Proj $proj -ExecCmd "docker exec ..."

View File

@ -24,6 +24,12 @@
@{ @{
Denied = @( Denied = @(
@{ Name = 'private project name'; Pattern = '\b(EvoCivilCode|EvoPlatform|DocketMail|SmartPlant\w*|ProvenSheet|evoehs|evoproven|evoaicc|evolocate|evoplatform)\b' } @{ 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 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 = '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' } @{ Name = 'local drive path'; Pattern = '[A-Za-z]:\\\\?evomedia\.net' }

View File

@ -54,6 +54,7 @@
"deploy": { "deploy": {
"zipName": "PyAppDeploy.zip", "zipName": "PyAppDeploy.zip",
"gitPull": true, "gitPull": true,
"tagOnDeploy": false,
"exclude": [ "exclude": [
"docs" "docs"
], ],

View File

@ -557,6 +557,10 @@ function Invoke-PythonDeploy {
-FailHint "App restart after the build bump failed." -FailHint "App restart after the build bump failed."
Wait-VerifyApiBuild -Key $Key -Proj $Proj -ExpectedLabel $BuildVersion -TimeoutSec 30 | Out-Null 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) { } elseif ($Proj.verify -and $Proj.verify.port) {
Test-DeployHealth -Key $Key -Proj $Proj -TimeoutSec 60 | Out-Null Test-DeployHealth -Key $Key -Proj $Proj -TimeoutSec 60 | Out-Null
} elseif ($Proj.domain) { } elseif ($Proj.domain) {

View File

@ -198,21 +198,15 @@ function Invoke-ProjectStartPrep {
Write-Host " env $($item.Name)=$($item.Value)" -ForegroundColor DarkGray Write-Host " env $($item.Name)=$($item.Value)" -ForegroundColor DarkGray
} }
} }
if ($Proj.start.gitPull -and (Test-Path (Join-Path $Proj.localRoot ".git"))) { if ($Proj.start.gitPull) {
Push-Location -LiteralPath $Proj.localRoot # Never lets a pull stand between the user and a running server: the
# GIT_TERMINAL_PROMPT=0 so a repo that needs credentials fails fast # helper reports, it does not throw (#130). The inline version this
# instead of blocking the server start on a "Username for ..." prompt. # replaced aborted on git's ordinary stderr progress under Stop.
$prev = $env:GIT_TERMINAL_PROMPT; $env:GIT_TERMINAL_PROMPT = "0" $pull = Invoke-StartGitPull -Root $Proj.localRoot
try { if ($pull.Ok) {
$pullOut = git pull --ff-only 2>&1 Write-Host " git pull: $($pull.Message)" -ForegroundColor DarkGray
$last = ($pullOut | Select-Object -Last 1) } else {
Write-Host " git pull: $last" -ForegroundColor DarkGray Write-Host " Auto-pull skipped - starting with the current checkout. ($($pull.Message); set start.gitPull=false to stop trying)" -ForegroundColor Yellow
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
} }
} }
} }