Commit Graph

24 Commits

Author SHA1 Message Date
KellyMichels
0a701fb7f2 chore(sync): bring zdeploy/ZHelpers up to the private tree, sanitized
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).
2026-08-31 14:14:05 -05:00
471789f530
chore(release): v1.0.0.0.20 (#62)
Build stamp catch-up for 1 merged PR(s) since v1.0.0.0.19:
  e4c563d fix(zversion): help text says one bump per release, not one per PR (#61)
2026-08-30 21:06:11 -05:00
e4c563d1d5
fix(zversion): help text says one bump per release, not one per PR (#61)
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.
2026-08-30 20:08:26 -05:00
c32fa8537b
chore(release): v1.0.0.0.19 (#60)
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)
2026-08-30 15:48:25 -05:00
e738b62cdf
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.
2026-08-30 15:44:42 -05:00
58c888aa56
chore(release): v1.0.0.0.18 (#58)
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)
2026-08-25 15:51:58 -05:00
4230566e47
feat: two blank lines after every z-script run (#57)
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.
2026-08-25 15:37:45 -05:00
432f2109e1
fix(zdeploy): the pre-zip line states the label verification will require (#56)
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.
2026-08-21 22:38:26 -05:00
fcbad44e0e
feat(zdeploy): add the static deploy kind, and a test that keeps this repo sanitized (#55)
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.
2026-08-21 21:43:40 -05:00
7b32bfada8
fix(zdeploy): docker kind now ships config subdirectories (#54)
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.
2026-08-21 21:29:53 -05:00
KellyMichels
77d7c2f1f5 chore(release): v1.0.0.0.14
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)
2026-08-20 11:45:19 -05:00
6c30f400ad
feat(zdeploy): sync deploy hardening from the private toolkit (#53)
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).
2026-08-20 11:44:42 -05:00
KellyMichels
91bbaca0e7 chore(release): v1.0.0.0.11
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)
2026-08-14 12:47:12 -05:00
KellyMichels
bc86162170 chore(release): v1.0.0.0.10
Build stamp catch-up for 1 merged PR(s) since v1.0.0.0.9:
  eedb95b fix(zdeploy): sync via explicit fetch + ff-only merge, not git pull (#49)
2026-08-13 19:53:00 -05:00
eedb95bbf1
fix(zdeploy): sync via explicit fetch + ff-only merge, not git pull (#49)
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
2026-08-13 19:52:19 -05:00
KellyMichels
a2fafcc6d0 chore(release): v1.0.0.0.9
Reconcile to the version build-version.json already committed. No increment.
2026-08-13 18:36:09 -05:00
KellyMichels
5701dff60a chore(release): v1.0.0.0.8
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)
2026-08-12 16:29:36 -05:00
6c03138a2e
fix(zdeploy): deploy edge first for any project list, not just 'all' (#46)
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.
2026-08-12 15:45:51 -05:00
kellymichels
0e4d9c3cca
fix(zdeploy): ship edge asset subdirectories, stop deploys hanging on ssh prompts, keep vendored archives (#43)
* 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.
2026-08-06 23:29:33 -05:00
kellymichels
c8fcfee76c
fix(zdeploy): verify Next.js deploys on the server instead of probing the public IP (stops the bogus security-group warning) (#41)
* 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.
2026-08-06 19:01:21 -05:00
kellymichels
b84d24b3a2
fix(zdeploy): report where a domain-less project deployed to (#40)
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.
2026-08-06 17:24:26 -05:00
kellymichels
4668404378
fix(zdeploy): re-land the two blank lines after the timestamp (#38)
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.
2026-08-06 11:28:35 -05:00
kellymichels
0ddffc1020
feat(zdeploy): print 'Last deployed at' timestamp as the last line of a run (#36)
* 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.
2026-08-03 13:26:46 -05:00
kellymichels
91b638ac31
feat: checksums, toolkit versioning (v{major}.{rc}.{beta}.{alpha}.{build}), and downloadable release zips (#35)
* 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).
2026-07-28 13:47:25 -05:00