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