Build stamp catch-up for 2 merged PR(s) since v1.0.0.0.26:
c317b13 docs(readme): give the download-and-verify path a PowerShell form (#85)
d4cfa98 feat(site): track the public page this project serves (#84)
Build stamp catch-up for 1 merged PR(s) since v1.0.0.0.25:
f2e6302 fix(zdeploy): build docker stacks that come from a Dockerfile, instead of restarting the old image (#81)
* fix(zdeploy): build docker stacks that come from a Dockerfile
Mirrors the fix in the private scripts repo; the code is identical in both, only
the config differs.
The docker kind ran `docker compose pull` then `docker compose up -d`. That is
right for a stack of published images and wrong for one built from a Dockerfile
in the tree, where there is nothing to pull. `up -d` builds only when the image
is MISSING, so the first deploy works and every one after it uploads the new
code, starts the old image, and reports success.
A project opts into building with deploy.build, which runs
`docker compose build --pull` so the base image is refreshed at the same time.
Stacks that pull are unaffected.
The example config documents the flag on the docker project, next to the
existing note about startApp, because the failure is silent and nobody goes
looking for a setting they do not know exists.
CHECKSUMS.txt regenerated, since two covered scripts changed.
286 tests pass, 1 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(checksums): hash the scripts as git checks them out, not as a tool wrote them
CI failed on the two files this branch touches while the same suite passed here.
The manifest was right about the wrong bytes.
.gitattributes pins *.ps1 to eol=crlf, and its comment says why: it makes these
files byte-identical on every platform, which is what lets CHECKSUMS.txt hold
one hash per file rather than one per OS. The edit that added Get-DockerImageStep
was applied by a script that wrote LF, so the working copy stopped matching the
pin. zchecksums then faithfully recorded the LF hashes, and every checkout that
honours .gitattributes - including CI - disagreed.
Nothing was wrong with the committed content: git normalises on the way in, so
the objects were always correct. Only the local working copy and the manifest
taken from it were off.
Re-materialised both files through git so they carry the endings the attribute
pins, then regenerated the manifest from those.
286 tests pass, 1 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Build stamp catch-up for 6 merged PR(s) since v1.0.0.0.24:
732a448 feat: add zmerge and zpull — fleet-wide PR merging and checkout sync (#79)
573e010 docs(security): security notes, and a twin for every root .md (#78)
3b0194a perf(tests): run each child-process invocation once (#77)
f27f9dc fix(deploy): read the string build stamp, and never compare two unreadable labels (#76)
16c56dc fix(ci): push trigger names master, this repo's default branch, so the workflow runs after a merge (#75)
32e8d74 chore(ci): add a GitHub Actions workflow that lints with PSScriptAnalyzer and runs the Pester suite on Windows (#74)
* feat: add zmerge and zpull
Two commands that were private-only until now. They turned out to be useful
beyond the fleet they were written for, so they are manifested for publication
and removed from the sanitization denylist's private-only list.
zmerge merge every pull request across the org that is genuinely ready -
MERGEABLE/CLEAN and not a draft - re-checking each one immediately
before and after every merge, because merging into a default branch
can conflict a sibling PR in the same repository. Dry run by
default; -Execute or -e merges.
zpull zmerge, then git pull --ff-only in every checkout the merges
affected. Skips a checkout that is dirty or is not on its default
branch rather than guessing at it.
WHY THEY COULD BE PUBLISHED NOW. zmerge carried a hardcoded list of sixteen
repository names, which was both the reason it could not be published and a
bug: the org has thirty active repositories, so it scanned about half and
reported "Nothing open to merge" while a ready pull request sat in one it had
never heard of. It asks GitHub now, and the names went with the list.
Get-FleetRepos throws rather than returning an empty list when gh fails,
because a tool that quietly scans nothing prints the same reassuring line as
one that scanned everything and found nothing.
-e is an alias for -Execute on both, the way -s already works for -Scan.
Also: __pycache__/ is gitignored. scripts/plaintext_twins.py creates it on
every run and it was showing up as untracked work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: CRLF the new scripts, as .gitattributes pins them
zmerge.ps1, zpull.ps1 and zpull.cmd went in with LF endings. .gitattributes
pins *.ps1 and *.cmd to eol=crlf precisely so CHECKSUMS.txt can hold one hash
per file rather than one per platform - so git handed CI a CRLF checkout while
the manifest carried hashes taken from my LF copies, and the three new files
were the only ones that failed.
Local verification passed and CI did not, which is the tell: the manifest was
generated against bytes that only existed on this machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Get-LabelFromBuildJsonObj knew two stamp shapes and not the third - the string
form the versioning scheme specifies and zbump writes. It fell through to the
legacy branch, where productVersion is null ("") and buildNumber is null (0),
so every string stamp became "v.0".
Deploy verification runs BOTH sides through this function: the local stamp and
the one read back from the running container. So it did not fail loudly - it
collapsed both to "v.0", compared them equal, and printed PASS:
local 'v1.0.0.0.0' -> 'v.0' remote 'v9.9.9.9.9' -> 'v.0' equal? True
A check that cannot fail is worse than no check, because it is believed. It
would report PASS against a container serving a build from weeks ago, which is
the exact case it exists to catch.
- The string form is read first: a stamp that states its version means it,
even if it also carries stray numeric fields from a half-migration. A
missing leading v is tolerated so all three shapes stay comparable.
- The legacy branch returns $null when there is nothing to build a label from,
so a caller sees "no label" instead of a label matching every other
unreadable stamp.
- zdeploy refuses to verify an unreadable local label, and never treats a null
remote label as a match - otherwise $null -eq $null restores the same
vacuous pass one level up.
CHECKSUMS.txt regenerated, since both covered files changed.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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>
Regenerated in a clean worktree of master, not in the working checkout: that
checkout carries unrelated uncommitted work, and zchecksums hashes files on
disk, so an -Update run there would have recorded hashes of files that are not
committed. Here the only inputs are what master holds.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Build stamp catch-up for 1 merged PR(s) since v1.0.0.0.22:
135c585 docs(changelog): give every shipped release its own section, and generate the twin (#68)
Build stamp catch-up for 1 merged PR(s) since v1.0.0.0.21:
40fa250 refactor(tests): move the sanitization denylist to a data file both sides can read (#66)
The mirror's deploy pair had drifted ~280 lines behind: it lacked the
transactional .env preserve/restore (an interrupted deploy could destroy
server-side env files), the stderr-flattening step wrapper (a successful
deploy reported failure and skipped its own verification), and the
verification rework (channel re-picked every retry, edge only with a Host
to route by, verify.timeoutSeconds, honest split of "stale build" vs "no
channel answered").
The sync is byte-faithful to the private tree except where the mirror's
own Sanitization suite demands otherwise - and it caught the first copy:
three failures for private project names, a private domain, and a
private-only script name that rode along in comments. Each war story keeps
its lesson and loses its cast, per the convention already in the file
("EvoCivilCode: deploy/" was already published as "(deploy/, infra/,
...)"). That suite going red on an unsanitized copy is exactly what it
exists for.
Also in this change:
- tests/VerifyPlan.Tests.ps1 - the channel-selection rules are pure
functions and Pester pins them (no domain => no edge attempt; the PS 5.1
one-element-unroll trap). First verification tests in the mirror.
- README: the verify block now documents viaProxy/upstream (they shipped
in the docker-network read but were never in the README),
timeoutSeconds, and the channel order with why it re-resolves per retry.
- README.txt: generated plain-text twin, via scripts/readme_txt.py
(vendored from the fleet's reference implementation; the file is
generated, never edited by hand).
- CHANGELOG.md/.txt: entries merged into the existing Unreleased sections.
CHECKSUMS.txt refreshed (42 entries). Pester: 240 passed, 0 failed.
Both synced files parse clean.
Observed, untouched: the Unreleased section carries duplicate "### Fixed"
headings from earlier appends; folding them risks reordering entries whose
prose references their neighbours, so it is left for the next release cut
(zbump #110 rolls Unreleased into the version being cut).
zversion's usage block and its bump help line both said 'one per PR, one per
defect fix'. The build counter advances once per release: the number names
something that shipped, so a release carrying five PRs moves it by one, and
PRs that never shipped on their own were never separate builds.
This is help text rather than behaviour, but it is the wording that gets
followed - it is what stamped a single evo.www release as two builds. The
matching comments in ZHelpers.ps1 and zdeploy.ps1 are corrected with it.
CHECKSUMS.txt regenerated for the three edited scripts, since the manifest
tests fail the moment it drifts. CHANGELOG entry added under Unreleased, with
its .txt twin. The older CHANGELOG entry recording what the rule was when
zversion shipped is deliberately left as written - a changelog describes what
happened, not what is currently true.
Pester: 231 passed, 0 failed.
Build stamp catch-up for 1 merged PR(s) since v1.0.0.0.18:
e738b62 feat(version): read a live build from inside the docker network, not the public proxy (#59)
zdeploy, zec2 and zec2online now prefer
docker exec <viaProxy> curl http://<upstream>/api/build-version
when a project sets verify.viaProxy and verify.upstream.
Two problems it closes. A build stamp is something many sites deliberately
do not serve publicly, and a checker that reads it over the public URL stops
working the moment that endpoint is blocked -- reporting "unknown", which is
indistinguishable from "could not reach it". And the proxy answers from
whichever vhost matches the Host header, so a container with no public route
was getting another site's version back and failing deploys that had worked.
Reading it from a container on the shared network also exercises the real
HTTP path, so it proves the app is serving rather than that its database
knows a version. Purely additive: a project without those two keys behaves
exactly as before.
zec2 gains $PemKey and $SshTarget, which it had no need of until now -- the
read is inside a try/catch, so without them it would throw, be swallowed,
and fall through silently.
CHECKSUMS.txt regenerated (zchecksums -Update), zconfig.example.json
documents both shapes of the verify block, and CHANGELOG.md carries its
plain-text twin.
Pester: 231 passed, 0 failed -- including the sanitization suite.
Build stamp catch-up for 4 merged PR(s) since v1.0.0.0.14:
4230566 feat: two blank lines after every z-script run (#57)
432f210 fix(zdeploy): the pre-zip line states the label verification will require (#56)
fcbad44 feat(zdeploy): add the static deploy kind, and a test that keeps this repo sanitized (#55)
7b32bfa fix(zdeploy): docker kind now ships config subdirectories (#54)
zdeploy ended with two blank lines and nothing else did, so its output
was the only one that did not butt up against the next prompt. Applied
everywhere.
Implemented in Stop-ZTracking rather than per script, because every
tracked script ends by calling it - including the usage and guard paths
that do `Stop-ZTracking; exit 1`. One place therefore covers every exit
of sixteen scripts.
* Write-ZTrailer emits the two lines. Stop-ZTracking calls it on both
paths, including the early return when tracking never started - a
script that printed output still deserves the separation.
* -FinalNote prints one last line AFTER the tracking footer and BEFORE
the blanks. zdeploy's "Last deployed at ..." now goes through it, so
it stays the last thing on screen instead of being followed by the
token count.
* The standalone tools (zchecksums, zversion, zrelease) get a local
three-line copy at each exit rather than dot-sourcing ZHelpers -
they are deliberately dependency-free so they run from an extracted
release zip with nothing beside them.
* Redundant trailing blanks were removed where a script already
printed one before exiting, so the count is exactly two, not three.
Also fixes a related gap found while testing: the error exits inside
Get-ZConfig and Get-ZProject bypassed Stop-ZTracking entirely, so a run
that died on a missing zconfig.json printed no trailer AND no token
footer. Those three exits now route through Stop-ZTracking.
zkill/zrestart are one-line wrappers around scripts that already trail,
so they are untouched - adding it would double the blanks.
Verified by running each script and counting the trailing blank lines in
its captured output: 2 on every success path, every usage path, and the
config-error path. Suite 231/231. CHECKSUMS.txt refreshed.
Mirrors evo.scripts#79. Invoke-ViteDeploy announced "(server-side build will
bump +1)" and then Wait-VerifyStaticBuild asked for the committed stamp
unchanged, so the operator was told to expect one label and watched a check
pass on another.
The behavior was already correct; the message predates the removal of the
prebuild hook that used to self-bump inside the image. Wait-VerifyStaticBuild
documents it: "Expect the COMMITTED stamp, not +1: builds no longer
self-bump ... so 'is the build I just packed live?' means an exact match."
CHECKSUMS.txt regenerated, since zdeploy.ps1 changed.
Two things, both prompted by the same incident.
STATIC KIND
Plain static sites - no build, no container of their own; a shared web
container serves them off disk, so shipping the files IS the deploy.
Directories are staged to a sibling and swapped in with mv rather than
copied in place, because a large media file uploaded in place is served
half-written to anyone who requests it mid-copy. The swap is a rename,
so the switch is atomic.
Skips $JunkDirNames + .github + deploy.skipDirs, matching the docker
kind rather than inventing a third convention.
SANITIZATION TEST
This repo is public and must stay standalone, but the toolkit is
developed in a private checkout and copied here. Twice in three days a
wholesale copy landed carrying real project names, internal hostnames,
host disk figures, and references to scripts that exist only in the
private copy. Both times a human reading the diff caught it - the
control that fails exactly when a diff is 280 lines of good work with
three bad words buried in it.
So it is a test now. Seven rules: private project names, private product
domains, private-only script names, local drive paths, the operator home
path, the real key filename, and any IPv4 outside RFC 5737 documentation
space and the private ranges.
Two anti-vacuity guards, because a denylist that silently matches
nothing is worse than no denylist: one asserts the file scan is
non-empty, and one plants a known violation and requires the pattern to
find it.
The IP rule bounds on [\d.] rather than \d deliberately - this toolkit's
own 5-segment version (v1.0.0.0.14) contains "0.0.0.14", which a plain
digit boundary reads as an address.
Verified against the real unsanitized copy that slipped through: 3 of
the 7 rules fire, naming file and line.
Tests: 231/231 (222 + 9 new). CHECKSUMS.txt refreshed.
Invoke-DockerDeploy uploaded top-level files ONLY. For any stack that
keeps configuration in a directory, that is silently wrong in the worst
way: the compose file arrives, the containers restart, and the config
they read is whatever was already on the box. The deploy reports
success while shipping nothing that matters.
The sharp case is a provisioning directory read at container start -
alert rules, datasources, mounted config - where the restart makes it
look like the change was applied.
Same fix the edge kind already got: subdirectories ship recursively.
Skipped are $JunkDirNames, plus .github (CI config belongs in the repo,
never on a deploy target - it is not in $JunkDirNames because backups
DO want it), plus anything the project lists in the new
deploy.skipDirs.
deploy.skipDirs exists for SERVER-SIDE STATE that shares the tree: a
data/ holding mailboxes or a time-series database must never be
overwritten by whatever the local checkout has - usually nothing, which
is the dangerous case, since scp -r of an absent directory is not the
no-op you want to rely on.
CHECKSUMS.txt refreshed alongside. Tests: 222/222.
Build stamp catch-up for 3 merged PR(s) since v1.0.0.0.11:
6c30f40 feat(zdeploy): sync deploy hardening from the private toolkit (#53)
8959d9f fix(zdeploy): stop passing ssh -n to scp, which rejects it (#52)
d99f8a1 fix(zdeploy): stop mirroring the build stamp into the local checkout (#51)
Nine fixes that had accumulated only in the private copy. Each one is a
production failure that already happened:
* ssh -n on the deploy path. Without it ssh reads its stdin, inherits
the console handle under PowerShell, and can block forever. The
existing timeouts cannot catch it - they bound a connection that is
dying, and this one was never established. Get-Ec2ScpOpts derives
from the same list minus -n, because scp rejects it with a usage
error that reads like an unrelated failure.
* Invoke-DeployGitPull now switches TO the default branch instead of
pulling whatever is checked out. Pulling the current branch breaks
as soon as the remote deletes branches on merge, and worse, could
ship a feature branch to production. Refuses over local changes,
excluding the files the deploy itself stamps.
* nextjs handler resolves composeDir. It ran compose against
remote.path, so a project whose compose lives in a subdirectory
recycled whatever docker-compose.yml sat at the project root -
usually the local dev one shipped in the same archive. Two deploys
in a row exited 0 having shipped nothing, and verification passed
because the untouched old container still answered.
* BUILD BEFORE DOWN. The old order took the site offline for the whole
build and left it offline if the build failed - one deploy served
502 for 19 hours with no container running. The old image now keeps
serving until the new one is ready.
* compose build takes the named service, so a compose file that does
not define it fails loudly instead of succeeding with nothing to do.
* deploy.verifyHost overrides domain for verification only, for when a
domain is retired ahead of its replacement.
* deploy.stampCmd writes the build version from git before zipping,
then reverts the tree so the stamp cannot block the next deploy.
* Optional ztokens pseudo-project and passive usage records. Both
degrade to a printed skip when no sibling ztokens checkout exists.
Sanitized on the way across, per the public-repo rule: the war stories
that make these comments worth reading are kept, the private product
names, domains, internal script names, outage dates and host figures are
not. Also dropped a "See issue #28" pointer that resolves to an
unrelated PR in this repo, and two references to scripts that exist only
in the private toolkit.
CHECKSUMS.txt refreshed alongside, per the manifest rule.
Tests: 222/222 (the 4 failures before the refresh were the checksum
suite correctly flagging both edited files).
Build stamp catch-up for 1 merged PR(s) since v1.0.0.0.10:
a1dd642 chore(changelog): drop the product name from the versioning entry; add the plain-text twin (#50)
With deploy.gitPull set, Invoke-DeployGitPull ran `git pull --ff-only`.
Pull merges whatever .git/FETCH_HEAD marks "for merge", and a
concurrent fetch in the same repo - an editor's background auto-fetch
racing the deploy - can leave duplicate for-merge lines. Pull then dies
with "Cannot fast-forward to multiple branches" even when both lines
name the same commit. Reproduced live on 2026-08-13: the deploy failed
at step 0, and by inspection time the same pull succeeded, with the
duplicated FETCH_HEAD entries as the only evidence.
Now the function fetches explicitly and fast-forwards against the
remote-tracking ref:
git fetch origin
git merge --ff-only origin/$branch
Identical fast-forward-or-refuse semantics, but nothing shared and
mutable in the path. A failed fetch and a failed merge now also throw
distinct messages, so the operator knows which half broke.
CHECKSUMS.txt refreshed alongside, per the manifest rule.
Tested: PS 5.1 parse clean; full Pester suite 222/222 after the
manifest refresh (the 3 pre-refresh failures were the checksum suite
correctly flagging the edited file). The same fix pattern was
exercised against live and scratch repos for the private twin
(evomedia-net/evo.scripts#58): behind -> fast-forwards, diverged ->
refuses and aborts the deploy.
Closes#48
Build stamp catch-up for 8 merged PR(s) since v1.0.0.0.0:
6c03138 fix(zdeploy): deploy edge first for any project list, not just 'all' (#46)
976eb6d chore: point repo URLs at evomedia-net/evo.* (#44)
0e4d9c3 fix(zdeploy): ship edge asset subdirectories, stop deploys hanging on ssh prompts, keep vendored archives (#43)
c8fcfee fix(zdeploy): verify Next.js deploys on the server instead of probing the public IP (stops the bogus security-group warning) (#41)
b84d24b fix(zdeploy): report where a domain-less project deployed to (#40)
4668404 fix(zdeploy): re-land the two blank lines after the timestamp (#38)
a868973 fix(zdeploy): verify against the committed build stamp, and read 5-segment versions (#37)
0ddffc1 feat(zdeploy): print 'Last deployed at' timestamp as the last line of a run (#36)
Closes#45.
'zdeploy evo edge' deployed evo first, then edge. The edge-first sort was
nested inside the 'all' branch, so an explicit list walked in whatever order
was typed.
That ordering is a correctness property, not a convenience of 'all': the proxy
has to route before the apps behind it ship, or there is a window where a new
app is live behind stale routing. 'zdeploy evo edge' reads as 'these two, edge
included' and quietly did the risky order.
Hoisted the sort out of the branch so it applies however the list was produced.
Order within each group is preserved, so an intentional sequence still holds -
and when the order does change, it now says so rather than reordering silently.
Verified across six shapes: evo+edge reorders and reports it; edge+evo is left
alone with no note; edge last in a longer list moves to front keeping the rest
in order; single project and no-edge lists are untouched.
* fix(zdeploy): edge deploy ships asset subdirectories, not just top-level files
A project self-hosting assets in folders (fonts/, vendor/) lost them on
every deploy: docker created empty root-owned mount points and nginx
served 404s from them, so fonts fell back silently and vendored JS
never loaded. Every subdirectory except nginx-logs/ and .git/ now ships
recursively, and the ensure-dir chown is recursive so scp into
docker-created root-owned dirs cannot fail.
Closes#42
* fix(zdeploy): stop deploys hanging on ssh prompts, keep vendored archives, make unzip install idempotent
- Get-Ec2SshOpts: every deploy-path ssh/scp now carries BatchMode=yes
plus connect/keepalive timeouts. Without BatchMode ssh prompts and
waits forever, and because the deploy pipes stderr through the
pipeline the prompt never reaches the screen - the run just stops
under the last step label with no explanation.
- Archive filter exempts files under vendor/: a vendored *.tgz is a
build input, and dropping it fails a Dockerfile COPY at image build.
- unzip install checks command -v first instead of an apt round-trip
on every deploy.
Ported from the private script; three tests cover the vendor/ exemption.
* fix(zdeploy): verify Next.js deploys on the server, not the public IP
The nextjs handler verified by requesting http://<ec2-ip>:<prod-port>/.
A compose stack behind the edge proxy normally publishes to 127.0.0.1
only, so that request can never be answered and every deploy ended with
"is the port open in the security group?" — pointing at a firewall rule
for an app that was already serving fine. No security-group change could
have made that probe succeed, which is what made the warning actively
harmful: it named the one fix guaranteed not to work, and opening the
port would have exposed the app directly, bypassing the proxy and TLS.
The nextjs handler now uses the precedence the python handler already
used: verify.port (curl localhost on the server) -> domain (Host-header
check through the edge proxy) -> an honest "NOT verified" instead of a
misleading warning.
Also follow redirects in the health check. An app whose "/" answers 307
(Next.js -> /login) returns a body of a few bytes that read as "not
ready"; -L fetches the page that actually renders. No effect on projects
that point verify.path at a plain 200 endpoint such as /health.
The example config now documents the verify block on the nextjs project,
and CHECKSUMS.txt is regenerated for the changed script.
* fix(zdeploy): truncate the health-check body in the PASS line
Pointing the nextjs handler at Test-DeployHealth means the body is now
often an HTML page, not a line of JSON, so the PASS line dumped ~10 KB
of markup into the deploy output and buried everything after it.
Match on the full body as before, but print at most 200 characters plus
the byte count. A /health endpoint still prints in full.
Closes#39.
A project without a public domain finished with no indication of where it went
- the 'Site:' line was gated on $Proj.domain in all four handlers, so an
internal service deployed successfully and said nothing about the result.
The information was never missing, only unused: a domain-less project that
deploys almost certainly has a verify block (the documented pattern for stacks
not published through the edge proxy), carrying the exact port and health path
the deploy had just checked. zdeploy verified against that endpoint and then
discarded it.
New Write-DeployLocation falls back through what the project actually declares:
domain -> public URL; else verify.port (+ path) -> server-local URL flagged as
not publicly routed; else ports.prod -> same without a path; else prints
nothing, as before. Replaces the four call sites, keeping the nextjs handler's
column alignment via -Pad.
Exercised against every shape: domain, verify-only, ports.prod-only, nothing at
all, and padded. CHECKSUMS regenerated; suite 219/219.
These were written and pushed, but landed on the PR #36 branch AFTER that PR
had already merged, so they never reached master. Private main has them; public
did not. The two copies of zdeploy.ps1 had quietly diverged on the last line of
output.
CHECKSUMS.txt regenerated. Tail now byte-identical to the private copy.
* feat(zdeploy): print 'Last deployed at' as the final line of a run
Scroll to the bottom of a deploy and you can see how long ago it happened,
without digging through the log or checking the server.
Placed after Stop-ZTracking so it is genuinely the last line, below the
token-tracking footer. Only reached on success - a failed deploy throws out of
the dispatch loop, so this never claims a deploy that did not happen. Prints
once per run rather than per project, so 'zdeploy all' ends with a single
timestamp.
24-hour clock (MM/dd/yyyy HH:mm:ss): the requested format carried no AM/PM
marker, and 24-hour is unambiguous for judging elapsed time at a glance.
CHECKSUMS.txt regenerated, since changing a covered script invalidates it.
* feat(zdeploy): use 12-hour clock with AM/PM in the timestamp
Kelly's preference: 'Last deployed at 08/03/2026 01:24:09 PM' rather than
24-hour. The AM/PM marker keeps it unambiguous.
CHECKSUMS.txt regenerated.
* feat(zchecksums): SHA-256 manifest so a download can be verified before it's run
CHECKSUMS.txt lists a SHA-256 for every top-level .ps1 and .cmd - the files a
user actually executes. zchecksums verifies them; zchecksums -Update
regenerates after an intentional edit.
The manifest is sha256sum format, so 'sha256sum -c CHECKSUMS.txt' works on
Linux/macOS/WSL as well as the PowerShell path on Windows. Hashes are identical
on every platform because .gitattributes pins .ps1/.cmd to CRLF everywhere -
that pin is now load-bearing, so it is commented as such.
Beyond changed and missing files it also reports a script that is on disk but
NOT in the manifest, so something added outside a commit still gets noticed.
Exits non-zero on any of the three.
Honest about its limits, in the header and the README: the manifest lives in
the same repo as the code, so it is an integrity check rather than a signature.
It catches a truncated clone, a forgotten local edit, or an unlisted file - not
a compromised repo.
CHECKSUMS.txt is pinned to LF: sha256sum treats a trailing CR as part of the
filename and would report every entry as missing on Linux.
tests/Checksums.Tests.ps1 keeps it from rotting - a stale manifest is worse
than none, since it either cries wolf until people ignore it or quietly stops
covering a new script. The tests assert the format, LF endings, sort order,
full coverage of on-disk scripts, current hashes, and that zchecksums itself
exits 1 on a tampered file (proved by appending a byte and restoring it).
* feat(zversion, zrelease): toolkit versioning + downloadable release zips
Implements the versioning rule (SmartPlant's 5-segment scheme, now the global
standard; currently only sp and zscripts are on it at v1.x):
v{major}.{rc}.{beta}.{alpha}.{build}
zversion: get / bump / bump-stage / set. A stage bump zeroes every lower
segment including build. 'bump' is one per PR and one per defect fix, not per
file. Any write rewrites three things together, because they are only useful
when they agree: build-version.json (source of truth), a '# Version:' line in
all 42 script headers (a lone copied script still says which release it came
from), and CHECKSUMS.txt (stamping changes every file).
zrelease: packages the current version as releases/zscripts-<version>.zip with
a sibling .sha256, for people who want the toolkit without cloning. One hash
verifies the download; the bundled CHECKSUMS.txt verifies the extracted
contents. Refuses to overwrite an existing version's zip (released = immutable;
bump instead), and refuses to package when zchecksums fails. tests/ excluded
from the zip; releases/ never packages itself.
First release included: releases/zscripts-v1.0.0.0.0.zip (42 scripts + 7
support files) and its .sha256.
.gitattributes: releases/*.sha256 pinned LF (sha256sum treats a trailing CR as
part of the filename), releases/*.zip marked binary.
Verified end-to-end as a downloader would experience it, in WSL: sha256sum -c
on the zip passes, unzip, sha256sum -c CHECKSUMS.txt inside gives 42 OK / 0
FAILED, and the extracted zdeploy.ps1 header and build-version.json both read
v1.0.0.0.0. Double-release guard and -Verify mode exercised. Full Pester suite
219/219 (the checksum tests absorb the new files automatically).